feat: initial Gridline implementation (#1)
* chore: configure Tailwind, Vitest, Rust deps, dialog plugin (Task 1)
* feat: shared types and validation/filter utilities (Task 2)
* feat: Rust models, SQLite store, and migrations (Task 3)
* feat: Tauri commands for connections and folders (Task 4)
* feat: tag, settings, import/export Tauri commands (Task 5)
* feat: frontend command wrappers and Zustand stores (Task 6)
* feat: useSearch debounce and useFilteredConnections hooks (Task 7)
* feat: App view router with load-on-mount (Task 8)
* feat: UI primitives Button, Input, Badge, Card (Task 9)
* feat: SearchBar, ActionRow, ImportExportMenu (Task 10)
* feat: ConnectionCard, ConnectionGrid, TagBadge (Task 11)
* fix: resolve TypeScript errors across utils, Input, and test files
* feat: FolderTree, CreateFolderDialog, NewConnectionForm, SettingsPage, HomeScreen (Task 12)
* feat: Tauri file dialog integration for import/export (Task 13)
* feat: error/loading states and full App router integration (Task 14)
* chore: final integration gate — full test + build green (Task 15)
* fix: move folders inline, rearrange ActionRow layout, add borders to buttons
* feat: upgrade to Tailwind v4, apply new color palette
* Landing page restyle, folder explorer, selection/delete, and icon update
- Black-and-white color scheme with blue accent
- Glassmorphic New Folder modal with auto-parent
- Nested folder explorer with breadcrumb navigation
- Folder/connection selection with hover checkboxes
- Select All / Clear Selection / Delete dropdown
- Delete reparents children to parent folder
- Fixed Rust serde camelCase mismatch (models now use snake_case)
- Updated app icons from Apple Icon Composer exports
- Search bar with ⌘K badge
- Button styling: rounded pills, borders, cursor-pointer
- Prevent drag selection across app
* Tag system, folder edit/delete, searchable tag picker, delete confirmation, tag reordering
- Added folder_tags table for folder-tag associations
- Tags now show configured colors on folder and connection cards
- Searchable tag picker with text filtering in New/Edit Folder dialogs
- Edit Folder dialog with name and tag management
- Edit/Delete buttons next to breadcrumb with icons
- Delete confirmation dialog with reparenting notice
- Tag reordering in Settings with up/down buttons, persisted via tag_order setting
- useSortedTags hook for consistent tag ordering
- Cmd+K focuses search input
- Selection dropdown simplified to Select All/Clear/Delete
- Click-outside closes all dropdowns
- cursor-pointer on all interactables
* deps: install motion for animations
* feat(types): add system theme option
* feat(settings): default stored theme to system
* feat(ui): add Toggle primitive
* feat(ui): add Select primitive
* feat(ui): add SettingsRow and SettingsSection primitives
* fix(ui): improve SettingsRow and SettingsSection quality
* feat(ui): add ThemePicker primitive
* feat(ui): add AnimatedModal primitive with motion
* refactor(dialogs): use AnimatedModal for enter/exit animations
* fix(dialogs): ensure exit animations and Escape handling work with AnimatedModal
* feat(settings): redesign page with multi-section layout
* refactor(settings): split tabs, improve accessibility and animation
* fix(tauri): set window background color to dark canvas
* fix(settings): remove fake traffic lights, add header background, transparent title bar
* fix(settings): move Back into sidebar, add Settings heading, dynamic window title
* fix(tauri): show native window title so Home/Settings labels are visible
* fix(ui): disable overscroll on both axes
* test(dialogs): verify CreateFolderDialog shows and saves tags
* chore: ignore .worktrees directory
* refactor: centralize db type icons and labels
* feat: add connection string parser and detection
* test: add edge cases for connection string parser
* fix: preserve absolute paths in SQLite connection strings
* types: add new connection form fields
* feat: add folder path label helper
* test: cover missing folder in path label helper
* feat: add stub connection string parse and test commands
* feat: add stub test connection command
* feat: add new connection form primitives
* feat: add connection form shell
* feat: add shared form type and simple connection form
* fix: keep SimpleConnectionForm fully controlled
* style: format and tighten types for simple connection form
* feat: add detailed connection form
* fix: export DetailedConnectionFormProps and assert port update
* feat: add new connection screen container
* fix: clean up useEffect dependencies in new connection screen
* fix: address quality feedback on new connection screen
* feat: wire new connection screen into app and remove old form
* feat: open new connection screen from pasted db url in search
* test: add home search URL shortcut integration test
* refactor: replace FolderSelect and EnvironmentSelect with shared SelectDropdown
Add a reusable SelectDropdown component that keeps the select-styled
trigger while using the ActionRow-style popover menu. Update both
FolderSelect and EnvironmentSelect to use it.
* refactor: remove db type icon and label from connection form shell
Drop DbTypeHeader and the db_type prop from ConnectionFormShell so the
form header no longer displays the database icon/type. Update
NewConnectionScreen and its test accordingly.
* feat: home screen redesign and connection form updates
- Redesign HomeScreen layout and settings page
- Update connection card, grid, and simple form
- Replace selects with SelectDropdown component
- Remove DbTypeHeader from connection form
- Fix tests to match updated components
* fix: hide folders during search, update placeholder, and set window titles
- ConnectionGrid now hides folders when hasSearch is true so only
matching connections are shown.
- SearchBar placeholder now mentions typing a database URL to create
a new connection.
- App window titles set to Gridline (home), Settings, and New Connection.
* fix: shorter placeholder, dynamic window title, and startup flash
- Shorten SearchBar placeholder to mention DB URL creation concisely.
- Add core:window:allow-set-title permission and set document.title so
the window title updates per view (Gridline, Settings, New Connection).
- Add inline dark background style to index.html to prevent white flash
before CSS loads.
* feat: extend connection models with SSH/SSL/database fields, add db_viewer models (Task 1a)
* feat: extend frontend types with SSH/SSL fields and DB viewer types (Task 1b)
* feat: migrate connections table with SSH/SSL columns (Task 1c)
* chore: add new Rust dependencies (tokio, sqlx, redis, ssh2, indexmap, etc.) for Phase 2
* feat: connection pool manager with LRU eviction (Task 2a)
* feat: schema introspection query builders for PG, MySQL, SQLite (Task 2b)
* feat: test connection command (all 4 DB types) and SSH tunnel manager (Task 2c)
* feat: db viewer Rust commands and AppState refactor (Task 2d)
* feat: frontend command wrappers for DB viewer and updated validation (Task 3a)
* feat: dbViewer store with tabs, changes queue, pagination; uiStore activeConnectionId (Task 3b)
* feat: ConnectionCard navigates to DB Viewer, App routes to DbViewerScreen (Task 3c)
* feat: tooltip UI primitive (Task 4a)
* feat: SSH/SSL form tabs in DetailedConnectionForm (Task 4b)
* feat: DbViewerScreen shell with sidebar navigation (Task 4c)
* feat: toolbar, table tree, and overflow menu (Task 4d)
* fix: align TableInfo interface between frontend types and test files
* feat: tab bar, data grid, and pagination controls (Task 4e)
* feat: changes queue panel with cancel per change (Task 4f)
* feat: wire real IPC connect/disconnect/load in DbViewerScreen (Task 5a)
* feat: error banner, guard dialogs for destructive actions (Task 5b)
* feat: changes queue commit all with per-change IPC and error handling (Task 5c)
* chore: suppress expected dead-code warnings during multi-phase development
* fix: wire ConnectionCard click through to DB Viewer (HomeScreen -> ConnectionGrid -> ConnectionCard -> App route)
* fix: click connection opens DB viewer when nothing selected, toggles selection when items already selected; fix delete connections + generic confirm message
* fix: align ConnectionTestResult field name (ok vs success) and fix testConnection invoke param name (input vs config) to match Rust backend
* feat: implement DB Viewer Tauri commands (db_connect, get_databases, etc.)
* fix: align DB viewer IPC param names (snake_case), implement execute_change + refresh_connection, snake_case Change enum tags
* fix: use camelCase IPC keys for multi-word Tauri command params (Tauri v2 converts snake_case Rust -> camelCase)
* fix: surface full postgres error detail and redact credentials instead of opaque 'db error'; URL-encode pg connection strings
* fix: cache passwords per-session so saved connections can auto-connect; surface full postgres error detail
* fix: auto-fetch table data, full-row click, fill viewport, load columns on expand, database switching
* fix: align ColumnInfo/QueryResult types with Rust backend (columns=ColumnInfo[], rows=unknown[][], field names match)
* feat: truncate cell text, resizable columns with drag handles, tab bar no-wrap horizontal scroll
* fix: QueryResult uses total_rows/page/page_size (match Rust), brighter table borders, pagination NaN fix
* fix: table horizontal scrolling, viewport fills screen height (h-screen), remove clipping overflow-hidden
* fix: constrain layout to viewport with overflow-hidden on content column; flex-1 fills height; sidebar+grid scroll independently
* fix: move overflow-hidden down to grid wrapper so DataGrid scrollbars surface properly
* fix: add min-w-0 to DataGrid wrapper so flexbox allows shrinking for horizontal scroll
* fix: add overflow-hidden to flex row and right column to clip at viewport, DataGrid scrolls inside
* fix: use w-0 on right column to force width:0 flex-basis, preventing any content-based expansion
* fix: overscroll-contain on DataGrid and TableTree scroll areas to prevent bounce
* fix: inline overscroll-behavior:none + WebkitOverflowScrolling:auto for reliable macOS bounce prevention
* feat: page-size selector (50/100/200) in pagination bar, setPageSize resets to page 1 and triggers re-fetch
* fix: column resize with refs, FK cell click opens table with filter, vertical borders, tooltip positioning (right/bottom), sidebar transparent bg
* fix: sidebar bg-canvas, remove overflow-hidden from toolbar parent so dropdown tooltips aren't clipped
* chore: rename sidebar label from DB Viewer to Explorer
* feat: resizable table panel (180-600px) with drag handle on right edge
* feat: double-click panel resize handle resets to default 280px
* feat: column visibility dropdown in filter bar, max column width 800px, double-click resets column width
* fix: enforce column width on th cells, remove min-width:100% so columns stay at set width instead of stretching
* fix: removable resize handle always clickable (remove opacity-0), maxWidth+overflow on th/td to prevent column blowout
* feat: add environment label to connections (production/staging/development) with badge on cards
* feat: OS keychain integration for connection passwords via tauri-plugin-keyring-store
* style: monospace font for table data cells
* style: use Space Mono (font-heading) for table data cells
* feat: unified table controls bar (insert, refresh, auto-refresh, filter modal, sort modal, export, columns, pagination); fix pagination setPage not clearing data
* fix: auto-refresh defaults to off, click opens dropdown to pick interval
* fix: pagination/refresh keeps existing data visible, shows subtle loading bar instead of blank
* fix: new tabs start with loading:true so auto-fetch triggers instead of stuck on 'loading table data'
* feat: checkbox column for row selection (header=all, row=individual, selected state tracks indices)
* feat: selected row count in toolbar with clear button
* feat: bulk actions dropdown on selection (Copy JSON, Copy CSV, Copy SQL INSERT, Delete rows)
* docs: add changes-queue-before-execution rule to AGENTS.md guardrails
* docs: broaden CRUD guardrail to cover all destructive operations (DB data via queue, app entities via confirm dialog)
* feat: auto-create demo SQLite DB on first launch with sample e-commerce schema (users, products, orders, order_items)
* fix: demo DB startup panic (state before manage), add settings: re-add demo, table refresh rate, table page size
* feat: Cmd+W closes tab or navigates home, edit connection modal with update_connection backend, action queue button with count badge and dropdown
* fix: columns button icon-only, wire settings.table_page_size and table_refresh_rate to actual table behavior
* feat: shortcuts settings tab showing all keyboard shortcuts (Cmd+K, Cmd+W, Esc, Enter, Space, click, header checkbox)
* feat: editable keyboard shortcuts - click pencil to record new keybinding, persisted to settings, useShortcut hook for dynamic binding
* feat: add Tags & Env tab to edit connection modal (environment, folder, tag picker)
* feat(db-viewer): UX polish - cursor pointers, icon-only toolbar, top border
- Add cursor-pointer to all interactive elements across db-viewer components
- Simplify TableControls: Filter/Sort/Export show icons only with tooltips
- Add border-t to DbViewerScreen for visual separation from window frame
* fix(db-viewer): style EditConnectionModal, fix test connection, working refresh button
- Match EditConnectionModal styling to home screen modals (AnimatedModal, Button, no dividers)
- Fix test connection sending null password by fetching from keychain first
- Make refresh database button functional with success/error visual feedback and spin animation
* feat(db-viewer): compact ghost-style DB/schema dropdowns in single row
- Add variant prop to SelectDropdown (pill | ghost) for minimal text-only style
- Place DB and schema dropdowns side-by-side on one row with | separator
* fix(db-viewer): proper FK/enum detection and improved schema tree display
Rust backend:
- PostgreSQL: fix column query to detect FKs (was hardcoded false) and map
USER-DEFINED types to udt_name for enum display
- SQLite: use PRAGMA table_info for types/PK/NOT NULL/defaults and
PRAGMA foreign_key_list for FK detection
Frontend:
- FK columns show orange key icon in TableTree
- Data type abbreviations (varchar, int, bool, timestamptz, etc.) with
full type name on hover tooltip
- Column icons use shrink-0 to maintain size
* feat(db-viewer): PK/FK icons and shorthand types in DataGrid headers
* fix(db-viewer): fix NULL values for UUID and timestamp columns, FK underline style
- Add uuid::Uuid, chrono types to pg_value_to_json chain so UUID PKs/FKs
and timestamp columns display correctly instead of NULL
- Enable with-serde_json-1 feature on tokio-postgres
- Change FK cell styling from blue text to dotted underline
* feat(db-viewer): FK preview popover with inline filter
- Replace direct FK navigation with popover showing the referenced row
- New get_fk_preview Rust command fetches single row by column value
- FkPreviewPopover component displays columns with PK/FK icons
- 'Open' button creates filtered tab, visible in existing Filter UI
- Consolidate columnFilter into filterRules (no duplicate filter logic)
* feat(db-viewer): selectable cell text, JSON/JSONB popover with formatted/raw views
- Add select-text to cell contents for copy support
- New JsonCellPopover component with Formatted/Raw tabs and copy button
- JSON/JSONB columns show brief preview ({ N keys } / [ N items ])
- Click JSON cells to open popover with pretty-printed or raw output
* feat(db-viewer): smart default sort adds newest-first sorting automatically
- 12-tier priority system detects recency/ordering columns
- Prefers updated_at, created_at, *_at suffixes, last_* prefixes
- Falls back to timestamp types, numeric IDs, sequence/position cols
- Also covers rank, version, count/quantity columns
- Applied once per tab, visible in Sort dropdown for manual override
* feat(db-viewer): search tables input with slide animation and tree filtering
- Search icon in toolbar toggles animated input with slide-down effect
- Filters TableTree by table name as user types
- Clean border-bottom styling, search icon on left, X clear on right
- Auto-hides on blur when empty, stays when content present
- Search icon highlights when active
* docs: add comprehensive implementation status to AGENTS.md
- Status matrix across 6 areas: connections, home, db viewer, object explorer, query editor, backup/restore, settings, onboarding
- Covers 60+ features with ✅/🟡/❌ markers and details
- Identifies remaining work: query editor, object explorer, virtualized grid, MySQL browsing, SSH tunnels, backup/restore
This commit is contained in:
@@ -0,0 +1,742 @@
|
||||
pub mod migrations;
|
||||
|
||||
use rusqlite::params;
|
||||
use rusqlite::Connection as SqliteConnection;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::models::{Connection, ConnectionInput, Folder, FolderInput, Settings, Tag, TagInput};
|
||||
|
||||
pub struct Store {
|
||||
conn: Mutex<SqliteConnection>,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
pub fn from_connection(conn: SqliteConnection) -> Self {
|
||||
Self {
|
||||
conn: Mutex::new(conn),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open(path: &str) -> Result<Self, String> {
|
||||
let conn = SqliteConnection::open(path).map_err(|e| e.to_string())?;
|
||||
migrations::run_migrations(&conn).map_err(|e| e.to_string())?;
|
||||
Ok(Self::from_connection(conn))
|
||||
}
|
||||
|
||||
fn now() -> String {
|
||||
chrono::Utc::now().to_rfc3339()
|
||||
}
|
||||
|
||||
pub fn get_folders(&self) -> Result<Vec<Folder>, String> {
|
||||
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id, name, parent_id, created_at, updated_at FROM folders ORDER BY name")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(Folder {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
parent_id: row.get(2)?,
|
||||
tag_ids: vec![],
|
||||
created_at: row.get(3)?,
|
||||
updated_at: row.get(4)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut folders: Vec<Folder> = rows.filter_map(|r| r.ok()).collect();
|
||||
// Load tags for each folder
|
||||
for f in folders.iter_mut() {
|
||||
let mut tag_stmt = conn
|
||||
.prepare("SELECT tag_id FROM folder_tags WHERE folder_id = ?1")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let tag_rows = tag_stmt
|
||||
.query_map(params![f.id], |row| row.get::<_, String>(0))
|
||||
.map_err(|e| e.to_string())?;
|
||||
f.tag_ids = tag_rows.filter_map(|r| r.ok()).collect();
|
||||
}
|
||||
Ok(folders)
|
||||
}
|
||||
|
||||
pub fn create_folder(&self, input: FolderInput) -> Result<Folder, String> {
|
||||
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let now = Self::now();
|
||||
conn.execute(
|
||||
"INSERT INTO folders (id, name, parent_id, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![id, input.name, input.parent_id, now, now],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let tag_ids = input.tag_ids.unwrap_or_default();
|
||||
for tag_id in &tag_ids {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO folder_tags (folder_id, tag_id) VALUES (?1, ?2)",
|
||||
params![id, tag_id],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(Folder {
|
||||
id,
|
||||
name: input.name,
|
||||
parent_id: input.parent_id,
|
||||
tag_ids,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_folder(&self, id: &str, input: FolderInput) -> Result<Folder, String> {
|
||||
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
||||
let now = Self::now();
|
||||
conn.execute(
|
||||
"UPDATE folders SET name = ?1, parent_id = ?2, updated_at = ?3 WHERE id = ?4",
|
||||
params![input.name, input.parent_id, now, id],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
// Replace all tags: clear existing, insert new
|
||||
conn.execute("DELETE FROM folder_tags WHERE folder_id = ?1", params![id])
|
||||
.map_err(|e| e.to_string())?;
|
||||
let tag_ids = input.tag_ids.unwrap_or_default();
|
||||
for tag_id in &tag_ids {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO folder_tags (folder_id, tag_id) VALUES (?1, ?2)",
|
||||
params![id, tag_id],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(Folder {
|
||||
id: id.to_string(),
|
||||
name: input.name,
|
||||
parent_id: input.parent_id,
|
||||
tag_ids,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_folder(&self, id: &str) -> Result<(), String> {
|
||||
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
||||
// Get the folder's parent_id to reparent children
|
||||
let parent_id: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT parent_id FROM folders WHERE id = ?1",
|
||||
params![id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
// Move child folders to the parent
|
||||
conn.execute(
|
||||
"UPDATE folders SET parent_id = ?1 WHERE parent_id = ?2",
|
||||
params![parent_id, id],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
// Move child connections to the parent
|
||||
conn.execute(
|
||||
"UPDATE connections SET folder_id = ?1 WHERE folder_id = ?2",
|
||||
params![parent_id, id],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
// Delete the folder
|
||||
conn.execute("DELETE FROM folders WHERE id = ?1", params![id])
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_folder_tags(&self, folder_id: &str, tag_ids: &[String]) -> Result<(), String> {
|
||||
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
||||
for tag_id in tag_ids {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO folder_tags (folder_id, tag_id) VALUES (?1, ?2)",
|
||||
params![folder_id, tag_id],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_connection_tags(&self, conn_id: &str, tag_ids: &[String]) -> Result<(), String> {
|
||||
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
||||
for tag_id in tag_ids {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO connection_tags (connection_id, tag_id) VALUES (?1, ?2)",
|
||||
params![conn_id, tag_id],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_tags(&self) -> Result<Vec<Tag>, String> {
|
||||
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id, name, color, created_at FROM tags ORDER BY name")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(Tag {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
color: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(rows.filter_map(|r| r.ok()).collect())
|
||||
}
|
||||
|
||||
pub fn create_tag(&self, input: TagInput) -> Result<Tag, String> {
|
||||
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let now = Self::now();
|
||||
conn.execute(
|
||||
"INSERT INTO tags (id, name, color, created_at) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![id, input.name, input.color, now],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(Tag {
|
||||
id,
|
||||
name: input.name,
|
||||
color: input.color,
|
||||
created_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_tag(&self, id: &str) -> Result<(), String> {
|
||||
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
||||
conn.execute("DELETE FROM tags WHERE id = ?1", params![id])
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_tag(&self, id: &str, input: TagInput) -> Result<Tag, String> {
|
||||
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
||||
let now = Self::now();
|
||||
conn.execute(
|
||||
"UPDATE tags SET name = ?1, color = ?2 WHERE id = ?3",
|
||||
params![input.name, input.color, id],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(Tag {
|
||||
id: id.to_string(),
|
||||
name: input.name,
|
||||
color: input.color,
|
||||
created_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_connections(&self) -> Result<Vec<Connection>, String> {
|
||||
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, name, db_type, host, port, username, database, folder_id, keychain_ref, ssh_host, ssh_port, ssh_user, ssh_auth_method, ssh_private_key_path, ssl_mode, ssl_ca_path, ssl_cert_path, ssl_key_path, environment, created_at, updated_at FROM connections ORDER BY name",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(Connection {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
db_type: row.get(2)?,
|
||||
host: row.get(3)?,
|
||||
port: row.get(4)?,
|
||||
username: row.get(5)?,
|
||||
database: row.get(6)?,
|
||||
folder_id: row.get(7)?,
|
||||
keychain_ref: row.get(8)?,
|
||||
ssh_host: row.get(9)?,
|
||||
ssh_port: row.get(10)?,
|
||||
ssh_user: row.get(11)?,
|
||||
ssh_auth_method: row.get(12)?,
|
||||
ssh_private_key_path: row.get(13)?,
|
||||
ssl_mode: row.get(14)?,
|
||||
ssl_ca_path: row.get(15)?,
|
||||
ssl_cert_path: row.get(16)?,
|
||||
ssl_key_path: row.get(17)?,
|
||||
environment: row.get(18)?,
|
||||
tag_ids: vec![],
|
||||
created_at: row.get(19)?,
|
||||
updated_at: row.get(20)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut conns: Vec<Connection> = rows.filter_map(|r| r.ok()).collect();
|
||||
// Load tags for each connection
|
||||
for c in conns.iter_mut() {
|
||||
let mut tag_stmt = conn
|
||||
.prepare("SELECT tag_id FROM connection_tags WHERE connection_id = ?1")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let tag_rows = tag_stmt
|
||||
.query_map(params![c.id], |row| row.get::<_, String>(0))
|
||||
.map_err(|e| e.to_string())?;
|
||||
c.tag_ids = tag_rows.filter_map(|r| r.ok()).collect();
|
||||
}
|
||||
Ok(conns)
|
||||
}
|
||||
|
||||
pub fn create_connection(&self, input: ConnectionInput) -> Result<Connection, String> {
|
||||
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let now = Self::now();
|
||||
conn.execute(
|
||||
"INSERT INTO connections (id, name, db_type, host, port, username, database, folder_id, keychain_ref, ssh_host, ssh_port, ssh_user, ssh_auth_method, ssh_private_key_path, ssl_mode, ssl_ca_path, ssl_cert_path, ssl_key_path, environment, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, NULL, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
|
||||
params![id, input.name, input.db_type, input.host, input.port, input.username, input.database, input.folder_id, input.ssh_host, input.ssh_port, input.ssh_user, input.ssh_auth_method, input.ssh_private_key_path, input.ssl_mode, input.ssl_ca_path, input.ssl_cert_path, input.ssl_key_path, input.environment, now, now],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
for tag_id in &input.tag_ids {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO connection_tags (connection_id, tag_id) VALUES (?1, ?2)",
|
||||
params![id, tag_id],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(Connection {
|
||||
id,
|
||||
name: input.name,
|
||||
db_type: input.db_type,
|
||||
host: input.host,
|
||||
port: input.port,
|
||||
username: input.username,
|
||||
folder_id: input.folder_id,
|
||||
database: input.database,
|
||||
keychain_ref: None,
|
||||
environment: input.environment,
|
||||
ssh_host: input.ssh_host,
|
||||
ssh_port: input.ssh_port,
|
||||
ssh_user: input.ssh_user,
|
||||
ssh_auth_method: input.ssh_auth_method,
|
||||
ssh_private_key_path: input.ssh_private_key_path,
|
||||
ssl_mode: input.ssl_mode,
|
||||
ssl_ca_path: input.ssl_ca_path,
|
||||
ssl_cert_path: input.ssl_cert_path,
|
||||
ssl_key_path: input.ssl_key_path,
|
||||
tag_ids: input.tag_ids,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_connection(&self, id: &str) -> Result<(), String> {
|
||||
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
||||
conn.execute("DELETE FROM connections WHERE id = ?1", params![id])
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_connection(&self, id: &str, input: ConnectionInput) -> Result<Connection, String> {
|
||||
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
||||
let now = Self::now();
|
||||
conn.execute(
|
||||
"UPDATE connections SET name=?1, db_type=?2, host=?3, port=?4, username=?5, database=?6, folder_id=?7, ssh_host=?8, ssh_port=?9, ssh_user=?10, ssh_auth_method=?11, ssh_private_key_path=?12, ssl_mode=?13, ssl_ca_path=?14, ssl_cert_path=?15, ssl_key_path=?16, environment=?17, updated_at=?18 WHERE id=?19",
|
||||
params![
|
||||
input.name, input.db_type, input.host, input.port, input.username,
|
||||
input.database, input.folder_id, input.ssh_host, input.ssh_port,
|
||||
input.ssh_user, input.ssh_auth_method, input.ssh_private_key_path,
|
||||
input.ssl_mode, input.ssl_ca_path, input.ssl_cert_path, input.ssl_key_path,
|
||||
input.environment, now, id
|
||||
],
|
||||
).map_err(|e| e.to_string())?;
|
||||
// Update tags
|
||||
conn.execute("DELETE FROM connection_tags WHERE connection_id = ?1", params![id])
|
||||
.map_err(|e| e.to_string())?;
|
||||
for tag_id in &input.tag_ids {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO connection_tags (connection_id, tag_id) VALUES (?1, ?2)",
|
||||
params![id, tag_id],
|
||||
).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(Connection {
|
||||
id: id.to_string(),
|
||||
name: input.name,
|
||||
db_type: input.db_type,
|
||||
host: input.host,
|
||||
port: input.port,
|
||||
username: input.username,
|
||||
database: input.database,
|
||||
folder_id: input.folder_id,
|
||||
keychain_ref: None,
|
||||
environment: input.environment,
|
||||
ssh_host: input.ssh_host,
|
||||
ssh_port: input.ssh_port,
|
||||
ssh_user: input.ssh_user,
|
||||
ssh_auth_method: input.ssh_auth_method,
|
||||
ssh_private_key_path: input.ssh_private_key_path,
|
||||
ssl_mode: input.ssl_mode,
|
||||
ssl_ca_path: input.ssl_ca_path,
|
||||
ssl_cert_path: input.ssl_cert_path,
|
||||
ssl_key_path: input.ssl_key_path,
|
||||
tag_ids: input.tag_ids.clone(),
|
||||
created_at: String::new(), // not updated
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_settings(&self) -> Result<Settings, String> {
|
||||
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
||||
let mut map: HashMap<String, String> = HashMap::new();
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT key, value FROM settings")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
))
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
for r in rows.filter_map(|r| r.ok()) {
|
||||
map.insert(r.0, r.1);
|
||||
}
|
||||
let theme = map
|
||||
.get("theme")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "system".to_string());
|
||||
let font_size = map
|
||||
.get("font_size")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "medium".to_string());
|
||||
let confirm = map
|
||||
.get("confirm_before_delete")
|
||||
.map(|v| v == "true")
|
||||
.unwrap_or(true);
|
||||
let default_folder_id = map
|
||||
.get("default_folder_id")
|
||||
.filter(|v| v.as_str() != "null")
|
||||
.cloned();
|
||||
let mut default_ports = HashMap::new();
|
||||
default_ports.insert("postgresql".to_string(), Some(5432i64));
|
||||
default_ports.insert("mysql".to_string(), Some(3306i64));
|
||||
default_ports.insert("redis".to_string(), Some(6379i64));
|
||||
default_ports.insert("sqlite".to_string(), None);
|
||||
if let Some(ports_json) = map.get("default_ports") {
|
||||
if let Ok(parsed) =
|
||||
serde_json::from_str::<HashMap<String, Option<i64>>>(ports_json)
|
||||
{
|
||||
default_ports = parsed;
|
||||
}
|
||||
}
|
||||
Ok(Settings {
|
||||
confirm_before_delete: confirm,
|
||||
default_folder_id,
|
||||
theme,
|
||||
font_size,
|
||||
default_ports,
|
||||
tag_order: map.get("tag_order").cloned(),
|
||||
table_refresh_rate: map
|
||||
.get("table_refresh_rate")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0),
|
||||
table_page_size: map
|
||||
.get("table_page_size")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(50),
|
||||
shortcuts: map
|
||||
.get("shortcuts")
|
||||
.and_then(|v| serde_json::from_str(v).ok())
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_setting(&self, key: &str, value: &str) -> Result<(), String> {
|
||||
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
||||
conn.execute(
|
||||
"INSERT INTO settings (key, value) VALUES (?1, ?2) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
params![key, value],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::{ConnectionInput, FolderInput, TagInput};
|
||||
|
||||
fn fresh_store() -> Store {
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
crate::store::migrations::run_migrations(&conn).unwrap();
|
||||
Store::from_connection(conn)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_and_get_folder() {
|
||||
let store = fresh_store();
|
||||
let folder = store
|
||||
.create_folder(FolderInput { tag_ids: None,
|
||||
name: "Work".into(),
|
||||
parent_id: None,
|
||||
})
|
||||
.unwrap();
|
||||
let got = store.get_folders().unwrap();
|
||||
assert_eq!(got.len(), 1);
|
||||
assert_eq!(got[0].name, "Work");
|
||||
assert_eq!(got[0].id, folder.id);
|
||||
assert!(got[0].parent_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_nested_folders() {
|
||||
let store = fresh_store();
|
||||
let parent = store
|
||||
.create_folder(FolderInput { tag_ids: None,
|
||||
name: "root".into(),
|
||||
parent_id: None,
|
||||
})
|
||||
.unwrap();
|
||||
let child = store
|
||||
.create_folder(FolderInput { tag_ids: None,
|
||||
name: "child".into(),
|
||||
parent_id: Some(parent.id.clone()),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(child.parent_id, Some(parent.id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_and_get_tag() {
|
||||
let store = fresh_store();
|
||||
let tag = store
|
||||
.create_tag(TagInput {
|
||||
name: "production".into(),
|
||||
color: "#ef4444".into(),
|
||||
})
|
||||
.unwrap();
|
||||
let got = store.get_tags().unwrap();
|
||||
assert_eq!(got.len(), 1);
|
||||
assert_eq!(got[0].name, "production");
|
||||
assert_eq!(got[0].color, "#ef4444");
|
||||
assert_eq!(got[0].id, tag.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_and_get_connection() {
|
||||
let store = fresh_store();
|
||||
let conn = store
|
||||
.create_connection(ConnectionInput {
|
||||
name: "Prod".into(),
|
||||
db_type: "postgresql".into(),
|
||||
host: "prod.example.com".into(),
|
||||
port: Some(5432),
|
||||
username: Some("admin".into()),
|
||||
folder_id: None,
|
||||
password: None,
|
||||
database: None,
|
||||
ssh_host: None,
|
||||
ssh_port: None,
|
||||
ssh_user: None,
|
||||
ssh_auth_method: None,
|
||||
ssh_private_key_path: None,
|
||||
ssh_passphrase: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
environment: None,
|
||||
tag_ids: vec![],
|
||||
})
|
||||
.unwrap();
|
||||
let got = store.get_connections().unwrap();
|
||||
assert_eq!(got.len(), 1);
|
||||
assert_eq!(got[0].name, "Prod");
|
||||
assert_eq!(got[0].port, Some(5432));
|
||||
assert!(got[0].tag_ids.is_empty());
|
||||
assert_eq!(got[0].id, conn.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_with_tags_persists_join() {
|
||||
let store = fresh_store();
|
||||
let t1 = store
|
||||
.create_tag(TagInput {
|
||||
name: "prod".into(),
|
||||
color: "#ef4444".into(),
|
||||
})
|
||||
.unwrap();
|
||||
let t2 = store
|
||||
.create_tag(TagInput {
|
||||
name: "primary".into(),
|
||||
color: "#3b82f6".into(),
|
||||
})
|
||||
.unwrap();
|
||||
store
|
||||
.create_connection(ConnectionInput {
|
||||
name: "Prod".into(),
|
||||
db_type: "postgresql".into(),
|
||||
host: "h".into(),
|
||||
port: Some(5432),
|
||||
username: None,
|
||||
folder_id: None,
|
||||
password: None,
|
||||
database: None,
|
||||
ssh_host: None,
|
||||
ssh_port: None,
|
||||
ssh_user: None,
|
||||
ssh_auth_method: None,
|
||||
ssh_private_key_path: None,
|
||||
ssh_passphrase: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
environment: None,
|
||||
tag_ids: vec![t1.id.clone(), t2.id.clone()],
|
||||
})
|
||||
.unwrap();
|
||||
let got = store.get_connections().unwrap();
|
||||
assert_eq!(got[0].tag_ids.len(), 2);
|
||||
assert!(got[0].tag_ids.contains(&t1.id));
|
||||
assert!(got[0].tag_ids.contains(&t2.id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_folder_sets_connection_folder_null() {
|
||||
let store = fresh_store();
|
||||
let folder = store
|
||||
.create_folder(FolderInput { tag_ids: None,
|
||||
name: "f".into(),
|
||||
parent_id: None,
|
||||
})
|
||||
.unwrap();
|
||||
store
|
||||
.create_connection(ConnectionInput {
|
||||
name: "C".into(),
|
||||
db_type: "postgresql".into(),
|
||||
host: "h".into(),
|
||||
port: Some(5432),
|
||||
username: None,
|
||||
folder_id: Some(folder.id.clone()),
|
||||
password: None,
|
||||
database: None,
|
||||
ssh_host: None,
|
||||
ssh_port: None,
|
||||
ssh_user: None,
|
||||
ssh_auth_method: None,
|
||||
ssh_private_key_path: None,
|
||||
ssh_passphrase: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
environment: None,
|
||||
tag_ids: vec![],
|
||||
})
|
||||
.unwrap();
|
||||
store.delete_folder(&folder.id).unwrap();
|
||||
let conns = store.get_connections().unwrap();
|
||||
assert!(conns[0].folder_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_tag_removes_from_connection() {
|
||||
let store = fresh_store();
|
||||
let tag = store
|
||||
.create_tag(TagInput {
|
||||
name: "prod".into(),
|
||||
color: "#ef4444".into(),
|
||||
})
|
||||
.unwrap();
|
||||
store
|
||||
.create_connection(ConnectionInput {
|
||||
name: "C".into(),
|
||||
db_type: "postgresql".into(),
|
||||
host: "h".into(),
|
||||
port: Some(5432),
|
||||
username: None,
|
||||
folder_id: None,
|
||||
password: None,
|
||||
database: None,
|
||||
ssh_host: None,
|
||||
ssh_port: None,
|
||||
ssh_user: None,
|
||||
ssh_auth_method: None,
|
||||
ssh_private_key_path: None,
|
||||
ssh_passphrase: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
environment: None,
|
||||
tag_ids: vec![tag.id.clone()],
|
||||
})
|
||||
.unwrap();
|
||||
store.delete_tag(&tag.id).unwrap();
|
||||
let conns = store.get_connections().unwrap();
|
||||
assert!(conns[0].tag_ids.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_get_returns_defaults_when_empty() {
|
||||
let store = fresh_store();
|
||||
let settings = store.get_settings().unwrap();
|
||||
assert_eq!(settings.theme, "system");
|
||||
assert_eq!(settings.font_size, "medium");
|
||||
assert!(settings.confirm_before_delete);
|
||||
assert_eq!(
|
||||
settings.default_ports.get("postgresql"),
|
||||
Some(&Some(5432))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_update_persists() {
|
||||
let store = fresh_store();
|
||||
store.update_setting("theme", "light").unwrap();
|
||||
let settings = store.get_settings().unwrap();
|
||||
assert_eq!(settings.theme, "light");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssh_ssl_fields_persist_and_retrieve() {
|
||||
let store = fresh_store();
|
||||
let conn = store
|
||||
.create_connection(ConnectionInput {
|
||||
name: "SSH-Tunnel-DB".into(),
|
||||
db_type: "postgresql".into(),
|
||||
host: "localhost".into(),
|
||||
port: Some(5432),
|
||||
username: Some("dbuser".into()),
|
||||
folder_id: None,
|
||||
password: None,
|
||||
database: Some("analytics".into()),
|
||||
ssh_host: Some("jumphost.example.com".into()),
|
||||
ssh_port: Some(2222),
|
||||
ssh_user: Some("tunneluser".into()),
|
||||
ssh_auth_method: Some("Key".into()),
|
||||
ssh_private_key_path: Some("/home/user/.ssh/id_rsa".into()),
|
||||
ssh_passphrase: None,
|
||||
ssl_mode: Some("verify-full".into()),
|
||||
ssl_ca_path: Some("/etc/ssl/certs/ca.pem".into()),
|
||||
ssl_cert_path: Some("/etc/ssl/certs/client-cert.pem".into()),
|
||||
ssl_key_path: Some("/etc/ssl/private/client-key.pem".into()),
|
||||
environment: None,
|
||||
tag_ids: vec![],
|
||||
})
|
||||
.unwrap();
|
||||
let got = store.get_connections().unwrap();
|
||||
assert_eq!(got.len(), 1);
|
||||
assert_eq!(got[0].database.as_deref(), Some("analytics"));
|
||||
assert_eq!(got[0].ssh_host.as_deref(), Some("jumphost.example.com"));
|
||||
assert_eq!(got[0].ssh_port, Some(2222));
|
||||
assert_eq!(got[0].ssh_user.as_deref(), Some("tunneluser"));
|
||||
assert_eq!(got[0].ssh_auth_method.as_deref(), Some("Key"));
|
||||
assert_eq!(
|
||||
got[0].ssh_private_key_path.as_deref(),
|
||||
Some("/home/user/.ssh/id_rsa")
|
||||
);
|
||||
assert_eq!(got[0].ssl_mode.as_deref(), Some("verify-full"));
|
||||
assert_eq!(
|
||||
got[0].ssl_ca_path.as_deref(),
|
||||
Some("/etc/ssl/certs/ca.pem")
|
||||
);
|
||||
assert_eq!(
|
||||
got[0].ssl_cert_path.as_deref(),
|
||||
Some("/etc/ssl/certs/client-cert.pem")
|
||||
);
|
||||
assert_eq!(
|
||||
got[0].ssl_key_path.as_deref(),
|
||||
Some("/etc/ssl/private/client-key.pem")
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user