feat(tabs): show query execution status across tab surfaces
Closes #4986
This commit is contained in:
1 parent
277386ec81
commit
a2f8824e6a
8 files changed
+163
-5
No files matched your search
@@ -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
|
||||
<GitBranch v-else-if="tab.mode === 'dolt-version-control'" class="h-3.5 w-3.5" />
|
||||
<Code2 v-else class="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<TabExecutionStatus :tab="tab" />
|
||||
<input
|
||||
v-if="editingTabId === tab.id"
|
||||
v-model="editingTitle"
|
||||
@@ -843,6 +845,7 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul
|
||||
>
|
||||
<DatabaseIcon v-if="tab.mode === 'mq'" :db-type="tabDatabaseIconType(tab)" class="h-3.5 w-3.5 shrink-0" />
|
||||
<component :is="tabMenuIcon(tab)" v-else :class="['h-3.5 w-3.5 shrink-0', tabIconClass(tab)]" />
|
||||
<TabExecutionStatus :tab="tab" />
|
||||
<span class="inline-flex min-w-0 flex-1 items-center gap-0.5 overflow-hidden">
|
||||
<span v-if="isDirtyTab(tab)" aria-hidden="true" class="dirty-tab-marker">*</span>
|
||||
<span class="min-w-0 flex-1 truncate" :style="tabTitleStyle(tab)">{{ tabTitleText(tab) }}</span>
|
||||
@@ -927,6 +930,7 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul
|
||||
<GitBranch v-else-if="tab.mode === 'dolt-version-control'" class="h-3.5 w-3.5" />
|
||||
<Code2 v-else class="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<TabExecutionStatus :tab="tab" />
|
||||
<input
|
||||
v-if="editingTabId === tab.id"
|
||||
v-model="editingTitle"
|
||||
@@ -995,6 +999,7 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul
|
||||
>
|
||||
<DatabaseIcon v-if="tab.mode === 'mq'" :db-type="tabDatabaseIconType(tab)" class="h-3.5 w-3.5 shrink-0" />
|
||||
<component :is="tabMenuIcon(tab)" v-else :class="['h-3.5 w-3.5 shrink-0', tabIconClass(tab)]" />
|
||||
<TabExecutionStatus :tab="tab" />
|
||||
<span class="inline-flex min-w-0 flex-1 items-center gap-0.5 overflow-hidden">
|
||||
<span v-if="isDirtyTab(tab)" aria-hidden="true" class="dirty-tab-marker">*</span>
|
||||
<span class="min-w-0 flex-1 truncate" :style="tabTitleStyle(tab)">{{ tabTitleText(tab) }}</span>
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { Loader2 } from "@lucide/vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { queryExecutionLabelKey } from "@/lib/sql/queryExecutionState";
|
||||
import type { QueryTab } from "@/types/database";
|
||||
|
||||
const props = defineProps<{
|
||||
tab: Pick<QueryTab, "mode" | "isExecuting" | "isCancelling">;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const status = computed(() => {
|
||||
if (props.tab.mode !== "query" || !props.tab.isExecuting) return undefined;
|
||||
return props.tab.isCancelling ? "cancelling" : "running";
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
v-if="status"
|
||||
data-tab-execution-status
|
||||
role="status"
|
||||
class="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-background/80"
|
||||
:class="status === 'cancelling' ? 'text-amber-600 dark:text-amber-400' : 'text-blue-600 dark:text-blue-400'"
|
||||
:aria-label="t(queryExecutionLabelKey(tab))"
|
||||
:title="t(queryExecutionLabelKey(tab))"
|
||||
>
|
||||
<Loader2 aria-hidden="true" class="h-3 w-3 animate-spin motion-reduce:animate-none" />
|
||||
</span>
|
||||
</template>
|
||||
@@ -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(/<TabExecutionStatus :tab="tab" \/>/g)).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
@@ -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")');
|
||||
});
|
||||
});
|
||||
@@ -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<typeof createApp>[] = [];
|
||||
|
||||
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<HTMLElement>("[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<HTMLElement>("[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();
|
||||
});
|
||||
});
|
||||
@@ -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<QueryTab | undefined>(executionTab);
|
||||
const activeConnection = ref<ConnectionConfig | undefined>(connection("mysql"));
|
||||
const activeOutputView = ref<"result" | "summary" | "explain" | "chart" | "messages">("result");
|
||||
const queryStore = useQueryStore();
|
||||
let finishExecution!: () => void;
|
||||
const executionFinished = new Promise<void>((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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in new issue
Block a user