diff --git a/CHANGELOG.md b/CHANGELOG.md index 5492200..8d70092 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ 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 +- **PSQL Console error messages no longer flash and disappear.** Errors in the CLI execution path (PostgreSQL version unknown, download cancelled, download failed, psql not found, `\watch` errors) are now committed as persistent `psqlConsoleEntry` entries before the live-output grace period expires, preventing the output from going blank after 300ms. The `\watch` loop-end path also commits accumulated output. Fixes Windows WebView2 race where `showLiveGrace` expired without entries. +- **"Open in Explorer" now works reliably on Windows.** Empty tabs no longer silently fail — the auto-save directory is resolved as a fallback parent path. All catch blocks now show toast feedback (`showToastMessage`) instead of silent failure. +- **Error feedback added to 4 previously-silent catch blocks in DatabaseExplorer, MainContent, and ERDDialog.** Drop Role failure, DDL load failures (double-click and keyboard shortcut), ER Diagram connection failure, and SVG export failure now show error dialogs or toasts instead of silently swallowing errors. - **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. diff --git a/src/components/explorer/DatabaseExplorer.tsx b/src/components/explorer/DatabaseExplorer.tsx index 96a6933..e55a59a 100644 --- a/src/components/explorer/DatabaseExplorer.tsx +++ b/src/components/explorer/DatabaseExplorer.tsx @@ -1625,7 +1625,8 @@ export function DatabaseExplorer({ isAddConnectionDialogOpen = false }: Database })); } } catch (e) { - console.error("Failed to get DDL:", e); + const errMsg = typeof e === 'string' ? e : (e as any)?.message || "Failed to get DDL"; + confirmDialog.dialog({ title: "Failed to Load DDL", message: errMsg, confirmLabel: "OK", type: "danger" }); } } if (hasChildren || isFolder || node.icon === "server") toggleExpand(node.id); @@ -1771,7 +1772,10 @@ export function DatabaseExplorer({ isAddConnectionDialogOpen = false }: Database detail: { query: ddl, name: `DDL ${node.name}` } })); } - } catch (e) {} + } catch (e) { + const errMsg = typeof e === 'string' ? e : (e as any)?.message || "Failed to get DDL"; + confirmDialog.dialog({ title: "Failed to Load DDL", message: errMsg, confirmLabel: "OK", type: "danger" }); + } })(); } } @@ -2612,7 +2616,8 @@ export function DatabaseExplorer({ isAddConnectionDialogOpen = false }: Database try { await dropRole(roleName); } catch (e: any) { - console.error("Drop role failed:", e); + const errMsg = typeof e === 'string' ? e : e?.message || String(e); + confirmDialog.dialog({ title: "Drop Role Failed", message: errMsg, confirmLabel: "OK", type: "danger" }); } closeContextMenu(); }} diff --git a/src/components/layout/MainContent.tsx b/src/components/layout/MainContent.tsx index 07b78e6..a59b1e0 100644 --- a/src/components/layout/MainContent.tsx +++ b/src/components/layout/MainContent.tsx @@ -405,33 +405,52 @@ export function MainContent() { } 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 + // Resolve path and parent directory; handle empty tabs by using the + // auto-save directory as fallback parent. + let path: string | null = null; let parentDir: string | null = null; + try { - const { dirname } = await import("@tauri-apps/api/path"); - parentDir = await dirname(path); + const { join, dirname, appDataDir } = await import("@tauri-apps/api/path"); + + path = await getTabAutoSavePath(tab); + if (path) { + parentDir = await dirname(path); + } else { + parentDir = settings.autoSavePath + ? settings.autoSavePath + : await appDataDir().then((b: string) => join(b, "auto-save")); + } + const { mkdir } = await import("@tauri-apps/plugin-fs"); - await mkdir(parentDir, { recursive: true }); + if (parentDir) { + await mkdir(parentDir, { recursive: true }); + } } catch { - // can't create dir, try to open anyway + showToastMessage("Could not prepare file manager path"); } - 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 - } + + if (path) { + try { + const { revealItemInDir } = await import("@tauri-apps/plugin-opener"); + await revealItemInDir(path); + return; + } catch { + showToastMessage("Could not reveal file, opening parent folder"); } } + + if (parentDir) { + try { + const { openPath } = await import("@tauri-apps/plugin-opener"); + await openPath(parentDir); + return; + } catch { + showToastMessage("Could not open file manager"); + } + } else { + showToastMessage("Could not open file manager"); + } } // Close tab context menu on outside click / Escape @@ -974,6 +993,21 @@ const executeQuery = useCallback(async (specificQuery?: any, statementInfo?: { l const msg = "PostgreSQL version unknown. Connect to the database first so QueryDen can detect the server version."; appendPsqlOutput([`ERROR: ${msg}`]); setError(msg); + if (currentTabId) { + const errEntry: PsqlConsoleEntry = { + id: crypto.randomUUID(), + command: runningCmdRef.current || queryToRun, + outputLines: psqlOutputRef.current.length > 0 ? [...psqlOutputRef.current] : [`ERROR: ${msg}`], + hasErrors: true, + executionTime: 0, + }; + updateTabState(currentTabId, { + psqlEntries: [...(currentTab?.psqlEntries || []), errEntry], + psqlOutput: [], + }); + psqlOutputRef.current = []; + setPsqlOutput([]); + } setIsExecuting(false); isExecutingRef.current = false; return; @@ -993,8 +1027,24 @@ const executeQuery = useCallback(async (specificQuery?: any, statementInfo?: { l type: "info", }); if (!confirmed) { - appendPsqlOutput([`ERROR: CLI tool download cancelled.`]); - setError("CLI tool download cancelled."); + const errMsg = "CLI tool download cancelled."; + appendPsqlOutput([`ERROR: ${errMsg}`]); + setError(errMsg); + if (currentTabId) { + const errEntry: PsqlConsoleEntry = { + id: crypto.randomUUID(), + command: runningCmdRef.current || queryToRun, + outputLines: psqlOutputRef.current.length > 0 ? [...psqlOutputRef.current] : [`ERROR: ${errMsg}`], + hasErrors: true, + executionTime: 0, + }; + updateTabState(currentTabId, { + psqlEntries: [...(currentTab?.psqlEntries || []), errEntry], + psqlOutput: [], + }); + psqlOutputRef.current = []; + setPsqlOutput([]); + } setIsExecuting(false); isExecutingRef.current = false; return; @@ -1007,6 +1057,21 @@ const executeQuery = useCallback(async (specificQuery?: any, statementInfo?: { l const msg = `Download failed: ${dlErr.message || String(dlErr)}`; appendPsqlOutput([`ERROR: ${msg}`]); setError(msg); + if (currentTabId) { + const errEntry: PsqlConsoleEntry = { + id: crypto.randomUUID(), + command: runningCmdRef.current || queryToRun, + outputLines: psqlOutputRef.current.length > 0 ? [...psqlOutputRef.current] : [`ERROR: ${msg}`], + hasErrors: true, + executionTime: 0, + }; + updateTabState(currentTabId, { + psqlEntries: [...(currentTab?.psqlEntries || []), errEntry], + psqlOutput: [], + }); + psqlOutputRef.current = []; + setPsqlOutput([]); + } setIsExecuting(false); isExecutingRef.current = false; return; @@ -1033,6 +1098,21 @@ const executeQuery = useCallback(async (specificQuery?: any, statementInfo?: { l } appendPsqlOutput([`ERROR: ${installHint}`]); setError("psql not found"); + if (currentTabId) { + const errEntry: PsqlConsoleEntry = { + id: crypto.randomUUID(), + command: runningCmdRef.current || queryToRun, + outputLines: psqlOutputRef.current.length > 0 ? [...psqlOutputRef.current] : [`ERROR: ${installHint}`], + hasErrors: true, + executionTime: 0, + }; + updateTabState(currentTabId, { + psqlEntries: [...(currentTab?.psqlEntries || []), errEntry], + psqlOutput: [], + }); + psqlOutputRef.current = []; + setPsqlOutput([]); + } setIsExecuting(false); isExecutingRef.current = false; return; @@ -1263,6 +1343,21 @@ const executeQuery = useCallback(async (specificQuery?: any, statementInfo?: { l const msg = "\\watch cannot be used with an empty query on the PSQLWindow / console. Run a query first (e.g. SELECT 1) and then use \\watch."; appendPsqlOutput([`ERROR: ${msg}`]); setError(msg); + if (currentTabId) { + const errEntry: PsqlConsoleEntry = { + id: crypto.randomUUID(), + command: runningCmdRef.current || queryToRun, + outputLines: psqlOutputRef.current.length > 0 ? [...psqlOutputRef.current] : [`ERROR: ${msg}`], + hasErrors: true, + executionTime: 0, + }; + updateTabState(currentTabId, { + psqlEntries: [...(currentTab?.psqlEntries || []), errEntry], + psqlOutput: [], + }); + psqlOutputRef.current = []; + setPsqlOutput([]); + } setIsExecuting(false); isExecutingRef.current = false; return; @@ -1287,6 +1382,24 @@ const executeQuery = useCallback(async (specificQuery?: any, statementInfo?: { l } } + // Commit accumulated watch output as a persistent entry + if (currentTabId) { + const watchCmd = runningCmdRef.current || queryToRun; + const watchOutput = psqlOutputRef.current; + const watchEntry: PsqlConsoleEntry = { + id: crypto.randomUUID(), + command: watchCmd, + outputLines: watchOutput.length > 0 ? [...watchOutput] : ["(watch cancelled)"], + hasErrors: watchOutput.some(l => l.startsWith("ERROR:") || l.startsWith("FATAL:")), + executionTime: Date.now() - startTime, + }; + updateTabState(currentTabId, { + psqlEntries: [...(currentTab?.psqlEntries || []), watchEntry], + psqlOutput: [], + }); + psqlOutputRef.current = []; + setPsqlOutput([]); + } setIsExecuting(false); isExecutingRef.current = false; return; @@ -1308,7 +1421,8 @@ const executeQuery = useCallback(async (specificQuery?: any, statementInfo?: { l psqlEntries: [...(currentTab?.psqlEntries || []), newEntry], psqlOutput: [], }); - clearPsqlOutput(); + psqlOutputRef.current = []; + setPsqlOutput([]); } setIsExecuting(false); isExecutingRef.current = false; @@ -1347,15 +1461,16 @@ const executeQuery = useCallback(async (specificQuery?: any, statementInfo?: { l hasErrors: true, executionTime: Date.now() - startTime, }; - updateTabState(currentTabId, { - psqlEntries: [...(currentTab?.psqlEntries || []), errEntry], - psqlOutput: [], - }); - clearPsqlOutput(); - } - setIsExecuting(false); - isExecutingRef.current = false; - return; + updateTabState(currentTabId, { + psqlEntries: [...(currentTab?.psqlEntries || []), errEntry], + psqlOutput: [], + }); + psqlOutputRef.current = []; + setPsqlOutput([]); + } + setIsExecuting(false); + isExecutingRef.current = false; + return; } if (currentTabId) updateTabState(currentTabId, { statementResults }); } @@ -2773,6 +2888,7 @@ const executeQuery = useCallback(async (specificQuery?: any, statementInfo?: { l try { await connectToDatabase(activeTab.target.connectionId, activeTab.target.database); } catch { + showToastMessage("Failed to connect — cannot open ER Diagram"); return; } } diff --git a/src/components/tools/ERDDialog.tsx b/src/components/tools/ERDDialog.tsx index 61d06e6..7aaba90 100644 --- a/src/components/tools/ERDDialog.tsx +++ b/src/components/tools/ERDDialog.tsx @@ -14,6 +14,7 @@ import { } from "lucide-react"; import { useConnections } from "../../contexts/useConnections"; import { Dialog } from "../ui/Dialog"; +import { useConfirmDialog } from "../ui/ConfirmDialog"; import { IconButton } from "../ui/IconButton"; import { Input } from "../ui/Input"; import { ERDCanvas } from "./ERDCanvas"; @@ -88,6 +89,7 @@ function getRelatedTableIds( export function ERDDialog({ isOpen, onClose }: ERDDialogProps) { const { currentDb, activeConnection, selectedDatabase, schemaItems } = useConnections(); + const confirmDialog = useConfirmDialog(); const [selectedTables, setSelectedTables] = useState([]); const [showSelector, setShowSelector] = useState(true); @@ -272,8 +274,9 @@ export function ERDDialog({ isOpen, onClose }: ERDDialogProps) { if (path) { await writeTextFile(path, svgData); } - } catch { - // export failed silently + } catch (e: any) { + const errMsg = e?.message || String(e); + confirmDialog.dialog({ title: "Export Failed", message: errMsg, confirmLabel: "OK", type: "danger" }); } }, [selectedDatabase]);