fix(desktop): unify grouped tab bar portal across special pages

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-09-06 20:39:34 +08:00
1 parent 3123e962c8
commit 148345494e
27 files changed
+1352 -590

No files matched your search

+37 -151
View File
@@ -1,15 +1,14 @@
<script setup lang="ts">
import { computed, onUnmounted, ref, watch, type CSSProperties } from "vue";
import { computed, inject, 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";
import { AlertTriangle } from "@lucide/vue";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { useQueryStore } from "@/stores/queryStore";
import { useSettingsStore } from "@/stores/settingsStore";
import TabModeIcon from "./TabModeIcon.vue";
import { tabColorStyle, tabDisplayTitle, tabIconClass } from "@/lib/tabs/tabPresentation";
import { GROUP_TAB_BAR_PORTAL } from "./groupTabBarPortal";
import { tabDisplayTitle } from "@/lib/tabs/tabPresentation";
import "./appTabBar.css";
const props = defineProps<{
@@ -20,6 +19,8 @@ const props = defineProps<{
agentDriverUpdateCount?: number;
detachedDropTarget?: boolean;
canDetachTabs?: boolean;
tabBarWidth?: number;
tabBarCollapsed?: boolean;
}>();
const emit = defineEmits<{
@@ -38,7 +39,29 @@ const emit = defineEmits<{
const { t } = useI18n();
const queryStore = useQueryStore();
const settingsStore = useSettingsStore();
const isClassicLayout = computed(() => settingsStore.editorSettings.appLayout === "classic");
const tabBarPortal = inject(GROUP_TAB_BAR_PORTAL, null);
const isVerticalLayout = computed(() => ["left", "right"].includes(settingsStore.editorSettings.tabPlacement));
const layoutClass = computed(() => {
switch (settingsStore.editorSettings.tabPlacement) {
case "bottom":
return "flex-col-reverse";
case "left":
return "flex-row";
case "right":
return "flex-row-reverse";
default:
return "flex-col";
}
});
const navigationStyle = computed<CSSProperties>(() => {
if (!isVerticalLayout.value) return { maxHeight: "50%" };
const width = props.tabBarCollapsed ? "3.5rem" : `${props.tabBarWidth ?? 240}px`;
return { width, flex: `0 0 ${width}` };
});
function setTabBarTarget(groupId: string, element: unknown) {
if (element instanceof HTMLElement) tabBarPortal?.targets.set(groupId, element);
else tabBarPortal?.targets.delete(groupId);
}
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));
@@ -64,13 +87,6 @@ const closeConfirmMessage = computed(() => {
});
const closeConfirmListOpen = ref(false);
let closeConfirmListCloseTimer: ReturnType<typeof setTimeout> | null = null;
const compactTabTitle = computed({
get: () => settingsStore.editorSettings.compactTabTitle,
set: (checked: boolean | "indeterminate") => {
settingsStore.updateEditorSettings({ compactTabTitle: checked === true });
},
});
function openCloseConfirmList() {
if (closeConfirmListCloseTimer) {
clearTimeout(closeConfirmListCloseTimer);
@@ -105,37 +121,6 @@ watch(
},
);
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[] {
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) {
@@ -173,32 +158,6 @@ function closeOtherActiveTabs() {
defineExpose({ closeOtherActiveTabs });
function getSpecialRegularTabMenuItems(surface: SpecialRegularSurface): ContextMenuItem[] {
const keep = surface;
const closeCurrent = surface === "driverStore" ? () => emit("close-driver-store") : () => emit("close-settings-page");
const specialSurfaceCount = (props.driverStoreOpen ? 1 : 0) + (props.settingsPageOpen ? 1 : 0);
const closeOtherDisabled = specialSurfaceCount <= 1;
return [
{
label: compactTabTitle.value ? t("contextMenu.fullTabTitle") : t("contextMenu.compactTabTitle"),
action: toggleCompactTabTitle,
icon: compactTabTitle.value ? Maximize2 : Minimize2,
},
{ label: "", separator: true },
{ label: t("contextMenu.closeTab"), action: closeCurrent, icon: X },
{
label: t("contextMenu.closeOtherTabs"),
action: () => {
closeSpecialRegularSurfaces(keep);
},
disabled: closeOtherDisabled,
icon: X,
},
{ label: t("contextMenu.closeAllTabs"), action: closeCurrent, variant: "destructive" as const, icon: X },
];
}
function handleSaveAndClose() {
const id = queryStore.saveAndClosePendingTab();
if (id) {
@@ -227,87 +186,14 @@ function handleCancelClose() {
</script>
<template>
<!-- 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">
<div
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')"
>
<span class="shrink-0 text-sky-600 dark:text-sky-400">
<Settings class="h-3.5 w-3.5" />
</span>
<span class="min-w-0 truncate flex-1">{{ t("settings.title") }}</span>
<button class="rounded hover:bg-muted-foreground/20 p-0.5 shrink-0" @click.stop="emit('close-settings-page')">
<X class="h-3 w-3" />
</button>
</div>
</div>
</CustomContextMenu>
<!-- Driver Store Tab -->
<CustomContextMenu v-if="driverStoreOpen" :items="getSpecialRegularTabMenuItems('driverStore')" v-slot="{ onContextMenu }">
<div :class="isClassicLayout ? 'h-full' : ''" @contextmenu="onContextMenu">
<div
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')"
>
<span class="shrink-0 text-amber-600 dark:text-amber-400">
<Package class="h-3.5 w-3.5" />
</span>
<span class="min-w-0 truncate flex-1">{{ t("toolbar.driverManager") }}</span>
<span v-if="(agentDriverUpdateCount ?? 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')">
{{ (agentDriverUpdateCount ?? 0) > 99 ? "99+" : agentDriverUpdateCount }}
</span>
<button class="rounded hover:bg-muted-foreground/20 p-0.5 shrink-0" @click.stop="emit('close-driver-store')">
<X class="h-3 w-3" />
</button>
</div>
</div>
</CustomContextMenu>
<div class="min-w-8 flex-1 self-stretch" data-tauri-drag-region />
</div>
</div>
<!-- Targets remain mounted while inactive so the original group bars can
move here without losing their local presentation state. -->
<div v-show="driverStoreActive || settingsPageActive" data-special-page-workspace class="flex min-h-0 min-w-0 flex-1 overflow-hidden" :class="layoutClass">
<div data-special-page-navigation class="flex min-h-0 min-w-0 shrink-0 flex-col overflow-auto" :style="navigationStyle">
<div v-for="group in queryStore.groups" :key="group.id" :ref="(element) => setTabBarTarget(group.id, element)" :data-special-page-tab-target="group.id" class="flex min-h-0 min-w-0" :class="isVerticalLayout ? 'flex-1' : 'shrink-0'" />
</div>
<div data-special-page-content class="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<slot />
</div>
</div>
@@ -5,6 +5,7 @@ import { appendDebugLog, isDebugLoggingEnabled } from "@/lib/backend/debugLog";
import { canReloadUnavailableDataTab, restoredDataTabReloadFilters } from "@/lib/table/tableDataRefresh";
import { defaultViewForResult } from "@/lib/query/queryResultDefaultView";
import { isQueryExecutionErrorResult } from "@/lib/query/queryResultError";
import { hasQueryOutput as tabHasQueryOutput } from "@/lib/query/queryOutput";
import { batchSqlRecoveryState, type BatchSqlRecoveryAction } from "@/lib/query/batchSqlRecovery";
import type { CSSProperties } from "vue";
import { useI18n } from "vue-i18n";
@@ -132,6 +133,7 @@ import { canCancelQueryExecution, isActiveResultLoading, queryExecutionLabelKey
import {
databaseDisplayNameForTab,
executionSummaryItems,
isPreviewTab,
queryResultExecutionSql,
resultGridCacheKey,
resultGridColumnWidthCacheKey,
@@ -246,14 +248,8 @@ const { toast } = useToast();
const DEFAULT_QUERY_RESULTS_PANE_SIZE = 68;
onMounted(() => {
// Deliberately not preloading DataGrid here for every tab: evaluating that
// chunk is expensive (large component graph) and previously ran
// unconditionally shortly after any tab mounted, including source-only
// tabs (e.g. viewing a large object's DDL) that never need a grid. That
// turned a "warm the cache" optimization into a multi-second main-thread
// freeze right when the user was trying to read the newly-opened tab (see
// issue #8103). The watcher below still preloads it eagerly for tabs that
// actually need one.
// The watcher below warms the grid for query/data tabs. Keep source-only
// tabs out of that path: loading the grid there caused freezes (#8103).
window.addEventListener("dbx-refresh-active-kv-browser", onRefreshActiveKvBrowser);
window.addEventListener("resize", updateStandaloneResultToolbarDimensions);
window.visualViewport?.addEventListener("resize", updateStandaloneResultToolbarDimensions);
@@ -262,9 +258,14 @@ onMounted(() => {
});
watch(
() => [props.activeTab.mode, !!props.activeTab.result] as const,
([mode, hasResult]) => {
if (mode === "data" || hasResult) preloadDataGridComponent();
() => {
const tab = props.activeTab;
const gridMode = tab.mode === "query" || tab.mode === "data";
// Object source and preview tabs also use query mode, but often never run SQL.
return !!tab.result || (gridMode && (tab.isExecuting || tab.isExplaining || (!tab.sourceView && !tab.objectSource && !isPreviewTab(tab))));
},
(shouldPreload) => {
if (shouldPreload) preloadDataGridComponent();
},
{ immediate: true },
);
@@ -431,18 +432,7 @@ const activeQueryError = computed(() => {
if (!result || !isQueryExecutionErrorResult(result)) return "";
return String(result.rows[0]?.[0] ?? "");
});
const hasQueryOutput = computed(
() =>
!!props.activeTab.result ||
!!props.activeTab.resultRuns?.length ||
props.activeTab.resultEvicted === true ||
!!props.activeTab.explainPlan ||
!!props.activeTab.explainError ||
!!props.activeTab.explainTableResult ||
!!props.activeTab.explainTableError ||
props.activeTab.isExecuting === true ||
props.activeTab.isExplaining === true,
);
const hasQueryOutput = computed(() => tabHasQueryOutput(props.activeTab));
const visibleResultItems = computed(() => tabularResultItems(props.activeTab.results ?? (props.activeTab.result ? [props.activeTab.result] : undefined)));
const tabularResults = computed(() => tabularResultItems(props.activeTab.results));
const allResultExportSheets = computed(() =>
@@ -13,13 +13,16 @@ import { createContentSurfaceEventForwarders } from "@/lib/tabs/contentSurfaceEv
import { isPreviewTab } from "@/lib/tabs/tabPresentation";
import { resolveExecutableSql } from "@/lib/sql/sqlExecutionTarget";
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import { GROUP_TAB_BAR_PORTAL } from "./groupTabBarPortal";
import type { ContentAreaSurfaceEmits, ContentAreaSurfaceProps, QueryEditorSurfaceHandle, StatementRange } from "./querySurfaces";
import type { QueryTab } from "@/types/database";
defineOptions({ inheritAttrs: false });
const props = defineProps<
ContentAreaSurfaceProps & {
Omit<ContentAreaSurfaceProps, "activeTab"> & {
activeTab?: QueryTab;
showTabNavigation?: boolean;
groupId: string;
tabIds: string[];
activeTabId: string | null;
@@ -88,6 +91,8 @@ const connectionStore = useConnectionStore();
const queryStore = useQueryStore();
const settingsStore = useSettingsStore();
const toolbar = inject(EDITOR_TOOLBAR_ACTIONS, createNoopEditorToolbarActions());
const tabBarPortal = inject(GROUP_TAB_BAR_PORTAL, null);
const tabBarTarget = computed(() => tabBarPortal?.targets.get(props.groupId));
const groupTabs = computed(() => {
const byId = new Map(queryStore.tabs.map((tab) => [tab.id, tab]));
return props.tabIds.map((id) => byId.get(id)).filter((tab): tab is QueryTab => !!tab);
@@ -129,26 +134,28 @@ const groupExecutableSql = computed(() => {
<template>
<div class="editor-group flex h-full min-h-0 min-w-0 overflow-hidden" :class="[groupClass, groupLayoutClass]" :data-group-id="groupId" @pointerdown.capture="$emit('focus-group', groupId)" @focusin="$emit('focus-group', groupId)">
<EditorGroupTabBar
:group-id="groupId"
:tabs="groupTabs"
:active-tab-id="activeTabId"
:tab-bar-width="tabBarWidth"
: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()"
/>
<Teleport defer v-if="showTabNavigation !== false" :to="tabBarTarget" :disabled="!tabBarPortal?.active.value || !tabBarTarget">
<EditorGroupTabBar
:group-id="groupId"
:tabs="groupTabs"
:active-tab-id="activeTabId"
:tab-bar-width="tabBarWidth"
: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()"
/>
</Teleport>
<!-- 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">
@@ -201,9 +208,11 @@ const groupExecutableSql = computed(() => {
<div class="relative flex-1 min-h-0">
<QueryEditorSurface v-if="activeTab?.mode === 'query'" ref="activeSurfaceRef" v-bind="surfaceBindings" :auto-focus="groupId === queryStore.focusedGroupId" class="h-full" />
<ContentArea v-else-if="activeTab" ref="activeSurfaceRef" v-bind="surfaceBindings" class="h-full" />
<div v-else class="flex h-full items-center justify-center text-sm text-muted-foreground">
{{ t("tabs.emptyGroup") }}
</div>
<slot v-else name="empty">
<div class="flex h-full items-center justify-center text-sm text-muted-foreground">
{{ t("tabs.emptyGroup") }}
</div>
</slot>
</div>
</div>
</div>
@@ -133,8 +133,16 @@ const isClassicLayout = computed(() => settingsStore.editorSettings.appLayout ==
// 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);
const specialPageActive = computed(() => !!(props.specialPageTabs?.settingsActive || props.specialPageTabs?.driverStoreActive));
function isTabActive(tab: QueryTab): boolean {
return !specialPageActive.value && tab.id === props.activeTabId;
}
function specialPageTabClass(active: boolean): string[] {
if (isVerticalLayout.value) {
return ["h-8 w-full rounded-md border", active ? "border-ring font-medium text-foreground" : "border-transparent text-foreground/70 hover:text-foreground/90"];
}
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"];
}
@@ -142,6 +150,7 @@ function specialPageTabClass(active: boolean): string[] {
}
function specialPageTabStyle(active: boolean) {
if (isVerticalLayout.value) return active ? { "--app-tab-background": "var(--accent)" } : undefined;
if (!isClassicLayout.value) return undefined;
return active ? { boxShadow: "inset 0 -2px 0 var(--ring)" } : undefined;
}
@@ -178,6 +187,23 @@ function toggleCompactTabTitle() {
compactTabTitle.value = !compactTabTitle.value;
}
function getSpecialPageTabMenuItems(surface: "settings" | "driverStore"): ContextMenuItem[] {
const closeCurrent = surface === "settings" ? () => emit("close-settings") : () => emit("close-driver-store");
const otherOpen = surface === "settings" ? props.specialPageTabs?.driverStoreOpen : props.specialPageTabs?.settingsOpen;
return [
{ label: compactTabTitle.value ? t("contextMenu.fullTabTitle") : t("contextMenu.compactTabTitle"), action: toggleCompactTabTitle, icon: compactTabTitle.value ? Maximize2 : Minimize2 },
{ label: "", separator: true },
{ label: t("contextMenu.closeTab"), action: closeCurrent, icon: X },
{
label: t("contextMenu.closeOtherTabs"),
action: () => (surface === "settings" ? emit("close-driver-store") : emit("close-settings")),
disabled: !otherOpen,
icon: X,
},
{ label: t("contextMenu.closeAllTabs"), action: closeCurrent, variant: "destructive", icon: X },
];
}
const tabBarClass = computed(() => [
isVerticalLayout.value
? `vertical-tab-layout h-full w-60 flex-col bg-background ${settingsStore.editorSettings.tabPlacement === "right" ? "border-l" : "border-r"}`
@@ -416,7 +442,7 @@ function isTabGroupCollapsed(tab: QueryTab) {
function isTabGroupActive(tab: QueryTab) {
const section = tab.pinned ? sortedPinnedTabs.value : sortedRegularTabs.value;
const groupKey = tabGroupKey(tab);
return section.some((item) => tabGroupKey(item) === groupKey && item.id === props.activeTabId);
return section.some((item) => tabGroupKey(item) === groupKey && isTabActive(item));
}
function toggleTabGroup(tab: QueryTab) {
@@ -648,13 +674,13 @@ function tabColorStyle(tab: QueryTab): CSSProperties | undefined {
// Sidebar tabs carry their group/connection color as a soft active wash;
// the active indicator itself comes from the vertical CSS rules.
if (isVerticalLayout.value) {
if (tab.id !== props.activeTabId) {
if (!isTabActive(tab)) {
return undefined;
}
const color = connectionColor(tab.connectionId);
return { "--app-tab-background": color ? hexToRgba(color, 0.12) : "var(--accent)" } as CSSProperties;
}
return sharedTabColorStyle(tab, tab.id === props.activeTabId, isClassicLayout.value);
return sharedTabColorStyle(tab, isTabActive(tab), isClassicLayout.value);
}
const tabTooltipSide = computed(() => {
@@ -1125,6 +1151,15 @@ watch(
nextTick(updateScrollButtons);
},
);
watch([() => props.specialPageTabs?.settingsActive, () => props.specialPageTabs?.driverStoreActive, () => settingsStore.editorSettings.tabPlacement, () => props.tabBarCollapsed], () => {
nextTick(() => {
updateScrollButtons();
if (showSpecialPageTabs.value && specialPageActive.value) {
tabsContainerRef.value?.querySelector<HTMLElement>('[data-active-tab="true"]')?.scrollIntoView({ block: "nearest", inline: "nearest" });
}
});
});
</script>
<template>
@@ -1195,15 +1230,15 @@ watch(
</button>
</CustomContextMenu>
<CustomContextMenu v-else :items="getTabMenuItems(entry.tab)" v-slot="{ onContextMenu }">
<div :class="isClassicLayout ? 'h-full' : ''" @contextmenu="onContextMenu">
<div :class="isClassicLayout && !isVerticalLayout ? 'h-full' : ''" @contextmenu="onContextMenu">
<Tooltip>
<TooltipTrigger as-child>
<div
class="app-tab-pill group flex cursor-default items-center gap-1 px-2 text-xs transition-colors whitespace-nowrap select-none"
:class="[
isClassicLayout
? ['h-full border-r border-border/80 font-medium dark:border-border/45', entry.tab.id === activeTabId ? 'bg-background text-foreground' : 'text-foreground/70 hover:text-foreground/90']
: ['h-7 rounded-md border', entry.tab.id === activeTabId ? 'text-foreground font-medium' : 'border-border/60 text-foreground/70 hover:border-border hover:text-foreground/90'],
? ['h-full border-r border-border/80 font-medium dark:border-border/45', isTabActive(entry.tab) ? 'bg-background text-foreground' : 'text-foreground/70 hover:text-foreground/90']
: ['h-7 rounded-md border', isTabActive(entry.tab) ? 'text-foreground font-medium' : 'border-border/60 text-foreground/70 hover:border-border hover:text-foreground/90'],
{
'tab-group-tab': entry.grouping,
'tab-group-tab--first': entry.grouping && entry.groupFirst,
@@ -1211,7 +1246,7 @@ watch(
},
]"
:style="[tabColorStyle(entry.tab), entry.grouping ? tabGroupStyle(entry.tab) : undefined, tabDropStyle(entry.tab)]"
:data-active-tab="entry.tab.id === activeTabId"
:data-active-tab="isTabActive(entry.tab)"
:data-tab-id="entry.tab.id"
@pointerdown="handleTabPointerDown($event, entry.tab)"
@click="handleTabClick(entry.tab)"
@@ -1273,46 +1308,60 @@ 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 v-if="showSpecialPageTabs">
<CustomContextMenu v-if="specialPageTabs?.settingsOpen" :items="getSpecialPageTabMenuItems('settings')" v-slot="{ onContextMenu }">
<div
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')"
:aria-label="t('settings.title')"
:aria-pressed="!!specialPageTabs?.settingsActive"
role="button"
tabindex="0"
@click="emit('activate-settings')"
@keydown.enter.self.prevent="emit('activate-settings')"
@keydown.space.self.prevent="emit('activate-settings')"
@contextmenu="onContextMenu"
@mousedown.middle.prevent="emit('close-settings')"
>
<Settings class="h-3.5 w-3.5 shrink-0 text-sky-600 dark:text-sky-400" />
<span v-if="!isTabBarCollapsed" class="min-w-0 flex-1 truncate">{{ t("settings.title") }}</span>
<button v-if="!isTabBarCollapsed" 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>
</CustomContextMenu>
<CustomContextMenu v-if="specialPageTabs?.driverStoreOpen" :items="getSpecialPageTabMenuItems('driverStore')" v-slot="{ onContextMenu }">
<div
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')"
:aria-label="t('toolbar.driverManager')"
:aria-pressed="!!specialPageTabs?.driverStoreActive"
role="button"
tabindex="0"
@click="emit('activate-driver-store')"
@keydown.enter.self.prevent="emit('activate-driver-store')"
@keydown.space.self.prevent="emit('activate-driver-store')"
@contextmenu="onContextMenu"
@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 v-if="!isTabBarCollapsed" class="min-w-0 flex-1 truncate">{{ t("toolbar.driverManager") }}</span>
<span v-if="!isTabBarCollapsed && (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 v-if="!isTabBarCollapsed" 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>
</CustomContextMenu>
</template>
<div :class="tabTailDragRegionClass" data-tauri-drag-region />
</div>
@@ -1333,7 +1382,7 @@ watch(
<CustomContextMenu v-for="tab in filteredGroupTabs" :key="tab.id" :items="getTabMenuItems(tab)" v-slot="{ onContextMenu }">
<div
class="group flex w-full items-center gap-2 rounded-md px-1.5 py-1.5 text-left text-sm outline-hidden hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground"
:class="tab.id === activeTabId ? 'bg-accent/70 text-accent-foreground' : ''"
:class="isTabActive(tab) ? 'bg-accent/70 text-accent-foreground' : ''"
:title="tabDisplayTitle(tab, t)"
role="menuitem"
tabindex="0"
@@ -1,10 +1,13 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, useAttrs, watch } from "vue";
import { computed, inject, nextTick, onMounted, provide, ref, useAttrs, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Splitpanes, Pane } from "splitpanes";
import "splitpanes/dist/splitpanes.css";
import "./sqlEditorWorkspace.css";
import { useQueryStore } from "@/stores/queryStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { createGroupTabBarPortal, GROUP_TAB_BAR_PORTAL } from "./groupTabBarPortal";
import { hasQueryOutput } from "@/lib/query/queryOutput";
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
import { Button } from "@/components/ui/button";
import EditorGroup from "./EditorGroup.vue";
@@ -16,7 +19,9 @@ import type { QueryTab } from "@/types/database";
defineOptions({ inheritAttrs: false });
const props = defineProps<
ContentAreaSurfaceProps & {
Omit<ContentAreaSurfaceProps, "activeTab"> & {
activeTab?: QueryTab;
showTabNavigation?: boolean;
tabBarWidth?: number;
tabBarCollapsed?: boolean;
canDetachTabs?: boolean;
@@ -35,7 +40,7 @@ const emit = defineEmits<
const attrs = useAttrs();
const workspaceClass = computed(() => (typeof attrs.class === "string" ? attrs.class : undefined));
const surfaceProps = computed<ContentAreaSurfaceProps>(() => ({
const surfaceProps = computed(() => ({
activeTab: props.activeTab,
activeConnection: props.activeConnection,
executableSql: props.executableSql,
@@ -49,7 +54,7 @@ const surfaceProps = computed<ContentAreaSurfaceProps>(() => ({
}));
const contentEmits = createContentSurfaceEventForwarders(emit);
const editorGroupBindings = computed(() => ({ ...surfaceProps.value, ...contentEmits }));
const resultSurfaceBindings = computed(() => ({ ...surfaceProps.value, ...contentEmits }));
const resultSurfaceBindings = computed(() => ({ ...surfaceProps.value, ...contentEmits, activeTab: activeTab.value! }));
defineExpose({
focusSearch: (target: Element | null = null) => {
@@ -105,8 +110,29 @@ defineExpose({
const { t } = useI18n();
const queryStore = useQueryStore();
const settingsStore = useSettingsStore();
const isVerticalTabLayout = computed(() => settingsStore.editorSettings.tabPlacement === "left" || settingsStore.editorSettings.tabPlacement === "right");
const globalTabBarPortal = inject(GROUP_TAB_BAR_PORTAL, null);
const workspaceTabBarPortal = createGroupTabBarPortal(isVerticalTabLayout);
// A special page owns navigation while active. Otherwise side tabs stay
// outside the editor/result split, and horizontal tabs return to their group.
provide(GROUP_TAB_BAR_PORTAL, {
active: computed(() => !!globalTabBarPortal?.active.value || isVerticalTabLayout.value),
get targets() {
return globalTabBarPortal?.active.value ? globalTabBarPortal.targets : workspaceTabBarPortal.targets;
},
});
const tabNavigationStyle = computed(() => {
const width = props.tabBarCollapsed ? "3.5rem" : `${props.tabBarWidth ?? 240}px`;
return { width, flex: `0 0 ${width}` };
});
function setTabBarTarget(groupId: string, element: unknown) {
if (element instanceof HTMLElement) workspaceTabBarPortal.targets.set(groupId, element);
else workspaceTabBarPortal.targets.delete(groupId);
}
const activeTab = computed(() => queryStore.tabs.find((tab) => tab.id === queryStore.activeTabId));
const showSharedResult = computed(() => activeTab.value?.mode === "query");
const hasSharedOutput = computed(() => showSharedResult.value && hasQueryOutput(activeTab.value));
const SHARED_RESULT_PANE_MIN_SIZE = 12;
const SHARED_RESULT_PANE_MAX_SIZE = 80;
@@ -115,11 +141,10 @@ const SHARED_RESULT_PANE_STORAGE_KEY = "dbx-shared-results-pane-size";
const storedResultPaneSize = Number(safeLocalStorageGet(SHARED_RESULT_PANE_STORAGE_KEY));
const resultPaneSize = ref(Number.isFinite(storedResultPaneSize) && storedResultPaneSize >= SHARED_RESULT_PANE_MIN_SIZE && storedResultPaneSize <= SHARED_RESULT_PANE_MAX_SIZE ? storedResultPaneSize : SHARED_RESULT_PANE_DEFAULT_SIZE);
const showResultPane = ref(true);
// The result pane stays mounted and animates its size between the stored
// 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));
const isResultPaneVisible = computed(() => hasSharedOutput.value && showResultPane.value);
// Keep the pane mounted, but apply its target size immediately so available
// results never wait for an expansion animation before becoming visible.
const resultPaneTargetSize = computed(() => (isResultPaneVisible.value ? resultPaneSize.value : 0));
const editorPaneSize = computed(() => 100 - resultPaneTargetSize.value);
/**
@@ -133,6 +158,7 @@ function toggleSharedResultsPane(): boolean {
if (!showSharedResult.value) {
return activeEditorGroup()?.toggleResultsPane() ?? false;
}
if (!hasSharedOutput.value) return false;
showResultPane.value = !showResultPane.value;
return true;
}
@@ -141,9 +167,9 @@ function toggleSharedResultsPane(): boolean {
// behavior (see issue #6193): a collapsed shared pane comes back when the
// active tab starts executing.
watch(
() => activeTab.value?.isExecuting,
(isExecuting) => {
if (isExecuting) showResultPane.value = true;
() => [activeTab.value?.id, activeTab.value?.isExecuting, activeTab.value?.isExplaining] as const,
([, isExecuting, isExplaining]) => {
if (isExecuting || isExplaining) showResultPane.value = true;
},
);
// Entrance choreography is hydration-gated: groups present at first render
@@ -217,44 +243,50 @@ function handleFocusStatement(tabId: string, range: StatementRange | null): bool
</script>
<template>
<div class="sql-editor-workspace relative flex h-full min-h-0 flex-1 flex-col overflow-hidden" :class="workspaceClass">
<Splitpanes horizontal class="sql-editor-workspace-split flex-1 min-h-0" :class="{ 'result-pane-collapsed': resultPaneTargetSize === 0 }" @resized="onSharedResultResized">
<Pane class="min-h-0 min-w-0" :size="editorPaneSize" :min-size="100 - SHARED_RESULT_PANE_MAX_SIZE">
<Splitpanes
:horizontal="queryStore.orientation === 'horizontal'"
class="sql-editor-groups h-full min-h-0"
@resized="
(event: { panes: Array<{ size: number }> }) => {
queryStore.sizes = event.panes.map((pane) => pane.size);
}
"
>
<Pane v-for="group in queryStore.groups" :key="group.id" :size="queryStore.sizes[queryStore.groups.indexOf(group)] ?? undefined" :min-size="10" class="min-h-0 min-w-0" :class="paneEnterClass(group.id)">
<EditorGroup
:ref="(el: unknown) => setGroupRef(group.id, el)"
:group-id="group.id"
:tab-ids="group.tabIds"
:active-tab-id="group.activeTabId"
:tab-bar-width="tabBarWidth"
:tab-bar-collapsed="tabBarCollapsed"
:can-detach-tabs="canDetachTabs"
:detached-drop-target="detachedDropTarget"
class="h-full"
v-bind="editorGroupBindings"
@focus-group="queryStore.focusGroup($event)"
@activate-tab="queryStore.activateTabInGroup(group.id, $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)"
/>
</Pane>
</Splitpanes>
</Pane>
<Pane class="min-h-0" :size="resultPaneTargetSize" :min-size="resultPaneTargetSize > 0 ? SHARED_RESULT_PANE_MIN_SIZE : 0" :max-size="SHARED_RESULT_PANE_MAX_SIZE">
<Transition name="result-surface">
<div v-if="showSharedResult && showResultPane" data-shared-result-surface class="h-full min-h-0 overflow-hidden">
<div class="sql-editor-workspace relative flex h-full min-h-0 min-w-0 flex-1 overflow-hidden" :class="[workspaceClass, settingsStore.editorSettings.tabPlacement === 'right' ? 'flex-row-reverse' : 'flex-row']">
<div v-show="isVerticalTabLayout && showTabNavigation !== false" data-workspace-tab-navigation class="flex min-h-0 shrink-0 flex-col overflow-hidden" :style="tabNavigationStyle">
<div v-for="group in queryStore.groups" :key="group.id" :ref="(element) => setTabBarTarget(group.id, element)" :data-workspace-tab-target="group.id" class="flex min-h-0 min-w-0 flex-1" @pointerdown.capture="queryStore.focusGroup(group.id)" @focusin="queryStore.focusGroup(group.id)" />
</div>
<div data-workspace-content class="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<Splitpanes horizontal class="sql-editor-workspace-split flex-1 min-h-0" :class="{ 'result-pane-collapsed': resultPaneTargetSize === 0 }" @resized="onSharedResultResized">
<Pane class="min-h-0 min-w-0" :size="editorPaneSize" :min-size="100 - SHARED_RESULT_PANE_MAX_SIZE">
<Splitpanes
:horizontal="queryStore.orientation === 'horizontal'"
class="sql-editor-groups h-full min-h-0"
@resized="
(event: { panes: Array<{ size: number }> }) => {
queryStore.sizes = event.panes.map((pane) => pane.size);
}
"
>
<Pane v-for="group in queryStore.groups" :key="group.id" :size="queryStore.sizes[queryStore.groups.indexOf(group)] ?? undefined" :min-size="10" class="min-h-0 min-w-0" :class="paneEnterClass(group.id)">
<EditorGroup
:ref="(el: unknown) => setGroupRef(group.id, el)"
:group-id="group.id"
:tab-ids="group.tabIds"
:active-tab-id="group.activeTabId"
:show-tab-navigation="showTabNavigation"
:tab-bar-width="tabBarWidth"
:tab-bar-collapsed="tabBarCollapsed"
:can-detach-tabs="canDetachTabs"
:detached-drop-target="detachedDropTarget"
class="h-full"
v-bind="editorGroupBindings"
@focus-group="queryStore.focusGroup($event)"
@activate-tab="queryStore.activateTabInGroup(group.id, $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)"
>
<template #empty><slot name="empty" /></template>
</EditorGroup>
</Pane>
</Splitpanes>
</Pane>
<Pane class="min-h-0" :size="resultPaneTargetSize" :min-size="resultPaneTargetSize > 0 ? SHARED_RESULT_PANE_MIN_SIZE : 0" :max-size="SHARED_RESULT_PANE_MAX_SIZE">
<div v-if="isResultPaneVisible" data-shared-result-surface class="h-full min-h-0 overflow-hidden">
<QueryResultSurface
ref="resultSurfaceRef"
v-bind="resultSurfaceBindings"
@@ -272,22 +304,22 @@ function handleFocusStatement(tabId: string, range: StatementRange | null): bool
"
/>
</div>
</Transition>
</Pane>
</Splitpanes>
<!-- The shared surface unmounts when collapsed, so the mouse re-show
</Pane>
</Splitpanes>
<!-- The shared surface unmounts when collapsed, so the mouse re-show
affordance lives here, outside the collapsible pane. -->
<Button
v-if="showSharedResult && !showResultPane"
type="button"
variant="secondary"
size="sm"
class="absolute bottom-3 right-3 z-20 h-7 gap-1.5 rounded-full border bg-background/95 px-3 text-xs shadow-lg hover:bg-accent"
:title="t('editor.showResultsPane')"
:aria-label="t('editor.showResultsPane')"
@click="showResultPane = true"
>
{{ t("editor.showResultsPane") }}
</Button>
<Button
v-if="hasSharedOutput && !showResultPane"
type="button"
variant="secondary"
size="sm"
class="absolute bottom-3 right-3 z-20 h-7 gap-1.5 rounded-full border bg-background/95 px-3 text-xs shadow-lg hover:bg-accent"
:title="t('editor.showResultsPane')"
:aria-label="t('editor.showResultsPane')"
@click="showResultPane = true"
>
{{ t("editor.showResultsPane") }}
</Button>
</div>
</div>
</template>
@@ -0,0 +1,378 @@
// @vitest-environment happy-dom
import { computed, createApp, defineComponent, nextTick, reactive, ref } from "vue";
import { createPinia, setActivePinia } from "pinia";
import { createI18n } from "vue-i18n";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("splitpanes", () => ({
Splitpanes: { template: "<div><slot /></div>" },
Pane: { template: "<div><slot /></div>" },
}));
vi.mock("@/components/layout/EditorToolbar.vue", () => ({ default: { template: '<div data-test="toolbar" />' } }));
vi.mock("@/components/layout/QueryEditorSurface.vue", () => ({ default: { template: '<div data-test="query-editor" />' } }));
vi.mock("@/components/layout/QueryResultSurface.vue", () => ({ default: { template: '<div data-test="query-result" />' } }));
vi.mock("@/components/layout/ContentArea.vue", () => ({ default: { template: '<div data-test="content-area" />' } }));
vi.mock("@/lib/backend/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/backend/api")>()),
closeClientConnectionSession: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/tabs/tabResultCache", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/tabs/tabResultCache")>()),
deleteTabResultSnapshot: vi.fn().mockResolvedValue(undefined),
}));
import AppTabBar from "../AppTabBar.vue";
import SqlEditorWorkspace from "../SqlEditorWorkspace.vue";
import { TooltipProvider } from "@/components/ui/tooltip";
import { createGroupTabBarPortal, GROUP_TAB_BAR_PORTAL } from "../groupTabBarPortal";
import { createNoopEditorToolbarActions, EDITOR_TOOLBAR_ACTIONS } from "../editorToolbarActions";
import { useQueryStore } from "@/stores/queryStore";
import { useSettingsStore } from "@/stores/settingsStore";
import type { QueryTab } from "@/types/database";
function queryTab(id: string, connectionId = "mysql-local"): QueryTab {
return { id, title: id, sql: "SELECT 1", mode: "query", connectionId, database: "app", isExecuting: false };
}
const cleanups: Array<() => void> = [];
async function settle() {
await nextTick();
await nextTick();
await nextTick();
}
function mountNavigation(placement: "top" | "bottom" | "left" | "right", empty = false) {
const pinia = createPinia();
setActivePinia(pinia);
const store = useQueryStore();
const settings = useSettingsStore();
settings.editorSettings.tabPlacement = placement;
settings.editorSettings.tabGroupMode = "connection";
store.tabs = empty ? [] : [queryTab("Query alpha"), queryTab("Query beta")];
store.groups = [{ id: "main", tabIds: store.tabs.map((tab) => tab.id), activeTabId: store.tabs[0]?.id ?? null }];
store.activeTabId = store.tabs[0]?.id ?? null;
store.focusedGroupId = "main";
store.sizes = [100];
const pages = reactive({ settingsOpen: true, driverOpen: true, driverActive: false, draft: "initial" });
const navigation = reactive({ width: 260, collapsed: false });
const workspace = ref<{ toggleResultsPane: () => boolean } | null>(null);
const active = computed(() => settings.settingsPageActive || pages.driverActive);
const portal = createGroupTabBarPortal(active);
const actions = {
...createNoopEditorToolbarActions(),
specialPageTabs: computed(() => ({ settingsOpen: pages.settingsOpen, settingsActive: settings.settingsPageActive, driverStoreOpen: pages.driverOpen, driverStoreActive: pages.driverActive, driverUpdateCount: 2 })),
activateSettingsPage: () => {
pages.settingsOpen = true;
pages.driverActive = false;
settings.settingsPageActive = true;
},
closeSettingsPage: () => {
pages.settingsOpen = false;
settings.settingsPageActive = false;
},
activateDriverStore: () => {
pages.driverOpen = true;
settings.settingsPageActive = false;
pages.driverActive = true;
},
closeDriverStore: () => {
pages.driverOpen = false;
pages.driverActive = false;
},
};
const onQuerySurface = () => {
pages.driverActive = false;
settings.settingsPageActive = false;
};
window.addEventListener("dbx:activate-query-surface", onQuerySurface);
const host = document.createElement("div");
document.body.appendChild(host);
const root = defineComponent({
components: { AppTabBar, SqlEditorWorkspace, TooltipProvider },
setup: () => ({ store, settings, pages, active, navigation, workspace }),
template: `
<TooltipProvider>
<AppTabBar :settings-page-open="pages.settingsOpen" :settings-page-active="settings.settingsPageActive" :driver-store-open="pages.driverOpen" :driver-store-active="pages.driverActive" :tab-bar-width="navigation.width" :tab-bar-collapsed="navigation.collapsed">
<div v-show="settings.settingsPageActive" data-test="settings-page"><input v-model="pages.draft" data-test="settings-input" /></div>
<div v-show="pages.driverActive" data-test="driver-page">Drivers</div>
</AppTabBar>
<div v-show="!active" data-test="editor-workspace-wrapper">
<SqlEditorWorkspace ref="workspace" :active-tab="store.tabs.find(tab => tab.id === store.activeTabId)" :show-tab-navigation="store.tabs.length > 0 || pages.settingsOpen || pages.driverOpen" active-output-view="result" executable-sql="SELECT 1" :format-sql-request="null" :compress-sql-request="null" selected-sql="" :cursor-pos="0" :block-dangerous-redis-commands="false" :tab-bar-width="navigation.width" :tab-bar-collapsed="navigation.collapsed">
<template #empty><div data-test="welcome"><button @click="store.createTab('mysql-local', 'app', 'New query', 'query')">New query</button></div></template>
</SqlEditorWorkspace>
</div>
</TooltipProvider>`,
});
const app = createApp(root);
const warnings: string[] = [];
app.config.warnHandler = (message) => warnings.push(message);
app.use(pinia);
app.use(createI18n({ legacy: false, locale: "en", messages: { en: {} }, missingWarn: false, fallbackWarn: false }));
app.provide(GROUP_TAB_BAR_PORTAL, portal);
app.provide(EDITOR_TOOLBAR_ACTIONS, actions);
app.mount(host);
cleanups.push(() => {
app.unmount();
host.remove();
window.removeEventListener("dbx:activate-query-surface", onQuerySurface);
});
return { host, store, settings, pages, portal, actions, warnings, navigation, workspace };
}
function element(host: HTMLElement, selector: string): HTMLElement {
const result = host.querySelector<HTMLElement>(selector);
expect(result, selector).not.toBeNull();
return result!;
}
describe("AppTabBar group navigation portal", () => {
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
for (const cleanup of cleanups.splice(0)) cleanup();
});
it.each(["top", "bottom", "left", "right"] as const)("moves the same %s bar between query, settings and driver surfaces", async (placement) => {
const { host, pages, warnings } = mountNavigation(placement);
await settle();
const bar = element(host, "[data-main-tab-bar]");
expect(bar.dataset.placement).toBe(placement);
element(bar, "[data-settings-page-tab]").click();
await settle();
expect(host.querySelectorAll("[data-main-tab-bar]")).toHaveLength(1);
expect(element(host, "[data-special-page-tab-target='main']").contains(bar)).toBe(true);
expect(element(host, "[data-test='editor-workspace-wrapper']").style.display).toBe("none");
expect(element(host, "[data-special-page-workspace]").style.display).not.toBe("none");
expect(element(bar, "[data-settings-page-tab]").dataset.activeTab).toBe("true");
expect(bar.querySelectorAll("[data-tab-id][data-active-tab='true']")).toHaveLength(0);
const input = element(host, "[data-test='settings-input']") as HTMLInputElement;
input.value = "edited setting";
input.dispatchEvent(new Event("input", { bubbles: true }));
expect(pages.draft).toBe("edited setting");
element(bar, "[data-driver-store-tab]").click();
await settle();
expect(element(host, "[data-main-tab-bar]")).toBe(bar);
expect(element(bar, "[data-driver-store-tab]").dataset.activeTab).toBe("true");
expect(element(bar, "[data-settings-page-tab]").dataset.activeTab).toBe("false");
element(bar, "[data-tab-id='Query alpha']").click();
await settle();
expect(element(host, "[data-main-tab-bar]")).toBe(bar);
expect(element(host, "[data-test='editor-workspace-wrapper']").contains(bar)).toBe(true);
expect(element(host, "[data-special-page-workspace]").style.display).toBe("none");
expect(element(bar, "[data-tab-id='Query alpha']").dataset.activeTab).toBe("true");
expect(element(host, "[data-test='settings-input']")).toBe(input);
expect(input.value).toBe("edited setting");
expect(warnings).toEqual([]);
});
it("preserves the side bar search and collapsed connection group across portal moves", async () => {
const { host, actions, warnings } = mountNavigation("left");
await settle();
const bar = element(host, "[data-main-tab-bar]");
element(bar, ".tab-group-header").click();
await settle();
expect(element(bar, ".tab-group-header").getAttribute("aria-expanded")).toBe("false");
const search = element(bar, "input[type='search']") as HTMLInputElement;
search.value = "alpha";
search.dispatchEvent(new Event("input", { bubbles: true }));
await settle();
expect(bar.querySelector("[data-tab-id='Query beta']")).toBeNull();
actions.activateSettingsPage();
await settle();
expect(element(host, "[data-main-tab-bar]")).toBe(bar);
expect(element(bar, "input[type='search']")).toBe(search);
expect(search.value).toBe("alpha");
actions.closeSettingsPage();
await settle();
expect(element(host, "[data-main-tab-bar]")).toBe(bar);
expect(search.value).toBe("alpha");
search.value = "";
search.dispatchEvent(new Event("input", { bubbles: true }));
await settle();
expect(element(bar, ".tab-group-header").getAttribute("aria-expanded")).toBe("false");
expect(warnings).toEqual([]);
});
it("keeps special navigation usable with no ordinary tabs", async () => {
const { host, actions, warnings } = mountNavigation("left", true);
actions.activateSettingsPage();
await settle();
expect(host.querySelectorAll("[data-main-tab-bar]")).toHaveLength(1);
expect(host.querySelectorAll("[data-tab-id]")).toHaveLength(0);
expect(element(host, "[data-special-page-tab-target='main'] [data-settings-page-tab]").dataset.activeTab).toBe("true");
element(host, "[data-driver-store-tab]").click();
await settle();
expect(element(host, "[data-driver-store-tab]").dataset.activeTab).toBe("true");
expect(warnings).toEqual([]);
});
it.each(["top", "bottom", "left", "right"] as const)("shows welcome after the last query closes while retaining %s special tabs", async (placement) => {
const { host, store, pages, actions, warnings } = mountNavigation(placement);
await settle();
actions.activateSettingsPage();
await settle();
element(host, "[data-tab-id='Query alpha']").click();
await settle();
const tabIds = store.tabs.map((tab) => tab.id);
for (const id of tabIds) store.closeTab(id, { force: true });
await settle();
const wrapper = element(host, "[data-test='editor-workspace-wrapper']");
expect(wrapper.style.display).not.toBe("none");
expect(wrapper.querySelectorAll("[data-test='welcome']")).toHaveLength(1);
expect(wrapper.querySelectorAll("[data-settings-page-tab]")).toHaveLength(1);
expect(wrapper.querySelectorAll("[data-driver-store-tab]")).toHaveLength(1);
expect(wrapper.querySelector("[data-shared-result-surface]")).toBeNull();
element(wrapper, "[data-settings-page-tab]").click();
await settle();
expect(wrapper.style.display).toBe("none");
actions.closeSettingsPage();
actions.closeDriverStore();
await settle();
expect(wrapper.style.display).not.toBe("none");
expect(wrapper.querySelectorAll("[data-test='welcome']")).toHaveLength(1);
expect(host.querySelector("[data-main-tab-bar]")).toBeNull();
expect(element(host, "[data-workspace-tab-navigation]").style.display).toBe("none");
expect(pages.settingsOpen || pages.driverOpen).toBe(false);
element(wrapper, "[data-test='welcome'] button").click();
await settle();
expect(store.tabs).toHaveLength(1);
expect(wrapper.querySelector("[data-test='welcome']")).toBeNull();
expect(wrapper.querySelector("[data-test='query-editor']")).not.toBeNull();
expect(host.querySelectorAll("[data-main-tab-bar]")).toHaveLength(1);
expect(warnings).toEqual([]);
});
it("registers and removes portal targets as editor groups change while settings is open", async () => {
const { host, store, portal, actions, warnings } = mountNavigation("left");
actions.activateSettingsPage();
await settle();
store.tabs.push(queryTab("Query gamma", "postgres-local"));
store.groups.push({ id: "secondary", tabIds: ["Query gamma"], activeTabId: "Query gamma" });
store.sizes = [50, 50];
await settle();
expect(portal.targets.size).toBe(2);
expect(host.querySelectorAll("[data-main-tab-bar]")).toHaveLength(2);
expect(element(host, "[data-special-page-tab-target='secondary'] [data-main-tab-bar]").dataset.groupId).toBe("secondary");
store.groups = store.groups.filter((group) => group.id !== "secondary");
store.tabs = store.tabs.filter((tab) => tab.id !== "Query gamma");
store.sizes = [100];
await settle();
expect(portal.targets.has("secondary")).toBe(false);
expect(host.querySelectorAll("[data-main-tab-bar]")).toHaveLength(1);
expect(warnings).toEqual([]);
});
it.each(["left", "right"] as const)("keeps the same %s navigation outside the results split throughout query execution", async (placement) => {
const { host, store, workspace, actions, warnings } = mountNavigation(placement);
await settle();
const bar = element(host, "[data-main-tab-bar]");
const rail = element(host, "[data-workspace-tab-navigation]");
const target = element(rail, "[data-workspace-tab-target='main']");
const content = element(host, "[data-workspace-content]");
expect(target.contains(bar)).toBe(true);
expect(rail.parentElement).toBe(content.parentElement);
expect(content.contains(rail)).toBe(false);
expect(content.querySelector("[data-shared-result-surface]")).toBeNull();
store.tabs[0]!.isExecuting = true;
await settle();
expect(content.querySelector("[data-shared-result-surface]")).not.toBeNull();
store.tabs[0]!.result = { columns: ["id"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 };
store.tabs[0]!.isExecuting = false;
await settle();
expect(element(host, "[data-workspace-tab-target='main']")).toBe(target);
expect(element(target, "[data-main-tab-bar]")).toBe(bar);
expect(rail.querySelector("[data-shared-result-surface]")).toBeNull();
expect(workspace.value!.toggleResultsPane()).toBe(true);
await settle();
await vi.waitFor(() => expect(content.querySelector("[data-shared-result-surface]")).toBeNull());
expect(element(target, "[data-main-tab-bar]")).toBe(bar);
expect(workspace.value!.toggleResultsPane()).toBe(true);
await settle();
expect(content.querySelector("[data-shared-result-surface]")).not.toBeNull();
expect(element(host, "[data-workspace-tab-target='main']")).toBe(target);
expect(element(target, "[data-main-tab-bar]")).toBe(bar);
actions.activateSettingsPage();
await settle();
expect(element(host, "[data-special-page-tab-target='main']").contains(bar)).toBe(true);
element(bar, "[data-tab-id='Query alpha']").click();
await settle();
expect(element(target, "[data-main-tab-bar]")).toBe(bar);
expect(content.querySelector("[data-shared-result-surface]")).not.toBeNull();
expect(warnings).toEqual([]);
});
it.each(["left", "right"] as const)("returns the %s rail bar to its original editor group for horizontal placements", async (placement) => {
const { host, settings, warnings } = mountNavigation(placement);
await settle();
const bar = element(host, "[data-workspace-tab-target='main'] [data-main-tab-bar]");
for (const horizontal of ["top", "bottom"] as const) {
settings.editorSettings.tabPlacement = horizontal;
await settle();
expect(element(host, ".editor-group[data-group-id='main']").contains(bar)).toBe(true);
expect(bar.dataset.placement).toBe(horizontal);
expect(host.querySelectorAll("[data-main-tab-bar]")).toHaveLength(1);
settings.editorSettings.tabPlacement = placement;
await settle();
expect(element(host, "[data-workspace-tab-target='main']").contains(bar)).toBe(true);
}
expect(warnings).toEqual([]);
});
it.each(["left", "right"] as const)("synchronizes %s rail width and collapse with the persistent bar", async (placement) => {
const { host, navigation, warnings } = mountNavigation(placement);
await settle();
const rail = element(host, "[data-workspace-tab-navigation]");
const bar = element(rail, "[data-main-tab-bar]");
expect(rail.style.width).toBe("260px");
expect(bar.style.width).toBe("260px");
navigation.width = 320;
await settle();
expect(rail.style.width).toBe("320px");
expect(bar.style.width).toBe("320px");
navigation.collapsed = true;
await settle();
expect(rail.style.width).toBe("3.5rem");
expect(bar.style.width).toBe("3.5rem");
expect(element(rail, "[data-main-tab-bar]")).toBe(bar);
navigation.collapsed = false;
await settle();
expect(rail.style.width).toBe("320px");
expect(bar.style.width).toBe("320px");
expect(warnings).toEqual([]);
});
it("preserves each group's bar and tab ownership across workspace and special-page rails", async () => {
const { host, store, actions, warnings } = mountNavigation("left");
store.tabs.push(queryTab("Query gamma", "postgres-local"));
store.groups.push({ id: "secondary", tabIds: ["Query gamma"], activeTabId: "Query gamma" });
store.sizes = [50, 50];
await settle();
const main = element(host, "[data-workspace-tab-target='main'] [data-main-tab-bar]");
const secondary = element(host, "[data-workspace-tab-target='secondary'] [data-main-tab-bar]");
const originalGroups = store.groups.map((group) => ({ id: group.id, tabIds: [...group.tabIds], activeTabId: group.activeTabId }));
actions.activateSettingsPage();
await settle();
expect(element(host, "[data-special-page-tab-target='main']").contains(main)).toBe(true);
expect(element(host, "[data-special-page-tab-target='secondary']").contains(secondary)).toBe(true);
actions.closeSettingsPage();
await settle();
expect(element(host, "[data-workspace-tab-target='main']").contains(main)).toBe(true);
expect(element(host, "[data-workspace-tab-target='secondary']").contains(secondary)).toBe(true);
expect(main.querySelector("[data-tab-id='Query gamma']")).toBeNull();
expect(secondary.querySelector("[data-tab-id='Query alpha']")).toBeNull();
expect(store.groups).toEqual(originalGroups);
expect(store.focusedGroupId).toBe("main");
expect(host.querySelectorAll("[data-main-tab-bar]")).toHaveLength(2);
expect(warnings).toEqual([]);
});
});
+38 -117
View File
@@ -3,29 +3,30 @@ import { describe, expect, it } from "vitest";
const tabBarSource = readFileSync(new URL("../AppTabBar.vue", import.meta.url), "utf8");
const groupTabBarSource = readFileSync(new URL("../EditorGroupTabBar.vue", import.meta.url), "utf8");
const groupSource = readFileSync(new URL("../EditorGroup.vue", import.meta.url), "utf8");
function sourceBetween(start: string, end: string): string {
const startIndex = tabBarSource.indexOf(start);
const endIndex = tabBarSource.indexOf(end, startIndex + start.length);
expect(startIndex).toBeGreaterThanOrEqual(0);
expect(endIndex).toBeGreaterThan(startIndex);
return tabBarSource.slice(startIndex, endIndex);
}
describe("AppTabBar single production path", () => {
it("no longer renders the regular or pinned tab strip", () => {
expect(tabBarSource).not.toContain("hideRegularTabs");
expect(tabBarSource).not.toContain("regularTabs");
expect(tabBarSource).not.toContain("fixedTabsContainerRef");
expect(tabBarSource).not.toContain("useTabDrag");
describe("AppTabBar shared group navigation", () => {
it("hosts existing group bars without rendering a second tab strip", () => {
expect(tabBarSource).toContain("GROUP_TAB_BAR_PORTAL");
expect(tabBarSource).toContain('v-for="group in queryStore.groups"');
expect(tabBarSource).toContain(':data-special-page-tab-target="group.id"');
expect(tabBarSource).not.toContain("overlayReturnTabs");
expect(tabBarSource).not.toContain("data-return-tab");
expect(tabBarSource).not.toContain("createRenameDuplicateTabItems");
expect(groupTabBarSource).toContain("createRenameDuplicateTabItems");
expect(groupSource).toContain("<Teleport defer");
expect(groupSource).toContain(':disabled="!tabBarPortal?.active.value || !tabBarTarget"');
});
it("keeps special pages and the close-confirm dialog as its only surfaces", () => {
expect(tabBarSource).toContain("getSpecialRegularTabMenuItems('settings')");
expect(tabBarSource).toContain("getSpecialRegularTabMenuItems('driverStore')");
expect(tabBarSource).toContain(':open="queryStore.showCloseConfirm"');
it("retains targets while hiding inactive special content and follows all placements", () => {
expect(tabBarSource).toContain('v-show="driverStoreActive || settingsPageActive"');
expect(tabBarSource).toContain("data-special-page-navigation");
expect(tabBarSource).toContain("data-special-page-content");
expect(tabBarSource).toContain("<slot />");
for (const layout of ["flex-col", "flex-col-reverse", "flex-row", "flex-row-reverse"]) {
expect(tabBarSource).toContain(layout);
}
expect(tabBarSource).toContain("props.tabBarCollapsed");
expect(tabBarSource).toContain("props.tabBarWidth");
});
it("scopes the close-other shortcut to the active tab's owner group", () => {
@@ -49,123 +50,43 @@ describe("AppTabBar close confirmation layout", () => {
});
it("shows the dirty tab list using the shared tab title presentation", () => {
const listBlock = sourceBetween('v-for="tab in closeConfirmDirtyTabs"', "</PopoverContent>");
expect(listBlock).toContain("tabDisplayTitle(tab, t)");
});
});
describe("AppTabBar special page selection", () => {
it("renders settings and driver store as app-level pills outside the editor groups", () => {
expect(tabBarSource).toContain("data-settings-page-tab");
expect(tabBarSource).toContain("data-driver-store-tab");
expect(tabBarSource).toContain("@click=\"emit('activate-settings-page')\"");
expect(tabBarSource).toContain("@click=\"emit('activate-driver-store')\"");
expect(tabBarSource).toContain('t("toolbar.driverManager")');
});
it("keeps the driver update badge on the driver store pill", () => {
expect(tabBarSource).toContain("agentDriverUpdateCount");
expect(tabBarSource).toContain("aria-label=\"t('toolbar.updatableDriverCount')\"");
});
});
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')");
// 经典布局激活的特殊标签与普通标签一样显示 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\(\);/);
const start = tabBarSource.indexOf('v-for="tab in closeConfirmDirtyTabs"');
const end = tabBarSource.indexOf("</PopoverContent>", start);
expect(start).toBeGreaterThanOrEqual(0);
expect(end).toBeGreaterThan(start);
expect(tabBarSource.slice(start, end)).toContain("tabDisplayTitle(tab, t)");
});
});
describe("Group strip special page tabs", () => {
it("appends open special pages to the focused group strip like v0.6.2", () => {
it("keeps special page tabs and their update badge in the focused group", () => {
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;');
expect(groupTabBarSource).toContain("aria-label=\"t('toolbar.updatableDriverCount')\"");
expect(groupTabBarSource).toContain("specialPageTabs?.driverUpdateCount");
});
it("wires the group strip through the injected special page actions", () => {
const groupSource = readFileSync(new URL("../EditorGroup.vue", import.meta.url), "utf8");
it("routes special-page actions and preserves ordinary-tab dirty closing", () => {
expect(groupSource).toContain(':special-page-tabs="toolbar.specialPageTabs.value"');
expect(groupSource).toContain('@activate-settings="toolbar.activateSettingsPage()"');
expect(groupSource).toContain('@close-driver-store="toolbar.closeDriverStore()"');
expect(groupTabBarSource).toContain('@mousedown.middle.prevent="closeTab(entry.tab)"');
expect(groupTabBarSource).toContain('@click.stop="closeTab(entry.tab)"');
expect(groupTabBarSource).toContain("queryStore.closeTab(tab.id)");
});
});
describe("Group tabbar inherited presentation", () => {
it("imports the shared tab bar stylesheet so pills, scrollbar, and wrap styles apply", () => {
it("uses one presentation path for active special pages and inactive ordinary tabs", () => {
expect(groupTabBarSource).toContain("return !specialPageActive.value && tab.id === props.activeTabId;");
expect(groupTabBarSource).toContain("specialPageTabClass(!!specialPageTabs?.settingsActive)");
expect(groupTabBarSource).toContain("specialPageTabClass(!!specialPageTabs?.driverStoreActive)");
expect(groupTabBarSource).toContain('return active ? { boxShadow: "inset 0 -2px 0 var(--ring)" } : undefined;');
expect(groupTabBarSource).toContain('import "./appTabBar.css"');
expect(groupTabBarSource).toContain("app-tab-pill");
expect(groupTabBarSource).toContain("app-tab-scrollbar");
expect(groupTabBarSource).toContain("app-tab-bar");
});
it("keeps the dirty marker and dirty title styling", () => {
expect(groupTabBarSource).toContain("dirty-tab-marker");
expect(groupTabBarSource).toContain("dirtyTabTitleStyle");
expect(groupTabBarSource).toContain("createRenameDuplicateTabItems");
});
});
@@ -0,0 +1,171 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, reactive } from "vue";
import { createPinia, setActivePinia } from "pinia";
import { createI18n } from "vue-i18n";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/components/editor/QueryEditor.vue", () => ({ default: { render: () => null } }));
vi.mock("@/components/grid/DataGrid.vue", () => ({ default: { render: () => null } }));
vi.mock("@/lib/backend/debugLog", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/backend/debugLog")>()),
isDebugLoggingEnabled: () => true,
appendDebugLog: vi.fn(),
}));
import ContentArea from "../ContentArea.vue";
import { appendDebugLog } from "@/lib/backend/debugLog";
import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
import type { ConnectionConfig, QueryTab } from "@/types/database";
// Keep the real setup/watch lifecycle and props, without mounting editor/grid UI.
const PreloadSurface = { ...ContentArea, render: () => null };
const cleanups: Array<() => void> = [];
const result = { columns: ["id"], rows: [], affected_rows: 0, execution_time_ms: 1 };
function queryTab(overrides: Partial<QueryTab> = {}): QueryTab {
return { id: "query", title: "Query", connectionId: "mysql", database: "app", mode: "query", sql: "", isExecuting: false, ...overrides };
}
function sourceTab(): QueryTab {
return queryTab({ id: "source", objectSource: { name: "view_users", objectType: "VIEW" }, sql: "CREATE VIEW view_users AS SELECT 1" });
}
function readOnlySourceTab(): QueryTab {
return queryTab({ id: "readonly-source", sourceView: true, sql: "CREATE SEQUENCE seq_users" });
}
function previewTab(): QueryTab {
return queryTab({ id: "preview", connectionId: "preview-connection" });
}
function connection(id: string, name: string): ConnectionConfig {
return { id, name, db_type: "mysql", host: "localhost", port: 3306, username: "", password: "" };
}
function mountSurface(initialTab: QueryTab | (() => QueryTab)) {
const pinia = createPinia();
setActivePinia(pinia);
const connectionStore = useConnectionStore();
connectionStore.connections = [connection("mysql", "Local MySQL"), connection("preview-connection", "[Preview] Imported SQL")];
const state = reactive({ activeTab: typeof initialTab === "function" ? initialTab() : initialTab });
const root = defineComponent({
setup: () => () =>
h(PreloadSurface, {
activeTab: state.activeTab,
activeConnection: connectionStore.getConfig(state.activeTab.connectionId),
activeOutputView: "result",
executableSql: "",
formatSqlRequest: null,
compressSqlRequest: null,
selectedSql: "",
cursorPos: 0,
blockDangerousRedisCommands: false,
editorOnly: true,
}),
});
const host = document.createElement("div");
document.body.appendChild(host);
const app = createApp(root);
app.use(pinia);
app.use(createI18n({ legacy: false, locale: "en", messages: { en: {} }, missingWarn: false, fallbackWarn: false }));
app.mount(host);
cleanups.push(() => {
app.unmount();
host.remove();
});
return { state, connectionStore };
}
function loadStarts(): number {
return vi.mocked(appendDebugLog).mock.calls.filter((call) => call[1] === "[DBX][DataGrid:load:start]").length;
}
describe("ContentArea grid preload behavior", () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
});
afterEach(async () => {
for (const cleanup of cleanups.splice(0)) cleanup();
await nextTick();
});
it.each(["query", "data"] as const)("preloads for an ordinary empty %s tab", async (mode) => {
mountSurface(queryTab({ mode }));
await nextTick();
expect(loadStarts()).toBe(1);
});
it.each([
["object source", sourceTab],
["read-only source", readOnlySourceTab],
["Preview", previewTab],
] as const)("does not preload an idle %s query tab", async (_, createTab) => {
mountSurface(createTab());
await nextTick();
expect(loadStarts()).toBe(0);
});
for (const [label, createTab] of [
["object source", sourceTab],
["read-only source", readOnlySourceTab],
["Preview", previewTab],
] as const) {
it.each(["isExecuting", "isExplaining"] as const)(`preloads when an idle ${label} tab starts %s`, async (flag) => {
const { state } = mountSurface(createTab());
expect(loadStarts()).toBe(0);
state.activeTab[flag] = true;
await nextTick();
expect(loadStarts()).toBe(1);
state.activeTab[flag] = false;
state.activeTab.result = result;
await nextTick();
expect(loadStarts()).toBe(1);
});
it(`preloads an existing result on an otherwise idle ${label} tab`, async () => {
mountSurface({ ...createTab(), result });
await nextTick();
expect(loadStarts()).toBe(1);
});
it(`re-evaluates when a ${label} tab is replaced with an ordinary query of the same mode`, async () => {
const { state } = mountSurface(createTab());
expect(loadStarts()).toBe(0);
state.activeTab = queryTab();
await nextTick();
expect(loadStarts()).toBe(1);
});
}
it("does not preload a read-only source created by the store or enable source saving", async () => {
const { state } = mountSurface(() => {
const store = useQueryStore();
const id = store.createTab("mysql", "app", "Source - seq_users", "query", "app", "CREATE SEQUENCE seq_users", undefined, { forceNew: true, sourceView: true });
return store.tabs.find((tab) => tab.id === id)!;
});
await nextTick();
expect(state.activeTab.objectSource).toBeUndefined();
expect(loadStarts()).toBe(0);
state.activeTab.isExecuting = true;
await nextTick();
expect(loadStarts()).toBe(1);
});
it("re-evaluates source metadata changes without changing the tab mode or id", async () => {
const { state } = mountSurface(sourceTab());
expect(loadStarts()).toBe(0);
state.activeTab.objectSource = undefined;
await nextTick();
expect(loadStarts()).toBe(1);
});
it("uses the canonical Preview connection name and reacts when it becomes an ordinary connection", async () => {
const { connectionStore } = mountSurface(previewTab());
expect(loadStarts()).toBe(0);
connectionStore.connections[1]!.name = "Local MySQL";
await nextTick();
expect(loadStarts()).toBe(1);
});
});
@@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createPinia, setActivePinia } from "pinia";
import { createI18n } from "vue-i18n";
import { createApp, nextTick } from "vue";
import { createApp, nextTick, reactive } from "vue";
import EditorGroupTabBar from "../EditorGroupTabBar.vue";
import { useQueryStore } from "@/stores/queryStore";
import { useSettingsStore } from "@/stores/settingsStore";
@@ -460,3 +460,131 @@ describe("EditorGroupTabBar group behavior", () => {
host.remove();
});
});
describe("EditorGroupTabBar special page navigation", () => {
beforeEach(() => {
document.body.innerHTML = "";
vi.restoreAllMocks();
setActivePinia(createPinia());
});
function mountSpecialBar(extraProps: Record<string, unknown> = {}) {
const pinia = createPinia();
setActivePinia(pinia);
const store = useQueryStore();
const settings = useSettingsStore();
settings.editorSettings.tabGroupMode = "connection";
const tabId = store.createTab("pg-1", "app", "Query", "query");
const specialPageTabs = reactive({ settingsOpen: true, settingsActive: true, driverStoreOpen: true, driverStoreActive: false, driverUpdateCount: 105 });
const events: string[] = [];
const mounted = mountBar(store.focusedGroupId, store.tabs.slice(), tabId, pinia, {
specialPageTabs,
"onActivate-settings": () => events.push("activate-settings"),
"onActivate-driver-store": () => events.push("activate-driver-store"),
"onClose-settings": () => events.push("close-settings"),
"onClose-driver-store": () => events.push("close-driver-store"),
...extraProps,
});
return { ...mounted, store, settings, tabId, specialPageTabs, events };
}
it.each(["classic", "separated"] as const)("keeps grouped tabs and both special pages at every placement in %s layout", async (layout) => {
const { app, host, settings, store, tabId } = mountSpecialBar();
settings.editorSettings.appLayout = layout;
for (const placement of ["top", "bottom", "left", "right"] as const) {
settings.editorSettings.tabPlacement = placement;
await settle();
expect(host.querySelectorAll(".tab-group-header")).toHaveLength(1);
expect(host.querySelectorAll("[data-settings-page-tab]")).toHaveLength(1);
expect(host.querySelectorAll("[data-driver-store-tab]")).toHaveLength(1);
expect(host.querySelector(`[data-tab-id="${tabId}"]`)?.getAttribute("data-active-tab")).toBe("false");
expect(host.querySelector(".tab-group-header--active")).toBeNull();
expect(store.activeTabId).toBe(tabId);
const vertical = placement === "left" || placement === "right";
expect(host.querySelector(".app-tab-bar")?.classList.contains("vertical-tab-layout")).toBe(vertical);
const special = host.querySelector<HTMLElement>("[data-settings-page-tab]")!;
expect(special.classList.contains("h-8")).toBe(vertical);
expect(special.style.boxShadow).toBe(vertical ? "" : layout === "classic" ? "inset 0 -2px 0 var(--ring)" : "");
}
app.unmount();
host.remove();
});
it.each(["left", "right"] as const)("keeps collapsed %s special tabs accessible and closable without labels or badges", async (placement) => {
const { app, host, settings, events } = mountSpecialBar({ tabBarCollapsed: true });
settings.editorSettings.tabPlacement = placement;
await settle();
for (const [selector, action] of [
["[data-settings-page-tab]", "settings"],
["[data-driver-store-tab]", "driver-store"],
]) {
const tab = host.querySelector<HTMLElement>(selector!)!;
expect(tab.getAttribute("aria-label")).toBe(tab.title);
expect(tab.getAttribute("tabindex")).toBe("0");
expect(tab.textContent?.trim()).toBe("");
expect(tab.querySelector("button")).toBeNull();
tab.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
tab.dispatchEvent(new KeyboardEvent("keydown", { key: " ", bubbles: true, cancelable: true }));
tab.dispatchEvent(new MouseEvent("mousedown", { button: 1, bubbles: true, cancelable: true }));
expect(events).toEqual(expect.arrayContaining([`activate-${action}`, `close-${action}`]));
expect(events.filter((event) => event === `activate-${action}`)).toHaveLength(2);
}
app.unmount();
host.remove();
});
it.each(["classic", "separated"] as const)("lets side-tab wrappers size to their rows while preserving the %s horizontal layout", async (layout) => {
const { app, host, settings, tabId } = mountSpecialBar();
settings.editorSettings.appLayout = layout;
for (const placement of ["top", "left", "bottom", "right", "top"] as const) {
settings.editorSettings.tabPlacement = placement;
await settle();
const tab = host.querySelector<HTMLElement>(`[data-tab-id="${tabId}"]`)!;
const wrapper = tab.closest<HTMLElement>(".app-tab-scroll > div")!;
expect(wrapper).not.toBeNull();
expect(wrapper).not.toBe(tab);
const horizontal = placement === "top" || placement === "bottom";
expect(wrapper.classList.contains("h-full")).toBe(layout === "classic" && horizontal);
}
app.unmount();
host.remove();
});
it("restores normal activation without resetting a collapsed semantic group", async () => {
const { app, host, specialPageTabs, tabId, store } = mountSpecialBar();
await settle();
const header = host.querySelector<HTMLButtonElement>(".tab-group-header")!;
header.click();
await settle();
expect(header.getAttribute("aria-expanded")).toBe("false");
specialPageTabs.settingsActive = false;
await settle();
expect(header.getAttribute("aria-expanded")).toBe("false");
expect(store.activeTabId).toBe(tabId);
header.click();
await settle();
expect(host.querySelector(`[data-tab-id="${tabId}"]`)?.getAttribute("data-active-tab")).toBe("true");
expect(host.querySelector(".tab-group-header--active")).not.toBeNull();
app.unmount();
host.remove();
});
it("preserves special-tab context menu close scope and prevents close-button activation", async () => {
const { app, host, events, store, tabId } = mountSpecialBar();
await settle();
const tab = host.querySelector<HTMLElement>("[data-settings-page-tab]")!;
tab.querySelector<HTMLButtonElement>("button")!.click();
expect(events).toEqual(["close-settings"]);
tab.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 10, clientY: 10 }));
await settle();
const menu = document.body.querySelector<HTMLElement>("[data-dbx-context-menu]")!;
const closeOther = Array.from(menu.querySelectorAll<HTMLButtonElement>("button")).find((button) => button.textContent?.includes("Close other tabs"))!;
expect(closeOther).toBeDefined();
closeOther.click();
await settle();
expect(events).toEqual(["close-settings", "close-driver-store"]);
expect(store.tabs.map((item) => item.id)).toEqual([tabId]);
app.unmount();
host.remove();
});
});
@@ -78,8 +78,9 @@ vi.mock("@/components/layout/QueryResultSurface.vue", () => ({
import SqlEditorWorkspace from "../SqlEditorWorkspace.vue";
import { useQueryStore } from "@/stores/queryStore";
import type { QueryTab } from "@/types/database";
function tab(id: string) {
function tab(id: string): QueryTab {
return {
id,
title: id,
@@ -87,10 +88,12 @@ function tab(id: string) {
database: "db",
sql: "SELECT 1",
mode: "query",
} as const;
isExecuting: false,
result: { columns: ["id"], rows: [], affected_rows: 0, execution_time_ms: 1 },
};
}
function dataTab(id: string) {
function dataTab(id: string): QueryTab {
return { ...tab(id), mode: "data" };
}
@@ -104,8 +107,42 @@ describe("SqlEditorWorkspace mount contract", () => {
let pinia: ReturnType<typeof createPinia>;
let i18n: ReturnType<typeof createI18n>;
function mountOutputWorkspace(tabs: QueryTab[]) {
const store = useQueryStore();
store.tabs = tabs;
store.activeTabId = tabs[0]!.id;
store.groups = [{ id: "g1", tabIds: tabs.map((item) => item.id), activeTabId: tabs[0]!.id }];
store.focusedGroupId = "g1";
store.sizes = [100];
const host = createHost();
const app = createApp(SqlEditorWorkspace, {
activeTab: tabs[0]!,
activeConnection: undefined,
executableSql: "SELECT 1",
activeOutputView: "result",
formatSqlRequest: null,
compressSqlRequest: null,
selectedSql: "",
cursorPos: 0,
blockDangerousRedisCommands: false,
});
app.use(pinia);
app.use(i18n);
const vm = app.mount(host) as unknown as { toggleResultsPane: () => boolean };
return {
store,
host,
vm,
cleanup: () => {
app.unmount();
host.remove();
},
};
}
beforeEach(() => {
document.body.innerHTML = "";
localStorage.removeItem("dbx-shared-results-pane-size");
editorPreviewCalls.length = 0;
editorFocusCalls.length = 0;
groupHandleModRCalls.length = 0;
@@ -121,6 +158,89 @@ describe("SqlEditorWorkspace mount contract", () => {
});
});
it("gives an idle query the full editor pane without a result surface or re-show button", async () => {
const { host, vm, cleanup } = mountOutputWorkspace([{ ...tab("idle"), result: undefined }]);
await nextTick();
expect(host.querySelector("[data-shared-result-surface]")).toBeNull();
expect(host.querySelector('[aria-label="editor.showResultsPane"]')).toBeNull();
expect(host.querySelector(".result-pane-collapsed")).not.toBeNull();
expect(host.querySelector(".pane-stub")?.getAttribute("size")).toBe("100");
expect(vm.toggleResultsPane()).toBe(false);
cleanup();
});
it("shows arriving results and hides or restores them within a single render tick", async () => {
const { store, host, vm, cleanup } = mountOutputWorkspace([{ ...tab("idle"), result: undefined }]);
await nextTick();
expect(host.querySelector("[data-shared-result-surface]")).toBeNull();
store.tabs[0]!.result = tab("result").result;
await nextTick();
expect(host.querySelector('[data-test="result-surface"]')).not.toBeNull();
expect(host.querySelector(".pane-stub")?.getAttribute("size")).toBe("68");
expect(host.querySelector('[class*="result-surface-enter"]')).toBeNull();
expect(vm.toggleResultsPane()).toBe(true);
await nextTick();
expect(host.querySelector("[data-shared-result-surface]")).toBeNull();
expect(host.querySelector(".pane-stub")?.getAttribute("size")).toBe("100");
expect(vm.toggleResultsPane()).toBe(true);
await nextTick();
expect(host.querySelector('[data-test="result-surface"]')).not.toBeNull();
expect(host.querySelector(".pane-stub")?.getAttribute("size")).toBe("68");
expect(host.querySelector('[class*="result-surface-enter"]')).toBeNull();
cleanup();
});
it.each(["isExecuting", "isExplaining"] as const)("opens and reopens output on %s and removes it after the last output is cleared", async (runningKey) => {
const { store, host, vm, cleanup } = mountOutputWorkspace([{ ...tab("idle"), result: undefined }]);
const active = store.tabs[0]!;
active[runningKey] = true;
await nextTick();
expect(host.querySelector("[data-shared-result-surface]")).not.toBeNull();
expect(host.querySelector(".pane-stub")?.getAttribute("size")).toBe("68");
active.result = tab("result").result;
active[runningKey] = false;
await nextTick();
expect(vm.toggleResultsPane()).toBe(true);
await nextTick();
expect(host.querySelector("[data-shared-result-surface]")).toBeNull();
expect(host.querySelector('[aria-label="editor.showResultsPane"]')).not.toBeNull();
active[runningKey] = true;
await nextTick();
expect(host.querySelector("[data-shared-result-surface]")).not.toBeNull();
active[runningKey] = false;
active.result = undefined;
await nextTick();
expect(host.querySelector("[data-shared-result-surface]")).toBeNull();
expect(host.querySelector('[aria-label="editor.showResultsPane"]')).toBeNull();
expect(host.querySelector(".pane-stub")?.getAttribute("size")).toBe("100");
cleanup();
});
it("preserves the saved result height across result and idle query tabs", async () => {
localStorage.setItem("dbx-shared-results-pane-size", "45");
const { store, host, cleanup } = mountOutputWorkspace([tab("result"), { ...tab("idle"), result: undefined }]);
await nextTick();
expect(host.querySelector(".pane-stub")?.getAttribute("size")).toBe("55");
store.activeTabId = "idle";
await nextTick();
expect(host.querySelector("[data-shared-result-surface]")).toBeNull();
expect(host.querySelector(".pane-stub")?.getAttribute("size")).toBe("100");
store.activeTabId = "result";
await nextTick();
expect(host.querySelector("[data-shared-result-surface]")).not.toBeNull();
expect(host.querySelector(".pane-stub")?.getAttribute("size")).toBe("55");
cleanup();
});
it("renders one editor group per store group and binds the shared result to the global active tab", async () => {
const store = useQueryStore();
store.tabs = [tab("tab-a"), tab("tab-b")];
@@ -232,9 +352,6 @@ describe("SqlEditorWorkspace mount contract", () => {
vm.toggleResultsPane();
await nextTick();
// Wait out the leave transition frame before asserting the unmount.
await new Promise((resolve) => setTimeout(resolve, 30));
expect(host.querySelector('[data-test="result-surface"]')).toBeNull();
app.unmount();
@@ -341,10 +458,6 @@ describe("SqlEditorWorkspace mount contract", () => {
store.activeTabId = "tab-data";
await nextTick();
// The leave transition keeps the surface in the DOM for one more frame
// before unmounting it (CSS durations collapse to zero in the test env).
await new Promise((resolve) => setTimeout(resolve, 30));
expect(host.querySelector("[data-shared-result-surface]")).toBeNull();
// Outer editor pane + always-mounted result pane (collapsed to size 0)
// + one pane per group.
@@ -97,12 +97,12 @@ describe("SQL editor workspace single-group tracer bullet", () => {
// Collapsing unmounts the shared surface, so the mouse re-show affordance
// must live at the workspace level, not inside the collapsible pane.
expect(workspaceSource).toContain("editor.showResultsPane");
expect(workspaceSource).toContain("showSharedResult && !showResultPane");
expect(workspaceSource).toContain("hasSharedOutput && !showResultPane");
// Running a query re-expands a collapsed pane (issue #6193 promise).
expect(workspaceSource).toContain("if (isExecuting) showResultPane.value = true;");
expect(workspaceSource).toContain("if (isExecuting || isExplaining) showResultPane.value = true;");
});
it("animates layout transitions for split groups and the shared result surface", () => {
it("shows results without transitions while retaining new editor-group animation", () => {
const workspaceCssSource = readFileSync(new URL("../sqlEditorWorkspace.css", import.meta.url), "utf8");
expect(workspaceSource).toContain('import "./sqlEditorWorkspace.css"');
// Split-created group panes emerge from the divider side; hydration-gated
@@ -110,14 +110,15 @@ describe("SQL editor workspace single-group tracer bullet", () => {
expect(workspaceSource).toContain("paneEnterClass");
expect(workspaceSource).toContain("workspace-pane-enter");
expect(workspaceCssSource).toContain("@keyframes dbx-workspace-pane-in");
// The result pane stays mounted and collapses via its size; the surface
// fades through a Vue transition while the splitter hides.
// A completed response must not wait for a fade or pane-size transition.
expect(workspaceSource).toContain("resultPaneTargetSize");
expect(workspaceSource).toContain('name="result-surface"');
expect(workspaceSource).not.toContain("<Transition");
expect(workspaceSource).toContain("result-pane-collapsed");
expect(workspaceCssSource).toContain(".result-surface-enter-active");
expect(workspaceCssSource).toContain(".result-surface-leave-active");
// Motion degrades to instant under prefers-reduced-motion.
expect(workspaceCssSource).not.toContain(".result-surface-enter-active");
expect(workspaceCssSource).not.toContain(".result-surface-leave-active");
expect(workspaceCssSource).toMatch(/\.sql-editor-workspace-split\.splitpanes--ready\s*>\s*\.splitpanes__pane\s*\{\s*transition:\s*none !important;/);
expect(workspaceCssSource).toMatch(/\.sql-editor-workspace-split\s*>\s*\.splitpanes__splitter\s*\{\s*transition:\s*none;/);
// Group animation still respects reduced motion.
expect(workspaceCssSource).toContain("@media (prefers-reduced-motion: reduce)");
});
});
@@ -11,8 +11,7 @@ const appSource = readFileSync(new URL("../../../App.vue", import.meta.url), "ut
describe("App main content surface structure", () => {
it("hides only the editor workspace via the surface guard, never the special pages", () => {
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.
// The empty-state slot shares the query surface's visibility guard.
expect(appSource.split(guard).length - 1).toBe(1);
// Source order: special surfaces first, then the guarded editor wrapper.
@@ -22,9 +21,11 @@ describe("App main content surface structure", () => {
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('v-else-if="queryStore.tabs.length === 0 && !driverStoreActive && !settingsStore.settingsPageActive"');
it("keeps navigation independent from the workspace welcome content", () => {
expect(appSource).toContain(':show-tab-navigation="queryStore.tabs.length > 0 || settingsPageTabOpen || driverStoreTabOpen"');
expect(appSource).toContain(':active-tab="activeTab ?? undefined"');
expect(appSource).toMatch(/<template #empty>\s*<WelcomeScreen/);
expect(appSource).not.toContain('v-if="queryStore.tabs.length > 0 || settingsPageTabOpen || driverStoreTabOpen"');
});
it("anchors the drag-back hit test on every pane strip and the special-surfaces bar", () => {
@@ -32,8 +33,8 @@ describe("App main content surface structure", () => {
const slimBarSource = readFileSync(new URL("../AppTabBar.vue", import.meta.url), "utf8");
expect(groupBarSource).toContain("data-main-tab-bar");
expect(groupBarSource).toContain("'ring-2 ring-primary ring-inset': detachedDropTarget");
expect(slimBarSource).toContain("data-main-tab-bar");
expect(slimBarSource).toContain("'ring-2 ring-primary ring-inset': detachedDropTarget");
expect(slimBarSource).toContain("data-special-page-tab-target");
expect(slimBarSource).not.toContain("data-return-tab");
// The split workspace renders several bars; the hit test must union their rects.
expect(appSource).toContain('querySelectorAll<HTMLElement>("[data-main-tab-bar]")');
});
@@ -4,38 +4,19 @@ import { describe, expect, it } from "vitest";
const appSource = readFileSync(new URL("../../../App.vue", import.meta.url), "utf8");
const tabBarSource = readFileSync(new URL("../AppTabBar.vue", import.meta.url), "utf8");
function sourceBetween(source: string, start: string, end: string): string {
const startIndex = source.indexOf(start);
const endIndex = source.indexOf(end, startIndex + start.length);
expect(startIndex).toBeGreaterThanOrEqual(0);
expect(endIndex).toBeGreaterThan(startIndex);
return source.slice(startIndex, endIndex);
}
// Regression test for #8213: AppTabBar's settings/driver special-page strip is
// still a horizontal-only "w-full" bar. Placing it inside a flex-row /
// flex-row-reverse workspace (left/right column tab placement) squeezes the
// sibling content pane (EditorSettingsPage/DriverStorePage) to zero width,
// making the settings/driver page invisible after it's reopened. The fix
// falls back to a flex-col workspace while either special page is active.
describe("tabWorkspaceLayoutClass column-mode special-page fallback", () => {
const computedBlock = sourceBetween(appSource, "const tabWorkspaceLayoutClass = computed(() => {", "});");
it("falls back to flex-col while the settings/driver strip is active, before the vertical branch", () => {
const specialPageGuardIndex = computedBlock.indexOf("isSpecialPageStripActive.value");
const verticalRowIndex = computedBlock.indexOf('isVerticalTabPlacement.value ? "flex-row" : "flex-col"');
expect(specialPageGuardIndex).toBeGreaterThanOrEqual(0);
expect(verticalRowIndex).toBeGreaterThan(specialPageGuardIndex);
expect(computedBlock).toMatch(/isVerticalTabPlacement\.value\s*&&\s*isSpecialPageStripActive\.value\)\s*return\s*"flex-col"/);
// Special pages use the global tab-bar portal so their navigation remains
// separate from the editor groups in every tab-placement mode.
describe("special-page workspace portal layout", () => {
it("provides the portal from App and renders the special-page workspace", () => {
expect(appSource).toContain("provide(GROUP_TAB_BAR_PORTAL");
expect(tabBarSource).toContain('v-show="driverStoreActive || settingsPageActive"');
expect(tabBarSource).toContain("data-special-page-workspace");
});
it("derives isSpecialPageStripActive from the same flags AppTabBar uses to render", () => {
expect(appSource).toContain("const isSpecialPageStripActive = computed(() => driverStoreActive.value || settingsStore.settingsPageActive);");
// AppTabBar itself only renders while one of these is true - keep the guard in sync with it.
expect(tabBarSource).toContain('v-if="driverStoreActive || settingsPageActive"');
});
it("still stretches the special-page strip full width (the part the flex-col fallback protects against)", () => {
expect(tabBarSource).toMatch(/class="app-tab-bar[^"]*\bw-full\b/);
it("keeps the special-page navigation and content panes independently sized", () => {
expect(tabBarSource).toContain("data-special-page-navigation");
expect(tabBarSource).toContain("data-special-page-content");
expect(tabBarSource).toContain(':class="layoutClass"');
expect(tabBarSource).toContain("flex-1 flex-col overflow-hidden");
});
});
@@ -360,11 +360,17 @@
}
.vertical-tab-layout .app-tab-strip {
display: flex;
flex-direction: column;
height: auto;
min-height: 0;
width: 100%;
}
.vertical-tab-layout .app-tab-scroll {
min-height: 0;
}
.vertical-tab-layout .app-tab-pill {
width: 100%;
min-width: 0 !important;
@@ -0,0 +1,13 @@
import { shallowReactive, type InjectionKey, type Ref } from "vue";
export interface GroupTabBarPortal {
active: Readonly<Ref<boolean>>;
targets: Map<string, HTMLElement>;
}
export const GROUP_TAB_BAR_PORTAL: InjectionKey<GroupTabBarPortal> = Symbol("dbx:group-tab-bar-portal");
// Moving the existing bars preserves their grouping, search and scroll state.
export function createGroupTabBarPortal(active: Readonly<Ref<boolean>>): GroupTabBarPortal {
return { active, targets: shallowReactive(new Map<string, HTMLElement>()) };
}
@@ -1,30 +1,15 @@
/*
* Motion for layout transitions inside the SQL editor workspace.
*
* Pane SIZE changes (split, result-pane collapse/expand) are smoothed by
* splitpanes' stock pane transition, which globals.css kills for horizontal
* splitpanes — re-enabled here for this workspace only (drag still disables
* it via the higher-specificity rule below). The entrance effects cover the
* content that would otherwise pop in: groups created by a split, and the
* shared result surface re-mounting on a query tab.
*
* Product motion rules: transform/opacity only, ease-out-quart, 150-250ms,
* no load choreography (groups present at first render never animate), and
* everything degrades to instant under prefers-reduced-motion.
* New editor groups animate when split. Query results and their pane sizes
* update immediately, including when restoring a collapsed result pane.
*/
.sql-editor-workspace {
--sql-workspace-ease-out: cubic-bezier(0.25, 1, 0.5, 1);
}
/* Pane size transitions: only the outer editor/result split animates pane
sizes (the result-pane collapse/expand glide). Group panes never animate
their sizes — splits use the entrance keyframes below — so they stay on
stock splitpanes behavior (transition, disabled while dragging). */
/* Override splitpanes' stock transition so ready results are not clipped
while the result pane expands. */
.sql-editor-workspace .sql-editor-workspace-split.splitpanes--ready > .splitpanes__pane {
transition: width 0.2s ease-out, height 0.2s ease-out !important;
}
.sql-editor-workspace .sql-editor-workspace-split.splitpanes--ready.splitpanes--dragging > .splitpanes__pane {
transition: none !important;
}
@@ -47,28 +32,12 @@
}
}
/* Shared result surface: rises into place while its pane expands, and fades
out faster than the pane collapses. */
.sql-editor-workspace .result-surface-enter-active {
transition: opacity 180ms var(--sql-workspace-ease-out), transform 180ms var(--sql-workspace-ease-out);
}
.sql-editor-workspace .result-surface-enter-from {
opacity: 0;
transform: translateY(10px);
}
.sql-editor-workspace .result-surface-leave-active {
transition: opacity 120ms var(--sql-workspace-ease-out);
}
.sql-editor-workspace .result-surface-leave-to {
opacity: 0;
}
/* The splitter between the editor and result panes stays in the splitpanes
flow even when the result pane is collapsed; fade it out and disable it so
flow even when the result pane is collapsed; hide it and disable it so
the closed state is visually clean and cannot be dragged open into an
empty pane. */
.sql-editor-workspace-split > .splitpanes__splitter {
transition: opacity 150ms var(--sql-workspace-ease-out);
transition: none;
}
.sql-editor-workspace-split.result-pane-collapsed > .splitpanes__splitter {
opacity: 0;
@@ -79,11 +48,4 @@
.sql-editor-workspace .workspace-pane-enter {
animation: none;
}
.sql-editor-workspace .result-surface-enter-active,
.sql-editor-workspace .result-surface-leave-active {
transition: none;
}
.sql-editor-workspace .sql-editor-workspace-split.splitpanes--ready > .splitpanes__pane {
transition: none !important;
}
}
@@ -2458,7 +2458,7 @@ function openObjectSourceDialog(initialEditing: boolean, viewPackageBody = false
},
});
} else {
queryStore.createTab(connectionId, database, `Source - ${node.label}`, "query", schema, editableSource, node.catalog, { forceNew: true });
queryStore.createTab(connectionId, database, `Source - ${node.label}`, "query", schema, editableSource, node.catalog, { forceNew: true, sourceView: true });
}
})
.catch((e: any) => {