feat: Query history + saved queries (#5)
* fix: restore frontend test baseline (vitest jsdom env + tsc + mock fixes) * feat: v6 migration — favorite column + queries table (Task 1) * feat: extend TS types for favorites + saved queries (Task 2) * feat: dedup consecutive + prune to 500 in insert_query_history (Task 3) * feat: favorite column threading + set_history_favorite command (Task 4) * feat: saved queries store CRUD (Task 5) * feat: saved query commands + registration (Task 6) * feat: queryStore — Zustand cache for history + saved queries (Task 7) * feat: QueryHistoryDropdown — toolbar history dropdown (Task 8) * feat: SaveQueryDialog — save query modal (Task 9) * feat: QueryToolbar — add History + Save icons (Task 10) * feat: QueriesPanel — History + Saved tabs (Task 11) * feat: wire Queries view + toolbar to panel (Task 12) * fix: global scope wrappers, empty/spinner states, cache invalidation (Task 13) * feat: Queries view — history sidebar + tabbed query workspace (PR feedback) * fix: toolbar action order + view-specific empty state (PR feedback) * fix: view-specific empty state icon (PR feedback) * fix: portal Tooltip to body so it is never clipped by overflow containers (PR feedback) * feat: Queries sidebar — Explorer-style header + per-connection scoping (PR feedback) * fix: move History/Saved dropdown to right side of Queries header (PR feedback) * fix: History/Saved dropdown on its own row in Queries header (PR feedback) * fix: Queries header order (search above dropdown) + sidebar width matches Explorer (PR feedback) * fix: Queries header spacing — tight rows, pb-3 on container (PR feedback) * fix: Queries header spacing — space-y-2 on container, no mb on title row (PR feedback) * feat: history/saved rows click-to-load, remove sub-buttons (PR feedback) * feat: merge Functions/Triggers/Sequences/Enums/Extensions into single Objects view (PR feedback) * fix: Schema Visualizer nav icon — node graph glyph (PR feedback) * fix: Objects view — type dropdown replaces static header label (PR feedback) * fix: Objects empty state — remove bg circle, larger icon (PR feedback) * fix: center icon in Objects empty state (PR feedback) * feat: merge Backup/Restore/DB Sync into single Tools view (PR feedback) * docs: update AGENTS.md + README for query history/saved queries, merged Objects + Tools views
This commit is contained in:
@@ -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) | ❌ | |
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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"]
|
||||
@@ -32,6 +32,46 @@ pub struct QueryHistoryEntry {
|
||||
pub status: String,
|
||||
pub error_message: Option<String>,
|
||||
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<String>,
|
||||
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<String>,
|
||||
#[serde(rename = "queryText")]
|
||||
pub query_text: Option<String>,
|
||||
pub folder: Option<String>,
|
||||
}
|
||||
|
||||
impl From<crate::store::SavedQueryRow> 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<crate::store::Store>,
|
||||
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<crate::store::Store>,
|
||||
connection_id: Option<String>,
|
||||
name: String,
|
||||
query_text: String,
|
||||
folder: String,
|
||||
) -> Result<SavedQueryCommand, String> {
|
||||
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<crate::store::Store>,
|
||||
connection_id: Option<String>,
|
||||
) -> Result<Vec<SavedQueryCommand>, 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<crate::store::Store>,
|
||||
id: String,
|
||||
name: Option<String>,
|
||||
query_text: Option<String>,
|
||||
folder: Option<String>,
|
||||
) -> 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<crate::store::Store>,
|
||||
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<String>,
|
||||
name: String,
|
||||
query_text: String,
|
||||
folder: Option<String>,
|
||||
state: State<'_, crate::AppState>,
|
||||
) -> Result<SavedQueryCommand, String> {
|
||||
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<String>,
|
||||
state: State<'_, crate::AppState>,
|
||||
) -> Result<Vec<SavedQueryCommand>, 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
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<String> = {
|
||||
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<String> = {
|
||||
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");
|
||||
}
|
||||
}
|
||||
+523
-2
@@ -11,6 +11,22 @@ pub struct Store {
|
||||
conn: Mutex<SqliteConnection>,
|
||||
}
|
||||
|
||||
/// A saved query row (returned from the store).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SavedQueryRow {
|
||||
pub id: String,
|
||||
pub connection_id: Option<String>,
|
||||
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<Box<dyn rusqlite::types::ToSql>>) =
|
||||
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<SavedQueryRow, String> {
|
||||
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<Vec<SavedQueryRow>, String> {
|
||||
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
||||
let (sql, params_vec): (String, Vec<Box<dyn rusqlite::types::ToSql>>) =
|
||||
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::<Result<Vec<_>, _>>().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<String> = vec!["updated_at = ?1".to_string()];
|
||||
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = 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<dyn rusqlite::types::ToSql> = 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<String> = 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");
|
||||
}
|
||||
}
|
||||
@@ -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<string | null>(null);
|
||||
const [currentView, setCurrentView] = useState<string>("db-viewer");
|
||||
const [tablePanelWidth, setTablePanelWidth] = useState(280);
|
||||
const [queriesPanelWidth, setQueriesPanelWidth] = useState(280);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedRows, setSelectedRows] = useState<Set<number>>(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<HTMLDivElement>(null);
|
||||
const resultsResizeRef = useRef<{ startY: number; startH: number } | null>(
|
||||
@@ -541,58 +626,24 @@ export function DbViewerScreen({
|
||||
const activeSchema = activeTab?.schema ?? "";
|
||||
const activeTable = activeTab?.table ?? "";
|
||||
|
||||
function renderQueryWorkspace() {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="h-screen bg-canvas flex border-t border-border">
|
||||
<DbViewerSidebar
|
||||
currentView={currentView}
|
||||
onNavigate={handleNavigate}
|
||||
/>
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{connectionError && connectionError !== dismissedError && (
|
||||
<ConnectionDropBanner
|
||||
error={connectionError}
|
||||
onRetry={() => {
|
||||
setDismissedError(null);
|
||||
connect();
|
||||
}}
|
||||
onDismiss={() => setDismissedError(connectionError)}
|
||||
/>
|
||||
)}
|
||||
{currentView === "db-viewer" ? (
|
||||
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||
<div
|
||||
className="border-r border-border flex flex-col shrink-0"
|
||||
style={{ width: tablePanelWidth }}
|
||||
>
|
||||
<DbViewerToolbar
|
||||
databases={databases}
|
||||
currentDatabase={currentDatabase}
|
||||
setCurrentDatabase={setCurrentDatabase}
|
||||
schemas={schemas}
|
||||
currentSchema={currentSchema}
|
||||
setCurrentSchema={setCurrentSchema}
|
||||
onEdit={() => setEditModalOpen(true)}
|
||||
connectionId={connectionId}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
/>
|
||||
<div
|
||||
className="flex-1 overflow-y-auto"
|
||||
style={{ overscrollBehavior: "none" }}
|
||||
>
|
||||
<TableTree searchQuery={searchQuery} />
|
||||
</div>
|
||||
</div>
|
||||
{/* panel resize handle */}
|
||||
<div
|
||||
className="w-1 cursor-col-resize bg-border/20 hover:bg-accent/30 active:bg-accent/50 shrink-0 border-r border-border"
|
||||
onMouseDown={onPanelResizeStart}
|
||||
onDoubleClick={() => setTablePanelWidth(280)}
|
||||
/>
|
||||
<div className="flex-1 w-0 flex flex-col min-w-0 overflow-hidden">
|
||||
<TabBar />
|
||||
{activeTab?.tabType === "query" ? (
|
||||
{!activeTab ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-2 text-text-muted">
|
||||
{currentView === "queries" ? (
|
||||
<Terminal size={32} />
|
||||
) : (
|
||||
<Table2 size={32} />
|
||||
)}
|
||||
<span>
|
||||
{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"}
|
||||
</span>
|
||||
</div>
|
||||
) : activeTab?.tabType === "query" ? (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="p-4 text-text-muted">
|
||||
@@ -604,6 +655,9 @@ export function DbViewerScreen({
|
||||
<QueryToolbar
|
||||
onRun={handleRunQuery}
|
||||
onFormat={handleFormatQuery}
|
||||
connectionId={connectionId}
|
||||
onRestore={handleRestoreSql}
|
||||
onRunFromHistory={handleRunFromHistory}
|
||||
dbType={currentConnection?.db_type}
|
||||
/>
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
@@ -922,39 +976,78 @@ export function DbViewerScreen({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="h-screen bg-canvas flex border-t border-border">
|
||||
<DbViewerSidebar
|
||||
currentView={currentView}
|
||||
onNavigate={handleNavigate}
|
||||
/>
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{connectionError && connectionError !== dismissedError && (
|
||||
<ConnectionDropBanner
|
||||
error={connectionError}
|
||||
onRetry={() => {
|
||||
setDismissedError(null);
|
||||
connect();
|
||||
}}
|
||||
onDismiss={() => setDismissedError(connectionError)}
|
||||
/>
|
||||
)}
|
||||
{currentView === "db-viewer" ? (
|
||||
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||
<div
|
||||
className="border-r border-border flex flex-col shrink-0"
|
||||
style={{ width: tablePanelWidth }}
|
||||
>
|
||||
<DbViewerToolbar
|
||||
databases={databases}
|
||||
currentDatabase={currentDatabase}
|
||||
setCurrentDatabase={setCurrentDatabase}
|
||||
schemas={schemas}
|
||||
currentSchema={currentSchema}
|
||||
setCurrentSchema={setCurrentSchema}
|
||||
onEdit={() => setEditModalOpen(true)}
|
||||
connectionId={connectionId}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
/>
|
||||
<div
|
||||
className="flex-1 overflow-y-auto"
|
||||
style={{ overscrollBehavior: "none" }}
|
||||
>
|
||||
<TableTree searchQuery={searchQuery} />
|
||||
</div>
|
||||
) : currentView === "functions" ? (
|
||||
<ObjectExplorerPage
|
||||
key="functions"
|
||||
type="functions"
|
||||
connectionId={connectionId}
|
||||
</div>
|
||||
{/* panel resize handle */}
|
||||
<div
|
||||
className="w-1 cursor-col-resize bg-border/20 hover:bg-accent/30 active:bg-accent/50 shrink-0 border-r border-border"
|
||||
onMouseDown={onPanelResizeStart}
|
||||
onDoubleClick={() => setTablePanelWidth(280)}
|
||||
/>
|
||||
) : currentView === "triggers" ? (
|
||||
<ObjectExplorerPage
|
||||
key="triggers"
|
||||
type="triggers"
|
||||
{renderQueryWorkspace()}
|
||||
</div>
|
||||
) : currentView === "objects" ? (
|
||||
<ObjectExplorerPage connectionId={connectionId} />
|
||||
) : currentView === "tools" ? (
|
||||
<ToolsPage connectionId={connectionId} />
|
||||
) : currentView === "queries" ? (
|
||||
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||
<QueriesPanel
|
||||
connectionId={connectionId}
|
||||
onRestore={handleRestoreSql}
|
||||
style={{ width: queriesPanelWidth }}
|
||||
/>
|
||||
) : currentView === "sequences" ? (
|
||||
<ObjectExplorerPage
|
||||
key="sequences"
|
||||
type="sequences"
|
||||
connectionId={connectionId}
|
||||
<div
|
||||
className="w-1 cursor-col-resize bg-border/20 hover:bg-accent/30 active:bg-accent/50 shrink-0 border-r border-border"
|
||||
onMouseDown={onQueriesPanelResizeStart}
|
||||
onDoubleClick={() => setQueriesPanelWidth(280)}
|
||||
/>
|
||||
) : currentView === "enums" ? (
|
||||
<ObjectExplorerPage key="enums" type="enums" connectionId={connectionId} />
|
||||
) : currentView === "extensions" ? (
|
||||
<ObjectExplorerPage
|
||||
key="extensions"
|
||||
type="extensions"
|
||||
connectionId={connectionId}
|
||||
/>
|
||||
) : currentView === "backup" ? (
|
||||
<BackupPage connectionId={connectionId} />
|
||||
) : currentView === "restore" ? (
|
||||
<RestorePage connectionId={connectionId} />
|
||||
) : currentView === "sync" ? (
|
||||
<SyncPage />
|
||||
{renderQueryWorkspace()}
|
||||
</div>
|
||||
) : currentView === "schema-visualizer" ? (
|
||||
<SchemaVisualizerPage
|
||||
connectionId={connectionId}
|
||||
@@ -963,7 +1056,7 @@ export function DbViewerScreen({
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{currentView === "db-viewer" && <ChangesQueuePanel />}
|
||||
{(currentView === "db-viewer" || currentView === "queries") && <ChangesQueuePanel />}
|
||||
</div>
|
||||
{currentConnection && (
|
||||
<EditConnectionModal
|
||||
|
||||
@@ -50,4 +50,22 @@ describe("DbViewerSidebar", () => {
|
||||
expect(btn).toBeInTheDocument();
|
||||
expect(btn).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("renders Queries nav item", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DbViewerSidebar currentView="db-viewer" onNavigate={() => {}} />
|
||||
</TooltipProvider>
|
||||
);
|
||||
expect(screen.getByLabelText("Queries")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Tools nav item", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DbViewerSidebar currentView="db-viewer" onNavigate={() => {}} />
|
||||
</TooltipProvider>
|
||||
);
|
||||
expect(screen.getByLabelText("Tools")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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: <Database size={16} /> },
|
||||
{
|
||||
id: "queries",
|
||||
label: "Queries",
|
||||
icon: <Clock size={16} />,
|
||||
},
|
||||
{
|
||||
id: "schema-visualizer",
|
||||
label: "Schema Visualizer",
|
||||
icon: <Grid2x2 size={16} />,
|
||||
},
|
||||
{
|
||||
id: "functions",
|
||||
label: "Functions",
|
||||
icon: <FunctionSquare size={16} />,
|
||||
},
|
||||
{ id: "triggers", label: "Triggers", icon: <GitBranch size={16} /> },
|
||||
{
|
||||
id: "sequences",
|
||||
label: "Sequences",
|
||||
icon: <ListOrdered size={16} />,
|
||||
},
|
||||
{ id: "enums", label: "Enums", icon: <Tag size={16} /> },
|
||||
{ id: "extensions", label: "Extensions", icon: <Puzzle size={16} /> },
|
||||
{ id: "backup", label: "Backup", icon: <Download size={16} /> },
|
||||
{ id: "restore", label: "Restore", icon: <Upload size={16} /> },
|
||||
{
|
||||
id: "sync",
|
||||
label: "DB Sync",
|
||||
icon: <ArrowLeftRight size={16} />,
|
||||
icon: <Share2 size={16} />,
|
||||
},
|
||||
{ id: "objects", label: "Objects", icon: <Boxes size={16} /> },
|
||||
{ id: "tools", label: "Tools", icon: <DatabaseBackup size={16} /> },
|
||||
];
|
||||
|
||||
const bottomItems: NavItem[] = [
|
||||
|
||||
@@ -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<ObjectType, string> = {
|
||||
extensions: "Extensions",
|
||||
};
|
||||
|
||||
const OBJECT_TYPE_OPTIONS = (Object.keys(TYPE_LABELS) as ObjectType[]).map(
|
||||
(t) => ({ value: t, label: TYPE_LABELS[t] }),
|
||||
);
|
||||
|
||||
const SINGULAR_LABELS: Record<ObjectType, string> = {
|
||||
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<ObjectType>("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 (
|
||||
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||
{/* Left panel: toolbar + object list */}
|
||||
@@ -952,9 +965,13 @@ export function ObjectExplorerPage({
|
||||
>
|
||||
<div className="p-3 border-b border-border space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-text">
|
||||
{label}
|
||||
</span>
|
||||
<SelectDropdown
|
||||
value={type}
|
||||
onChange={(v) => handleTypeChange(v as ObjectType)}
|
||||
options={OBJECT_TYPE_OPTIONS}
|
||||
variant="ghost"
|
||||
aria-label="Object type"
|
||||
/>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
aria-label="Refresh"
|
||||
@@ -1144,8 +1161,8 @@ export function ObjectExplorerPage({
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-text-muted">
|
||||
<div className="text-center space-y-2">
|
||||
<div className="w-12 h-12 mx-auto rounded-full bg-surface flex items-center justify-center">
|
||||
{icon}
|
||||
<div className="flex justify-center">
|
||||
{cloneElement(icon as React.ReactElement<{ size?: number }>, { size: 20 })}
|
||||
</div>
|
||||
<p className="text-sm">
|
||||
Select a {singular} to view details
|
||||
|
||||
@@ -14,9 +14,9 @@ const sampleTable: TableNode = {
|
||||
schema: "public",
|
||||
table_type: "TABLE",
|
||||
columns: [
|
||||
{ name: "id", data_type: "integer", is_pk: true, is_fk: false, is_unique: true, fk_ref: null },
|
||||
{ name: "name", data_type: "text", is_pk: false, is_fk: false, is_unique: false, fk_ref: null },
|
||||
{ name: "email", data_type: "text", is_pk: false, is_fk: false, is_unique: true, fk_ref: null },
|
||||
{ name: "id", data_type: "integer", is_pk: true, is_fk: false, is_unique: true, is_nullable: false, fk_ref: null },
|
||||
{ name: "name", data_type: "text", is_pk: false, is_fk: false, is_unique: false, is_nullable: true, fk_ref: null },
|
||||
{ name: "email", data_type: "text", is_pk: false, is_fk: false, is_unique: true, is_nullable: false, fk_ref: null },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
MiniMap,
|
||||
Controls,
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
getNodesBounds,
|
||||
@@ -107,7 +108,7 @@ function layoutGraph(
|
||||
labelStyle: { fill: "#9ca3af", fontSize: 9 },
|
||||
labelBgStyle: { fill: "#1f2937", fillOpacity: 0.85 },
|
||||
labelBgPadding: [3, 1],
|
||||
labelBorderRadius: 0,
|
||||
labelBgBorderRadius: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -162,8 +163,8 @@ export function SchemaVisualizerPage({
|
||||
const setCurrentDatabase = useDbViewerStore((s) => s.setCurrentDatabase);
|
||||
const setCurrentSchema = useDbViewerStore((s) => s.setCurrentSchema);
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [tableCount, setTableCount] = useState(0);
|
||||
@@ -552,7 +553,11 @@ export function SchemaVisualizerPage({
|
||||
className="bg-canvas"
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background variant="dots" gap={20} color="var(--color-border)" />
|
||||
<Background
|
||||
variant={BackgroundVariant.Dots}
|
||||
gap={20}
|
||||
color="var(--color-border)"
|
||||
/>
|
||||
<MiniMap
|
||||
position="bottom-right"
|
||||
nodeStrokeWidth={2}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useState } from "react";
|
||||
import { SelectDropdown } from "../ui/SelectDropdown";
|
||||
import { BackupPage } from "./BackupPage";
|
||||
import { RestorePage } from "./RestorePage";
|
||||
import { SyncPage } from "./SyncPage";
|
||||
|
||||
type ToolOperation = "backup" | "restore" | "sync";
|
||||
|
||||
const OPERATION_OPTIONS = [
|
||||
{ value: "backup", label: "Backup" },
|
||||
{ value: "restore", label: "Restore" },
|
||||
{ value: "sync", label: "DB Sync" },
|
||||
];
|
||||
|
||||
export function ToolsPage({ connectionId }: { connectionId: string }) {
|
||||
const [operation, setOperation] = useState<ToolOperation>("backup");
|
||||
return (
|
||||
<div className="flex flex-1 min-h-0 flex-col overflow-hidden">
|
||||
{/* Operation switcher toolbar */}
|
||||
<div className="px-3 pt-3 pb-3 border-b border-border shrink-0">
|
||||
<SelectDropdown
|
||||
value={operation}
|
||||
onChange={(v) => setOperation(v as ToolOperation)}
|
||||
options={OPERATION_OPTIONS}
|
||||
variant="ghost"
|
||||
aria-label="Operation"
|
||||
/>
|
||||
</div>
|
||||
{/* Content — BackupPage/RestorePage/SyncPage each render their own
|
||||
toolbar header and flex-1 overflow-y-auto scroll container, so this
|
||||
wrapper only provides a definite height (h-full resolves against it). */}
|
||||
<div className="flex-1 min-h-0">
|
||||
{operation === "backup" && <BackupPage connectionId={connectionId} />}
|
||||
{operation === "restore" && <RestorePage connectionId={connectionId} />}
|
||||
{operation === "sync" && <SyncPage />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryHistoryDropdown } from "./QueryHistoryDropdown";
|
||||
import { useQueryStore } from "../../stores/queryStore";
|
||||
import { TooltipProvider } from "../ui/Tooltip";
|
||||
|
||||
// Mock queryStore
|
||||
vi.mock("../../stores/queryStore", () => ({
|
||||
useQueryStore: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockLoadHistory = vi.fn();
|
||||
const mockClearHistory = vi.fn();
|
||||
const mockToggleFavorite = vi.fn();
|
||||
|
||||
function setStoreState(overrides: Record<string, unknown> = {}) {
|
||||
(useQueryStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
(selector: (s: Record<string, unknown>) => unknown) =>
|
||||
selector({
|
||||
history: null,
|
||||
historyLoading: false,
|
||||
historyError: null,
|
||||
historyStale: true,
|
||||
loadHistory: mockLoadHistory,
|
||||
clearHistory: mockClearHistory,
|
||||
toggleFavorite: mockToggleFavorite,
|
||||
...overrides,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function renderDropdown(props: {
|
||||
connectionId: string;
|
||||
onRestore?: (sql: string) => void;
|
||||
onRun?: (sql: string) => void;
|
||||
}) {
|
||||
return render(
|
||||
<TooltipProvider>
|
||||
<QueryHistoryDropdown
|
||||
connectionId={props.connectionId}
|
||||
onRestore={props.onRestore ?? (() => {})}
|
||||
onRun={props.onRun ?? (() => {})}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setStoreState();
|
||||
});
|
||||
|
||||
describe("QueryHistoryDropdown", () => {
|
||||
it("opens dropdown on button click and loads history if stale", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDropdown({ connectionId: "conn-1" });
|
||||
|
||||
const btn = screen.getByLabelText("Query history");
|
||||
await user.click(btn);
|
||||
|
||||
// Should trigger loadHistory because stale
|
||||
expect(mockLoadHistory).toHaveBeenCalledWith("conn-1");
|
||||
|
||||
// Dropdown menu should appear (check for empty state)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/no queries/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("displays history items with metadata", async () => {
|
||||
const user = userEvent.setup();
|
||||
setStoreState({
|
||||
history: [
|
||||
{
|
||||
id: "h1",
|
||||
connection_id: "conn-1",
|
||||
query_text: "SELECT * FROM users",
|
||||
execution_time_ms: 42,
|
||||
row_count: 10,
|
||||
status: "success",
|
||||
error_message: null,
|
||||
executed_at: "2026-01-01T12:00:00Z",
|
||||
favorite: false,
|
||||
},
|
||||
],
|
||||
historyStale: false,
|
||||
});
|
||||
|
||||
renderDropdown({ connectionId: "conn-1" });
|
||||
|
||||
await user.click(screen.getByLabelText("Query history"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/SELECT \* FROM users/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/42ms/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/10 rows/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("calls onRestore when Load button clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRestore = vi.fn();
|
||||
setStoreState({
|
||||
history: [
|
||||
{
|
||||
id: "h1",
|
||||
connection_id: "conn-1",
|
||||
query_text: "SELECT 1",
|
||||
execution_time_ms: 1,
|
||||
row_count: 1,
|
||||
status: "success",
|
||||
error_message: null,
|
||||
executed_at: "",
|
||||
favorite: false,
|
||||
},
|
||||
],
|
||||
historyStale: false,
|
||||
});
|
||||
|
||||
renderDropdown({ connectionId: "conn-1", onRestore });
|
||||
await user.click(screen.getByLabelText("Query history"));
|
||||
await user.click(screen.getByLabelText("Load query into editor"));
|
||||
|
||||
expect(onRestore).toHaveBeenCalledWith("SELECT 1");
|
||||
});
|
||||
|
||||
it("calls onRun when Run button clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRun = vi.fn();
|
||||
setStoreState({
|
||||
history: [
|
||||
{
|
||||
id: "h1",
|
||||
connection_id: "conn-1",
|
||||
query_text: "SELECT 1",
|
||||
execution_time_ms: 1,
|
||||
row_count: 1,
|
||||
status: "success",
|
||||
error_message: null,
|
||||
executed_at: "",
|
||||
favorite: false,
|
||||
},
|
||||
],
|
||||
historyStale: false,
|
||||
});
|
||||
|
||||
renderDropdown({ connectionId: "conn-1", onRun });
|
||||
await user.click(screen.getByLabelText("Query history"));
|
||||
await user.click(screen.getByLabelText("Run query from history"));
|
||||
|
||||
expect(onRun).toHaveBeenCalledWith("SELECT 1");
|
||||
});
|
||||
|
||||
it("shows loading spinner while fetching", async () => {
|
||||
const user = userEvent.setup();
|
||||
setStoreState({ history: null, historyLoading: true, historyStale: true });
|
||||
renderDropdown({ connectionId: "conn-1" });
|
||||
|
||||
await user.click(screen.getByLabelText("Query history"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("query-history-spinner")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error state on fetch failure", async () => {
|
||||
const user = userEvent.setup();
|
||||
setStoreState({
|
||||
history: null,
|
||||
historyLoading: false,
|
||||
historyError: "Fetch failed",
|
||||
historyStale: false,
|
||||
});
|
||||
renderDropdown({ connectionId: "conn-1" });
|
||||
|
||||
await user.click(screen.getByLabelText("Query history"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/fetch failed/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("closes on Escape key", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDropdown({ connectionId: "conn-1" });
|
||||
|
||||
await user.click(screen.getByLabelText("Query history"));
|
||||
// Menu is open
|
||||
expect(screen.getByText(/no queries/i)).toBeInTheDocument();
|
||||
|
||||
// Press Escape
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/no queries/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { Clock, Trash2, Download, Play, Star } from "lucide-react";
|
||||
import { Tooltip } from "../ui/Tooltip";
|
||||
import { useQueryStore } from "../../stores/queryStore";
|
||||
|
||||
interface QueryHistoryDropdownProps {
|
||||
connectionId: string;
|
||||
onRestore: (sql: string) => void;
|
||||
onRun: (sql: string) => void;
|
||||
}
|
||||
|
||||
export function QueryHistoryDropdown({
|
||||
connectionId,
|
||||
onRestore,
|
||||
onRun,
|
||||
}: QueryHistoryDropdownProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const history = useQueryStore((s) => s.history);
|
||||
const loading = useQueryStore((s) => s.historyLoading);
|
||||
const historyError = useQueryStore((s) => s.historyError);
|
||||
const stale = useQueryStore((s) => s.historyStale);
|
||||
const loadHistory = useQueryStore((s) => s.loadHistory);
|
||||
const clearHistory = useQueryStore((s) => s.clearHistory);
|
||||
const toggleFavorite = useQueryStore((s) => s.toggleFavorite);
|
||||
|
||||
// Lazy fetch on open
|
||||
const handleToggle = useCallback(() => {
|
||||
const willOpen = !open;
|
||||
setOpen(willOpen);
|
||||
if (willOpen && stale) {
|
||||
loadHistory(connectionId);
|
||||
}
|
||||
}, [open, stale, connectionId, loadHistory]);
|
||||
|
||||
// Close on outside click / Escape
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onMouseDown, true);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onMouseDown, true);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={menuRef}>
|
||||
<Tooltip content="Query history" side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggle}
|
||||
aria-label="Query history"
|
||||
className="flex items-center rounded px-2 py-1.5 hover:bg-surface-raised hover:text-text transition-colors cursor-pointer text-text-muted"
|
||||
>
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{open && (
|
||||
<div className="absolute left-0 top-full mt-1 rounded-xl bg-surface border border-border py-1 z-20 w-80 shadow-lg max-h-80 overflow-y-auto">
|
||||
{loading && history === null && (
|
||||
<div className="flex items-center justify-center gap-2 px-3 py-4 text-center text-sm text-text-muted">
|
||||
<span
|
||||
data-testid="query-history-spinner"
|
||||
aria-hidden="true"
|
||||
className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-text-muted/30 border-t-text-muted"
|
||||
/>
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
{historyError && !loading && (
|
||||
<div className="px-3 py-4 text-center text-sm text-red-400">
|
||||
{historyError}
|
||||
</div>
|
||||
)}
|
||||
{!loading && !historyError && (!history || history.length === 0) && (
|
||||
<div className="px-3 py-4 text-center text-sm text-text-muted">
|
||||
No queries yet
|
||||
</div>
|
||||
)}
|
||||
{!loading && !historyError && history && history.length > 0 && (
|
||||
<>
|
||||
{history.slice(0, 50).map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="group flex items-start gap-2 px-3 py-2 hover:bg-surface-raised border-b border-border/50 last:border-b-0"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs text-text font-mono truncate max-w-[220px]">
|
||||
{entry.query_text}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5 text-[10px] text-text-muted">
|
||||
{entry.status === "error" ? (
|
||||
<span className="text-red-400">Error</span>
|
||||
) : (
|
||||
<>
|
||||
{entry.execution_time_ms != null && (
|
||||
<span>{entry.execution_time_ms}ms</span>
|
||||
)}
|
||||
{entry.row_count != null && (
|
||||
<span>{entry.row_count} rows</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Tooltip content="Load into editor" side="top">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Load query into editor"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onRestore(entry.query_text);
|
||||
}}
|
||||
className="rounded p-1 text-text-muted hover:text-text hover:bg-surface-raised opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
|
||||
>
|
||||
<Download className="h-3 w-3" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Run query" side="top">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Run query from history"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onRun(entry.query_text);
|
||||
}}
|
||||
className="rounded p-1 text-text-muted hover:text-text hover:bg-surface-raised opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
|
||||
>
|
||||
<Play className="h-3 w-3" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={entry.favorite ? "Unfavorite" : "Favorite"}
|
||||
onClick={() => toggleFavorite(entry.id, connectionId)}
|
||||
className={`rounded p-1 cursor-pointer ${
|
||||
entry.favorite
|
||||
? "text-amber-400 hover:text-amber-300"
|
||||
: "text-text-muted hover:text-amber-400 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
}`}
|
||||
>
|
||||
<Star
|
||||
className="h-3 w-3"
|
||||
fill={entry.favorite ? "currentColor" : "none"}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{/* Clear History footer */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
clearHistory(connectionId);
|
||||
}}
|
||||
className="flex items-center gap-1.5 w-full px-3 py-2 text-xs text-text-muted hover:text-red-400 hover:bg-surface-raised transition-colors cursor-pointer border-t border-border"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
Clear History
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
<TooltipProvider>
|
||||
@@ -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 ?? (() => {})}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
<div
|
||||
className="flex items-center gap-1"
|
||||
aria-label="Query toolbar actions"
|
||||
>
|
||||
{/* Run Query: outline play that fills on hover; tooltip (delayed) reveals the shortcut */}
|
||||
<Tooltip
|
||||
content={
|
||||
@@ -84,6 +100,13 @@ export function QueryToolbar({
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* History dropdown */}
|
||||
<QueryHistoryDropdown
|
||||
connectionId={connectionId}
|
||||
onRestore={onRestore}
|
||||
onRun={onRunFromHistory}
|
||||
/>
|
||||
|
||||
{/* Auto format: icon only with tooltip */}
|
||||
<Tooltip content="Auto format query" side="bottom">
|
||||
<button
|
||||
@@ -96,6 +119,23 @@ export function QueryToolbar({
|
||||
<Wand2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Save Query icon */}
|
||||
<Tooltip content="Save query" side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Save query"
|
||||
onClick={() => {
|
||||
if (currentQueryText.trim()) {
|
||||
setSaveDialogOpen(true);
|
||||
}
|
||||
}}
|
||||
disabled={readOnly || !currentQueryText.trim()}
|
||||
className="flex items-center rounded px-2 py-1.5 hover:bg-surface-raised hover:text-text transition-colors cursor-pointer disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{dbType && (
|
||||
@@ -105,6 +145,13 @@ export function QueryToolbar({
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SaveQueryDialog
|
||||
open={saveDialogOpen}
|
||||
onClose={() => setSaveDialogOpen(false)}
|
||||
connectionId={connectionId}
|
||||
queryText={currentQueryText}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<typeof vi.fn>).mockImplementation(
|
||||
(selector: (s: Record<string, unknown>) => unknown) =>
|
||||
selector({
|
||||
saveCurrentQuery: mockSaveCurrentQuery,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setStoreMock();
|
||||
mockSaveCurrentQuery.mockResolvedValue({ id: "q-new" });
|
||||
});
|
||||
|
||||
describe("SaveQueryDialog", () => {
|
||||
it("renders nothing when closed", () => {
|
||||
render(
|
||||
<SaveQueryDialog
|
||||
open={false}
|
||||
onClose={() => {}}
|
||||
connectionId="c1"
|
||||
queryText="SELECT 1"
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText(/save query/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows form when open", () => {
|
||||
render(
|
||||
<SaveQueryDialog
|
||||
open={true}
|
||||
onClose={() => {}}
|
||||
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(
|
||||
<SaveQueryDialog open={true} onClose={onClose} connectionId="c1" queryText="SELECT 1" />,
|
||||
);
|
||||
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(
|
||||
<SaveQueryDialog open={true} onClose={onClose} connectionId="c1" queryText="SELECT 1" />,
|
||||
);
|
||||
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(
|
||||
<SaveQueryDialog open={true} onClose={onClose} connectionId="c1" queryText="SELECT 1" />,
|
||||
);
|
||||
await user.click(screen.getByText(/cancel/i));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<AnimatedModal open={open} onClose={handleClose}>
|
||||
<div className="w-80">
|
||||
<h3 className="font-heading text-text text-lg mb-1">Save Query</h3>
|
||||
<p className="text-sm text-text-muted mb-4">Save the current query for later use.</p>
|
||||
|
||||
<div className="flex flex-col gap-3 mb-4">
|
||||
<div>
|
||||
<label className="block text-xs text-text-muted mb-1">Name</label>
|
||||
<Input
|
||||
value={name}
|
||||
placeholder="Query name"
|
||||
onChange={(v) => { setName(v); if (error) setError(""); }}
|
||||
aria-label="Query name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-text-muted mb-1">Folder (optional)</label>
|
||||
<Input
|
||||
value={folder}
|
||||
placeholder="Folder"
|
||||
onChange={setFolder}
|
||||
aria-label="Folder"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-red-400 text-xs mb-3">{error}</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={handleClose}>Cancel</Button>
|
||||
<Button variant="primary" onClick={handleSave} disabled={saving}>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedModal>
|
||||
);
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
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<typeof vi.fn>).mockImplementation(
|
||||
(sel: (s: MockStore) => unknown) => sel(store),
|
||||
);
|
||||
return store;
|
||||
}
|
||||
|
||||
function renderPanel(ui: ReactElement) {
|
||||
return render(<TooltipProvider>{ui}</TooltipProvider>);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setMocks();
|
||||
});
|
||||
|
||||
describe("QueriesPanel", () => {
|
||||
it("renders Queries header with History/Saved dropdown", () => {
|
||||
renderPanel(
|
||||
<QueriesPanel connectionId="c1" onRestore={() => {}} />,
|
||||
);
|
||||
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(
|
||||
<QueriesPanel connectionId="c1" onRestore={() => {}} />,
|
||||
);
|
||||
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(
|
||||
<QueriesPanel connectionId="c1" onRestore={() => {}} />,
|
||||
);
|
||||
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(
|
||||
<QueriesPanel connectionId="c1" onRestore={() => {}} />,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(store.loadHistory).toHaveBeenCalledWith("c1");
|
||||
});
|
||||
});
|
||||
|
||||
it("loads saved queries scoped to the connection on mount", async () => {
|
||||
const store = setMocks();
|
||||
renderPanel(
|
||||
<QueriesPanel connectionId="c1" onRestore={() => {}} />,
|
||||
);
|
||||
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(
|
||||
<QueriesPanel connectionId="c1" onRestore={onRestore} />,
|
||||
);
|
||||
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(
|
||||
<QueriesPanel connectionId="c1" onRestore={onRestore} />,
|
||||
);
|
||||
// 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(
|
||||
<QueriesPanel connectionId="c1" onRestore={() => {}} />,
|
||||
);
|
||||
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(
|
||||
<QueriesPanel connectionId="c1" onRestore={() => {}} />,
|
||||
);
|
||||
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(
|
||||
<QueriesPanel connectionId="c1" onRestore={() => {}} />,
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<HTMLInputElement>(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 (
|
||||
<div className="flex flex-col h-full border-r border-border shrink-0" data-testid="queries-panel" style={style}>
|
||||
{/* Header row */}
|
||||
<div className="px-3 pt-3 pb-3 border-b border-border space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-normal text-text-muted">Queries</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{mode === "history" && (
|
||||
<>
|
||||
<Tooltip
|
||||
content={favoritesOnly ? "Show all queries" : "Show favorites only"}
|
||||
side="bottom"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Show favorites only"
|
||||
onClick={() => setFavoritesOnly(!favoritesOnly)}
|
||||
className={`w-7 h-7 rounded-md flex items-center justify-center cursor-pointer ${
|
||||
favoritesOnly
|
||||
? "text-accent bg-accent/10"
|
||||
: "text-text-muted hover:text-text hover:bg-surface-raised"
|
||||
}`}
|
||||
>
|
||||
<Star size={14} fill={favoritesOnly ? "currentColor" : "none"} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Clear history" side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Clear history"
|
||||
onClick={() => clearHistory(connectionId)}
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
<Tooltip content="Search queries" side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Search queries"
|
||||
onClick={toggleSearch}
|
||||
className={`w-7 h-7 rounded-md flex items-center justify-center cursor-pointer ${
|
||||
searchOpen
|
||||
? "text-accent bg-accent/10"
|
||||
: "text-text-muted hover:text-text hover:bg-surface-raised"
|
||||
}`}
|
||||
>
|
||||
<Search size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{/* Animated search input — row 2 */}
|
||||
<div
|
||||
className={`overflow-hidden transition-all duration-200 ease-out ${searchOpen ? "max-h-10 opacity-100" : "max-h-0 opacity-0"}`}
|
||||
>
|
||||
<div className="relative flex items-center">
|
||||
<Search
|
||||
size={12}
|
||||
className="absolute left-2.5 text-text-muted pointer-events-none"
|
||||
/>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => 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 && (
|
||||
<button
|
||||
onClick={() => handleSearchChange("")}
|
||||
className="absolute right-1 flex items-center justify-center w-5 h-5 rounded text-text-muted hover:text-text cursor-pointer"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Mode dropdown — its own row, below the search */}
|
||||
<div className="flex items-center">
|
||||
<SelectDropdown
|
||||
value={mode}
|
||||
onChange={(v) => setMode(v as "history" | "saved")}
|
||||
options={MODE_OPTIONS}
|
||||
variant="ghost"
|
||||
aria-label="History/Saved"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content area */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{mode === "history" && (
|
||||
<>
|
||||
{historyLoading && (
|
||||
<div className="px-4 py-8 text-center text-sm text-text-muted">Loading...</div>
|
||||
)}
|
||||
{historyError && (
|
||||
<ErrorBanner error={historyError} onRetry={() => loadHistory(connectionId)} />
|
||||
)}
|
||||
{!historyLoading && !historyError && filteredHistory.length === 0 && (
|
||||
<div className="px-4 py-8 text-center text-sm text-text-muted">
|
||||
{searchQuery || favoritesOnly ? "No matching queries" : "No queries yet"}
|
||||
</div>
|
||||
)}
|
||||
{filteredHistory.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => 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"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={entry.favorite ? "Unfavorite" : "Favorite"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleFavorite(entry.id, entry.connection_id);
|
||||
}}
|
||||
className={`shrink-0 mt-0.5 cursor-pointer ${
|
||||
entry.favorite ? "text-amber-400" : "text-text-muted opacity-40 group-hover:opacity-80"
|
||||
}`}
|
||||
>
|
||||
<Star className="h-3.5 w-3.5" fill={entry.favorite ? "currentColor" : "none"} />
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs text-text font-mono truncate">{entry.query_text}</div>
|
||||
<div className="flex items-center gap-2 mt-0.5 text-[10px] text-text-muted">
|
||||
{entry.status === "error" ? (
|
||||
<span className="text-red-400">Error</span>
|
||||
) : (
|
||||
<>
|
||||
{entry.execution_time_ms != null && <span>{entry.execution_time_ms}ms</span>}
|
||||
{entry.row_count != null && <span>{entry.row_count} rows</span>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{mode === "saved" && (
|
||||
<>
|
||||
{savedLoading && (
|
||||
<div className="px-4 py-8 text-center text-sm text-text-muted">Loading...</div>
|
||||
)}
|
||||
{savedError && (
|
||||
<ErrorBanner error={savedError} onRetry={() => loadSavedQueries(connectionId)} />
|
||||
)}
|
||||
{!savedLoading && !savedError && savedQueries && savedQueries.length === 0 && (
|
||||
<div className="px-4 py-8 text-center text-sm text-text-muted">
|
||||
No saved queries yet
|
||||
</div>
|
||||
)}
|
||||
{filteredSaved.map((q) => (
|
||||
<div
|
||||
key={q.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => 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"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-text font-medium">{q.name}</div>
|
||||
{q.folder && (
|
||||
<div className="text-[10px] text-text-muted mt-0.5">{q.folder}</div>
|
||||
)}
|
||||
<div className="text-xs text-text-muted font-mono truncate max-w-md mt-0.5">{q.query_text}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Delete saved query"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteSavedQuery(q.id);
|
||||
}}
|
||||
className="rounded p-1 text-text-muted hover:text-red-400 cursor-pointer"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+109
-31
@@ -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<ReturnType<typeof setTimeout> | null>(null);
|
||||
const triggerRef = useRef<HTMLSpanElement>(null);
|
||||
const tooltipRef = useRef<HTMLSpanElement>(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 (
|
||||
<span
|
||||
ref={triggerRef}
|
||||
className="relative inline-flex cursor-pointer"
|
||||
onMouseEnter={show}
|
||||
onMouseLeave={hide}
|
||||
@@ -95,17 +149,41 @@ export function Tooltip({ content, children, side = "top" }: TooltipProps) {
|
||||
onBlur={hide}
|
||||
>
|
||||
{children}
|
||||
{isActive && (
|
||||
{isActive &&
|
||||
createPortal(
|
||||
<span
|
||||
ref={tooltipRef}
|
||||
role="tooltip"
|
||||
className={`absolute z-50 px-2 py-1 text-xs rounded-md bg-surface-raised border border-border text-text shadow-lg whitespace-nowrap ${tc.wrapper}`}
|
||||
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" && (
|
||||
<span
|
||||
className={`absolute border-4 border-transparent ${tc.arrow}`}
|
||||
className="absolute left-1/2 -translate-x-1/2 top-full border-4 border-transparent border-t-surface-raised"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{side === "bottom" && (
|
||||
<span
|
||||
className="absolute left-1/2 -translate-x-1/2 bottom-full border-4 border-transparent border-b-surface-raised"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{side === "left" && (
|
||||
<span
|
||||
className="absolute left-full top-1/2 -translate-y-1/2 border-4 border-transparent border-l-surface-raised"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{side === "right" && (
|
||||
<span
|
||||
className="absolute right-full top-1/2 -translate-y-1/2 border-4 border-transparent border-r-surface-raised"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</span>,
|
||||
document.body,
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
+134
-1
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn().mockResolvedValue({ tables: [], relationships: [] }),
|
||||
@@ -19,8 +19,14 @@ import {
|
||||
executeQuery,
|
||||
getQueryHistory,
|
||||
clearQueryHistory,
|
||||
setHistoryFavorite,
|
||||
saveQuery,
|
||||
getSavedQueries,
|
||||
updateSavedQuery,
|
||||
deleteSavedQuery,
|
||||
} from "./commands";
|
||||
import type { SchemaGraph } from "./types";
|
||||
import type { QueryHistoryEntry } from "./commands";
|
||||
|
||||
describe("commands", () => {
|
||||
it("testConnection has correct signature", () => {
|
||||
@@ -102,4 +108,131 @@ describe("query commands", () => {
|
||||
await clearQueryHistory("conn-1");
|
||||
expect(invoke).toHaveBeenCalledWith("clear_query_history", { connectionId: "conn-1" });
|
||||
});
|
||||
|
||||
it("getQueryHistory with null calls invoke with null connectionId", async () => {
|
||||
vi.mocked(invoke).mockResolvedValueOnce([]);
|
||||
await getQueryHistory(null, 50, 0);
|
||||
expect(invoke).toHaveBeenCalledWith("get_query_history", {
|
||||
connectionId: null,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("clearQueryHistory with null clears all", async () => {
|
||||
vi.mocked(invoke).mockResolvedValueOnce(undefined);
|
||||
await clearQueryHistory(null);
|
||||
expect(invoke).toHaveBeenCalledWith("clear_query_history", {
|
||||
connectionId: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Query History — v6", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("QueryHistoryEntry includes favorite field", () => {
|
||||
const entry: QueryHistoryEntry = {
|
||||
id: "h1",
|
||||
connection_id: "c1",
|
||||
query_text: "SELECT 1",
|
||||
execution_time_ms: 42,
|
||||
row_count: 5,
|
||||
status: "success",
|
||||
error_message: null,
|
||||
executed_at: "2026-01-01T00:00:00Z",
|
||||
favorite: false, // NEW — must be accepted
|
||||
};
|
||||
expect(entry.favorite).toBe(false);
|
||||
});
|
||||
|
||||
it("setHistoryFavorite calls invoke with correct params", async () => {
|
||||
const mockInvoke = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mocked(invoke).mockImplementation(mockInvoke);
|
||||
|
||||
await setHistoryFavorite("entry-id-1", "conn-abc");
|
||||
expect(mockInvoke).toHaveBeenCalledWith("set_history_favorite", {
|
||||
id: "entry-id-1",
|
||||
connectionId: "conn-abc",
|
||||
});
|
||||
});
|
||||
|
||||
it("saveQuery calls invoke with correct params", async () => {
|
||||
const mockInvoke = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mocked(invoke).mockImplementation(mockInvoke);
|
||||
|
||||
await saveQuery({
|
||||
connectionId: "conn-xyz",
|
||||
name: "My Saved Query",
|
||||
queryText: "SELECT * FROM users",
|
||||
folder: "reports",
|
||||
});
|
||||
expect(mockInvoke).toHaveBeenCalledWith("save_query", {
|
||||
connectionId: "conn-xyz",
|
||||
name: "My Saved Query",
|
||||
queryText: "SELECT * FROM users",
|
||||
folder: "reports",
|
||||
});
|
||||
});
|
||||
|
||||
it("saveQuery accepts null connectionId for global queries", async () => {
|
||||
const mockInvoke = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mocked(invoke).mockImplementation(mockInvoke);
|
||||
|
||||
await saveQuery({
|
||||
connectionId: null,
|
||||
name: "Global query",
|
||||
queryText: "SELECT 1",
|
||||
folder: "",
|
||||
});
|
||||
expect(mockInvoke).toHaveBeenCalledWith("save_query", {
|
||||
connectionId: null,
|
||||
name: "Global query",
|
||||
queryText: "SELECT 1",
|
||||
folder: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("getSavedQueries calls invoke with correct params", async () => {
|
||||
const mockInvoke = vi.fn().mockResolvedValue([]);
|
||||
vi.mocked(invoke).mockImplementation(mockInvoke);
|
||||
|
||||
await getSavedQueries("conn-123");
|
||||
expect(mockInvoke).toHaveBeenCalledWith("get_saved_queries", {
|
||||
connectionId: "conn-123",
|
||||
});
|
||||
});
|
||||
|
||||
it("getSavedQueries accepts null for all-connections", async () => {
|
||||
const mockInvoke = vi.fn().mockResolvedValue([]);
|
||||
vi.mocked(invoke).mockImplementation(mockInvoke);
|
||||
|
||||
await getSavedQueries(null);
|
||||
expect(mockInvoke).toHaveBeenCalledWith("get_saved_queries", {
|
||||
connectionId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("updateSavedQuery calls invoke with correct params", async () => {
|
||||
const mockInvoke = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mocked(invoke).mockImplementation(mockInvoke);
|
||||
|
||||
await updateSavedQuery("q-id", { name: "Renamed" });
|
||||
expect(mockInvoke).toHaveBeenCalledWith("update_saved_query", {
|
||||
id: "q-id",
|
||||
patch: { name: "Renamed" },
|
||||
});
|
||||
});
|
||||
|
||||
it("deleteSavedQuery calls invoke with correct params", async () => {
|
||||
const mockInvoke = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mocked(invoke).mockImplementation(mockInvoke);
|
||||
|
||||
await deleteSavedQuery("q-id");
|
||||
expect(mockInvoke).toHaveBeenCalledWith("delete_saved_query", {
|
||||
id: "q-id",
|
||||
});
|
||||
});
|
||||
});
|
||||
+59
-2
@@ -159,6 +159,30 @@ export interface QueryHistoryEntry {
|
||||
status: string;
|
||||
error_message: string | null;
|
||||
executed_at: string;
|
||||
favorite: boolean; // NEW — v6
|
||||
}
|
||||
|
||||
export interface SavedQuery {
|
||||
id: string;
|
||||
connection_id: string | null;
|
||||
name: string;
|
||||
query_text: string;
|
||||
folder: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface SaveQueryInput {
|
||||
connectionId: string | null;
|
||||
name: string;
|
||||
queryText: string;
|
||||
folder: string;
|
||||
}
|
||||
|
||||
export interface UpdateSavedQueryPatch {
|
||||
name?: string;
|
||||
queryText?: string;
|
||||
folder?: string;
|
||||
}
|
||||
|
||||
export async function executeQuery(
|
||||
@@ -171,13 +195,46 @@ export async function executeQuery(
|
||||
}
|
||||
|
||||
export async function getQueryHistory(
|
||||
connectionId: string,
|
||||
connectionId: string | null,
|
||||
limit: number,
|
||||
offset: number,
|
||||
): Promise<QueryHistoryEntry[]> {
|
||||
return invoke<QueryHistoryEntry[]>("get_query_history", { connectionId, limit, offset });
|
||||
}
|
||||
|
||||
export async function clearQueryHistory(connectionId: string): Promise<void> {
|
||||
export async function clearQueryHistory(connectionId: string | null): Promise<void> {
|
||||
return invoke<void>("clear_query_history", { connectionId });
|
||||
}
|
||||
|
||||
export async function setHistoryFavorite(
|
||||
id: string,
|
||||
connectionId: string,
|
||||
): Promise<void> {
|
||||
return invoke<void>("set_history_favorite", { id, connectionId });
|
||||
}
|
||||
|
||||
export async function saveQuery(input: SaveQueryInput): Promise<SavedQuery> {
|
||||
return invoke<SavedQuery>("save_query", {
|
||||
connectionId: input.connectionId,
|
||||
name: input.name,
|
||||
queryText: input.queryText,
|
||||
folder: input.folder,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getSavedQueries(
|
||||
connectionId: string | null,
|
||||
): Promise<SavedQuery[]> {
|
||||
return invoke<SavedQuery[]>("get_saved_queries", { connectionId });
|
||||
}
|
||||
|
||||
export async function updateSavedQuery(
|
||||
id: string,
|
||||
patch: UpdateSavedQueryPatch,
|
||||
): Promise<void> {
|
||||
return invoke<void>("update_saved_query", { id, patch });
|
||||
}
|
||||
|
||||
export async function deleteSavedQuery(id: string): Promise<void> {
|
||||
return invoke<void>("delete_saved_query", { id });
|
||||
}
|
||||
@@ -372,6 +372,7 @@ describe("Schema graph types", () => {
|
||||
is_pk: false,
|
||||
is_fk: true,
|
||||
is_unique: false,
|
||||
is_nullable: false,
|
||||
fk_ref: ["public", "users", "id"],
|
||||
};
|
||||
expect(col.name).toBe("user_id");
|
||||
@@ -385,8 +386,8 @@ describe("Schema graph types", () => {
|
||||
schema: "public",
|
||||
table_type: "TABLE",
|
||||
columns: [
|
||||
{ name: "id", data_type: "integer", is_pk: true, is_fk: false, is_unique: true, fk_ref: null },
|
||||
{ name: "user_id", data_type: "integer", is_pk: false, is_fk: true, is_unique: false, fk_ref: ["public", "users", "id"] },
|
||||
{ name: "id", data_type: "integer", is_pk: true, is_fk: false, is_unique: true, is_nullable: false, fk_ref: null },
|
||||
{ name: "user_id", data_type: "integer", is_pk: false, is_fk: true, is_unique: false, is_nullable: false, fk_ref: ["public", "users", "id"] },
|
||||
],
|
||||
};
|
||||
expect(node.name).toBe("orders");
|
||||
@@ -424,6 +425,7 @@ describe("Schema graph types", () => {
|
||||
is_pk: false,
|
||||
is_fk: false,
|
||||
is_unique: false,
|
||||
is_nullable: true,
|
||||
fk_ref: null,
|
||||
};
|
||||
expect(col.fk_ref).toBeNull();
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { useQueryStore } from "./queryStore";
|
||||
import * as commands from "../lib/commands";
|
||||
|
||||
// Mock the commands module
|
||||
vi.mock("../lib/commands", () => ({
|
||||
getQueryHistory: vi.fn().mockResolvedValue([]),
|
||||
clearQueryHistory: vi.fn().mockResolvedValue(undefined),
|
||||
setHistoryFavorite: vi.fn().mockResolvedValue(undefined),
|
||||
getSavedQueries: vi.fn().mockResolvedValue([]),
|
||||
saveQuery: vi.fn().mockResolvedValue({
|
||||
id: "q-new",
|
||||
connection_id: "c1",
|
||||
name: "New",
|
||||
query_text: "SELECT 1",
|
||||
folder: "",
|
||||
created_at: "2026-01-01",
|
||||
updated_at: "2026-01-01",
|
||||
}),
|
||||
updateSavedQuery: vi.fn().mockResolvedValue(undefined),
|
||||
deleteSavedQuery: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset the store to initial state before each test
|
||||
useQueryStore.setState({
|
||||
history: null,
|
||||
historyLoading: false,
|
||||
historyError: null,
|
||||
historyStale: true,
|
||||
savedQueries: null,
|
||||
savedLoading: false,
|
||||
savedError: null,
|
||||
historyScope: null,
|
||||
historySearch: "",
|
||||
favoritesOnly: false,
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("queryStore — history", () => {
|
||||
it("loadHistory fetches and stores entries, clears stale flag", async () => {
|
||||
const mockEntries = [
|
||||
{
|
||||
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,
|
||||
},
|
||||
];
|
||||
vi.mocked(commands.getQueryHistory).mockResolvedValueOnce(mockEntries);
|
||||
|
||||
await useQueryStore.getState().loadHistory("c1");
|
||||
|
||||
const state = useQueryStore.getState();
|
||||
expect(state.history).toEqual(mockEntries);
|
||||
expect(state.historyStale).toBe(false);
|
||||
expect(state.historyLoading).toBe(false);
|
||||
expect(state.historyError).toBeNull();
|
||||
});
|
||||
|
||||
it("loadHistory sets error on failure", async () => {
|
||||
vi.mocked(commands.getQueryHistory).mockRejectedValueOnce(new Error("fail"));
|
||||
|
||||
await useQueryStore.getState().loadHistory("c1");
|
||||
|
||||
const state = useQueryStore.getState();
|
||||
expect(state.historyError).toBe("fail");
|
||||
expect(state.historyLoading).toBe(false);
|
||||
});
|
||||
|
||||
it("clearHistory calls clearQueryHistory then resets", async () => {
|
||||
await useQueryStore.getState().clearHistory("c1");
|
||||
|
||||
expect(commands.clearQueryHistory).toHaveBeenCalledWith("c1");
|
||||
const state = useQueryStore.getState();
|
||||
expect(state.history).toEqual([]);
|
||||
expect(state.historyStale).toBe(false);
|
||||
});
|
||||
|
||||
it('loadHistory("") maps the All-connections sentinel to null (global scope)', async () => {
|
||||
await useQueryStore.getState().loadHistory("");
|
||||
expect(commands.getQueryHistory).toHaveBeenCalledWith(null, 200, 0);
|
||||
});
|
||||
|
||||
it("loadHistory(undefined) maps to null (global scope)", async () => {
|
||||
await useQueryStore.getState().loadHistory(undefined);
|
||||
expect(commands.getQueryHistory).toHaveBeenCalledWith(null, 200, 0);
|
||||
});
|
||||
|
||||
it('clearHistory("") maps the All-connections sentinel to null (global scope)', async () => {
|
||||
await useQueryStore.getState().clearHistory("");
|
||||
expect(commands.clearQueryHistory).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it("toggleFavorite optimistic-updates then refetches on failure", async () => {
|
||||
const mockEntries = [
|
||||
{ id: "h1", connection_id: "c1", query_text: "X", execution_time_ms: null, row_count: null, status: "success", error_message: null, executed_at: "", favorite: false },
|
||||
{ id: "h2", connection_id: "c1", query_text: "Y", execution_time_ms: null, row_count: null, status: "success", error_message: null, executed_at: "", favorite: false },
|
||||
];
|
||||
vi.mocked(commands.getQueryHistory).mockResolvedValueOnce(mockEntries);
|
||||
await useQueryStore.getState().loadHistory("c1");
|
||||
|
||||
// Toggle h1
|
||||
vi.mocked(commands.setHistoryFavorite).mockResolvedValueOnce(undefined);
|
||||
await useQueryStore.getState().toggleFavorite("h1", "c1");
|
||||
|
||||
let state = useQueryStore.getState();
|
||||
expect(state.history![0].favorite).toBe(true); // optimistic set
|
||||
|
||||
// On failure, refetch
|
||||
const reverted = [
|
||||
{ id: "h1", connection_id: "c1", query_text: "X", execution_time_ms: null, row_count: null, status: "success", error_message: null, executed_at: "", favorite: false },
|
||||
{ id: "h2", connection_id: "c1", query_text: "Y", execution_time_ms: null, row_count: null, status: "success", error_message: null, executed_at: "", favorite: false },
|
||||
];
|
||||
vi.mocked(commands.setHistoryFavorite).mockRejectedValueOnce(new Error("boom"));
|
||||
vi.mocked(commands.getQueryHistory).mockResolvedValueOnce(reverted);
|
||||
|
||||
// First toggle h2 — should fail and refetch
|
||||
await useQueryStore.getState().toggleFavorite("h2", "c1");
|
||||
state = useQueryStore.getState();
|
||||
expect(state.history![1].favorite).toBe(false); // rolled back via refetch
|
||||
});
|
||||
});
|
||||
|
||||
describe("queryStore — saved queries", () => {
|
||||
it("loadSavedQueries fetches and stores entries", async () => {
|
||||
const mockSaved = [{ id: "q1", connection_id: "c1", name: "Q1", query_text: "SELECT 1", folder: "", created_at: "", updated_at: "" }];
|
||||
vi.mocked(commands.getSavedQueries).mockResolvedValueOnce(mockSaved);
|
||||
|
||||
await useQueryStore.getState().loadSavedQueries("c1");
|
||||
|
||||
const state = useQueryStore.getState();
|
||||
expect(state.savedQueries).toEqual(mockSaved);
|
||||
expect(state.savedLoading).toBe(false);
|
||||
expect(state.savedError).toBeNull();
|
||||
});
|
||||
|
||||
it("saveCurrentQuery calls saveQuery with correct input", async () => {
|
||||
vi.mocked(commands.saveQuery).mockResolvedValueOnce({
|
||||
id: "new-q", connection_id: "c1", name: "MyQ", query_text: "SELECT 2", folder: "r", created_at: "", updated_at: "",
|
||||
});
|
||||
|
||||
await useQueryStore.getState().saveCurrentQuery({ connectionId: "c1", name: "MyQ", queryText: "SELECT 2", folder: "r" });
|
||||
|
||||
expect(commands.saveQuery).toHaveBeenCalledWith({ connectionId: "c1", name: "MyQ", queryText: "SELECT 2", folder: "r" });
|
||||
});
|
||||
|
||||
it("renameSavedQuery calls updateSavedQuery", async () => {
|
||||
await useQueryStore.getState().renameSavedQuery("q1", { name: "Renamed" });
|
||||
expect(commands.updateSavedQuery).toHaveBeenCalledWith("q1", { name: "Renamed" });
|
||||
});
|
||||
|
||||
it("deleteSavedQuery calls deleteSavedQuery and removes from list", async () => {
|
||||
const mockSaved = [{ id: "q1", connection_id: "c1", name: "Q1", query_text: "SELECT 1", folder: "", created_at: "", updated_at: "" }];
|
||||
vi.mocked(commands.getSavedQueries).mockResolvedValueOnce(mockSaved);
|
||||
await useQueryStore.getState().loadSavedQueries("c1");
|
||||
|
||||
vi.mocked(commands.deleteSavedQuery).mockResolvedValueOnce(undefined);
|
||||
await useQueryStore.getState().deleteSavedQuery("q1");
|
||||
|
||||
expect(commands.deleteSavedQuery).toHaveBeenCalledWith("q1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { create } from "zustand";
|
||||
import type { QueryHistoryEntry, SavedQuery, SaveQueryInput, UpdateSavedQueryPatch } from "../lib/commands";
|
||||
import {
|
||||
getQueryHistory,
|
||||
clearQueryHistory,
|
||||
setHistoryFavorite,
|
||||
getSavedQueries,
|
||||
saveQuery,
|
||||
updateSavedQuery,
|
||||
deleteSavedQuery,
|
||||
} from "../lib/commands";
|
||||
|
||||
interface QueryState {
|
||||
// History
|
||||
history: QueryHistoryEntry[] | null;
|
||||
historyLoading: boolean;
|
||||
historyError: string | null;
|
||||
historyStale: boolean;
|
||||
historyScope: string | null; // null = all connections, string = connectionId
|
||||
historySearch: string;
|
||||
favoritesOnly: boolean;
|
||||
|
||||
// Saved queries
|
||||
savedQueries: SavedQuery[] | null;
|
||||
savedLoading: boolean;
|
||||
savedError: string | null;
|
||||
|
||||
// Actions — History
|
||||
loadHistory: (connectionId?: string) => Promise<void>;
|
||||
clearHistory: (connectionId?: string) => Promise<void>;
|
||||
toggleFavorite: (id: string, connectionId: string) => Promise<void>;
|
||||
invalidateHistory: (connectionId: string) => void;
|
||||
setHistoryScope: (scope: string | null) => void;
|
||||
setHistorySearch: (search: string) => void;
|
||||
setFavoritesOnly: (on: boolean) => void;
|
||||
|
||||
// Actions — Saved queries
|
||||
loadSavedQueries: (connectionId?: string | null) => Promise<void>;
|
||||
saveCurrentQuery: (input: SaveQueryInput) => Promise<SavedQuery>;
|
||||
renameSavedQuery: (id: string, patch: UpdateSavedQueryPatch) => Promise<void>;
|
||||
deleteSavedQuery: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useQueryStore = create<QueryState>((set, get) => ({
|
||||
history: null,
|
||||
historyLoading: false,
|
||||
historyError: null,
|
||||
historyStale: true,
|
||||
historyScope: null,
|
||||
historySearch: "",
|
||||
favoritesOnly: false,
|
||||
savedQueries: null,
|
||||
savedLoading: false,
|
||||
savedError: null,
|
||||
|
||||
loadHistory: async (connectionId) => {
|
||||
set({ historyLoading: true, historyError: null });
|
||||
try {
|
||||
const rows = await getQueryHistory(connectionId || null, 200, 0);
|
||||
set({ history: rows, historyStale: false, historyLoading: false });
|
||||
} catch (e) {
|
||||
set({ historyError: e instanceof Error ? e.message : String(e), historyLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
clearHistory: async (connectionId) => {
|
||||
await clearQueryHistory(connectionId || null);
|
||||
set({ history: [], historyStale: false });
|
||||
},
|
||||
|
||||
toggleFavorite: async (id, connectionId) => {
|
||||
const prev = get().history;
|
||||
if (prev) {
|
||||
// Optimistic toggle
|
||||
set({
|
||||
history: prev.map((e) =>
|
||||
e.id === id ? { ...e, favorite: !e.favorite } : e,
|
||||
),
|
||||
});
|
||||
}
|
||||
try {
|
||||
await setHistoryFavorite(id, connectionId);
|
||||
} catch {
|
||||
// Roll back by refetching
|
||||
await get().loadHistory(connectionId);
|
||||
}
|
||||
},
|
||||
|
||||
invalidateHistory: (connectionId) => {
|
||||
// Only invalidate if the current scope includes this connection
|
||||
const scope = get().historyScope;
|
||||
if (scope === null || scope === connectionId) {
|
||||
set({ historyStale: true });
|
||||
}
|
||||
},
|
||||
|
||||
setHistoryScope: (scope) => set({ historyScope: scope, historyStale: true }),
|
||||
setHistorySearch: (search) => set({ historySearch: search }),
|
||||
setFavoritesOnly: (on) => set({ favoritesOnly: on }),
|
||||
|
||||
loadSavedQueries: async (connectionId) => {
|
||||
set({ savedLoading: true, savedError: null });
|
||||
try {
|
||||
const rows = await getSavedQueries(connectionId ?? null);
|
||||
set({ savedQueries: rows, savedLoading: false });
|
||||
} catch (e) {
|
||||
set({ savedError: e instanceof Error ? e.message : String(e), savedLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
saveCurrentQuery: async (input) => {
|
||||
const result = await saveQuery(input);
|
||||
// Refresh the list
|
||||
await get().loadSavedQueries(input.connectionId);
|
||||
return result;
|
||||
},
|
||||
|
||||
renameSavedQuery: async (id, patch) => {
|
||||
await updateSavedQuery(id, patch);
|
||||
// Refresh — we don't know which scope the panel was viewing, so reload all
|
||||
await get().loadSavedQueries(null);
|
||||
},
|
||||
|
||||
deleteSavedQuery: async (id) => {
|
||||
await deleteSavedQuery(id);
|
||||
// Optimistic: remove from local state
|
||||
const prev = get().savedQueries;
|
||||
if (prev) {
|
||||
set({ savedQueries: prev.filter((q) => q.id !== id) });
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,67 @@
|
||||
// Bun test runner DOM setup: `bun test` uses Bun's native runner, which does
|
||||
// not read vite.config.ts and provides no DOM. Register jsdom globals (plus the
|
||||
// ResizeObserver polyfill used by @xyflow/react) so the vitest-authored suite
|
||||
// runs under `bun test` too. This MUST load before any module that imports
|
||||
// @testing-library/react, because bun caches modules process-wide and
|
||||
// testing-library's `screen` binds `document.body` at module-evaluation time.
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
if (typeof globalThis.document === "undefined") {
|
||||
const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>", {
|
||||
url: "http://localhost/",
|
||||
pretendToBeVisual: true,
|
||||
});
|
||||
|
||||
const { window } = dom;
|
||||
for (const key of Object.getOwnPropertyNames(window)) {
|
||||
if (
|
||||
key !== "window" &&
|
||||
key !== "self" &&
|
||||
key !== "top" &&
|
||||
!(key in globalThis)
|
||||
) {
|
||||
(globalThis as Record<string, unknown>)[key] = (
|
||||
window as unknown as Record<string, unknown>
|
||||
)[key];
|
||||
}
|
||||
}
|
||||
globalThis.window = window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = window.document;
|
||||
globalThis.navigator = window.navigator;
|
||||
globalThis.HTMLElement = window.HTMLElement;
|
||||
globalThis.Element = window.Element;
|
||||
globalThis.Node = window.Node;
|
||||
globalThis.getComputedStyle = window.getComputedStyle.bind(window);
|
||||
globalThis.requestAnimationFrame = (cb: FrameRequestCallback) =>
|
||||
setTimeout(() => cb(Date.now()), 0) as unknown as number;
|
||||
globalThis.cancelAnimationFrame = (id: number) => clearTimeout(id);
|
||||
globalThis.matchMedia =
|
||||
globalThis.matchMedia ||
|
||||
((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
}));
|
||||
}
|
||||
|
||||
// Polyfill ResizeObserver for jsdom (required by @xyflow/react)
|
||||
if (typeof globalThis.ResizeObserver === "undefined") {
|
||||
globalThis.ResizeObserver = class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
};
|
||||
}
|
||||
|
||||
// jsdom does not implement the execCommand family; monaco-editor probes
|
||||
// document.queryCommandSupported at import time and crashes without it.
|
||||
if (typeof globalThis.document.queryCommandSupported !== "function") {
|
||||
globalThis.document.queryCommandSupported = () => false;
|
||||
globalThis.document.queryCommandEnabled = () => false;
|
||||
globalThis.document.execCommand = () => false;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Bun test runner setup: register testing-library cleanup (bun does not inject
|
||||
// a global `afterEach`, so RTL's auto-cleanup never hooks in) and minimal
|
||||
// vitest-compat shims for `vi.mocked`/`vi.hoisted` (bun's compat `vi` omits
|
||||
// them). Loaded via bunfig.toml [test].preload — bun-dom.ts must run first so
|
||||
// `document` exists before @testing-library/react evaluates.
|
||||
import { afterEach, vi } from "vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom";
|
||||
|
||||
// NOTE: under `bun test`, importing from "vitest" resolves to bun's built-in
|
||||
// vitest-compat module (which provides afterEach and a limited vi), while under
|
||||
// `tsc` it resolves to the real vitest types.
|
||||
|
||||
// @testing-library/react auto-cleanup relies on a global `afterEach`, which
|
||||
// bun does not inject; register it explicitly so DOM doesn't leak between tests.
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// Minimal vi shims matching vitest semantics used by the suite.
|
||||
(vi as unknown as { mocked: unknown }).mocked = (m: unknown) => m;
|
||||
(vi as unknown as { hoisted: unknown }).hoisted = <T>(factory: () => T): T =>
|
||||
factory();
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
// jsdom ships no type declarations and @types/jsdom is not installed; this
|
||||
// ambient declaration keeps `src/test/bun-dom.ts` (used only by `bun test`)
|
||||
// type-clean. The JSDOM API is consumed through `any`.
|
||||
declare module "jsdom";
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import "@testing-library/jest-dom";
|
||||
|
||||
// Polyfill ResizeObserver for jsdom (required by @xyflow/react)
|
||||
global.ResizeObserver = class ResizeObserver {
|
||||
globalThis.ResizeObserver = class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
|
||||
+5
-1
@@ -1,4 +1,4 @@
|
||||
import { defineConfig } from "vite";
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
@@ -30,4 +30,8 @@ export default defineConfig(async () => ({
|
||||
ignored: ["**/src-tauri/**"],
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
setupFiles: ["src/test/setup.ts"],
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user