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.
This commit is contained in:
t8y2 committed 2026-09-05 12:46:32 +08:00
1 parent 789d65d3df
commit c66a699aa7
11 files changed
+266 -100

No files matched your search

+16 -4
View File
@@ -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()"
/>
<!-- 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">
<div v-if="queryStore.tabs.length > 0" v-show="!driverStoreActive && !settingsStore.settingsPageActive" 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"
@@ -1,5 +1,5 @@
<script setup lang="ts">
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() {
</script>
<template>
<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>
<!-- Renders only while a special page owns the main area (the workspace and
its group strips are hidden then); open-but-inactive special pages
append to the focused group's tab strip instead of a row of their own. -->
<div v-if="driverStoreActive || settingsPageActive" 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" :class="specialStripScrollClass">
<!-- Open tabs as the mouse path back while a special page owns the main area -->
<div
v-for="tab in overlayReturnTabs"
:key="tab.id"
data-return-tab
class="app-tab-pill group flex cursor-default items-center gap-1 px-2 text-xs transition-colors whitespace-nowrap select-none"
:class="specialTabClass(false)"
:style="tabColorStyle(tab, false, isClassicLayout)"
:title="tabDisplayTitle(tab, t)"
data-active-tab="false"
@click="emit('activate-tab', tab.id)"
@mousedown.middle.prevent="queryStore.closeTab(tab.id)"
>
<span class="shrink-0" :class="tabIconClass(tab)">
<TabModeIcon :tab="tab" class="h-3.5 w-3.5" />
</span>
<span class="min-w-0 flex-1 truncate">{{ tabDisplayTitle(tab, t) }}</span>
<span v-if="queryStore.isTabDirty(tab)" aria-hidden="true" class="dirty-tab-marker">*</span>
<button class="shrink-0 rounded p-0.5 hover:bg-muted-foreground/20" :aria-label="t('contextMenu.closeTab')" :title="t('contextMenu.closeTab')" @pointerdown.stop @click.stop="queryStore.closeTab(tab.id)">
<X class="h-3 w-3" />
</button>
</div>
<!-- Settings Page Tab -->
<CustomContextMenu v-if="settingsPageOpen" :items="getSpecialRegularTabMenuItems('settings')" v-slot="{ onContextMenu }">
<div :class="isClassicLayout ? 'h-full' : ''" @contextmenu="onContextMenu">
@@ -223,6 +264,7 @@ function handleCancelClose() {
data-settings-page-tab
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')"
:style="specialTabColorStyle(!!settingsPageActive)"
:data-active-tab="settingsPageActive"
@click="emit('activate-settings-page')"
@mousedown.middle.prevent="emit('close-settings-page')"
@@ -245,6 +287,7 @@ function handleCancelClose() {
data-driver-store-tab
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')"
:style="specialTabColorStyle(!!driverStoreActive)"
:data-active-tab="driverStoreActive"
@click="emit('activate-driver-store')"
@mousedown.middle.prevent="emit('close-driver-store')"
@@ -27,8 +27,6 @@ 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;
}
>();
@@ -139,16 +137,21 @@ const groupExecutableSql = computed(() => {
:tab-bar-collapsed="tabBarCollapsed"
:can-detach-tabs="canDetachTabs"
:detached-drop-target="detachedDropTarget"
:special-page-tabs="toolbar.specialPageTabs.value"
@activate-tab="$emit('activate-tab', $event)"
@locate-tab="$emit('locate-tab', $event)"
@toggle-zen-mode="$emit('toggle-zen-mode')"
@start-resize="$emit('start-resize', $event)"
@toggle-collapse="$emit('toggle-collapse')"
@detach-tab="$emit('detach-tab', $event)"
@activate-settings="toolbar.activateSettingsPage()"
@close-settings="toolbar.closeSettingsPage()"
@activate-driver-store="toolbar.activateDriverStore()"
@close-driver-store="toolbar.closeDriverStore()"
/>
<!-- The toolbar stays at the top of the pane's content column in every
placement; only the tab bar moves around it. -->
<div v-show="!contentHidden" data-group-content class="flex min-h-0 min-w-0 flex-1 flex-col">
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
<EditorToolbar
v-if="activeTab && showGroupToolbar"
:active-tab="activeTab"
@@ -48,6 +48,7 @@ import {
Maximize2,
Minimize2,
Network,
Package,
PanelTop,
Pencil,
PencilRuler,
@@ -55,6 +56,7 @@ import {
RotateCcw,
RotateCw,
Search,
Settings,
ShieldCheck,
Table2,
TableProperties,
@@ -97,6 +99,8 @@ const props = defineProps<{
canDetachTabs?: boolean;
/** A detached tab is being dragged over this bar — highlight it as the drop target. */
detachedDropTarget?: boolean;
/** App-level special pages (settings / driver store) appended after the tabs. */
specialPageTabs?: { settingsOpen: boolean; settingsActive: boolean; driverStoreOpen: boolean; driverStoreActive: boolean; driverUpdateCount: number };
}>();
const emit = defineEmits<{
@@ -106,6 +110,10 @@ const emit = defineEmits<{
"start-resize": [event: MouseEvent];
"toggle-collapse": [];
"detach-tab": [tab: QueryTab];
"activate-settings": [];
"close-settings": [];
"activate-driver-store": [];
"close-driver-store": [];
}>();
const { t } = useI18n();
@@ -122,6 +130,21 @@ const editingTitle = ref("");
// with the drag state. A fresh pointerdown always resets it.
const suppressNextTabClick = ref(false);
const isClassicLayout = computed(() => settingsStore.editorSettings.appLayout === "classic");
// Special pages append to the focused group's strip only: one instance at a
// time, in the pane the user is working in (v0.6.2 kept them in the single strip).
const showSpecialPageTabs = computed(() => !!props.specialPageTabs && (props.specialPageTabs.settingsOpen || props.specialPageTabs.driverStoreOpen) && queryStore.focusedGroupId === props.groupId);
function specialPageTabClass(active: boolean): 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 rounded-md border", active ? "border-ring font-medium text-foreground" : "border-border/60 text-foreground/70 hover:border-border hover:text-foreground/90"];
}
function specialPageTabStyle(active: boolean) {
if (!isClassicLayout.value) return undefined;
return active ? { boxShadow: "inset 0 -2px 0 var(--ring)" } : undefined;
}
const isVerticalLayout = computed(() => settingsStore.editorSettings.tabPlacement === "left" || settingsStore.editorSettings.tabPlacement === "right");
const isWrapLayout = computed(() => !isVerticalLayout.value && settingsStore.editorSettings.tabLayout === "wrap");
// The icon-only collapse only exists in the vertical toolbar; horizontal
@@ -1250,6 +1273,47 @@ watch(
</div>
</CustomContextMenu>
</template>
<!-- Settings / driver store append to the focused strip like v0.6.2
(vertical strips get them with the #8213 column-mode redesign) -->
<template v-if="showSpecialPageTabs && !isVerticalLayout">
<div
v-if="specialPageTabs?.settingsOpen"
data-settings-page-tab
class="app-tab-pill group flex shrink-0 cursor-default items-center gap-1 px-2 text-xs transition-colors whitespace-nowrap select-none"
:class="specialPageTabClass(!!specialPageTabs?.settingsActive)"
:style="specialPageTabStyle(!!specialPageTabs?.settingsActive)"
:data-active-tab="specialPageTabs?.settingsActive"
:title="t('settings.title')"
@click="emit('activate-settings')"
@mousedown.middle.prevent="emit('close-settings')"
>
<Settings class="h-3.5 w-3.5 shrink-0 text-sky-600 dark:text-sky-400" />
<span class="min-w-0 flex-1 truncate">{{ t("settings.title") }}</span>
<button class="shrink-0 rounded p-0.5 hover:bg-muted-foreground/20" :aria-label="t('common.close')" :title="t('common.close')" @click.stop="emit('close-settings')">
<X class="h-3 w-3" />
</button>
</div>
<div
v-if="specialPageTabs?.driverStoreOpen"
data-driver-store-tab
class="app-tab-pill group flex shrink-0 cursor-default items-center gap-1 px-2 text-xs transition-colors whitespace-nowrap select-none"
:class="specialPageTabClass(!!specialPageTabs?.driverStoreActive)"
:style="specialPageTabStyle(!!specialPageTabs?.driverStoreActive)"
:data-active-tab="specialPageTabs?.driverStoreActive"
:title="t('toolbar.driverManager')"
@click="emit('activate-driver-store')"
@mousedown.middle.prevent="emit('close-driver-store')"
>
<Package class="h-3.5 w-3.5 shrink-0 text-amber-600 dark:text-amber-400" />
<span class="min-w-0 flex-1 truncate">{{ t("toolbar.driverManager") }}</span>
<span v-if="(specialPageTabs?.driverUpdateCount ?? 0) > 0" class="inline-flex h-4 min-w-4 shrink-0 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-medium leading-none text-white" :aria-label="t('toolbar.updatableDriverCount')">
{{ (specialPageTabs?.driverUpdateCount ?? 0) > 99 ? "99+" : specialPageTabs?.driverUpdateCount }}
</span>
<button class="shrink-0 rounded p-0.5 hover:bg-muted-foreground/20" :aria-label="t('common.close')" :title="t('common.close')" @click.stop="emit('close-driver-store')">
<X class="h-3 w-3" />
</button>
</div>
</template>
<div :class="tabTailDragRegionClass" data-tauri-drag-region />
</div>
</div>
@@ -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)"
@@ -0,0 +1,28 @@
<script setup lang="ts">
import { AlertTriangle, CalendarClock, Code2, Database, Gauge, KeyRound, Network, PencilRuler, ShieldCheck, Table2, TableProperties } from "@lucide/vue";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import { tabDatabaseIconType } from "@/lib/tabs/tabPresentation";
import type { QueryTab } from "@/types/database";
// Mirrors EditorGroupTabBar's per-mode tab icon chain so surfaces outside the
// group bar (e.g. the special-page strip's return tabs) render identical icons.
defineProps<{ tab: QueryTab }>();
</script>
<template>
<AlertTriangle v-if="tab.externalSqlFileMissing" />
<Table2 v-else-if="tab.mode === 'data' || tab.mode === 'mongo' || tab.mode === 'redis' || tab.mode === 'hbase'" />
<DatabaseIcon v-else-if="tab.mode === 'mq'" :db-type="tabDatabaseIconType(tab)" />
<TableProperties v-else-if="tab.mode === 'vector'" />
<KeyRound v-else-if="tab.mode === 'etcd' || tab.mode === 'zookeeper' || tab.mode === 'consul'" />
<Gauge v-else-if="tab.mode === 'consul-overview' || tab.mode === 'etcd-dashboard' || tab.mode === 'mysql-dashboard' || tab.mode === 'postgres-dashboard' || tab.mode === 'nacos-dashboard'" />
<ShieldCheck v-else-if="tab.mode === 'etcd-access-control'" />
<Network v-else-if="tab.mode === 'nacos'" />
<Database v-else-if="tab.mode === 'databases'" />
<TableProperties v-else-if="tab.mode === 'objects'" />
<PencilRuler v-else-if="tab.mode === 'structure'" />
<CalendarClock v-else-if="tab.mode === 'dameng-jobs'" />
<Activity v-else-if="tab.mode === 'processlist' || tab.mode === 'sqlserver-trace'" />
<Gauge v-else-if="tab.mode === 'dolt-version-control'" />
<Code2 v-else />
</template>
@@ -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('<TabModeIcon :tab="tab" class="h-3.5 w-3.5" />');
});
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()"');
});
});
@@ -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<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,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 = ["<DriverStorePage", "<EditorSettingsPage", '<div v-if="queryStore.tabs.length > 0"', "<SqlEditorWorkspace", "<WelcomeScreen"];
// Source order: special surfaces first, then the guarded editor wrapper.
const markers = ["<DriverStorePage", "<EditorSettingsPage", guard, "<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" class="flex flex-col flex-1 min-h-0">');
expect(appSource).toContain('<div v-if="queryStore.tabs.length > 0" v-show="!driverStoreActive && !settingsStore.settingsPageActive"');
expect(appSource).toContain('v-else-if="queryStore.tabs.length === 0 && !driverStoreActive && !settingsStore.settingsPageActive"');
});
@@ -9,6 +9,15 @@ import type { InjectionKey, Ref } from "vue";
* the acting tab from the explicit tabId argument (never re-reading the global
* active tab after an await).
*/
/** Open/active state of the App-level special pages (settings / driver store). */
export interface SpecialPageTabsState {
settingsOpen: boolean;
settingsActive: boolean;
driverStoreOpen: boolean;
driverStoreActive: boolean;
driverUpdateCount: number;
}
export interface EditorToolbarActions {
explainMode: Ref<"explain" | "autotrace">;
blockDangerousRedisCommands: Ref<boolean>;
@@ -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<SpecialPageTabsState>;
activateSettingsPage(): void;
closeSettingsPage(): void;
activateDriverStore(): void;
closeDriverStore(): void;
}
export const EDITOR_TOOLBAR_ACTIONS: InjectionKey<EditorToolbarActions> = 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<boolean>;
const specialPageTabs = {
value: { settingsOpen: false, settingsActive: false, driverStoreOpen: false, driverStoreActive: false, driverUpdateCount: 0 },
} as Ref<SpecialPageTabsState>;
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,
};
}