diff --git a/CHANGELOG.md b/CHANGELOG.md index d000d18..f92d270 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ All notable changes to QueryDen are documented here. This project adheres to [Se ### Fixed - **[#217](https://github.com/openidle-dev/queryden/issues/217) — Running a query now gives instant feedback and can't be triggered twice.** Pressing `Ctrl+Enter` previously showed no "running" state until *after* the database connection handshake (`Database.load`), which can take seconds on a cold connection — so the editor looked idle and a second press would run the query again. `setIsExecuting(true)` now fires synchronously at the start of execution (the spinner and tab status glyph appear the instant you press the shortcut), and a re-entrancy guard drops a duplicate trigger while a run is already in flight. +- **Cancelling a query no longer freezes query execution.** The re-entrancy guard added in #217 keys off `isExecutingRef`, which every code path cleared on completion — except `cancelQuery`, which only flipped the visible spinner off. Because libpq has no real query cancel, the in-flight `db.select` keeps running server-side and only clears the flag when it eventually returns, so after pressing **Cancel** the toolbar showed "Run" but the guard silently swallowed every subsequent run and the results bar never came up. `cancelQuery` now clears the executing flag immediately (and bumps the execution generation so the abandoned run no-ops on its late return instead of clobbering whatever ran after the cancel). +- **The "running" spinner now stays on the tab that launched the query.** Execution state was a single global `isExecuting` flag, and every indicator (the tab status glyph, the SQL Editor header bar, the results-panel loading state) was gated on `activeTabId === tab.id` — so the spinner painted onto whichever tab was *active* and appeared to "follow" you when you switched tabs, even though a different tab owned the running query. A new `executingTabId` records which tab actually started the run: the tab glyph is keyed to that tab, and the editor header / psql window / results-panel loading only show "running" when the active tab is the executing one. (The top toolbar's Run/Cancel control stays global, since QueryDen runs one query at a time and you can cancel it from anywhere.) - **You can now close the last remaining query tab.** The tab close button was hidden whenever only one tab was open, leaving no way to close the final editor. The button is always available now; closing the last tab falls back to the empty-state launcher. - **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. diff --git a/src/components/layout/MainContent.tsx b/src/components/layout/MainContent.tsx index e7d82dd..e08af52 100644 --- a/src/components/layout/MainContent.tsx +++ b/src/components/layout/MainContent.tsx @@ -122,6 +122,13 @@ export function MainContent() { const [activeTabId, setActiveTabId] = useState(null); const [results, setResults] = useState([]); const [isExecuting, setIsExecuting] = useState(false); + // Which tab owns the in-flight query. `isExecuting` is a single global flag + // (only one query runs at a time), but the running spinner must point at the + // tab that actually launched the query — not whichever tab happens to be + // active — otherwise the indicator appears to "follow" you when you switch + // tabs. Set when a run starts; cleared when it ends, is cancelled, or is + // superseded by a newer run. + const [executingTabId, setExecutingTabId] = useState(null); const [error, setError] = useState(null); const [success, setSuccess] = useState(null); const [multiResults, setMultiResults] = useState([]); @@ -977,6 +984,9 @@ const executeQuery = useCallback(async (specificQuery?: any, statementInfo?: { l // per-path `setIsExecuting(true)` calls below are now redundant no-ops. setIsExecuting(true); isExecutingRef.current = true; + // Pin the running indicator to the tab that launched this run so it + // doesn't follow tab switches (see executingTabId). + setExecutingTabId(currentTabId || null); // Declare execution state at the top so both libpq and CLI paths can reference them let rows: any[] = []; @@ -2065,13 +2075,27 @@ const executeQuery = useCallback(async (specificQuery?: any, statementInfo?: { l cancelFlagRef.current = false; setIsExecuting(false); isExecutingRef.current = false; + setExecutingTabId(null); } } }, [activeConnection, selectedDatabase, addQuery, currentDb, vaultCredentials, settings, confirmDialog]); const cancelQuery = useCallback(() => { cancelFlagRef.current = true; + // Release the re-entrancy guard NOW. libpq has no real query cancel: the + // in-flight `db.select` keeps running server-side and only resets the + // executing flags in its `finally` when it eventually returns — which for + // a heavy query can be a long time, or effectively never. Until then + // `isExecutingRef` would stay true and the guard at the top of + // executeQuery silently swallows every new run, so the results bar never + // comes up after a cancel. Clearing the flag lets the next query start. + // Bumping the generation makes the abandoned run a stale generation, so + // its late resolution no-ops at the gen checkpoints/finally instead of + // clobbering the state of whatever ran after the cancel. + executionGenRef.current++; + isExecutingRef.current = false; setIsExecuting(false); + setExecutingTabId(null); setError("Query execution cancelled by user."); setExecutionTime(runningTimeMs); if (activeTabId) { @@ -3037,6 +3061,12 @@ const executeQuery = useCallback(async (specificQuery?: any, statementInfo?: { l executeQueryRef.current = executeQuery; }); + // True only when the *active* tab is the one running a query. The editor + // header, psql window, and results-panel loading state render for the active + // tab only, so they use this rather than the global `isExecuting` — otherwise + // they'd show a spinner after switching away to a tab that isn't running. + const activeTabIsExecuting = isExecuting && executingTabId === activeTabId; + return (
{/* Variable Substitution Dialog */} @@ -3298,8 +3328,10 @@ const executeQuery = useCallback(async (specificQuery?: any, statementInfo?: { l ? tabConnectionName.substring(0, 10) + "..." : tabConnectionName; - // Determine status for this tab - const tabIsExecuting = activeTabId === tab.id && isExecuting; + // Determine status for this tab. Executing status is keyed to the + // tab that actually launched the query (executingTabId), not the + // active tab, so the spinner stays on its own tab when you switch. + const tabIsExecuting = isExecuting && executingTabId === tab.id; const tabHasError = tab.error && activeTabId === tab.id; const tabHasSuccess = tab.success && activeTabId === tab.id && !tab.error; @@ -3481,8 +3513,8 @@ const executeQuery = useCallback(async (specificQuery?: any, statementInfo?: { l 0 ? psqlOutput : stashPsqlOutputRef.current} - runningCommand={isExecuting ? (runningCmdRef.current || activeTab.query || "") : null} - isExecuting={isExecuting} + runningCommand={activeTabIsExecuting ? (runningCmdRef.current || activeTab.query || "") : null} + isExecuting={activeTabIsExecuting} executionTime={executionTime} onRun={(q: string) => executeQuery(q)} onClear={() => { @@ -3519,7 +3551,7 @@ const executeQuery = useCallback(async (specificQuery?: any, statementInfo?: { l databaseName={activeTab?.target?.database || selectedDatabase || undefined} tabId={activeTabId!} tabName={activeTab?.name} - isExecuting={isExecuting} + isExecuting={activeTabIsExecuting} hasError={!!error} hasSuccess={!!success} statementResults={activeTab?.statementResults} @@ -3545,7 +3577,7 @@ const executeQuery = useCallback(async (specificQuery?: any, statementInfo?: { l error={error} successMessage={success} multiResults={multiResults} - isLoading={isExecuting} + isLoading={activeTabIsExecuting} executionTime={executionTime} tableName={activeTableName || undefined} columnTypes={tableColumnTypes?.types}