diff --git a/AGENTS.md b/AGENTS.md index 6a05639..f5e0c96 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -249,6 +249,7 @@ cargo test # Rust tests ### Object Explorer (non-table objects) | Feature | Status | Details | | :--- | :---: | :--- | +| Unified Objects view | ✅ | Functions/Triggers/Sequences/Enums/Extensions merged into a single **Objects** nav item; sidebar header leads with an object-type dropdown (title position) + refresh/search icons, animated search, db/schema switchers; switching type resets selection and refetches | | Functions | ✅ | Full detail view: signature, arguments with mode/type, syntax-highlighted line-numbered source. Overloads disambiguated by argument signature. Schema-filtered via pg_proc query. | | Triggers | ✅ | Full detail view: table, event, timing, orientation, status (color-coded), definition. tgtype bitmask corrected. Schema-filtered. | | Sequences | ✅ | Full detail view: current value, increment, start, min/max, cycle flag. Schema-filtered via information_schema.sequences. | @@ -266,11 +267,12 @@ cargo test # Rust tests | SQL text editor (Monaco) | ✅ | Lazy-loaded Monaco SQL editor with Cmd/Ctrl+Enter to run (`src/components/editor/QueryEditor.tsx`) | | Custom query execution (arbitrary SQL) | ✅ | `execute_query` Rust command: subquery-wrapped pagination + raw fallback; PostgreSQL + SQLite; results in virtualized grid | | Destructive query guard | ✅ | Confirmation dialog for INSERT/UPDATE/DELETE/DROP/ALTER/TRUNCATE/CREATE/REPLACE (`isDestructiveQuery` + `DestructiveQueryDialog`) | -| Query history / recent queries | 🟡 | Backend + commands done (v5 `query_history` table, `get_query_history`/`clear_query_history`); UI dropdown to show history in the query editor is **not wired yet** | +| Query history / recent queries | ✅ | v5 `query_history` table + v6 `favorite` column; consecutive-identical dedup + retention pruning (500/connection); `get_query_history`/`clear_query_history`/`set_history_favorite` commands; **QueryHistoryDropdown** in the query toolbar (load / run / favorite / clear, lazy fetch, loading + error states) | | SQL autocomplete (keywords, tables, columns) | ✅ | Completion provider in `src/lib/monacoSetup.ts` backed by `src/lib/sqlCompletion.ts` (pure, unit-tested): keywords (~60) + table names from the active schema; typing `table.` or `schema.table.` suggests that table's columns (introspected via `get_schema_graph`, cached per schema in memory, `incomplete: true` warm-up on first use) | | Multiple result sets | ❌ | | -| Saved queries (named, organized) | ❌ | No `queries` table in local SQLite | -| Query favorites / pinning | ❌ | | +| Saved queries (named, organized) | ✅ | v6 `queries` table (nullable `connection_id` for global queries, `folder` field, `ON DELETE CASCADE`); `save_query`/`get_saved_queries`/`update_saved_query`/`delete_saved_query` commands with validation (name ≤200, folder ≤100, text ≤1MB); **SaveQueryDialog** (name + folder, empty-name guard); managed in the Queries view Saved tab | +| Query favorites / pinning | ✅ | Star toggle per history entry via `set_history_favorite`; favorites-only filter in the Queries view History tab | +| Queries view | ✅ | Two-pane layout following Explorer: left sidebar (Explorer-styled header — Queries title, History/Saved dropdown, favorites/clear/search icons, animated search) scoped to the **current connection**, right side reuses the shared tabbed query workspace (TabBar + toolbar + editor + results); clicking a history/saved row loads it into the editor | | Editor settings (font, tab size, word wrap, minimap) | ❌ | Settings page has "Editor" tab with "coming soon" placeholder | ### Backup & Restore @@ -281,6 +283,7 @@ cargo test # Rust tests | Backup UI | ✅ | In-page view: format selector, file browse (Tauri dialog), schema dropdown, no-owner toggle, progress bar with event-driven status | | Restore UI | ✅ | In-page view: file browse, format, clean toggle, destructive confirmation checkbox, progress bar | | DB-to-DB sync | ✅ | In-page view: source/target connection pickers, schema dropdown, pipe-based pg_dump → pg_restore | +| Unified Tools view | ✅ | Backup / Restore / DB Sync merged into a single **Tools** nav item; operation-switcher dropdown in the view toolbar, existing forms rendered below | | SQLite .dump | ❌ | | | Table structure export (DDL) | ❌ | | diff --git a/README.md b/README.md index 111f9b5..37c231a 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,7 @@ Full tree-view navigation of all native PostgreSQL schema objects: - **Sequences & Enums** — current values, increments, cycle flags; enum labels in bordered list view - **Extensions** — installed extensions with version, schema, and comment - **Schema Visualizer (ER Diagram)** — interactive React Flow graph with dagre auto-layout, crow's foot notation (1:1, 1:N, N:M), color-coded relationships, schema selector, zoom controls, collapsible columns (PK/FK/unique-only), cross-schema FK support for PostgreSQL + SQLite +- **Unified Objects View** — Functions, Triggers, Sequences, Enums, and Extensions share a single sidebar with an object-type dropdown switcher (title position), refresh/search, and db/schema selectors ### SQL Editor & Query Workbench - **Monaco SQL Editor** — lazy-loaded [Monaco Editor](https://microsoft.github.io/monaco-editor/) with SQL syntax highlighting, Cmd/Ctrl+Enter to run @@ -104,7 +105,9 @@ Full tree-view navigation of all native PostgreSQL schema objects: - **Multi-Tab Workspace** — unlimited named tabs, session persistence across restarts - **Changes Queue** — queue INSERT/UPDATE/DELETE changes; preview before committing all. The tab bar's **Changes** button (checklist icon + pending-count badge) toggles the bottom Commit All panel — the single entry point - **Smart Default Sort** — auto-detects `updated_at`, `created_at`, `_id` columns for logical initial sorting -- *(query history UI dropdown, saved queries/snippets — upcoming)* +- **Query History** — recent queries per connection in a toolbar dropdown (load / run / favorite / clear), consecutive-identical dedup, retention pruned to 500 per connection +- **Saved Queries** — save the current query with a name + folder from the toolbar; manage them in the Queries view +- **Queries View** — two-pane workspace: an Explorer-styled sidebar with History / Saved Queries (per-connection scope, favorites filter, animated search) beside the tabbed query editor; click any row to load it into the editor ### Data Grid & Schema Browser - **Virtualized Grid** — row-level virtualization via `@tanstack/react-virtual` handles 100k+ rows @@ -120,6 +123,7 @@ Full tree-view navigation of all native PostgreSQL schema objects: - **Visual Backup** — `pg_dump` wrapper with format selector (Plain SQL, Custom, Tar, Directory), file browser, schema filter, no-owner toggle, real-time progress bar - **Visual Restore** — `pg_restore` wrapper with file browser, format, clean toggle, destructive confirmation checkbox - **DB-to-DB Sync** — pipe `pg_dump` → `pg_restore` between two connections with source/target pickers, schema filter, flow indicator +- **Unified Tools View** — Backup, Restore, and DB-to-DB Sync grouped under one **Tools** view with an operation-switcher dropdown ### App Portability - **Export** — save all workspaces, folders, saved queries, tags, and non-sensitive metadata to a single JSON archive @@ -232,18 +236,19 @@ gridline/ - **Home Screen & Organization** — Connection cards by folder, folders CRUD, tags CRUD with colors, global search (Cmd+K), import/export connections (JSON), bulk select/delete, DB type filter, demo SQLite database - **Query Editor (Core)** — Monaco SQL editor with Cmd+Enter execution, `execute_query` Rust command (PostgreSQL + SQLite, subquery pagination with raw fallback), query tabs in the DB viewer, destructive query confirmation dialog, `query_history` persistence backend - **Home Screen Filters** — Tag filter with OR semantics, folder cards matching tags or containing matching connections, DB type filter hiding empty folders, environment filter (All/Production/Staging/Development/None), global search across all folders with "Showing Search Results" breadcrumb + Clear +- **Query History & Saved Queries** — toolbar history dropdown (load / run / favorite / clear), favorites, consecutive-identical dedup + 500-retention pruning, SaveQueryDialog, and a two-pane Queries view (History / Saved Queries sidebar scoped per connection + tabbed query workspace) +- **Consolidated Navigation** — merged Functions/Triggers/Sequences/Enums/Extensions into a single Objects view (object-type dropdown) and Backup/Restore/DB Sync into a single Tools view (operation dropdown) ### 🟡 In Progress / Upcoming -- **Query Editor (Polish)** — SQL autocomplete (keywords, tables, and per-table columns), query history UI dropdown, saved queries +- **Editor Settings** — font, tab size, word wrap, minimap options - **SSH/SSL Runtime** — SSH tunnel via `ssh2` crate, SSL/TLS config passed to `sqlx`/`tokio-postgres` - **Inline Cell Editing** — Edit cells directly in the data grid - **Data Import** — CSV, JSON import with column mapping ### 🔮 Future - **Multi-DB Support** — MySQL browsing, Redis key browser, full MySQL/SQLite/Redis parity with PostgreSQL -- **Query Workbench** — Multiple result sets, query favorites/pinning, visual query builder +- **Query Workbench** — Multiple result sets, visual query builder - **Deeper PostgreSQL** — Indexes, constraints, materialized views, stored procedure view, user/role management -- **Collaboration** — Team workspaces, shared connections, query sharing - **Notebook Reports** — SQL-backed markdown reports with embedded results - **AI Integration (BYOK)** — Bring-Your-Own-Key AI assistant: natural-language → SQL generation, query explanations, schema summaries, error suggestions. Key stored in OS keychain; only user's chosen provider sees SQL/text. diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..9873459 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,5 @@ +# Bun test runner configuration. `bun test` uses Bun's native runner (it does +# not read vite.config.ts), so the jsdom/DOM preload lives here. The canonical +# test command is `bun run test` (vitest run), which uses vite.config.ts. +[test] +preload = ["./src/test/bun-dom.ts", "./src/test/bun-setup.ts"] diff --git a/src-tauri/src/commands/query.rs b/src-tauri/src/commands/query.rs index e23951c..7fba407 100644 --- a/src-tauri/src/commands/query.rs +++ b/src-tauri/src/commands/query.rs @@ -32,6 +32,46 @@ pub struct QueryHistoryEntry { pub status: String, pub error_message: Option, pub executed_at: String, + pub favorite: bool, // NEW — v6 +} + +// --------------------------------------------------------------------------- +// SavedQueryCommand +// --------------------------------------------------------------------------- + +/// A saved query, returned to the frontend. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SavedQueryCommand { + pub id: String, + pub connection_id: Option, + pub name: String, + pub query_text: String, + pub folder: String, + pub created_at: String, + pub updated_at: String, +} + +/// Patch body for `update_saved_query` — all fields optional. +#[derive(Debug, Deserialize)] +pub struct UpdateSavedQueryPatch { + pub name: Option, + #[serde(rename = "queryText")] + pub query_text: Option, + pub folder: Option, +} + +impl From for SavedQueryCommand { + fn from(r: crate::store::SavedQueryRow) -> Self { + Self { + id: r.id, + connection_id: r.connection_id, + name: r.name, + query_text: r.query_text, + folder: r.folder, + created_at: r.created_at, + updated_at: r.updated_at, + } + } } // --------------------------------------------------------------------------- @@ -494,6 +534,57 @@ pub(crate) fn clear_query_history_inner( store.clear_query_history(connection_id) } +pub(crate) fn set_history_favorite_inner( + db_store: &std::sync::Mutex, + id: &str, + connection_id: &str, +) -> Result<(), String> { + let store = db_store.lock().map_err(|e| e.to_string())?; + store.set_history_favorite(id, connection_id) +} + +pub(crate) fn save_query_inner( + db_store: &std::sync::Mutex, + connection_id: Option, + name: String, + query_text: String, + folder: String, +) -> Result { + let store = db_store.lock().map_err(|e| e.to_string())?; + store + .save_query(connection_id.as_deref(), &name, &query_text, &folder) + .map(SavedQueryCommand::from) +} + +pub(crate) fn get_saved_queries_inner( + db_store: &std::sync::Mutex, + connection_id: Option, +) -> Result, String> { + let store = db_store.lock().map_err(|e| e.to_string())?; + store + .list_saved_queries(connection_id.as_deref()) + .map(|rows| rows.into_iter().map(SavedQueryCommand::from).collect()) +} + +pub(crate) fn update_saved_query_inner( + db_store: &std::sync::Mutex, + id: String, + name: Option, + query_text: Option, + folder: Option, +) -> Result<(), String> { + let store = db_store.lock().map_err(|e| e.to_string())?; + store.update_saved_query(&id, name.as_deref(), query_text.as_deref(), folder.as_deref()) +} + +pub(crate) fn delete_saved_query_inner( + db_store: &std::sync::Mutex, + id: String, +) -> Result<(), String> { + let store = db_store.lock().map_err(|e| e.to_string())?; + store.delete_saved_query(&id) +} + // --------------------------------------------------------------------------- // Tauri commands // --------------------------------------------------------------------------- @@ -534,6 +625,57 @@ pub async fn clear_query_history( store.clear_query_history(connection_id.as_deref()) } +#[tauri::command] +pub async fn set_history_favorite( + id: String, + connection_id: String, + state: State<'_, crate::AppState>, +) -> Result<(), String> { + set_history_favorite_inner(&state.db_store, &id, &connection_id) +} + +#[tauri::command] +pub async fn save_query( + connection_id: Option, + name: String, + query_text: String, + folder: Option, + state: State<'_, crate::AppState>, +) -> Result { + save_query_inner( + &state.db_store, + connection_id, + name, + query_text, + folder.unwrap_or_default(), + ) +} + +#[tauri::command] +pub async fn get_saved_queries( + connection_id: Option, + state: State<'_, crate::AppState>, +) -> Result, String> { + get_saved_queries_inner(&state.db_store, connection_id) +} + +#[tauri::command] +pub async fn update_saved_query( + id: String, + patch: UpdateSavedQueryPatch, + state: State<'_, crate::AppState>, +) -> Result<(), String> { + update_saved_query_inner(&state.db_store, id, patch.name, patch.query_text, patch.folder) +} + +#[tauri::command] +pub async fn delete_saved_query( + id: String, + state: State<'_, crate::AppState>, +) -> Result<(), String> { + delete_saved_query_inner(&state.db_store, id) +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -735,6 +877,75 @@ mod tests { } } + // ------------------------------------------------------------------ + // Test 5: Saved query command roundtrip (save → list → update → delete) + // ------------------------------------------------------------------ + + #[test] + fn save_query_command_roundtrip() { + use crate::models::ConnectionInput; + let conn = SqliteConnection::open_in_memory().unwrap(); + crate::store::migrations::run_migrations(&conn).unwrap(); + let store = Mutex::new(Store::from_connection(conn)); + let sc2 = store + .lock() + .unwrap() + .create_connection(ConnectionInput { + name: "sc2".into(), + db_type: "postgresql".into(), + host: "h".into(), + port: Some(5432), + username: None, + folder_id: None, + tag_ids: vec![], + password: None, + database: None, + environment: 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, + }) + .unwrap(); + + // save + let result = save_query_inner( + &store, + Some(sc2.id.clone()), + "Q1".to_string(), + "SELECT 1".to_string(), + "r".to_string(), + ) + .unwrap(); + assert_eq!(result.name, "Q1"); + assert_eq!(result.connection_id, Some(sc2.id.clone())); + + // list + let list = get_saved_queries_inner(&store, Some(sc2.id.clone())).unwrap(); + assert_eq!(list.len(), 1); + + // update (bogus id "update" — no-op, exercises the code path) + update_saved_query_inner( + &store, + "update".to_string(), + Some("Renamed".to_string()), + None, + None, + ) + .unwrap(); + + // delete + delete_saved_query_inner(&store, result.id).unwrap(); + let after = get_saved_queries_inner(&store, Some(sc2.id.clone())).unwrap(); + assert!(after.is_empty()); + } + // ------------------------------------------------------------------ // Helper: unwrap a DbHandle::Sqlite to get the connection reference // ------------------------------------------------------------------ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d0c8d3a..2639714 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -98,6 +98,11 @@ pub fn run() { query::execute_query, query::get_query_history, query::clear_query_history, + query::set_history_favorite, + query::save_query, + query::get_saved_queries, + query::update_saved_query, + query::delete_saved_query, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/store/migrations.rs b/src-tauri/src/store/migrations.rs index d62c47e..e14fd78 100644 --- a/src-tauri/src/store/migrations.rs +++ b/src-tauri/src/store/migrations.rs @@ -194,6 +194,31 @@ pub fn run_migrations(conn: &Connection) -> Result<(), String> { .map_err(|e| e.to_string())?; } + // v6: query history favorites + saved queries + if current_ver < 6 { + conn.execute_batch( + "ALTER TABLE query_history ADD COLUMN favorite INTEGER NOT NULL DEFAULT 0; + CREATE TABLE IF NOT EXISTS queries ( + id TEXT PRIMARY KEY, + connection_id TEXT, + name TEXT NOT NULL, + query_text TEXT NOT NULL, + folder TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (connection_id) REFERENCES connections(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_queries_connection ON queries(connection_id); + CREATE INDEX IF NOT EXISTS idx_queries_folder ON queries(folder);" + ).map_err(|e| e.to_string())?; + + conn.execute( + "INSERT INTO schema_version (version) VALUES (6)", + [], + ) + .map_err(|e| e.to_string())?; + } + Ok(()) } @@ -244,7 +269,7 @@ mod tests { let count: i64 = conn .query_row("SELECT COUNT(*) FROM schema_version", [], |row| row.get(0)) .unwrap(); - assert_eq!(count, 4); + assert_eq!(count, 5); } #[test] @@ -296,4 +321,100 @@ mod tests { .unwrap(); assert_eq!(count, 0); } + + #[test] + fn v6_adds_favorite_column_to_query_history() { + let conn = Connection::open_in_memory().unwrap(); + run_migrations(&conn).unwrap(); + // Verify the favorite column exists via PRAGMA + let columns: Vec = { + let mut stmt = conn.prepare("PRAGMA table_info(query_history)").unwrap(); + let rows = stmt + .query_map([], |row| row.get::<_, String>(1)) + .unwrap(); + rows.filter_map(|r| r.ok()).collect() + }; + assert!( + columns.contains(&"favorite".to_string()), + "Expected query_history to have a 'favorite' column after v6 migration" + ); + // Existing rows default to 0 + let fav: i64 = conn + .query_row( + "SELECT COUNT(*) FROM query_history WHERE favorite != 0", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(fav, 0); + } + + #[test] + fn v6_creates_queries_table() { + let conn = Connection::open_in_memory().unwrap(); + run_migrations(&conn).unwrap(); + // Table exists + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM queries", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 0); + // Column check + let columns: Vec = { + let mut stmt = conn.prepare("PRAGMA table_info(queries)").unwrap(); + let rows = stmt + .query_map([], |row| row.get::<_, String>(1)) + .unwrap(); + rows.filter_map(|r| r.ok()).collect() + }; + for c in &["id", "connection_id", "name", "query_text", "folder", "created_at", "updated_at"] { + assert!(columns.contains(&c.to_string()), "Expected queries table to have column: {}", c); + } + } + + #[test] + fn v6_queries_cascade_on_connection_delete() { + let conn = Connection::open_in_memory().unwrap(); + run_migrations(&conn).unwrap(); + // Insert a connection + let conn_id = "test-qc-conn"; + conn.execute( + "INSERT INTO connections (id, name, db_type, host, port, created_at, updated_at) VALUES (?1, 't', 'postgresql', 'h', 5432, datetime('now'), datetime('now'))", + rusqlite::params![conn_id], + ).unwrap(); + // Insert a saved query for that connection + conn.execute( + "INSERT INTO queries (id, connection_id, name, query_text, folder) VALUES ('q1', ?1, 'my query', 'SELECT 1', '')", + rusqlite::params![conn_id], + ).unwrap(); + // Insert a global saved query (connection_id NULL) + conn.execute( + "INSERT INTO queries (id, connection_id, name, query_text, folder) VALUES ('q2', NULL, 'global query', 'SELECT 2', '')", + [], + ).unwrap(); + // Delete connection — should cascade the non-NULL row + conn.execute("DELETE FROM connections WHERE id = ?1", rusqlite::params![conn_id]).unwrap(); + let count_scoped: i64 = conn + .query_row("SELECT COUNT(*) FROM queries WHERE connection_id = ?1", rusqlite::params![conn_id], |r| r.get(0)) + .unwrap(); + assert_eq!(count_scoped, 0, "Scoped saved query should be cascade-deleted"); + // NULL-saved query survives + let count_global: i64 = conn + .query_row("SELECT COUNT(*) FROM queries WHERE id = 'q2'", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count_global, 1, "Global saved query (connection_id NULL) should survive"); + } + + #[test] + fn v6_bumps_schema_version_to_6() { + let conn = Connection::open_in_memory().unwrap(); + run_migrations(&conn).unwrap(); + let ver: i64 = conn + .query_row( + "SELECT MAX(version) FROM schema_version", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(ver, 6, "Schema version should be 6 after v6 migration"); + } } \ No newline at end of file diff --git a/src-tauri/src/store/mod.rs b/src-tauri/src/store/mod.rs index 32a0af7..9b0abe5 100644 --- a/src-tauri/src/store/mod.rs +++ b/src-tauri/src/store/mod.rs @@ -11,6 +11,22 @@ pub struct Store { conn: Mutex, } +/// A saved query row (returned from the store). +#[derive(Debug, Clone)] +pub struct SavedQueryRow { + pub id: String, + pub connection_id: Option, + pub name: String, + pub query_text: String, + pub folder: String, + pub created_at: String, + pub updated_at: String, +} + +const MAX_NAME_LEN: usize = 200; +const MAX_FOLDER_LEN: usize = 100; +const MAX_QUERY_TEXT_LEN: usize = 1_048_576; // 1 MB + impl Store { pub fn from_connection(conn: SqliteConnection) -> Self { Self { @@ -449,6 +465,8 @@ impl Store { } /// Insert a row into the `query_history` table. + /// Dedups consecutive identical queries per connection (UPDATE the last row + /// instead of INSERTing a new one) and prunes to at most 500 rows per connection. pub fn insert_query_history( &self, id: &str, @@ -461,11 +479,46 @@ impl Store { ) -> Result<(), String> { let conn = self.conn.lock().map_err(|e| e.to_string())?; let now = Self::now(); + + // DEDUP: check last row for this connection + let last: Option<(String, String)> = { + let mut stmt = conn + .prepare( + "SELECT id, query_text FROM query_history + WHERE connection_id = ?1 + ORDER BY executed_at DESC LIMIT 1", + ) + .map_err(|e| e.to_string())?; + stmt.query_row(params![connection_id], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) + .ok() + }; + + if let Some((existing_id, existing_text)) = last { + if existing_text == query_text { + // Consecutive identical — UPDATE the existing row + conn.execute( + "UPDATE query_history SET execution_time_ms = ?1, row_count = ?2, status = ?3, error_message = ?4, executed_at = ?5 WHERE id = ?6", + params![execution_time_ms, row_count, status, error_message, now, existing_id], + ) + .map_err(|e| e.to_string())?; + // Still run pruning in case updates shifted retention needs + prune_query_history(&conn, connection_id, 500)?; + return Ok(()); + } + } + + // INSERT new row conn.execute( "INSERT INTO query_history (id, connection_id, query_text, execution_time_ms, row_count, status, error_message, executed_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", params![id, connection_id, query_text, execution_time_ms, row_count, status, error_message, now], ) .map_err(|e| e.to_string())?; + + // PRUNING: keep at most `max_rows` per connection + prune_query_history(&conn, connection_id, 500)?; + Ok(()) } @@ -481,12 +534,12 @@ impl Store { let (sql, params): (String, Vec>) = if let Some(cid) = connection_id { ( - "SELECT id, connection_id, query_text, execution_time_ms, row_count, status, error_message, executed_at FROM query_history WHERE connection_id = ?1 ORDER BY executed_at DESC LIMIT ?2 OFFSET ?3".to_string(), + "SELECT id, connection_id, query_text, execution_time_ms, row_count, status, error_message, executed_at, favorite FROM query_history WHERE connection_id = ?1 ORDER BY executed_at DESC LIMIT ?2 OFFSET ?3".to_string(), vec![Box::new(cid.to_string()), Box::new(limit), Box::new(offset)], ) } else { ( - "SELECT id, connection_id, query_text, execution_time_ms, row_count, status, error_message, executed_at FROM query_history ORDER BY executed_at DESC LIMIT ?1 OFFSET ?2".to_string(), + "SELECT id, connection_id, query_text, execution_time_ms, row_count, status, error_message, executed_at, favorite FROM query_history ORDER BY executed_at DESC LIMIT ?1 OFFSET ?2".to_string(), vec![Box::new(limit), Box::new(offset)], ) }; @@ -503,6 +556,7 @@ impl Store { status: row.get(5)?, error_message: row.get(6)?, executed_at: row.get(7)?, + favorite: row.get::<_, i64>(8)? != 0, // convert INTEGER to bool }) }) .map_err(|e| e.to_string())?; @@ -524,6 +578,179 @@ impl Store { } Ok(()) } + + /// Toggle the `favorite` flag for a query history entry. + /// Returns an error if no row with the given id + connection_id exists. + pub fn set_history_favorite(&self, id: &str, connection_id: &str) -> Result<(), String> { + let conn = self.conn.lock().map_err(|e| e.to_string())?; + let affected = conn + .execute( + "UPDATE query_history SET favorite = 1 - favorite WHERE id = ?1 AND connection_id = ?2", + params![id, connection_id], + ) + .map_err(|e| e.to_string())?; + if affected == 0 { + return Err("History entry not found".to_string()); + } + Ok(()) + } + + pub fn save_query( + &self, + connection_id: Option<&str>, + name: &str, + query_text: &str, + folder: &str, + ) -> Result { + if name.is_empty() || name.len() > MAX_NAME_LEN { + return Err("Name must be 1–200 characters".to_string()); + } + if folder.len() > MAX_FOLDER_LEN { + return Err("Folder must be ≤100 characters".to_string()); + } + if query_text.len() > MAX_QUERY_TEXT_LEN { + return Err("Query text must be ≤1 MB".to_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 queries (id, connection_id, name, query_text, folder, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![id, connection_id, name, query_text, folder, now, now], + ) + .map_err(|e| e.to_string())?; + + Ok(SavedQueryRow { + id, + connection_id: connection_id.map(|s| s.to_string()), + name: name.to_string(), + query_text: query_text.to_string(), + folder: folder.to_string(), + created_at: now.clone(), + updated_at: now, + }) + } + + pub fn list_saved_queries( + &self, + connection_id: Option<&str>, + ) -> Result, String> { + let conn = self.conn.lock().map_err(|e| e.to_string())?; + let (sql, params_vec): (String, Vec>) = + if let Some(cid) = connection_id { + ( + "SELECT id, connection_id, name, query_text, folder, created_at, updated_at FROM queries WHERE connection_id = ?1 ORDER BY updated_at DESC".to_string(), + vec![Box::new(cid.to_string())], + ) + } else { + ( + "SELECT id, connection_id, name, query_text, folder, created_at, updated_at FROM queries ORDER BY updated_at DESC".to_string(), + vec![], + ) + }; + let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?; + let refs: Vec<&dyn rusqlite::types::ToSql> = params_vec.iter().map(|p| p.as_ref()).collect(); + let rows = stmt + .query_map(rusqlite::params_from_iter(&refs), |row| { + Ok(SavedQueryRow { + id: row.get(0)?, + connection_id: row.get(1)?, + name: row.get(2)?, + query_text: row.get(3)?, + folder: row.get(4)?, + created_at: row.get(5)?, + updated_at: row.get(6)?, + }) + }) + .map_err(|e| e.to_string())?; + rows.collect::, _>>().map_err(|e| e.to_string()) + } + + pub fn update_saved_query( + &self, + id: &str, + name: Option<&str>, + query_text: Option<&str>, + folder: Option<&str>, + ) -> Result<(), String> { + let conn = self.conn.lock().map_err(|e| e.to_string())?; + let now = Self::now(); + // Build dynamic SET clauses + let mut sets: Vec = vec!["updated_at = ?1".to_string()]; + let mut params: Vec> = vec![Box::new(now)]; + let mut idx = 2i32; // next param index (after ?1 for updated_at) + + if let Some(n) = name { + if n.is_empty() || n.len() > MAX_NAME_LEN { + return Err("Name must be 1–200 characters".to_string()); + } + sets.push(format!("name = ?{idx}")); + params.push(Box::new(n.to_string())); + idx += 1; + } + if let Some(qt) = query_text { + if qt.len() > MAX_QUERY_TEXT_LEN { + return Err("Query text must be ≤1 MB".to_string()); + } + sets.push(format!("query_text = ?{idx}")); + params.push(Box::new(qt.to_string())); + idx += 1; + } + if let Some(f) = folder { + if f.len() > MAX_FOLDER_LEN { + return Err("Folder must be ≤100 characters".to_string()); + } + sets.push(format!("folder = ?{idx}")); + params.push(Box::new(f.to_string())); + idx += 1; + } + + let sql = format!( + "UPDATE queries SET {} WHERE id = ?{idx}", + sets.join(", "), + ); + let mut all_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + let id_param: Box = Box::new(id.to_string()); + all_refs.push(id_param.as_ref()); + + conn.execute(&sql, rusqlite::params_from_iter(&all_refs)) + .map_err(|e| e.to_string())?; + Ok(()) + } + + pub fn delete_saved_query(&self, id: &str) -> Result<(), String> { + let conn = self.conn.lock().map_err(|e| e.to_string())?; + conn.execute("DELETE FROM queries WHERE id = ?1", params![id]) + .map_err(|e| e.to_string())?; + Ok(()) + } +} + +/// Delete oldest rows for a connection, keeping at most `max_rows`. +fn prune_query_history( + conn: &rusqlite::Connection, + connection_id: &str, + max_rows: i64, +) -> Result<(), String> { + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM query_history WHERE connection_id = ?1", + params![connection_id], + |r| r.get(0), + ) + .map_err(|e| e.to_string())?; + if count > max_rows { + conn.execute( + "DELETE FROM query_history WHERE connection_id = ?1 AND id NOT IN ( + SELECT id FROM query_history WHERE connection_id = ?1 + ORDER BY executed_at DESC LIMIT ?2 + )", + params![connection_id, max_rows], + ) + .map_err(|e| e.to_string())?; + } + Ok(()) } #[cfg(test)] @@ -816,4 +1043,298 @@ mod tests { Some("/etc/ssl/private/client-key.pem") ); } + + #[test] + fn insert_query_history_dedups_consecutive_identical() { + let store = fresh_store(); + // Insert connection needed for FK + let conn = store + .create_connection(ConnectionInput { + name: "dedup-conn".into(), + db_type: "postgresql".into(), + host: "localhost".into(), + port: Some(5432), + username: None, + folder_id: None, + password: None, + database: Some("public".into()), + 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(); + + // First insert + store + .insert_query_history("h1", &conn.id, "SELECT 1", Some(10), Some(5), "success", None) + .unwrap(); + // Consecutive identical — should UPDATE, not INSERT + store + .insert_query_history("h2", &conn.id, "SELECT 1", Some(20), Some(8), "success", None) + .unwrap(); + // There should still be 1 row (not 2), with updated stats + let rows = store.get_query_history(Some(&conn.id), 10, 0).unwrap(); + assert_eq!(rows.len(), 1, "Consecutive identical queries should dedup to one row"); + assert_eq!(rows[0].execution_time_ms, Some(20), "Stats should update after dedup"); + assert_eq!(rows[0].id, "h1", "Original ID should persist after dedup"); + + // Different query — should INSERT a new row + store + .insert_query_history("h3", &conn.id, "SELECT 2", Some(5), Some(0), "success", None) + .unwrap(); + let rows2 = store.get_query_history(Some(&conn.id), 10, 0).unwrap(); + assert_eq!(rows2.len(), 2, "Different query should create a new row"); + assert_eq!(rows2[0].id, "h3", "Most recent row should be the new one"); + } + + #[test] + fn insert_query_history_prunes_oldest_beyond_500() { + let store = fresh_store(); + let conn = store + .create_connection(ConnectionInput { + name: "prune-conn".into(), + db_type: "postgresql".into(), + host: "localhost".into(), + port: Some(5432), + username: None, + folder_id: None, + password: None, + database: Some("public".into()), + 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(); + // Insert 510 rows — should trigger pruning beyond 500 + for i in 0..510 { + store + .insert_query_history( + &format!("ph{}", i), + &conn.id, + &format!("SELECT {}", i), + Some(1), + Some(1), + "success", + None, + ) + .unwrap(); + } + let rows = store.get_query_history(Some(&conn.id), 1000, 0).unwrap(); + assert_eq!(rows.len(), 500, "Should be pruned to 500 rows"); + // Oldest rows (ph0..ph9) should be pruned; most recent (ph509) kept + let all_ids: Vec = rows.iter().map(|r| r.id.clone()).collect(); + assert!(!all_ids.contains(&"ph0".to_string()), "Oldest rows should be pruned"); + assert!(all_ids.contains(&"ph509".to_string()), "Most recent rows should be kept"); + } + + #[test] + fn get_query_history_includes_favorite_column() { + let store = fresh_store(); + let conn = store + .create_connection(ConnectionInput { + name: "fav-conn".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![], + }) + .unwrap(); + store + .insert_query_history("fh1", &conn.id, "SELECT 1", Some(5), Some(1), "success", None) + .unwrap(); + let rows = store.get_query_history(Some(&conn.id), 10, 0).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].favorite, false, "Default favorite should be false"); + } + + #[test] + fn set_history_favorite_toggles() { + let store = fresh_store(); + let conn = store + .create_connection(ConnectionInput { + name: "ft-conn".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![], + }) + .unwrap(); + store + .insert_query_history("ft1", &conn.id, "SELECT 1", Some(5), Some(1), "success", None) + .unwrap(); + + // Toggle on + store.set_history_favorite("ft1", &conn.id).unwrap(); + let rows = store.get_query_history(Some(&conn.id), 10, 0).unwrap(); + assert_eq!(rows[0].favorite, true); + + // Toggle off + store.set_history_favorite("ft1", &conn.id).unwrap(); + let rows2 = store.get_query_history(Some(&conn.id), 10, 0).unwrap(); + assert_eq!(rows2[0].favorite, false); + } + + #[test] + fn set_history_favorite_unknown_id_returns_error() { + let store = fresh_store(); + let result = store.set_history_favorite("nonexistent", "any-conn"); + assert!(result.is_err(), "Unknown id should be an error"); + } + + fn create_test_connection(store: &Store, name: &str) -> crate::models::Connection { + store + .create_connection(ConnectionInput { + name: name.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![], + }) + .unwrap() + } + + #[test] + fn save_and_list_saved_queries() { + let store = fresh_store(); + let conn = create_test_connection(&store, "sq-conn"); + + let saved = store + .save_query(Some(&conn.id), "My Query", "SELECT 1", "reports") + .unwrap(); + assert_eq!(saved.name, "My Query"); + assert_eq!(saved.query_text, "SELECT 1"); + assert_eq!(saved.folder, "reports"); + assert_eq!(saved.connection_id, Some(conn.id.clone())); + + let list = store.list_saved_queries(Some(&conn.id)).unwrap(); + assert_eq!(list.len(), 1); + assert_eq!(list[0].id, saved.id); + } + + #[test] + fn save_global_saved_query() { + let store = fresh_store(); + let saved = store + .save_query(None, "Global Query", "SELECT version()", "") + .unwrap(); + assert_eq!(saved.connection_id, None); + let list = store.list_saved_queries(None).unwrap(); + assert!(list.iter().any(|q| q.id == saved.id)); + } + + #[test] + fn update_and_delete_saved_query() { + let store = fresh_store(); + let conn = create_test_connection(&store, "ud-conn"); + let saved = store + .save_query(Some(&conn.id), "Original", "SELECT 1", "") + .unwrap(); + + // Update name + store + .update_saved_query(&saved.id, Some("Renamed"), None, None) + .unwrap(); + let after = store.list_saved_queries(Some(&conn.id)).unwrap(); + assert_eq!(after[0].name, "Renamed"); + + // Update query text + store + .update_saved_query(&saved.id, None, Some("SELECT 2"), None) + .unwrap(); + let after2 = store.list_saved_queries(Some(&conn.id)).unwrap(); + assert_eq!(after2[0].query_text, "SELECT 2"); + + // Delete + store.delete_saved_query(&saved.id).unwrap(); + let empty = store.list_saved_queries(Some(&conn.id)).unwrap(); + assert!(empty.is_empty()); + } + + #[test] + fn saved_query_name_validated() { + let store = fresh_store(); + // Name > 200 chars should fail + let long_name = "a".repeat(201); + let result = store.save_query(None, &long_name, "SELECT 1", ""); + assert!(result.is_err(), "Over-long name should be rejected"); + } + + #[test] + fn saved_query_folder_validated() { + let store = fresh_store(); + let long_folder = "b".repeat(101); + let result = store.save_query(None, "ok", "SELECT 1", &long_folder); + assert!(result.is_err(), "Over-long folder should be rejected"); + } + + #[test] + fn saved_query_text_size_validated() { + let store = fresh_store(); + let huge_text = "x".repeat(1_048_577); // 1MB + 1 byte + let result = store.save_query(None, "ok", &huge_text, ""); + assert!(result.is_err(), "Over-size query text should be rejected"); + } } \ No newline at end of file diff --git a/src/components/db-viewer/DbViewerScreen.tsx b/src/components/db-viewer/DbViewerScreen.tsx index 3a696c6..eb6085e 100644 --- a/src/components/db-viewer/DbViewerScreen.tsx +++ b/src/components/db-viewer/DbViewerScreen.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState, Suspense, lazy } from "react"; -import { ChevronDown, ChevronUp } from "lucide-react"; +import { ChevronDown, ChevronUp, Table2, Terminal } from "lucide-react"; import { format as formatSql } from "sql-formatter"; import { TooltipProvider } from "../ui/Tooltip"; import { DbViewerSidebar } from "./DbViewerSidebar"; @@ -25,10 +25,10 @@ import { useConnectionStore } from "../../stores/connectionStore"; import { useSettingsStore } from "../../stores/settingsStore"; import { useShortcut } from "../../hooks/useShortcut"; import { ConnectionDropBanner } from "./ConnectionDropBanner"; -import { BackupPage } from "./BackupPage"; -import { RestorePage } from "./RestorePage"; -import { SyncPage } from "./SyncPage"; +import { ToolsPage } from "./ToolsPage"; import { SchemaVisualizerPage } from "./SchemaVisualizerPage"; +import { QueriesPanel } from "../queries/QueriesPanel"; +import { useQueryStore } from "../../stores/queryStore"; import * as cmd from "../../lib/commands"; export interface DbViewerScreenProps { @@ -46,6 +46,7 @@ export function DbViewerScreen({ const [dismissedError, setDismissedError] = useState(null); const [currentView, setCurrentView] = useState("db-viewer"); const [tablePanelWidth, setTablePanelWidth] = useState(280); + const [queriesPanelWidth, setQueriesPanelWidth] = useState(280); const [searchQuery, setSearchQuery] = useState(""); const [selectedRows, setSelectedRows] = useState>(new Set()); const [editModalOpen, setEditModalOpen] = useState(false); @@ -70,6 +71,10 @@ export function DbViewerScreen({ const panelResizeRef = useRef<{ startX: number; startW: number } | null>( null, ); + const queriesPanelResizeRef = useRef<{ + startX: number; + startW: number; + } | null>(null); const activeTab = useDbViewerStore((s) => { if (!s.activeTabId) return null; @@ -124,8 +129,10 @@ export function DbViewerScreen({ try { const result = await executeQuery(connectionId, sql, tab.page, tab.pageSize); setTabData(tabId, result); + useQueryStore.getState().invalidateHistory(connectionId); } catch (e) { setTabError(tabId, e instanceof Error ? e.message : String(e)); + useQueryStore.getState().invalidateHistory(connectionId); } } @@ -168,6 +175,54 @@ export function DbViewerScreen({ } }, [currentConnection?.db_type]); + // Restore SQL from history/saved panel: fill active query tab or open a new one + const handleRestoreSql = useCallback((sql: string) => { + const state = useDbViewerStore.getState(); + const tab = state.tabs.find((t) => t.id === state.activeTabId); + if (tab && tab.tabType === "query") { + useDbViewerStore.setState((s) => ({ + tabs: s.tabs.map((t) => + t.id === tab.id ? { ...t, query: sql } : t, + ), + })); + } else { + state.openQueryTab(); + requestAnimationFrame(() => { + const ns = useDbViewerStore.getState(); + const nt = ns.tabs.find((t) => t.id === ns.activeTabId); + if (nt) { + useDbViewerStore.setState((s) => ({ + tabs: s.tabs.map((t) => + t.id === nt.id ? { ...t, query: sql } : t, + ), + })); + } + }); + } + }, []); + + // Run SQL from history/saved panel: always open a new tab and execute + const handleRunFromHistory = useCallback((sql: string) => { + const state = useDbViewerStore.getState(); + state.openQueryTab(); + requestAnimationFrame(() => { + const ns = useDbViewerStore.getState(); + const nt = ns.tabs.find((t) => t.id === ns.activeTabId); + if (nt) { + useDbViewerStore.setState((s) => ({ + tabs: s.tabs.map((t) => + t.id === nt.id ? { ...t, query: sql } : t, + ), + })); + if (isDestructiveQuery(sql)) { + setDestructiveQuery(sql); + } else { + executeQueryForTab(nt.id, sql); + } + } + }); + }, [connectionId]); + // Cmd+W / Ctrl+W: close current tab, or navigate home if no tabs (configurable in Settings → Shortcuts) useShortcut("close_tab", () => { const state = useDbViewerStore.getState(); @@ -488,6 +543,36 @@ export function DbViewerScreen({ [tablePanelWidth], ); +const onQueriesPanelResizeStart = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + queriesPanelResizeRef.current = { + startX: e.clientX, + startW: queriesPanelWidth, + }; + const onMove = (ev: MouseEvent) => { + if (!queriesPanelResizeRef.current) return; + const w = Math.max( + 180, + Math.min( + 600, + queriesPanelResizeRef.current.startW + + (ev.clientX - queriesPanelResizeRef.current.startX), + ), + ); + setQueriesPanelWidth(w); + }; + const onUp = () => { + queriesPanelResizeRef.current = null; + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }, + [queriesPanelWidth], + ); + // Query results panel: collapsible + resizable (min 120px, max 80% of column) const queryColumnRef = useRef(null); const resultsResizeRef = useRef<{ startY: number; startH: number } | null>( @@ -541,58 +626,24 @@ export function DbViewerScreen({ const activeSchema = activeTab?.schema ?? ""; const activeTable = activeTab?.table ?? ""; - return ( - -
- -
- {connectionError && connectionError !== dismissedError && ( - { - setDismissedError(null); - connect(); - }} - onDismiss={() => setDismissedError(connectionError)} - /> - )} - {currentView === "db-viewer" ? ( -
-
- setEditModalOpen(true)} - connectionId={connectionId} - searchQuery={searchQuery} - onSearchChange={setSearchQuery} - /> -
- -
-
- {/* panel resize handle */} -
setTablePanelWidth(280)} - /> + function renderQueryWorkspace() { + return (
- {activeTab?.tabType === "query" ? ( + {!activeTab ? ( +
+ {currentView === "queries" ? ( + + ) : ( + + )} + + {currentView === "queries" + ? "Open a new query tab or run a query from the history" + : "Select a table from the tree to browse its data, or open a new query tab"} + +
+ ) : activeTab?.tabType === "query" ? ( @@ -604,6 +655,9 @@ export function DbViewerScreen({
@@ -922,39 +976,78 @@ export function DbViewerScreen({ )}
+ ); + } + + return ( + +
+ +
+ {connectionError && connectionError !== dismissedError && ( + { + setDismissedError(null); + connect(); + }} + onDismiss={() => setDismissedError(connectionError)} + /> + )} + {currentView === "db-viewer" ? ( +
+
+ setEditModalOpen(true)} + connectionId={connectionId} + searchQuery={searchQuery} + onSearchChange={setSearchQuery} + /> +
+ +
+
+ {/* panel resize handle */} +
setTablePanelWidth(280)} + /> + {renderQueryWorkspace()} +
+ ) : currentView === "objects" ? ( + + ) : currentView === "tools" ? ( + + ) : currentView === "queries" ? ( +
+ +
setQueriesPanelWidth(280)} + /> + {renderQueryWorkspace()}
- ) : currentView === "functions" ? ( - - ) : currentView === "triggers" ? ( - - ) : currentView === "sequences" ? ( - - ) : currentView === "enums" ? ( - - ) : currentView === "extensions" ? ( - - ) : currentView === "backup" ? ( - - ) : currentView === "restore" ? ( - - ) : currentView === "sync" ? ( - ) : currentView === "schema-visualizer" ? ( ) : null} - {currentView === "db-viewer" && } + {(currentView === "db-viewer" || currentView === "queries") && }
{currentConnection && ( { expect(btn).toBeInTheDocument(); expect(btn).not.toBeDisabled(); }); + + it("renders Queries nav item", () => { + render( + + {}} /> + + ); + expect(screen.getByLabelText("Queries")).toBeInTheDocument(); + }); + + it("renders Tools nav item", () => { + render( + + {}} /> + + ); + expect(screen.getByLabelText("Tools")).toBeInTheDocument(); + }); }); \ No newline at end of file diff --git a/src/components/db-viewer/DbViewerSidebar.tsx b/src/components/db-viewer/DbViewerSidebar.tsx index 7e9fbca..5f30e59 100644 --- a/src/components/db-viewer/DbViewerSidebar.tsx +++ b/src/components/db-viewer/DbViewerSidebar.tsx @@ -1,16 +1,11 @@ import { - ArrowLeftRight, + Boxes, + Clock, Database, - Download, - FunctionSquare, - GitBranch, - Grid2x2, + DatabaseBackup, Home, - ListOrdered, - Puzzle, Settings, - Tag, - Upload, + Share2, } from "lucide-react"; import { Tooltip } from "../ui/Tooltip"; @@ -32,31 +27,18 @@ export function DbViewerSidebar({ }: DbViewerSidebarProps) { const topItems: NavItem[] = [ { id: "db-viewer", label: "Explorer", icon: }, + { + id: "queries", + label: "Queries", + icon: , + }, { id: "schema-visualizer", label: "Schema Visualizer", - icon: , - }, - { - id: "functions", - label: "Functions", - icon: , - }, - { id: "triggers", label: "Triggers", icon: }, - { - id: "sequences", - label: "Sequences", - icon: , - }, - { id: "enums", label: "Enums", icon: }, - { id: "extensions", label: "Extensions", icon: }, - { id: "backup", label: "Backup", icon: }, - { id: "restore", label: "Restore", icon: }, - { - id: "sync", - label: "DB Sync", - icon: , + icon: , }, + { id: "objects", label: "Objects", icon: }, + { id: "tools", label: "Tools", icon: }, ]; const bottomItems: NavItem[] = [ diff --git a/src/components/db-viewer/ObjectExplorerPage.tsx b/src/components/db-viewer/ObjectExplorerPage.tsx index f8cca1e..5385cea 100644 --- a/src/components/db-viewer/ObjectExplorerPage.tsx +++ b/src/components/db-viewer/ObjectExplorerPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useMemo, useCallback, useRef } from "react"; +import { useEffect, useState, useMemo, useCallback, useRef, cloneElement } from "react"; import { ChevronRight, FunctionSquare, @@ -29,7 +29,6 @@ export type ObjectType = | "extensions"; interface ObjectExplorerPageProps { - type: ObjectType; connectionId: string; } @@ -41,6 +40,10 @@ const TYPE_LABELS: Record = { extensions: "Extensions", }; +const OBJECT_TYPE_OPTIONS = (Object.keys(TYPE_LABELS) as ObjectType[]).map( + (t) => ({ value: t, label: TYPE_LABELS[t] }), +); + const SINGULAR_LABELS: Record = { functions: "function", triggers: "trigger", @@ -805,10 +808,8 @@ function renderDetail(type: ObjectType, item: AnyObject) { } } -export function ObjectExplorerPage({ - type, - connectionId, -}: ObjectExplorerPageProps) { +export function ObjectExplorerPage({ connectionId }: ObjectExplorerPageProps) { + const [type, setType] = useState("functions"); const [panelWidth, setPanelWidth] = useState(280); const panelResizeRef = useRef<{ startX: number; startW: number } | null>( null, @@ -943,6 +944,18 @@ export function ObjectExplorerPage({ const label = TYPE_LABELS[type]; const singular = SINGULAR_LABELS[type]; + // Switching object type: reset selection/search, clear the stale list so + // the loading state renders (no flash of the previous type's objects), and + // reset the last-fetched-schema marker so the fetch effect re-runs. + const handleTypeChange = (next: ObjectType) => { + setType(next); + setSearchQuery(""); + setSelectedItem(null); + setItems(null); + setLoading(true); + lastSchemaRef.current = undefined; + }; + return (
{/* Left panel: toolbar + object list */} @@ -952,9 +965,13 @@ export function ObjectExplorerPage({ >
- - {label} - + handleTypeChange(v as ObjectType)} + options={OBJECT_TYPE_OPTIONS} + variant="ghost" + aria-label="Object type" + />
+ + + {open && ( +
+ {loading && history === null && ( +
+
+ )} + {historyError && !loading && ( +
+ {historyError} +
+ )} + {!loading && !historyError && (!history || history.length === 0) && ( +
+ No queries yet +
+ )} + {!loading && !historyError && history && history.length > 0 && ( + <> + {history.slice(0, 50).map((entry) => ( +
+
+
+ {entry.query_text} +
+
+ {entry.status === "error" ? ( + Error + ) : ( + <> + {entry.execution_time_ms != null && ( + {entry.execution_time_ms}ms + )} + {entry.row_count != null && ( + {entry.row_count} rows + )} + + )} +
+
+
+ + + + + + + +
+
+ ))} + {/* Clear History footer */} + + + )} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/components/editor/QueryToolbar.test.tsx b/src/components/editor/QueryToolbar.test.tsx index 1a51f96..de26a98 100644 --- a/src/components/editor/QueryToolbar.test.tsx +++ b/src/components/editor/QueryToolbar.test.tsx @@ -8,6 +8,9 @@ function renderToolbar(props: { onRun?: () => void; onFormat?: () => void; dbType?: "postgresql" | "mysql" | "sqlite" | "redis"; + connectionId?: string; + onRestore?: (sql: string) => void; + onRunFromHistory?: (sql: string) => void; }) { return render( @@ -15,6 +18,9 @@ function renderToolbar(props: { onRun={props.onRun ?? (() => {})} onFormat={props.onFormat ?? (() => {})} dbType={props.dbType} + connectionId={props.connectionId ?? "conn-1"} + onRestore={props.onRestore ?? (() => {})} + onRunFromHistory={props.onRunFromHistory ?? (() => {})} /> , ); @@ -136,4 +142,26 @@ describe("QueryToolbar", () => { const button = screen.getByRole("button", { name: /auto format/i }); expect(button.textContent?.trim()).toBe(""); }); + + it("renders History icon button", () => { + renderToolbar({}); + expect(screen.getByLabelText("Query history")).toBeInTheDocument(); + }); + + it("renders Save Query icon button", () => { + renderToolbar({}); + expect(screen.getByLabelText("Save query")).toBeInTheDocument(); + }); + + it("orders toolbar actions: Run, History, Format, Save", () => { + renderToolbar({}); + const container = screen.getByLabelText("Query toolbar actions"); + const buttons = Array.from(container.querySelectorAll("button")); + const btnLabels = buttons.map((b) => b.getAttribute("aria-label")); + // Run Query first, then History, then Auto Format, then Save + expect(btnLabels[0]).toBe("Run query"); + expect(btnLabels[1]).toBe("Query history"); + expect(btnLabels[2]).toBe("Auto format query"); + expect(btnLabels[3]).toBe("Save query"); + }); }); \ No newline at end of file diff --git a/src/components/editor/QueryToolbar.tsx b/src/components/editor/QueryToolbar.tsx index f890721..085e882 100644 --- a/src/components/editor/QueryToolbar.tsx +++ b/src/components/editor/QueryToolbar.tsx @@ -1,5 +1,8 @@ -import { Play, Wand2 } from "lucide-react"; +import { useState } from "react"; +import { Play, Wand2, Save } from "lucide-react"; import { Tooltip } from "../ui/Tooltip"; +import { QueryHistoryDropdown } from "./QueryHistoryDropdown"; +import { SaveQueryDialog } from "./SaveQueryDialog"; import { useDbViewerStore } from "../../stores/dbViewerStore"; import type { DbType } from "../../lib/types"; @@ -28,6 +31,9 @@ export function queryShortcut(platform: string): { interface QueryToolbarProps { onRun: () => void; onFormat: () => void; + connectionId: string; + onRestore: (sql: string) => void; + onRunFromHistory: (sql: string) => void; dbType?: DbType; readOnly?: boolean; } @@ -35,12 +41,19 @@ interface QueryToolbarProps { export function QueryToolbar({ onRun, onFormat, + connectionId, + onRestore, + onRunFromHistory, dbType, readOnly = false, }: QueryToolbarProps) { const tabs = useDbViewerStore((s) => s.tabs); const activeTabId = useDbViewerStore((s) => s.activeTabId); const isRunning = tabs.find((t) => t.id === activeTabId)?.loading ?? false; + const [saveDialogOpen, setSaveDialogOpen] = useState(false); + + const activeTab = tabs.find((t) => t.id === activeTabId); + const currentQueryText = activeTab?.query ?? ""; const shortcut = queryShortcut( typeof navigator !== "undefined" ? navigator.platform : "", @@ -56,7 +69,10 @@ export function QueryToolbar({ className="absolute inset-0 pointer-events-none animate-toolbar-pulse bg-accent" /> )} -
+
{/* Run Query: outline play that fills on hover; tooltip (delayed) reveals the shortcut */} + {/* History dropdown */} + + {/* Auto format: icon only with tooltip */} + + {/* Save Query icon */} + + +
{dbType && ( @@ -105,6 +145,13 @@ export function QueryToolbar({
)} + + setSaveDialogOpen(false)} + connectionId={connectionId} + queryText={currentQueryText} + />
); } \ No newline at end of file diff --git a/src/components/editor/SaveQueryDialog.test.tsx b/src/components/editor/SaveQueryDialog.test.tsx new file mode 100644 index 0000000..24898d4 --- /dev/null +++ b/src/components/editor/SaveQueryDialog.test.tsx @@ -0,0 +1,98 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { SaveQueryDialog } from "./SaveQueryDialog"; +import { useQueryStore } from "../../stores/queryStore"; + +vi.mock("../../stores/queryStore", () => ({ + useQueryStore: vi.fn(), +})); + +const mockSaveCurrentQuery = vi.fn(); + +function setStoreMock() { + (useQueryStore as unknown as ReturnType).mockImplementation( + (selector: (s: Record) => unknown) => + selector({ + saveCurrentQuery: mockSaveCurrentQuery, + }), + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + setStoreMock(); + mockSaveCurrentQuery.mockResolvedValue({ id: "q-new" }); +}); + +describe("SaveQueryDialog", () => { + it("renders nothing when closed", () => { + render( + {}} + connectionId="c1" + queryText="SELECT 1" + />, + ); + expect(screen.queryByText(/save query/i)).not.toBeInTheDocument(); + }); + + it("shows form when open", () => { + render( + {}} + connectionId="c1" + queryText="SELECT 1" + />, + ); + expect(screen.getByText(/save query/i)).toBeInTheDocument(); + expect(screen.getByPlaceholderText(/query name/i)).toBeInTheDocument(); + expect(screen.getByPlaceholderText(/folder/i)).toBeInTheDocument(); + }); + + it("blocks save when name is empty", async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + render( + , + ); + await user.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => { + expect(screen.getByText(/name is required/i)).toBeInTheDocument(); + }); + expect(mockSaveCurrentQuery).not.toHaveBeenCalled(); + }); + + it("saves with name + folder and closes", async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + render( + , + ); + await user.type(screen.getByPlaceholderText(/query name/i), "My Query"); + await user.type(screen.getByPlaceholderText(/folder/i), "reports"); + await user.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => { + expect(mockSaveCurrentQuery).toHaveBeenCalledWith({ + connectionId: "c1", + name: "My Query", + queryText: "SELECT 1", + folder: "reports", + }); + }); + expect(onClose).toHaveBeenCalled(); + }); + + it("closes on cancel", async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + render( + , + ); + await user.click(screen.getByText(/cancel/i)); + expect(onClose).toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/src/components/editor/SaveQueryDialog.tsx b/src/components/editor/SaveQueryDialog.tsx new file mode 100644 index 0000000..8254c40 --- /dev/null +++ b/src/components/editor/SaveQueryDialog.tsx @@ -0,0 +1,101 @@ +import { useState } from "react"; +import { AnimatedModal } from "../ui/AnimatedModal"; +import { Input } from "../ui/Input"; +import { Button } from "../ui/Button"; +import { useQueryStore } from "../../stores/queryStore"; + +interface SaveQueryDialogProps { + open: boolean; + onClose: () => void; + connectionId: string; + queryText: string; +} + +export function SaveQueryDialog({ + open, + onClose, + connectionId, + queryText, +}: SaveQueryDialogProps) { + const [name, setName] = useState(""); + const [folder, setFolder] = useState(""); + const [error, setError] = useState(""); + const [saving, setSaving] = useState(false); + const saveCurrentQuery = useQueryStore((s) => s.saveCurrentQuery); + + const handleSave = async () => { + const trimmed = name.trim(); + if (!trimmed) { + setError("Name is required"); + return; + } + setSaving(true); + setError(""); + try { + await saveCurrentQuery({ + connectionId, + name: trimmed, + queryText, + folder: folder.trim(), + }); + // Reset form on success + setName(""); + setFolder(""); + onClose(); + } catch (e) { + setError(e instanceof Error ? e.message : "Save failed"); + } finally { + setSaving(false); + } + }; + + const handleClose = () => { + setError(""); + setName(""); + setFolder(""); + onClose(); + }; + + if (!open) return null; + + return ( + +
+

Save Query

+

Save the current query for later use.

+ +
+
+ + { setName(v); if (error) setError(""); }} + aria-label="Query name" + /> +
+
+ + +
+
+ + {error && ( +

{error}

+ )} + +
+ + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/queries/QueriesPanel.test.tsx b/src/components/queries/QueriesPanel.test.tsx new file mode 100644 index 0000000..b1d35a1 --- /dev/null +++ b/src/components/queries/QueriesPanel.test.tsx @@ -0,0 +1,225 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { ReactElement } from "react"; +import { QueriesPanel } from "./QueriesPanel"; +import { useQueryStore } from "../../stores/queryStore"; +import { TooltipProvider } from "../ui/Tooltip"; + +vi.mock("../../stores/queryStore", () => ({ + useQueryStore: vi.fn(), +})); + +type MockStore = Record; + +function setMocks(historyOverrides = {}, savedOverrides = {}) { + const store: MockStore = { + history: [], + historyLoading: false, + historyError: null, + historyStale: false, + historySearch: "", + favoritesOnly: false, + savedQueries: [], + savedLoading: false, + savedError: null, + loadHistory: vi.fn(), + clearHistory: vi.fn(), + toggleFavorite: vi.fn(), + setHistorySearch: vi.fn(), + setFavoritesOnly: vi.fn(), + loadSavedQueries: vi.fn(), + renameSavedQuery: vi.fn(), + deleteSavedQuery: vi.fn(), + ...historyOverrides, + ...savedOverrides, + }; + (useQueryStore as unknown as ReturnType).mockImplementation( + (sel: (s: MockStore) => unknown) => sel(store), + ); + return store; +} + +function renderPanel(ui: ReactElement) { + return render({ui}); +} + +beforeEach(() => { + vi.clearAllMocks(); + setMocks(); +}); + +describe("QueriesPanel", () => { + it("renders Queries header with History/Saved dropdown", () => { + renderPanel( + {}} />, + ); + expect(screen.getByText("Queries")).toBeInTheDocument(); + expect(screen.getByLabelText("History/Saved")).toBeInTheDocument(); + // Dropdown shows the current value (History by default) + expect(screen.getByText("History")).toBeInTheDocument(); + }); + + it("switches between History and Saved Queries via the dropdown", async () => { + const user = userEvent.setup(); + setMocks({}, { + savedQueries: [ + { id: "q1", connection_id: "c1", name: "My Saved", query_text: "SELECT 2", folder: "", created_at: "", updated_at: "" }, + ], + savedLoading: false, + }); + renderPanel( + {}} />, + ); + await user.click(screen.getByLabelText("History/Saved")); + await user.click(screen.getByText("Saved Queries")); + await waitFor(() => { + expect(screen.getByText("My Saved")).toBeInTheDocument(); + }); + }); + + it("shows empty state when no history", async () => { + setMocks({ history: [], historyStale: false }); + renderPanel( + {}} />, + ); + await waitFor(() => { + expect(screen.getByText(/no queries yet/i)).toBeInTheDocument(); + }); + }); + + it("loads history scoped to the connection on mount", async () => { + const store = setMocks({ historyStale: true }); + renderPanel( + {}} />, + ); + await waitFor(() => { + expect(store.loadHistory).toHaveBeenCalledWith("c1"); + }); + }); + + it("loads saved queries scoped to the connection on mount", async () => { + const store = setMocks(); + renderPanel( + {}} />, + ); + await waitFor(() => { + expect(store.loadSavedQueries).toHaveBeenCalledWith("c1"); + }); + }); + + it("shows history entries; clicking a row restores the query into the editor", async () => { + const onRestore = vi.fn(); + const store = setMocks({ + history: [ + { id: "h1", connection_id: "c1", query_text: "SELECT 1", execution_time_ms: 5, row_count: 1, status: "success", error_message: null, executed_at: "2026-01-01", favorite: false }, + ], + historyStale: false, + }); + const user = userEvent.setup(); + renderPanel( + , + ); + await waitFor(() => { + expect(screen.getByText(/SELECT 1/)).toBeInTheDocument(); + }); + + // Load/Run sub-buttons are gone + expect(screen.queryByLabelText("Load query into editor")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Run query from history")).not.toBeInTheDocument(); + + // Clicking the row loads the query into the editor + await user.click(screen.getByText(/SELECT 1/)); + expect(onRestore).toHaveBeenCalledWith("SELECT 1"); + + // Favorite toggle still exists, toggles the favorite, and does NOT restore + onRestore.mockClear(); + await user.click(screen.getByLabelText("Favorite")); + expect(store.toggleFavorite).toHaveBeenCalledWith("h1", "c1"); + expect(onRestore).not.toHaveBeenCalled(); + }); + + it("shows saved queries; clicking a row restores it, delete works without restoring", async () => { + const onRestore = vi.fn(); + const store = setMocks({}, { + savedQueries: [ + { id: "q1", connection_id: "c1", name: "My Saved", query_text: "SELECT 2", folder: "", created_at: "", updated_at: "" }, + ], + savedLoading: false, + }); + const user = userEvent.setup(); + renderPanel( + , + ); + // Switch to Saved Queries via the dropdown + await user.click(screen.getByLabelText("History/Saved")); + await user.click(screen.getByText("Saved Queries")); + await waitFor(() => { + expect(screen.getByText("My Saved")).toBeInTheDocument(); + }); + + // Load/Run sub-buttons are gone + expect(screen.queryByLabelText("Load saved query")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Run saved query")).not.toBeInTheDocument(); + + // Clicking the row loads the query into the editor + await user.click(screen.getByText("My Saved")); + expect(onRestore).toHaveBeenCalledWith("SELECT 2"); + + // Delete button exists, deletes, and does NOT restore + onRestore.mockClear(); + await user.click(screen.getByLabelText("Delete saved query")); + expect(store.deleteSavedQuery).toHaveBeenCalledWith("q1"); + expect(onRestore).not.toHaveBeenCalled(); + }); + + it("shows Favorites and Clear icons in history mode only", async () => { + const user = userEvent.setup(); + renderPanel( + {}} />, + ); + expect(screen.getByLabelText("Show favorites only")).toBeInTheDocument(); + expect(screen.getByLabelText("Clear history")).toBeInTheDocument(); + + // Switch to saved mode — the history-only icons disappear + await user.click(screen.getByLabelText("History/Saved")); + await user.click(screen.getByText("Saved Queries")); + + expect(screen.queryByLabelText("Show favorites only")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Clear history")).not.toBeInTheDocument(); + }); + + it("toggles favorites only and clears history for the connection", async () => { + const store = setMocks({ history: [], historyStale: false }); + const user = userEvent.setup(); + renderPanel( + {}} />, + ); + await user.click(screen.getByLabelText("Show favorites only")); + expect(store.setFavoritesOnly).toHaveBeenCalledWith(true); + await user.click(screen.getByLabelText("Clear history")); + expect(store.clearHistory).toHaveBeenCalledWith("c1"); + }); + + it("filters history by search query and syncs to the store", async () => { + const store = setMocks({ + history: [ + { id: "h1", connection_id: "c1", query_text: "SELECT 1", execution_time_ms: 5, row_count: 1, status: "success", error_message: null, executed_at: "2026-01-01", favorite: false }, + { id: "h2", connection_id: "c1", query_text: "SELECT 2", execution_time_ms: 5, row_count: 1, status: "success", error_message: null, executed_at: "2026-01-01", favorite: false }, + ], + historyStale: false, + }); + const user = userEvent.setup(); + renderPanel( + {}} />, + ); + await user.click(screen.getByLabelText("Search queries")); + const input = screen.getByPlaceholderText("Filter queries…"); + await user.type(input, "SELECT 2"); + await waitFor(() => { + expect(store.setHistorySearch).toHaveBeenCalledWith("SELECT 2"); + }); + expect(screen.queryByText("SELECT 1")).not.toBeInTheDocument(); + expect(screen.getByText("SELECT 2")).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/src/components/queries/QueriesPanel.tsx b/src/components/queries/QueriesPanel.tsx new file mode 100644 index 0000000..9eb495b --- /dev/null +++ b/src/components/queries/QueriesPanel.tsx @@ -0,0 +1,311 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import { Trash2, Star, Search, X } from "lucide-react"; +import { useQueryStore } from "../../stores/queryStore"; +import { SelectDropdown } from "../ui/SelectDropdown"; +import { Tooltip } from "../ui/Tooltip"; +import { ErrorBanner } from "../ui/ErrorBanner"; + +interface QueriesPanelProps { + connectionId: string; + onRestore: (sql: string) => void; + style?: React.CSSProperties; +} + +const MODE_OPTIONS = [ + { value: "history", label: "History" }, + { value: "saved", label: "Saved Queries" }, +]; + +export function QueriesPanel({ connectionId, onRestore, style }: QueriesPanelProps) { + const [mode, setMode] = useState<"history" | "saved">("history"); + const [searchOpen, setSearchOpen] = useState(false); + const searchInputRef = useRef(null); + + // History state + const history = useQueryStore((s) => s.history); + const historyLoading = useQueryStore((s) => s.historyLoading); + const historyError = useQueryStore((s) => s.historyError); + const historyStale = useQueryStore((s) => s.historyStale); + const historySearch = useQueryStore((s) => s.historySearch); + const favoritesOnly = useQueryStore((s) => s.favoritesOnly); + const loadHistory = useQueryStore((s) => s.loadHistory); + const clearHistory = useQueryStore((s) => s.clearHistory); + const toggleFavorite = useQueryStore((s) => s.toggleFavorite); + const setHistorySearch = useQueryStore((s) => s.setHistorySearch); + const setFavoritesOnly = useQueryStore((s) => s.setFavoritesOnly); + + // Saved queries state + const savedQueries = useQueryStore((s) => s.savedQueries); + const savedLoading = useQueryStore((s) => s.savedLoading); + const savedError = useQueryStore((s) => s.savedError); + const loadSavedQueries = useQueryStore((s) => s.loadSavedQueries); + const deleteSavedQuery = useQueryStore((s) => s.deleteSavedQuery); + + // Shared local search state; history mode also syncs to the store + const [searchQuery, setSearchQuery] = useState(historySearch); + const handleSearchChange = useCallback( + (value: string) => { + setSearchQuery(value); + setHistorySearch(value); + }, + [setHistorySearch], + ); + + // Fetch scoped to this connection only + useEffect(() => { + if (historyStale) loadHistory(connectionId); + }, [historyStale, connectionId, loadHistory]); + + useEffect(() => { + loadSavedQueries(connectionId); + }, [connectionId, loadSavedQueries]); + + // Focus input when search opens + useEffect(() => { + if (searchOpen && searchInputRef.current) { + searchInputRef.current.focus(); + } + }, [searchOpen]); + + // Auto-hide on blur when empty + const handleSearchBlur = useCallback(() => { + // Small delay to allow clicks on clear button / search icon + setTimeout(() => { + if (!searchQuery.trim()) { + setSearchOpen(false); + } + }, 150); + }, [searchQuery]); + + const toggleSearch = useCallback(() => { + setSearchOpen((prev) => { + const next = !prev; + if (!next) handleSearchChange(""); // clear when closing + return next; + }); + }, [handleSearchChange]); + + // Client-side filtering for the active mode + const filteredHistory = (history ?? []).filter((e) => { + if (favoritesOnly && !e.favorite) return false; + if (searchQuery) { + return e.query_text.toLowerCase().includes(searchQuery.toLowerCase()); + } + return true; + }); + + const filteredSaved = (savedQueries ?? []).filter((q) => { + if (!searchQuery) return true; + const haystack = [q.name, q.folder ?? "", q.query_text] + .join(" ") + .toLowerCase(); + return haystack.includes(searchQuery.toLowerCase()); + }); + + return ( +
+ {/* Header row */} +
+
+ Queries +
+ {mode === "history" && ( + <> + + + + + + + + )} + + + +
+
+ {/* Animated search input — row 2 */} +
+
+ + handleSearchChange(e.target.value)} + onBlur={handleSearchBlur} + placeholder="Filter queries…" + className="w-full bg-transparent border-0 border-b border-border pl-8 pr-7 py-1.5 text-xs text-text placeholder:text-text-muted/60 outline-none focus:border-accent/50 transition-colors" + /> + {searchQuery && ( + + )} +
+
+ {/* Mode dropdown — its own row, below the search */} +
+ setMode(v as "history" | "saved")} + options={MODE_OPTIONS} + variant="ghost" + aria-label="History/Saved" + /> +
+
+ + {/* Content area */} +
+ {mode === "history" && ( + <> + {historyLoading && ( +
Loading...
+ )} + {historyError && ( + loadHistory(connectionId)} /> + )} + {!historyLoading && !historyError && filteredHistory.length === 0 && ( +
+ {searchQuery || favoritesOnly ? "No matching queries" : "No queries yet"} +
+ )} + {filteredHistory.map((entry) => ( +
onRestore(entry.query_text)} + onKeyDown={(e) => { + if (e.key === "Enter" && e.target === e.currentTarget) { + onRestore(entry.query_text); + } + }} + className="group flex items-start gap-3 px-4 py-3 hover:bg-surface-raised border-b border-border/50 transition-colors cursor-pointer" + > + +
+
{entry.query_text}
+
+ {entry.status === "error" ? ( + Error + ) : ( + <> + {entry.execution_time_ms != null && {entry.execution_time_ms}ms} + {entry.row_count != null && {entry.row_count} rows} + + )} +
+
+
+ ))} + + )} + + {mode === "saved" && ( + <> + {savedLoading && ( +
Loading...
+ )} + {savedError && ( + loadSavedQueries(connectionId)} /> + )} + {!savedLoading && !savedError && savedQueries && savedQueries.length === 0 && ( +
+ No saved queries yet +
+ )} + {filteredSaved.map((q) => ( +
onRestore(q.query_text)} + onKeyDown={(e) => { + if (e.key === "Enter" && e.target === e.currentTarget) { + onRestore(q.query_text); + } + }} + className="group flex items-start gap-3 px-4 py-3 hover:bg-surface-raised border-b border-border/50 transition-colors cursor-pointer" + > +
+
{q.name}
+ {q.folder && ( +
{q.folder}
+ )} +
{q.query_text}
+
+
+ +
+
+ ))} + + )} +
+
+ ); +} \ No newline at end of file diff --git a/src/components/ui/Tooltip.tsx b/src/components/ui/Tooltip.tsx index 4ea3c26..4bcc267 100644 --- a/src/components/ui/Tooltip.tsx +++ b/src/components/ui/Tooltip.tsx @@ -1,4 +1,17 @@ -import { createContext, useContext, useId, useRef, useState, useCallback, type Dispatch, type ReactElement, type ReactNode, type SetStateAction } from "react"; +import { + createContext, + useContext, + useId, + useRef, + useState, + useCallback, + useLayoutEffect, + type Dispatch, + type ReactElement, + type ReactNode, + type SetStateAction, +} from "react"; +import { createPortal } from "react-dom"; interface TooltipContextValue { activeId: string | null; @@ -34,38 +47,23 @@ interface TooltipProps { side?: "top" | "right" | "bottom" | "left"; } -function tooltipClasses(side: "top" | "right" | "bottom" | "left") { - switch (side) { - case "right": - return { - wrapper: "left-full ml-2 top-1/2 -translate-y-1/2", - arrow: "right-full top-1/2 -translate-y-1/2 border-r-surface-raised", - }; - case "bottom": - return { - wrapper: "top-full left-1/2 -translate-x-1/2 mt-2", - arrow: "bottom-full left-1/2 -translate-x-1/2 border-b-surface-raised", - }; - case "left": - return { - wrapper: "right-full mr-2 top-1/2 -translate-y-1/2", - arrow: "left-full top-1/2 -translate-y-1/2 border-l-surface-raised", - }; - case "top": - default: - return { - wrapper: "bottom-full left-1/2 -translate-x-1/2 mb-2", - arrow: "top-full left-1/2 -translate-x-1/2 border-t-surface-raised", - }; - } -} +const GAP = 8; +const VIEWPORT_MARGIN = 4; +/** + * Tooltip that renders into `document.body` via a portal and positions itself + * with fixed coordinates relative to its trigger. Rendering through a portal + * means tooltips are never clipped by `overflow`/`transform` ancestors (e.g. + * scrollable dropdowns or panels). + */ export function Tooltip({ content, children, side = "top" }: TooltipProps) { const id = useId(); const { activeId, setActiveId } = useTooltipContext(); const showTimer = useRef | null>(null); + const triggerRef = useRef(null); + const tooltipRef = useRef(null); + const [pos, setPos] = useState<{ top: number; left: number } | null>(null); const isActive = activeId === id; - const tc = tooltipClasses(side); const clearTimer = useCallback(() => { if (showTimer.current) { @@ -86,8 +84,64 @@ export function Tooltip({ content, children, side = "top" }: TooltipProps) { setActiveId((prev) => (prev === id ? null : prev)); }, [clearTimer, id, setActiveId]); + const measure = useCallback(() => { + const trigger = triggerRef.current; + const tooltipEl = tooltipRef.current; + if (!trigger || !tooltipEl) return; + + const tr = trigger.getBoundingClientRect(); + const tt = tooltipEl.getBoundingClientRect(); + + let top = 0; + let left = 0; + switch (side) { + case "right": + top = tr.top + tr.height / 2 - tt.height / 2; + left = tr.right + GAP; + break; + case "bottom": + top = tr.bottom + GAP; + left = tr.left + tr.width / 2 - tt.width / 2; + break; + case "left": + top = tr.top + tr.height / 2 - tt.height / 2; + left = tr.left - tt.width - GAP; + break; + case "top": + default: + top = tr.top - tt.height - GAP; + left = tr.left + tr.width / 2 - tt.width / 2; + break; + } + + // Keep the tooltip fully inside the viewport. + const vw = window.innerWidth; + const vh = window.innerHeight; + top = Math.max(VIEWPORT_MARGIN, Math.min(top, vh - tt.height - VIEWPORT_MARGIN)); + left = Math.max(VIEWPORT_MARGIN, Math.min(left, vw - tt.width - VIEWPORT_MARGIN)); + + setPos({ top, left }); + }, [side]); + + // Position the tooltip once it is visible, and keep it glued to the trigger + // while scrolling (capture phase catches scrolls in any container). + useLayoutEffect(() => { + if (!isActive) { + setPos(null); + return; + } + measure(); + window.addEventListener("scroll", measure, true); + window.addEventListener("resize", measure); + return () => { + window.removeEventListener("scroll", measure, true); + window.removeEventListener("resize", measure); + }; + }, [isActive, measure]); + return ( {children} - {isActive && ( - - {content} + {isActive && + createPortal( - )} + ref={tooltipRef} + role="tooltip" + className="fixed z-50 px-2 py-1 text-xs rounded-md bg-surface-raised border border-border text-text shadow-lg whitespace-nowrap" + style={pos ?? undefined} + > + {content} + {side === "top" && ( +