diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index b5f18e9d0e..36404b9fe4 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -124,7 +124,7 @@ import { useConnectionStore } from "@/stores/connectionStore"; import { TABLE_FONT_SIZE_MAX, TABLE_FONT_SIZE_MIN, useSettingsStore, type DataGridSearchMode, type ResultRunDisplayMode } from "@/stores/settingsStore"; import { useToast } from "@/composables/useToast"; import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; -import { canCancelQueryExecution, queryExecutionLabelKey } from "@/lib/sql/queryExecutionState"; +import { canCancelQueryExecution, isActiveResultLoading, queryExecutionLabelKey } from "@/lib/sql/queryExecutionState"; import { databaseDisplayNameForTab, executionSummaryItems, @@ -491,6 +491,7 @@ const resultArchiveExporting = ref(false); const canExportResultArchive = computed(() => props.activeTab.mode === "query" && (!!props.activeTab.result || !!props.activeTab.results?.length || !!props.activeTab.resultRuns?.length)); const resultAutoSave = computed(() => props.activeTab.resultAutoSave === true); const activeResultRunItem = computed(() => resultRuns.value.find((run) => run.active)); +const activeResultIsLoading = computed(() => isActiveResultLoading(props.activeTab)); const showResultRunTabs = computed(() => resultRuns.value.length > 0 && resultRunDisplayMode.value === "tabs"); const showResultRunSelector = computed(() => resultRuns.value.length > 0 && resultRunDisplayMode.value === "list"); const canCloseQueryResult = computed(() => props.activeTab.mode === "query" && !props.activeTab.isExecuting && !props.activeTab.activeResultRunId && (!!props.activeTab.result || !!props.activeTab.results?.length || props.activeTab.resultEvicted === true)); @@ -1784,7 +1785,7 @@ defineExpose({ :initial-order-by-input="activeTab.orderByInput" :sql="activeResultSql" :export-sql="activeResultExportSql" - :loading="activeTab.isExecuting" + :loading="activeResultIsLoading" :editable="!!activeTab.queryAnalysis || !!mongoQueryResultSaveHandler" :source-columns="activeTab.querySourceColumns" :readonly-column-indexes="groupedQueryReadonlyColumnIndexes(activeTab)" diff --git a/apps/desktop/src/lib/__tests__/app/openTabsPersistence.spec.ts b/apps/desktop/src/lib/__tests__/app/openTabsPersistence.spec.ts index d6d5ec79e9..1b13cb619e 100644 --- a/apps/desktop/src/lib/__tests__/app/openTabsPersistence.spec.ts +++ b/apps/desktop/src/lib/__tests__/app/openTabsPersistence.spec.ts @@ -51,6 +51,19 @@ describe("openTabsPersistence originalSql round-trip", () => { expect(restored.originalSql).toBe(""); }); + it("does not persist the transient result execution target", () => { + const tab = queryTab({ activeResultRunId: "run-1", executingResultRunId: "run-1", isExecuting: true }); + const [saved] = serializeOpenTabs([tab]); + const [restored] = restoreOpenTabsPayload({ + tabs: [{ ...saved, executingResultRunId: "run-1" }], + activeTabId: tab.id, + }).tabs; + + expect(saved).not.toHaveProperty("executingResultRunId"); + expect(restored.executingResultRunId).toBeUndefined(); + expect(restored.isExecuting).toBe(false); + }); + it("preserves an external Doris catalog across tab restore", () => { const [restored] = roundTrip([queryTab({ database: "dbx_catalog_completion", catalog: "dbx_mysql_catalog" })]); diff --git a/apps/desktop/src/lib/__tests__/sql/queryExecutionState.spec.ts b/apps/desktop/src/lib/__tests__/sql/queryExecutionState.spec.ts new file mode 100644 index 0000000000..e41b17e85c --- /dev/null +++ b/apps/desktop/src/lib/__tests__/sql/queryExecutionState.spec.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { isActiveResultLoading, type QueryResultLoadingStateLike } from "@/lib/sql/queryExecutionState"; + +function state(overrides: Partial = {}): QueryResultLoadingStateLike { + return { + isExecuting: false, + ...overrides, + }; +} + +describe("active query result loading", () => { + it("keeps the single-result execution loading", () => { + expect(isActiveResultLoading(state({ isExecuting: true, executingResultRunId: null }))).toBe(true); + }); + + it("keeps the result run being updated loading", () => { + expect(isActiveResultLoading(state({ isExecuting: true, activeResultRunId: "run-b", executingResultRunId: "run-b" }))).toBe(true); + }); + + it("does not load a completed result while a new run is executing", () => { + expect(isActiveResultLoading(state({ isExecuting: true, activeResultRunId: "run-a", executingResultRunId: null }))).toBe(false); + }); + + it("loads again when the user switches back to the executing run", () => { + expect(isActiveResultLoading(state({ isExecuting: true, activeResultRunId: "run-b", executingResultRunId: "run-b" }))).toBe(true); + }); + + it("clears loading after execution finishes", () => { + expect(isActiveResultLoading(state({ isExecuting: false, activeResultRunId: "run-b", executingResultRunId: "run-b" }))).toBe(false); + }); + + it("keeps legacy unscoped executions conservative", () => { + expect(isActiveResultLoading(state({ isExecuting: true, activeResultRunId: "run-a" }))).toBe(true); + }); +}); diff --git a/apps/desktop/src/lib/app/openTabsPersistence.ts b/apps/desktop/src/lib/app/openTabsPersistence.ts index 84d73a8a20..4d767bb9aa 100644 --- a/apps/desktop/src/lib/app/openTabsPersistence.ts +++ b/apps/desktop/src/lib/app/openTabsPersistence.ts @@ -190,6 +190,7 @@ function restoreOpenTabsArray(parsed: unknown, rawActiveTabId: string | null, op isExecuting: false, isCancelling: false, queryExecutionStartedAt: undefined, + executingResultRunId: undefined, editorViewport: undefined, editorSelection: undefined, isExplaining: false, diff --git a/apps/desktop/src/lib/sql/queryExecutionState.ts b/apps/desktop/src/lib/sql/queryExecutionState.ts index c87a7fa709..8aaf6a1ccc 100644 --- a/apps/desktop/src/lib/sql/queryExecutionState.ts +++ b/apps/desktop/src/lib/sql/queryExecutionState.ts @@ -8,6 +8,21 @@ export function canCancelQueryExecution(state: QueryExecutionStateLike): boolean return state.isExecuting && !!state.executionId && !state.isCancelling; } +export interface QueryResultLoadingStateLike extends QueryExecutionStateLike { + activeResultRunId?: string; + /** The result run being updated; null means the execution will create a new run. */ + executingResultRunId?: string | null; +} + +export function isActiveResultLoading(state: QueryResultLoadingStateLike): boolean { + if (!state.isExecuting) return false; + if (!state.activeResultRunId) return true; + // Keep unscoped legacy callers conservative while all new executions set an + // explicit run id (or null when they are producing a new result run). + if (state.executingResultRunId === undefined) return true; + return state.executingResultRunId === state.activeResultRunId; +} + export function queryExecutionLabelKey(state: Pick): "common.loading" | "common.stopping" { return state.isCancelling ? "common.stopping" : "common.loading"; } diff --git a/apps/desktop/src/stores/__tests__/queryStore.cancel.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.cancel.spec.ts index 290beb334f..1c0da15702 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.cancel.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.cancel.spec.ts @@ -45,6 +45,7 @@ describe("queryStore cancel timeout recovery", () => { isExecuting: false, isCancelling: false, executionId: undefined, + executingResultRunId: undefined, }); expect(store.tabs[0]?.result?.rows[0]?.[0]).toContain("Cancel request timed out"); }, 15_000); @@ -84,6 +85,7 @@ describe("queryStore cancel timeout recovery", () => { isExecuting: false, isCancelling: false, executionId: undefined, + executingResultRunId: undefined, batchSqlExecution: { finishedAt: expect.any(Number), items: [{ status: "cancelled" }, { status: "skipped" }], diff --git a/apps/desktop/src/stores/__tests__/queryStore.multiStatementError.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.multiStatementError.spec.ts index 82cedea924..70d57f77fe 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.multiStatementError.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.multiStatementError.spec.ts @@ -1,4 +1,5 @@ import { createPinia, setActivePinia } from "pinia"; +import { isActiveResultLoading } from "@/lib/sql/queryExecutionState"; import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ @@ -978,8 +979,7 @@ describe("queryStore multi-statement errors", () => { const { useQueryStore } = await import("@/stores/queryStore"); const store = useQueryStore(); const tabId = store.createTab("mysql-1", "app", "Query"); - await store.executeCurrentSql("SELECT 1 AS value"); - store.toggleResultAutoSave(tabId); + await store.executeCurrentSql("SELECT 1 AS value", { openInNewResultTab: true }); const tab = store.tabs.find((item) => item.id === tabId)!; const retainedRunId = tab.activeResultRunId!; const retainedRunCacheKey = tab.resultRuns?.find((run) => run.id === retainedRunId)?.resultCacheKey; @@ -987,11 +987,16 @@ describe("queryStore multi-statement errors", () => { const execution = store.executeCurrentSql("SELECT 2 AS value", { openInNewResultTab: true }); await vi.waitFor(() => expect(mocks.executeMulti).toHaveBeenCalledTimes(2)); + expect(tab.executingResultRunId).toBeNull(); + expect(isActiveResultLoading(tab)).toBe(true); expect(await store.setActiveResultRun(tabId, retainedRunId)).toBe(true); expect(tab.batchSqlExecution?.submittedSql).toBe("SELECT 1 AS value"); + expect(isActiveResultLoading(tab)).toBe(false); pendingExecution.resolve([{ columns: ["value"], rows: [[2]], affected_rows: 0, execution_time_ms: 1 }]); await execution; + expect(tab.executingResultRunId).toBeUndefined(); + expect(isActiveResultLoading(tab)).toBe(false); expect(tab.resultRuns).toHaveLength(2); expect(tab.activeResultRunId).not.toBe(retainedRunId); expect(tab.result).toMatchObject({ rows: [[2]] }); @@ -1016,6 +1021,28 @@ describe("queryStore multi-statement errors", () => { expect(tab.batchSqlExecution?.submittedSql).toBe("SELECT 1 AS value"); }); + it("keeps loading scoped to the result run being rerun", async () => { + const pendingExecution = deferred>(); + mocks.executeMulti.mockResolvedValueOnce([{ columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }]).mockImplementationOnce(() => pendingExecution.promise); + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const tabId = store.createTab("mysql-1", "app", "Query"); + await store.executeCurrentSql("SELECT 1 AS value", { openInNewResultTab: true }); + const tab = store.tabs.find((item) => item.id === tabId)!; + const executingRunId = tab.activeResultRunId!; + + const execution = store.executeCurrentSql("SELECT 2 AS value"); + await vi.waitFor(() => expect(mocks.executeMulti).toHaveBeenCalledTimes(2)); + + expect(tab.executingResultRunId).toBe(executingRunId); + expect(isActiveResultLoading(tab)).toBe(true); + pendingExecution.resolve([{ columns: ["value"], rows: [[2]], affected_rows: 0, execution_time_ms: 1 }]); + await execution; + + expect(tab.executingResultRunId).toBeUndefined(); + expect(isActiveResultLoading(tab)).toBe(false); + }); + it("restores the retained result when a new-result execution returns no result", async () => { mocks.executeMulti.mockResolvedValueOnce([{ columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }]).mockResolvedValueOnce([]); const { useQueryStore } = await import("@/stores/queryStore"); diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 292f6a9fdd..1ece148407 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -861,6 +861,7 @@ export const useQueryStore = defineStore("query", () => { isExecuting: false, isCancelling: false, executionId: undefined, + executingResultRunId: undefined, queryExecutionStartedAt: undefined, batchSqlExecution: undefined, cancelRequestCount: 0, @@ -1903,6 +1904,7 @@ export const useQueryStore = defineStore("query", () => { tab.isExecuting = false; tab.isCancelling = false; tab.executionId = undefined; + tab.executingResultRunId = undefined; tab.queryExecutionStartedAt = undefined; if (tab.result) touchResult(tab); return id; @@ -3003,6 +3005,7 @@ export const useQueryStore = defineStore("query", () => { editorViewport: undefined, editorSelection: undefined, executionId: undefined, + executingResultRunId: undefined, isExplaining: false, explainExecutionId: undefined, mode: original.mode, @@ -3687,6 +3690,7 @@ export const useQueryStore = defineStore("query", () => { const tab = tabs.value.find((t) => t.id === id); if (!tab) return; tab.isExecuting = isExecuting; + tab.executingResultRunId = isExecuting ? null : undefined; tab.queryExecutionStartedAt = isExecuting ? Date.now() : undefined; if (!isExecuting) { tab.isCancelling = false; @@ -3698,6 +3702,7 @@ export const useQueryStore = defineStore("query", () => { const tab = tabs.value.find((t) => t.id === id); if (!tab) return; tab.isExecuting = true; + tab.executingResultRunId = null; tab.executionId = executionId; tab.isCancelling = false; tab.queryExecutionStartedAt = Date.now(); @@ -3743,6 +3748,7 @@ export const useQueryStore = defineStore("query", () => { tab.isCancelling = false; tab.queryExecutionStartedAt = undefined; tab.executionId = undefined; + tab.executingResultRunId = undefined; touchResult(tab); } @@ -3757,6 +3763,7 @@ export const useQueryStore = defineStore("query", () => { current.isExecuting = false; current.isCancelling = false; current.executionId = undefined; + current.executingResultRunId = undefined; current.queryExecutionStartedAt = undefined; if (!restorePendingResultRun(current, executionId)) { current.result = toErrorResult(new Error("Query canceled")); @@ -4617,7 +4624,12 @@ export const useQueryStore = defineStore("query", () => { const traceId = executionId.slice(0, 8); const startedAt = performance.now(); const elapsed = () => `${Math.round(performance.now() - startedAt)}ms`; + const batchResume = options?.batchResume; + const continueOnBatchError = batchResume?.continueOnError ?? settingsStore.editorSettings.continueOnErrorOnBatch; + const preserveResultDuringExecution = batchResume !== undefined || options?.preserveResultDuringExecution === true || captureAutoSavedResultRun || (tab.mode === "query" && !!tab.activeResultRunId && !tab.resultAutoSave && !captureResultRun); + const updateActiveResultRun = !!tab.activeResultRunId && preserveResultDuringExecution; tab.isExecuting = true; + tab.executingResultRunId = !captureResultRun && updateActiveResultRun ? tab.activeResultRunId : null; options?.onExecutionStarted?.(); tab.isCancelling = false; if (!tab.queryExecutionStartedAt) { @@ -4638,8 +4650,6 @@ export const useQueryStore = defineStore("query", () => { if (captureResultRun && tab.activeResultRunId) { pendingResultRunRestores.set(executionId, tab.activeResultRunId); } - const batchResume = options?.batchResume; - const continueOnBatchError = batchResume?.continueOnError ?? settingsStore.editorSettings.continueOnErrorOnBatch; if (batchResume) { tab.batchSqlExecution = prepareBatchSqlRecovery(batchResume.batch, executionId, batchResume.startStatementIndex); liveBatchSqlExecutions.set(tab, tab.batchSqlExecution); @@ -4647,8 +4657,6 @@ export const useQueryStore = defineStore("query", () => { tab.batchSqlExecution = undefined; liveBatchSqlExecutions.delete(tab); } - const preserveResultDuringExecution = batchResume !== undefined || options?.preserveResultDuringExecution === true || captureAutoSavedResultRun || (tab.mode === "query" && !!tab.activeResultRunId && !tab.resultAutoSave && !captureResultRun); - const updateActiveResultRun = !!tab.activeResultRunId && preserveResultDuringExecution; if (!updateActiveResultRun) { tab.activeResultRunId = undefined; } @@ -5754,6 +5762,7 @@ export const useQueryStore = defineStore("query", () => { current.isCancelling = false; current.queryExecutionStartedAt = undefined; current.executionId = undefined; + current.executingResultRunId = undefined; clearLiveBatchSqlExecution(current, executionId); queryExecutionLog("info", "finish", { traceId, elapsed: elapsed() }); } else { @@ -6165,6 +6174,7 @@ export const useQueryStore = defineStore("query", () => { current.isExecuting = false; current.isCancelling = false; current.executionId = undefined; + current.executingResultRunId = undefined; current.queryExecutionStartedAt = undefined; clearLiveBatchSqlExecution(current, executionId); } @@ -6182,6 +6192,7 @@ export const useQueryStore = defineStore("query", () => { current.isCancelling = false; current.queryExecutionStartedAt = undefined; current.executionId = undefined; + current.executingResultRunId = undefined; } else { // 复用 setErrorResult 的完整清理:分组结果不清空的话,错误结果不会展示, // 估算值也会继续按旧的 results 计算 diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index c26280ebc1..c4c8251913 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -1199,6 +1199,8 @@ export interface QueryTab { head: number; }; executionId?: string; + /** Ephemeral result run targeted by the current execution; null means a new run is being produced. */ + executingResultRunId?: string | null; isExplaining?: boolean; explainExecutionId?: string; /** Per-run connection session for explain flows that require session state. */