diff --git a/apps/desktop/src/components/layout/AppTabBar.vue b/apps/desktop/src/components/layout/AppTabBar.vue index c293fd5011..52304057f7 100644 --- a/apps/desktop/src/components/layout/AppTabBar.vue +++ b/apps/desktop/src/components/layout/AppTabBar.vue @@ -10,6 +10,7 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from " import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import DatabaseIcon from "@/components/icons/DatabaseIcon.vue"; +import TabExecutionStatus from "@/components/layout/TabExecutionStatus.vue"; import { useConnectionStore } from "@/stores/connectionStore"; import { useQueryStore } from "@/stores/queryStore"; import { useSettingsStore } from "@/stores/settingsStore"; @@ -721,6 +722,7 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul + + {{ tabTitleText(tab) }} @@ -927,6 +930,7 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul + + {{ tabTitleText(tab) }} diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index 321fa6206a..e9a4ca491f 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -720,7 +720,7 @@ watch( ); watch( - () => [props.activeTab.id, props.activeTab.result, props.activeTab.results, props.activeTab.isExecuting] as const, + () => [props.activeTab.id, props.activeTab.result, props.activeTab.results, props.activeTab.isExecuting, props.activeOutputView] as const, () => { if (props.activeTab.isExecuting) return; if (hasExecutionSummary.value && !hasTabularResult.value && props.activeOutputView === "result") { diff --git a/apps/desktop/src/components/layout/TabExecutionStatus.vue b/apps/desktop/src/components/layout/TabExecutionStatus.vue new file mode 100644 index 0000000000..9fe5d3a081 --- /dev/null +++ b/apps/desktop/src/components/layout/TabExecutionStatus.vue @@ -0,0 +1,31 @@ + + + diff --git a/apps/desktop/src/components/layout/__tests__/AppTabBar.spec.ts b/apps/desktop/src/components/layout/__tests__/AppTabBar.spec.ts index 0f33a7fca5..54aa122b78 100644 --- a/apps/desktop/src/components/layout/__tests__/AppTabBar.spec.ts +++ b/apps/desktop/src/components/layout/__tests__/AppTabBar.spec.ts @@ -119,3 +119,10 @@ describe("AppTabBar overflow search", () => { expect(tabBarSource).toContain('[data-tab-search-input="fixed"]'); }); }); + +describe("AppTabBar query execution status", () => { + it("renders one shared status indicator in regular, pinned, and both overflow surfaces", () => { + expect(tabBarSource).toContain('import TabExecutionStatus from "@/components/layout/TabExecutionStatus.vue";'); + expect(tabBarSource.match(//g)).toHaveLength(4); + }); +}); diff --git a/apps/desktop/src/components/layout/__tests__/ContentArea.outputView.spec.ts b/apps/desktop/src/components/layout/__tests__/ContentArea.outputView.spec.ts new file mode 100644 index 0000000000..2d111bf0e4 --- /dev/null +++ b/apps/desktop/src/components/layout/__tests__/ContentArea.outputView.spec.ts @@ -0,0 +1,11 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const contentAreaSource = readFileSync(new URL("../ContentArea.vue", import.meta.url), "utf8"); + +describe("ContentArea completed query output", () => { + it("re-evaluates the no-table fallback when tab activation resets the shared view", () => { + expect(contentAreaSource).toContain("props.activeTab.isExecuting, props.activeOutputView] as const"); + expect(contentAreaSource).toContain('emit("update:activeOutputView", result ? defaultViewForResult(result) : "summary")'); + }); +}); diff --git a/apps/desktop/src/components/layout/__tests__/TabExecutionStatus.spec.ts b/apps/desktop/src/components/layout/__tests__/TabExecutionStatus.spec.ts new file mode 100644 index 0000000000..ff995fb3e6 --- /dev/null +++ b/apps/desktop/src/components/layout/__tests__/TabExecutionStatus.spec.ts @@ -0,0 +1,69 @@ +// @vitest-environment happy-dom + +import { createApp, h, nextTick, reactive } from "vue"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import TabExecutionStatus from "../TabExecutionStatus.vue"; + +vi.mock("vue-i18n", () => ({ + useI18n: () => ({ t: (key: string) => key }), +})); + +const mountedApps: ReturnType[] = []; + +function mountStatus(initial: { mode: "query" | "data"; isExecuting: boolean; isCancelling?: boolean }) { + const state = reactive(initial); + const root = document.createElement("div"); + document.body.appendChild(root); + const app = createApp({ + setup: () => () => h(TabExecutionStatus, { tab: state }), + }); + mountedApps.push(app); + app.mount(root); + return { root, state }; +} + +describe("TabExecutionStatus", () => { + afterEach(() => { + for (const app of mountedApps.splice(0)) app.unmount(); + document.body.innerHTML = ""; + }); + + it("shows an accessible, reduced-motion-safe running indicator", async () => { + const { root } = mountStatus({ mode: "query", isExecuting: true, isCancelling: false }); + await nextTick(); + + const status = root.querySelector("[data-tab-execution-status]"); + expect(status?.getAttribute("aria-label")).toBe("common.loading"); + expect(status?.className).toContain("text-blue-600"); + expect(status?.querySelector("svg")?.getAttribute("aria-hidden")).toBe("true"); + expect(status?.querySelector("svg")?.getAttribute("class")).toContain("motion-reduce:animate-none"); + }); + + it("announces cancelling distinctly", async () => { + const { root } = mountStatus({ mode: "query", isExecuting: true, isCancelling: true }); + await nextTick(); + + const status = root.querySelector("[data-tab-execution-status]"); + expect(status?.getAttribute("aria-label")).toBe("common.stopping"); + expect(status?.className).toContain("text-amber-600"); + }); + + it.each(["success", "error", "cancelled"])("disappears as soon as %s completes", async () => { + const { root, state } = mountStatus({ mode: "query", isExecuting: true, isCancelling: true }); + await nextTick(); + state.isExecuting = false; + state.isCancelling = false; + await nextTick(); + + expect(root.querySelector("[data-tab-execution-status]")).toBeNull(); + }); + + it("does not show query status for idle or non-query tabs", async () => { + const idle = mountStatus({ mode: "query", isExecuting: false }); + const data = mountStatus({ mode: "data", isExecuting: true }); + await nextTick(); + + expect(idle.root.querySelector("[data-tab-execution-status]")).toBeNull(); + expect(data.root.querySelector("[data-tab-execution-status]")).toBeNull(); + }); +}); diff --git a/apps/desktop/src/composables/__tests__/useSqlExecution.spec.ts b/apps/desktop/src/composables/__tests__/useSqlExecution.spec.ts index 3a6034aa4d..e595d53305 100644 --- a/apps/desktop/src/composables/__tests__/useSqlExecution.spec.ts +++ b/apps/desktop/src/composables/__tests__/useSqlExecution.spec.ts @@ -259,6 +259,40 @@ describe("useSqlExecution", () => { expect(activeOutputView.value).toBe("summary"); }); + it("does not change the current output view when a background tab finishes", async () => { + const sql = "SELECT run_side_effect()"; + const executionTab = { ...queryTab("app"), sql }; + const activeTab = ref(executionTab); + const activeConnection = ref(connection("mysql")); + const activeOutputView = ref<"result" | "summary" | "explain" | "chart" | "messages">("result"); + const queryStore = useQueryStore(); + let finishExecution!: () => void; + const executionFinished = new Promise((resolve) => { + finishExecution = resolve; + }); + const executeCurrentSql = vi.spyOn(queryStore, "executeCurrentSql").mockImplementation(async () => { + await executionFinished; + executionTab.result = { columns: [], rows: [], affected_rows: 1, execution_time_ms: 1 }; + }); + vi.spyOn(useHistoryStore(), "add").mockResolvedValue(undefined); + + const execution = useSqlExecution({ + activeTab: computed(() => activeTab.value), + activeConnection: computed(() => activeConnection.value), + executableSql: computed(() => sql), + activeOutputView, + }); + + const pending = execution.tryExecute(); + await vi.waitFor(() => expect(executeCurrentSql).toHaveBeenCalledOnce()); + activeTab.value = { ...queryTab("app"), id: "tab-2" }; + activeOutputView.value = "messages"; + finishExecution(); + await pending; + + expect(activeOutputView.value).toBe("messages"); + }); + it("shows SQL Server PRINT messages instead of leaving them behind the execution summary", async () => { const sql = `IF 1 = 1 BEGIN diff --git a/apps/desktop/src/composables/useSqlExecution.ts b/apps/desktop/src/composables/useSqlExecution.ts index b31239501b..946f9c0d8b 100644 --- a/apps/desktop/src/composables/useSqlExecution.ts +++ b/apps/desktop/src/composables/useSqlExecution.ts @@ -302,14 +302,15 @@ export function useSqlExecution(deps: { ...(options.editorViewportRequestId !== undefined ? { onExecutionStarted: () => deps.onExecutionStarted?.(options.editorViewportRequestId!) } : {}), }); if (producedResult === false) return; + const executionTabStillActive = deps.activeTab.value?.id === tab.id; const sqlServerMessageResultIndex = executionDatabaseType === "sqlserver" ? tab.results?.findIndex((result) => result.server_message === true) : undefined; if (sqlServerMessageResultIndex !== undefined && sqlServerMessageResultIndex >= 0) { focusSqlServerDataResult(tab.id, executionDatabaseType, tab); - deps.activeOutputView.value = "result"; + if (executionTabStillActive) deps.activeOutputView.value = "result"; } else if (executionDatabaseType === "sqlserver" && tab.result?.server_message === true) { - deps.activeOutputView.value = "result"; + if (executionTabStillActive) deps.activeOutputView.value = "result"; } else if (tab.result && !tab.result.columns.length && !tab.results?.some((result) => result.columns.length > 0)) { - deps.activeOutputView.value = statementCount === 1 ? defaultViewForResult(tab.result) : "summary"; + if (executionTabStillActive) deps.activeOutputView.value = statementCount === 1 ? defaultViewForResult(tab.result) : "summary"; } const elapsed = Date.now() - start; const failure = firstQueryExecutionError(tab); @@ -481,7 +482,7 @@ export function useSqlExecution(deps: { // 会让多个 worker 几乎同时改这个共享 ref,导致主视图在 result/summary 间反复 // 跳动(闪烁/竞态)。worker 结果已由 captureMultiDbExecutionWorkerResult 记录 // 到 source tab 的 result run 并通过 projectResultRun 投影显示,无需再切主视图。 - if (!workerId) { + if (!workerId && deps.activeTab.value?.id === tab.id) { deps.activeOutputView.value = success && (latest.result?.columns.length || latest.results?.some((result) => result.columns.length)) ? "result" : "summary"; } return finish(success ? { status: "success", errorMessage } : { status: "failed", errorMessage });