Skip to content

Commit f9652fc

Browse files
committed
fix #35, add cancel query button
1 parent de4133b commit f9652fc

File tree

7 files changed

+235
-63
lines changed

7 files changed

+235
-63
lines changed

package.json

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "gigapi-ui",
3-
"version": "1.0.26",
3+
"version": "1.0.27",
44
"type": "module",
55
"description": "UI interface for GigAPI: The Infinite Timeseries Lakehouse. GigAPI UI provides a slick web interface to query time-series using GigAPI Catalog Metadata + DuckDB",
66
"scripts": {
@@ -16,7 +16,7 @@
1616
"author": "",
1717
"license": "AGPL-3.0",
1818
"dependencies": {
19-
"@hookform/resolvers": "^5.2.1",
19+
"@hookform/resolvers": "^5.2.2",
2020
"@monaco-editor/react": "^4.7.0",
2121
"@radix-ui/react-alert-dialog": "^1.1.15",
2222
"@radix-ui/react-checkbox": "^1.3.3",
@@ -36,7 +36,7 @@
3636
"@tanstack/react-table": "^8.21.3",
3737
"@tanstack/react-virtual": "^3.13.12",
3838
"@types/react-grid-layout": "^1.3.5",
39-
"axios": "^1.11.0",
39+
"axios": "^1.12.2",
4040
"class-variance-authority": "^0.7.1",
4141
"clsx": "^2.1.1",
4242
"cmdk": "^1.1.1",
@@ -45,17 +45,17 @@
4545
"highlight.js": "^11.11.1",
4646
"jotai": "^2.14.0",
4747
"katex": "^0.16.22",
48-
"lucide-react": "^0.543.0",
48+
"lucide-react": "^0.544.0",
4949
"pako": "^2.1.0",
5050
"react": "^19.1.1",
51-
"react-day-picker": "^9.9.0",
51+
"react-day-picker": "^9.11.0",
5252
"react-dom": "^19.1.1",
5353
"react-grid-layout": "^1.5.2",
54-
"react-hook-form": "^7.62.0",
54+
"react-hook-form": "^7.63.0",
5555
"react-markdown": "^10.1.0",
5656
"react-resizable": "^3.0.5",
57-
"react-resizable-panels": "^3.0.5",
58-
"react-router-dom": "^7.8.2",
57+
"react-resizable-panels": "^3.0.6",
58+
"react-router-dom": "^7.9.1",
5959
"rehype-highlight": "^7.0.2",
6060
"rehype-katex": "^7.0.1",
6161
"remark-gfm": "^4.0.1",
@@ -65,16 +65,16 @@
6565
"tw-animate-css": "^1.3.8",
6666
"uplot": "^1.6.32",
6767
"uuid": "^13.0.0",
68-
"vite": "^7.1.5",
69-
"zod": "^4.1.7"
68+
"vite": "^7.1.7",
69+
"zod": "^4.1.11"
7070
},
7171
"devDependencies": {
72-
"@types/node": "^24.3.1",
72+
"@types/node": "^24.5.2",
7373
"@types/pako": "^2.0.4",
74-
"@types/react": "^19.1.12",
74+
"@types/react": "^19.1.13",
7575
"@types/react-dom": "^19.1.9",
76-
"@vitejs/plugin-react": "^5.0.2",
77-
"knip": "^5.63.1",
76+
"@vitejs/plugin-react": "^5.0.3",
77+
"knip": "^5.64.0",
7878
"typescript": "^5.9.2"
7979
}
8080
}

src/atoms/query-atoms.ts

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,43 @@ export const processedQueryAtom = currentTabProcessedQueryAtom;
4545
// Alias the tab-aware query history atom for backward compatibility
4646
export const queryHistoryAtom = currentTabQueryHistoryAtom;
4747

48+
// Store abort controllers for each tab's running query
49+
const abortControllersAtom = atom<Map<string, AbortController>>(new Map());
50+
4851
// Actions
4952
export const setQueryAtom = atom(null, (_get, set, query: string) => {
5053
set(queryAtom, query);
5154
});
5255

