From 187c6b3f1ccab31720e77f004d73e3d36f6897b6 Mon Sep 17 00:00:00 2001 From: Keenan Simpson Date: Mon, 1 Jun 2026 12:42:07 +0200 Subject: [PATCH] feat: tab context menu, inline quoting, session restore, handleSave target DB, PSQL console windows fix --- CHANGELOG.md | 6 +- src-tauri/capabilities/default.json | 2 + src/components/layout/MainContent.tsx | 372 +++++++++++++++++++++----- src/components/ui/PsqlWindow.tsx | 23 +- 4 files changed, 335 insertions(+), 68 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f98ffd3..5492200 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,11 @@ All notable changes to QueryDen are documented here. This project adheres to [Se - **[#15](https://github.com/openidle-dev/queryden/issues/15) — Argon2id parameters are now explicitly locked.** Replaced `Argon2::default()` with an explicit `Params::new(19456, 2, 1, None)` — 19 MiB memory cost, 2 iterations, 1 parallel thread, using Argon2id v1.3. These match the actual defaults of the argon2 0.5 crate and prevent silent parameter drift across crate version bumps. ### Fixed -- **[#182](https://github.com/openidle-dev/queryden/issues/182) — PostgreSQL OID type now handled explicitly.** The `tauri-plugin-sql` patch's PostgreSQL decoder now has a dedicated `"OID"` match arm that decodes as `i32`, avoiding fallthrough through the wildcard chain. Fixes "unsupported datatype: OID" errors when querying system catalogs like `pg_stat_activity`. +- **Tab right-click context menu.** Right-clicking a query editor tab now shows a DataGrip-style menu: Close, Close Others, Close All, Rename, Duplicate, Copy File Path, Open in Explorer. Copy File Path copies the tab's auto-save `.sql` path to the clipboard with fallback (textarea + `execCommand`) for Linux WebKitGTK where the async clipboard API is unavailable. Open in Explorer creates the auto-save directory if it doesn't exist, then reveals the file via `revealItemInDir` with an `openPath` fallback on all platforms (Windows, Linux, macOS). +- **Inline editing SQL now quotes table/column names.** When inserting, updating, or deleting rows from the results grid, the generated `INSERT`/`UPDATE`/`DELETE` SQL uses `quoteIdentifier()` for all table names and column names — handling mixed-case identifiers, reserved words, and schema-qualified names (`"schema"."table"`) consistently across PostgreSQL, MySQL/MariaDB, SQLite, and CockroachDB. +- **Session restore tabs now visible without a connection.** Restored query tabs (from `sessions.json`) render immediately on launch — the tab strip and Monaco editor appear even before the user selects a database from the sidebar. Previously all tabs were hidden behind the `isDatabaseReady` gate, requiring a manual connection click. The toolbar (Run/Save/Format buttons) stays gated as those actions require a live database. +- **`handleSave` now respects tab-scoped target databases.** When adding a new row and saving, the `INSERT`/`UPDATE` SQL now routes to the tab's target database (set when opening a table from the Data Explorer) instead of relying on the global `currentDb` connection. The save operation also resolves the target connection's own credentials (host, port, type, vault) via `connections.find()`, mirroring how `executeQuery` handles tab-scoped connections. Fixes "relation does not exist" errors that occurred when the tab's target differed from the active connection's database. +- **PSQL Console output no longer disappears on Windows after command execution.** On Windows WebView2, React 18 can split its batch so that `setIsExecuting(false)` renders one frame before `setQueryTabs` commits the new `psqlEntries`. In that split frame neither `liveOutput` (already `[]`) nor `entries` (stale) carried the result. Fixed by (a) stashing the last non-empty `psqlOutput` in a ref and always passing it as `liveOutput`, and (b) adding a grace period in `PsqlWindow` that keeps the live section visible for up to 300ms post-execution (or until entries reflect the output, whichever comes first). - **[#157](https://github.com/openidle-dev/queryden/issues/157) — SQLite provider form stuck on SSH tab after provider switch.** The "Back to Providers" button now resets the active tab to General, preventing a state where the SQLite form is unreachable because the tab row is hidden for SQLite connections. - **[#183](https://github.com/openidle-dev/queryden/issues/183) — Activity monitor database filter now works correctly.** The `datname` filter in the PostgreSQL activity monitor was building SQL with double-quoted identifiers instead of string literals, causing a "column does not exist" error. Fixed by using a bind parameter (`$1`) instead of `quoteIdentifier`. - **[#138](https://github.com/openidle-dev/queryden/issues/138) — Unsaved-query exit warning no longer shows stale dirty state across tabs editing the same saved query.** When the same saved query is open in multiple tabs and one tab saves, all other tabs sharing that saved query now refresh their dirty-state snapshot so the exit prompt correctly reflects whether each tab's content matches the on-disk text. The toolbar Save Query button now also checks for existing queries and updates the tab's dirty state like Ctrl+S. diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 752e080..e2ec8af 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -8,6 +8,8 @@ "core:window:allow-destroy", "opener:default", "opener:allow-open-url", + "opener:allow-open-path", + "opener:allow-reveal-item-in-dir", "sql:default", "sql:allow-execute", "dialog:default", diff --git a/src/components/layout/MainContent.tsx b/src/components/layout/MainContent.tsx index ef4c277..07b78e6 100644 --- a/src/components/layout/MainContent.tsx +++ b/src/components/layout/MainContent.tsx @@ -4,7 +4,7 @@ import { ResultsPanel } from "../results/ResultsPanel"; import { useConnections } from "../../contexts/useConnections"; import { useQueryHistory } from "../../store/queryHistoryStore"; import { useSettings } from "../../store/settingsStore"; -import { Play, Plus, X, ChevronDown, ChevronRight, Terminal, Database, Sparkles, GitCompare, Save, Square, Activity, Loader2, CheckCircle, XCircle } from "lucide-react"; +import { Play, Plus, X, ChevronDown, ChevronRight, Terminal, Database, Sparkles, GitCompare, Save, Square, Activity, Loader2, CheckCircle, XCircle, Copy as CopyIcon, FolderOpen, Clipboard, Pencil } from "lucide-react"; import { useSavedQueries } from "../../store/savedQueryStore"; import { useConfirmDialog } from "../ui/ConfirmDialog"; import { Copy, FileText, BarChart2, Activity as ActivityIcon, Layers, Table } from "lucide-react"; @@ -18,6 +18,8 @@ import { useLocalHistory } from "../../store/localHistoryStore"; import { Button } from "../ui/Button"; import { IconButton } from "../ui/IconButton"; import { Select } from "../ui/Select"; +import { Menu, MenuItem, MenuSeparator } from "../ui/Menu"; +import { quoteIdentifier, type DatabaseType } from "../../utils/sqlSecurity"; // Lazy-loaded editor — Monaco (core + SQL contribution) is the single // heaviest dependency in the app. Pulling QueryEditor out of the cold-start @@ -126,18 +128,32 @@ export function MainContent() { const [executionTime, setExecutionTime] = useState(0); const [runningTimeMs, setRunningTimeMs] = useState(0); const [lastColumns, setLastColumns] = useState([]); + const [toastMessage, setToastMessage] = useState(null); + const [showToast, setShowToast] = useState(false); + const showToastMessage = useCallback((msg: string) => { + setToastMessage(msg); + setShowToast(true); + setTimeout(() => setShowToast(false), 2000); + }, []); /** Raw psql stdout lines — only populated when running via CLI */ const [psqlOutput, setPsqlOutput] = useState([]); const psqlOutputRef = useRef([]); + // Retain the last non-empty psqlOutput so PsqlWindow's live section doesn't + // go blank before psqlEntries are committed (WebView2 on Windows can split + // React 18's batch, creating a frame where neither liveOutput nor entries + // carries the result). + const stashPsqlOutputRef = useRef([]); // Wrapper to keep ref and state in sync const appendPsqlOutput = (linesOrFn: string[] | ((prev: string[]) => string[])) => { const next = typeof linesOrFn === 'function' ? linesOrFn(psqlOutputRef.current) : [...psqlOutputRef.current, ...linesOrFn]; psqlOutputRef.current = next; setPsqlOutput(next); + stashPsqlOutputRef.current = next; }; const clearPsqlOutput = () => { psqlOutputRef.current = []; setPsqlOutput([]); + stashPsqlOutputRef.current = []; }; // Ref to always have the latest query text from the active editor // This avoids stale closures where React state hasn't flushed yet @@ -160,6 +176,7 @@ export function MainContent() { useEffect(() => { connectionsRef.current = connections; }, [connections]); useEffect(() => { foldersRef.current = folders; }, [folders]); const [activeTableName, setActiveTableName] = useState(null); + const [contextMenu, setContextMenu] = useState<{ x: number; y: number; tabId: string } | null>(null); /** * Column name -> SQL type for the current table-backed result set, plus the * table name those types belong to. Populated when the explorer opens a @@ -330,6 +347,114 @@ export function MainContent() { }; }, [settings.autoSaveEnabled, settings.autoSaveInterval, settings.autoSavePath, queryTabs]); + async function getTabAutoSavePath(tab: QueryTab): Promise { + if (!tab.query || tab.query.trim() === "") return null; + try { + const { join } = await import("@tauri-apps/api/path"); + let dir: string; + if (settings.autoSavePath) { + dir = settings.autoSavePath; + } else { + const { appDataDir } = await import("@tauri-apps/api/path"); + dir = await appDataDir().then((b: string) => join(b, "auto-save")); + } + const conn = connectionsRef.current.find( + c => c.id === (tab.target?.connectionId || activeConnRef.current?.id), + ); + const folder = conn?.folderId + ? foldersRef.current.find(f => f.id === conn.folderId) + : null; + const folderPart = (folder?.name || conn?.name || "unknown") + .replace(/[^a-zA-Z0-9_-]/g, "_"); + const dbPart = (tab.target?.database || selectedDbRef.current || "none") + .replace(/[^a-zA-Z0-9_-]/g, "_"); + const shortId = tab.id.slice(0, 8); + return await join(dir, `${folderPart}_${dbPart}_${shortId}.sql`); + } catch { + return null; + } + } + + /** Cross-platform clipboard write: uses the modern async API first, falls + * back to execCommand('copy') for platforms where the async API isn't + * available (e.g. WebKitGTK on Linux). */ + async function copyToClipboard(text: string): Promise { + try { + await navigator.clipboard.writeText(text); + return; + } catch { + // fall through to execCommand + } + // execCommand fallback — works in all webview engines (WebKitGTK, + // WebView2, WKWebView) despite being technically deprecated. + const el = document.createElement("textarea"); + el.value = text; + el.style.position = "fixed"; + el.style.left = "-9999px"; + el.style.top = "-9999px"; + el.setAttribute("readonly", ""); + document.body.appendChild(el); + el.focus(); + el.select(); + try { + document.execCommand("copy"); + } catch { + // clipboard write failed on all paths + } + document.body.removeChild(el); + } + + async function openTabFileInExplorer(tab: QueryTab): Promise { + const path = await getTabAutoSavePath(tab); + if (!path) return; + // Ensure parent directory exists on disk so the file manager can navigate to it + let parentDir: string | null = null; + try { + const { dirname } = await import("@tauri-apps/api/path"); + parentDir = await dirname(path); + const { mkdir } = await import("@tauri-apps/plugin-fs"); + await mkdir(parentDir, { recursive: true }); + } catch { + // can't create dir, try to open anyway + } + try { + const { revealItemInDir } = await import("@tauri-apps/plugin-opener"); + await revealItemInDir(path); + } catch { + // revealItemInDir failed (file doesn't exist on disk yet) — open the + // parent directory in the file manager instead + if (parentDir) { + try { + const { openPath } = await import("@tauri-apps/plugin-opener"); + await openPath(parentDir); + } catch { + // swallow — can't open explorer + } + } + } + } + + // Close tab context menu on outside click / Escape + useEffect(() => { + if (!contextMenu) return; + const handler = (e: MouseEvent | KeyboardEvent) => { + if (e instanceof KeyboardEvent && e.key !== "Escape") return; + setContextMenu(null); + }; + // Delay adding the listener so the menu's own render + click doesn't close itself + const id = setTimeout(() => { + document.addEventListener("click", handler); + document.addEventListener("keydown", handler); + }, 0); + return () => { + clearTimeout(id); + document.removeEventListener("click", handler); + document.removeEventListener("keydown", handler); + }; + }, [contextMenu]); + + const qid = (name: string) => quoteIdentifier(name, (activeConnection?.type || "postgres") as DatabaseType); + // ── Session persistence: restore open tabs on startup ──────────────────── const sessionRestoredRef = useRef(false); useEffect(() => { @@ -583,6 +708,21 @@ export function MainContent() { }); }, [activeTabId]); + const closeOthers = useCallback((tabId: string) => { + setQueryTabs((prev) => { + const tab = prev.find((t) => t.id === tabId); + if (!tab) return prev; + if (!prev.find((t) => t.id !== tabId)) return prev; + setActiveTabId(tab.id); + return [tab]; + }); + }, []); + + const closeAll = useCallback(() => { + setQueryTabs([]); + setActiveTabId(null); + }, []); + // Extract the single statement at cursor position, or selected text // Mirrors QueryEditor's "smart run" logic const extractSelectedOrCursorStatement = (fullText: string): string => { @@ -710,11 +850,22 @@ const executeQuery = useCallback(async (specificQuery?: any, statementInfo?: { l } // Attempt to extract table name for enabling row operations (ADD/DUP/REMOVE) - const tableNameMatch = queryToRun.match(/FROM\s+["']?([a-zA-Z0-9_.]+(?:\.[a-zA-Z0-9_.]+)*)["']?/i); + // Match the first identifier after FROM/JOIN/UPDATE/INTO, handling: + // - Quoted identifiers: "MyTable", "my_schema"."MyTable" + // - Schema-qualified: schema.table + // - Excludes CTE aliases and subqueries + const tableNameMatch = queryToRun.match(/(?:FROM|JOIN|UPDATE|INTO)\s+(?:"([^"]+)"(?:\."([^"]+)")?|([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)?))\b/i); if (tableNameMatch) { - const detectedTable = tableNameMatch[1]; + let detectedTable = ""; + if (tableNameMatch[1]) { + detectedTable = tableNameMatch[2] + ? `${tableNameMatch[1]}.${tableNameMatch[2]}` + : tableNameMatch[1]; + } else if (tableNameMatch[3]) { + detectedTable = tableNameMatch[3]; + } // Only update if it looks like a simple table name and not a complex subquery - if (!detectedTable.startsWith("(")) { + if (detectedTable && !detectedTable.startsWith("(")) { setActiveTableName(detectedTable); if (currentTabId) updateTabState(currentTabId, { tableName: detectedTable }); } @@ -1924,25 +2075,25 @@ const executeQuery = useCallback(async (specificQuery?: any, statementInfo?: { l for (const col of columns) { if (String(oldRow[col]) !== String(newRow[col])) { - setClauses.push(`${col} = ${formatSqlValue(newRow[col])}`); + setClauses.push(`${qid(col)} = ${formatSqlValue(newRow[col])}`); } } if (setClauses.length === 0) return; if (pk && oldRow[pk] !== undefined && oldRow[pk] !== null) { - whereClauses.push(`${pk} = ${formatSqlValue(oldRow[pk])}`); + whereClauses.push(`${qid(pk)} = ${formatSqlValue(oldRow[pk])}`); } else { for (const col of columns) { const val = oldRow[col]; - if (val === null) whereClauses.push(`${col} IS NULL`); - else whereClauses.push(`${col} = ${formatSqlValue(val)}`); + if (val === null) whereClauses.push(`${qid(col)} IS NULL`); + else whereClauses.push(`${qid(col)} = ${formatSqlValue(val)}`); } } const sqlSet = setClauses.join(", "); const sqlWhere = whereClauses.length > 0 ? whereClauses.join(" AND ") : "TRUE"; - const updateQuery = `UPDATE ${activeTableName} SET ${sqlSet} WHERE ${sqlWhere}`; + const updateQuery = `UPDATE ${qid(activeTableName)} SET ${sqlSet} WHERE ${sqlWhere}`; const confirmed = await confirmDialog.confirm({ title: "Confirm Changes", @@ -1993,16 +2144,16 @@ const executeQuery = useCallback(async (specificQuery?: any, statementInfo?: { l const whereClauses: string[] = []; if (pk && cleanRow[pk] !== undefined && cleanRow[pk] !== null) { - whereClauses.push(`${pk} = ${formatSqlValue(cleanRow[pk])}`); + whereClauses.push(`${qid(pk)} = ${formatSqlValue(cleanRow[pk])}`); } else { for (const col of columns) { const val = cleanRow[col]; - if (val === null) whereClauses.push(`${col} IS NULL`); - else whereClauses.push(`${col} = ${formatSqlValue(val)}`); + if (val === null) whereClauses.push(`${qid(col)} IS NULL`); + else whereClauses.push(`${qid(col)} = ${formatSqlValue(val)}`); } } - const deleteQuery = `DELETE FROM ${activeTableName} WHERE ` + (whereClauses.length > 0 ? whereClauses.join(" AND ") : "FALSE"); + const deleteQuery = `DELETE FROM ${qid(activeTableName)} WHERE ` + (whereClauses.length > 0 ? whereClauses.join(" AND ") : "FALSE"); try { setSuppressTabSwitch(true); @@ -2037,37 +2188,43 @@ const executeQuery = useCallback(async (specificQuery?: any, statementInfo?: { l setSuppressTabSwitch(true); // ─── Step 1: Build a DB connection for the save operation ─── - // This connection is used for both NOT NULL validation and for executing - // the actual INSERT/UPDATE queries. Using db.execute() directly (instead - // of executeQuery, which silently swallows errors) ensures that DB errors - // propagate to the outer catch and are reported to the user. + // Respect the active tab's target database, same as executeQuery does. + // Also respect the target connection's own credentials (host, port, type) + // when the tab's target points to a different connection than the + // globally active one. Without this, INSERT/UPDATE would use the wrong + // database or wrong server credentials. + const activeTab = queryTabs.find(t => t.id === activeTabId); + const targetConn = activeTab?.target; + const saveConn = targetConn + ? connections.find(c => c.id === targetConn.connectionId) + : activeConnection; + const saveDbName = targetConn?.database || selectedDatabase || activeConnection.database; + const conn = saveConn || activeConnection; + let username = conn.username || ""; + let password = conn.password || ""; + if (conn.vaultCredentialId) { + const vaultCred = vaultCredentials.find(vc => vc.id === conn.vaultCredentialId); + if (vaultCred) { username = vaultCred.username || ""; password = vaultCred.password || ""; } + } + const encodedUser = encodeURIComponent(username); + const encodedPass = encodeURIComponent(password); + const port = conn.port || (conn.type === "mysql" || conn.type === "mariadb" ? 3306 : 5432); + const Database = (await import("@tauri-apps/plugin-sql")).default; + let connectionString = ""; + let db: any; + if (conn.type === "sqlite") { + connectionString = `sqlite:${conn.filepath || "queryden.db"}`; + } else if (["postgres", "supabase", "cockroach"].includes(conn.type)) { + connectionString = `postgres://${encodedUser}:${encodedPass}@${conn.host}:${port}/${saveDbName}`; + } else { + connectionString = `mysql://${encodedUser}:${encodedPass}@${conn.host}:${port}/${saveDbName}`; + } + db = await Database.load(connectionString); + const tableParts = activeTableName.split("."); const schemaName = tableParts.length > 1 ? tableParts[0] : "public"; const tableName = tableParts.length > 1 ? tableParts.slice(1).join(".") : activeTableName; - let db = currentDb; - if (!db) { - const Database = (await import("@tauri-apps/plugin-sql")).default; - let username = activeConnection.username || ""; - let password = activeConnection.password || ""; - if (activeConnection.vaultCredentialId) { - const vaultCred = vaultCredentials.find(vc => vc.id === activeConnection.vaultCredentialId); - if (vaultCred) { username = vaultCred.username || ""; password = vaultCred.password || ""; } - } - const encodedUser = encodeURIComponent(username); - const encodedPass = encodeURIComponent(password); - const port = activeConnection.port || (activeConnection.type === "mysql" || activeConnection.type === "mariadb" ? 3306 : 5432); - let connectionString = ""; - if (activeConnection.type === "sqlite") { - connectionString = `sqlite:${activeConnection.filepath || "queryden.db"}`; - } else if (["postgres", "supabase", "cockroach"].includes(activeConnection.type)) { - connectionString = `postgres://${encodedUser}:${encodedPass}@${activeConnection.host}:${port}/${selectedDatabase || activeConnection.database}`; - } else { - connectionString = `mysql://${encodedUser}:${encodedPass}@${activeConnection.host}:${port}/${selectedDatabase || activeConnection.database}`; - } - db = await Database.load(connectionString); - } - // ─── Step 2: Validate NOT NULL + FK constraints (all providers) ─── const rowsWithMissing: { rowIndex: number; missing: string[] }[] = []; @@ -2138,14 +2295,14 @@ const executeQuery = useCallback(async (specificQuery?: any, statementInfo?: { l let query = ""; if (columns.length === 0) { - query = `INSERT INTO ${activeTableName} DEFAULT VALUES`; + query = `INSERT INTO ${qid(activeTableName)} DEFAULT VALUES`; if (activeConnection.type === "mysql" || activeConnection.type === "mariadb") { - query = `INSERT INTO ${activeTableName} () VALUES ()`; + query = `INSERT INTO ${qid(activeTableName)} () VALUES ()`; } } else { - const cols = columns.join(", "); + const cols = columns.map(c => qid(c)).join(", "); const vals = columns.map(c => formatSqlValue(data[c])).join(", "); - query = `INSERT INTO ${activeTableName} (${cols}) VALUES (${vals})`; + query = `INSERT INTO ${qid(activeTableName)} (${cols}) VALUES (${vals})`; } await db.execute(query); } @@ -2165,29 +2322,23 @@ const executeQuery = useCallback(async (specificQuery?: any, statementInfo?: { l const setClauses: string[] = []; const whereClauses: string[] = []; - // For simplicity in buffered mode, we update ALL columns or we'd need a "snapshot" of original values. - // Since we don't have the original row values here easily without more state, - // we'll just use the columns we have. - // Actually, let's just generate the update based on what's there. for (const col of columns) { - setClauses.push(`${col} = ${formatSqlValue(data[col])}`); + setClauses.push(`${qid(col)} = ${formatSqlValue(data[col])}`); } if (pk && data[pk] !== undefined && data[pk] !== null) { - whereClauses.push(`${pk} = ${formatSqlValue(data[pk])}`); + whereClauses.push(`${qid(pk)} = ${formatSqlValue(data[pk])}`); } else { - // Fallback to all columns for WHERE if no PK - // This is risky but standard for DB managers without PKs for (const col of columns) { const val = data[col]; - if (val === null) whereClauses.push(`${col} IS NULL`); - else whereClauses.push(`${col} = ${formatSqlValue(val)}`); + if (val === null) whereClauses.push(`${qid(col)} IS NULL`); + else whereClauses.push(`${qid(col)} = ${formatSqlValue(val)}`); } } const sqlSet = setClauses.join(", "); const sqlWhere = whereClauses.length > 0 ? whereClauses.join(" AND ") : "TRUE"; - const updateQuery = `UPDATE ${activeTableName} SET ${sqlSet} WHERE ${sqlWhere}`; + const updateQuery = `UPDATE ${qid(activeTableName)} SET ${sqlSet} WHERE ${sqlWhere}`; await db.execute(updateQuery); } @@ -2206,7 +2357,7 @@ const executeQuery = useCallback(async (specificQuery?: any, statementInfo?: { l isExecutingRef.current = false; setSuppressTabSwitch(false); } - }, [activeTableName, activeConnection, selectedDatabase, currentDb, vaultCredentials, executeQuery, confirmDialog]); + }, [activeTableName, activeConnection, selectedDatabase, vaultCredentials, executeQuery, confirmDialog, connections]); const handleAddRow = useCallback(async (newRow: any, localOnly = true): Promise => { if (localOnly) { @@ -2237,9 +2388,9 @@ const executeQuery = useCallback(async (specificQuery?: any, statementInfo?: { l // Just insert default values try { setSuppressTabSwitch(true); - let sql = `INSERT INTO ${activeTableName} DEFAULT VALUES`; + let sql = `INSERT INTO ${qid(activeTableName)} DEFAULT VALUES`; if (activeConnection.type === "mysql") { - sql = `INSERT INTO ${activeTableName} () VALUES ()`; + sql = `INSERT INTO ${qid(activeTableName)} () VALUES ()`; } await executeQuery(sql); if (lastSelectQueryRef.current) { @@ -2261,16 +2412,16 @@ const executeQuery = useCallback(async (specificQuery?: any, statementInfo?: { l setSuppressTabSwitch(true); if (Object.keys(newRow).length === 0) { // Insert a default blank row - let sql = `INSERT INTO ${activeTableName} DEFAULT VALUES`; + let sql = `INSERT INTO ${qid(activeTableName)} DEFAULT VALUES`; if (activeConnection.type === "mysql" || activeConnection.type === "mariadb") { - sql = `INSERT INTO ${activeTableName} () VALUES ()`; + sql = `INSERT INTO ${qid(activeTableName)} () VALUES ()`; } await executeQuery(sql); await executeQuery(lastSelectQueryRef.current); } else { - const cols = columns.join(", "); + const cols = columns.map(c => qid(c)).join(", "); const vals = columns.map(c => formatSqlValue(newRow[c])).join(", "); - const query = `INSERT INTO ${activeTableName} (${cols}) VALUES (${vals})`; + const query = `INSERT INTO ${qid(activeTableName)} (${cols}) VALUES (${vals})`; await executeQuery(query); await executeQuery(lastSelectQueryRef.current); } @@ -2702,8 +2853,10 @@ const executeQuery = useCallback(async (specificQuery?: any, statementInfo?: { l )} - {/* Query Tabs - DataGrip Style — gated alongside the toolbar (#84). */} - {isDatabaseReady && ( + {/* Query Tabs - DataGrip Style — visible whenever tabs exist, even + before a database is connected (#84 relaxed: restored sessions + should render immediately). */} + {queryTabs.length > 0 && (
{queryTabs.map((tab) => { @@ -2737,6 +2890,11 @@ const executeQuery = useCallback(async (specificQuery?: any, statementInfo?: { l window.dispatchEvent(new CustomEvent("focus-editor")); }, 50); }} + onContextMenu={(e) => { + e.preventDefault(); + e.stopPropagation(); + setContextMenu({ x: e.clientX, y: e.clientY, tabId: tab.id }); + }} > {/* Connection color stripe - left edge */}
)} + {/* Tab context menu */} + {contextMenu && (() => { + const ctxTab = queryTabs.find(t => t.id === contextMenu.tabId); + if (!ctxTab) return null; + return ( + + } + onClick={async () => { closeTab(ctxTab.id); setContextMenu(null); }} + > + Close + + } + onClick={() => { closeOthers(ctxTab.id); setContextMenu(null); }} + disabled={queryTabs.length <= 1} + > + Close Others + + } + onClick={() => { closeAll(); setContextMenu(null); }} + disabled={queryTabs.length <= 1} + > + Close All + + + } + onClick={() => { + const newName = window.prompt("Rename tab:", ctxTab.name); + if (newName && newName.trim()) { + updateTabState(ctxTab.id, { name: newName.trim() }); + } + setContextMenu(null); + }} + > + Rename + + } + onClick={() => { + addNewTab(ctxTab.query, ctxTab.name + " (copy)", ctxTab.usePsql, + ctxTab.target?.connectionId, ctxTab.target?.connectionName, ctxTab.target?.database); + setContextMenu(null); + }} + > + Duplicate + + + } + onClick={async () => { + const path = await getTabAutoSavePath(ctxTab); + if (path) { + await copyToClipboard(path); + showToastMessage("Copied to clipboard"); + } + setContextMenu(null); + }} + > + Copy File Path + + } + onClick={async () => { + await openTabFileInExplorer(ctxTab); + setContextMenu(null); + }} + > + Open in Explorer + + + ); + })()} + {/* Query Editor and Results */} {/* Top panel: Editor or Dashboard — must be a Panel for PanelGroup to work */} - {!isDatabaseReady ? ( + {!activeTab && queryTabs.length === 0 ? ( ) : activeTab ? ( activeTab.usePsql ? ( @@ -2819,7 +3053,7 @@ const executeQuery = useCallback(async (specificQuery?: any, statementInfo?: { l 0 ? psqlOutput : stashPsqlOutputRef.current} runningCommand={isExecuting ? (runningCmdRef.current || activeTab.query || "") : null} isExecuting={isExecuting} executionTime={executionTime} @@ -2965,6 +3199,12 @@ const executeQuery = useCallback(async (specificQuery?: any, statementInfo?: { l /> )} + + {showToast && toastMessage && ( +
+ {toastMessage} +
+ )}
); } diff --git a/src/components/ui/PsqlWindow.tsx b/src/components/ui/PsqlWindow.tsx index dca2b55..edd24a9 100644 --- a/src/components/ui/PsqlWindow.tsx +++ b/src/components/ui/PsqlWindow.tsx @@ -134,6 +134,27 @@ export const PsqlWindow = memo(function PsqlWindow({ const [copied, setCopied] = useState(false); const [showHelp, setShowHelp] = useState(false); const isAtBottomRef = useRef(true); + // Keep the live section visible for a short grace window after execution + // finishes, so the output doesn't flash-gap before psqlEntries are + // committed (Windows WebView2 can split React 18's batch). + const [showLiveGrace, setShowLiveGrace] = useState(false); + useEffect(() => { + if (isExecuting) { + setShowLiveGrace(true); + } else if (showLiveGrace) { + // Cancel grace as soon as entries catch up, with a 300ms timeout + // fallback for edge cases where entries never reflect the output. + if (liveOutput.length > 0 && entries.length > 0) { + const lastEntry = entries[entries.length - 1]; + if (lastEntry && lastEntry.outputLines.join("") === liveOutput.join("")) { + setShowLiveGrace(false); + return; + } + } + const timer = setTimeout(() => setShowLiveGrace(false), 300); + return () => clearTimeout(timer); + } + }, [isExecuting, showLiveGrace, entries, liveOutput]); const fontFamily = settings.editorFontFamily || "'JetBrains Mono', 'Fira Code', 'Consolas', 'Cascadia Code', monospace"; @@ -360,7 +381,7 @@ export const PsqlWindow = memo(function PsqlWindow({ ))} {/* Live output (during execution) */} - {(liveOutput.length > 0 || isExecuting) && ( + {(isExecuting || showLiveGrace) && (
{entries.length > 0 &&
}