feat(tabs): optimize result run tabs layout and middle click close

This commit is contained in:
lxk955
2026-10-11 13:33:47 +08:00
committed by GitHub
parent 739e805b83
commit 63adea29c1
4 changed files with 199 additions and 7 deletions
@@ -2103,6 +2103,7 @@ defineExpose({
class="group/result-run inline-flex h-7 shrink-0 select-none items-center overflow-hidden rounded-md border transition-colors"
:class="run.active ? 'border-border bg-background text-foreground shadow-sm' : 'border-transparent text-muted-foreground hover:border-border/70 hover:bg-background/70 hover:text-foreground'"
@contextmenu="onContextMenu"
@mousedown.middle.stop.prevent="removeResultRun(run.id)"
>
<button
type="button"
@@ -2114,6 +2115,7 @@ defineExpose({
class="flex h-full select-none items-center gap-1 whitespace-nowrap pl-2.5 pr-1 text-xs font-medium outline-none focus-visible:bg-accent focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/50"
@click="selectResultRunFromTab(run.id)"
@keydown="onResultRunTabKeydown($event, runIndex)"
@mousedown.middle.stop.prevent="removeResultRun(run.id)"
>
<Pin v-if="run.pinned" class="h-3 w-3 shrink-0 fill-current text-primary" />
{{ run.title || run.sourceLabel || resultRunFallbackLabel(run.sequence) }}
@@ -2132,7 +2134,7 @@ defineExpose({
</div>
</div>
</div>
<div v-else-if="showResultRunSelector" class="min-w-0" :class="{ 'flex-1': visibleResultItems.length === 0 }">
<div v-else-if="showResultRunSelector" class="min-w-0" :class="{ 'flex-1': visibleResultItems.length <= 1 }">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="sm" class="h-6 max-w-48 gap-1 px-2 text-xs">
@@ -2142,7 +2144,7 @@ defineExpose({
</DropdownMenuTrigger>
<DropdownMenuContent align="start" class="w-48">
<CustomContextMenu v-for="run in resultRuns" :key="run.id" :items="() => resultRunContextMenuItems(run)" v-slot="{ onContextMenu }">
<DropdownMenuItem class="flex items-center gap-2 pr-1" @select="selectResultRunFromTab(run.id)" @contextmenu="onContextMenu">
<DropdownMenuItem class="flex items-center gap-2 pr-1" @select="selectResultRunFromTab(run.id)" @contextmenu="onContextMenu" @mousedown.middle.stop.prevent="removeResultRun(run.id)">
<Check v-if="run.active" class="h-3.5 w-3.5 shrink-0" />
<span v-else class="h-3.5 w-3.5 shrink-0" />
<Pin v-if="run.pinned" class="h-3 w-3 shrink-0 fill-current text-primary" />
@@ -2162,9 +2164,9 @@ defineExpose({
</DropdownMenu>
</div>
<div v-else-if="resultRuns.length > 0" class="min-w-0 flex-1" />
<span v-if="resultRuns.length > 0 && visibleResultItems.length > 0" class="mx-1 h-4 w-px shrink-0 bg-border" />
<span v-if="resultRuns.length > 0 && visibleResultItems.length > 1" class="mx-1 h-4 w-px shrink-0 bg-border" />
<ResultSetNavigator
v-if="visibleResultItems.length > 0"
v-if="resultRuns.length > 0 ? visibleResultItems.length > 1 : visibleResultItems.length > 0"
:key="`${activeTab.id}:${activeTab.activeResultRunId ?? 'current'}`"
:items="visibleResultItems"
:busy="resultBatchBusy"
@@ -2172,6 +2174,7 @@ defineExpose({
:active-index="activeTab.activeResultIndex ?? 0"
:active="activeOutputView === 'result'"
:display-mode="resultRunDisplayMode"
:has-sibling-run-tabs="resultRuns.length > 0"
@select="selectResultItem"
@copy-sql="copySelectedResultSql"
@copy-query-sql="copySelectedResultQueries"
@@ -17,9 +17,11 @@ const props = withDefaults(
busy?: boolean;
canExportXlsx?: boolean;
displayMode?: "tabs" | "list";
hasSiblingRunTabs?: boolean;
}>(),
{
displayMode: "tabs",
hasSiblingRunTabs: false,
},
);
const emit = defineEmits<{
@@ -159,7 +161,7 @@ function onListKeydown(event: KeyboardEvent, index: number) {
</script>
<template>
<div data-result-set-tabs-region role="group" :aria-label="t('tabs.resultSets')" class="flex h-full min-w-0 flex-1 items-center gap-1 overflow-hidden">
<div data-result-set-tabs-region role="group" :aria-label="t('tabs.resultSets')" class="flex h-full min-w-0 items-center gap-1 overflow-hidden" :class="hasSiblingRunTabs ? 'shrink-0 max-w-[50%]' : 'flex-1'">
<template v-if="displayMode === 'list'">
<div v-if="items.length > 0" class="flex min-w-0 items-center gap-1">
<Popover v-if="items.length > 1" v-model:open="open">
@@ -0,0 +1,172 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, reactive } from "vue";
import { createPinia, setActivePinia } from "pinia";
import { createI18n } from "vue-i18n";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { QueryResult, QueryTab } from "@/types/database";
vi.mock("@/components/editor/QueryEditor.vue", () => ({ default: { render: () => null } }));
vi.mock("@/components/transfer/QueryResultTransferDialog.vue", () => ({ __esModule: true, default: { render: () => null } }));
vi.mock("@/components/grid/DataGrid.vue", () => ({ default: { render: () => h("div", { "data-test": "data-grid" }) } }));
import ContentArea from "../ContentArea.vue";
import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
const cleanups: Array<() => void> = [];
afterEach(() => {
cleanups.splice(0).forEach((cleanup) => cleanup());
document.body.replaceChildren();
window.localStorage?.clear();
});
async function mountContentAreaWithRuns(options: { results: QueryResult[]; resultRuns?: NonNullable<QueryTab["resultRuns"]>; activeResultRunId?: string }) {
const pinia = createPinia();
setActivePinia(pinia);
const connection = { id: "mysql-1", name: "MySQL", db_type: "mysql" as const, host: "localhost", port: 3306, username: "root", password: "" };
useConnectionStore().connections = [connection];
const tab = reactive<QueryTab>({
id: "query-tab-1",
title: "Query 1",
connectionId: connection.id,
database: "app",
mode: "query",
sql: "SELECT 1",
isExecuting: false,
results: options.results,
result: options.results[0],
activeResultIndex: 0,
resultRuns: options.resultRuns,
activeResultRunId: options.activeResultRunId,
});
useQueryStore().tabs.push(tab);
const state = reactive({ view: "result" as "result" | "messages" });
const host = document.createElement("div");
document.body.appendChild(host);
const app = createApp(
defineComponent({
setup: () => () =>
h(ContentArea, {
activeTab: tab,
activeConnection: connection,
activeOutputView: state.view,
executableSql: "",
formatSqlRequest: null,
compressSqlRequest: null,
selectedSql: "",
cursorPos: 0,
resultOnly: true,
blockDangerousRedisCommands: false,
"onUpdate:activeOutputView": (_tabId: string, view: string) => {
state.view = view as typeof state.view;
},
}),
}),
);
app.use(pinia);
app.use(
createI18n({
legacy: false,
locale: "en",
messages: {
en: {
tabs: {
resultN: "Result {n}",
runN: "Run {n}",
resultRuns: "Result runs",
resultSets: "Result sets",
allResults: "All results ({count})",
removeRun: "Remove run {n}",
},
},
},
missingWarn: false,
fallbackWarn: false,
}),
);
app.mount(host);
cleanups.push(() => app.unmount());
await nextTick();
await nextTick();
return { host, state, tab };
}
describe("ContentArea result run tabs layout and middle-click close", () => {
const singleResult: QueryResult = {
columns: ["id"],
rows: [[1]],
affected_rows: 1,
execution_time_ms: 5,
};
const secondResult: QueryResult = {
columns: ["name"],
rows: [["Alice"]],
affected_rows: 1,
execution_time_ms: 3,
};
it("hides ResultSetNavigator when resultRuns exist and active run has a single result set", async () => {
const { host } = await mountContentAreaWithRuns({
results: [singleResult],
resultRuns: [
{ id: "run-1", title: "Run 1", sequence: 1, sql: "SELECT 1", createdAt: 1, result: singleResult, results: [singleResult] },
{ id: "run-2", title: "Run 2", sequence: 2, sql: "SELECT 2", createdAt: 2, result: singleResult, results: [singleResult] },
],
activeResultRunId: "run-1",
});
const runTabsRegion = host.querySelector("[data-result-run-tabs-region]");
expect(runTabsRegion).not.toBeNull();
const runTabs = host.querySelectorAll("[data-result-run-tab]");
expect(runTabs.length).toBe(2);
// ResultSetNavigator is hidden because visibleResultItems.length <= 1
const resultSetRegion = host.querySelector("[data-result-set-tabs-region]");
expect(resultSetRegion).toBeNull();
});
it("shows ResultSetNavigator when resultRuns exist and active run has multiple result sets", async () => {
const { host } = await mountContentAreaWithRuns({
results: [singleResult, secondResult],
resultRuns: [
{ id: "run-1", title: "Run 1", sequence: 1, sql: "SELECT 1; SELECT 2;", createdAt: 1, result: singleResult, results: [singleResult, secondResult] },
{ id: "run-2", title: "Run 2", sequence: 2, sql: "SELECT 3", createdAt: 2, result: singleResult, results: [singleResult] },
],
activeResultRunId: "run-1",
});
const runTabsRegion = host.querySelector("[data-result-run-tabs-region]");
expect(runTabsRegion).not.toBeNull();
// ResultSetNavigator is shown because visibleResultItems.length > 1
const resultSetRegion = host.querySelector("[data-result-set-tabs-region]");
expect(resultSetRegion).not.toBeNull();
expect(resultSetRegion?.classList.contains("shrink-0")).toBe(true);
expect(resultSetRegion?.classList.contains("max-w-[50%]")).toBe(true);
});
it("closes result run on middle click on the result run tab", async () => {
const { host } = await mountContentAreaWithRuns({
results: [singleResult],
resultRuns: [
{ id: "run-1", title: "Run 1", sequence: 1, sql: "SELECT 1", createdAt: 1, result: singleResult, results: [singleResult] },
{ id: "run-2", title: "Run 2", sequence: 2, sql: "SELECT 2", createdAt: 2, result: singleResult, results: [singleResult] },
],
activeResultRunId: "run-1",
});
const queryStore = useQueryStore();
const removeSpy = vi.spyOn(queryStore, "removeResultRun").mockResolvedValue(true);
const runTabs = host.querySelectorAll<HTMLButtonElement>("[data-result-run-tab]");
expect(runTabs.length).toBe(2);
// Middle click on run-2 (button: 1)
const middleClickEvent = new MouseEvent("mousedown", { button: 1, bubbles: true, cancelable: true });
runTabs[1]!.dispatchEvent(middleClickEvent);
await nextTick();
expect(removeSpy).toHaveBeenCalledWith("query-tab-1", "run-2");
});
});
@@ -12,7 +12,7 @@ afterEach(() => {
document.body.replaceChildren();
});
async function mountNavigator(inputResults?: QueryResult[], canExportXlsx = true, displayMode: "tabs" | "list" = "tabs", activeIndex = 1) {
async function mountNavigator(inputResults?: QueryResult[], canExportXlsx = true, displayMode: "tabs" | "list" = "tabs", activeIndex = 1, hasSiblingRunTabs = false) {
// Include a non-tabular result so ordinal and storage index are different.
const results = inputResults ?? ([{ columns: [], rows: [] }, ...Array.from({ length: 100 }, (_, i) => ({ columns: ["value"], rows: [[i + 1]], sourceStatement: `SELECT ${i + 1} AS value` }))] as QueryResult[]);
const select = vi.fn();
@@ -21,7 +21,7 @@ async function mountNavigator(inputResults?: QueryResult[], canExportXlsx = true
const exportXlsx = vi.fn();
const container = document.createElement("div");
document.body.append(container);
app = createApp(ResultSetNavigator, { items: tabularResultItems(results), activeIndex, active: true, canExportXlsx, displayMode, onSelect: select, onCopySql: copySql, onCopyQuerySql: copyQuerySql, onExportXlsx: exportXlsx });
app = createApp(ResultSetNavigator, { items: tabularResultItems(results), activeIndex, active: true, canExportXlsx, displayMode, hasSiblingRunTabs, onSelect: select, onCopySql: copySql, onCopyQuerySql: copyQuerySql, onExportXlsx: exportXlsx });
app.use(
createI18n({
legacy: false,
@@ -239,4 +239,19 @@ describe("large result-set navigation", () => {
expect(button.textContent?.trim()).toBe("Result sets");
expect(button.querySelector("svg")).toBeNull();
});
it("uses flex-1 when hasSiblingRunTabs is false or omitted", async () => {
const { container } = await mountNavigator();
const region = container.querySelector("[data-result-set-tabs-region]")!;
expect(region.classList.contains("flex-1")).toBe(true);
expect(region.classList.contains("shrink-0")).toBe(false);
});
it("constrains width with shrink-0 max-w-[50%] when hasSiblingRunTabs is true", async () => {
const { container } = await mountNavigator(undefined, true, "tabs", 1, true);
const region = container.querySelector("[data-result-set-tabs-region]")!;
expect(region.classList.contains("shrink-0")).toBe(true);
expect(region.classList.contains("max-w-[50%]")).toBe(true);
expect(region.classList.contains("flex-1")).toBe(false);
});
});