appSource.indexOf(marker));
+ expect(indices.every((index) => index >= 0)).toBe(true);
+ expect([...indices].sort((a, b) => a - b)).toEqual(indices);
+ });
+
+ it("guards the editor wrapper with the open-tab count and chains the welcome screen to it", () => {
+ expect(appSource).toContain(' {
+ const groupBarSource = readFileSync(new URL("../EditorGroupTabBar.vue", import.meta.url), "utf8");
+ 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");
+ // The split workspace renders several bars; the hit test must union their rects.
+ expect(appSource).toContain('querySelectorAll("[data-main-tab-bar]")');
+ });
+});
diff --git a/apps/desktop/src/components/layout/__tests__/toolbarOverflow.contract.spec.ts b/apps/desktop/src/components/layout/__tests__/toolbarOverflow.contract.spec.ts
new file mode 100644
index 0000000000..36b4885ee6
--- /dev/null
+++ b/apps/desktop/src/components/layout/__tests__/toolbarOverflow.contract.spec.ts
@@ -0,0 +1,44 @@
+import { readFileSync } from "node:fs";
+import { describe, expect, it } from "vitest";
+
+const contentAreaSource = readFileSync(new URL("../ContentArea.vue", import.meta.url), "utf8");
+const databaseBrowserSource = readFileSync(new URL("../../objects/DatabaseBrowser.vue", import.meta.url), "utf8");
+const objectBrowserSource = readFileSync(new URL("../../objects/ObjectBrowser.vue", import.meta.url), "utf8");
+const overflowMenuSource = readFileSync(new URL("../../../components/ui/ToolbarOverflowMenu.vue", import.meta.url), "utf8");
+
+describe("measured toolbar condensation wiring (non-SQL toolbars)", () => {
+ it("keeps the overflow trigger a single-layer dropdown (no Tooltip nesting)", () => {
+ expect(overflowMenuSource).toContain("DropdownMenuTrigger as-child");
+ expect(overflowMenuSource).not.toContain("TooltipTrigger");
+ expect(overflowMenuSource).not.toContain("LightTooltip");
+ });
+
+ it("condenses the data-mode header row through measured tiers", () => {
+ expect(contentAreaSource).toContain("useToolbarOverflow(dataToolbarRef");
+ // Chips keep a min-width floor so scrollWidth reports real overflow.
+ expect(contentAreaSource).toContain("inline-flex min-w-12 items-center truncate");
+ expect(contentAreaSource).toContain('ref="dataToolbarRef"');
+ expect(contentAreaSource).toContain("showDataColumnsChip");
+ expect(contentAreaSource).toContain("showDataTableInfoButton");
+ expect(contentAreaSource).toContain("dataToolbarCompact");
+ expect(contentAreaSource).toContain('v-if="showDataToolbarOverflow"');
+ });
+
+ it("condenses the database browser header row through measured tiers", () => {
+ expect(databaseBrowserSource).toContain("useToolbarOverflow(toolbarRef");
+ expect(databaseBrowserSource).toContain('ref="toolbarRef"');
+ expect(databaseBrowserSource).toContain("showInlineSortAndView");
+ expect(databaseBrowserSource).toContain("showConnectionChip");
+ expect(databaseBrowserSource).toContain('v-if="showToolbarOverflow"');
+ });
+
+ it("condenses the object browser header row through measured tiers", () => {
+ expect(objectBrowserSource).toContain("useToolbarOverflow(toolbarRef");
+ expect(objectBrowserSource).toContain('ref="toolbarRef"');
+ expect(objectBrowserSource).toContain("showInlineSortAndView");
+ expect(objectBrowserSource).toContain("showInlineCheckboxToggle");
+ expect(objectBrowserSource).toContain("showInlineObjectFilter");
+ expect(objectBrowserSource).toContain("showDatabaseChip");
+ expect(objectBrowserSource).toContain('v-if="showToolbarOverflow"');
+ });
+});
diff --git a/apps/desktop/src/components/layout/appTabBar.css b/apps/desktop/src/components/layout/appTabBar.css
new file mode 100644
index 0000000000..7fac0749fb
--- /dev/null
+++ b/apps/desktop/src/components/layout/appTabBar.css
@@ -0,0 +1,531 @@
+/*
+ * Shared tab-bar styles for AppTabBar (special pages) and EditorGroupTabBar
+ * (per-group tabs). These rules are global CSS instead of scoped styles
+ * because both components render the same class names and scoped rules in one
+ * component would never match the other's DOM.
+ */
+
+/* 多行平铺模式:覆盖滚动相关样式,让标签换行展示 */
+.app-tab-scroll.wrap-mode {
+ height: auto !important;
+ overflow: visible !important;
+ overflow-x: visible !important;
+ overflow-y: visible !important;
+ flex-wrap: wrap;
+}
+
+/* 父级 strip 容器在包含 wrap-mode 时也需要解除高度约束和裁剪 */
+.app-tab-strip:has(.wrap-mode) {
+ height: auto !important;
+ overflow: visible !important;
+}
+
+/* 标签栏行容器(直接子 div)在包含 wrap-mode 时解除固定高度和裁剪 */
+.app-tab-bar > div:has(.wrap-mode) {
+ height: auto !important;
+ overflow: visible !important;
+}
+
+/* 标签栏本身也解除裁剪,让换行内容自然撑高 */
+.app-tab-bar:has(.wrap-mode) {
+ overflow: visible !important;
+}
+
+/* 经典布局 + 多行模式:优化多行标签显示 */
+.app-tab-scroll.classic-wrap {
+ row-gap: 0.25rem;
+ padding-top: 0.25rem;
+ padding-bottom: 0.25rem;
+ align-items: flex-start;
+}
+
+/* 经典布局下 h-full 在 height:auto 容器中失效,改为固定高度 */
+.app-tab-scroll.classic-wrap > div {
+ height: 2rem;
+}
+
+.app-tab-pill {
+ background-color: var(--app-tab-background);
+}
+
+.app-tab-pill[data-active-tab="false"]:hover {
+ background-color: var(--app-tab-hover-background, color-mix(in oklch, var(--foreground) 8%, transparent));
+}
+
+.dirty-tab-marker {
+ display: inline-flex;
+ width: 0.5rem;
+ height: 0.75rem;
+ flex-shrink: 0;
+ align-items: center;
+ justify-content: center;
+ color: currentColor;
+ font-size: 13px;
+ font-weight: 700;
+ line-height: 12px;
+ opacity: 0.9;
+ transform: translateY(2px);
+}
+
+.app-tab-scroll::-webkit-scrollbar {
+ display: none;
+}
+
+.app-tab-scrollbar {
+ position: absolute;
+ inset-inline: 0;
+ top: 0;
+ z-index: 20;
+ height: 6px;
+ cursor: pointer;
+ opacity: 0;
+ pointer-events: none;
+ touch-action: none;
+ transition: opacity 140ms ease;
+}
+
+.app-tab-strip:hover .app-tab-scrollbar,
+.app-tab-strip:focus-within .app-tab-scrollbar,
+.app-tab-scrollbar--dragging {
+ opacity: 1;
+ pointer-events: auto;
+}
+
+.app-tab-scrollbar::before {
+ content: "";
+ position: absolute;
+ inset-inline: 0;
+ top: 0;
+ height: 2px;
+ border-radius: 999px;
+ background: color-mix(in oklch, var(--foreground) 8%, transparent);
+}
+
+.app-tab-scrollbar--bottom {
+ top: auto;
+ bottom: 0;
+}
+
+.app-tab-scrollbar--bottom::before {
+ top: auto;
+ bottom: 0;
+}
+
+.app-tab-scrollbar__thumb {
+ position: absolute;
+ top: 0;
+ height: 2px;
+ min-width: 20px;
+ border-radius: 999px;
+ background: color-mix(in oklch, var(--foreground) 30%, transparent);
+ transition:
+ height 120ms ease,
+ background-color 120ms ease;
+}
+
+.app-tab-scrollbar--bottom .app-tab-scrollbar__thumb {
+ top: auto;
+ bottom: 0;
+}
+
+.app-tab-scrollbar:hover .app-tab-scrollbar__thumb,
+.app-tab-scrollbar--dragging .app-tab-scrollbar__thumb {
+ height: 5px;
+ background: color-mix(in oklch, var(--foreground) 52%, transparent);
+}
+
+/* ------------------------------------------------------------------ */
+/* Semantic tab groups (pill clusters) — ported from upstream AppTabBar */
+/* ------------------------------------------------------------------ */
+
+.tab-group-header {
+ display: inline-flex;
+ flex: none;
+ cursor: pointer;
+ align-items: center;
+ min-width: 0;
+ border: 0;
+ background: transparent;
+ outline: none;
+ -webkit-user-select: none;
+ user-select: none;
+ transition:
+ color 150ms ease,
+ box-shadow 150ms ease;
+}
+
+.tab-group-header:focus-visible {
+ box-shadow: 0 0 0 2px var(--ring);
+}
+
+.tab-group-header-content {
+ display: inline-flex;
+ min-width: 0;
+ align-items: center;
+ gap: 0.25rem;
+ transition:
+ color 150ms ease,
+ background-color 150ms ease,
+ box-shadow 150ms ease;
+}
+
+.tab-group-marker {
+ width: 0.4rem;
+ height: 0.4rem;
+ flex: none;
+ border-radius: 9999px;
+ background: var(--tab-group-color);
+}
+
+.tab-group-chevron {
+ width: 0.75rem;
+ height: 0.75rem;
+ flex: none;
+ transition: transform 150ms ease;
+}
+
+.tab-group-pin {
+ width: 0.7rem;
+ height: 0.7rem;
+ flex: none;
+ fill: currentColor;
+ opacity: 0.7;
+}
+
+.tab-group-label {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.tab-group-count {
+ display: inline-flex;
+ min-width: 1rem;
+ height: 1rem;
+ flex: none;
+ align-items: center;
+ justify-content: center;
+ border-radius: 9999px;
+ padding-inline: 0.25rem;
+ background: color-mix(in srgb, var(--tab-group-color) 18%, transparent);
+ font-size: 0.625rem;
+ line-height: 1;
+}
+
+.app-tab-bar:not(.vertical-tab-layout) .tab-group-header {
+ position: relative;
+ height: 100%;
+ align-self: stretch;
+ margin-inline: 0.15rem 0.05rem;
+ padding: 0;
+ color: var(--tab-group-color);
+ font-size: 0.6875rem;
+ font-weight: 650;
+ line-height: 1;
+}
+
+.app-tab-bar:not(.vertical-tab-layout) .tab-group-header-content {
+ height: 1.625rem;
+ border-radius: 9999px;
+ padding-inline: 0.4rem 0.5rem;
+ background: var(--tab-group-soft);
+}
+
+.app-tab-bar:not(.vertical-tab-layout) .tab-group-header:hover .tab-group-header-content,
+.app-tab-bar:not(.vertical-tab-layout) .tab-group-header--active .tab-group-header-content {
+ background: color-mix(in srgb, var(--tab-group-color) 18%, transparent);
+}
+
+.app-tab-bar:not(.vertical-tab-layout) .tab-group-header--active .tab-group-header-content {
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--tab-group-color) 35%, transparent);
+}
+
+.app-tab-bar:not(.vertical-tab-layout) .tab-group-header::after {
+ content: "";
+ position: absolute;
+ z-index: 1;
+ right: -0.45rem;
+ bottom: 0;
+ left: 0.3rem;
+ height: 2px;
+ border-radius: 9999px;
+ background: var(--tab-group-color);
+ pointer-events: none;
+ opacity: 0.82;
+}
+
+.app-tab-bar:not(.vertical-tab-layout) .tab-group-header--collapsed::after {
+ right: 0.3rem;
+}
+
+.app-tab-bar:not(.vertical-tab-layout)[data-placement="bottom"] .tab-group-header::after {
+ top: 0;
+ bottom: auto;
+}
+
+.app-tab-bar:not(.vertical-tab-layout) .tab-group-tab {
+ position: relative;
+}
+
+.app-tab-bar:not(.vertical-tab-layout) .tab-group-tab::after {
+ content: "";
+ position: absolute;
+ z-index: 1;
+ right: -0.2rem;
+ bottom: 0;
+ left: -0.2rem;
+ height: 2px;
+ border-radius: 9999px;
+ background: var(--tab-group-color);
+ pointer-events: none;
+ opacity: 0.82;
+}
+
+.app-tab-bar:not(.vertical-tab-layout)[data-placement="bottom"] .tab-group-tab::after {
+ top: 0;
+ bottom: auto;
+}
+
+.app-tab-bar:not(.vertical-tab-layout) .tab-group-tab--first::after {
+ left: 0;
+}
+
+.app-tab-bar:not(.vertical-tab-layout) .tab-group-tab--last::after {
+ right: 0.3rem;
+}
+
+.tab-group-color-option,
+.tab-group-custom-color {
+ position: relative;
+ display: inline-flex;
+ width: 1.75rem;
+ height: 1.75rem;
+ flex: none;
+ cursor: pointer;
+ align-items: center;
+ justify-content: center;
+ border: 2px solid var(--background);
+ border-radius: 9999px;
+ background: var(--tab-group-option-color);
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--foreground) 18%, transparent);
+ outline: none;
+ transition:
+ box-shadow 150ms ease,
+ transform 150ms ease;
+}
+
+.tab-group-color-option:hover,
+.tab-group-custom-color:hover {
+ transform: scale(1.08);
+}
+
+.tab-group-color-option--selected {
+ box-shadow:
+ 0 0 0 2px var(--background),
+ 0 0 0 4px var(--tab-group-option-color, var(--tab-group-color));
+}
+
+.tab-group-color-option--auto {
+ color: var(--muted-foreground);
+ background: var(--muted);
+}
+
+.tab-group-custom-color {
+ overflow: hidden;
+ background: conic-gradient(#ef4444, #f59e0b, #22c55e, #06b6d4, #3b82f6, #a855f7, #ef4444);
+}
+
+.tab-group-custom-color input {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ cursor: pointer;
+ opacity: 0;
+}
+
+/* ------------------------------------------------------------------ */
+/* Vertical placement (left/right): per-pane sidebar strips (Phase 3) */
+/* ------------------------------------------------------------------ */
+
+.vertical-tab-layout {
+ flex: 0 0 15rem;
+}
+
+.vertical-tab-layout .app-tab-strip {
+ height: auto;
+ min-height: 0;
+ width: 100%;
+}
+
+.vertical-tab-layout .app-tab-pill {
+ width: 100%;
+ min-width: 0 !important;
+ min-height: 2rem;
+ height: 2rem !important;
+ border-right: 0;
+ background-color: var(--app-tab-background, transparent);
+ transition:
+ background-color 150ms ease,
+ border-color 150ms ease,
+ box-shadow 150ms ease,
+ color 150ms ease;
+}
+
+.vertical-tab-layout .app-tab-pill[data-active-tab="true"] {
+ border-color: color-mix(in srgb, var(--tab-group-color, var(--ring)) 18%, transparent);
+ box-shadow:
+ inset 0 0 0 1px color-mix(in srgb, var(--tab-group-color, var(--ring)) 13%, transparent),
+ 0 1px 2px rgb(0 0 0 / 0.08),
+ 0 3px 9px color-mix(in srgb, var(--tab-group-color, var(--foreground)) 8%, transparent);
+}
+
+.vertical-tab-layout .app-tab-scroll > * {
+ width: 100%;
+ flex: none;
+}
+
+.vertical-tab-layout--collapsed .app-tab-scroll {
+ align-items: center;
+}
+
+.vertical-tab-layout--collapsed .app-tab-pill {
+ position: relative;
+ width: 2.5rem;
+ min-height: 2.5rem;
+ height: 2.5rem !important;
+ justify-content: center;
+ margin: 0.125rem auto;
+ padding: 0 !important;
+ border-radius: 0.625rem;
+}
+
+.vertical-tab-layout--collapsed .compact-dirty-tab-marker {
+ position: absolute;
+ top: 0.3rem;
+ right: 0.3rem;
+ width: 0.35rem;
+ height: 0.35rem;
+ border-radius: 9999px;
+ background: var(--destructive);
+ box-shadow: 0 0 0 1px var(--background);
+}
+
+.vertical-tab-layout.vertical-tab-layout--collapsed .tab-group-header {
+ width: 2.5rem !important;
+ height: 1.75rem;
+ margin: 0.35rem auto 0.1rem;
+}
+
+.vertical-tab-layout.vertical-tab-layout--collapsed .tab-group-header-content {
+ justify-content: center;
+ padding: 0;
+}
+
+.vertical-tab-layout.vertical-tab-layout--collapsed .tab-group-tab {
+ width: 2.5rem;
+ margin-inline: auto;
+ padding: 0 !important;
+}
+
+.vertical-tab-layout.vertical-tab-layout--collapsed .tab-group-header::after,
+.vertical-tab-layout.vertical-tab-layout--collapsed .tab-group-tab::before,
+.vertical-tab-layout.vertical-tab-layout--collapsed .tab-group-tab::after {
+ display: none;
+}
+
+.vertical-tab-layout .tab-group-header {
+ position: relative;
+ width: calc(100% - 1rem) !important;
+ height: 2rem;
+ align-self: center;
+ margin: 0.35rem 0.5rem 0.08rem;
+ padding: 0;
+ color: var(--foreground);
+ font-size: 0.75rem;
+ font-weight: 650;
+ line-height: 1rem;
+}
+
+.vertical-tab-layout .tab-group-header-content {
+ width: 100%;
+ height: 100%;
+ border-radius: 0.5rem;
+ padding-inline: 0.55rem;
+}
+
+.vertical-tab-layout .tab-group-header:hover .tab-group-header-content,
+.vertical-tab-layout .tab-group-header--active .tab-group-header-content {
+ background: var(--tab-group-soft);
+}
+
+.vertical-tab-layout .tab-group-header--active {
+ color: var(--tab-group-color);
+}
+
+.vertical-tab-layout .tab-group-marker {
+ width: 0.22rem;
+ height: 1rem;
+ border-radius: 9999px;
+}
+
+.vertical-tab-layout .tab-group-header:not(.tab-group-header--collapsed)::after {
+ content: "";
+ position: absolute;
+ z-index: 1;
+ top: 1rem;
+ bottom: -1.08rem;
+ left: 0.66rem;
+ width: 0.7rem;
+ border-bottom: 1px solid var(--tab-group-rail);
+ border-left: 1px solid var(--tab-group-rail);
+ border-bottom-left-radius: 0.1875rem;
+ pointer-events: none;
+}
+
+.vertical-tab-layout .tab-group-header:not(.tab-group-header--collapsed) .tab-group-marker {
+ position: relative;
+ z-index: 2;
+}
+
+.vertical-tab-layout .tab-group-tab {
+ position: relative;
+ width: calc(100% - 3rem);
+ margin-inline: 2.5rem 0.5rem;
+ padding-inline-start: 0.65rem !important;
+ border-color: transparent;
+}
+
+.vertical-tab-layout .tab-group-tab::before {
+ content: "";
+ position: absolute;
+ top: -0.25rem;
+ bottom: -0.25rem;
+ left: -0.65rem;
+ width: 1px;
+ border-radius: 9999px;
+ background: var(--tab-group-rail);
+ pointer-events: none;
+}
+
+.vertical-tab-layout .tab-group-tab--first::before {
+ top: 50%;
+}
+
+.vertical-tab-layout .tab-group-tab--last::before {
+ bottom: 50%;
+ border-radius: 9999px 9999px 0 0;
+}
+
+.vertical-tab-layout .tab-group-tab::after {
+ content: "";
+ position: absolute;
+ top: calc(50% - 0.5px);
+ left: -0.65rem;
+ width: 0.65rem;
+ height: 1px;
+ border-radius: 0 9999px 9999px 0;
+ background: var(--tab-group-rail);
+ pointer-events: none;
+}
diff --git a/apps/desktop/src/components/layout/editorToolbarActions.ts b/apps/desktop/src/components/layout/editorToolbarActions.ts
new file mode 100644
index 0000000000..a24bd207d2
--- /dev/null
+++ b/apps/desktop/src/components/layout/editorToolbarActions.ts
@@ -0,0 +1,74 @@
+import type { InjectionKey, Ref } from "vue";
+
+/**
+ * App-level orchestration behind the per-group SQL editor toolbar.
+ *
+ * The toolbar renders inside each editor group, but execution, dialogs, and
+ * format/compress requests stay App-owned. The group focuses itself on
+ * pointerdown/focusin before any toolbar event fires, so handlers may resolve
+ * the acting tab from the explicit tabId argument (never re-reading the global
+ * active tab after an await).
+ */
+export interface EditorToolbarActions {
+ explainMode: Ref<"explain" | "autotrace">;
+ blockDangerousRedisCommands: Ref;
+ /** Highlights the database selector of the tab that needs a database choice. */
+ databaseRequiredSignalFor(tabId: string): number;
+ /** Captures the acting editor's execution snapshot before a toolbar click. */
+ captureExecutionSnapshot(): void;
+ toolbarExecute(source: "pointer" | "keyboard"): void;
+ cancelExecution(tabId: string): void;
+ explain(tabId: string): void;
+ formatSql(tabId: string): void;
+ compressSql(tabId: string): void;
+ toggleSqlKeywordCase(): void;
+ saveSql(tabId: string): void;
+ openSqlFile(): void;
+ importResultArchive(): void;
+ pasteSqlInCondition(): void;
+ multiExecute(): void;
+ previewChanges(tabId: string): void;
+ changeConnection(tabId: string, connectionId: string): void;
+ changeCatalog(tabId: string, catalog: string | undefined, database: string): void;
+ changeDatabase(tabId: string, database: string): void;
+ changeSchema(tabId: string, schema: string | undefined): void;
+ setDefaultDatabase(tabId: string): void;
+ clearDefaultDatabase(tabId: string): void;
+}
+
+export const EDITOR_TOOLBAR_ACTIONS: InjectionKey = Symbol("dbx:editor-toolbar-actions");
+
+/**
+ * No-op fallback so a group mounted outside the workspace (tests, future
+ * hosts) renders a toolbar that does nothing instead of crashing. Production
+ * always provides real actions from App.
+ */
+export function createNoopEditorToolbarActions(): EditorToolbarActions {
+ const noop = () => undefined;
+ const mode = { value: "explain" } as Ref<"explain" | "autotrace">;
+ const flag = { value: true } as Ref;
+ return {
+ explainMode: mode,
+ blockDangerousRedisCommands: flag,
+ databaseRequiredSignalFor: () => 0,
+ captureExecutionSnapshot: noop,
+ toolbarExecute: noop,
+ cancelExecution: noop,
+ explain: noop,
+ formatSql: noop,
+ compressSql: noop,
+ toggleSqlKeywordCase: noop,
+ saveSql: noop,
+ openSqlFile: noop,
+ importResultArchive: noop,
+ pasteSqlInCondition: noop,
+ multiExecute: noop,
+ previewChanges: noop,
+ changeConnection: noop,
+ changeCatalog: noop,
+ changeDatabase: noop,
+ changeSchema: noop,
+ setDefaultDatabase: noop,
+ clearDefaultDatabase: noop,
+ };
+}
diff --git a/apps/desktop/src/components/layout/querySurfaces.ts b/apps/desktop/src/components/layout/querySurfaces.ts
new file mode 100644
index 0000000000..2497fd4ba0
--- /dev/null
+++ b/apps/desktop/src/components/layout/querySurfaces.ts
@@ -0,0 +1,98 @@
+import type { ConnectionConfig, ObjectBrowserViewport, QueryTab } from "@/types/database";
+import type { DataGridReloadIntent } from "@/lib/dataGrid/dataGridToolbar";
+import type { DataGridSortMode } from "@/lib/dataGrid/dataGridSort";
+import type { SqlObjectNavigationTarget } from "@/lib/sql/sqlNavigation";
+import type { SqlExecutionOverride, SqlExecutionSnapshot } from "@/lib/sql/sqlExecutionTarget";
+
+export interface StatementRange {
+ from: number;
+ to: number;
+}
+
+export interface QueryEditorSurfaceHandle {
+ focusSearch(target?: Element | null): boolean;
+ openGoToColumn(): boolean;
+ refreshData(target?: Element | null): boolean;
+ toggleResultsPane(): boolean;
+ refreshQueryEditorCompletionCache(): boolean;
+ handleModRTarget(target: Element): boolean;
+ requestQueryEditorExecute(): boolean;
+ captureQueryEditorExecutionSnapshot(): SqlExecutionSnapshot | undefined;
+ requestQueryEditorExecuteInNewResultTab(): boolean;
+ requestQueryEditorPreviewChanges(stackSql?: string): Promise;
+ shouldBlockQueryEditorExecutionShortcut(event: KeyboardEvent): boolean;
+ cancelQueryEditorExecutionViewport(requestId: number): boolean;
+ acceptQueryEditorExecutionViewport(requestId: number): boolean;
+ pasteClipboardAsSqlInCondition(): Promise;
+ applyTableStructureChanges(): Promise;
+ insertRedisCommand(command: string): Promise;
+ executeRedisCommand(command: string): Promise;
+ previewStatementRange(range: StatementRange | null): boolean;
+ focusStatementRange(range: StatementRange | null): boolean;
+}
+
+export interface QueryResultSurfaceHandle {
+ focusSearch(target?: Element | null): boolean;
+ refreshData(target?: Element | null): boolean;
+ toggleResultsPane(): boolean;
+ handleModRTarget(target: Element): boolean;
+ previewStatementRange(range: StatementRange | null): boolean;
+ focusStatementRange(range: StatementRange | null): boolean;
+}
+
+export interface ContentAreaSurfaceProps {
+ activeTab: QueryTab;
+ activeConnection?: ConnectionConfig;
+ executableSql: string;
+ activeOutputView: "result" | "summary" | "explain" | "chart" | "messages";
+ formatSqlRequest: { id: number; tabId: string } | null;
+ compressSqlRequest: { id: number; tabId: string } | null;
+ selectedSql: string;
+ cursorPos: number;
+ blockDangerousRedisCommands: boolean;
+ zenMode?: boolean;
+}
+
+/**
+ * Event contract between content surfaces and the workspace/App layer.
+ * Every operation that can outlive a focus change — execution, cancellation,
+ * result paging, AI hand-off, formatting, view switches — carries the tabId
+ * of the tab that originated it, so consumers never have to re-read the
+ * global active tab after an await (see redevelopment guide §6.1).
+ */
+export interface ContentAreaSurfaceEmits {
+ "update:activeOutputView": [tabId: string, value: "result" | "summary" | "explain" | "chart" | "messages"];
+ fixWithAi: [tabId: string, errorMessage: string];
+ sendSelectionToAi: [tabId: string, sql: string];
+ previewChangesAvailable: [tabId: string, value: boolean];
+ execute: [tabId: string, sqlOverride?: SqlExecutionOverride];
+ executeInNewResultTab: [tabId: string, sqlOverride?: SqlExecutionOverride];
+ saveSql: [tabId: string];
+ cancel: [tabId: string];
+ explain: [tabId: string];
+ editorUpdate: [tabId: string, value: string];
+ editorSelectionChange: [tabId: string, value: string];
+ editorCursorChange: [tabId: string, pos: number];
+ editorViewportChange: [tabId: string, viewport: { scrollTop: number; scrollLeft: number }];
+ editorSelectionStateChange: [tabId: string, selection: { anchor: number; head: number }];
+ formatError: [tabId: string];
+ reload: [tabId: string, sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number, intent?: DataGridReloadIntent];
+ paginate: [tabId: string, offset: number, limit: number, whereInput?: string, orderBy?: string];
+ sort: [tabId: string, column: string, columnIndex: number, direction: "asc" | "desc" | null, whereInput?: string, mode?: DataGridSortMode];
+ executeSql: [tabId: string, sql: string];
+ clickTable: [tabId: string, target: SqlObjectNavigationTarget];
+ viewTableData: [tabId: string, target: SqlObjectNavigationTarget];
+ viewTableDdl: [tabId: string, target: SqlObjectNavigationTarget];
+ editTableStructure: [tabId: string, target: SqlObjectNavigationTarget];
+ openObjectSource: [tabId: string, target: SqlObjectNavigationTarget, initialEditing: boolean];
+ openObjectTable: [tabId: string, target: { tableName: string; schema?: string; tableType?: string; catalog?: string }];
+ objectSchemaChange: [tabId: string, schema: string | undefined];
+ objectBrowserViewportChange: [tabId: string, viewport: ObjectBrowserViewport];
+ structureEditorSaved: [tabId: string, commentChanged: boolean];
+ structureEditorClose: [tabId: string];
+ previewStatement: [tabId: string, range: StatementRange | null];
+ focusStatement: [tabId: string, range: StatementRange | null];
+ openSettings: [initialTab?: string, initialSection?: string];
+ openConnectionSettings: [connectionId: string, initialTab: "advanced"];
+ toggleZenMode: [];
+}
diff --git a/apps/desktop/src/components/layout/sqlEditorWorkspace.css b/apps/desktop/src/components/layout/sqlEditorWorkspace.css
new file mode 100644
index 0000000000..f702e6a31e
--- /dev/null
+++ b/apps/desktop/src/components/layout/sqlEditorWorkspace.css
@@ -0,0 +1,87 @@
+/*
+ * 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.
+ */
+
+.sql-editor-workspace {
+ --sql-workspace-ease-out: cubic-bezier(0.25, 1, 0.5, 1);
+}
+
+/* Pane size transitions: restore splitpanes' stock behavior inside the
+ workspace (globals.css disables it for all horizontal splitpanes). */
+.sql-editor-workspace .splitpanes--ready .splitpanes__pane {
+ transition: width 0.2s ease-out, height 0.2s ease-out !important;
+}
+.sql-editor-workspace .splitpanes--ready.splitpanes--dragging .splitpanes__pane {
+ transition: none !important;
+}
+
+/* A group pane created after the workspace is interactive emerges from its
+ owner's side (the divider). No fill mode: the transform is released once
+ the animation ends so the pane never becomes a containing block. */
+.sql-editor-workspace .workspace-pane-enter {
+ animation: dbx-workspace-pane-in 220ms var(--sql-workspace-ease-out);
+}
+.sql-editor-workspace .workspace-pane-enter--from-left {
+ --dbx-pane-enter-x: -12px;
+}
+.sql-editor-workspace .workspace-pane-enter--from-top {
+ --dbx-pane-enter-y: -12px;
+}
+@keyframes dbx-workspace-pane-in {
+ from {
+ opacity: 0;
+ transform: translate(var(--dbx-pane-enter-x, 0), var(--dbx-pane-enter-y, 0));
+ }
+}
+
+/* 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
+ 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);
+}
+.sql-editor-workspace-split.result-pane-collapsed > .splitpanes__splitter {
+ opacity: 0;
+ pointer-events: none;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .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 .splitpanes--ready .splitpanes__pane {
+ transition: none !important;
+ }
+}
diff --git a/apps/desktop/src/components/objects/DatabaseBrowser.vue b/apps/desktop/src/components/objects/DatabaseBrowser.vue
index e616af5531..bd77abfef8 100644
--- a/apps/desktop/src/components/objects/DatabaseBrowser.vue
+++ b/apps/desktop/src/components/objects/DatabaseBrowser.vue
@@ -1,9 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/desktop/src/composables/__tests__/useDataGridActions.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridActions.spec.ts
index e3a9f79d1b..7fffba43d6 100644
--- a/apps/desktop/src/composables/__tests__/useDataGridActions.spec.ts
+++ b/apps/desktop/src/composables/__tests__/useDataGridActions.spec.ts
@@ -155,7 +155,7 @@ describe("useDataGridActions", () => {
const tab = tableDataTab();
const actions = useDataGridActions(computed(() => tab));
- await actions.onReloadData(tab.sql, "", "", "", undefined, undefined, "refresh");
+ await actions.onReloadData(tab.id, tab.sql, "", "", "", undefined, undefined, "refresh");
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(
expect.objectContaining({
@@ -191,7 +191,9 @@ describe("useDataGridActions", () => {
const actions = useDataGridActions(computed(() => tab));
const { whereInput, orderBy } = restoredDataTabReloadFilters(tab);
- await actions.onReloadData(undefined, undefined, whereInput, orderBy);
+ // ContentArea's empty-placeholder refresh emits the owning tabId first
+ // (tabId-first contract), so the filters land in the whereInput/orderBy slots.
+ await actions.onReloadData(tab.id, undefined, undefined, whereInput, orderBy);
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ whereInput: "status = 'active'", orderBy: '"name" DESC' }));
expect(tab.whereInput).toBe("status = 'active'");
@@ -205,7 +207,7 @@ describe("useDataGridActions", () => {
mocks.tabs.push(tab);
const actions = useDataGridActions(computed(() => tab));
- await actions.onReloadData(tab.sql, "", undefined, undefined, undefined, undefined, "refresh");
+ await actions.onReloadData(tab.id, tab.sql, "", undefined, undefined, undefined, undefined, "refresh");
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ whereInput: undefined, orderBy: undefined }));
expect(tab.whereInput).toBe("");
@@ -219,7 +221,7 @@ describe("useDataGridActions", () => {
mocks.buildTableSelectSql.mockResolvedValueOnce("SELECT * FROM public.users LIMIT 25 OFFSET 50");
const actions = useDataGridActions(computed(() => tab));
- await actions.onReloadData(tab.sql, "", "", "", 25, 50, "refresh");
+ await actions.onReloadData(tab.id, tab.sql, "", "", "", 25, 50, "refresh");
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ limit: 25, offset: 50 }));
expect(mocks.executeTabSql).toHaveBeenCalledWith("tab-1", "SELECT * FROM public.users LIMIT 25 OFFSET 50", expect.objectContaining({ pagination: { limit: 25, offset: 50 } }));
@@ -238,7 +240,7 @@ describe("useDataGridActions", () => {
mocks.buildTableSelectSql.mockResolvedValueOnce("SELECT * FROM public.users LIMIT 100 OFFSET 100");
const actions = useDataGridActions(computed(() => tab));
- await actions.onPaginate(100, 100);
+ await actions.onPaginate(tab.id, 100, 100);
expect(mocks.executeTabSql).toHaveBeenCalledWith("tab-1", "SELECT * FROM public.users LIMIT 100 OFFSET 100", expect.objectContaining({ appendResult: { maxRows: 10_000 } }));
});
@@ -264,7 +266,7 @@ describe("useDataGridActions", () => {
mocks.tabs.push(tab);
const actions = useDataGridActions(computed(() => tab));
- await actions.onReloadData(tab.sql, "", "", '"old_name" ASC', undefined, undefined, "refresh");
+ await actions.onReloadData(tab.id, tab.sql, "", "", '"old_name" ASC', undefined, undefined, "refresh");
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ orderBy: undefined }));
expect(tab.resultSortColumn).toBeUndefined();
@@ -289,7 +291,7 @@ describe("useDataGridActions", () => {
mocks.tabs.push(tab);
const actions = useDataGridActions(computed(() => tab));
- await actions.onReloadData(tab.sql, "", "", '"old_name" ASC', undefined, undefined, "refresh");
+ await actions.onReloadData(tab.id, tab.sql, "", "", '"old_name" ASC', undefined, undefined, "refresh");
expect(mocks.clearInvalidDataTabSort).toHaveReturnedWith(false);
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ orderBy: undefined }));
@@ -317,7 +319,7 @@ describe("useDataGridActions", () => {
mocks.tabs.push(tab);
const actions = useDataGridActions(computed(() => tab));
- await actions.onReloadData(tab.sql, "", "", "LOWER(new_name) ASC", undefined, undefined, "refresh");
+ await actions.onReloadData(tab.id, tab.sql, "", "", "LOWER(new_name) ASC", undefined, undefined, "refresh");
expect(mocks.clearInvalidDataTabSort).toHaveReturnedWith(true);
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ orderBy: "LOWER(new_name) ASC" }));
@@ -341,7 +343,7 @@ describe("useDataGridActions", () => {
} as QueryTab;
const actions = useDataGridActions(computed(() => tab));
- await actions.onReloadData(tab.sql, "", "", "", undefined, undefined, "refresh");
+ await actions.onReloadData(tab.id, tab.sql, "", "", "", undefined, undefined, "refresh");
expect(mocks.buildTableSelectSql).not.toHaveBeenCalled();
expect(mocks.executeTabSql).toHaveBeenCalledWith(
@@ -366,7 +368,7 @@ describe("useDataGridActions", () => {
mocks.tabs.push(tab);
const actions = useDataGridActions(computed(() => tab));
- await actions.onReloadData(tab.sql, "", "", "", undefined, undefined, "refresh");
+ await actions.onReloadData(tab.id, tab.sql, "", "", "", undefined, undefined, "refresh");
// 合成的 ["Error"] 结果列不得进入 SQL 投影:真实列缺失时省略 columns(SELECT *)
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ columns: undefined }));
@@ -387,7 +389,7 @@ describe("useDataGridActions", () => {
// 第一轮:stale-tab 早退(tabs 里找不到匹配项时 refreshDataTabTableMeta 直接返回)
mocks.tabs.length = 0;
- await actions.onReloadData(tab.sql, "", "", "", undefined, undefined, "refresh");
+ await actions.onReloadData(tab.id, tab.sql, "", "", "", undefined, undefined, "refresh");
await vi.waitFor(() => expect(mocks.getColumns).toHaveBeenCalledTimes(1));
expect(mocks.setTableMeta).not.toHaveBeenCalled();
expect(tab.tableMetaPending).toBe(true);
@@ -395,7 +397,7 @@ describe("useDataGridActions", () => {
// 第二轮:真实 columns 仍为空,新的消费者加入同一在途请求;共享缓存应
// 去重后端调用,但本轮仍要在目标恢复后落地结果
mocks.tabs.push(tab);
- await actions.onReloadData(tab.sql, "", "", "", undefined, undefined, "refresh");
+ await actions.onReloadData(tab.id, tab.sql, "", "", "", undefined, undefined, "refresh");
await vi.waitFor(() => {
expect(mocks.getColumns).toHaveBeenCalledTimes(1);
expect(mocks.setTableMeta).toHaveBeenCalledWith("tab-1", expect.objectContaining({ primaryKeys: ["id"] }));
@@ -422,7 +424,7 @@ describe("useDataGridActions", () => {
mocks.tabs.push(tab);
const actions = useDataGridActions(computed(() => tab));
- await actions.onReloadData(tab.sql, "", "", "", undefined, undefined, "refresh");
+ await actions.onReloadData(tab.id, tab.sql, "", "", "", undefined, undefined, "refresh");
// Dameng 元数据必须排在数据查询之后(串行约束,同 useSidebarDataOpenRuntime);
// 真实元数据落地后结束行标识等待
@@ -445,7 +447,7 @@ describe("useDataGridActions", () => {
const actions = useDataGridActions(computed(() => tab));
// 查询 reject 仍会重新抛出,但元数据刷新必须已启动,标签页可恢复
- await expect(actions.onReloadData(tab.sql, "", "", "", undefined, undefined, "refresh")).rejects.toThrow("query failed");
+ await expect(actions.onReloadData(tab.id, tab.sql, "", "", "", undefined, undefined, "refresh")).rejects.toThrow("query failed");
await vi.waitFor(() => {
expect(mocks.getColumns).toHaveBeenCalled();
expect(tab.tableMetaPending).toBe(false);
@@ -473,7 +475,7 @@ describe("useDataGridActions", () => {
mocks.listIndexes.mockResolvedValueOnce([]);
const actions = useDataGridActions(computed(() => tab));
- await actions.onReloadData(tab.sql, "", "", "", undefined, undefined, "refresh");
+ await actions.onReloadData(tab.id, tab.sql, "", "", "", undefined, undefined, "refresh");
expect(mocks.getColumns).toHaveBeenCalledTimes(1);
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ columns: ["id", "age"] }));
@@ -501,7 +503,7 @@ describe("useDataGridActions", () => {
mocks.getColumns.mockResolvedValueOnce([{ name: "new_name", data_type: "text", is_nullable: true, column_default: null, is_primary_key: false, extra: null }]);
const actions = useDataGridActions(computed(() => tab));
- await actions.onReloadData(tab.sql, "", "", "", undefined, undefined, "refresh");
+ await actions.onReloadData(tab.id, tab.sql, "", "", "", undefined, undefined, "refresh");
expect(mocks.ensureConnected).toHaveBeenCalled();
expect(mocks.getColumns).toHaveBeenCalledTimes(1);
@@ -514,7 +516,7 @@ describe("useDataGridActions", () => {
mocks.tabs.push(tab);
const actions = useDataGridActions(computed(() => tab));
- await actions.onReloadData(tab.sql, "", "", "", undefined, undefined, "refresh");
+ await actions.onReloadData(tab.id, tab.sql, "", "", "", undefined, undefined, "refresh");
expect(mocks.getColumns).not.toHaveBeenCalled();
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ columns: ["id"] }));
@@ -535,7 +537,7 @@ describe("useDataGridActions", () => {
}),
);
const actions = useDataGridActions(computed(() => tab));
- const reload = actions.onReloadData(tab.sql, "", "", "", undefined, undefined, "refresh");
+ const reload = actions.onReloadData(tab.id, tab.sql, "", "", "", undefined, undefined, "refresh");
await vi.waitFor(() => expect(mocks.getColumns).toHaveBeenCalledTimes(1));
mocks.metadataGeneration = 1;
@@ -574,7 +576,7 @@ describe("useDataGridActions", () => {
} as QueryTab;
const actions = useDataGridActions(computed(() => tab));
- await actions.onSort("email", 2, "asc");
+ await actions.onSort(tab.id, "email", 2, "asc");
expect(mocks.executeTabSql).toHaveBeenCalledWith(
"tab-1",
@@ -644,7 +646,7 @@ describe("useDataGridActions", () => {
});
const actions = useDataGridActions(computed(() => tab));
- await actions.onPaginate(300, 100);
+ await actions.onPaginate(tab.id, 300, 100);
expect(mocks.executeTabSql.mock.calls.map((call) => call[2].pagination)).toEqual([
{ offset: 100, limit: 100, sessionId: "cursor-1", clientSessionId: "client-1" },
@@ -677,7 +679,7 @@ describe("useDataGridActions", () => {
mocks.getConfig.mockImplementation((id: string) => ({ id, db_type: "postgres" }));
const actions = useDataGridActions(computed(() => tab));
- await actions.onPaginate(100, 100);
+ await actions.onPaginate(tab.id, 100, 100);
expect(mocks.executeTabSql).toHaveBeenCalledWith(
"tab-1",
@@ -719,7 +721,7 @@ describe("useDataGridActions", () => {
});
const actions = useDataGridActions(computed(() => tab));
- await actions.onSort("sort_value", 0, "asc", "status = 'active'");
+ await actions.onSort(tab.id, "sort_value", 0, "asc", "status = 'active'");
expect(tab.result?.rows.map((row) => row[0])).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
expect(tab.orderByInput).toBe('"sort_value" ASC');
@@ -753,7 +755,7 @@ describe("useDataGridActions", () => {
mocks.buildTableSelectSql.mockResolvedValueOnce("SELECT sorted");
const actions = useDataGridActions(computed(() => tab));
- await actions.onSort("sort_value", 0, direction, "status = 'active'", "database");
+ await actions.onSort(tab.id, "sort_value", 0, direction, "status = 'active'", "database");
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ orderBy, whereInput: "status = 'active'", limit: 7, offset: 0 }));
expect(mocks.executeTabSql).toHaveBeenCalledWith("tab-1", "SELECT sorted", expect.objectContaining({ pagination: { limit: 7, offset: 0 }, preserveTotalRowCountDuringExecution: true }));
@@ -781,7 +783,7 @@ describe("useDataGridActions", () => {
mocks.buildTableSelectSql.mockResolvedValueOnce("SELECT unsorted");
const actions = useDataGridActions(computed(() => tab));
- await actions.onSort("sort_value", 0, null, "status = 'active'", "database");
+ await actions.onSort(tab.id, "sort_value", 0, null, "status = 'active'", "database");
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ orderBy: undefined, whereInput: "status = 'active'", limit: 10, offset: 0 }));
expect(mocks.executeTabSql).toHaveBeenCalledWith("tab-1", "SELECT unsorted", expect.objectContaining({ pagination: { limit: 10, offset: 0 }, preserveTotalRowCountDuringExecution: true }));
@@ -813,7 +815,7 @@ describe("useDataGridActions", () => {
mocks.buildTableSelectSql.mockResolvedValueOnce("SELECT sorted page 2");
const actions = useDataGridActions(computed(() => tab));
- await actions.onPaginate(10, 10, "status = 'active'");
+ await actions.onPaginate(tab.id, 10, 10, "status = 'active'");
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ orderBy: '"sort_value" ASC', whereInput: "status = 'active'", limit: 10, offset: 10 }));
expect(mocks.executeTabSql).toHaveBeenCalledWith("tab-1", "SELECT sorted page 2", expect.objectContaining({ pagination: { offset: 10, limit: 10 }, preserveTotalRowCountDuringExecution: true }));
@@ -834,7 +836,7 @@ describe("useDataGridActions", () => {
});
const actions = useDataGridActions(computed(() => tab));
- await actions.onSort("sort_value", 0, "asc", "status = 'active'", "local");
+ await actions.onSort(tab.id, "sort_value", 0, "asc", "status = 'active'", "local");
expect(mocks.sortTabResultLocally).toHaveBeenCalledWith("tab-1", "sort_value", 0, "asc");
expect(mocks.buildTableSelectSql).not.toHaveBeenCalled();
@@ -862,7 +864,7 @@ describe("useDataGridActions", () => {
mocks.buildSortedQuerySql.mockResolvedValueOnce({ ok: true, sql: "SELECT sorted" });
const actions = useDataGridActions(computed(() => tab));
- await actions.onSort("sort_value", 0, "desc", undefined, "database");
+ await actions.onSort(tab.id, "sort_value", 0, "desc", undefined, "database");
expect(mocks.executeTabSql).toHaveBeenCalledWith("tab-1", "SELECT sorted", expect.objectContaining({ pagination: { limit: 10, offset: 0 }, preserveTotalRowCountDuringExecution: true }));
});
diff --git a/apps/desktop/src/composables/__tests__/useSqlExecution.spec.ts b/apps/desktop/src/composables/__tests__/useSqlExecution.spec.ts
index 243577accc..87ce8f136c 100644
--- a/apps/desktop/src/composables/__tests__/useSqlExecution.spec.ts
+++ b/apps/desktop/src/composables/__tests__/useSqlExecution.spec.ts
@@ -219,7 +219,7 @@ describe("useSqlExecution", () => {
expect(execution.showSqlParameterDialog.value).toBe(false);
expect(execution.sqlParameterNames.value).toEqual([]);
- expect(executeCurrentSql).toHaveBeenCalledWith(sql, {});
+ expect(executeCurrentSql).toHaveBeenCalledWith(sql, { tabId: "tab-1" });
});
it("passes the selected statement's editor offset to the query store", async () => {
@@ -245,7 +245,7 @@ describe("useSqlExecution", () => {
await execution.tryExecute({ fullSql, selectedSql, cursorPos: selectionFrom, selectionFrom, selectionTo: fullSql.length - 1 });
- expect(executeCurrentSql).toHaveBeenCalledWith(selectedSql, { sourceOffset: selectionFrom });
+ expect(executeCurrentSql).toHaveBeenCalledWith(selectedSql, { tabId: "tab-1", sourceOffset: selectionFrom });
});
it("expands preceding @set values in a selected shell-style statement", async () => {
@@ -273,7 +273,7 @@ describe("useSqlExecution", () => {
await execution.tryExecute({ fullSql, selectedSql, cursorPos: selectionFrom, selectionFrom, selectionTo });
expect(execution.showSqlParameterDialog.value).toBe(false);
- expect(executeCurrentSql).toHaveBeenCalledWith("SELECT * FROM patrol WHERE post_id = '224';", {});
+ expect(executeCurrentSql).toHaveBeenCalledWith("SELECT * FROM patrol WHERE post_id = '224';", { tabId: "tab-1" });
});
it("opens the result table for a multi-statement batch by default", async () => {
@@ -671,7 +671,7 @@ SELECT @value AS Message;`;
await execution.tryExecuteInNewResultTab();
- expect(executeCurrentSql).toHaveBeenCalledWith(sql, { openInNewResultTab: true });
+ expect(executeCurrentSql).toHaveBeenCalledWith(sql, { tabId: "tab-1", openInNewResultTab: true });
});
it("does not record or refresh when a new-result execution restores the prior run", async () => {
@@ -723,7 +723,7 @@ SELECT @value AS Message;`;
await execution.onSqlParametersConfirm(resolvedSql);
- expect(executeCurrentSql).toHaveBeenCalledWith(resolvedSql, { openInNewResultTab: true });
+ expect(executeCurrentSql).toHaveBeenCalledWith(resolvedSql, { tabId: "tab-1", openInNewResultTab: true });
});
it("executes the PostgreSQL ARRAY date-format query without opening the parameter dialog", async () => {
@@ -783,7 +783,7 @@ SELECT @value AS Message;`;
expect(execution.showSqlParameterDialog.value).toBe(false);
expect(execution.sqlParameterNames.value).toEqual([]);
- expect(executeCurrentSql).toHaveBeenCalledWith(sql, {});
+ expect(executeCurrentSql).toHaveBeenCalledWith(sql, { tabId: "tab-1" });
});
it("executes Oracle database-link queries without opening the parameter dialog", async () => {
@@ -808,7 +808,7 @@ SELECT @value AS Message;`;
expect(execution.showSqlParameterDialog.value).toBe(false);
expect(execution.sqlParameterNames.value).toEqual([]);
- expect(executeCurrentSql).toHaveBeenCalledWith(sql, {});
+ expect(executeCurrentSql).toHaveBeenCalledWith(sql, { tabId: "tab-1" });
});
it("sends native SET variables without client-side expansion", async () => {
@@ -872,7 +872,7 @@ SELECT @value AS Message;`;
expect(execution.showSqlParameterDialog.value).toBe(false);
expect(execution.sqlParameterNames.value).toEqual([]);
- expect(executeCurrentSql).toHaveBeenCalledWith(sql, {});
+ expect(executeCurrentSql).toHaveBeenCalledWith(sql, { tabId: "tab-1" });
});
it("executes MySQL cursor procedures with compact labels without opening the parameter dialog", async () => {
@@ -912,7 +912,7 @@ SELECT @value AS Message;`;
expect(execution.showSqlParameterDialog.value).toBe(false);
expect(execution.sqlParameterNames.value).toEqual([]);
- expect(executeCurrentSql).toHaveBeenCalledWith(sql, {});
+ expect(executeCurrentSql).toHaveBeenCalledWith(sql, { tabId: "tab-1" });
});
it.each([
@@ -975,7 +975,7 @@ SELECT @value AS Message;`;
expect(execution.showSqlParameterDialog.value).toBe(false);
expect(execution.sqlParameterNames.value).toEqual([]);
- expect(executeCurrentSql).toHaveBeenCalledWith(sql, { sourceOffset: 0, onExecutionStarted: expect.any(Function) });
+ expect(executeCurrentSql).toHaveBeenCalledWith(sql, { tabId: "tab-1", sourceOffset: 0, onExecutionStarted: expect.any(Function) });
});
it("sends Doris STRUCT DDL unchanged without opening the parameter dialog", async () => {
@@ -1008,7 +1008,7 @@ SELECT @value AS Message;`;
expect(execution.showSqlParameterDialog.value).toBe(false);
expect(execution.sqlParameterNames.value).toEqual([]);
- expect(executeCurrentSql).toHaveBeenCalledWith(sql, {});
+ expect(executeCurrentSql).toHaveBeenCalledWith(sql, { tabId: "tab-1" });
});
it("records a later MySQL batch error and skips metadata refresh", async () => {
@@ -1287,7 +1287,7 @@ SELECT @value AS Message;`;
await execution.onDangerConfirm();
- expect(executeCurrentSql).toHaveBeenCalledWith(sql, { openInNewResultTab: true });
+ expect(executeCurrentSql).toHaveBeenCalledWith(sql, { tabId: "tab-1", openInNewResultTab: true });
});
it("keeps the active retained result when a new-result dangerous prompt is cancelled", async () => {
@@ -1493,7 +1493,7 @@ SELECT @value AS Message;`;
await execution.onDangerConfirm();
- expect(executeCurrentSql).toHaveBeenCalledWith(sql, { skipRedisSafetyCheck: false, openInNewResultTab: true });
+ expect(executeCurrentSql).toHaveBeenCalledWith(sql, { tabId: "tab-1", skipRedisSafetyCheck: false, openInNewResultTab: true });
});
it("distinguishes read-only and mutating Meilisearch REST requests", () => {
diff --git a/apps/desktop/src/composables/__tests__/useTabDrag.spec.ts b/apps/desktop/src/composables/__tests__/useTabDrag.spec.ts
deleted file mode 100644
index 121f05af72..0000000000
--- a/apps/desktop/src/composables/__tests__/useTabDrag.spec.ts
+++ /dev/null
@@ -1,233 +0,0 @@
-// @vitest-environment happy-dom
-
-import { afterEach, describe, expect, it, vi } from "vitest";
-import { TAB_DRAG_HORIZONTAL_THRESHOLD, useTabDrag } from "@/composables/useTabDrag";
-
-function createDragHarness(onDrop = vi.fn(() => true)) {
- const drag = useTabDrag(onDrop);
- const source = document.createElement("div");
- source.innerHTML = 'Source';
- source.addEventListener("pointerdown", (event) => drag.startDrag(event as PointerEvent, "source"));
- document.body.appendChild(source);
- return { drag, source, onDrop };
-}
-
-function beginDrag(source: HTMLElement, x = 100, y = 20, pointerType = "mouse", buttons = 1, pointerId = 1) {
- source.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0, buttons, clientX: x, clientY: y, pointerId, pointerType }));
-}
-
-function movePointer(x: number, y = 20, pointerType = "mouse", buttons = 1, pointerId = 1) {
- document.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, buttons, clientX: x, clientY: y, pointerId, pointerType }));
-}
-
-function releasePointer(x = 0, y = 0, pointerId = 1) {
- document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, clientX: x, clientY: y, pointerId, screenX: x, screenY: y }));
-}
-
-function cancelPointer(pointerId = 1) {
- document.dispatchEvent(new PointerEvent("pointercancel", { bubbles: true, pointerId }));
-}
-
-function enterDropTarget(drag: ReturnType, tabId = "target", x = 10) {
- const target = document.createElement("div");
- target.getBoundingClientRect = () => ({ x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 28, width: 100, height: 28, toJSON: () => ({}) });
- target.addEventListener("mousemove", (event) => drag.updateTarget(event, tabId));
- document.body.appendChild(target);
- target.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, clientX: x, clientY: 20 }));
-}
-
-afterEach(() => {
- releasePointer();
- document.body.innerHTML = "";
- document.body.style.cursor = "";
- document.body.style.userSelect = "";
-});
-
-describe("useTabDrag", () => {
- it("ignores sub-threshold horizontal movement and vertical pointer jitter", () => {
- const { drag, source, onDrop } = createDragHarness();
- beginDrag(source);
-
- movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD - 1, 120);
-
- expect(drag.state.active).toBe(false);
- expect(drag.state.suppressClick).toBe(false);
- expect(onDrop).not.toHaveBeenCalled();
- });
-
- it("does not suppress the click when a drag gesture has no valid drop target", () => {
- const { drag, source, onDrop } = createDragHarness();
- beginDrag(source);
- movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD);
- expect(drag.state.active).toBe(true);
-
- releasePointer();
-
- expect(onDrop).not.toHaveBeenCalled();
- expect(drag.state.suppressClick).toBe(false);
- });
-
- it("does not suppress the click when the drop callback reports no reorder", () => {
- const onDrop = vi.fn(() => false);
- const { drag, source } = createDragHarness(onDrop);
- beginDrag(source);
- movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD);
- enterDropTarget(drag);
-
- releasePointer();
-
- expect(onDrop).toHaveBeenCalledWith("source", "target", "before");
- expect(drag.state.suppressClick).toBe(false);
- });
-
- it("suppresses the click only after a completed reorder", () => {
- const { drag, source, onDrop } = createDragHarness();
- beginDrag(source);
- movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD);
- enterDropTarget(drag, "target", 90);
-
- releasePointer();
-
- expect(onDrop).toHaveBeenCalledWith("source", "target", "after");
- expect(drag.state.suppressClick).toBe(true);
-
- beginDrag(source);
- expect(drag.state.suppressClick).toBe(false);
- });
-
- it("clears completed-drag click suppression on a later trackpad tap", () => {
- const { drag, source } = createDragHarness();
- beginDrag(source);
- movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD);
- enterDropTarget(drag, "target", 90);
- releasePointer();
- expect(drag.state.suppressClick).toBe(true);
-
- beginDrag(source, 100, 20, "mouse", 0);
-
- expect(drag.state.active).toBe(false);
- expect(drag.state.suppressClick).toBe(false);
- });
-
- it("ignores 18px of jitter, matching a typical touchscreen tap reported as generic mouse input", () => {
- const { drag, source, onDrop } = createDragHarness();
- beginDrag(source);
-
- movePointer(100 - 18, 24);
-
- expect(drag.state.active).toBe(false);
- expect(drag.state.suppressClick).toBe(false);
- expect(onDrop).not.toHaveBeenCalled();
- });
-
- it("does not arm a drag when the primary button is not held", () => {
- const { drag, source, onDrop } = createDragHarness();
- beginDrag(source, 100, 20, "mouse", 0);
- movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD, 20, "mouse", 0);
-
- releasePointer();
-
- expect(drag.state.active).toBe(false);
- expect(onDrop).not.toHaveBeenCalled();
- });
-
- it("cancels a pending drag when the primary button is released before movement", () => {
- const { drag, source, onDrop } = createDragHarness();
- beginDrag(source);
- movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD, 20, "mouse", 0);
- movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD + 10);
-
- expect(drag.state.active).toBe(false);
- expect(onDrop).not.toHaveBeenCalled();
- });
-
- it("cancels an active drag when the primary button is no longer held", () => {
- const { drag, source, onDrop } = createDragHarness();
- beginDrag(source);
- movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD);
- expect(drag.state.active).toBe(true);
-
- movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD + 10, 20, "mouse", 0);
-
- expect(drag.state.active).toBe(false);
- expect(drag.state.draggedId).toBe(null);
- expect(document.body.style.cursor).toBe("");
- expect(document.body.style.userSelect).toBe("");
- expect(onDrop).not.toHaveBeenCalled();
- });
-
- it("ignores movement from a different pointer", () => {
- const { drag, source, onDrop } = createDragHarness();
- beginDrag(source, 100, 20, "mouse", 1, 7);
- movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD, 20, "mouse", 1, 8);
-
- expect(drag.state.active).toBe(false);
- expect(onDrop).not.toHaveBeenCalled();
- releasePointer(0, 0, 7);
- });
-
- it("never arms a drag for touch input, even past the horizontal threshold", () => {
- const { drag, source, onDrop } = createDragHarness();
- beginDrag(source, 100, 20, "touch");
-
- movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD + 20, 20, "touch");
-
- expect(drag.state.active).toBe(false);
- expect(drag.state.suppressClick).toBe(false);
- expect(onDrop).not.toHaveBeenCalled();
- });
-
- it("detaches only when an armed drag is released outside the tab strip", () => {
- const onDetach = vi.fn(() => true);
- const drag = useTabDrag(
- vi.fn(() => true),
- onDetach,
- );
- drag.setDetachBoundsProvider(() => ({ left: 0, top: 0, right: 300, bottom: 40 }) as DOMRect);
- const source = document.createElement("div");
- source.innerHTML = 'Source';
- source.addEventListener("pointerdown", (event) => drag.startDrag(event as PointerEvent, "source"));
- document.body.appendChild(source);
-
- beginDrag(source);
- movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD);
- releasePointer(500, 500);
-
- expect(onDetach).toHaveBeenCalledWith("source", { x: 500, y: 500 });
- expect(drag.state.active).toBe(false);
- expect(drag.state.suppressClick).toBe(true);
- drag.setDetachBoundsProvider(null);
- });
-
- it("clears an armed drag when the pointer is cancelled", () => {
- const { drag, source } = createDragHarness();
- beginDrag(source);
- movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD);
- expect(drag.state.active).toBe(true);
-
- cancelPointer();
-
- expect(drag.state.active).toBe(false);
- expect(drag.state.draggedId).toBe(null);
- expect(drag.state.suppressClick).toBe(false);
- });
-
- it("does not detach when the pointer is released inside the tab strip", () => {
- const onDetach = vi.fn(() => true);
- const onDrop = vi.fn(() => false);
- const drag = useTabDrag(onDrop, onDetach);
- drag.setDetachBoundsProvider(() => ({ left: 0, top: 0, right: 300, bottom: 40 }) as DOMRect);
- const source = document.createElement("div");
- source.innerHTML = 'Source';
- source.addEventListener("pointerdown", (event) => drag.startDrag(event as PointerEvent, "source"));
- document.body.appendChild(source);
-
- beginDrag(source);
- movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD);
- releasePointer();
-
- expect(onDetach).not.toHaveBeenCalled();
- expect(onDrop).not.toHaveBeenCalled();
- drag.setDetachBoundsProvider(null);
- });
-});
diff --git a/apps/desktop/src/composables/__tests__/useToolbarOverflow.spec.ts b/apps/desktop/src/composables/__tests__/useToolbarOverflow.spec.ts
new file mode 100644
index 0000000000..622c4cc1fa
--- /dev/null
+++ b/apps/desktop/src/composables/__tests__/useToolbarOverflow.spec.ts
@@ -0,0 +1,133 @@
+// @vitest-environment happy-dom
+import { createApp, defineComponent, h, nextTick, ref } from "vue";
+import type { Ref, WatchSource } from "vue";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { useToolbarOverflow } from "../useToolbarOverflow";
+import type { EditorToolbarTier } from "@/lib/tabs/editorToolbarLayout";
+
+function createHarness(revalidateSources: WatchSource[] = []) {
+ const rootRef = ref(null);
+ let tierRef: Ref | undefined;
+ let measureFn: () => void = () => {};
+ const app = createApp(
+ defineComponent({
+ setup() {
+ const { tier, measure } = useToolbarOverflow(rootRef, revalidateSources);
+ tierRef = tier;
+ measureFn = measure;
+ return () => h("div");
+ },
+ }),
+ );
+ app.mount(document.createElement("div"));
+ return { rootRef, tierRef: tierRef!, measure: () => measureFn() };
+}
+
+/**
+ * Fake toolbar row whose scrollWidth shrinks once a tier is condensed —
+ * mirroring real rows where controls move into the overflow menu, so the
+ * measure loop can converge instead of running to the max tier.
+ */
+function fakeRow(clientWidth: number, contentWidthAtTier: (tier: EditorToolbarTier) => number, tierSource: Ref): HTMLElement {
+ const element = document.createElement("div");
+ Object.defineProperty(element, "clientWidth", { value: clientWidth, configurable: true });
+ Object.defineProperty(element, "scrollWidth", { configurable: true, get: () => contentWidthAtTier(tierSource.value) });
+ return element;
+}
+
+async function flush(ticks = 5) {
+ for (let i = 0; i < ticks; i += 1) {
+ await nextTick();
+ }
+}
+
+describe("useToolbarOverflow", () => {
+ beforeEach(() => {
+ document.body.innerHTML = "";
+ });
+
+ afterEach(() => {
+ document.body.innerHTML = "";
+ });
+
+ it("steps up while the row content overflows and keeps the tier when it merely fits again", async () => {
+ const { rootRef, tierRef, measure } = createHarness();
+
+ rootRef.value = fakeRow(200, (tier) => (tier >= 1 ? 180 : 260), tierRef);
+ await flush();
+ expect(tierRef.value).toBe(1);
+
+ // Widening without enough growth keeps the condensed tier: the row fits
+ // (slack 20px < 48px) and the pane grew 0px since the condensation. The
+ // explicit measure() stands in for the ResizeObserver event that a real
+ // browser fires after the box resize.
+ Object.defineProperty(rootRef.value!, "clientWidth", { value: 220, configurable: true });
+ measure();
+ expect(tierRef.value).toBe(1);
+ });
+
+ it("condenses tier by tier and caps at the max tier while still overflowing", async () => {
+ const { rootRef, tierRef } = createHarness();
+
+ rootRef.value = fakeRow(160, () => 400, tierRef);
+ await flush();
+ expect(tierRef.value).toBe(3);
+ });
+
+ it("steps down only with real slack and meaningful pane growth", async () => {
+ const { rootRef, tierRef, measure } = createHarness();
+
+ rootRef.value = fakeRow(200, (tier) => (tier >= 1 ? 180 : 260), tierRef);
+ await flush();
+ expect(tierRef.value).toBe(1);
+
+ // Grew by 10px only: below the 64px growth hysteresis, no step down even
+ // though the condensed row now has slack.
+ Object.defineProperty(rootRef.value!, "clientWidth", { value: 210, configurable: true });
+ measure();
+ expect(tierRef.value).toBe(1);
+
+ // Grew by 100px with 70px slack: both step-down thresholds pass.
+ Object.defineProperty(rootRef.value!, "clientWidth", { value: 300, configurable: true });
+ measure();
+ expect(tierRef.value).toBe(0);
+ });
+
+ it("re-measures when a revalidate source changes the control set", async () => {
+ const flag = ref(0);
+ const { rootRef, tierRef } = createHarness([() => flag.value]);
+ let contentGrew = false;
+
+ rootRef.value = fakeRow(200, (tier) => (tier >= 1 ? 180 : contentGrew ? 260 : 180), tierRef);
+ await flush();
+ expect(tierRef.value).toBe(0);
+
+ // A conditional control appeared: the content extent grew without the
+ // toolbar box resizing (no ResizeObserver event) — only the source watch
+ // re-measures this.
+ contentGrew = true;
+ flag.value += 1;
+ await flush();
+ expect(tierRef.value).toBe(1);
+ });
+
+ it("starts from the full layout when a row re-attaches instead of inheriting a stale tier", async () => {
+ const { rootRef, tierRef } = createHarness();
+
+ rootRef.value = fakeRow(200, () => 400, tierRef);
+ await flush();
+ expect(tierRef.value).toBe(3);
+
+ rootRef.value = fakeRow(300, () => 230, tierRef);
+ await flush();
+ expect(tierRef.value).toBe(0);
+ });
+
+ it("keeps the current tier while the row is unmeasured", async () => {
+ const { rootRef, tierRef } = createHarness();
+
+ rootRef.value = fakeRow(0, () => 0, tierRef);
+ await flush();
+ expect(tierRef.value).toBe(0);
+ });
+});
diff --git a/apps/desktop/src/composables/useDataGridActions.ts b/apps/desktop/src/composables/useDataGridActions.ts
index feee864795..4e83dabf10 100644
--- a/apps/desktop/src/composables/useDataGridActions.ts
+++ b/apps/desktop/src/composables/useDataGridActions.ts
@@ -45,6 +45,21 @@ export function useDataGridActions(activeTab: ComputedRef)
const queryStore = useQueryStore();
const settingsStore = useSettingsStore();
+ // A data-grid action names the tab that emitted the event, so a queued
+ // or late-routed event cannot fall back to whichever tab is active now.
+ function resolveActionTab(tabId: string | undefined): QueryTab | undefined {
+ if (!tabId) {
+ return activeTab.value;
+ }
+ const fromStore = queryStore.tabs.find((candidate) => candidate.id === tabId);
+ if (fromStore) {
+ return fromStore;
+ }
+ // Hosts may hold the acting tab outside the store array; identity comes
+ // from the captured id either way.
+ return activeTab.value?.id === tabId ? activeTab.value : undefined;
+ }
+
function activeQueryTargetOptions(tab: QueryTab) {
const executionTarget = queryStore.activeResultExecutionTarget(tab.id);
if (!executionTarget) return {};
@@ -162,15 +177,15 @@ export function useDataGridActions(activeTab: ComputedRef)
return true;
}
- async function onExecuteSql(sql: string) {
- const tab = activeTab.value;
+ async function onExecuteSql(sql: string, tabId?: string) {
+ const tab = resolveActionTab(tabId);
if (!tab) return;
queryStore.updateSql(tab.id, sql);
await queryStore.executeTabSql(tab.id, sql, { preserveResultDuringExecution: true });
}
- async function onReloadData(sql?: string, _searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number, intent?: DataGridReloadIntent) {
- const tab = activeTab.value;
+ async function onReloadData(tabId: string | undefined, sql?: string, _searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number, intent?: DataGridReloadIntent) {
+ const tab = resolveActionTab(tabId);
if (!tab) return;
const traceId = uuid().slice(0, 8);
const startedAt = performance.now();
@@ -328,8 +343,8 @@ export function useDataGridActions(activeTab: ComputedRef)
await queryStore.executeCurrentTab();
}
- async function onPaginate(offset: number, limit: number, whereInput?: string, orderBy?: string) {
- const tab = activeTab.value;
+ async function onPaginate(tabId: string | undefined, offset: number, limit: number, whereInput?: string, orderBy?: string) {
+ const tab = resolveActionTab(tabId);
if (!tab) return;
const appendResult = settingsStore.editorSettings.infiniteScroll && offset > 0 && offset === tab.result?.rows.length;
const appendOptions = appendResult
@@ -462,8 +477,8 @@ export function useDataGridActions(activeTab: ComputedRef)
});
}
- async function onSort(column: string, columnIndex: number, direction: "asc" | "desc" | null, whereInput?: string, mode: DataGridSortMode = "database") {
- const tab = activeTab.value;
+ async function onSort(tabId: string | undefined, column: string, columnIndex: number, direction: "asc" | "desc" | null, whereInput?: string, mode: DataGridSortMode = "database") {
+ const tab = resolveActionTab(tabId);
if (!tab) return;
tab.resultSortColumn = direction ? column : undefined;
tab.resultSortColumnIndex = direction ? columnIndex : undefined;
diff --git a/apps/desktop/src/composables/useDataGridSelection.ts b/apps/desktop/src/composables/useDataGridSelection.ts
index de269f0d3b..4db53eb97d 100644
--- a/apps/desktop/src/composables/useDataGridSelection.ts
+++ b/apps/desktop/src/composables/useDataGridSelection.ts
@@ -43,9 +43,9 @@ const AUTO_SCROLL_EDGE_SIZE = 40;
const AUTO_SCROLL_MAX_SPEED = 28;
// Trackpad/mouse jitter during a plain click can move the pointer a few pixels between
// mousedown and mouseup. Without a minimum drag distance, that jitter reads as an
-// intentional drag and turns a single click into a multi-cell/row selection. Guards the
-// same class of gesture as TAB_DRAG_HORIZONTAL_THRESHOLD in useTabDrag.ts, though that one
-// is tuned higher to also absorb touchscreen tap jitter, which doesn't apply here.
+// intentional drag and turns a single click into a multi-cell/row selection. Same class
+// of gesture as the group tab bar's horizontal tab-drag threshold, though that one is
+// tuned higher to also absorb touchscreen tap jitter, which doesn't apply here.
const GRID_SELECTION_DRAG_THRESHOLD_PX = 12;
type RowSelectionOperation = "replace" | "add" | "remove";
diff --git a/apps/desktop/src/composables/useSqlExecution.ts b/apps/desktop/src/composables/useSqlExecution.ts
index a88189510d..986eae21f2 100644
--- a/apps/desktop/src/composables/useSqlExecution.ts
+++ b/apps/desktop/src/composables/useSqlExecution.ts
@@ -34,6 +34,18 @@ const DANGER_RE = /^\s*(DROP|DELETE|TRUNCATE|ALTER|UPDATE|MERGE|REPLACE)\b/i;
interface SqlExecutionOptions {
openInNewResultTab?: boolean;
editorViewportRequestId?: number;
+ /** Tab that owns this execution request; resolved once, never re-read from the global active tab. */
+ tabId?: string;
+}
+
+/**
+ * Targeting context captured synchronously when an execution request starts.
+ * Async resume points (danger / parameter dialogs, awaits) must keep using it
+ * instead of re-reading the global active tab or active connection.
+ */
+interface SqlExecutionContext {
+ tabId: string;
+ connection: ConnectionConfig | undefined;
}
interface TargetSqlExecutionInput {
@@ -112,10 +124,10 @@ export function useSqlExecution(deps: {
activeTab: ComputedRef;
activeConnection: ComputedRef;
executableSql: ComputedRef;
- resolveExecutableSql?: (snapshot?: SqlExecutionSnapshot) => Promise;
+ resolveExecutableSql?: (snapshot?: SqlExecutionSnapshot, tab?: QueryTab) => Promise;
activeOutputView: Ref<"result" | "summary" | "explain" | "chart" | "messages">;
blockDangerousRedisCommands?: Ref;
- onMissingDatabase?: () => void;
+ onMissingDatabase?: (tabId?: string) => void;
requestDangerConfirmation?: (request: SqlExecutionDangerRequest) => Promise;
onExecutionStarted?: (editorViewportRequestId: number) => void;
onExecutionCancelled?: (editorViewportRequestId: number) => void;
@@ -144,19 +156,47 @@ export function useSqlExecution(deps: {
const pendingOpenInNewResultTab = ref(false);
const pendingSqlParameterEditorViewportRequestId = ref();
const pendingDangerEditorViewportRequestId = ref();
+ const pendingDangerTabId = ref();
+ const pendingSqlParameterTabId = ref();
let pendingSqlParameterContinuation: ((sql: string, sourceOffset?: number) => Promise | void) | undefined;
function cancelEditorViewportRequest(editorViewportRequestId?: number) {
if (editorViewportRequestId !== undefined) deps.onExecutionCancelled?.(editorViewportRequestId);
}
- async function resolvedExecutableSql(source?: SqlExecutionOverride): Promise<{ sql: string; sourceOffset?: number; editorViewportRequestId?: number }> {
- const atSetEnabled = resolveSqlVariableSyntaxToggles(settingsStore.editorSettings.sqlVariableSyntaxOverrides, deps.activeConnection.value?.db_type, settingsStore.editorSettings.sqlVariableSubstitutionEnabled).atSet;
- const databaseType = effectiveDatabaseTypeForConnection(deps.activeConnection.value) ?? deps.activeConnection.value?.db_type;
+ function resolveExecutionTab(tabId: string | undefined): QueryTab | undefined {
+ if (!tabId) {
+ return deps.activeTab.value;
+ }
+ const fromStore = queryStore.tabs.find((tab) => tab.id === tabId);
+ if (fromStore) {
+ return fromStore;
+ }
+ // The host may hold the acting tab outside the store array (multi-db
+ // workers, hosts under test); identity still comes from the captured id.
+ return deps.activeTab.value?.id === tabId ? deps.activeTab.value : undefined;
+ }
+
+ function resolveExecutionConnection(tabId: string | undefined): ConnectionConfig | undefined {
+ const tab = resolveExecutionTab(tabId);
+ return (tab ? connectionStore.getConfig(tab.connectionId) : undefined) ?? deps.activeConnection.value;
+ }
+
+ function captureExecutionContext(tabId?: string): SqlExecutionContext | undefined {
+ const tab = resolveExecutionTab(tabId);
+ if (!tab) {
+ return undefined;
+ }
+ return { tabId: tab.id, connection: resolveExecutionConnection(tabId) };
+ }
+
+ async function resolvedExecutableSql(source: SqlExecutionOverride | undefined, context: SqlExecutionContext): Promise<{ sql: string; sourceOffset?: number; editorViewportRequestId?: number }> {
+ const atSetEnabled = resolveSqlVariableSyntaxToggles(settingsStore.editorSettings.sqlVariableSyntaxOverrides, context.connection?.db_type, settingsStore.editorSettings.sqlVariableSubstitutionEnabled).atSet;
+ const databaseType = effectiveDatabaseTypeForConnection(context.connection) ?? context.connection?.db_type;
const expand = (sql: string, declarationSql?: string) => (atSetEnabled ? expandSqlVariables(sql, { declarationSql, databaseType }).sql : sql);
if (typeof source === "string") return { sql: expand(source) };
- const resolved = deps.resolveExecutableSql ? await deps.resolveExecutableSql(source) : isSqlExecutionSnapshot(source) ? resolveExecutableSql(source.fullSql, source.selectedSql, { cursorPos: source.cursorPos }) : deps.executableSql.value;
+ const resolved = deps.resolveExecutableSql ? await deps.resolveExecutableSql(source, resolveExecutionTab(context.tabId)) : isSqlExecutionSnapshot(source) ? resolveExecutableSql(source.fullSql, source.selectedSql, { cursorPos: source.cursorPos }) : deps.executableSql.value;
const declarationSql = isSqlExecutionSnapshot(source) ? source.fullSql.slice(0, source.selectionTo) : resolved;
const sql = expand(resolved, declarationSql);
const editorViewportRequestId = isSqlExecutionSnapshot(source) ? source.editorViewportRequestId : undefined;
@@ -167,33 +207,49 @@ export function useSqlExecution(deps: {
}
async function tryExecute(sqlOverride?: SqlExecutionOverride, options: SqlExecutionOptions = {}) {
- const tab = deps.activeTab.value;
- const { sql, sourceOffset, editorViewportRequestId } = await resolvedExecutableSql(sqlOverride);
- const executionOptions = { ...options, editorViewportRequestId };
- if (!tab || !sql.trim()) {
+ const context = captureExecutionContext(options.tabId);
+ if (!context) {
+ cancelEditorViewportRequest(options.editorViewportRequestId);
+ return;
+ }
+ const { sql, sourceOffset, editorViewportRequestId } = await resolvedExecutableSql(sqlOverride, context);
+ const executionOptions = { ...options, editorViewportRequestId, tabId: context.tabId };
+ if (!sql.trim()) {
cancelEditorViewportRequest(editorViewportRequestId);
return;
}
- if (requiresDatabaseSelection(tab, deps.activeConnection.value, sql)) {
- deps.onMissingDatabase?.();
+ const tab = resolveExecutionTab(context.tabId);
+ if (!tab) {
cancelEditorViewportRequest(editorViewportRequestId);
return;
}
- if (supportsSqlTemplateParameters(deps.activeConnection.value, sql) && prepareSqlParameterDialog(sql, sourceOffset, executionOptions)) return;
- await continueExecute(sql, sourceOffset, executionOptions);
+ if (requiresDatabaseSelection(tab, context.connection, sql)) {
+ deps.onMissingDatabase?.(context.tabId);
+ cancelEditorViewportRequest(editorViewportRequestId);
+ return;
+ }
+ if (supportsSqlTemplateParameters(context.connection, sql) && prepareSqlParameterDialog(sql, sourceOffset, executionOptions)) {
+ return;
+ }
+ await continueExecute(context, sql, sourceOffset, executionOptions);
}
- function tryExecuteInNewResultTab(sqlOverride?: SqlExecutionOverride) {
- return tryExecute(sqlOverride, { openInNewResultTab: true });
+ function tryExecuteInNewResultTab(sqlOverride?: SqlExecutionOverride, options: SqlExecutionOptions = {}) {
+ return tryExecute(sqlOverride, { ...options, openInNewResultTab: true });
}
- async function continueExecute(sql: string, sourceOffset?: number, options: SqlExecutionOptions = {}) {
- if (!(await ensureReadOnlyWriteAccess({ connection: deps.activeConnection.value, sql, source: t("production.sourceSqlEditor") }))) {
+ async function continueExecute(context: SqlExecutionContext, sql: string, sourceOffset?: number, options: SqlExecutionOptions = {}) {
+ const tab = resolveExecutionTab(context.tabId);
+ if (!tab) {
+ cancelEditorViewportRequest(options.editorViewportRequestId);
+ return;
+ }
+ if (!(await ensureReadOnlyWriteAccess({ connection: context.connection, sql, source: t("production.sourceSqlEditor") }))) {
cancelEditorViewportRequest(options.editorViewportRequestId);
return;
}
// Redis: block dangerous commands when toggle is on (scan entire batch for highest safety level)
- if (deps.activeConnection.value?.db_type === "redis" && deps.blockDangerousRedisCommands?.value !== false) {
+ if (context.connection?.db_type === "redis" && deps.blockDangerousRedisCommands?.value !== false) {
const commands = sql
.split("\n")
.map((line) => line.trim())
@@ -221,32 +277,37 @@ export function useSqlExecution(deps: {
pendingDangerSourceOffset.value = sourceOffset;
pendingOpenInNewResultTab.value = options.openInNewResultTab === true;
pendingDangerEditorViewportRequestId.value = options.editorViewportRequestId;
+ pendingDangerTabId.value = context.tabId;
suppressDangerConfirm.value = false;
showDangerDialog.value = true;
return;
}
}
- const productionAssessment = assessProductionSql(sql, deps.activeConnection.value, deps.activeTab.value?.database);
+ const productionAssessment = assessProductionSql(sql, context.connection, tab.database);
if (productionAssessment.active && productionAssessment.isMutation) {
// Production writes always need a new explicit decision; editor preferences cannot suppress this gate.
const confirmed = await productionSafetyStore.requestConfirmation({
sql,
- connectionName: deps.activeConnection.value?.name,
- database: deps.activeTab.value?.database,
+ connectionName: context.connection?.name,
+ database: tab.database,
productionDatabases: productionAssessment.databases,
source: t("production.sourceSqlEditor"),
});
- if (confirmed) await doExecute(sql, sourceOffset, options);
- else cancelEditorViewportRequest(options.editorViewportRequestId);
+ if (confirmed) {
+ await doExecute(sql, sourceOffset, options);
+ } else {
+ cancelEditorViewportRequest(options.editorViewportRequestId);
+ }
return;
}
- if (isDangerousSql(sql, deps.activeConnection.value?.db_type) && settingsStore.editorSettings.confirmDangerousSqlExecution) {
+ if (isDangerousSql(sql, context.connection?.db_type) && settingsStore.editorSettings.confirmDangerousSqlExecution) {
dangerSql.value = sql;
pendingDangerSql.value = sql;
pendingDangerKind.value = "sql";
pendingDangerSourceOffset.value = sourceOffset;
pendingOpenInNewResultTab.value = options.openInNewResultTab === true;
pendingDangerEditorViewportRequestId.value = options.editorViewportRequestId;
+ pendingDangerTabId.value = context.tabId;
suppressDangerConfirm.value = false;
showDangerDialog.value = true;
} else {
@@ -255,12 +316,14 @@ export function useSqlExecution(deps: {
}
function prepareSqlParameterDialog(sql: string, sourceOffset?: number, options: SqlExecutionOptions = {}, continuation?: (sql: string, sourceOffset?: number) => Promise | void): boolean {
- const connection = deps.activeConnection.value;
+ const connection = resolveExecutionConnection(options.tabId);
const databaseType = effectiveDatabaseTypeForConnection(connection) ?? connection?.db_type;
const toggles = resolveSqlVariableSyntaxToggles(settingsStore.editorSettings.sqlVariableSyntaxOverrides, databaseType, settingsStore.editorSettings.sqlVariableSubstitutionEnabled);
const enabledSyntaxes = enabledSqlParameterSyntaxes(toggles);
const parameters = extractSqlParameterDescriptors(sql, { databaseType, enabledSyntaxes });
- if (!parameters.length) return false;
+ if (!parameters.length) {
+ return false;
+ }
sqlParameterSourceSql.value = sql;
sqlParameterNames.value = parameters;
sqlParameterDatabaseType.value = databaseType;
@@ -268,16 +331,22 @@ export function useSqlExecution(deps: {
pendingSourceOffset.value = sourceOffset;
pendingOpenInNewResultTab.value = options.openInNewResultTab === true;
pendingSqlParameterEditorViewportRequestId.value = options.editorViewportRequestId;
+ pendingSqlParameterTabId.value = options.tabId;
pendingSqlParameterContinuation = continuation;
showSqlParameterDialog.value = true;
return true;
}
async function prepareMultiExecute(onReady: (sql: string, sourceOffset?: number) => Promise | void): Promise {
- const tab = deps.activeTab.value;
- const { sql, sourceOffset } = await resolvedExecutableSql();
- if (!tab || !sql.trim()) return false;
- if (supportsSqlTemplateParameters(deps.activeConnection.value, sql) && prepareSqlParameterDialog(sql, sourceOffset, {}, onReady)) return true;
+ const context = captureExecutionContext();
+ if (!context) {
+ return false;
+ }
+ const { sql, sourceOffset } = await resolvedExecutableSql(undefined, context);
+ if (!sql.trim()) return false;
+ if (supportsSqlTemplateParameters(context.connection, sql) && prepareSqlParameterDialog(sql, sourceOffset, { tabId: context.tabId }, onReady)) {
+ return true;
+ }
await onReady(sql, sourceOffset);
return false;
}
@@ -300,26 +369,41 @@ export function useSqlExecution(deps: {
async function doExecute(sql?: string, sourceOffset?: number, options: SqlExecutionOptions = {}) {
if (sql === undefined) {
- ({ sql, sourceOffset } = await resolvedExecutableSql());
+ const context = captureExecutionContext(options.tabId);
+ if (!context) {
+ cancelEditorViewportRequest(options.editorViewportRequestId);
+ return;
+ }
+ ({ sql, sourceOffset } = await resolvedExecutableSql(undefined, context));
}
- const tab = deps.activeTab.value;
- if (!tab || !sql.trim()) {
+ const executionTabId = options.tabId ?? deps.activeTab.value?.id;
+ if (!executionTabId || !sql || !sql.trim()) {
+ cancelEditorViewportRequest(options.editorViewportRequestId);
+ return;
+ }
+ const tab = resolveExecutionTab(executionTabId);
+ if (!tab) {
cancelEditorViewportRequest(options.editorViewportRequestId);
return;
}
const executionConnection = connectionStore.getConfig(tab.connectionId) ?? deps.activeConnection.value;
const executionDatabaseType = executionConnection?.db_type;
if (requiresDatabaseSelection(tab, executionConnection, sql)) {
- deps.onMissingDatabase?.();
+ deps.onMissingDatabase?.(executionTabId);
cancelEditorViewportRequest(options.editorViewportRequestId);
return;
}
const statementCount = splitSqlStatementRanges(sql, executionDatabaseType).length;
- deps.activeOutputView.value = statementCount > 1 ? settingsStore.editorSettings.multiStatementDefaultView : "result";
+ // Output-view switching belongs to the tab the user is looking at — both
+ // when the query starts and when it finishes.
+ if (deps.activeTab.value?.id === executionTabId) {
+ deps.activeOutputView.value = statementCount > 1 ? settingsStore.editorSettings.multiStatementDefaultView : "result";
+ }
const connName = executionConnection?.name || "";
const start = Date.now();
const isRedis = executionDatabaseType === "redis";
const producedResult = await queryStore.executeCurrentSql(sql, {
+ tabId: executionTabId,
...(isRedis ? { skipRedisSafetyCheck: deps.blockDangerousRedisCommands?.value === false } : {}),
...(sourceOffset !== undefined ? { sourceOffset } : {}),
...(options.openInNewResultTab ? { openInNewResultTab: true } : {}),
@@ -329,15 +413,21 @@ export function useSqlExecution(deps: {
cancelEditorViewportRequest(options.editorViewportRequestId);
return;
}
- const executionTabStillActive = deps.activeTab.value?.id === tab.id;
+ const executionTabStillActive = deps.activeTab.value?.id === executionTabId;
const sqlServerMessageResultIndex = executionDatabaseType === "sqlserver" ? tab.results?.findIndex((result) => result.server_message === true) : undefined;
if (sqlServerMessageResultIndex !== undefined && sqlServerMessageResultIndex >= 0) {
focusSqlServerDataResult(tab.id, executionDatabaseType, tab);
- if (executionTabStillActive) deps.activeOutputView.value = "result";
+ if (executionTabStillActive) {
+ deps.activeOutputView.value = "result";
+ }
} else if (executionDatabaseType === "sqlserver" && tab.result?.server_message === true) {
- if (executionTabStillActive) deps.activeOutputView.value = "result";
+ if (executionTabStillActive) {
+ deps.activeOutputView.value = "result";
+ }
} else if (tab.result && !tab.result.columns.length && !tab.results?.some((result) => result.columns.length > 0)) {
- if (executionTabStillActive) deps.activeOutputView.value = statementCount === 1 ? defaultViewForResult(tab.result) : "summary";
+ if (executionTabStillActive) {
+ deps.activeOutputView.value = statementCount === 1 ? defaultViewForResult(tab.result) : "summary";
+ }
}
const elapsed = Date.now() - start;
const failure = firstQueryExecutionError(tab);
@@ -529,50 +619,83 @@ export function useSqlExecution(deps: {
}
}
- function cancelActiveExecution() {
- const tab = deps.activeTab.value;
- if (!tab) return;
- if (tab.isExecuting) void queryStore.cancelTabExecution(tab.id);
- else if (tab.isExplaining) void queryStore.cancelTabExplain(tab.id);
+ /**
+ * Opens the danger dialog for an App-level flow (AI auto-execution) that has
+ * already made its own gating decision. The acting tab id is captured here so
+ * the confirmation resumes against the requesting tab, not whichever tab is
+ * active when the user confirms.
+ */
+ function requestDangerConfirmation(sql: string, tabId?: string) {
+ dangerSql.value = sql;
+ pendingDangerSql.value = sql;
+ pendingDangerKind.value = "sql";
+ pendingOpenInNewResultTab.value = false;
+ pendingDangerTabId.value = tabId;
+ suppressDangerConfirm.value = false;
+ showDangerDialog.value = true;
+ }
+
+ function cancelActiveExecution(tabId?: string) {
+ const tab = resolveExecutionTab(tabId);
+ if (!tab) {
+ return;
+ }
+ if (tab.isExecuting) {
+ void queryStore.cancelTabExecution(tab.id);
+ } else if (tab.isExplaining) {
+ void queryStore.cancelTabExplain(tab.id);
+ }
}
function explainReasonMessage(reason: string): string {
- if (reason === "unsupported") return t("explain.unsupported");
- if (reason === "unsafe") return t("explain.unsafe");
+ if (reason === "unsupported") {
+ return t("explain.unsupported");
+ }
+ if (reason === "unsafe") {
+ return t("explain.unsafe");
+ }
return t("explain.emptySql");
}
- async function runExplain(sql: string) {
- const tab = deps.activeTab.value;
+ async function runExplain(sql: string, context: SqlExecutionContext) {
+ const tab = resolveExecutionTab(context.tabId);
if (!tab || !sql.trim()) {
toast(t("explain.emptySql"));
return;
}
- deps.activeOutputView.value = "explain";
- const connection = deps.activeConnection.value;
- const databaseType = effectiveDatabaseTypeForConnection(connection) ?? connection?.db_type;
+ if (deps.activeTab.value?.id === tab.id) {
+ deps.activeOutputView.value = "explain";
+ }
+ const databaseType = effectiveDatabaseTypeForConnection(context.connection) ?? context.connection?.db_type;
const result = await queryStore.explainTabSql(tab.id, sql, databaseType, explainMode.value);
if (!result.ok) {
toast(explainReasonMessage(result.reason), 5000);
return;
}
- const current = deps.activeTab.value;
- if (current?.explainError) toast(current.explainError, 5000);
+ const current = resolveExecutionTab(context.tabId);
+ if (current?.explainError) {
+ toast(current.explainError, 5000);
+ }
}
- async function tryExplain(sqlOverride?: SqlExecutionOverride) {
- const tab = deps.activeTab.value;
- const { sql, sourceOffset } = await resolvedExecutableSql(sqlOverride);
- if (!tab || !sql.trim()) {
+ async function tryExplain(sqlOverride?: SqlExecutionOverride, options: SqlExecutionOptions = {}) {
+ const context = captureExecutionContext(options.tabId);
+ if (!context) {
+ return;
+ }
+ const { sql, sourceOffset } = await resolvedExecutableSql(sqlOverride, context);
+ if (!sql.trim()) {
toast(t("explain.emptySql"));
return;
}
// Resolve SQL template variables exactly like tryExecute so EXPLAIN never runs the raw
// placeholders (e.g. `EXPLAIN (FORMAT JSON) SELECT :var;` fails on PostgreSQL).
- if (supportsSqlTemplateParameters(deps.activeConnection.value, sql) && prepareSqlParameterDialog(sql, sourceOffset, {}, (resolvedSql) => runExplain(resolvedSql))) return;
- await runExplain(sql);
+ if (supportsSqlTemplateParameters(context.connection, sql) && prepareSqlParameterDialog(sql, sourceOffset, { tabId: context.tabId }, (resolvedSql) => runExplain(resolvedSql, context))) {
+ return;
+ }
+ await runExplain(sql, context);
}
async function onDangerConfirm() {
@@ -581,25 +704,33 @@ export function useSqlExecution(deps: {
const kind = pendingDangerKind.value;
const openInNewResultTab = pendingOpenInNewResultTab.value;
const editorViewportRequestId = pendingDangerEditorViewportRequestId.value;
+ const tabId = pendingDangerTabId.value;
pendingDangerSql.value = "";
pendingDangerSourceOffset.value = undefined;
pendingDangerKind.value = "sql";
pendingOpenInNewResultTab.value = false;
pendingDangerEditorViewportRequestId.value = undefined;
+ pendingDangerTabId.value = undefined;
if (suppressDangerConfirm.value && kind === "sql") {
settingsStore.updateEditorSettings({ confirmDangerousSqlExecution: false });
}
suppressDangerConfirm.value = false;
- if (!(await ensureReadOnlyWriteAccess({ connection: deps.activeConnection.value, sql, source: t("production.sourceSqlEditor") }))) {
+ const tab = resolveExecutionTab(tabId);
+ if (!tab) {
+ return;
+ }
+ const connection = connectionStore.getConfig(tab.connectionId);
+ if (!(await ensureReadOnlyWriteAccess({ connection, sql, source: t("production.sourceSqlEditor") }))) {
cancelEditorViewportRequest(editorViewportRequestId);
return;
}
- await doExecute(sql, sourceOffset, { openInNewResultTab, editorViewportRequestId });
+ await doExecute(sql, sourceOffset, { openInNewResultTab, editorViewportRequestId, tabId: tab.id });
}
async function onSqlParametersConfirm(sql: string) {
const openInNewResultTab = pendingOpenInNewResultTab.value;
const editorViewportRequestId = pendingSqlParameterEditorViewportRequestId.value;
+ const tabId = pendingSqlParameterTabId.value;
showSqlParameterDialog.value = false;
sqlParameterSourceSql.value = "";
sqlParameterNames.value = [];
@@ -609,10 +740,18 @@ export function useSqlExecution(deps: {
pendingSourceOffset.value = undefined;
pendingOpenInNewResultTab.value = false;
pendingSqlParameterEditorViewportRequestId.value = undefined;
+ pendingSqlParameterTabId.value = undefined;
const continuation = pendingSqlParameterContinuation;
pendingSqlParameterContinuation = undefined;
- if (continuation) await continuation(sql, sourceOffset);
- else await continueExecute(sql, sourceOffset, { openInNewResultTab, editorViewportRequestId });
+ if (continuation) {
+ await continuation(sql, sourceOffset);
+ return;
+ }
+ const context = captureExecutionContext(tabId);
+ if (!context) {
+ return;
+ }
+ await continueExecute(context, sql, sourceOffset, { openInNewResultTab, editorViewportRequestId, tabId: context.tabId });
}
watch(showSqlParameterDialog, (open) => {
@@ -625,6 +764,7 @@ export function useSqlExecution(deps: {
pendingSourceOffset.value = undefined;
pendingOpenInNewResultTab.value = false;
pendingSqlParameterEditorViewportRequestId.value = undefined;
+ pendingSqlParameterTabId.value = undefined;
pendingSqlParameterContinuation = undefined;
cancelEditorViewportRequest(editorViewportRequestId);
});
@@ -637,6 +777,7 @@ export function useSqlExecution(deps: {
pendingDangerKind.value = "sql";
pendingOpenInNewResultTab.value = false;
pendingDangerEditorViewportRequestId.value = undefined;
+ pendingDangerTabId.value = undefined;
suppressDangerConfirm.value = false;
cancelEditorViewportRequest(editorViewportRequestId);
});
@@ -650,6 +791,7 @@ export function useSqlExecution(deps: {
tryExecuteInNewResultTab,
doExecute,
cancelActiveExecution,
+ requestDangerConfirmation,
tryExplain,
onDangerConfirm,
showSqlParameterDialog,
diff --git a/apps/desktop/src/composables/useTabDrag.ts b/apps/desktop/src/composables/useTabDrag.ts
deleted file mode 100644
index 5a60287137..0000000000
--- a/apps/desktop/src/composables/useTabDrag.ts
+++ /dev/null
@@ -1,245 +0,0 @@
-import { reactive, readonly } from "vue";
-
-export type TabDropPosition = "before" | "after";
-
-interface TabDragState {
- active: boolean;
- draggedId: string | null;
- targetId: string | null;
- dropPosition: TabDropPosition | null;
- suppressClick: boolean;
- startX: number;
- startY: number;
-}
-
-// Many touchscreen digitizers report to the OS/webview as a plain HID mouse
-// (no distinguishable pointerType), with tap contact-point drift well beyond
-// what real mouse/trackpad click jitter produces. 24px absorbs that jitter
-// while still requiring a clearly deliberate drag to reorder a tab.
-export const TAB_DRAG_HORIZONTAL_THRESHOLD = 24;
-
-const state = reactive({
- active: false,
- draggedId: null,
- targetId: null,
- dropPosition: null,
- suppressClick: false,
- startX: 0,
- startY: 0,
-});
-
-let pending: {
- id: string;
- x: number;
- y: number;
- pointerId: number;
- sourceEl: HTMLElement | null;
-} | null = null;
-let onDropCallback: ((draggedId: string, targetId: string, position: TabDropPosition) => boolean) | null = null;
-let onDetachCallback: ((draggedId: string, position: { x: number; y: number }) => boolean) | null = null;
-let detachBoundsProvider: (() => DOMRect | undefined) | null = null;
-let dragAxis: () => "horizontal" | "vertical" = () => "horizontal";
-let ghostEl: HTMLElement | null = null;
-
-function createGhost(sourceEl: HTMLElement, x: number, y: number) {
- const ghost = document.createElement("div");
- const textNode = sourceEl.querySelector(".truncate");
- ghost.textContent = textNode?.textContent || "";
- ghost.style.cssText = `
- position: fixed;
- pointer-events: none;
- z-index: 9999;
- opacity: 0.9;
- box-shadow: 0 2px 8px rgba(0,0,0,0.15);
- border-radius: var(--dbx-radius-fixed-6);
- background: var(--background, #fff);
- border: 1px solid var(--border, #e5e7eb);
- max-width: 200px;
- height: 28px;
- padding: 0 12px;
- font-size: 12px;
- line-height: 28px;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- left: ${x + 12}px;
- top: ${y - 14}px;
- `;
- document.body.appendChild(ghost);
- return ghost;
-}
-
-function moveGhost(x: number, y: number) {
- if (!ghostEl) return;
- ghostEl.style.left = `${x + 8}px`;
- ghostEl.style.top = `${y - 14}px`;
-}
-
-function removeGhost() {
- if (ghostEl) {
- ghostEl.remove();
- ghostEl = null;
- }
-}
-
-function activateDrag(x: number, y: number) {
- if (!pending) return;
-
- activePointerId = pending.pointerId;
- state.active = true;
- state.draggedId = pending.id;
- state.startX = pending.x;
- state.startY = pending.y;
- if (pending.sourceEl) {
- ghostEl = createGhost(pending.sourceEl, x, y);
- }
- pending = null;
- document.body.style.cursor = "grabbing";
- document.body.style.userSelect = "none";
-}
-
-function onPointerMove(event: PointerEvent) {
- if (!pending && !state.active) return;
-
- const trackedPointerId = pending?.pointerId ?? activePointerId;
- if (trackedPointerId !== null && trackedPointerId !== undefined && event.pointerId !== trackedPointerId) return;
-
- // macOS reports a trackpad tap as a mouse pointer with button=0, but
- // buttons=0. It is a click gesture, not a held primary-button drag.
- if ((event.buttons & 1) !== 1) {
- reset();
- return;
- }
-
- if (pending && !state.active) {
- const delta = dragAxis() === "vertical" ? event.clientY - pending.y : event.clientX - pending.x;
- if (Math.abs(delta) < TAB_DRAG_HORIZONTAL_THRESHOLD) return;
- activateDrag(event.clientX, event.clientY);
- }
-
- if (state.active) {
- moveGhost(event.clientX, event.clientY);
- }
-}
-
-function onMouseUp(event: PointerEvent) {
- const trackedPointerId = pending?.pointerId ?? activePointerId;
- if (trackedPointerId !== null && trackedPointerId !== undefined && event.pointerId !== trackedPointerId) return;
-
- state.suppressClick = false;
- if (state.active && state.draggedId && onDetachCallback && isOutsideDetachBounds(event.clientX, event.clientY)) {
- const scale = typeof window !== "undefined" && Number.isFinite(window.devicePixelRatio) && window.devicePixelRatio > 0 ? window.devicePixelRatio : 1;
- state.suppressClick = onDetachCallback(state.draggedId, { x: event.screenX * scale, y: event.screenY * scale });
- } else if (state.active && state.draggedId && state.targetId && state.dropPosition && onDropCallback) {
- state.suppressClick = onDropCallback(state.draggedId, state.targetId, state.dropPosition);
- }
- reset();
-}
-
-function onPointerCancel(event?: Event) {
- const trackedPointerId = pending?.pointerId ?? activePointerId;
- if (event && "pointerId" in event && trackedPointerId !== null && trackedPointerId !== undefined && (event as PointerEvent).pointerId !== trackedPointerId) return;
-
- state.suppressClick = false;
- reset();
-}
-
-function isOutsideDetachBounds(x: number, y: number): boolean {
- const bounds = detachBoundsProvider?.();
- return !!bounds && (x < bounds.left || x > bounds.right || y < bounds.top || y > bounds.bottom);
-}
-
-function reset() {
- state.active = false;
- state.draggedId = null;
- state.targetId = null;
- state.dropPosition = null;
- state.startX = 0;
- state.startY = 0;
- activePointerId = null;
- pending = null;
- removeGhost();
- document.body.style.cursor = "";
- document.body.style.userSelect = "";
-}
-
-let listenersAttached = false;
-let activePointerId: number | null = null;
-
-function ensureListeners() {
- if (listenersAttached) return;
- document.addEventListener("pointermove", onPointerMove, true);
- document.addEventListener("pointerup", onMouseUp, true);
- document.addEventListener("pointercancel", onPointerCancel, true);
- window.addEventListener("blur", onPointerCancel, true);
- listenersAttached = true;
-}
-
-export function useTabDrag(onDrop: (draggedId: string, targetId: string, position: TabDropPosition) => boolean, onDetach?: (draggedId: string, position: { x: number; y: number }) => boolean, axis: () => "horizontal" | "vertical" = () => "horizontal") {
- ensureListeners();
- onDropCallback = onDrop;
- onDetachCallback = onDetach ?? null;
- dragAxis = axis;
-
- function setDetachBoundsProvider(provider: (() => DOMRect | undefined) | null) {
- detachBoundsProvider = provider;
- }
-
- function startDrag(event: PointerEvent, tabId: string) {
- if (event.button !== 0) return;
- // Consume click suppression on the next primary-button gesture even when
- // macOS reports a trackpad tap with buttons=0 and no drag can be armed.
- state.suppressClick = false;
- if ((event.buttons & 1) !== 1) return;
- // Touch input has no reliable equivalent of mouse jitter: a real finger's
- // contact point commonly drifts well past TAB_DRAG_HORIZONTAL_THRESHOLD
- // during an ordinary tap, which would misread the tap as a drag. Skip
- // arming the drag for touch entirely and let the tab strip's native
- // horizontal scroll handle the gesture instead.
- if (event.pointerType === "touch") return;
- const target = event.target as HTMLElement;
- if (target.closest("button, input, [data-tab-title-input]")) return;
- const el = (event.currentTarget as HTMLElement) || null;
- pending = {
- id: tabId,
- x: event.clientX,
- y: event.clientY,
- pointerId: event.pointerId,
- sourceEl: el,
- };
- }
-
- function updateTarget(event: MouseEvent, tabId: string) {
- if (!state.active || tabId === state.draggedId) {
- if (state.targetId === tabId) {
- state.targetId = null;
- state.dropPosition = null;
- }
- return;
- }
-
- state.targetId = tabId;
-
- const el = event.currentTarget as HTMLElement;
- const rect = el.getBoundingClientRect();
- const vertical = axis() === "vertical";
- const offset = vertical ? event.clientY - rect.top : event.clientX - rect.left;
- const size = vertical ? rect.height : rect.width;
- state.dropPosition = offset < size / 2 ? "before" : "after";
- }
-
- function clearTarget(tabId: string) {
- if (state.targetId === tabId) {
- state.targetId = null;
- state.dropPosition = null;
- }
- }
-
- return {
- state: readonly(state),
- startDrag,
- updateTarget,
- clearTarget,
- setDetachBoundsProvider,
- };
-}
diff --git a/apps/desktop/src/composables/useToolbarOverflow.ts b/apps/desktop/src/composables/useToolbarOverflow.ts
new file mode 100644
index 0000000000..4f8a9eb21e
--- /dev/null
+++ b/apps/desktop/src/composables/useToolbarOverflow.ts
@@ -0,0 +1,87 @@
+import { nextTick, onUnmounted, ref, watch, type Ref, type WatchSource } from "vue";
+import { resolveNextEditorToolbarTier, type EditorToolbarTier } from "@/lib/tabs/editorToolbarLayout";
+
+/**
+ * Measured progressive condensation for single-row toolbars — the same
+ * mechanism as the SQL editor toolbar (resolveNextEditorToolbarTier): while
+ * the row's content overflows its available width the tier steps up (moving
+ * controls into an overflow menu); it only steps back down when there is real
+ * slack AND the pane has grown meaningfully since the last condensation, so a
+ * static narrow layout can never oscillate.
+ *
+ * Measurement contract: the toolbar row must be `overflow-hidden` with
+ * non-shrinking (shrink-0) or floored (min-w-*) children, so scrollWidth
+ * reliably exceeds clientWidth once nothing can shrink any further. Pure
+ * `truncate` children without a floor collapse to zero width before the row
+ * ever reports overflow, which would silently disable condensation.
+ *
+ * @param rootRef the toolbar row element
+ * @param revalidateSources reactive sources whose change alters the row's
+ * visible control set (active tab, feature flags, conditional controls);
+ * each change re-measures, because hiding/restoring controls changes the
+ * content extent without resizing the toolbar box.
+ */
+export function useToolbarOverflow(rootRef: Ref, revalidateSources: WatchSource[] = []) {
+ const tier = ref(0);
+ // Available width when the current tier was condensed into; anchors the
+ // step-down hysteresis so a static narrow layout cannot oscillate.
+ const condensedAtWidth = ref(0);
+ let resizeObserver: ResizeObserver | undefined;
+
+ function measure() {
+ const element = rootRef.value;
+ if (!element) {
+ return;
+ }
+ const next = resolveNextEditorToolbarTier({
+ tier: tier.value,
+ availableWidth: element.clientWidth,
+ contentWidth: element.scrollWidth,
+ condensedAtWidth: condensedAtWidth.value,
+ });
+ if (next !== tier.value) {
+ if (next > tier.value) {
+ condensedAtWidth.value = element.clientWidth;
+ }
+ tier.value = next;
+ }
+ }
+
+ // Hiding or restoring controls changes the row content without resizing the
+ // toolbar box, so every tier change re-measures until the row settles.
+ watch(tier, () => {
+ void nextTick(measure);
+ });
+
+ if (revalidateSources.length > 0) {
+ watch(revalidateSources, () => {
+ void nextTick(measure);
+ });
+ }
+
+ watch(
+ rootRef,
+ (element) => {
+ resizeObserver?.disconnect();
+ resizeObserver = undefined;
+ // A freshly attached row (mode switch, tab remount) starts from the full
+ // layout and converges from real measurements instead of inheriting a
+ // stale tier from whatever was previously measured.
+ tier.value = 0;
+ condensedAtWidth.value = 0;
+ if (element && typeof ResizeObserver !== "undefined") {
+ resizeObserver = new ResizeObserver(measure);
+ resizeObserver.observe(element);
+ }
+ void nextTick(measure);
+ },
+ { flush: "post" },
+ );
+
+ onUnmounted(() => {
+ resizeObserver?.disconnect();
+ resizeObserver = undefined;
+ });
+
+ return { tier, measure };
+}
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts
index a225fd1940..1a5f1178c8 100644
--- a/apps/desktop/src/i18n/locales/en.ts
+++ b/apps/desktop/src/i18n/locales/en.ts
@@ -142,6 +142,7 @@ export default {
changePasswordDescription: "Enter your current password and choose a new one",
},
toolbar: {
+ moreActions: "More actions",
newConnection: "New Connection",
newQuery: "New Query",
execute: "Execute",
@@ -1425,6 +1426,7 @@ export default {
},
tabs: {
sql: "SQL",
+ emptyGroup: "No open tabs",
table: "Table",
tableData: "Table Data",
redis: "Redis",
@@ -3041,6 +3043,10 @@ export default {
refreshTab: "Refresh Data",
renameTab: "Rename Tab",
duplicateTab: "Duplicate Tab",
+ splitRight: "Split Right",
+ splitDown: "Split Down",
+ changeOrientation: "Change Split Direction",
+ unsplit: "Unsplit",
compactTabTitle: "Shorten Tab Titles",
fullTabTitle: "Show Full Tab Titles",
closeTab: "Close Tab",
diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts
index 9ea979e7b6..f4444fa9d2 100644
--- a/apps/desktop/src/i18n/locales/es.ts
+++ b/apps/desktop/src/i18n/locales/es.ts
@@ -144,6 +144,7 @@ export default withEnglishFallback({
changePasswordDescription: "Ingresa tu contraseña actual y elige una nueva",
},
toolbar: {
+ moreActions: "Más acciones",
newConnection: "Nueva conexión",
newQuery: "Nueva consulta",
execute: "Ejecutar",
@@ -1407,6 +1408,7 @@ export default withEnglishFallback({
},
tabs: {
sql: "SQL",
+ emptyGroup: "No hay pestañas abiertas",
table: "Tabla",
tableData: "Datos de tabla",
redis: "Redis",
@@ -1462,6 +1464,7 @@ export default withEnglishFallback({
scrollLeft: "Desplazar pestañas a la izquierda",
scrollRight: "Desplazar pestañas a la derecha",
openTabs: "Pestañas abiertas",
+ openInNewWindow: "Abrir en una ventana nueva",
searchOpenTabs: "Buscar pestañas abiertas...",
collapseTabBar: "Contraer barra de pestañas",
expandTabBar: "Expandir barra de pestañas",
@@ -2958,6 +2961,10 @@ export default withEnglishFallback({
refreshTab: "Actualizar datos",
renameTab: "Renombrar pestaña",
duplicateTab: "Duplicar pestaña",
+ splitRight: "Dividir a la derecha",
+ splitDown: "Dividir hacia abajo",
+ changeOrientation: "Cambiar dirección de división",
+ unsplit: "Anular división",
compactTabTitle: "Acortar títulos de pestañas",
fullTabTitle: "Mostrar títulos completos de pestañas",
closeTab: "Cerrar pestaña",
diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts
index 4a79d78638..e2307e5f3d 100644
--- a/apps/desktop/src/i18n/locales/it.ts
+++ b/apps/desktop/src/i18n/locales/it.ts
@@ -143,6 +143,7 @@ export default withEnglishFallback({
changePasswordDescription: "Inserisci la password attuale e scegline una nuova",
},
toolbar: {
+ moreActions: "Altre azioni",
newConnection: "Nuova Connessione",
newQuery: "Nuova Query",
execute: "Esegui",
@@ -1405,6 +1406,7 @@ export default withEnglishFallback({
},
tabs: {
sql: "SQL",
+ emptyGroup: "Nessuna scheda aperta",
table: "Tabella",
tableData: "Dati Tabella",
redis: "Redis",
@@ -1460,6 +1462,7 @@ export default withEnglishFallback({
scrollLeft: "Scorri schede a sinistra",
scrollRight: "Scorri schede a destra",
openTabs: "Schede aperte",
+ openInNewWindow: "Apri in una nuova finestra",
searchOpenTabs: "Cerca schede aperte...",
collapseTabBar: "Comprimi barra delle schede",
expandTabBar: "Espandi barra delle schede",
@@ -2956,6 +2959,10 @@ export default withEnglishFallback({
refreshTab: "Aggiorna Dati",
renameTab: "Rinomina Scheda",
duplicateTab: "Duplica Scheda",
+ splitRight: "Dividi a destra",
+ splitDown: "Dividi in basso",
+ changeOrientation: "Cambia direzione di divisione",
+ unsplit: "Annulla divisione",
compactTabTitle: "Accorcia Titoli Schede",
fullTabTitle: "Mostra titoli completi delle schede",
closeTab: "Chiudi Scheda",
diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts
index fb41edc2b9..7c9207d33c 100644
--- a/apps/desktop/src/i18n/locales/ja.ts
+++ b/apps/desktop/src/i18n/locales/ja.ts
@@ -144,6 +144,7 @@ export default withEnglishFallback({
changePasswordDescription: "現在のパスワードと新しいパスワードを入力してください",
},
toolbar: {
+ moreActions: "その他の操作",
newConnection: "新しい接続",
newQuery: "新しいクエリ",
execute: "実行",
@@ -1426,6 +1427,7 @@ export default withEnglishFallback({
},
tabs: {
sql: "SQL",
+ emptyGroup: "開いているタブはありません",
table: "テーブル",
tableData: "テーブルデータ",
redis: "Redis",
@@ -1479,6 +1481,7 @@ export default withEnglishFallback({
scrollLeft: "タブを左にスクロール",
scrollRight: "タブを右にスクロール",
openTabs: "開いているタブ",
+ openInNewWindow: "新しいウィンドウで開く",
searchOpenTabs: "開いているタブを検索...",
collapseTabBar: "タブバーを折りたたむ",
expandTabBar: "タブバーを展開",
@@ -2980,6 +2983,10 @@ export default withEnglishFallback({
refreshTab: "データを更新",
renameTab: "タブ名を変更",
duplicateTab: "タブを複製",
+ splitRight: "右に分割",
+ splitDown: "下に分割",
+ changeOrientation: "分割方向を変更",
+ unsplit: "分割を解除",
compactTabTitle: "タブタイトルを短縮",
fullTabTitle: "タブの完全なタイトルを表示",
closeTab: "タブを閉じる",
diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts
index cad8c9d987..e4b4ad6225 100644
--- a/apps/desktop/src/i18n/locales/ko.ts
+++ b/apps/desktop/src/i18n/locales/ko.ts
@@ -143,6 +143,7 @@ export default withEnglishFallback({
changePasswordDescription: "현재 비밀번호를 입력하고 새 비밀번호를 선택하세요",
},
toolbar: {
+ moreActions: "더 많은 작업",
newConnection: "새 연결",
newQuery: "새 쿼리",
execute: "실행",
@@ -1308,6 +1309,7 @@ export default withEnglishFallback({
},
tabs: {
sql: "SQL",
+ emptyGroup: "열려 있는 탭 없음",
table: "테이블",
tableData: "테이블 데이터",
redis: "Redis",
@@ -1366,6 +1368,7 @@ export default withEnglishFallback({
scrollLeft: "탭을 왼쪽으로 스크롤",
scrollRight: "탭을 오른쪽으로 스크롤",
openTabs: "열린 탭",
+ openInNewWindow: "새 창에서 열기",
searchOpenTabs: "열린 탭 검색...",
collapseTabBar: "탭 표시줄 접기",
expandTabBar: "탭 표시줄 펼치기",
@@ -2850,6 +2853,10 @@ export default withEnglishFallback({
refreshTab: "데이터 새로고침",
renameTab: "탭 이름 변경",
duplicateTab: "탭 복제",
+ splitRight: "오른쪽으로 분할",
+ splitDown: "아래로 분할",
+ changeOrientation: "분할 방향 변경",
+ unsplit: "분할 해제",
compactTabTitle: "탭 제목 줄이기",
fullTabTitle: "전체 탭 제목 표시",
closeTab: "탭 닫기",
diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts
index 905ee0986a..c2d7cd3d88 100644
--- a/apps/desktop/src/i18n/locales/pt-BR.ts
+++ b/apps/desktop/src/i18n/locales/pt-BR.ts
@@ -144,6 +144,7 @@ export default withEnglishFallback({
changePasswordDescription: "Digite sua senha atual e escolha uma nova",
},
toolbar: {
+ moreActions: "Mais ações",
newConnection: "Nova Conexão",
newQuery: "Nova Consulta",
execute: "Executar",
@@ -1406,6 +1407,7 @@ export default withEnglishFallback({
},
tabs: {
sql: "SQL",
+ emptyGroup: "Nenhuma aba aberta",
table: "Tabela",
tableData: "Dados da Tabela",
redis: "Redis",
@@ -1462,6 +1464,7 @@ export default withEnglishFallback({
scrollLeft: "Rolar abas para a esquerda",
scrollRight: "Rolar abas para a direita",
openTabs: "Abas abertas",
+ openInNewWindow: "Abrir em nova janela",
searchOpenTabs: "Pesquisar abas abertas...",
collapseTabBar: "Recolher barra de abas",
expandTabBar: "Expandir barra de abas",
@@ -2958,6 +2961,10 @@ export default withEnglishFallback({
refreshTab: "Atualizar Dados",
renameTab: "Renomear Aba",
duplicateTab: "Duplicar Aba",
+ splitRight: "Dividir à direita",
+ splitDown: "Dividir para baixo",
+ changeOrientation: "Alterar direção da divisão",
+ unsplit: "Desfazer divisão",
compactTabTitle: "Encurtar Títulos das Abas",
fullTabTitle: "Mostrar títulos completos das abas",
closeTab: "Fechar Aba",
diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts
index b320e39df1..aa616ddedf 100644
--- a/apps/desktop/src/i18n/locales/tr.ts
+++ b/apps/desktop/src/i18n/locales/tr.ts
@@ -143,6 +143,7 @@ export default withEnglishFallback({
changePasswordDescription: "Mevcut parolanızı girin ve yeni bir parola seçin",
},
toolbar: {
+ moreActions: "Diğer İşlemler",
newConnection: "Yeni Bağlantı",
newQuery: "Yeni Sorgu",
execute: "Çalıştır",
@@ -1426,6 +1427,7 @@ export default withEnglishFallback({
},
tabs: {
sql: "SQL",
+ emptyGroup: "Açık Sekme Yok",
table: "Tablo",
tableData: "Tablo Verisi",
redis: "Redis",
@@ -3042,6 +3044,10 @@ export default withEnglishFallback({
refreshTab: "Verileri Yenile",
renameTab: "Sekmeyi Yeniden Adlandır",
duplicateTab: "Sekmeyi Çoğalt",
+ splitRight: "Sağa Böl",
+ splitDown: "Aşağı Böl",
+ changeOrientation: "Bölme Yönünü Değiştir",
+ unsplit: "Bölmeyi Kaldır",
compactTabTitle: "Sekme Başlıklarını Kısalt",
fullTabTitle: "Tam Sekme Başlıklarını Göster",
closeTab: "Sekmeyi Kapat",
diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts
index abfa24e88a..9f770ce816 100644
--- a/apps/desktop/src/i18n/locales/zh-CN.ts
+++ b/apps/desktop/src/i18n/locales/zh-CN.ts
@@ -67,6 +67,7 @@ export default withEnglishFallback({
changePasswordDescription: "输入当前密码并设置新密码",
},
toolbar: {
+ moreActions: "更多操作",
newConnection: "新建连接",
newQuery: "新建查询",
execute: "执行",
@@ -1349,6 +1350,7 @@ export default withEnglishFallback({
},
tabs: {
sql: "SQL",
+ emptyGroup: "暂无打开的页签",
table: "表",
tableData: "数据表",
redis: "Redis",
@@ -2964,6 +2966,10 @@ export default withEnglishFallback({
refreshTab: "刷新数据",
renameTab: "重命名标签页",
duplicateTab: "复制标签页",
+ splitRight: "拆分并右移",
+ splitDown: "拆分并下移",
+ changeOrientation: "更改拆分器方向",
+ unsplit: "取消拆分",
compactTabTitle: "缩短标签标题",
fullTabTitle: "完整标签标题",
closeTab: "关闭标签页",
diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts
index c2179ef750..59d6dad399 100644
--- a/apps/desktop/src/i18n/locales/zh-TW.ts
+++ b/apps/desktop/src/i18n/locales/zh-TW.ts
@@ -144,6 +144,7 @@ export default withEnglishFallback({
changePasswordDescription: "輸入目前密碼並設定新密碼",
},
toolbar: {
+ moreActions: "更多操作",
newConnection: "建立連線",
newQuery: "建立查詢",
execute: "執行",
@@ -1405,6 +1406,7 @@ export default withEnglishFallback({
},
tabs: {
sql: "SQL",
+ emptyGroup: "暫無開啟的頁籤",
table: "資料表",
tableData: "資料表",
redis: "Redis",
@@ -1461,6 +1463,7 @@ export default withEnglishFallback({
scrollLeft: "向左捲動分頁",
scrollRight: "向右捲動分頁",
openTabs: "已開啟的分頁",
+ openInNewWindow: "在新視窗中開啟",
searchOpenTabs: "搜尋已開啟的分頁...",
collapseTabBar: "收合分頁列",
expandTabBar: "展開分頁列",
@@ -2955,6 +2958,10 @@ export default withEnglishFallback({
refreshTab: "重新整理資料",
renameTab: "重新命名分頁",
duplicateTab: "複製分頁",
+ splitRight: "拆分並右移",
+ splitDown: "拆分並下移",
+ changeOrientation: "變更分割方向",
+ unsplit: "取消分割",
compactTabTitle: "縮短標籤標題",
fullTabTitle: "完整分頁標題",
closeTab: "關閉分頁",
diff --git a/apps/desktop/src/lib/__tests__/editor/queryEditorExecution.spec.ts b/apps/desktop/src/lib/__tests__/editor/queryEditorExecution.spec.ts
index a554520fc6..6af431aa14 100644
--- a/apps/desktop/src/lib/__tests__/editor/queryEditorExecution.spec.ts
+++ b/apps/desktop/src/lib/__tests__/editor/queryEditorExecution.spec.ts
@@ -5,6 +5,7 @@ import { createQueryEditorExecutionViewportOwnership, isQueryEditorPositionVisib
const queryEditorSource = readFileSync(new URL("../../../components/editor/QueryEditor.vue", import.meta.url), "utf8");
const contentAreaSource = readFileSync(new URL("../../../components/layout/ContentArea.vue", import.meta.url), "utf8");
const editorToolbarSource = readFileSync(new URL("../../../components/layout/EditorToolbar.vue", import.meta.url), "utf8");
+const editorGroupSource = readFileSync(new URL("../../../components/layout/EditorGroup.vue", import.meta.url), "utf8");
const appSource = readFileSync(new URL("../../../App.vue", import.meta.url), "utf8");
const sqlExecutionSource = readFileSync(new URL("../../../composables/useSqlExecution.ts", import.meta.url), "utf8");
const queryStoreSource = readFileSync(new URL("../../../stores/queryStore.ts", import.meta.url), "utf8");
@@ -56,14 +57,21 @@ describe("QueryEditor execution routing", () => {
expect(queryEditorSource).toContain("return sqlExecutionSnapshotFromView(currentView);");
expect(contentAreaSource).toContain("function captureQueryEditorExecutionSnapshot()");
expect(contentAreaSource).toContain("queryEditorRef.value?.captureExecutionSnapshot();");
- expect(appSource).toContain("const pendingToolbarExecutionSnapshot = ref();");
- expect(appSource).toContain("pendingToolbarExecutionSnapshot.value = contentAreaRef.value?.captureQueryEditorExecutionSnapshot?.();");
+ expect(appSource).toContain("const pendingToolbarExecutionSnapshot = ref();");
+ expect(appSource).toContain("pendingToolbarExecutionSnapshot.value = snapshot ? { ...snapshot, tabId: activeTab.value?.id } : undefined;");
expect(appSource).toContain('if (source === "pointer")');
expect(appSource).toContain("pendingToolbarExecutionSnapshot.value = undefined;");
- expect(appSource).toContain("void tryExecute(snapshot);");
- expect(appSource).toContain('@execute-pointer-down="captureActiveEditorExecutionSnapshot()"');
- expect(appSource).toContain('@execute="requestActiveEditorExecute($event)"');
+ expect(appSource).toContain("void tryExecute(snapshot, { tabId: snapshot.tabId ?? activeTab.value?.id });");
+ expect(appSource).toContain('@execute="(tabId: string, override?: SqlExecutionOverride) => tryExecute(override, { tabId })"');
+ // Per-group toolbars call back into App-owned orchestration via injection.
+ expect(appSource).toContain("provide(EDITOR_TOOLBAR_ACTIONS, {");
+ expect(appSource).toContain("captureExecutionSnapshot: captureActiveEditorExecutionSnapshot,");
+ expect(appSource).toContain("toolbarExecute: requestActiveEditorExecute,");
+ expect(editorGroupSource).toContain('@execute-pointer-down="toolbar.captureExecutionSnapshot()"');
+ expect(editorGroupSource).toContain('@toolbar-execute="toolbar.toolbarExecute($event)"');
expect(editorToolbarSource).toContain("function onExecutePointerDown(event: MouseEvent)");
+ expect(editorToolbarSource).toContain('emit("toolbarExecute", event.detail > 0 ? "pointer" : "keyboard")');
+ expect(editorToolbarSource).not.toContain('emit("execute", event.detail > 0 ? "pointer" : "keyboard")');
});
it("uses the opt-in blank-line fallback and otherwise reports the missing cursor statement", () => {
diff --git a/apps/desktop/src/lib/__tests__/editor/queryEditorFocus.spec.ts b/apps/desktop/src/lib/__tests__/editor/queryEditorFocus.spec.ts
index 2a941f368f..3fa2a307ae 100644
--- a/apps/desktop/src/lib/__tests__/editor/queryEditorFocus.spec.ts
+++ b/apps/desktop/src/lib/__tests__/editor/queryEditorFocus.spec.ts
@@ -45,7 +45,7 @@ describe("QueryEditor auto focus wiring", () => {
});
it("enables auto focus for query tabs", () => {
- expect(contentAreaSource).toMatch(/ {
+ it("passes the tab id as the execution context, not as SQL", () => {
+ // Regression: the injected action once called tryExplain(tabId), whose
+ // first parameter is sqlOverride — the id silently typed as SQL and the
+ // EXPLAIN button sent the tab UUID to the backend.
+ expect(appSource).toContain("explain: (tabId: string) => tryExplain(undefined, { tabId }),");
+ expect(appSource).not.toContain("explain: (tabId: string) => tryExplain(tabId),");
+ });
+
+ it("keeps tryExplain's first parameter an SQL override", () => {
+ expect(useSqlExecutionSource).toContain("async function tryExplain(sqlOverride?: SqlExecutionOverride, options: SqlExecutionOptions = {})");
+ });
+});
diff --git a/apps/desktop/src/lib/__tests__/tabs/editorToolbarLayout.spec.ts b/apps/desktop/src/lib/__tests__/tabs/editorToolbarLayout.spec.ts
new file mode 100644
index 0000000000..cc73617ef6
--- /dev/null
+++ b/apps/desktop/src/lib/__tests__/tabs/editorToolbarLayout.spec.ts
@@ -0,0 +1,40 @@
+import { describe, expect, it } from "vitest";
+import { EDITOR_TOOLBAR_MAX_TIER, EDITOR_TOOLBAR_STEP_DOWN_MIN_GROWTH_PX, EDITOR_TOOLBAR_STEP_DOWN_SLACK_PX, resolveNextEditorToolbarTier } from "@/lib/tabs/editorToolbarLayout";
+
+describe("editor toolbar measured condensation", () => {
+ it("steps up one tier while the content overflows", () => {
+ expect(resolveNextEditorToolbarTier({ tier: 0, availableWidth: 400, contentWidth: 700 })).toBe(1);
+ expect(resolveNextEditorToolbarTier({ tier: 1, availableWidth: 400, contentWidth: 450 })).toBe(2);
+ expect(resolveNextEditorToolbarTier({ tier: 2, availableWidth: 400, contentWidth: 420 })).toBe(3);
+ });
+
+ it("holds at the maximum tier when even the condensed row overflows", () => {
+ expect(resolveNextEditorToolbarTier({ tier: EDITOR_TOOLBAR_MAX_TIER, availableWidth: 200, contentWidth: 400 })).toBe(EDITOR_TOOLBAR_MAX_TIER);
+ });
+
+ it("holds the current tier while the row fits without real slack", () => {
+ expect(resolveNextEditorToolbarTier({ tier: 1, availableWidth: 500, contentWidth: 480 })).toBe(1);
+ expect(resolveNextEditorToolbarTier({ tier: 1, availableWidth: 500, contentWidth: 500 - EDITOR_TOOLBAR_STEP_DOWN_SLACK_PX + 1 })).toBe(1);
+ });
+
+ it("steps down only with slack and real pane growth since the last condensation", () => {
+ const grown = 400 + EDITOR_TOOLBAR_STEP_DOWN_MIN_GROWTH_PX + 10;
+ expect(resolveNextEditorToolbarTier({ tier: 2, availableWidth: grown, contentWidth: grown - EDITOR_TOOLBAR_STEP_DOWN_SLACK_PX - 10, condensedAtWidth: 400 })).toBe(1);
+ });
+
+ it("never oscillates: slack alone without pane growth keeps the condensed tier", () => {
+ // The row fits with plenty of slack, but the pane barely grew since the
+ // toolbar condensed at this width — stepping down would overflow again.
+ expect(resolveNextEditorToolbarTier({ tier: 2, availableWidth: 410, contentWidth: 350, condensedAtWidth: 400 })).toBe(2);
+ expect(resolveNextEditorToolbarTier({ tier: 2, availableWidth: 410, contentWidth: 410 - EDITOR_TOOLBAR_STEP_DOWN_SLACK_PX - 1, condensedAtWidth: 400 })).toBe(2);
+ });
+
+ it("allows stepping down immediately when the toolbar was never condensed", () => {
+ expect(resolveNextEditorToolbarTier({ tier: 1, availableWidth: 900, contentWidth: 500 })).toBe(0);
+ });
+
+ it("keeps the current tier for unmeasured or invalid input", () => {
+ expect(resolveNextEditorToolbarTier({ tier: 1, availableWidth: 0, contentWidth: 0 })).toBe(1);
+ expect(resolveNextEditorToolbarTier({ tier: 2, availableWidth: Number.NaN, contentWidth: 100 })).toBe(2);
+ });
+});
diff --git a/apps/desktop/src/lib/__tests__/tabs/tabDrag.spec.ts b/apps/desktop/src/lib/__tests__/tabs/tabDrag.spec.ts
new file mode 100644
index 0000000000..8061b5bad0
--- /dev/null
+++ b/apps/desktop/src/lib/__tests__/tabs/tabDrag.spec.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it } from "vitest";
+import { parseTabDragPayload, serializeTabDragPayload, TAB_DRAG_MIME } from "@/lib/tabs/tabDrag";
+
+describe("tab drag payload", () => {
+ it("serializes and parses a valid payload", () => {
+ const raw = serializeTabDragPayload({ tabId: "tab-1", sourceGroupId: "group-1" });
+ expect(raw).toContain(TAB_DRAG_MIME);
+ expect(parseTabDragPayload(raw)).toEqual({ mime: TAB_DRAG_MIME, tabId: "tab-1", sourceGroupId: "group-1" });
+ });
+
+ it("rejects malformed payloads", () => {
+ expect(parseTabDragPayload("not-json")).toBeNull();
+ expect(parseTabDragPayload(JSON.stringify({ tabId: "tab-1" }))).toBeNull();
+ });
+
+ it("rejects payloads carrying a foreign MIME discriminator", () => {
+ expect(parseTabDragPayload(JSON.stringify({ mime: "application/json", tabId: "tab-1", sourceGroupId: "group-1" }))).toBeNull();
+ });
+
+ it("exposes the custom MIME type", () => {
+ expect(TAB_DRAG_MIME).toBe("application/x-dbx-tab");
+ });
+});
diff --git a/apps/desktop/src/lib/__tests__/tabs/tabMenu.spec.ts b/apps/desktop/src/lib/__tests__/tabs/tabMenu.spec.ts
new file mode 100644
index 0000000000..b142c7acd5
--- /dev/null
+++ b/apps/desktop/src/lib/__tests__/tabs/tabMenu.spec.ts
@@ -0,0 +1,135 @@
+import { describe, expect, it } from "vitest";
+import { createCloseAllTabMenuItem, createCloseLeftTabMenuItem, createCloseOtherTabMenuItem, createCloseRightTabMenuItem, createCloseTabMenuItem, createLocateTabMenuItem, createPinTabMenuItem, createRenameDuplicateTabItems } from "@/lib/tabs/tabMenu";
+
+const t = (key: string) => key;
+
+describe("shared tab menu helpers", () => {
+ it("builds rename and duplicate items only when renaming is allowed", () => {
+ const items = createRenameDuplicateTabItems({
+ tab: { id: "t1" } as never,
+ t,
+ canRename: true,
+ onRename: () => undefined,
+ onDuplicate: () => undefined,
+ });
+
+ expect(items).toHaveLength(2);
+ expect(items[0]?.label).toBe("contextMenu.renameTab");
+ expect(items[1]?.label).toBe("contextMenu.duplicateTab");
+ expect(items[1]?.visible).toBe(true);
+
+ const queryOnly = createRenameDuplicateTabItems({
+ tab: { id: "t2" } as never,
+ t,
+ canRename: false,
+ onRename: () => undefined,
+ onDuplicate: () => undefined,
+ });
+ expect(queryOnly).toHaveLength(1);
+ expect(queryOnly[0]?.visible).toBe(false);
+ });
+
+ it("builds locate and pin menu items with the expected actions", () => {
+ let located = false;
+ let toggled = false;
+
+ const locate = createLocateTabMenuItem({
+ t,
+ visible: true,
+ onLocate: () => {
+ located = true;
+ },
+ });
+ locate.action?.();
+ expect(located).toBe(true);
+ expect(locate.label).toBe("sidebar.locateActiveTab");
+
+ const hiddenLocate = createLocateTabMenuItem({
+ t,
+ visible: false,
+ onLocate: () => undefined,
+ });
+ expect(hiddenLocate.visible).toBe(false);
+
+ const pin = createPinTabMenuItem({
+ label: "Pin",
+ iconClass: "fill-current",
+ onToggle: () => {
+ toggled = true;
+ },
+ });
+ pin.action?.();
+ expect(toggled).toBe(true);
+ expect(pin.iconClass).toBe("fill-current");
+
+ const pinWithoutIconClass = createPinTabMenuItem({
+ label: "Pin",
+ onToggle: () => undefined,
+ });
+ expect(pinWithoutIconClass.iconClass).toBeUndefined();
+ });
+
+ it("builds close menu items with labels and actions", () => {
+ let closed = false;
+ const close = createCloseTabMenuItem({
+ label: "Close",
+ onClose: () => {
+ closed = true;
+ },
+ });
+ close.action?.();
+ expect(closed).toBe(true);
+
+ const closeOther = createCloseOtherTabMenuItem({
+ label: "Close Other",
+ disabled: true,
+ onClose: () => undefined,
+ });
+ expect(closeOther.disabled).toBe(true);
+
+ const closeOtherWithoutDisabled = createCloseOtherTabMenuItem({
+ label: "Close Other",
+ onClose: () => undefined,
+ });
+ expect("disabled" in closeOtherWithoutDisabled).toBe(false);
+
+ const closeRight = createCloseRightTabMenuItem({
+ label: "Close Right",
+ disabled: false,
+ onClose: () => undefined,
+ });
+ expect(closeRight.disabled).toBe(false);
+
+ const closeRightWithoutDisabled = createCloseRightTabMenuItem({
+ label: "Close Right",
+ onClose: () => undefined,
+ });
+ expect("disabled" in closeRightWithoutDisabled).toBe(false);
+
+ const closeLeft = createCloseLeftTabMenuItem({
+ label: "Close Left",
+ disabled: true,
+ onClose: () => undefined,
+ });
+ expect(closeLeft.disabled).toBe(true);
+
+ const closeLeftWithoutDisabled = createCloseLeftTabMenuItem({
+ label: "Close Left",
+ onClose: () => undefined,
+ });
+ expect("disabled" in closeLeftWithoutDisabled).toBe(false);
+
+ const closeAll = createCloseAllTabMenuItem({
+ label: "Close All",
+ variant: "destructive",
+ onClose: () => undefined,
+ });
+ expect(closeAll.variant).toBe("destructive");
+
+ const closeAllDefault = createCloseAllTabMenuItem({
+ label: "Close All",
+ onClose: () => undefined,
+ });
+ expect(closeAllDefault.variant).toBeUndefined();
+ });
+});
diff --git a/apps/desktop/src/lib/__tests__/tabs/tabPresentation.spec.ts b/apps/desktop/src/lib/__tests__/tabs/tabPresentation.spec.ts
index 0bf3864859..1f58f3a009 100644
--- a/apps/desktop/src/lib/__tests__/tabs/tabPresentation.spec.ts
+++ b/apps/desktop/src/lib/__tests__/tabs/tabPresentation.spec.ts
@@ -12,9 +12,13 @@ import {
resultGridInstanceKey,
resultSourceRange,
statementExecutionMarkers,
+ tabColorStyle,
+ tabDatabaseIconType,
tabDisplayTitle,
+ tabIconClass,
tabTooltipLines,
tabularResultItems,
+ dirtyTabTitleStyle,
} from "@/lib/tabs/tabPresentation";
import { sqlTextFingerprint } from "@/lib/sql/sqlTextFingerprint";
import type { ConnectionConfig, QueryTab } from "@/types/database";
@@ -396,3 +400,38 @@ describe("statement execution markers", () => {
expect(statementExecutionMarkers(`-- edited\n${executedSql}`, [{ columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1, statement_index: 0, sourceStatement: "SELECT 1" }], "mysql", "stale-editor-fingerprint", executedSql)).toEqual([]);
});
});
+
+describe("shared tab presentation helpers", () => {
+ it("classifies tab icon colors without MQ special-casing", () => {
+ expect(tabIconClass(queryTab({ mode: "data" }))).toContain("text-emerald-600");
+ expect(tabIconClass(queryTab({ mode: "mq" }))).toBe("");
+ expect(tabIconClass(queryTab({ externalSqlFileMissing: true }))).toContain("text-amber-600");
+ });
+
+ it("builds active/inactive color styles for classic and non-classic layouts", () => {
+ const activeClassic = tabColorStyle(queryTab({}), true, true);
+ expect(activeClassic?.boxShadow).toContain("var(--ring)");
+ const inactiveModern = tabColorStyle(queryTab({}), false, false);
+ expect(inactiveModern?.borderColor).toBeUndefined();
+ });
+
+ it("resolves MQ driver icons from the connection store", () => {
+ const connectionStore = useConnectionStore();
+ connectionStore.connections = [
+ {
+ id: "mq-1",
+ name: "MQ",
+ db_type: "mq",
+ driver_profile: "kafka",
+ color: "",
+ } as ConnectionConfig,
+ ];
+ expect(tabDatabaseIconType(queryTab({ connectionId: "mq-1", mode: "mq" }))).toBe("kafka");
+ });
+
+ it("returns a dirty-title style only when the tab is dirty", () => {
+ expect(dirtyTabTitleStyle(false)).toBeUndefined();
+ expect(dirtyTabTitleStyle(true)?.fontStyle).toBe("italic");
+ expect(dirtyTabTitleStyle(true)?.fontWeight).toBe(700);
+ });
+});
diff --git a/apps/desktop/src/lib/ai/__tests__/aiRedisConsoleRouting.spec.ts b/apps/desktop/src/lib/ai/__tests__/aiRedisConsoleRouting.spec.ts
index 2c019e9007..b9e24304d9 100644
--- a/apps/desktop/src/lib/ai/__tests__/aiRedisConsoleRouting.spec.ts
+++ b/apps/desktop/src/lib/ai/__tests__/aiRedisConsoleRouting.spec.ts
@@ -32,7 +32,7 @@ describe("AI Redis console routing", () => {
const handler = appSource.match(/function onAiExecuteSql\(sql: string\) \{[\s\S]*?\n\}/)?.[0] ?? "";
expect(handler).toContain("buildDeduplicatedAppendedEditorSql(currentSql, sql)");
expect(handler).toContain("if (appendedSql !== currentSql) queryStore.updateSql(tabId, appendedSql);");
- expect(handler).toContain("runAiGeneratedSql(sql);");
+ expect(handler).toContain("runAiGeneratedSql(sql, tabId);");
expect(handler).not.toContain("buildAppendedEditorSql(");
});
diff --git a/apps/desktop/src/lib/app/openTabsPersistence.ts b/apps/desktop/src/lib/app/openTabsPersistence.ts
index 4d767bb9aa..9dd76776b2 100644
--- a/apps/desktop/src/lib/app/openTabsPersistence.ts
+++ b/apps/desktop/src/lib/app/openTabsPersistence.ts
@@ -66,6 +66,23 @@ export interface RestoredOpenTabs {
activeTabId: string | null;
}
+/** Group membership persisted with the open-tabs payload. */
+export interface PersistedEditorGroup {
+ id: string;
+ tabIds: string[];
+ activeTabId: string | null;
+}
+
+/** Shared open-tabs transport payload — the single definition both backend adapters persist. */
+export interface OpenTabsStatePayload {
+ tabs: unknown[];
+ activeTabId: string | null;
+ groups?: PersistedEditorGroup[];
+ focusedGroupId?: string;
+ orientation?: "vertical" | "horizontal";
+ sizes?: number[];
+}
+
export type OpenTabsRestoreFilter = "all" | "pinned";
export interface OpenTabsRestoreOptions {
queryOnly?: boolean;
diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts
index f15d05049c..d3cd88f120 100644
--- a/apps/desktop/src/lib/backend/http.ts
+++ b/apps/desktop/src/lib/backend/http.ts
@@ -1870,10 +1870,8 @@ export async function saveMaxRetries(maxRetries: number): Promise {
if (!res.ok) throw await backendResponseError(res);
}
-export interface OpenTabsStatePayload {
- tabs: unknown[];
- activeTabId: string | null;
-}
+export type { OpenTabsStatePayload, PersistedEditorGroup } from "@/lib/app/openTabsPersistence";
+import type { OpenTabsStatePayload } from "@/lib/app/openTabsPersistence";
export async function loadEditorSettings(): Promise {
return loadBrowserAppState("editor_settings");
@@ -1891,6 +1889,10 @@ export async function loadOpenTabsState(): Promise
? {
tabs: payload.tabs,
activeTabId: typeof payload.activeTabId === "string" ? payload.activeTabId : null,
+ ...(payload.groups !== undefined ? { groups: payload.groups } : {}),
+ ...(typeof payload.focusedGroupId === "string" ? { focusedGroupId: payload.focusedGroupId } : {}),
+ ...(payload.orientation === "vertical" || payload.orientation === "horizontal" ? { orientation: payload.orientation } : {}),
+ ...(Array.isArray(payload.sizes) ? { sizes: payload.sizes } : {}),
}
: null;
}
diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts
index 3901032fb2..cf31cab12f 100644
--- a/apps/desktop/src/lib/backend/tauri.ts
+++ b/apps/desktop/src/lib/backend/tauri.ts
@@ -720,10 +720,8 @@ export async function saveMaxRetries(maxRetries: number): Promise {
return invoke("save_max_retries", { maxRetries });
}
-export interface OpenTabsStatePayload {
- tabs: unknown[];
- activeTabId: string | null;
-}
+export type { OpenTabsStatePayload, PersistedEditorGroup } from "@/lib/app/openTabsPersistence";
+import type { OpenTabsStatePayload } from "@/lib/app/openTabsPersistence";
export async function loadEditorSettings(): Promise {
return invoke("load_editor_settings");
diff --git a/apps/desktop/src/lib/tabs/contentSurfaceEvents.ts b/apps/desktop/src/lib/tabs/contentSurfaceEvents.ts
new file mode 100644
index 0000000000..485a65485f
--- /dev/null
+++ b/apps/desktop/src/lib/tabs/contentSurfaceEvents.ts
@@ -0,0 +1,68 @@
+import type { ContentAreaSurfaceEmits } from "@/components/layout/querySurfaces";
+
+/**
+ * Single source of truth for the events every content surface forwards
+ * upward. Each layer (QueryEditorSurface / QueryResultSurface / EditorGroup /
+ * SqlEditorWorkspace) derives its v-bind handler object from this list, so the
+ * four forwarding layers cannot drift apart when the contract changes.
+ */
+export const contentSurfaceEventNames = [
+ "update:activeOutputView",
+ "fixWithAi",
+ "sendSelectionToAi",
+ "previewChangesAvailable",
+ "execute",
+ "executeInNewResultTab",
+ "saveSql",
+ "cancel",
+ "explain",
+ "editorUpdate",
+ "editorSelectionChange",
+ "editorCursorChange",
+ "editorViewportChange",
+ "editorSelectionStateChange",
+ "formatError",
+ "reload",
+ "paginate",
+ "sort",
+ "executeSql",
+ "clickTable",
+ "viewTableData",
+ "viewTableDdl",
+ "editTableStructure",
+ "openObjectSource",
+ "openObjectTable",
+ "objectSchemaChange",
+ "objectBrowserViewportChange",
+ "structureEditorSaved",
+ "structureEditorClose",
+ "previewStatement",
+ "focusStatement",
+ "openSettings",
+ "openConnectionSettings",
+ "toggleZenMode",
+] as const satisfies Array;
+
+export type ContentSurfaceEventName = (typeof contentSurfaceEventNames)[number];
+
+export type ContentSurfaceEventForwarders = {
+ [K in ContentSurfaceEventName as `on${Capitalize}`]: (...args: ContentAreaSurfaceEmits[K]) => void;
+};
+
+type ContentSurfaceEmit = (event: K, ...args: ContentAreaSurfaceEmits[K]) => void;
+
+/**
+ * Builds the v-bind event object that re-emits every shared content event.
+ * Emitters stay typed through ContentAreaSurfaceEmits, so adding a new event
+ * to the contract only requires extending the event list once.
+ */
+export function createContentSurfaceEventForwarders(emit: ContentSurfaceEmit): ContentSurfaceEventForwarders {
+ const forwarders = {} as Record void>;
+ for (const event of contentSurfaceEventNames) {
+ const handlerName = `on${event.charAt(0).toUpperCase()}${event.slice(1)}`;
+ forwarders[handlerName] = (...args: unknown[]) => {
+ (emit as (event: ContentSurfaceEventName, ...args: unknown[]) => void)(event, ...args);
+ };
+ }
+ return forwarders as ContentSurfaceEventForwarders;
+}
diff --git a/apps/desktop/src/lib/tabs/editorToolbarLayout.ts b/apps/desktop/src/lib/tabs/editorToolbarLayout.ts
new file mode 100644
index 0000000000..1c398a9c6a
--- /dev/null
+++ b/apps/desktop/src/lib/tabs/editorToolbarLayout.ts
@@ -0,0 +1,60 @@
+/**
+ * Progressive condensation for the per-group SQL editor toolbar.
+ *
+ * The tier is NOT derived from fixed width breakpoints — the control set
+ * changes per database type, transaction state, and feature flags, so any
+ * static guess would over- or under-condense. Instead the toolbar measures its
+ * own row: while the content overflows the available width it steps up one
+ * tier (moving controls into the overflow menu); it only steps back down when
+ * there is real slack AND the pane has grown meaningfully since the last
+ * condensation, so a static narrow layout can never oscillate.
+ *
+ * - Tier 0: full layout.
+ * - Tier 1: secondary edit/entry actions (compress, keyword case, semantic
+ * diagnostics, open SQL, import archive, paste-in-condition, multi-execute)
+ * move into the overflow menu.
+ * - Tier 2: format and the database helper buttons (clear / set-default) join
+ * the overflow menu.
+ * - Tier 3: the explain-analyze toggle and the schema/catalog selectors leave
+ * the row; the row keeps execute, explain, save, transaction controls, the
+ * connection and database selectors (hard-truncated), and the overflow menu.
+ */
+export type EditorToolbarTier = 0 | 1 | 2 | 3;
+
+export const EDITOR_TOOLBAR_MAX_TIER = 3;
+
+/** Slack (px) required before giving a tier back — absorbs rendering jitter. */
+export const EDITOR_TOOLBAR_STEP_DOWN_SLACK_PX = 48;
+/** How much wider the pane must grow (px) vs the last condensation before stepping down. */
+export const EDITOR_TOOLBAR_STEP_DOWN_MIN_GROWTH_PX = 64;
+
+export interface EditorToolbarTierInput {
+ tier: EditorToolbarTier;
+ /** Measured inner width of the toolbar row. */
+ availableWidth: number;
+ /** Measured extent of the row's content (scrollWidth), including overflow. */
+ contentWidth: number;
+ /** Toolbar width when the current tier was condensed into; anchors step-down hysteresis. */
+ condensedAtWidth?: number;
+}
+
+/**
+ * Resolves the next toolbar tier from real measurements. Monotonic per
+ * measurement — repeated calls after a tier change converge instead of
+ * oscillating, because stepping down additionally requires the pane to have
+ * grown by EDITOR_TOOLBAR_STEP_DOWN_MIN_GROWTH_PX since the last step up.
+ */
+export function resolveNextEditorToolbarTier(input: EditorToolbarTierInput): EditorToolbarTier {
+ const { tier, availableWidth, contentWidth, condensedAtWidth = 0 } = input;
+ if (!Number.isFinite(availableWidth) || !Number.isFinite(contentWidth) || availableWidth <= 0 || contentWidth <= 0) {
+ // Unmeasured (first paint, hosts without ResizeObserver) keeps the current tier.
+ return tier;
+ }
+ if (contentWidth > availableWidth && tier < EDITOR_TOOLBAR_MAX_TIER) {
+ return (tier + 1) as EditorToolbarTier;
+ }
+ if (tier > 0 && availableWidth - contentWidth >= EDITOR_TOOLBAR_STEP_DOWN_SLACK_PX && availableWidth - condensedAtWidth >= EDITOR_TOOLBAR_STEP_DOWN_MIN_GROWTH_PX) {
+ return (tier - 1) as EditorToolbarTier;
+ }
+ return tier;
+}
diff --git a/apps/desktop/src/lib/tabs/tabDrag.ts b/apps/desktop/src/lib/tabs/tabDrag.ts
new file mode 100644
index 0000000000..a1a7188c73
--- /dev/null
+++ b/apps/desktop/src/lib/tabs/tabDrag.ts
@@ -0,0 +1,28 @@
+export const TAB_DRAG_MIME = "application/x-dbx-tab";
+
+export interface TabDragPayload {
+ mime: typeof TAB_DRAG_MIME;
+ tabId: string;
+ sourceGroupId: string;
+}
+
+/**
+ * Serializes the drag contract under the project's custom MIME type. The MIME
+ * value travels inside the payload as a discriminator, so a drop handler can
+ * reject foreign or stale payloads before touching store state.
+ */
+export function serializeTabDragPayload(payload: { tabId: string; sourceGroupId: string }): string {
+ return JSON.stringify({ mime: TAB_DRAG_MIME, tabId: payload.tabId, sourceGroupId: payload.sourceGroupId });
+}
+
+export function parseTabDragPayload(raw: string): TabDragPayload | null {
+ try {
+ const value = JSON.parse(raw) as Partial;
+ if (value.mime === TAB_DRAG_MIME && typeof value.tabId === "string" && typeof value.sourceGroupId === "string") {
+ return { mime: TAB_DRAG_MIME, tabId: value.tabId, sourceGroupId: value.sourceGroupId };
+ }
+ return null;
+ } catch {
+ return null;
+ }
+}
diff --git a/apps/desktop/src/lib/tabs/tabMenu.ts b/apps/desktop/src/lib/tabs/tabMenu.ts
new file mode 100644
index 0000000000..2d57277707
--- /dev/null
+++ b/apps/desktop/src/lib/tabs/tabMenu.ts
@@ -0,0 +1,82 @@
+import { Copy, Crosshair, Pencil, Pin, X } from "@lucide/vue";
+import type { ContextMenuItem } from "@/components/ui/customContextMenuRegistry";
+import type { QueryTab } from "@/types/database";
+
+export function createRenameDuplicateTabItems(options: { tab: QueryTab; t: (key: string) => string; canRename: boolean; onRename: () => void; onDuplicate: () => void }): ContextMenuItem[] {
+ return [
+ ...(options.canRename
+ ? [
+ {
+ label: options.t("contextMenu.renameTab"),
+ action: options.onRename,
+ icon: Pencil,
+ },
+ ]
+ : []),
+ {
+ label: options.t("contextMenu.duplicateTab"),
+ action: options.onDuplicate,
+ icon: Copy,
+ visible: options.canRename,
+ },
+ ];
+}
+
+export function createLocateTabMenuItem(options: { t: (key: string) => string; visible: boolean; onLocate: () => void }): ContextMenuItem {
+ return {
+ label: options.t("sidebar.locateActiveTab"),
+ action: options.onLocate,
+ icon: Crosshair,
+ visible: options.visible,
+ };
+}
+
+export function createPinTabMenuItem(options: { label: string; iconClass?: string; onToggle: () => void }): ContextMenuItem {
+ return {
+ label: options.label,
+ action: options.onToggle,
+ icon: Pin,
+ ...(options.iconClass ? { iconClass: options.iconClass } : {}),
+ };
+}
+
+export function createCloseTabMenuItem(options: { label: string; onClose: () => void }): ContextMenuItem {
+ return { label: options.label, action: options.onClose, icon: X };
+}
+
+export function createCloseOtherTabMenuItem(options: { label: string; disabled?: boolean; shortcut?: string; onClose: () => void }): ContextMenuItem {
+ return {
+ label: options.label,
+ action: options.onClose,
+ ...(options.disabled !== undefined ? { disabled: options.disabled } : {}),
+ ...(options.shortcut ? { shortcut: options.shortcut } : {}),
+ icon: X,
+ };
+}
+
+export function createCloseRightTabMenuItem(options: { label: string; disabled?: boolean; onClose: () => void }): ContextMenuItem {
+ return {
+ label: options.label,
+ action: options.onClose,
+ ...(options.disabled !== undefined ? { disabled: options.disabled } : {}),
+ icon: X,
+ };
+}
+
+export function createCloseLeftTabMenuItem(options: { label: string; disabled?: boolean; onClose: () => void }): ContextMenuItem {
+ return {
+ label: options.label,
+ action: options.onClose,
+ ...(options.disabled !== undefined ? { disabled: options.disabled } : {}),
+ icon: X,
+ };
+}
+
+export function createCloseAllTabMenuItem(options: { label: string; variant?: "destructive"; onClose: () => void }): ContextMenuItem {
+ return {
+ label: options.label,
+ action: options.onClose,
+ ...(options.variant ? { variant: options.variant } : {}),
+ icon: X,
+ };
+}
diff --git a/apps/desktop/src/lib/tabs/tabPresentation.ts b/apps/desktop/src/lib/tabs/tabPresentation.ts
index 5a88badd18..b4f49b8854 100644
--- a/apps/desktop/src/lib/tabs/tabPresentation.ts
+++ b/apps/desktop/src/lib/tabs/tabPresentation.ts
@@ -1,5 +1,7 @@
-import { useConnectionStore } from "@/stores/connectionStore";
+import { useConnectionStore } from "@/stores/connectionStore";
import { useSettingsStore } from "@/stores/settingsStore";
+import { hexToRgba } from "@/lib/common/color";
+import type { CSSProperties } from "vue";
import { findConnectionGroupPath } from "@/lib/sidebar/sidebarLayout";
import { splitMongoCommandRanges } from "@/lib/mongo/mongoShellCommand";
import { executableStatementRanges, splitSqlStatementRanges, type SqlTextRange } from "@/lib/sql/sqlStatementRanges";
@@ -464,3 +466,60 @@ export function tabModeLabel(tab: QueryTab, t: Translate): string {
if (tab.mode === "dolt-version-control") return t("doltVersionControl.title");
return tab.mode;
}
+
+export function tabDatabaseIconType(tab: QueryTab): string {
+ const connectionStore = useConnectionStore();
+ const connection = connectionStore.getConfig(tab.connectionId);
+ if (!connection) return "mq";
+ if (connection.db_type === "mq") {
+ const externalConfig = connection.external_config as { systemKind?: unknown } | undefined;
+ const systemKind = typeof externalConfig?.systemKind === "string" ? externalConfig.systemKind : "";
+ if (connection.driver_profile === "kafka" || systemKind === "kafka") return "kafka";
+ if (connection.driver_profile === "rocketmq" || systemKind === "rocketmq") return "rocketmq";
+ if (connection.driver_profile === "rabbitmq" || systemKind === "rabbitmq") return "rabbitmq";
+ if (connection.driver_profile === "pulsar" || systemKind === "pulsar") return "pulsar";
+ }
+ return connection.driver_profile || connection.db_type;
+}
+
+export function tabIconClass(tab: QueryTab): string {
+ if (tab.externalSqlFileMissing) return "text-amber-600 dark:text-amber-400";
+ if (tab.mode === "mq") return "";
+ if (tab.mode === "databases" || tab.mode === "objects") return "text-amber-500 dark:text-amber-400";
+ if (tab.mode === "data" || tab.mode === "mongo" || tab.mode === "vector" || tab.mode === "redis" || tab.mode === "hbase" || tab.mode === "structure") return "text-emerald-600 dark:text-emerald-400";
+ return "text-blue-600 dark:text-blue-400";
+}
+
+export function tabColorStyle(tab: QueryTab, active: boolean, isClassic: boolean): CSSProperties | undefined {
+ const color = connectionColor(tab.connectionId);
+ if (!color) {
+ if (isClassic) {
+ return active ? { boxShadow: "inset 0 -2px 0 var(--ring)" } : undefined;
+ }
+ return active ? { borderColor: "var(--ring)" } : undefined;
+ }
+ if (isClassic) {
+ return {
+ "--app-tab-background": hexToRgba(color, active ? 0.16 : 0.07),
+ "--app-tab-hover-background": hexToRgba(color, 0.14),
+ boxShadow: active ? `inset 0 -2px 0 ${color}` : undefined,
+ };
+ }
+ return {
+ "--app-tab-background": hexToRgba(color, active ? 0.16 : 0.09),
+ "--app-tab-hover-background": hexToRgba(color, 0.16),
+ borderColor: active ? hexToRgba(color, 0.72) : hexToRgba(color, 0.18),
+ };
+}
+
+export function dirtyTabTitleStyle(isDirty: boolean): CSSProperties | undefined {
+ if (!isDirty) {
+ return undefined;
+ }
+ return {
+ fontStyle: "italic",
+ fontWeight: 700,
+ transform: "skewX(-8deg)",
+ transformOrigin: "left center",
+ };
+}
diff --git a/apps/desktop/src/stores/__tests__/queryStore.detached.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.detached.spec.ts
new file mode 100644
index 0000000000..cb25f1e402
--- /dev/null
+++ b/apps/desktop/src/stores/__tests__/queryStore.detached.spec.ts
@@ -0,0 +1,111 @@
+import { createPinia, setActivePinia } from "pinia";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.setConfig({ testTimeout: 10_000 });
+
+// The detach chain must never touch the persistence transport: adopting a
+// handoff is an in-memory store transition; the file only moves via
+// saveDetachedTabHandoff in App-level code, which these tests do not drive.
+vi.mock("@/lib/backend/api", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ loadOpenTabsState: vi.fn(() => Promise.resolve(null)),
+ saveOpenTabsState: vi.fn(() => Promise.resolve()),
+ saveDetachedTabHandoff: vi.fn(() => Promise.resolve()),
+ deleteDetachedTabHandoff: vi.fn(() => Promise.resolve()),
+ };
+});
+
+describe("queryStore detached-tab handoff", () => {
+ beforeEach(() => {
+ vi.resetModules();
+ vi.unstubAllGlobals();
+ vi.stubGlobal("localStorage", {
+ getItem: vi.fn(() => null),
+ setItem: vi.fn(),
+ removeItem: vi.fn(),
+ });
+ vi.stubGlobal("window", {
+ dispatchEvent: vi.fn(),
+ setTimeout: vi.fn((_fn: () => void) => 0),
+ clearTimeout: vi.fn(),
+ });
+ setActivePinia(createPinia());
+ });
+
+ async function makeHandoff() {
+ const { useQueryStore } = await import("@/stores/queryStore");
+ const store = useQueryStore();
+ const tabId = store.createTab("pg-1", "app", "Query 1", "query");
+ // A result-less query tab keeps prepareDetachedTab off the backend cache.
+ const handoff = await store.prepareDetachedTab(tabId);
+ return { store, tabId, handoff };
+ }
+
+ it("adopting a handoff lands the tab in the main pane so the split workspace can render it", async () => {
+ const { handoff } = await makeHandoff();
+
+ // Fresh store = the detached window's store before adoption.
+ setActivePinia(createPinia());
+ const { useQueryStore } = await import("@/stores/queryStore");
+ const freshStore = useQueryStore();
+ expect(freshStore.tabs).toHaveLength(0);
+
+ await freshStore.adoptDetachedTab(handoff);
+
+ expect(freshStore.tabs.map((tab) => tab.id)).toEqual([handoff.tabId]);
+ // Without group membership the workspace renders the empty-group
+ // placeholder regardless of tabs/activeTabId — membership is the fix.
+ expect(freshStore.groups[0]?.tabIds).toEqual([handoff.tabId]);
+ expect(freshStore.groups[0]?.activeTabId).toBe(handoff.tabId);
+ expect(freshStore.activeTabId).toBe(handoff.tabId);
+ expect(freshStore.focusedGroupId).toBe(freshStore.groups[0]?.id);
+ });
+
+ it("removeTabAfterDetachedReady clears the owning pane and prunes it when emptied", async () => {
+ const { useQueryStore } = await import("@/stores/queryStore");
+ const store = useQueryStore();
+ const mainTabId = store.createTab("pg-1", "app", "Query 1", "query");
+ const detachedTabId = store.createTab("pg-1", "app", "Query 2", "query");
+ store.splitTabRight(detachedTabId);
+ expect(store.groups).toHaveLength(2);
+ store.activateTab(detachedTabId);
+
+ expect(store.removeTabAfterDetachedReady(detachedTabId)).toBe(true);
+
+ // No dangling group entry: the emptied pane is pruned, the main pane and
+ // its tab survive, and the global active tab re-syncs to a live tab.
+ expect(store.tabs.map((tab) => tab.id)).toEqual([mainTabId]);
+ expect(store.groups).toHaveLength(1);
+ expect(store.groups[0]?.tabIds).toEqual([mainTabId]);
+ expect(store.activeTabId).toBe(mainTabId);
+ });
+
+ it("returning a detached tab re-homes it into the workspace after its pane was pruned", async () => {
+ const { store, tabId, handoff } = await makeHandoff();
+ store.activateTab(tabId);
+ store.removeTabAfterDetachedReady(tabId);
+ expect(store.tabs).toHaveLength(0);
+
+ await store.adoptDetachedTab(handoff);
+
+ expect(store.tabs.map((tab) => tab.id)).toEqual([tabId]);
+ expect(store.groups[0]?.tabIds).toEqual([tabId]);
+ expect(store.activeTabId).toBe(tabId);
+ });
+
+ it("re-adopting the same handoff does not duplicate the tab or its group entry", async () => {
+ const { handoff } = await makeHandoff();
+
+ setActivePinia(createPinia());
+ const { useQueryStore } = await import("@/stores/queryStore");
+ const freshStore = useQueryStore();
+ await freshStore.adoptDetachedTab(handoff);
+ await freshStore.adoptDetachedTab(handoff);
+
+ expect(freshStore.tabs).toHaveLength(1);
+ expect(freshStore.groups[0]?.tabIds).toEqual([handoff.tabId]);
+ expect(freshStore.activeTabId).toBe(handoff.tabId);
+ });
+});
diff --git a/apps/desktop/src/stores/__tests__/queryStore.groups.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.groups.spec.ts
new file mode 100644
index 0000000000..ea5fd6a357
--- /dev/null
+++ b/apps/desktop/src/stores/__tests__/queryStore.groups.spec.ts
@@ -0,0 +1,175 @@
+import { createPinia, setActivePinia } from "pinia";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.setConfig({ testTimeout: 10_000 });
+
+// Mock the persistence transport for the whole file: real timers scheduled by
+// one test's debounced persist must not leak a previous store's tabs into a
+// later test's localStorage-backed restore payload.
+const restoreWorkspacePayload = vi.hoisted(() => ({
+ tabs: Array.from({ length: 5 }, (_, index) => ({ id: `tab-${index}`, title: `Query ${index + 1}`, connectionId: "pg-1", database: "app", sql: "select 1" })),
+ activeTabId: "tab-0",
+ groups: Array.from({ length: 5 }, (_, index) => ({ id: `g${index}`, tabIds: [`tab-${index}`], activeTabId: `tab-${index}` })),
+ focusedGroupId: "g0",
+ orientation: "vertical",
+ sizes: [20, 20, 20, 20, 20],
+}));
+
+vi.mock("@/lib/backend/api", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ loadOpenTabsState: vi.fn(() => Promise.resolve(restoreWorkspacePayload)),
+ saveOpenTabsState: vi.fn(() => Promise.resolve()),
+ };
+});
+
+describe("queryStore editor groups", () => {
+ beforeEach(() => {
+ vi.resetModules();
+ vi.unstubAllGlobals();
+ vi.stubGlobal("localStorage", {
+ getItem: vi.fn(() => null),
+ setItem: vi.fn(),
+ removeItem: vi.fn(),
+ });
+ vi.stubGlobal("window", {
+ dispatchEvent: vi.fn(),
+ setTimeout: vi.fn((_fn: () => void) => 0),
+ clearTimeout: vi.fn(),
+ });
+ setActivePinia(createPinia());
+ });
+
+ it("splits down and toggles orientation", async () => {
+ const { useQueryStore } = await import("@/stores/queryStore");
+ const store = useQueryStore();
+ const firstId = store.createTab("pg-1", "app", "Query 1", "query");
+ store.createTab("pg-1", "app", "Query 2", "query");
+
+ expect(store.splitTabDown(firstId)).toBe(true);
+ expect(store.groups).toHaveLength(2);
+
+ store.setOrientation("horizontal");
+ expect(store.orientation).toBe("horizontal");
+ store.setOrientation("vertical");
+ expect(store.orientation).toBe("vertical");
+ });
+
+ it("moves a tab across groups and removes the empty source group", async () => {
+ const { useQueryStore } = await import("@/stores/queryStore");
+ const store = useQueryStore();
+ const firstId = store.createTab("pg-1", "app", "Query 1", "query");
+ const secondId = store.createTab("pg-1", "app", "Query 2", "query");
+ const thirdId = store.createTab("pg-1", "app", "Query 3", "query");
+
+ store.splitTabRight(firstId);
+ const rightGroup = store.groups[1];
+
+ expect(store.moveTabToGroup(secondId, rightGroup.id)).toBe(true);
+ expect(rightGroup.tabIds).toContain(secondId);
+ expect(store.groups[0].tabIds).toEqual([thirdId]);
+ expect(store.groups).toHaveLength(2);
+ });
+
+ it("unsplits a secondary group tab back to the main group", async () => {
+ const { useQueryStore } = await import("@/stores/queryStore");
+ const store = useQueryStore();
+ const firstId = store.createTab("pg-1", "app", "Query 1", "query");
+ const secondId = store.createTab("pg-1", "app", "Query 2", "query");
+
+ store.splitTabRight(firstId);
+ const mainId = store.groups[0].id;
+
+ expect(store.unsplitTab(firstId)).toBe(true);
+ expect(store.groups).toHaveLength(1);
+ expect(store.groups[0].id).toBe(mainId);
+ expect(store.groups[0].tabIds).toEqual([secondId, firstId]);
+ expect(store.focusedGroupId).toBe(mainId);
+ });
+
+ it("activates a tab atomically across group, focus, and global active tab", async () => {
+ const { useQueryStore } = await import("@/stores/queryStore");
+ const store = useQueryStore();
+ const firstId = store.createTab("pg-1", "app", "Query 1", "query");
+ const thirdId = store.createTab("pg-1", "app", "Query 3", "query");
+
+ store.splitTabRight(firstId);
+ const leftGroup = store.groups[0];
+ const rightGroup = store.groups[1];
+
+ expect(store.activateTab(thirdId)).toBe(true);
+ expect(leftGroup.activeTabId).toBe(thirdId);
+ expect(store.focusedGroupId).toBe(leftGroup.id);
+ expect(store.activeTabId).toBe(thirdId);
+
+ expect(store.activateTab(firstId)).toBe(true);
+ expect(rightGroup.activeTabId).toBe(firstId);
+ expect(store.focusedGroupId).toBe(rightGroup.id);
+ expect(store.activeTabId).toBe(firstId);
+
+ expect(store.activateTab("missing")).toBe(false);
+ });
+
+ it("close all in group only closes the same pinned partition as the trigger tab", async () => {
+ const { useQueryStore } = await import("@/stores/queryStore");
+ const store = useQueryStore();
+ const firstId = store.createTab("pg-1", "app", "Query 1", "query");
+ const secondId = store.createTab("pg-1", "app", "Query 2", "query");
+ const thirdId = store.createTab("pg-1", "app", "Query 3", "query");
+
+ store.togglePinnedTab(secondId);
+ const mainId = store.groups[0].id;
+
+ store.closeAllTabsInGroup(mainId, firstId);
+
+ expect(store.tabs.some((tab) => tab.id === firstId)).toBe(false);
+ expect(store.tabs.some((tab) => tab.id === thirdId)).toBe(false);
+ expect(store.tabs.some((tab) => tab.id === secondId)).toBe(true);
+
+ store.closeAllTabsInGroup(mainId, secondId);
+ expect(store.tabs.some((tab) => tab.id === secondId)).toBe(false);
+ });
+
+ it("normalizes more than four groups at the restore boundary without orphaning tabs", async () => {
+ const { useSettingsStore } = await import("@/stores/settingsStore");
+ const { useQueryStore } = await import("@/stores/queryStore");
+ await useSettingsStore().initEditorSettings();
+ const store = useQueryStore();
+ await store.initOpenTabs();
+
+ expect(store.groups.length).toBeLessThanOrEqual(4);
+ const assigned = store.groups.flatMap((group) => group.tabIds);
+ for (let index = 0; index < 5; index += 1) {
+ expect(assigned.filter((candidate) => candidate === `tab-${index}`)).toHaveLength(1);
+ }
+ expect(store.activeTabId).toBe(store.groups[0].activeTabId);
+ });
+
+ it("derives the restored global active tab from the focused group, not the stale payload value", async () => {
+ // The payload is internally inconsistent: activeTabId points into g2 while
+ // focusedGroupId is g1. Restore must trust the focused group.
+ restoreWorkspacePayload.activeTabId = "tab-1";
+ restoreWorkspacePayload.groups = [
+ { id: "g1", tabIds: ["tab-0"], activeTabId: "tab-0" },
+ { id: "g2", tabIds: ["tab-1"], activeTabId: "tab-1" },
+ ];
+ restoreWorkspacePayload.focusedGroupId = "g1";
+ restoreWorkspacePayload.sizes = [50, 50];
+
+ const { useSettingsStore } = await import("@/stores/settingsStore");
+ const { useQueryStore } = await import("@/stores/queryStore");
+ await useSettingsStore().initEditorSettings();
+ const store = useQueryStore();
+ await store.initOpenTabs();
+
+ expect(store.focusedGroupId).toBe("g1");
+ expect(store.activeTabId).toBe("tab-0");
+ expect(store.groups[1].activeTabId).toBe("tab-1");
+
+ restoreWorkspacePayload.activeTabId = "tab-0";
+ restoreWorkspacePayload.groups = Array.from({ length: 5 }, (_, index) => ({ id: `g${index}`, tabIds: [`tab-${index}`], activeTabId: `tab-${index}` }));
+ restoreWorkspacePayload.focusedGroupId = "g0";
+ restoreWorkspacePayload.sizes = [20, 20, 20, 20, 20];
+ });
+});
diff --git a/apps/desktop/src/stores/__tests__/queryStore.splitRight.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.splitRight.spec.ts
new file mode 100644
index 0000000000..be9fbd69e9
--- /dev/null
+++ b/apps/desktop/src/stores/__tests__/queryStore.splitRight.spec.ts
@@ -0,0 +1,87 @@
+import { createPinia, setActivePinia } from "pinia";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.setConfig({ testTimeout: 10_000 });
+
+describe("queryStore split right", () => {
+ beforeEach(() => {
+ vi.resetModules();
+ vi.unstubAllGlobals();
+ vi.stubGlobal("localStorage", {
+ getItem: vi.fn(() => null),
+ setItem: vi.fn(),
+ removeItem: vi.fn(),
+ });
+ vi.stubGlobal("window", { dispatchEvent: vi.fn() });
+ setActivePinia(createPinia());
+ });
+
+ it("moves a query tab into a new right group and focuses it", async () => {
+ const { useQueryStore } = await import("@/stores/queryStore");
+ const queryStore = useQueryStore();
+ const firstId = queryStore.createTab("pg-1", "app", "Query 1", "query");
+ const secondId = queryStore.createTab("pg-1", "app", "Query 2", "query");
+
+ const ok = queryStore.splitTabRight(firstId);
+
+ expect(ok).toBe(true);
+ expect(queryStore.groups).toHaveLength(2);
+ expect(queryStore.groups[0].tabIds).toEqual([secondId]);
+ expect(queryStore.groups[1].tabIds).toEqual([firstId]);
+ expect(queryStore.focusedGroupId).toBe(queryStore.groups[1].id);
+ expect(queryStore.activeTabId).toBe(firstId);
+ });
+
+ it("allows up to four groups and rejects the fifth split", async () => {
+ const { useQueryStore } = await import("@/stores/queryStore");
+ const queryStore = useQueryStore();
+ const ids = [
+ queryStore.createTab("pg-1", "app", "Query 1", "query"),
+ queryStore.createTab("pg-1", "app", "Query 2", "query"),
+ queryStore.createTab("pg-1", "app", "Query 3", "query"),
+ queryStore.createTab("pg-1", "app", "Query 4", "query"),
+ queryStore.createTab("pg-1", "app", "Query 5", "query"),
+ ];
+
+ expect(queryStore.splitTabRight(ids[1])).toBe(true);
+ expect(queryStore.groups).toHaveLength(2);
+ expect(queryStore.splitTabRight(ids[2])).toBe(true);
+ expect(queryStore.groups).toHaveLength(3);
+ expect(queryStore.splitTabRight(ids[3])).toBe(true);
+ expect(queryStore.groups).toHaveLength(4);
+ expect(queryStore.splitTabRight(ids[4])).toBe(false);
+ expect(queryStore.groups).toHaveLength(4);
+ });
+
+ it("splits non-query tabs into their own group like query tabs", async () => {
+ const { useQueryStore } = await import("@/stores/queryStore");
+ const queryStore = useQueryStore();
+ // A companion query tab keeps the store past the single-tab guard.
+ queryStore.createTab("pg-1", "app", "Query 1", "query");
+ const dataId = queryStore.createTab("pg-1", "app", "users", "data", "public");
+
+ expect(queryStore.splitTabRight(dataId)).toBe(true);
+ expect(queryStore.groups).toHaveLength(2);
+ expect(queryStore.groups[1].tabIds).toEqual([dataId]);
+ expect(queryStore.groups[1].activeTabId).toBe(dataId);
+ expect(queryStore.activeTabId).toBe(dataId);
+ });
+
+ it("rejects splitting the only open tab, which would be a no-op", async () => {
+ const { useQueryStore } = await import("@/stores/queryStore");
+ const queryStore = useQueryStore();
+ const onlyId = queryStore.createTab("pg-1", "app", "Query 1", "query");
+ const groupIdBefore = queryStore.groups[0].id;
+
+ const ok = queryStore.splitTabRight(onlyId);
+
+ // Splitting would move the tab into a new group and prune the emptied
+ // source group, returning to the same single-group layout — rejected so
+ // the menu item can render disabled instead of doing nothing.
+ expect(ok).toBe(false);
+ expect(queryStore.groups).toHaveLength(1);
+ expect(queryStore.groups[0].id).toBe(groupIdBefore);
+ expect(queryStore.groups[0].tabIds).toEqual([onlyId]);
+ expect(queryStore.focusedGroupId).toBe(groupIdBefore);
+ });
+});
diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts
index 4ca366ab3e..f6c28e9358 100644
--- a/apps/desktop/src/stores/queryStore.ts
+++ b/apps/desktop/src/stores/queryStore.ts
@@ -9,7 +9,7 @@ import { buildExplainSql, parseExplainResult, parseDamengExplainText, parseOracl
import { mysqlExplainCompatibilityHint } from "@/lib/diagram/mysqlExplainCompatibility";
import { allEditableColumnsWriteable, allPrimaryKeysPresent, analyzeEditableQueryEditability, analyzeSelectStructureForDisplay, resolveMetadataColumnName, resolveSourceColumnsByOrdinal, sourceColumnsForResult, type EditableQueryInfo, type EditableQuerySource } from "@/lib/sql/sqlAnalysis";
import { buildQueryWithHiddenPrimaryKeys, hiddenResultColumnIndexes, type HiddenPrimaryKeyProjection } from "@/lib/sql/editableQueryHiddenKeys";
-import { ACTIVE_TAB_STORAGE_KEY, OPEN_TABS_STORAGE_KEY, restoreOpenTabsPayload, restoreOpenTabsState, serializeOpenTabs } from "@/lib/app/openTabsPersistence";
+import { ACTIVE_TAB_STORAGE_KEY, OPEN_TABS_STORAGE_KEY, restoreOpenTabsPayload, restoreOpenTabsState, serializeOpenTabs, type OpenTabsStatePayload } from "@/lib/app/openTabsPersistence";
import {
evaluateMongoAggregateSafety,
evaluateMongoWriteSafety,
@@ -114,6 +114,19 @@ const ORACLE_QUERY_METADATA_PREFLIGHT_TIMEOUT = Symbol("oracle-query-metadata-pr
const SAVED_SQL_EDITOR_POSITION_PERSIST_DELAY_MS = 500;
type CloseConfirmContext = "tab" | "batch" | "app";
+export interface EditorGroup {
+ id: string;
+ tabIds: string[];
+ activeTabId: string | null;
+}
+
+export interface EditorWorkspacePersistState {
+ groups: EditorGroup[];
+ focusedGroupId: string;
+ orientation: "vertical" | "horizontal";
+ sizes: number[];
+}
+
interface BatchSqlResumeOptions {
batch: BatchSqlExecution;
previousResults: QueryResult[];
@@ -689,9 +702,20 @@ let saveTabsQueue = Promise.resolve();
let persistTimer: ReturnType | null = null;
let persistGeneration = 0;
-function saveTabs(tabs: QueryTab[], activeTabId: string | null): Promise {
+function saveTabs(tabs: QueryTab[], activeTabId: string | null, workspace?: EditorWorkspacePersistState): Promise {
if (isDetachedWindow()) return Promise.resolve();
- const payload = { tabs: serializeOpenTabs(tabs), activeTabId };
+ const payload: OpenTabsStatePayload = {
+ tabs: serializeOpenTabs(tabs),
+ activeTabId,
+ ...(workspace && tabs.length > 0
+ ? {
+ groups: workspace.groups.map((group) => ({ ...group, tabIds: [...group.tabIds] })),
+ focusedGroupId: workspace.focusedGroupId,
+ orientation: workspace.orientation,
+ sizes: [...workspace.sizes],
+ }
+ : {}),
+ };
saveTabsQueue = saveTabsQueue.catch(() => undefined).then(() => api.saveOpenTabsState(payload));
return saveTabsQueue;
}
@@ -708,23 +732,45 @@ function clearLegacySavedTabs() {
safeLocalStorageRemove(ACTIVE_TAB_STORAGE_KEY);
}
-function restoreSavedTabsFromPayload(payload: { tabs?: unknown; activeTabId?: unknown } | null | undefined, options: { validConnectionIds?: Iterable } = {}): { tabs: QueryTab[]; activeTabId: string | null } {
+function restoreSavedTabsFromPayload(
+ payload: { tabs?: unknown; activeTabId?: unknown; groups?: unknown; focusedGroupId?: unknown; orientation?: unknown; sizes?: unknown } | null | undefined,
+ options: { validConnectionIds?: Iterable } = {},
+): { tabs: QueryTab[]; activeTabId: string | null; workspace?: unknown } {
const restoreMode = useSettingsStore().editorSettings.openTabsRestoreMode;
- if (restoreMode === "none") return { tabs: [], activeTabId: null };
- return restoreOpenTabsPayload(payload, {
+ if (restoreMode === "none") {
+ return { tabs: [], activeTabId: null, workspace: undefined };
+ }
+ const restored = restoreOpenTabsPayload(payload, {
filter: restoreMode === "pinned" ? "pinned" : "all",
validConnectionIds: options.validConnectionIds,
});
+ return {
+ ...restored,
+ workspace:
+ payload && "groups" in payload
+ ? {
+ groups: payload.groups,
+ focusedGroupId: payload.focusedGroupId,
+ orientation: payload.orientation,
+ sizes: payload.sizes,
+ }
+ : undefined,
+ };
}
-function restoreLegacySavedTabs(options: { validConnectionIds?: Iterable } = {}): { tabs: QueryTab[]; activeTabId: string | null } {
+function restoreLegacySavedTabs(options: { validConnectionIds?: Iterable } = {}): { tabs: QueryTab[]; activeTabId: string | null; workspace?: undefined } {
const restoreMode = useSettingsStore().editorSettings.openTabsRestoreMode;
- if (restoreMode === "none") return { tabs: [], activeTabId: null };
+ if (restoreMode === "none") {
+ return { tabs: [], activeTabId: null, workspace: undefined };
+ }
const legacy = loadLegacySavedTabs();
- return restoreOpenTabsState(legacy.rawTabs, legacy.rawActiveTabId, {
- filter: restoreMode === "pinned" ? "pinned" : "all",
- validConnectionIds: options.validConnectionIds,
- });
+ return {
+ ...restoreOpenTabsState(legacy.rawTabs, legacy.rawActiveTabId, {
+ filter: restoreMode === "pinned" ? "pinned" : "all",
+ validConnectionIds: options.validConnectionIds,
+ }),
+ workspace: undefined,
+ };
}
function getI18nT() {
@@ -768,11 +814,378 @@ export const useQueryStore = defineStore("query", () => {
return keys;
});
const activeTabId = ref(null);
+ const groups = ref([{ id: "main", tabIds: [], activeTabId: null }]);
+ const focusedGroupId = ref("main");
+ const orientation = ref<"vertical" | "horizontal">("vertical");
+ const sizes = ref([100]);
const isOpenTabsLoaded = ref(false);
const activeTabHistory = ref([]);
// Most-recently-activated tab ids, oldest first. Read-only view for the
// Ctrl+Tab switcher, which renders them in reverse.
const recentTabIds = computed(() => activeTabHistory.value);
+
+ function findGroup(groupId: string): EditorGroup | undefined {
+ return groups.value.find((group) => group.id === groupId);
+ }
+
+ function focusedGroup(): EditorGroup {
+ return findGroup(focusedGroupId.value) ?? groups.value[0];
+ }
+
+ function groupForTab(tabId: string): EditorGroup | undefined {
+ return groups.value.find((group) => group.tabIds.includes(tabId));
+ }
+
+ function syncActiveTabFromFocusedGroup() {
+ const group = focusedGroup();
+ if (!group) {
+ return;
+ }
+ if (group.activeTabId && tabs.value.some((tab) => tab.id === group.activeTabId)) {
+ activeTabId.value = group.activeTabId;
+ return;
+ }
+ const firstAvailable = groups.value.find((candidate) => candidate.tabIds.length > 0)?.tabIds.find((id) => tabs.value.some((tab) => tab.id === id)) ?? tabs.value[0]?.id ?? null;
+ if (firstAvailable) {
+ activeTabId.value = firstAvailable;
+ if (group.tabIds.includes(firstAvailable)) {
+ group.activeTabId = firstAvailable;
+ }
+ } else {
+ group.activeTabId = null;
+ activeTabId.value = null;
+ }
+ }
+
+ function normalizeGroups() {
+ const validIds = new Set(tabs.value.map((tab) => tab.id));
+
+ // Group ids must be unique and non-empty; keep only the first occurrence.
+ const seenGroupIds = new Set();
+ groups.value = groups.value.filter((group) => {
+ if (!group.id || seenGroupIds.has(group.id)) {
+ return false;
+ }
+ seenGroupIds.add(group.id);
+ return true;
+ });
+
+ // Enforce the four-group limit before assigning missing tabs, so tabs from
+ // dropped groups are merged into a kept group instead of becoming ownerless.
+ if (groups.value.length > 1) {
+ groups.value = groups.value.filter((group) => group.tabIds.length > 0);
+ }
+ if (groups.value.length > 4) {
+ const target = groups.value[0];
+ for (const group of groups.value.slice(4)) {
+ target.tabIds.push(...group.tabIds);
+ }
+ groups.value = groups.value.slice(0, 4);
+ }
+
+ // A tab may only belong to the first group that claims it.
+ const globallyAssigned = new Set();
+ for (const group of groups.value) {
+ const nextTabIds: string[] = [];
+ for (const id of group.tabIds) {
+ if (!validIds.has(id) || globallyAssigned.has(id)) {
+ continue;
+ }
+ globallyAssigned.add(id);
+ nextTabIds.push(id);
+ }
+ group.tabIds = nextTabIds;
+ if (group.activeTabId && !group.tabIds.includes(group.activeTabId)) {
+ group.activeTabId = group.tabIds[0] ?? null;
+ }
+ }
+
+ const assigned = new Set(groups.value.flatMap((group) => group.tabIds));
+ const missing = tabs.value.filter((tab) => !assigned.has(tab.id));
+ if (missing.length > 0) {
+ const target = focusedGroup() ?? groups.value[0];
+ if (target) {
+ for (const tab of missing) {
+ target.tabIds.push(tab.id);
+ }
+ }
+ }
+
+ if (groups.value.length > 1) {
+ groups.value = groups.value.filter((group) => group.tabIds.length > 0);
+ }
+ if (groups.value.length === 0) {
+ groups.value = [{ id: "main", tabIds: [], activeTabId: null }];
+ }
+ if (!groups.value.some((group) => group.id === focusedGroupId.value)) {
+ focusedGroupId.value = groups.value[0].id;
+ }
+
+ // Restore boundary order (guide §3.9): repair each group's active tab,
+ // then repair sizes, and only at the very end derive the global active tab
+ // from the focused group. A stale persisted activeTabId must never pull
+ // focus (or the group active) toward another group.
+ for (const group of groups.value) {
+ if (!group.activeTabId || !group.tabIds.includes(group.activeTabId)) {
+ group.activeTabId = group.tabIds[0] ?? null;
+ }
+ }
+
+ repairGroupSizes();
+
+ syncActiveTabFromFocusedGroup();
+ }
+
+ /** Keeps `sizes` aligned with the current group count after structural changes. */
+ function repairGroupSizes() {
+ const sizeCount = groups.value.length;
+ const validSizes = Array.isArray(sizes.value) && sizes.value.length === sizeCount && sizes.value.every((size) => Number.isFinite(size) && size > 0);
+ if (!validSizes) {
+ const each = Math.floor(100 / sizeCount);
+ sizes.value = groups.value.map((_, index) => (index === sizeCount - 1 ? 100 - each * (sizeCount - 1) : each));
+ return;
+ }
+ const total = sizes.value.reduce((sum, size) => sum + size, 0);
+ if (total <= 0) {
+ const each = Math.floor(100 / sizeCount);
+ sizes.value = groups.value.map((_, index) => (index === sizeCount - 1 ? 100 - each * (sizeCount - 1) : each));
+ } else if (Math.abs(total - 100) > 0.01) {
+ sizes.value = sizes.value.map((size) => (size / total) * 100);
+ }
+ }
+
+ /**
+ * Drops groups that no longer own any tab, keeps one (possibly empty) main
+ * group, re-points the focused group, and re-pairs sizes. Structural store
+ * operations call this directly instead of leaning on a full normalization
+ * pass or a post-flush watcher.
+ */
+ function pruneEmptyGroups() {
+ if (groups.value.length > 1) {
+ groups.value = groups.value.filter((group) => group.tabIds.length > 0);
+ }
+ if (groups.value.length === 0) {
+ groups.value = [{ id: "main", tabIds: [], activeTabId: null }];
+ }
+ if (!groups.value.some((group) => group.id === focusedGroupId.value)) {
+ focusedGroupId.value = groups.value[0].id;
+ }
+ repairGroupSizes();
+ }
+
+ function focusGroup(groupId: string) {
+ const group = findGroup(groupId) ?? groups.value[0];
+ if (!group) {
+ return;
+ }
+ focusedGroupId.value = group.id;
+ const candidate = group.activeTabId && tabs.value.some((tab) => tab.id === group.activeTabId) ? group.activeTabId : (group.tabIds.find((tabId) => tabs.value.some((tab) => tab.id === tabId)) ?? null);
+ if (candidate) {
+ group.activeTabId = candidate;
+ activeTabId.value = candidate;
+ }
+ // If the focused group has no valid active tab, keep the current global
+ // active tab rather than clearing it and unmounting the workspace.
+ settingsStore.settingsPageActive = false;
+ if (typeof window !== "undefined") {
+ window.dispatchEvent(new Event(QUERY_SURFACE_ACTIVATION_EVENT));
+ }
+ }
+
+ function activateTabInGroup(groupId: string, tabId: string) {
+ const group = findGroup(groupId);
+ if (!group || !group.tabIds.includes(tabId)) {
+ return;
+ }
+ group.activeTabId = tabId;
+ focusedGroupId.value = group.id;
+ activeTabId.value = tabId;
+ settingsStore.settingsPageActive = false;
+ if (typeof window !== "undefined") {
+ window.dispatchEvent(new Event(QUERY_SURFACE_ACTIVATION_EVENT));
+ }
+ }
+
+ function activateTab(tabId: string): boolean {
+ const owner = groupForTab(tabId);
+ if (!owner) {
+ return false;
+ }
+ owner.activeTabId = tabId;
+ focusedGroupId.value = owner.id;
+ activeTabId.value = tabId;
+ settingsStore.settingsPageActive = false;
+ if (typeof window !== "undefined") {
+ window.dispatchEvent(new Event(QUERY_SURFACE_ACTIVATION_EVENT));
+ }
+ return true;
+ }
+
+ /**
+ * Atomically registers a freshly built tab: the tab joins the focused group
+ * and (by default) becomes that group's active tab and the global active tab
+ * in one synchronous step. Programmatic open paths must use this instead of
+ * pushing to `tabs` and writing `activeTabId` separately, which used to leave
+ * the new tab ownerless until a post-flush watcher repaired the groups.
+ */
+ function registerOpenTab(tab: QueryTab, options: { activate?: boolean; insertAfterTabId?: string } = {}): string {
+ const anchorIndex = options.insertAfterTabId ? tabs.value.findIndex((item) => item.id === options.insertAfterTabId) : -1;
+ if (anchorIndex >= 0) {
+ tabs.value.splice(anchorIndex + 1, 0, tab);
+ } else {
+ tabs.value.push(tab);
+ }
+ const group = focusedGroup() ?? groups.value[0];
+ if (group && !group.tabIds.includes(tab.id)) {
+ const groupAnchorIndex = options.insertAfterTabId ? group.tabIds.indexOf(options.insertAfterTabId) : -1;
+ if (groupAnchorIndex >= 0) {
+ // Fresh tabs are never pinned; keep them behind the group's pinned block.
+ const pinnedCount = group.tabIds.filter((id) => tabs.value.find((item) => item.id === id)?.pinned).length;
+ const insertAt = tab.pinned ? groupAnchorIndex + 1 : Math.max(groupAnchorIndex + 1, pinnedCount);
+ group.tabIds.splice(insertAt, 0, tab.id);
+ } else {
+ group.tabIds.push(tab.id);
+ }
+ }
+ if (options.activate !== false) {
+ activateTab(tab.id);
+ }
+ return tab.id;
+ }
+
+ function splitTab(tabId: string, direction: "right" | "down"): boolean {
+ if (groups.value.length >= 4) {
+ return false;
+ }
+ // With a single open tab the split is a no-op: the tab moves into the new
+ // group, the emptied source group is pruned, and the layout returns to one
+ // group holding the same tab (only the group identity churns).
+ if (tabs.value.length <= 1) {
+ return false;
+ }
+ const tab = tabs.value.find((item) => item.id === tabId);
+ if (!tab) {
+ return false;
+ }
+ const owner = groupForTab(tabId);
+ if (!owner) {
+ return false;
+ }
+
+ orientation.value = direction === "right" ? "vertical" : "horizontal";
+
+ const newGroupId = uuid();
+ const newGroup: EditorGroup = { id: newGroupId, tabIds: [tabId], activeTabId: tabId };
+ const ownerIndex = groups.value.findIndex((group) => group.id === owner.id);
+ owner.tabIds = owner.tabIds.filter((id) => id !== tabId);
+ if (owner.activeTabId === tabId) {
+ owner.activeTabId = owner.tabIds[0] ?? null;
+ }
+ groups.value.splice(ownerIndex + 1, 0, newGroup);
+ // Removing the tab may leave the source group empty; pruning keeps sizes
+ // paired with the surviving groups without a full normalization pass.
+ pruneEmptyGroups();
+ focusedGroupId.value = newGroup.id;
+ activeTabId.value = tabId;
+ return true;
+ }
+
+ function splitTabRight(tabId: string): boolean {
+ return splitTab(tabId, "right");
+ }
+
+ function splitTabDown(tabId: string): boolean {
+ return splitTab(tabId, "down");
+ }
+
+ function setOrientation(value: "vertical" | "horizontal") {
+ orientation.value = value;
+ }
+
+ function moveTabToGroup(tabId: string, targetGroupId: string, index?: number): boolean {
+ const tab = tabs.value.find((item) => item.id === tabId);
+ const target = findGroup(targetGroupId);
+ if (!tab || !target) {
+ return false;
+ }
+
+ const source = groupForTab(tabId);
+ if (!source) {
+ return false;
+ }
+
+ function pinnedCountIn(tabIds: string[]): number {
+ return tabIds.filter((id) => tabs.value.find((item) => item.id === id)?.pinned).length;
+ }
+
+ if (source.id === target.id) {
+ if (index === undefined) {
+ return false;
+ }
+ const fromIndex = target.tabIds.indexOf(tabId);
+ if (fromIndex < 0) {
+ return false;
+ }
+ const next = [...target.tabIds];
+ next.splice(fromIndex, 1);
+ let insertAt = index;
+ if (fromIndex < insertAt) {
+ insertAt -= 1;
+ }
+ const pinnedCount = pinnedCountIn(next);
+ if (tab.pinned) {
+ insertAt = Math.max(0, Math.min(insertAt, pinnedCount));
+ } else {
+ insertAt = Math.max(pinnedCount, Math.min(insertAt, next.length));
+ }
+ next.splice(insertAt, 0, tabId);
+ target.tabIds = next;
+ return true;
+ }
+
+ source.tabIds = source.tabIds.filter((id) => id !== tabId);
+ if (source.activeTabId === tabId) {
+ source.activeTabId = source.tabIds[0] ?? null;
+ }
+
+ const pinnedCount = pinnedCountIn(target.tabIds);
+ const rawIndex = index ?? target.tabIds.length;
+ const insertAt = tab.pinned ? Math.max(0, Math.min(rawIndex, pinnedCount)) : Math.max(pinnedCount, Math.min(rawIndex, target.tabIds.length));
+ target.tabIds.splice(insertAt, 0, tabId);
+
+ target.activeTabId = tabId;
+ focusedGroupId.value = target.id;
+ activeTabId.value = tabId;
+ pruneEmptyGroups();
+ return true;
+ }
+
+ function unsplitTab(tabId: string): boolean {
+ const main = groups.value[0];
+ if (!main) {
+ return false;
+ }
+ if (main.tabIds.includes(tabId)) {
+ focusGroup(main.id);
+ return true;
+ }
+ const source = groupForTab(tabId);
+ if (!source) {
+ return false;
+ }
+
+ source.tabIds = source.tabIds.filter((id) => id !== tabId);
+ if (source.activeTabId === tabId) {
+ source.activeTabId = source.tabIds[0] ?? null;
+ }
+ main.tabIds.push(tabId);
+ main.activeTabId = tabId;
+ focusedGroupId.value = main.id;
+ activeTabId.value = tabId;
+ pruneEmptyGroups();
+ return true;
+ }
+
const showCloseConfirm = ref(false);
const pendingCloseTabId = ref(null);
const pendingBatchCloseTabIds = ref(null);
@@ -1671,7 +2084,7 @@ export const useQueryStore = defineStore("query", () => {
clearResultPayload(tab, { evicted: true });
}
- function applyRestoredOpenTabs(restored: { tabs: QueryTab[]; activeTabId: string | null }) {
+ function applyRestoredOpenTabs(restored: { tabs: QueryTab[]; activeTabId: string | null; workspace?: unknown }) {
const connectionStore = useConnectionStore();
for (const tab of restored.tabs) {
const connection = connectionStore.getConfig(tab.connectionId);
@@ -1682,11 +2095,46 @@ export const useQueryStore = defineStore("query", () => {
}
}
tabs.value = restored.tabs;
- activeTabId.value = restored.activeTabId;
- activeTabHistory.value = restored.activeTabId ? [restored.activeTabId] : [];
for (const tab of restored.tabs) {
- if (tab.mode === "data") void deleteTabResultSnapshot(tabResultCacheKey(tab.id));
+ if (tab.mode === "data") {
+ void deleteTabResultSnapshot(tabResultCacheKey(tab.id));
+ }
}
+
+ // Restore order (guide §3.9): the persisted global active tab is only a
+ // hint for legacy tabs-only payloads. Group membership, group actives and
+ // the focused group are repaired first; the global active tab is derived
+ // from the focused group last, so a stale payload value can never move
+ // focus to a different group.
+ const workspace = restored.workspace && typeof restored.workspace === "object" ? (restored.workspace as Record) : undefined;
+ if (!Array.isArray(workspace?.groups) && restored.activeTabId && tabs.value.some((tab) => tab.id === restored.activeTabId)) {
+ groups.value = [{ id: "main", tabIds: tabs.value.map((tab) => tab.id), activeTabId: restored.activeTabId }];
+ focusedGroupId.value = "main";
+ } else if (workspace) {
+ if (Array.isArray(workspace.groups)) {
+ groups.value = (workspace.groups as Array>)
+ .filter((group) => group && typeof group.id === "string")
+ .map((group) => ({
+ id: group.id as string,
+ tabIds: Array.isArray(group.tabIds) ? (group.tabIds as unknown[]).map(String) : [],
+ activeTabId: typeof group.activeTabId === "string" ? group.activeTabId : null,
+ }));
+ }
+ if (typeof workspace.focusedGroupId === "string") {
+ focusedGroupId.value = workspace.focusedGroupId;
+ }
+ if (workspace.orientation === "vertical" || workspace.orientation === "horizontal") {
+ orientation.value = workspace.orientation;
+ }
+ if (Array.isArray(workspace.sizes)) {
+ sizes.value = (workspace.sizes as unknown[]).map(Number);
+ }
+ }
+ // Start from no global active so normalization cannot treat the persisted
+ // value as authoritative over the restored focused group.
+ activeTabId.value = null;
+ normalizeGroups();
+ activeTabHistory.value = activeTabId.value ? [activeTabId.value] : [];
}
async function recoverDetachedTabsToMain(options: { validConnectionIds?: Iterable } = {}) {
@@ -1726,7 +2174,7 @@ export const useQueryStore = defineStore("query", () => {
// Restore is explicitly disabled, so stale saved payloads should not
// reappear if the user later changes the setting.
clearLegacySavedTabs();
- await saveTabs(tabs.value, activeTabId.value).catch(() => undefined);
+ await saveTabs(tabs.value, activeTabId.value, { groups: groups.value, focusedGroupId: focusedGroupId.value, orientation: orientation.value, sizes: sizes.value }).catch(() => undefined);
}
await recoverDetachedTabsToMain(options);
await saveTabs(tabs.value, activeTabId.value).catch(() => undefined);
@@ -1749,7 +2197,7 @@ export const useQueryStore = defineStore("query", () => {
}
await recoverDetachedTabsToMain(options);
try {
- await saveTabs(tabs.value, activeTabId.value);
+ await saveTabs(tabs.value, activeTabId.value, { groups: groups.value, focusedGroupId: focusedGroupId.value, orientation: orientation.value, sizes: sizes.value });
// Keep old desktop installs readable until the async store has the
// migrated state; only then remove the synchronous startup payload.
clearLegacySavedTabs();
@@ -1817,19 +2265,27 @@ export const useQueryStore = defineStore("query", () => {
const storePersistGeneration = ++persistGeneration;
watch(
- [_persistSnapshot, activeTabId],
+ [_persistSnapshot, activeTabId, groups, focusedGroupId, orientation, sizes],
() => {
if (storePersistGeneration !== persistGeneration) return;
if (persistTimer) clearTimeout(persistTimer);
persistTimer = setTimeout(() => {
- if (isDetachedWindow()) void flushDetachedTabPersistence(activeTabId.value ?? undefined).catch(() => {});
- else void saveTabs(tabs.value, activeTabId.value).catch(() => {});
+ if (isDetachedWindow()) {
+ void flushDetachedTabPersistence(activeTabId.value ?? undefined).catch(() => {});
+ } else {
+ void saveTabs(tabs.value, activeTabId.value, { groups: groups.value, focusedGroupId: focusedGroupId.value, orientation: orientation.value, sizes: sizes.value }).catch(() => {});
+ }
persistTimer = null;
}, 300);
},
- { flush: "post" },
+ { flush: "post", deep: true },
);
+ // Group invariants are maintained by the atomic store operations themselves
+ // (registerOpenTab / activateTab / split / move / close); there is
+ // deliberately no post-flush watcher repairing them here. Normalization is
+ // reserved for the persistence restore boundary.
+
onScopeDispose(() => {
if (persistTimer) clearTimeout(persistTimer);
persistTimer = null;
@@ -1847,7 +2303,7 @@ export const useQueryStore = defineStore("query", () => {
clearTimeout(persistTimer);
persistTimer = null;
}
- return saveTabs(tabs.value, activeTabId.value);
+ return saveTabs(tabs.value, activeTabId.value, { groups: groups.value, focusedGroupId: focusedGroupId.value, orientation: orientation.value, sizes: sizes.value });
}
async function prepareDetachedTab(tabId: string, runtime: DetachedTabRuntimeState = {}): Promise {
@@ -1894,6 +2350,27 @@ export const useQueryStore = defineStore("query", () => {
await api.saveDetachedTabHandoff(tabId, handoff);
}
+ /**
+ * Lands a workspace-adopted tab in the pane layout. The split workspace
+ * renders from group membership, so a tab arriving through a detached
+ * handoff must belong to a group to be visible at all. A group that still
+ * carries the id (a return to a pane that never cleaned up) just gets its
+ * active tab repaired; everything else adopts into the first group. Used by
+ * detached-window open, return-to-main, and startup recovery alike.
+ */
+ function ensureTabInWorkspace(tabId: string) {
+ const owner = groups.value.find((group) => group.tabIds.includes(tabId));
+ if (owner) {
+ owner.activeTabId = tabId;
+ focusedGroupId.value = owner.id;
+ return;
+ }
+ const main = groups.value[0];
+ main.tabIds.push(tabId);
+ main.activeTabId = tabId;
+ focusedGroupId.value = main.id;
+ }
+
async function adoptDetachedTab(handoff: DetachedTabHandoff): Promise {
if (!handoff || handoff.schemaVersion !== 1 || !handoff.tab || typeof handoff.tab.id !== "string" || handoff.tab.id !== handoff.tabId) throw new Error("Unsupported detached tab state");
const restored = restoreOpenTabsPayload({ tabs: [handoff.tab], activeTabId: handoff.tabId });
@@ -1911,6 +2388,7 @@ export const useQueryStore = defineStore("query", () => {
} else {
tabs.value.push(restoredTab);
}
+ ensureTabInWorkspace(handoff.tabId);
activeTabId.value = handoff.tabId;
return handoff.tabId;
}
@@ -1918,8 +2396,13 @@ export const useQueryStore = defineStore("query", () => {
function removeTabAfterDetachedReady(tabId: string): boolean {
const index = tabs.value.findIndex((tab) => tab.id === tabId);
if (index < 0) return false;
+ // Group-aware removal: the split workspace renders from group membership,
+ // so the tab must leave its pane too. The emptied pane is pruned exactly
+ // like a bulk close, and a dangling pane (group entry without a tab) is
+ // never left behind for the return trip to trip over.
tabs.value.splice(index, 1);
- if (activeTabId.value === tabId) activeTabId.value = tabs.value[Math.min(index, tabs.value.length - 1)]?.id ?? null;
+ removeTabsFromGroups(new Set([tabId]));
+ if (activeTabId.value === tabId) syncActiveTabFromFocusedGroup();
return true;
}
@@ -1958,11 +2441,10 @@ export const useQueryStore = defineStore("query", () => {
...(mode === "query" ? { autoCommit: defaultAutoCommitForDbTypeWithSetting(dbType) } : {}),
};
if (mode === "query") tab.originalSql = initialSql ?? "";
- const activeIndex = options.insertAfterActive ? tabs.value.findIndex((item) => item.id === activeTabId.value) : -1;
- if (activeIndex >= 0) tabs.value.splice(activeIndex + 1, 0, tab);
- else tabs.value.push(tab);
- if (options.activate !== false) activeTabId.value = id;
- return id;
+ return registerOpenTab(tab, {
+ ...(options.activate === false ? { activate: false } : {}),
+ ...(options.insertAfterActive && activeTabId.value ? { insertAfterTabId: activeTabId.value } : {}),
+ });
}
function openObjectSourceTab(options: OpenObjectSourceTabOptions) {
@@ -2056,8 +2538,7 @@ export const useQueryStore = defineStore("query", () => {
mode: "query",
autoCommit: defaultAutoCommitForDbTypeWithSetting(dbType),
};
- tabs.value.push(tab);
- activeTabId.value = id;
+ registerOpenTab(tab);
refreshExternalSqlFileTitles();
return id;
}
@@ -2114,9 +2595,7 @@ export const useQueryStore = defineStore("query", () => {
eventCreateRequestId,
},
};
- tabs.value.push(tab);
- activeTabId.value = id;
- return id;
+ return registerOpenTab(tab);
}
function openDatabaseBrowser(connectionId: string) {
@@ -2127,7 +2606,7 @@ export const useQueryStore = defineStore("query", () => {
}
const id = uuid();
- tabs.value.push({
+ return registerOpenTab({
id,
title: "Databases",
connectionId,
@@ -2138,8 +2617,6 @@ export const useQueryStore = defineStore("query", () => {
isExplaining: false,
mode: "databases",
});
- activeTabId.value = id;
- return id;
}
function openDriverProfileWorkspace(connectionId: string, database: string, title: string, mode: QueryTab["mode"], tabScope: DriverProfileWorkspaceScope = "database", workspaceBranch?: string) {
@@ -2152,7 +2629,7 @@ export const useQueryStore = defineStore("query", () => {
}
const id = uuid();
- tabs.value.push({
+ return registerOpenTab({
id,
title,
connectionId,
@@ -2164,11 +2641,14 @@ export const useQueryStore = defineStore("query", () => {
isExplaining: false,
mode,
});
- activeTabId.value = id;
- return id;
}
function switchTab(tabId: string) {
+ const owner = groupForTab(tabId);
+ if (owner) {
+ activateTabInGroup(owner.id, tabId);
+ return;
+ }
activeTabId.value = tabId;
settingsStore.settingsPageActive = false;
if (typeof window !== "undefined") window.dispatchEvent(new Event(QUERY_SURFACE_ACTIVATION_EVENT));
@@ -2194,9 +2674,7 @@ export const useQueryStore = defineStore("query", () => {
isExplaining: false,
mode: "users",
};
- tabs.value.push(tab);
- activeTabId.value = id;
- return id;
+ return registerOpenTab(tab);
}
function openProcessList(connectionId: string) {
@@ -2219,9 +2697,7 @@ export const useQueryStore = defineStore("query", () => {
isExplaining: false,
mode: "processlist",
};
- tabs.value.push(tab);
- activeTabId.value = id;
- return id;
+ return registerOpenTab(tab);
}
function openSqlServerActivityTrace(connectionId: string) {
@@ -2244,9 +2720,7 @@ export const useQueryStore = defineStore("query", () => {
isExplaining: false,
mode: "sqlserver-trace",
};
- tabs.value.push(tab);
- activeTabId.value = id;
- return id;
+ return registerOpenTab(tab);
}
function openMysqlDashboard(connectionId: string) {
@@ -2269,9 +2743,7 @@ export const useQueryStore = defineStore("query", () => {
isExplaining: false,
mode: "mysql-dashboard",
};
- tabs.value.push(tab);
- activeTabId.value = id;
- return id;
+ return registerOpenTab(tab);
}
function openPostgresDashboard(connectionId: string) {
@@ -2294,9 +2766,7 @@ export const useQueryStore = defineStore("query", () => {
isExplaining: false,
mode: "postgres-dashboard",
};
- tabs.value.push(tab);
- activeTabId.value = id;
- return id;
+ return registerOpenTab(tab);
}
function openNacosDashboard(connectionId: string) {
@@ -2319,9 +2789,7 @@ export const useQueryStore = defineStore("query", () => {
isExplaining: false,
mode: "nacos-dashboard",
};
- tabs.value.push(tab);
- activeTabId.value = id;
- return id;
+ return registerOpenTab(tab);
}
function openDamengJobAdmin(connectionId: string) {
@@ -2344,9 +2812,7 @@ export const useQueryStore = defineStore("query", () => {
isExplaining: false,
mode: "dameng-jobs",
};
- tabs.value.push(tab);
- activeTabId.value = id;
- return id;
+ return registerOpenTab(tab);
}
function openDamengUsers(connectionId: string) {
@@ -2369,9 +2835,7 @@ export const useQueryStore = defineStore("query", () => {
isExplaining: false,
mode: "dameng-users",
};
- tabs.value.push(tab);
- activeTabId.value = id;
- return id;
+ return registerOpenTab(tab);
}
function openDamengRoles(connectionId: string) {
@@ -2394,9 +2858,7 @@ export const useQueryStore = defineStore("query", () => {
isExplaining: false,
mode: "dameng-roles",
};
- tabs.value.push(tab);
- activeTabId.value = id;
- return id;
+ return registerOpenTab(tab);
}
function openMongoBucket(connectionId: string, database: string, bucketName: string) {
@@ -2422,9 +2884,7 @@ export const useQueryStore = defineStore("query", () => {
bucketName,
},
};
- tabs.value.push(tab);
- activeTabId.value = id;
- return id;
+ return registerOpenTab(tab);
}
function openMongoGridFs(connectionId: string, database: string) {
@@ -2446,9 +2906,7 @@ export const useQueryStore = defineStore("query", () => {
isExplaining: false,
mode: "mongo-gridfs",
};
- tabs.value.push(tab);
- activeTabId.value = id;
- return id;
+ return registerOpenTab(tab);
}
function openMqAdmin(connectionId: string, target?: { tenant?: string; initialTab?: QueryTab["mqInitialTab"] }) {
@@ -2475,9 +2933,7 @@ export const useQueryStore = defineStore("query", () => {
mqTenant: target?.tenant,
mqInitialTab: target?.initialTab,
};
- tabs.value.push(tab);
- activeTabId.value = id;
- return id;
+ return registerOpenTab(tab);
}
function openNacosAdmin(connectionId: string, target?: { namespace?: string; namespaceName?: string; dataId?: string; group?: string; keyword?: string }) {
@@ -2516,9 +2972,7 @@ export const useQueryStore = defineStore("query", () => {
nacosTargetKeyword: target?.keyword,
nacosTargetRequestId: target?.dataId ? 1 : undefined,
};
- tabs.value.push(tab);
- activeTabId.value = id;
- return id;
+ return registerOpenTab(tab);
}
function openMqttAdmin(connectionId: string, target?: { initialTopic?: string }) {
@@ -2543,9 +2997,7 @@ export const useQueryStore = defineStore("query", () => {
mode: "mqtt",
mqttInitialTopic: target?.initialTopic,
};
- tabs.value.push(tab);
- activeTabId.value = id;
- return id;
+ return registerOpenTab(tab);
}
function clearNacosNavigationTarget(connectionId: string, namespace: string, requestId?: number) {
@@ -2593,9 +3045,7 @@ export const useQueryStore = defineStore("query", () => {
structureInitialTabRequestId: initialTab || initialTarget?.name ? 1 : undefined,
structureInitialTarget: initialTarget?.name ? initialTarget : undefined,
};
- tabs.value.push(tab);
- activeTabId.value = id;
- return id;
+ return registerOpenTab(tab);
}
function isTabDirty(tab: QueryTab): boolean {
@@ -2635,7 +3085,11 @@ export const useQueryStore = defineStore("query", () => {
function showDirtyTabCloseConfirm(tab: QueryTab, context: CloseConfirmContext) {
pendingCloseTabId.value = tab.id;
closeConfirmContext.value = context;
- activeTabId.value = tab.id;
+ // Move focus through the atomic activate so the confirming dialog, the
+ // shared result area, and the dirty tab's owner group stay coherent.
+ if (!activateTab(tab.id)) {
+ activeTabId.value = tab.id;
+ }
showCloseConfirm.value = true;
}
@@ -2764,11 +3218,27 @@ export const useQueryStore = defineStore("query", () => {
pendingBatchCloseFinalActiveTabId.value = undefined;
pendingBatchCloseComplete = null;
if (finalActiveTabId !== undefined) {
- activeTabId.value = finalActiveTabId && tabs.value.some((tab) => tab.id === finalActiveTabId) ? finalActiveTabId : null;
+ commitBatchCloseActiveTab(finalActiveTabId);
}
return onComplete;
}
+ /**
+ * Applies a batch close's chosen active tab while keeping the group
+ * invariants: the surviving tab becomes its owner group's active tab and the
+ * focused group. Falls back to the focused group when the choice no longer
+ * exists.
+ */
+ function commitBatchCloseActiveTab(finalActiveTabId: string | null) {
+ if (finalActiveTabId && tabs.value.some((tab) => tab.id === finalActiveTabId)) {
+ if (!activateTab(finalActiveTabId)) {
+ syncActiveTabFromFocusedGroup();
+ }
+ return;
+ }
+ syncActiveTabFromFocusedGroup();
+ }
+
function continuePendingBatchClose() {
const pendingIds = pendingBatchCloseTabIds.value;
if (!pendingIds) return;
@@ -2856,10 +3326,33 @@ export const useQueryStore = defineStore("query", () => {
releaseTabResultObjectPayloads(tabs.value[idx]);
clearResultRuns(tabs.value[idx]);
clearResultPayload(tabs.value[idx]);
+ const owner = groupForTab(id);
+ const ownerIndexInGroup = owner ? owner.tabIds.indexOf(id) : -1;
+ const wasOwnerActive = owner?.activeTabId === id;
+ const wasGlobalActive = activeTabId.value === id;
+
tabs.value.splice(idx, 1);
if (tab.externalSqlPath) refreshExternalSqlFileTitles();
- if (activeTabId.value === id) {
- activeTabId.value = fallbackActiveTabAfterClose(id, idx);
+
+ if (owner) {
+ owner.tabIds = owner.tabIds.filter((tabId) => tabId !== id);
+ if (wasOwnerActive) {
+ const groupHistory = activeTabHistory.value.filter((tabId) => tabId !== id && owner.tabIds.includes(tabId));
+ owner.activeTabId = groupHistory[groupHistory.length - 1] ?? owner.tabIds[Math.min(ownerIndexInGroup, owner.tabIds.length - 1)] ?? null;
+ }
+ if (owner.tabIds.length === 0 && groups.value.length > 1) {
+ const removedIndex = groups.value.findIndex((group) => group.id === owner.id);
+ groups.value.splice(removedIndex, 1);
+ if (focusedGroupId.value === owner.id) {
+ focusedGroupId.value = groups.value[0]?.id ?? "main";
+ }
+ repairGroupSizes();
+ }
+ }
+
+ if (wasGlobalActive) {
+ const nextGroup = focusedGroup();
+ activeTabId.value = nextGroup?.activeTabId ?? null;
}
if (force) resumePendingBatchCloseAfter(id);
}
@@ -2905,7 +3398,7 @@ export const useQueryStore = defineStore("query", () => {
const idsToClose = batchIds ?? (pendingId ? [pendingId] : []);
for (const id of idsToClose) closeTab(id, { force: true });
if (finalActiveTabId !== undefined) {
- activeTabId.value = finalActiveTabId && tabs.value.some((tab) => tab.id === finalActiveTabId) ? finalActiveTabId : null;
+ commitBatchCloseActiveTab(finalActiveTabId);
}
if (batchIds) onBatchComplete?.();
}
@@ -2939,7 +3432,7 @@ export const useQueryStore = defineStore("query", () => {
const dirtyTab = dirtyId ? tabs.value.find((tab) => tab.id === dirtyId) : undefined;
if (!dirtyTab) return false;
pendingCloseTabId.value = dirtyTab.id;
- activeTabId.value = dirtyTab.id;
+ activateTab(dirtyTab.id);
showCloseConfirm.value = true;
return true;
}
@@ -2964,7 +3457,7 @@ export const useQueryStore = defineStore("query", () => {
const idsToClose = batchIds ?? (pendingId ? [pendingId] : []);
for (const id of idsToClose) closeTab(id, { force: true });
if (finalActiveTabId !== undefined) {
- activeTabId.value = finalActiveTabId && tabs.value.some((tab) => tab.id === finalActiveTabId) ? finalActiveTabId : null;
+ commitBatchCloseActiveTab(finalActiveTabId);
}
if (batchIds) onBatchComplete?.();
return "tabs" as const;
@@ -2978,6 +3471,30 @@ export const useQueryStore = defineStore("query", () => {
);
}
+ function closeOtherTabsInGroup(groupId: string, id: string) {
+ const group = findGroup(groupId);
+ const target = tabs.value.find((tab) => tab.id === id);
+ if (!group || !target) return;
+ const ids = group.tabIds.filter((tabId) => {
+ const tab = tabs.value.find((item) => item.id === tabId);
+ return tab && tabId !== id && Boolean(tab.pinned) === Boolean(target.pinned);
+ });
+ beginBatchClose(ids, id);
+ }
+
+ function closeAllTabsInGroup(groupId: string, id: string) {
+ const group = findGroup(groupId);
+ const target = tabs.value.find((tab) => tab.id === id);
+ if (!group || !target) return;
+ const ids = group.tabIds.filter((tabId) => {
+ const tab = tabs.value.find((item) => item.id === tabId);
+ return tab && Boolean(tab.pinned) === Boolean(target.pinned);
+ });
+ if (ids.length === 0) return;
+ const finalActiveTabId = group.activeTabId && !ids.includes(group.activeTabId) ? group.activeTabId : id;
+ beginBatchClose(ids, finalActiveTabId);
+ }
+
function closeTabsByIds(ids: Iterable, finalActiveTabId?: string | null, onComplete?: () => void) {
beginBatchClose([...ids], finalActiveTabId, onComplete);
}
@@ -3136,6 +3653,14 @@ export const useQueryStore = defineStore("query", () => {
previewSql: original.previewSql,
};
tabs.value.splice(idx + 1, 0, newTab);
+
+ const owner = groupForTab(id);
+ if (owner) {
+ const ownerIndex = owner.tabIds.indexOf(id);
+ owner.tabIds.splice(ownerIndex + 1, 0, newId);
+ owner.activeTabId = newId;
+ focusedGroupId.value = owner.id;
+ }
activeTabId.value = newId;
}
@@ -3163,11 +3688,40 @@ export const useQueryStore = defineStore("query", () => {
const activeClosingIndex = tabs.value.findIndex((tab) => tab.id === activeTabId.value && closingIds.has(tab.id));
tabs.value = tabs.value.filter((tab) => !closingIds.has(tab.id));
- if (activeClosingIndex >= 0) {
- activeTabId.value = tabs.value[Math.min(activeClosingIndex, tabs.value.length - 1)]?.id ?? null;
+ // Resolve the fallback against the POST-filter array: the survivor at the
+ // closing tab's index, not the closing tab itself.
+ const fallbackTabId = activeClosingIndex >= 0 ? tabs.value[Math.min(activeClosingIndex, tabs.value.length - 1)]?.id : undefined;
+ // Drop the closed ids from their owning groups, repair each group's
+ // active tab, and remove groups emptied by this close.
+ removeTabsFromGroups(closingIds);
+ if (fallbackTabId && tabs.value.some((tab) => tab.id === fallbackTabId)) {
+ activateTab(fallbackTabId);
+ } else {
+ syncActiveTabFromFocusedGroup();
}
}
+ /**
+ * Removes a set of tab ids from whichever groups own them, repairs each
+ * group's active tab (MRU first, then adjacent), and prunes groups emptied
+ * by the removal. Used by bulk close paths so group state stays consistent
+ * without a normalization pass.
+ */
+ function removeTabsFromGroups(closingIds: Set) {
+ if (closingIds.size === 0) return;
+ for (const group of groups.value) {
+ if (!group.tabIds.some((tabId) => closingIds.has(tabId))) continue;
+ const ownerIndexInGroup = group.activeTabId ? group.tabIds.indexOf(group.activeTabId) : -1;
+ const wasOwnerActive = group.activeTabId != null && closingIds.has(group.activeTabId);
+ group.tabIds = group.tabIds.filter((tabId) => !closingIds.has(tabId));
+ if (wasOwnerActive) {
+ const groupHistory = activeTabHistory.value.filter((tabId) => !closingIds.has(tabId) && group.tabIds.includes(tabId));
+ group.activeTabId = groupHistory[groupHistory.length - 1] ?? group.tabIds[Math.min(Math.max(ownerIndexInGroup, 0), group.tabIds.length - 1)] ?? null;
+ }
+ }
+ pruneEmptyGroups();
+ }
+
function closeScopedTabsWhere(predicate: (tab: QueryTab) => boolean, options: { force?: boolean } = {}) {
const ids = tabs.value.filter((tab) => predicate(tab)).map((tab) => tab.id);
if (options.force) {
@@ -3633,9 +4187,7 @@ export const useQueryStore = defineStore("query", () => {
editorSelection: restoredPosition.selection,
editorViewport: restoredPosition.viewport,
};
- tabs.value.push(tab);
- activeTabId.value = id;
- return id;
+ return registerOpenTab(tab);
}
async function hydrateSavedSqlTabs() {
@@ -3659,6 +4211,13 @@ export const useQueryStore = defineStore("query", () => {
if (!tab) return;
tab.pinned = !tab.pinned;
tabs.value = orderPinnedFirst(tabs.value, (item) => !!item.pinned);
+
+ const owner = groupForTab(id);
+ if (owner) {
+ const pinned = owner.tabIds.filter((tabId) => tabs.value.find((item) => item.id === tabId)?.pinned);
+ const regular = owner.tabIds.filter((tabId) => !tabs.value.find((item) => item.id === tabId)?.pinned);
+ owner.tabIds = [...pinned, ...regular];
+ }
}
function reorderTab(id: string, targetId: string, position: "before" | "after") {
@@ -3673,6 +4232,12 @@ export const useQueryStore = defineStore("query", () => {
const nextTabs = orderPinnedFirst(reordered, (item) => !!item.pinned);
if (nextTabs.every((item, index) => item.id === tabs.value[index]?.id)) return false;
tabs.value = nextTabs;
+
+ const owner = groupForTab(id);
+ if (owner) {
+ const order = new Map(tabs.value.map((tab, index) => [tab.id, index]));
+ owner.tabIds = [...owner.tabIds].sort((a, b) => (order.get(a) ?? 0) - (order.get(b) ?? 0));
+ }
return true;
}
@@ -3916,8 +4481,10 @@ export const useQueryStore = defineStore("query", () => {
await executeCurrentSql(tab.sql);
}
- async function executeCurrentSql(sql: string, options?: { skipRedisSafetyCheck?: boolean; sourceOffset?: number; openInNewResultTab?: boolean; onExecutionStarted?: () => void }) {
- const executionTabId = activeTabId.value;
+ async function executeCurrentSql(sql: string, options?: { tabId?: string; skipRedisSafetyCheck?: boolean; sourceOffset?: number; openInNewResultTab?: boolean; onExecutionStarted?: () => void }) {
+ // Execution targets the requesting tab; only fall back to the global
+ // active tab when the caller did not (or could not) capture one.
+ const executionTabId = options?.tabId ?? activeTabId.value;
if (!executionTabId) return;
const tab = tabs.value.find((item) => item.id === executionTabId);
const previousGridKey = tab ? resultGridInstanceKey(tab) : undefined;
@@ -6531,15 +7098,6 @@ export const useQueryStore = defineStore("query", () => {
activeTabHistory.value = [...activeTabHistory.value.filter((tabId) => tabId !== id), id];
}
- function fallbackActiveTabAfterClose(closedId: string, closedIndex: number): string | null {
- const remainingIds = new Set(tabs.value.map((tab) => tab.id));
- // Prefer the most recently focused remaining tab. This preserves the
- // source query tab when a transient table-info/data tab is closed.
- const history = activeTabHistory.value.filter((tabId) => tabId !== closedId && remainingIds.has(tabId));
- activeTabHistory.value = history;
- return [...history].reverse().find((tabId) => remainingIds.has(tabId)) ?? tabs.value[Math.min(closedIndex, tabs.value.length - 1)]?.id ?? null;
- }
-
watch(
activeTabId,
(id) => {
@@ -6553,6 +7111,16 @@ export const useQueryStore = defineStore("query", () => {
{ flush: "sync" },
);
+ watch(
+ activeTabId,
+ (id) => {
+ if (tabs.value.length > 0 && (!id || !tabs.value.some((tab) => tab.id === id))) {
+ activeTabId.value = tabs.value[0].id;
+ }
+ },
+ { flush: "sync" },
+ );
+
function restoreCachedResultPayload(tab: QueryTab, snapshot: Awaited>) {
if (!snapshot) return false;
const results = snapshot.results ? markQueryResultsRowsRaw(snapshot.results) : undefined;
@@ -6657,9 +7225,7 @@ export const useQueryStore = defineStore("query", () => {
if (!restoreCachedResultPayload(tab, archive.snapshot)) return undefined;
const activeRun = tab.resultRuns?.find((run) => run.id === tab.activeResultRunId) ?? tab.resultRuns?.[0];
if (activeRun) projectResultRun(tab, activeRun);
- tabs.value.push(tab);
- activeTabId.value = id;
- return id;
+ return registerOpenTab(tab);
}
async function importResultArchive(bytes: Uint8Array | ArrayBuffer): Promise {
@@ -7044,6 +7610,10 @@ export const useQueryStore = defineStore("query", () => {
return {
tabs,
activeTabId,
+ groups,
+ focusedGroupId,
+ orientation,
+ sizes,
isOpenTabsLoaded,
recentTabIds,
initOpenTabs,
@@ -7061,6 +7631,14 @@ export const useQueryStore = defineStore("query", () => {
createTab,
openObjectSourceTab,
showExecutedQueryResults,
+ focusGroup,
+ activateTab,
+ activateTabInGroup,
+ splitTabRight,
+ splitTabDown,
+ setOrientation,
+ moveTabToGroup,
+ unsplitTab,
switchTab,
closeTab,
forceClosePendingTab,
@@ -7083,6 +7661,8 @@ export const useQueryStore = defineStore("query", () => {
discardTabChanges,
requestAppCloseConfirmation,
closeOtherTabs,
+ closeOtherTabsInGroup,
+ closeAllTabsInGroup,
closeTabsByIds,
closeRightTabs,
closeOtherRegularTabs,
diff --git a/packages/app-tests/dataTabHeaderConnection.test.ts b/packages/app-tests/dataTabHeaderConnection.test.ts
index 812ae00271..45f517c3ce 100644
--- a/packages/app-tests/dataTabHeaderConnection.test.ts
+++ b/packages/app-tests/dataTabHeaderConnection.test.ts
@@ -28,5 +28,7 @@ test("data tab header shows the active connection before the table context", ()
assert.match(template, /v-if="activeConnection\?\.name\?\.trim\(\)"/);
assert.match(template, /:title="activeConnection\.name"/);
assert.doesNotMatch(template, /data-data-header-connection class="[^"]*(?:max-w-4[89]|max-w-5[0-9])/);
- assert.match(template, /data-data-header-connection class="[^"]*min-w-0[^"]*truncate/);
+ // min-w floor keeps scrollWidth reporting real overflow for the measured
+ // condensation tiers; chips still flex and only truncate under pressure.
+ assert.match(template, /data-data-header-connection class="[^"]*min-w-12[^"]*truncate/);
});
diff --git a/packages/app-tests/queryMessagesView.test.ts b/packages/app-tests/queryMessagesView.test.ts
index ff96e80736..d8b4c73619 100644
--- a/packages/app-tests/queryMessagesView.test.ts
+++ b/packages/app-tests/queryMessagesView.test.ts
@@ -77,7 +77,7 @@ test("ContentArea wires server messages into the switcher and the messages view"
assert.match(contentArea, / {
diff --git a/packages/app-tests/queryResultToolbar.test.ts b/packages/app-tests/queryResultToolbar.test.ts
index 350f8f70b3..a5533f9263 100644
--- a/packages/app-tests/queryResultToolbar.test.ts
+++ b/packages/app-tests/queryResultToolbar.test.ts
@@ -230,5 +230,5 @@ test("DataGrid marks toolbar refresh separately from current-result reloads", ()
test("Elasticsearch JSON refresh preserves multi-result query groups", () => {
const contentArea = source(contentAreaPath);
- assert.match(contentArea, /if \(activeElasticsearchJsonResponse\.value\) \{[\s\S]*?emit\("reload", activeResultSql\.value, undefined, undefined, undefined, undefined, undefined, "refresh"\);/);
+ assert.match(contentArea, /if \(activeElasticsearchJsonResponse\.value\) \{[\s\S]*?emit\("reload", props\.activeTab\.id, activeResultSql\.value, undefined, undefined, undefined, undefined, undefined, "refresh"\);/);
});
diff --git a/packages/app-tests/useDataGridActions.test.ts b/packages/app-tests/useDataGridActions.test.ts
index b7748356b6..04720ecf25 100644
--- a/packages/app-tests/useDataGridActions.test.ts
+++ b/packages/app-tests/useDataGridActions.test.ts
@@ -96,7 +96,7 @@ test("data reload executes before slow metadata refresh completes", async () =>
assert.ok(tab);
const actions = useDataGridActions(computed(() => tab));
- const reload = actions.onReloadData(undefined, undefined, undefined, undefined, 50, 0);
+ const reload = actions.onReloadData(tabId, undefined, undefined, undefined, undefined, 50, 0);
await waitFor(() => !!executeBody, 5_000);
assert.equal(executeBody.clientSessionId, tabId);
@@ -161,7 +161,7 @@ test("data reload preserves current page offset instead of resetting to page 1",
const actions = useDataGridActions(computed(() => tab));
// Simulate refresh from page 5 with pageSize=100 → offset=400
- await actions.onReloadData(undefined, undefined, undefined, undefined, 100, 400);
+ await actions.onReloadData(tabId, undefined, undefined, undefined, undefined, 100, 400);
assert.equal(buildSqlOptions?.offset, 400, "build-table-select-sql must receive offset=400 to stay on page 5");
assert.equal(buildSqlOptions?.limit, 100, "limit must be 100");
@@ -210,7 +210,7 @@ test("infinite pagination fetches and appends only the next table segment", asyn
tab.resultPageOffset = 0;
const actions = useDataGridActions(computed(() => tab));
- await actions.onPaginate(1, 100);
+ await actions.onPaginate(tabId, 1, 100);
assert.equal(buildSqlOptions?.offset, 1);
assert.equal(buildSqlOptions?.limit, 100);
@@ -264,8 +264,8 @@ test("query toolbar refresh reruns the complete multi-result SQL and keeps the a
});
const actions = useDataGridActions(computed(() => tab));
- await actions.onReloadData("select * from orders", undefined, undefined, undefined, 100, 0, "refresh");
- await actions.onReloadData("select * from orders", undefined, undefined, undefined, 100, 0, "refresh");
+ await actions.onReloadData(tabId, "select * from orders", undefined, undefined, undefined, 100, 0, "refresh");
+ await actions.onReloadData(tabId, "select * from orders", undefined, undefined, undefined, 100, 0, "refresh");
assert.equal(executeTabSql.mock.calls.length, 2);
for (const call of executeTabSql.mock.calls) {
@@ -389,7 +389,7 @@ describe("multi-result preservation after submit", () => {
// Simulate DataGrid submit reload: onReloadData with the current
// result's sourceStatement (single statement, not the full batch SQL)
- await actions.onReloadData("select * from users");
+ await actions.onReloadData(tabId, "select * from users");
// The second result must be preserved
assert.equal(tab.results?.length, 2, "both results must survive after submit");
@@ -493,7 +493,7 @@ describe("multi-result preservation after submit", () => {
const actions = useDataGridActions(computed(() => tab));
// Simulate DataGrid submit reload on the SECOND result
- await actions.onReloadData("select * from orders");
+ await actions.onReloadData(tabId, "select * from orders");
// The first result must be preserved
assert.equal(tab.results?.length, 2, "both results must survive after submit on second result");