From a2f8824e6a60fb58df966d533e95577a1ff8e71a Mon Sep 17 00:00:00 2001
From: t8y2 <1156263951@qq.com>
Date: Tue, 25 Aug 2026 01:30:48 +0000
Subject: [PATCH] feat(tabs): show query execution status across tab surfaces
Closes #4986
---
.../src/components/layout/AppTabBar.vue | 5 ++
.../src/components/layout/ContentArea.vue | 2 +-
.../components/layout/TabExecutionStatus.vue | 31 +++++++++
.../layout/__tests__/AppTabBar.spec.ts | 7 ++
.../__tests__/ContentArea.outputView.spec.ts | 11 +++
.../__tests__/TabExecutionStatus.spec.ts | 69 +++++++++++++++++++
.../__tests__/useSqlExecution.spec.ts | 34 +++++++++
.../src/composables/useSqlExecution.ts | 9 +--
8 files changed, 163 insertions(+), 5 deletions(-)
create mode 100644 apps/desktop/src/components/layout/TabExecutionStatus.vue
create mode 100644 apps/desktop/src/components/layout/__tests__/ContentArea.outputView.spec.ts
create mode 100644 apps/desktop/src/components/layout/__tests__/TabExecutionStatus.spec.ts
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 });