From c66a699aa7813cbe43358ab0393f86654ea628bf Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Sat, 5 Sep 2026 12:39:41 +0800 Subject: [PATCH] fix(layout): merge special page tabs into the editor tab strip Settings and driver store no longer sit on a strip of their own: while open but inactive they append to the focused group's tab strip (v0.6.2 order), and while active the app-level strip re-renders the open tabs with mode icons and inactive styling as the mouse path back to the editors. Special tabs follow the classic/separated presentation, including the classic active underline. --- apps/desktop/src/App.vue | 20 ++++-- .../src/components/layout/AppTabBar.vue | 51 ++++++++++++-- .../src/components/layout/EditorGroup.vue | 9 ++- .../components/layout/EditorGroupTabBar.vue | 64 +++++++++++++++++ .../components/layout/SqlEditorWorkspace.vue | 7 +- .../src/components/layout/TabModeIcon.vue | 28 ++++++++ .../layout/__tests__/AppTabBar.spec.ts | 69 +++++++++++++++++++ .../__tests__/EditorGroup.mount.spec.ts | 44 ------------ .../appContentSurfaces.contract.spec.ts | 27 ++++---- .../components/layout/editorToolbarActions.ts | 23 +++++++ .../app-tests/specialSurfaceTabStrip.test.ts | 24 ------- 11 files changed, 266 insertions(+), 100 deletions(-) create mode 100644 apps/desktop/src/components/layout/TabModeIcon.vue delete mode 100644 packages/app-tests/specialSurfaceTabStrip.test.ts diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 28f9b1b6ff..f0ca23d2ab 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -781,6 +781,15 @@ function requestActiveEditorExecuteInNewResultTab() { // Per-group editor toolbars call back into this App-owned orchestration. The // group focuses itself on pointerdown/focusin before any toolbar event, so the // acting tab is passed explicitly instead of relying on the focused group. +// Declared above the provide: the object references the variable itself, while +// the lazy getter safely reads later-declared update-count refs at render time. +const specialPageTabs = computed(() => ({ + settingsOpen: settingsPageTabOpen.value, + settingsActive: settingsStore.settingsPageActive, + driverStoreOpen: driverStoreTabOpen.value, + driverStoreActive: driverStoreActive.value, + driverUpdateCount: toolbarAgentDriverUpdateCount.value, +})); provide(EDITOR_TOOLBAR_ACTIONS, { explainMode, blockDangerousRedisCommands, @@ -804,6 +813,11 @@ provide(EDITOR_TOOLBAR_ACTIONS, { changeSchema: changeActiveSchema, setDefaultDatabase: setActiveDatabaseAsDefault, clearDefaultDatabase: clearActiveDefaultDatabase, + specialPageTabs, + activateSettingsPage, + closeSettingsPage, + activateDriverStore: () => openDriverStorePage(), + closeDriverStore: closeDriverStorePage, }); // Upstream "preview changes" entry: dormant until the group toolbar wires the @@ -3445,6 +3459,7 @@ onUnmounted(() => { :can-detach-tabs="isDesktop" @activate-driver-store="openDriverStorePage" @activate-settings-page="activateSettingsPage" + @activate-tab="activateQueryTab" @close-driver-store="closeDriverStorePage" @close-settings-page="closeSettingsPage" @save-tab="handleSaveTab" @@ -3495,12 +3510,9 @@ onUnmounted(() => { @update:open="(open: boolean) => (open ? activateSettingsPage() : closeSettingsPage())" @check-updates="checkUpdates()" /> - -
+
-import { computed, onUnmounted, ref, watch } from "vue"; +import { computed, onUnmounted, ref, watch, type CSSProperties } from "vue"; import { useI18n } from "vue-i18n"; import { X, Minimize2, Maximize2, Settings, Package, AlertTriangle } from "@lucide/vue"; import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue"; @@ -8,7 +8,8 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from " import { Button } from "@/components/ui/button"; import { useQueryStore } from "@/stores/queryStore"; import { useSettingsStore } from "@/stores/settingsStore"; -import { tabDisplayTitle } from "@/lib/tabs/tabPresentation"; +import TabModeIcon from "./TabModeIcon.vue"; +import { tabColorStyle, tabDisplayTitle, tabIconClass } from "@/lib/tabs/tabPresentation"; import "./appTabBar.css"; const props = defineProps<{ @@ -26,6 +27,7 @@ const emit = defineEmits<{ "close-driver-store": []; "activate-settings-page": []; "close-settings-page": []; + "activate-tab": [tabId: string]; "save-tab": [tabId: string]; "discard-tab-close": []; "save-all-tab-close": []; @@ -114,13 +116,26 @@ const specialStripRowClass = computed(() => (isClassicLayout.value ? "h-9 items- 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[] { +function specialTabClass(active: boolean, widthClass = ""): 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"]; } +function specialTabColorStyle(active: boolean): CSSProperties | undefined { + // Special pages carry no connection color; mirror tabColorStyle's classic + // active underline so they read as selected like every other tab. + if (!isClassicLayout.value) return undefined; + return active ? { boxShadow: "inset 0 -2px 0 var(--ring)" } : undefined; +} + +// While a special page hides the workspace, the group tab strips disappear +// with it. Re-render the open tabs here so a click is the way back to the +// editors (v0.6.2 kept everything in one strip; the split-pane refactor moved +// regular tabs into the hidden workspace and left no mouse path back). +const overlayReturnTabs = computed(() => (props.settingsPageActive || props.driverStoreActive ? queryStore.tabs : [])); + type SpecialRegularSurface = "driverStore" | "settings"; function closeSpecialRegularSurfaces(keep?: SpecialRegularSurface) { @@ -212,10 +227,36 @@ function handleCancelClose() { + +
diff --git a/apps/desktop/src/components/layout/SqlEditorWorkspace.vue b/apps/desktop/src/components/layout/SqlEditorWorkspace.vue index 96e16b739b..296907ad80 100644 --- a/apps/desktop/src/components/layout/SqlEditorWorkspace.vue +++ b/apps/desktop/src/components/layout/SqlEditorWorkspace.vue @@ -19,8 +19,6 @@ 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< @@ -124,9 +122,7 @@ 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). -// 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 resultPaneTargetSize = computed(() => (showSharedResult.value && showResultPane.value ? 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 @@ -221,7 +217,6 @@ 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)" diff --git a/apps/desktop/src/components/layout/TabModeIcon.vue b/apps/desktop/src/components/layout/TabModeIcon.vue new file mode 100644 index 0000000000..52ed40d705 --- /dev/null +++ b/apps/desktop/src/components/layout/TabModeIcon.vue @@ -0,0 +1,28 @@ + + + diff --git a/apps/desktop/src/components/layout/__tests__/AppTabBar.spec.ts b/apps/desktop/src/components/layout/__tests__/AppTabBar.spec.ts index a2128c2dfd..065bc40ff3 100644 --- a/apps/desktop/src/components/layout/__tests__/AppTabBar.spec.ts +++ b/apps/desktop/src/components/layout/__tests__/AppTabBar.spec.ts @@ -84,6 +84,75 @@ describe("AppTabBar layout-aware special tabs", () => { 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')"); + // 经典布局激活的特殊标签与普通标签一样显示 inset 下划线指示条。 + expect(tabBarSource).toContain('return active ? { boxShadow: "inset 0 -2px 0 var(--ring)" } : undefined;'); + expect(tabBarSource).toContain(':style="specialTabColorStyle(!!settingsPageActive)"'); + expect(tabBarSource).toContain(':style="specialTabColorStyle(!!driverStoreActive)"'); + }); +}); + +describe("AppTabBar return tabs while a special page is active", () => { + // 特殊页激活时工作区被整体隐藏,分组标签条随之消失;必须在特殊条里重渲 + // 染打开的标签,否则用户没有任何鼠标路径切回编辑器(v0.6.4 回归)。 + // 未激活的特殊页不再渲染独立条,由分组标签条末尾承载(见 EditorGroupTabBar)。 + it("renders the app-level strip only while a special page is active", () => { + expect(tabBarSource).toContain('v-if="driverStoreActive || settingsPageActive"'); + }); + + it("re-renders the open tabs only while settings or the driver store is active", () => { + expect(tabBarSource).toContain("(props.settingsPageActive || props.driverStoreActive ? queryStore.tabs : [])"); + expect(tabBarSource).toContain('v-for="tab in overlayReturnTabs"'); + expect(tabBarSource).toContain("data-return-tab"); + expect(tabBarSource).toContain("@click=\"emit('activate-tab', tab.id)\""); + // 普通标签在前,设置/驱动管理追加在末尾(v0.6.2 的顺序;新开的特殊页 + // 不应插到最前面)。 + const indices = ['v-for="tab in overlayReturnTabs"', "data-settings-page-tab", "data-driver-store-tab"].map((marker) => tabBarSource.indexOf(marker)); + expect(indices.every((index) => index >= 0)).toBe(true); + expect([...indices].sort((a, b) => a - b)).toEqual(indices); + }); + + it("keeps return tabs inactive while the special page owns the surface", () => { + // 特殊页激活期间只有一个激活面:返回标签不得保留上一个标签的高亮。 + expect(tabBarSource).toContain(':class="specialTabClass(false)"'); + expect(tabBarSource).toContain(':style="tabColorStyle(tab, false, isClassicLayout)"'); + expect(tabBarSource).toContain('data-active-tab="false"'); + }); + + it("gives return tabs the native mode icon", () => { + expect(tabBarSource).toContain(':class="tabIconClass(tab)"'); + expect(tabBarSource).toContain(''); + }); + + it("keeps return tabs closable with the shared dirty-confirm flow", () => { + expect(tabBarSource).toContain('@mousedown.middle.prevent="queryStore.closeTab(tab.id)"'); + expect(tabBarSource).toContain('@click.stop="queryStore.closeTab(tab.id)"'); + }); + + it("wires the return-tab click to the query-surface activation in App", () => { + const appSource = readFileSync(new URL("../../../App.vue", import.meta.url), "utf8"); + expect(appSource).toContain('@activate-tab="activateQueryTab"'); + // activateQueryTab flips the special surfaces off and switches to the tab. + expect(appSource).toMatch(/function activateQueryTab\(tabId: string\): boolean \{[\s\S]*?activateQuerySurface\(\);/); + }); +}); + +describe("Group strip special page tabs", () => { + it("appends open special pages to the focused group strip like v0.6.2", () => { + expect(groupTabBarSource).toContain("queryStore.focusedGroupId === props.groupId"); + expect(groupTabBarSource).toContain("data-settings-page-tab"); + expect(groupTabBarSource).toContain("data-driver-store-tab"); + expect(groupTabBarSource).toContain("@click=\"emit('activate-settings')\""); + expect(groupTabBarSource).toContain("@click=\"emit('activate-driver-store')\""); + expect(groupTabBarSource).toContain('t("toolbar.driverManager")'); + // 经典布局下的激活下划线与普通标签一致。 + expect(groupTabBarSource).toContain('return active ? { boxShadow: "inset 0 -2px 0 var(--ring)" } : undefined;'); + }); + + it("wires the group strip through the injected special page actions", () => { + const groupSource = readFileSync(new URL("../EditorGroup.vue", import.meta.url), "utf8"); + expect(groupSource).toContain(':special-page-tabs="toolbar.specialPageTabs.value"'); + expect(groupSource).toContain('@activate-settings="toolbar.activateSettingsPage()"'); + expect(groupSource).toContain('@close-driver-store="toolbar.closeDriverStore()"'); }); }); diff --git a/apps/desktop/src/components/layout/__tests__/EditorGroup.mount.spec.ts b/apps/desktop/src/components/layout/__tests__/EditorGroup.mount.spec.ts index d845761cbb..49c1d63943 100644 --- a/apps/desktop/src/components/layout/__tests__/EditorGroup.mount.spec.ts +++ b/apps/desktop/src/components/layout/__tests__/EditorGroup.mount.spec.ts @@ -182,48 +182,4 @@ 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("[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("[data-group-content]"); - expect(visibleContent?.style.display).not.toBe("none"); - visible.app.unmount(); - visible.host.remove(); - }); }); diff --git a/apps/desktop/src/components/layout/__tests__/appContentSurfaces.contract.spec.ts b/apps/desktop/src/components/layout/__tests__/appContentSurfaces.contract.spec.ts index 73e6e78d8a..a23ad34681 100644 --- a/apps/desktop/src/components/layout/__tests__/appContentSurfaces.contract.spec.ts +++ b/apps/desktop/src/components/layout/__tests__/appContentSurfaces.contract.spec.ts @@ -3,30 +3,27 @@ import { describe, expect, it } from "vitest"; const appSource = readFileSync(new URL("../../../App.vue", import.meta.url), "utf8"); -// 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. +// 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. describe("App main content surface structure", () => { - it("keeps the editor workspace visible while special pages hide only its content", () => { + it("hides only the editor workspace via the surface guard, never the special pages", () => { const guard = 'v-show="!driverStoreActive && !settingsStore.settingsPageActive"'; - // 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"'); + // 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); - // Source order: special surfaces first, then the editor wrapper. - const markers = [" 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('
'); + expect(appSource).toContain('
; blockDangerousRedisCommands: Ref; @@ -34,6 +43,12 @@ export interface EditorToolbarActions { changeSchema(tabId: string, schema: string | undefined): void; setDefaultDatabase(tabId: string): void; clearDefaultDatabase(tabId: string): void; + /** Special pages appended to the focused group's tab strip while open but inactive. */ + specialPageTabs: Ref; + activateSettingsPage(): void; + closeSettingsPage(): void; + activateDriverStore(): void; + closeDriverStore(): void; } export const EDITOR_TOOLBAR_ACTIONS: InjectionKey = Symbol("dbx:editor-toolbar-actions"); @@ -47,6 +62,9 @@ export function createNoopEditorToolbarActions(): EditorToolbarActions { const noop = () => undefined; const mode = { value: "explain" } as Ref<"explain" | "autotrace">; const flag = { value: true } as Ref; + const specialPageTabs = { + value: { settingsOpen: false, settingsActive: false, driverStoreOpen: false, driverStoreActive: false, driverUpdateCount: 0 }, + } as Ref; return { explainMode: mode, blockDangerousRedisCommands: flag, @@ -70,5 +88,10 @@ export function createNoopEditorToolbarActions(): EditorToolbarActions { changeSchema: noop, setDefaultDatabase: noop, clearDefaultDatabase: noop, + specialPageTabs, + activateSettingsPage: noop, + closeSettingsPage: noop, + activateDriverStore: noop, + closeDriverStore: noop, }; } diff --git a/packages/app-tests/specialSurfaceTabStrip.test.ts b/packages/app-tests/specialSurfaceTabStrip.test.ts deleted file mode 100644 index 722e595a09..0000000000 --- a/packages/app-tests/specialSurfaceTabStrip.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -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"/); -});