fix(desktop): unify grouped tab bar portal across special pages
This commit is contained in:
27 files changed
+1352
-590
No files matched your search
+51
-55
@@ -5,6 +5,7 @@ import { ChevronsRight, FileText } from "@lucide/vue";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import AppToolbar from "@/components/layout/AppToolbar.vue";
|
||||
import AppTabBar from "@/components/layout/AppTabBar.vue";
|
||||
import { createGroupTabBarPortal, GROUP_TAB_BAR_PORTAL } from "@/components/layout/groupTabBarPortal";
|
||||
import AppSidebar from "@/components/layout/AppSidebar.vue";
|
||||
import SqlEditorWorkspace from "@/components/layout/SqlEditorWorkspace.vue";
|
||||
import { EDITOR_TOOLBAR_ACTIONS } from "@/components/layout/editorToolbarActions";
|
||||
@@ -790,6 +791,7 @@ const specialPageTabs = computed(() => ({
|
||||
driverStoreActive: driverStoreActive.value,
|
||||
driverUpdateCount: toolbarAgentDriverUpdateCount.value,
|
||||
}));
|
||||
provide(GROUP_TAB_BAR_PORTAL, createGroupTabBarPortal(computed(() => !isDetachedWindowContext && (driverStoreActive.value || settingsStore.settingsPageActive))));
|
||||
provide(EDITOR_TOOLBAR_ACTIONS, {
|
||||
explainMode,
|
||||
blockDangerousRedisCommands,
|
||||
@@ -910,19 +912,6 @@ useScheduledDatabaseBackups({ scheduler: true });
|
||||
|
||||
const appVersion = ref("");
|
||||
const isClassicLayout = computed(() => settingsStore.editorSettings.appLayout === "classic");
|
||||
const isVerticalTabPlacement = computed(() => settingsStore.editorSettings.tabPlacement === "left" || settingsStore.editorSettings.tabPlacement === "right");
|
||||
// AppTabBar only renders the settings/driver special-page strip, and that strip
|
||||
// is still a horizontal-only "w-full" bar (see AppTabBar.vue's app-tab-bar class).
|
||||
// Placing it inside a flex-row/flex-row-reverse workspace squeezes the sibling
|
||||
// content pane (EditorSettingsPage/DriverStorePage) to zero width, so fall back
|
||||
// to stacking it above the content instead of adopting the vertical layout here.
|
||||
const isSpecialPageStripActive = computed(() => driverStoreActive.value || settingsStore.settingsPageActive);
|
||||
const tabWorkspaceLayoutClass = computed(() => {
|
||||
if (settingsStore.editorSettings.tabPlacement === "bottom") return "flex-col-reverse";
|
||||
if (isVerticalTabPlacement.value && isSpecialPageStripActive.value) return "flex-col";
|
||||
if (settingsStore.editorSettings.tabPlacement === "right") return "flex-row-reverse";
|
||||
return isVerticalTabPlacement.value ? "flex-row" : "flex-col";
|
||||
});
|
||||
|
||||
// Every pane's vertical strip writes back to this shared width/collapse state.
|
||||
function startTabBarResize(event: MouseEvent) {
|
||||
@@ -2474,7 +2463,7 @@ async function onOpenObjectSource(table: SqlObjectNavigationTarget, initialEditi
|
||||
databaseType: resolvedDatabaseType,
|
||||
signature: navigation.signature,
|
||||
});
|
||||
const tabId = queryStore.createTab(target.connectionId, target.database, `Source - ${sourceName}`, "query", sourceSchema, editableSource, target.catalog, { forceNew: true });
|
||||
const tabId = queryStore.createTab(target.connectionId, target.database, `Source - ${sourceName}`, "query", sourceSchema, editableSource, target.catalog, { forceNew: true, sourceView: true });
|
||||
const sourceIsEditable = raw.editable !== false && !["SEQUENCE", "TRIGGER", "TYPE", "TYPE_BODY"].includes(resolvedType);
|
||||
if (sourceIsEditable) {
|
||||
queryStore.setObjectSource(tabId, {
|
||||
@@ -2854,7 +2843,7 @@ async function handleQuickOpenSelect(item: any) {
|
||||
databaseType,
|
||||
signature: item.signature,
|
||||
});
|
||||
const tabId = queryStore.createTab(item.connectionId, item.database, `Source - ${objectName}`);
|
||||
const tabId = queryStore.createTab(item.connectionId, item.database, `Source - ${objectName}`, "query", undefined, undefined, undefined, { sourceView: true });
|
||||
queryStore.updateSql(tabId, editableSource);
|
||||
if (item.type !== "sequence" && item.type !== "trigger" && item.type !== "event" && item.type !== "type" && item.type !== "type-body") {
|
||||
queryStore.setObjectSource(tabId, {
|
||||
@@ -3453,7 +3442,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<div v-show="!isAiPanelMaximized || isZenMode" :class="isDetachedWindowContext ? 'flex-1 min-w-0 overflow-hidden bg-background' : isClassicLayout ? 'flex-1 min-w-0 overflow-hidden' : 'flex-1 min-w-0 overflow-hidden rounded-md border border-border/80 bg-background'">
|
||||
<div class="h-full flex min-w-0" :class="isDetachedWindowContext ? 'flex-col' : tabWorkspaceLayoutClass">
|
||||
<div class="h-full flex min-h-0 min-w-0 flex-col">
|
||||
<AppTabBar
|
||||
v-if="!isDetachedWindowContext"
|
||||
ref="appTabBarRef"
|
||||
@@ -3464,6 +3453,8 @@ onUnmounted(() => {
|
||||
:agent-driver-update-count="toolbarAgentDriverUpdateCount"
|
||||
:detached-drop-target="detachedDropTargetTabId !== null"
|
||||
:can-detach-tabs="isDesktop"
|
||||
:tab-bar-width="tabBarWidth"
|
||||
:tab-bar-collapsed="tabBarCollapsed"
|
||||
@activate-driver-store="openDriverStorePage"
|
||||
@activate-settings-page="activateSettingsPage"
|
||||
@activate-tab="activateQueryTab"
|
||||
@@ -3475,7 +3466,26 @@ onUnmounted(() => {
|
||||
@discard-all-tab-close="handleDiscardAllPendingTabClose"
|
||||
@cancel-tab-close="cancelPendingAppClose"
|
||||
@detach-tab="detachTab"
|
||||
/>
|
||||
>
|
||||
<DriverStorePage v-if="driverStoreTabOpen" v-show="driverStoreActive" v-model:active-tab="driverStoreActiveTab" class="flex-1 min-h-0" :update-notifications-enabled="updateNotificationsEnabled" :focus-target="driverStoreFocus" @update-count-change="updateAgentDriverUpdateCount" />
|
||||
<EditorSettingsPage
|
||||
v-if="settingsPageTabOpen"
|
||||
v-show="settingsStore.settingsPageActive"
|
||||
variant="page"
|
||||
:open="settingsPageTabOpen"
|
||||
:initial-tab="settingsInitialTab"
|
||||
:initial-section="settingsInitialSection"
|
||||
:navigation-request-id="settingsNavigationRequestId"
|
||||
:ai-config-draft="settingsAiConfigDraft"
|
||||
:ai-config-request-id="settingsAiConfigRequestId"
|
||||
:app-version="appVersion"
|
||||
:checking-updates="checkingUpdates"
|
||||
class="flex-1 min-h-0"
|
||||
@update:open="(open: boolean) => (open ? activateSettingsPage() : closeSettingsPage())"
|
||||
@check-updates="checkUpdates()"
|
||||
@ai-config-deep-link-handled="settingsAiConfigDraft = null"
|
||||
/>
|
||||
</AppTabBar>
|
||||
<DetachedTabHeader
|
||||
v-else-if="activeTab"
|
||||
:title="activeTab.title"
|
||||
@@ -3499,33 +3509,16 @@ onUnmounted(() => {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<DriverStorePage v-if="driverStoreTabOpen" v-show="driverStoreActive" v-model:active-tab="driverStoreActiveTab" class="flex-1 min-h-0" :update-notifications-enabled="updateNotificationsEnabled" :focus-target="driverStoreFocus" @update-count-change="updateAgentDriverUpdateCount" />
|
||||
<EditorSettingsPage
|
||||
v-if="settingsPageTabOpen"
|
||||
v-show="settingsStore.settingsPageActive"
|
||||
variant="page"
|
||||
:open="settingsPageTabOpen"
|
||||
:initial-tab="settingsInitialTab"
|
||||
:initial-section="settingsInitialSection"
|
||||
:navigation-request-id="settingsNavigationRequestId"
|
||||
:ai-config-draft="settingsAiConfigDraft"
|
||||
:ai-config-request-id="settingsAiConfigRequestId"
|
||||
:app-version="appVersion"
|
||||
:checking-updates="checkingUpdates"
|
||||
class="flex-1 min-h-0"
|
||||
@update:open="(open: boolean) => (open ? activateSettingsPage() : closeSettingsPage())"
|
||||
@check-updates="checkUpdates()"
|
||||
@ai-config-deep-link-handled="settingsAiConfigDraft = null"
|
||||
/>
|
||||
<div v-if="queryStore.tabs.length > 0" v-show="!driverStoreActive && !settingsStore.settingsPageActive" class="flex flex-col flex-1 min-h-0">
|
||||
<div v-show="!driverStoreActive && !settingsStore.settingsPageActive" class="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<div class="flex flex-col flex-1 min-h-0">
|
||||
<SqlEditorWorkspace
|
||||
ref="contentAreaRef"
|
||||
@locate-tab="locateTabInSidebar"
|
||||
@toggle-zen-mode="toggleZenMode"
|
||||
@start-resize="startTabBarResize"
|
||||
@toggle-collapse="toggleTabBarCollapsed"
|
||||
:active-tab="activeTab!"
|
||||
:active-tab="activeTab ?? undefined"
|
||||
:show-tab-navigation="queryStore.tabs.length > 0 || settingsPageTabOpen || driverStoreTabOpen"
|
||||
:active-connection="activeConnection"
|
||||
:tab-bar-width="tabBarWidth"
|
||||
:tab-bar-collapsed="tabBarCollapsed"
|
||||
@@ -3624,24 +3617,27 @@ onUnmounted(() => {
|
||||
@structure-editor-close="(tabId: string) => queryStore.closeTab(tabId)"
|
||||
@open-settings="openSettings"
|
||||
@open-connection-settings="openConnectionSettings"
|
||||
/>
|
||||
>
|
||||
<template #empty>
|
||||
<WelcomeScreen
|
||||
class="h-full min-h-0"
|
||||
:connection-stats="connectionStats"
|
||||
:recent-connections="recentConnections"
|
||||
:saved-sql-history-items="savedSqlHistoryItems"
|
||||
:app-version="appVersion"
|
||||
:has-connections="connectionStore.connections.length > 0"
|
||||
@open-connection-query="openConnectionQuery"
|
||||
@open-saved-sql="openSavedSqlFromWelcome"
|
||||
@new-connection="showConnectionDialog = true"
|
||||
@new-query="newQuery"
|
||||
@show-history="openRightSidebarPanel('history')"
|
||||
@import-config="dialogs.onImportClick"
|
||||
@open-github="openGitHub"
|
||||
@open-mcp-guide="openMcpGuide"
|
||||
/>
|
||||
</template>
|
||||
</SqlEditorWorkspace>
|
||||
</div>
|
||||
<WelcomeScreen
|
||||
v-else-if="queryStore.tabs.length === 0 && !driverStoreActive && !settingsStore.settingsPageActive"
|
||||
:connection-stats="connectionStats"
|
||||
:recent-connections="recentConnections"
|
||||
:saved-sql-history-items="savedSqlHistoryItems"
|
||||
:app-version="appVersion"
|
||||
:has-connections="connectionStore.connections.length > 0"
|
||||
@open-connection-query="openConnectionQuery"
|
||||
@open-saved-sql="openSavedSqlFromWelcome"
|
||||
@new-connection="showConnectionDialog = true"
|
||||
@new-query="newQuery"
|
||||
@show-history="openRightSidebarPanel('history')"
|
||||
@import-config="dialogs.onImportClick"
|
||||
@open-github="openGitHub"
|
||||
@open-mcp-guide="openMcpGuide"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
+129
-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();
|
||||
});
|
||||
});
|
||||
+123
-10
@@ -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)");
|
||||
});
|
||||
});
|
||||
+8
-7
@@ -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]")');
|
||||
});
|
||||
|
||||
Vendored
+12
-31
@@ -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) => {
|
||||
|
||||
@@ -21,6 +21,17 @@ function roundTrip(tabs: QueryTab[]) {
|
||||
}
|
||||
|
||||
describe("openTabsPersistence originalSql round-trip", () => {
|
||||
it("preserves read-only source intent without adding editable source metadata", () => {
|
||||
const [restored] = roundTrip([queryTab({ sourceView: true, sql: "CREATE SEQUENCE seq_users" })]);
|
||||
expect(restored.sourceView).toBe(true);
|
||||
expect(restored.objectSource).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps legacy query tabs without source intent compatible", () => {
|
||||
const [restored] = roundTrip([queryTab({ sql: "SELECT 1" })]);
|
||||
expect(restored.sourceView).toBeUndefined();
|
||||
});
|
||||
|
||||
it("restores a clean prefilled query tab as clean (sql === originalSql)", () => {
|
||||
const sql = 'SELECT * FROM "public"."users"';
|
||||
const [restored] = roundTrip([queryTab({ sql, originalSql: sql })]);
|
||||
|
||||
@@ -52,6 +52,7 @@ export interface SavedOpenTab {
|
||||
structureTableName?: string;
|
||||
objectBrowser?: QueryTab["objectBrowser"];
|
||||
objectSource?: QueryTab["objectSource"];
|
||||
sourceView?: boolean;
|
||||
tableMeta?: QueryTab["tableMeta"];
|
||||
mongoEditTarget?: QueryTab["mongoEditTarget"];
|
||||
resultEvicted?: boolean;
|
||||
@@ -146,6 +147,7 @@ export function serializeOpenTabs(tabs: QueryTab[]): SavedOpenTab[] {
|
||||
...(tab.structureTableName !== undefined ? { structureTableName: tab.structureTableName } : {}),
|
||||
objectBrowser: tab.objectBrowser,
|
||||
objectSource: tab.objectSource,
|
||||
...(tab.sourceView ? { sourceView: true } : {}),
|
||||
tableMeta: tab.tableMeta,
|
||||
...(tab.mongoEditTarget !== undefined ? { mongoEditTarget: tab.mongoEditTarget } : {}),
|
||||
...(tab.mode !== "data" && tab.resultEvicted ? { resultEvicted: true } : {}),
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { QueryResult, QueryTab } from "@/types/database";
|
||||
import { hasQueryOutput } from "../queryOutput";
|
||||
|
||||
const emptyResult: QueryResult = { columns: ["id"], rows: [], affected_rows: 0, execution_time_ms: 1 };
|
||||
|
||||
describe("hasQueryOutput", () => {
|
||||
it("does not reserve output space for an idle query", () => {
|
||||
expect(hasQueryOutput(undefined)).toBe(false);
|
||||
expect(hasQueryOutput({ isExecuting: false, results: [], resultRuns: [] })).toBe(false);
|
||||
});
|
||||
|
||||
it.each<[string, Partial<QueryTab>]>([
|
||||
["running query", { isExecuting: true }],
|
||||
["running EXPLAIN", { isExplaining: true }],
|
||||
["zero-row SELECT", { result: emptyResult }],
|
||||
["DML summary", { result: { ...emptyResult, columns: [], affected_rows: 3 } }],
|
||||
["DDL summary", { result: { ...emptyResult, columns: [] } }],
|
||||
["query error", { result: { ...emptyResult, columns: ["Error"], rows: [["syntax error"]] } }],
|
||||
["multiple results", { results: [emptyResult] }],
|
||||
["retained result run", { resultRuns: [{ id: "run-1", title: "Result 1", sequence: 1, sql: "SELECT 1", createdAt: 1 }] }],
|
||||
["EXPLAIN plan", { explainPlan: { databaseType: "mysql", raw: {}, nodes: [] } }],
|
||||
["EXPLAIN table", { explainTableResult: emptyResult }],
|
||||
["EXPLAIN error", { explainError: "syntax error" }],
|
||||
["EXPLAIN table error", { explainTableError: "unsupported" }],
|
||||
["cached result", { resultEvicted: true, resultCacheState: "disk" }],
|
||||
["missing cached result", { resultEvicted: true, resultCacheState: "missing" }],
|
||||
])("retains output space for %s", (_, state) => {
|
||||
expect(hasQueryOutput({ isExecuting: false, ...state })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { QueryTab } from "@/types/database";
|
||||
|
||||
type QueryOutputState = Pick<QueryTab, "result" | "results" | "resultRuns" | "resultEvicted" | "explainPlan" | "explainError" | "explainTableResult" | "explainTableError" | "isExecuting" | "isExplaining">;
|
||||
|
||||
export function hasQueryOutput(tab: QueryOutputState | null | undefined): boolean {
|
||||
return !!tab && (!!tab.result || !!tab.results?.length || !!tab.resultRuns?.length || tab.resultEvicted === true || !!tab.explainPlan || !!tab.explainError || !!tab.explainTableResult || !!tab.explainTableError || tab.isExecuting === true || tab.isExplaining === true);
|
||||
}
|
||||
@@ -63,6 +63,19 @@ describe("queryStore detached-tab handoff", () => {
|
||||
expect(freshStore.focusedGroupId).toBe(freshStore.groups[0]?.id);
|
||||
});
|
||||
|
||||
it("keeps read-only source intent across detached-window handoff", async () => {
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
const id = store.createTab("pg-1", "app", "Source - seq_users", "query", "public", "CREATE SEQUENCE seq_users", undefined, { sourceView: true });
|
||||
const handoff = await store.prepareDetachedTab(id);
|
||||
expect(handoff.tab.sourceView).toBe(true);
|
||||
setActivePinia(createPinia());
|
||||
const detachedStore = useQueryStore();
|
||||
await detachedStore.adoptDetachedTab(handoff);
|
||||
expect(detachedStore.tabs[0]?.sourceView).toBe(true);
|
||||
expect(detachedStore.tabs[0]?.objectSource).toBeUndefined();
|
||||
});
|
||||
|
||||
it("removeTabAfterDetachedReady clears the owning pane and prunes it when emptied", async () => {
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("queryStore source-view intent", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("localStorage", { getItem: vi.fn(() => null), setItem: vi.fn(), removeItem: vi.fn() });
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
it("preserves read-only source intent when duplicating a tab", async () => {
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
const id = store.createTab("pg-1", "app", "Source - seq_users", "query", "public", "CREATE SEQUENCE seq_users", undefined, { sourceView: true });
|
||||
store.duplicateTab(id);
|
||||
expect(store.tabs).toHaveLength(2);
|
||||
for (const tab of store.tabs) {
|
||||
expect(tab.sourceView).toBe(true);
|
||||
expect(tab.objectSource).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("marks a reused legacy source tab without relying on its title to detect intent", async () => {
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
const id = store.createTab("pg-1", "app", "Source - seq_users");
|
||||
expect(store.tabs[0]?.sourceView).toBeUndefined();
|
||||
const reused = store.createTab("pg-1", "app", "Source - seq_users", "query", undefined, undefined, undefined, { sourceView: true });
|
||||
expect(reused).toBe(id);
|
||||
expect(store.tabs).toHaveLength(1);
|
||||
expect(store.tabs[0]?.sourceView).toBe(true);
|
||||
expect(store.tabs[0]?.objectSource).toBeUndefined();
|
||||
});
|
||||
|
||||
it("marks new and reused editable object-source tabs", async () => {
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
const options = { connectionId: "pg-1", database: "app", title: "Source - users", sql: "CREATE VIEW users AS SELECT 1", objectSource: { name: "users", objectType: "VIEW" as const } };
|
||||
const id = store.openObjectSourceTab(options);
|
||||
expect(store.tabs[0]?.sourceView).toBe(true);
|
||||
store.tabs[0]!.sourceView = undefined;
|
||||
expect(store.openObjectSourceTab(options)).toBe(id);
|
||||
expect(store.tabs[0]?.sourceView).toBe(true);
|
||||
expect(store.tabs[0]?.objectSource).toEqual(options.objectSource);
|
||||
});
|
||||
});
|
||||
@@ -2244,6 +2244,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
structureTableName: t.structureTableName,
|
||||
objectBrowser: t.objectBrowser,
|
||||
objectSource: t.objectSource,
|
||||
sourceView: t.sourceView,
|
||||
tableMeta: t.tableMeta,
|
||||
mongoEditTarget: t.mongoEditTarget,
|
||||
resultEvicted: t.resultEvicted,
|
||||
@@ -2430,10 +2431,20 @@ export const useQueryStore = defineStore("query", () => {
|
||||
return tabs.value.find((tab) => tab.connectionId === connectionId && tab.database === database && tab.title === title && tab.mode === mode && (tab.schema || "") === (schema || "") && (tab.catalog || "") === (catalog || ""));
|
||||
}
|
||||
|
||||
function createTab(connectionId: string, database: string, title?: string, mode: QueryTab["mode"] = "query", schema?: string, initialSql?: string, catalog?: string, options: { forceNew?: boolean; activate?: boolean; forceWordWrap?: boolean; insertAfterActive?: boolean } = {}) {
|
||||
function createTab(
|
||||
connectionId: string,
|
||||
database: string,
|
||||
title?: string,
|
||||
mode: QueryTab["mode"] = "query",
|
||||
schema?: string,
|
||||
initialSql?: string,
|
||||
catalog?: string,
|
||||
options: { forceNew?: boolean; activate?: boolean; forceWordWrap?: boolean; insertAfterActive?: boolean; sourceView?: boolean } = {},
|
||||
) {
|
||||
if (title && !options.forceNew) {
|
||||
const existing = findTabByIdentity(connectionId, database, title, mode, schema, catalog);
|
||||
if (existing) {
|
||||
if (options.sourceView) existing.sourceView = true;
|
||||
switchTab(existing.id);
|
||||
return existing.id;
|
||||
}
|
||||
@@ -2446,6 +2457,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
title: title || `query_${tabs.value.length + 1}`,
|
||||
customTitle: mode === "query" && title ? true : undefined,
|
||||
forceWordWrap: options.forceWordWrap,
|
||||
sourceView: options.sourceView,
|
||||
connectionId,
|
||||
database,
|
||||
schema,
|
||||
@@ -2478,6 +2490,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
(tab.objectSource.signature || "") === (options.objectSource.signature || ""),
|
||||
);
|
||||
if (existing) {
|
||||
existing.sourceView = true;
|
||||
switchTab(existing.id);
|
||||
if (!isTabDirty(existing)) {
|
||||
updateSql(existing.id, options.sql);
|
||||
@@ -2486,7 +2499,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
return existing.id;
|
||||
}
|
||||
|
||||
const id = createTab(options.connectionId, options.database, options.title, "query", options.schema, options.sql, options.catalog, { forceNew: true });
|
||||
const id = createTab(options.connectionId, options.database, options.title, "query", options.schema, options.sql, options.catalog, { forceNew: true, sourceView: true });
|
||||
setObjectSource(id, options.objectSource);
|
||||
return id;
|
||||
}
|
||||
@@ -3682,6 +3695,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
structureDraft: original.structureDraft ? cloneTabDraft(original.structureDraft) : undefined,
|
||||
objectBrowser: original.objectBrowser ? { ...original.objectBrowser } : undefined,
|
||||
objectSource: original.objectSource ? { ...original.objectSource } : undefined,
|
||||
sourceView: original.sourceView,
|
||||
tableMeta: original.tableMeta ? { ...original.tableMeta, columns: [...original.tableMeta.columns], primaryKeys: [...original.tableMeta.primaryKeys] } : undefined,
|
||||
queryAnalysis: original.queryAnalysis ? { ...original.queryAnalysis, sources: original.queryAnalysis.sources?.map((source) => ({ ...source })), columns: original.queryAnalysis.columns.map((c) => ({ ...c })) } : undefined,
|
||||
querySourceColumns: original.querySourceColumns ? [...original.querySourceColumns] : undefined,
|
||||
|
||||
@@ -1295,6 +1295,8 @@ export interface QueryTab {
|
||||
searchQuery?: string;
|
||||
viewport?: ObjectBrowserViewport;
|
||||
};
|
||||
/** Opened to view object source, including objects without editable source metadata. */
|
||||
sourceView?: boolean;
|
||||
objectSource?: {
|
||||
schema?: string;
|
||||
name: string;
|
||||
|
||||
Reference in new issue
Block a user