56+
// Cancel query action
57+
export const cancelQueryAtom = atom(null, (get, set, tabId?: string) => {
58+
const activeTabId = tabId || get(activeTabIdAtom);
59+
if (!activeTabId) return;
60+
61+
const controllers = get(abortControllersAtom);
62+
const controller = controllers.get(activeTabId);
63+
64+
if (controller) {
65+
// Actual query is running, cancel it
66+
controller.abort();
67+
controllers.delete(activeTabId);
68+
set(abortControllersAtom, new Map(controllers));
69+
toast.info("Query cancelled");
70+
} else {
71+
// No controller means it's a stale loading state
72+
// Just reset the UI state
73+
const getTabById = get(getTabByIdAtom);
74+
const tab = getTabById(activeTabId);
75+
if (tab?.queryLoading) {
76+
toast.info("Clearing stale query state");
77+
}
78+
}
79+
80+
// Always update UI state, whether there was a controller or not
81+
set(updateTabQueryLoadingByIdAtom, { tabId: activeTabId, loading: false });
82+
set(removeRunningQueryAtom, activeTabId);
83+
});
84+
5385
export const executeQueryAtom = atom(null, async (get, set) => {
5486
// Capture the tab ID at the start of execution
5587
const executeTabId = get(activeTabIdAtom);
@@ -94,6 +126,12 @@ export const executeQueryAtom = atom(null, async (get, set) => {
94126
// Mark this tab as having a running query
95127
set(addRunningQueryAtom, executeTabId);
96128

129+
// Create abort controller for this query
130+
const abortController = new AbortController();
131+
const controllers = get(abortControllersAtom);
132+
controllers.set(executeTabId, abortController);
133+
set(abortControllersAtom, new Map(controllers));
134+
97135
const startTime = Date.now();
98136

99137
try {
@@ -158,7 +196,11 @@ export const executeQueryAtom = atom(null, async (get, set) => {
158196
{
159197
query: processedQuery,
160198
},
161-
{ headers }
199+
{
200+
headers,
201+
signal: abortController.signal,
202+
timeout: 300000 // 5 minute timeout
203+
}
162204
);
163205

164206
const result = response.data;
@@ -248,6 +290,12 @@ export const executeQueryAtom = atom(null, async (get, set) => {
248290
// Add to the specific tab's history
249291
set(addToTabQueryHistoryByIdAtom, { tabId: executeTabId, historyItem });
250292
} catch (error: any) {
293+
// Handle cancellation
294+
if (axios.isCancel(error)) {
295+
// Query was cancelled, don't show error
296+
return;
297+
}
298+
251299
let errorMessage = "Query failed";
252300

253301
// Extract detailed error message from axios response
@@ -266,22 +314,43 @@ export const executeQueryAtom = atom(null, async (get, set) => {
266314
const parsed = JSON.parse(error.response.data);
267315
if (parsed.error) {
268316
errorMessage = parsed.error;
317+
} else {
318+
// Use the string as is if no error field
319+
errorMessage = error.response.data || "Query failed with unknown error";
269320
}
270321
} catch {
271322
// If not JSON, use as is
272-
errorMessage = error.response.data;
323+
errorMessage = error.response.data || "Query failed with unknown error";
273324
}
325+
} else {
326+
// If data is another type, try to stringify it
327+
errorMessage = JSON.stringify(error.response.data) || "Query failed with unknown error";
274328
}
275329
} else if (error.response.statusText) {
276330
errorMessage = `${error.response.status}: ${error.response.statusText}`;
331+
} else {
332+
errorMessage = `HTTP Error ${error.response.status || 'unknown'}`;
277333
}
278334
} else if (error.message) {
279335
errorMessage = error.message;
280336
}
281337

338+
// Ensure errorMessage is never empty
339+
if (!errorMessage || errorMessage.trim() === "") {
340+
errorMessage = "Query failed with unknown error";
341+
console.error("Query execution error:", error); // Log full error for debugging
342+
}
343+
282344
set(updateTabQueryErrorByIdAtom, { tabId: executeTabId, error: errorMessage });
283345
set(updateTabQueryResultsByIdAtom, { tabId: executeTabId, results: null }); // Clear results on error
284346

347+
// Show toast with validated error message
348+
if (errorMessage && errorMessage.trim() !== "") {
349+
toast.error(errorMessage);
350+
} else {
351+
toast.error("Query failed. Check console for details.");
352+
}
353+
285354
// Add failed query to history with full context
286355
const historyItem: QueryHistoryItem = {
287356
id: uuidv4(),
@@ -301,9 +370,14 @@ export const executeQueryAtom = atom(null, async (get, set) => {
301370
} finally {
302371
// Update loading state for the specific tab
303372
set(updateTabQueryLoadingByIdAtom, { tabId: executeTabId, loading: false });
304-
373+
305374
// Remove this tab from running queries
306375
set(removeRunningQueryAtom, executeTabId);
376+
377+
// Clean up abort controller
378+
const controllers = get(abortControllersAtom);
379+
controllers.delete(executeTabId);
380+
set(abortControllersAtom, new Map(controllers));
307381
}
308382
});
309383

src/atoms/tab-atoms.ts

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,18 @@ function migrateOldData(): TabsState {
2626
const existingTabs = localStorage.getItem("gigapi_tabs");
2727
if (existingTabs) {
2828
try {
29-
return JSON.parse(existingTabs);
29+
const state = JSON.parse(existingTabs);
30+
// ALWAYS reset loading states on app initialization to prevent stale state
31+
if (state.tabs) {
32+
state.tabs = state.tabs.map((tab: QueryTab) => ({
33+
...tab,
34+
queryLoading: false, // Always reset loading state on page refresh
35+
queryError: null // Also clear any stale errors
36+
}));
37+
}
38+
// Save the cleaned state back to localStorage
39+
localStorage.setItem("gigapi_tabs", JSON.stringify(state));
40+
return state;
3041
} catch (e) {
3142
console.error("Failed to parse existing tabs:", e);
3243
}
@@ -552,7 +563,19 @@ export const currentTabQueryErrorAtom = atom(
552563

553564
// Current tab's query loading state
554565
export const currentTabQueryLoadingAtom = atom(
555-
(get) => get(activeTabAtom)?.queryLoading || false,
566+
(get) => {
567+
const activeTab = get(activeTabAtom);
568+
if (!activeTab) return false;
569+
570+
// Check for stale loading state - if loading but no running query, it's stale
571+
const runningQueries = get(runningQueriesAtom);
572+
if (activeTab.queryLoading && !runningQueries.has(activeTab.id)) {
573+
// Stale loading state detected, return false
574+
return false;
575+
}
576+
577+
return activeTab.queryLoading || false;
578+
},
556579
(get, set, loading: boolean) => {
557580
const state = get(tabsStateAtom);
558581
const activeTab = get(activeTabAtom);
@@ -716,8 +739,27 @@ export const currentTabAvailableFieldsAtom = atom(
716739
// ============================================================================
717740

718741
// Track which tabs have running queries
742+
// Always starts empty on page load to prevent stale state
719743
export const runningQueriesAtom = atom<Set<string>>(new Set<string>());
720744

745+
// Reset all stale loading states on initialization
746+
export const resetStaleLoadingStatesAtom = atom(null, (get, set) => {
747+
const state = get(tabsStateAtom);
748+
const runningQueries = get(runningQueriesAtom);
749+
750+
// Clear loading state for all tabs that aren't actually running
751+
const updatedTabs = state.tabs.map((tab) => {
752+
if (tab.queryLoading && !runningQueries.has(tab.id)) {
753+
return { ...tab, queryLoading: false };
754+
}
755+
return tab;
756+
});
757+
758+
if (JSON.stringify(updatedTabs) !== JSON.stringify(state.tabs)) {
759+
set(tabsStateAtom, { ...state, tabs: updatedTabs });
760+
}
761+
});
762+
721763
// Add a tab to running queries
722764
export const addRunningQueryAtom = atom(null, (get, set, tabId: string) => {
723765
const running = new Set(get(runningQueriesAtom));

src/components/query/MonacoSqlEditor.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -379,12 +379,18 @@ export default function MonacoSqlEditor({
379379
const editorTheme = theme === "light" ? "light" : "vs-dark";
380380

381381
return (
382-
<div className="h-full w-full border rounded-md overflow-hidden bg-background relative">
382+
<div className={`h-full w-full border rounded-md overflow-hidden bg-background relative ${isLoading ? 'border-blue-500 animate-pulse' : ''}`}>
383383
{!isEditorReady && (
384384
<div className="absolute inset-0 z-10">
385385
<Skeleton className="h-full w-full" />
386386
</div>
387387
)}
388+
{isLoading && (
389+
<div className="absolute top-2 right-2 z-20 bg-blue-500/90 text-white px-3 py-1 rounded-md text-xs font-medium flex items-center gap-2">
390+
<Loader className="h-3 w-3" />
391+
Query running...
392+
</div>
393+
)}
388394
<Editor
389395
height="100%"
390396
defaultLanguage="sql"
@@ -407,7 +413,7 @@ export default function MonacoSqlEditor({
407413
tabSize: 2,
408414
wordWrap: "on",
409415
automaticLayout: true,
410-
readOnly: isLoading,
416+
// Remove readOnly: isLoading to allow editing during query execution
411417
}}
412418
loading={<Loader className="h-10 w-10" />}
413419
/>

src/components/query/QueryEditor.tsx

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
queryAtom,
55
queryResultsAtom,
66
executeQueryAtom,
7+
cancelQueryAtom,
78
queryLoadingAtom,
89
setQueryAtom,
910
selectedDbAtom,
@@ -37,6 +38,7 @@ export default function QueryEditor() {
3738
const [autoCompleteSchema] = useAtom(autoCompleteSchemaAtom);
3839
const setQuery = useSetAtom(setQueryAtom);
3940
const executeQuery = useSetAtom(executeQueryAtom);
41+
const cancelQuery = useSetAtom(cancelQueryAtom);
4042
const setSelectedTableAction = useSetAtom(setSelectedTableAtom);
4143
const setSelectedTimeField = useSetAtom(setSelectedTimeFieldAtom);
4244
const setHasTimeVariables = useSetAtom(setHasTimeVariablesAtom);
@@ -80,26 +82,33 @@ export default function QueryEditor() {
8082
// Handle time range change
8183
const setTimeRange = useSetAtom(setTimeRangeAtom);
8284
const [shouldAutoExecute, setShouldAutoExecute] = useState(false);
83-
85+
const [isInitialMount, setIsInitialMount] = useState(true);
86+
8487
const handleTimeRangeChange = useCallback(
8588
(newTimeRange: any) => {
8689
setTimeRange(newTimeRange);
87-
90+
8891
// Mark that we should auto-execute if conditions are met
89-
if (queryResults && queryResults.length > 0 && hasTimeVariables && query.trim()) {
92+
// But not on initial mount to prevent auto-execution on page refresh
93+
if (!isInitialMount && queryResults && queryResults.length > 0 && hasTimeVariables && query.trim()) {
9094
setShouldAutoExecute(true);
9195
}
9296
},
93-
[setTimeRange, queryResults, hasTimeVariables, query]
97+
[setTimeRange, queryResults, hasTimeVariables, query, isInitialMount]
9498
);
95-
99+
100+
// Mark initial mount as complete after first render
101+
useEffect(() => {
102+
setIsInitialMount(false);
103+
}, []);
104+
96105
// Auto-execute query when time range changes and conditions are met
97106
useEffect(() => {
98-
if (shouldAutoExecute) {
107+
if (shouldAutoExecute && !isInitialMount && !isLoading) {
99108
handleRunQuery();
100109
setShouldAutoExecute(false);
101110
}
102-
}, [shouldAutoExecute]);
111+
}, [shouldAutoExecute, isInitialMount, isLoading]);
103112

104113
// Handle running query with timeout protection - NOT using useCallback to avoid stale closures
105114
const handleRunQuery = async () => {
@@ -135,6 +144,11 @@ export default function QueryEditor() {
135144
}
136145
}, [setQuery]);
137146

147+
// Handle canceling query
148+
const handleCancelQuery = useCallback(() => {
149+
cancelQuery();
150+
}, [cancelQuery]);
151+
138152
// Handle refreshing schema
139153
const handleRefreshSchema = useCallback(() => {
140154
forceReloadSchema();
@@ -251,6 +265,7 @@ export default function QueryEditor() {
251265
showChatPanel={showChatPanel}
252266
chatSessionsCount={0}
253267
onRunQuery={handleRunQuery}
268+
onCancelQuery={handleCancelQuery}
254269
onClearQuery={handleClearQuery}
255270
onToggleChat={() => {
256271
setShowChatPanel(!showChatPanel);

0 commit comments

Comments
 (0)