fix(layout): keep group tab strips visible on special pages

Opening settings or the driver store hid the whole workspace, so every
editor tab vanished with no mouse path back. The workspace now stays
mounted and hides only the pane content, keeping the strips clickable.
Special-page tabs also follow the classic flat tab presentation instead
of always rendering as separated-layout pills.
This commit is contained in:
t8y2
2026-09-05 11:56:58 +08:00
parent 46afd0dd5e
commit 789d65d3df
8 changed files with 138 additions and 24 deletions
+4 -1
View File
@@ -3495,9 +3495,12 @@ onUnmounted(() => {
@update:open="(open: boolean) => (open ? activateSettingsPage() : closeSettingsPage())"
@check-updates="checkUpdates()"
/>
<div v-if="queryStore.tabs.length > 0" v-show="!driverStoreActive && !settingsStore.settingsPageActive" class="flex flex-col flex-1 min-h-0">
<!-- Keep the workspace mounted with its group tab strips visible while a special page is active:
the strips are the mouse path back to the editors (clicking a tab deactivates the page). -->
<div v-if="queryStore.tabs.length > 0" class="flex flex-col flex-1 min-h-0">
<SqlEditorWorkspace
ref="contentAreaRef"
:special-surface-active="driverStoreActive || settingsStore.settingsPageActive"
@locate-tab="locateTabInSidebar"
@toggle-zen-mode="toggleZenMode"
@start-resize="startTabBarResize"
@@ -36,6 +36,7 @@ const emit = defineEmits<{
const { t } = useI18n();
const queryStore = useQueryStore();
const settingsStore = useSettingsStore();
const isClassicLayout = computed(() => settingsStore.editorSettings.appLayout === "classic");
const closeConfirmDirtyCount = computed(() => queryStore.closeConfirmDirtyTabIds.length);
const showCloseConfirmBulkActions = computed(() => closeConfirmDirtyCount.value > 1);
const closeConfirmDirtyTabs = computed(() => queryStore.closeConfirmDirtyTabIds.map((id) => queryStore.tabs.find((tab) => tab.id === id)).filter((tab): tab is NonNullable<ReturnType<typeof queryStore.tabs.find>> => !!tab));
@@ -106,6 +107,20 @@ function toggleCompactTabTitle() {
compactTabTitle.value = !compactTabTitle.value;
}
// Special-page tabs share the regular tabs' presentation: flat full-height
// segments with right borders in the classic layout, floating pills in the
// separated layout (mirrors EditorGroupTabBar's strip classes).
const specialStripRowClass = computed(() => (isClassicLayout.value ? "h-9 items-stretch" : "h-10 items-center px-2"));
const specialStripScrollClass = computed(() => (isClassicLayout.value ? "h-full items-center overflow-x-auto" : "h-full items-center gap-1.5 overflow-x-auto py-1.5"));
const specialStripBarClass = computed(() => (isClassicLayout.value ? "bg-muted" : `bg-background ${settingsStore.editorSettings.tabPlacement === "bottom" ? "border-t" : "border-b"}`));
function specialTabClass(active: boolean, widthClass: string): string[] {
if (isClassicLayout.value) {
return ["h-full border-r border-border/80 font-medium dark:border-border/45", active ? "bg-background text-foreground" : "text-foreground/70 hover:text-foreground/90"];
}
return [`h-7 ${widthClass} rounded-md border`, active ? "border-ring font-medium text-foreground" : "border-border/60 text-foreground/70 hover:border-border hover:text-foreground/90"];
}
type SpecialRegularSurface = "driverStore" | "settings";
function closeSpecialRegularSurfaces(keep?: SpecialRegularSurface) {
@@ -197,17 +212,17 @@ function handleCancelClose() {
</script>
<template>
<div v-if="driverStoreOpen || settingsPageOpen" class="app-tab-bar relative flex w-full min-w-0 shrink-0 overflow-hidden border-b bg-background" data-main-tab-bar :class="{ 'ring-2 ring-primary ring-inset': detachedDropTarget }">
<div class="flex h-10 w-full min-w-0 shrink-0 items-center overflow-hidden px-2">
<div v-if="driverStoreOpen || settingsPageOpen" class="app-tab-bar relative flex w-full min-w-0 shrink-0 overflow-hidden" :class="[specialStripBarClass, { 'ring-2 ring-primary ring-inset': detachedDropTarget }]" data-main-tab-bar>
<div class="flex w-full min-w-0 shrink-0 overflow-hidden" :class="specialStripRowClass">
<div class="app-tab-strip relative h-full min-w-0 flex-1 overflow-hidden">
<div class="app-tab-scroll flex h-full w-full min-w-0 flex-1 items-center gap-1.5 overflow-x-auto py-1.5">
<div class="app-tab-scroll flex h-full w-full min-w-0 flex-1" :class="specialStripScrollClass">
<!-- Settings Page Tab -->
<CustomContextMenu v-if="settingsPageOpen" :items="getSpecialRegularTabMenuItems('settings')" v-slot="{ onContextMenu }">
<div @contextmenu="onContextMenu">
<div :class="isClassicLayout ? 'h-full' : ''" @contextmenu="onContextMenu">
<div
data-settings-page-tab
class="app-tab-pill group flex h-7 min-w-36 cursor-default items-center gap-1 rounded-md border px-2 text-xs transition-colors whitespace-nowrap"
:class="settingsPageActive ? 'border-ring font-medium text-foreground' : 'border-border/60 text-foreground/70 hover:border-border hover:text-foreground/90'"
class="app-tab-pill group flex cursor-default items-center gap-1 px-2 text-xs transition-colors whitespace-nowrap select-none"
:class="specialTabClass(!!settingsPageActive, 'min-w-36')"
:data-active-tab="settingsPageActive"
@click="emit('activate-settings-page')"
@mousedown.middle.prevent="emit('close-settings-page')"
@@ -225,11 +240,11 @@ function handleCancelClose() {
<!-- Driver Store Tab -->
<CustomContextMenu v-if="driverStoreOpen" :items="getSpecialRegularTabMenuItems('driverStore')" v-slot="{ onContextMenu }">
<div @contextmenu="onContextMenu">
<div :class="isClassicLayout ? 'h-full' : ''" @contextmenu="onContextMenu">
<div
data-driver-store-tab
class="app-tab-pill group flex h-7 min-w-38 cursor-default items-center gap-1 rounded-md border px-2 text-xs transition-colors whitespace-nowrap"
:class="driverStoreActive ? 'border-ring font-medium text-foreground' : 'border-border/60 text-foreground/70 hover:border-border hover:text-foreground/90'"
class="app-tab-pill group flex cursor-default items-center gap-1 px-2 text-xs transition-colors whitespace-nowrap select-none"
:class="specialTabClass(!!driverStoreActive, 'min-w-38')"
:data-active-tab="driverStoreActive"
@click="emit('activate-driver-store')"
@mousedown.middle.prevent="emit('close-driver-store')"
@@ -27,6 +27,8 @@ const props = defineProps<
tabBarCollapsed?: boolean;
canDetachTabs?: boolean;
detachedDropTarget?: boolean;
/** App-level special pages (settings / driver store) hide the pane content but keep the tab strip clickable. */
contentHidden?: boolean;
}
>();
@@ -146,7 +148,7 @@ const groupExecutableSql = computed(() => {
/>
<!-- The toolbar stays at the top of the pane's content column in every
placement; only the tab bar moves around it. -->
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
<div v-show="!contentHidden" data-group-content class="flex min-h-0 min-w-0 flex-1 flex-col">
<EditorToolbar
v-if="activeTab && showGroupToolbar"
:active-tab="activeTab"
@@ -19,6 +19,8 @@ const props = defineProps<
tabBarCollapsed?: boolean;
canDetachTabs?: boolean;
detachedDropTarget?: boolean;
/** While an App-level special page (settings / driver store) is active, keep only the group tab strips visible. */
specialSurfaceActive?: boolean;
}
>();
const emit = defineEmits<
@@ -122,7 +124,9 @@ const showResultPane = ref(true);
// split and 0, so collapsing/expanding glides instead of jumping: the pane
// transition for this outer split is re-enabled in sqlEditorWorkspace.css
// (globals.css kills it for horizontal splitpanes).
const resultPaneTargetSize = computed(() => (showSharedResult.value && showResultPane.value ? resultPaneSize.value : 0));
// Special pages own the main area: the shared result pane collapses so only
// the per-group tab strips remain clickable as the way back to the workspace.
const resultPaneTargetSize = computed(() => (showSharedResult.value && showResultPane.value && !props.specialSurfaceActive ? resultPaneSize.value : 0));
const editorPaneSize = computed(() => 100 - resultPaneTargetSize.value);
// Entrance choreography is hydration-gated: groups present at first render
// never animate (no load choreography); only groups created later — by a
@@ -217,6 +221,7 @@ function handleFocusStatement(tabId: string, range: StatementRange | null): bool
:tab-bar-collapsed="tabBarCollapsed"
:can-detach-tabs="canDetachTabs"
:detached-drop-target="detachedDropTarget"
:content-hidden="specialSurfaceActive"
class="h-full"
v-bind="editorGroupBindings"
@focus-group="queryStore.focusGroup($event)"
@@ -69,6 +69,24 @@ describe("AppTabBar special page selection", () => {
});
});
describe("AppTabBar layout-aware special tabs", () => {
it("mirrors the classic flat tab presentation instead of pills in the classic layout", () => {
// 经典布局下普通标签是全高 + 右边框分隔的扁平段;特殊标签必须跟随同一呈现,
// 不能永远是分离布局的圆角 pill(v0.6.3 分栏重构后的样式漂移)。
expect(tabBarSource).toMatch(/isClassicLayout = computed\(\(\) => settingsStore\.editorSettings\.appLayout === "classic"\)/);
expect(tabBarSource).toContain('isClassicLayout.value ? "h-9 items-stretch" : "h-10 items-center px-2"');
expect(tabBarSource).toContain('isClassicLayout.value ? "h-full items-center overflow-x-auto" : "h-full items-center gap-1.5 overflow-x-auto py-1.5"');
expect(tabBarSource).toContain('isClassicLayout.value ? "bg-muted" : `bg-background ${settingsStore.editorSettings.tabPlacement === "bottom" ? "border-t" : "border-b"}`');
expect(tabBarSource).toContain('["h-full border-r border-border/80 font-medium dark:border-border/45", active ? "bg-background text-foreground" : "text-foreground/70 hover:text-foreground/90"]');
expect(tabBarSource).toMatch(/h-7 \$\{widthClass\} rounded-md border/);
});
it("keeps both special tabs on the shared presentation helper", () => {
expect(tabBarSource).toContain("specialTabClass(!!settingsPageActive, 'min-w-36')");
expect(tabBarSource).toContain("specialTabClass(!!driverStoreActive, 'min-w-38')");
});
});
describe("Group tabbar inherited presentation", () => {
it("imports the shared tab bar stylesheet so pills, scrollbar, and wrap styles apply", () => {
expect(groupTabBarSource).toContain('import "./appTabBar.css"');
@@ -182,4 +182,48 @@ describe("EditorGroup mount contract", () => {
app2.unmount();
host.remove();
});
it("keeps the tab strip mounted while special pages hide the pane content", async () => {
const store = useQueryStore();
const queryId = store.createTab("pg-1", "app", "Query 1", "query");
const mountGroup = async (contentHidden: boolean) => {
const host = createHost();
const app = createApp(EditorGroup, {
groupId: "group-1",
tabIds: [queryId],
activeTabId: queryId,
activeTab: tab(queryId),
activeConnection: undefined,
executableSql: "SELECT 1",
activeOutputView: "result",
formatSqlRequest: null,
compressSqlRequest: null,
selectedSql: "",
cursorPos: 0,
blockDangerousRedisCommands: false,
contentHidden,
});
app.use(pinia);
app.use(i18n);
app.mount(host);
await nextTick();
return { host, app };
};
// 设置/驱动管理等 App 级页面接管主区域时,分组内容隐藏但标签条必须保留,
// 否则用户没有任何鼠标路径切回编辑器(v0.6.3 分栏重构把整个工作区藏掉的回归)。
const hidden = await mountGroup(true);
expect(hidden.host.querySelector('[data-test="group-tabbar"]')).not.toBeNull();
const hiddenContent = hidden.host.querySelector<HTMLElement>("[data-group-content]");
expect(hiddenContent?.style.display).toBe("none");
hidden.app.unmount();
hidden.host.remove();
const visible = await mountGroup(false);
const visibleContent = visible.host.querySelector<HTMLElement>("[data-group-content]");
expect(visibleContent?.style.display).not.toBe("none");
visible.app.unmount();
visible.host.remove();
});
});
@@ -3,27 +3,30 @@ import { describe, expect, it } from "vitest";
const appSource = readFileSync(new URL("../../../App.vue", import.meta.url), "utf8");
// The editor-content wrapper carries a v-show guard that hides it while the
// settings page or driver store is the active surface. A past merge resolved
// this region by nesting the special surfaces INSIDE that guarded wrapper,
// which display:none-d the settings page exactly when it was active. These
// assertions pin the sibling order so a future merge cannot re-introduce it.
// The editor-content wrapper used to carry a v-show guard that hid the whole
// workspace while the settings page or driver store was active. That guard
// also display:none-d every group tab strip, so opening a special page left
// the user with no mouse path back to the editors (v0.6.4 regression). The
// wrapper now stays visible and the workspace hides only the pane CONTENT via
// specialSurfaceActive (group tab strips remain clickable). These assertions
// pin the sibling order and the new hand-off so a future merge cannot
// re-introduce either the guard or nesting the special surfaces inside it.
describe("App main content surface structure", () => {
it("hides only the editor workspace via the surface guard, never the special pages", () => {
it("keeps the editor workspace visible while special pages hide only its content", () => {
const guard = 'v-show="!driverStoreActive && !settingsStore.settingsPageActive"';
// One guarded wrapper; the welcome screen's v-else-if reuses the flags in
// its own condition and must not be mistaken for a second guard.
expect(appSource.split(guard).length - 1).toBe(1);
// The wrapper guard is gone; the special-page state flows into the workspace instead.
expect(appSource).not.toContain(guard);
expect(appSource).toContain(':special-surface-active="driverStoreActive || settingsStore.settingsPageActive"');
// Source order: special surfaces first, then the guarded editor wrapper.
const markers = ["<DriverStorePage", "<EditorSettingsPage", guard, "<SqlEditorWorkspace", "<WelcomeScreen"];
// Source order: special surfaces first, then the editor wrapper.
const markers = ["<DriverStorePage", "<EditorSettingsPage", '<div v-if="queryStore.tabs.length > 0"', "<SqlEditorWorkspace", "<WelcomeScreen"];
const indices = markers.map((marker) => appSource.indexOf(marker));
expect(indices.every((index) => index >= 0)).toBe(true);
expect([...indices].sort((a, b) => a - b)).toEqual(indices);
});
it("guards the editor wrapper with the open-tab count and chains the welcome screen to it", () => {
expect(appSource).toContain('<div v-if="queryStore.tabs.length > 0" v-show="!driverStoreActive && !settingsStore.settingsPageActive"');
expect(appSource).toContain('<div v-if="queryStore.tabs.length > 0" class="flex flex-col flex-1 min-h-0">');
expect(appSource).toContain('v-else-if="queryStore.tabs.length === 0 && !driverStoreActive && !settingsStore.settingsPageActive"');
});
+24
View File
@@ -0,0 +1,24 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "vitest";
const appPath = "apps/desktop/src/App.vue";
const workspacePath = "apps/desktop/src/components/layout/SqlEditorWorkspace.vue";
// App.vue 是根组件无法挂载;打开设置/驱动管理后“整个工作区被 v-show 藏掉、
// 标签页凭空消失”正是 App 层接线回归(v0.6.3 分栏重构),这里钉住接线形态。
test("opening a special page keeps the workspace mounted with visible group tab strips", () => {
const appSource = readFileSync(appPath, "utf8");
// 工作区不再整体隐藏;改为把「特殊页激活」下传,由分组只隐藏内容列、保留标签条。
assert.match(appSource, /:special-surface-active="driverStoreActive \|\| settingsStore\.settingsPageActive"/);
assert.doesNotMatch(appSource, /v-show="!driverStoreActive && !settingsStore\.settingsPageActive"/);
});
test("the workspace collapses the shared result pane and hides group content while a special page is active", () => {
const workspaceSource = readFileSync(workspacePath, "utf8");
assert.match(workspaceSource, /specialSurfaceActive\?: boolean/);
assert.match(workspaceSource, /showSharedResult\.value && showResultPane\.value && !props\.specialSurfaceActive \? resultPaneSize\.value : 0/);
assert.match(workspaceSource, /:content-hidden="specialSurfaceActive"/);
});