fix(grid): mask loading only on executing result run

This commit is contained in:
jearhe authored and GitHub committed 2026-09-01 14:09:42 +08:00
1 parent 1aa9f06719
commit f19439ad7a
9 files changed
+115 -8

No files matched your search

@@ -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)"
@@ -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" })]);
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { isActiveResultLoading, type QueryResultLoadingStateLike } from "@/lib/sql/queryExecutionState";
function state(overrides: Partial<QueryResultLoadingStateLike> = {}): 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);
});
});
@@ -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,
@@ -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<QueryExecutionStateLike, "isCancelling">): "common.loading" | "common.stopping" {
return state.isCancelling ? "common.stopping" : "common.loading";
}
@@ -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" }],
@@ -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<Array<{ columns: string[]; rows: number[][]; affected_rows: number; execution_time_ms: number }>>();
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");
+15 -4
View File
@@ -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 计算
+2
View File
@@ -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. */