Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
44 changes: 38 additions & 6 deletions src/components/layout/MainContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,13 @@ export function MainContent() {
const [activeTabId, setActiveTabId] = useState<string | null>(null);
const [results, setResults] = useState<any[]>([]);
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<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [multiResults, setMultiResults] = useState<MultiResult[]>([]);
Expand Down Expand Up @@ -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[] = [];
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 (
<div className="flex-1 flex flex-col min-w-0 h-full bg-[var(--surface-base)]">
{/* Variable Substitution Dialog */}
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -3481,8 +3513,8 @@ const executeQuery = useCallback(async (specificQuery?: any, statementInfo?: { l
<PsqlWindow
entries={activeTab.psqlEntries || []}
liveOutput={psqlOutput.length > 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={() => {
Expand Down Expand Up @@ -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}
Expand All @@ -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}
Expand Down