\s*\.splitpanes__pane\s*\{\s*transition:\s*none !important;/);
+ expect(workspaceCssSource).toMatch(/\.sql-editor-workspace-split\s*>\s*\.splitpanes__splitter\s*\{\s*transition:\s*none;/);
+ // Group animation still respects reduced motion.
expect(workspaceCssSource).toContain("@media (prefers-reduced-motion: reduce)");
});
});
diff --git a/apps/desktop/src/components/layout/__tests__/appContentSurfaces.contract.spec.ts b/apps/desktop/src/components/layout/__tests__/appContentSurfaces.contract.spec.ts
index a23ad34681..2d42342bc0 100644
--- a/apps/desktop/src/components/layout/__tests__/appContentSurfaces.contract.spec.ts
+++ b/apps/desktop/src/components/layout/__tests__/appContentSurfaces.contract.spec.ts
@@ -11,8 +11,7 @@ const appSource = readFileSync(new URL("../../../App.vue", import.meta.url), "ut
describe("App main content surface structure", () => {
it("hides only the editor workspace via the surface guard, never the special pages", () => {
const guard = 'v-show="!driverStoreActive && !settingsStore.settingsPageActive"';
- // One guarded wrapper; the welcome screen's v-else-if reuses the flags in
- // its own condition and must not be mistaken for a second guard.
+ // The empty-state slot shares the query surface's visibility guard.
expect(appSource.split(guard).length - 1).toBe(1);
// Source order: special surfaces first, then the guarded editor wrapper.
@@ -22,9 +21,11 @@ describe("App main content surface structure", () => {
expect([...indices].sort((a, b) => a - b)).toEqual(indices);
});
- it("guards the editor wrapper with the open-tab count and chains the welcome screen to it", () => {
- expect(appSource).toContain(' {
+ expect(appSource).toContain(':show-tab-navigation="queryStore.tabs.length > 0 || settingsPageTabOpen || driverStoreTabOpen"');
+ expect(appSource).toContain(':active-tab="activeTab ?? undefined"');
+ expect(appSource).toMatch(/\s* 0 || settingsPageTabOpen || driverStoreTabOpen"');
});
it("anchors the drag-back hit test on every pane strip and the special-surfaces bar", () => {
@@ -32,8 +33,8 @@ describe("App main content surface structure", () => {
const slimBarSource = readFileSync(new URL("../AppTabBar.vue", import.meta.url), "utf8");
expect(groupBarSource).toContain("data-main-tab-bar");
expect(groupBarSource).toContain("'ring-2 ring-primary ring-inset': detachedDropTarget");
- expect(slimBarSource).toContain("data-main-tab-bar");
- expect(slimBarSource).toContain("'ring-2 ring-primary ring-inset': detachedDropTarget");
+ expect(slimBarSource).toContain("data-special-page-tab-target");
+ expect(slimBarSource).not.toContain("data-return-tab");
// The split workspace renders several bars; the hit test must union their rects.
expect(appSource).toContain('querySelectorAll("[data-main-tab-bar]")');
});
diff --git a/apps/desktop/src/components/layout/__tests__/tabWorkspaceLayout.column-mode-special-pages.spec.ts b/apps/desktop/src/components/layout/__tests__/tabWorkspaceLayout.column-mode-special-pages.spec.ts
index f335e16d4b..bfe3773ecb 100644
--- a/apps/desktop/src/components/layout/__tests__/tabWorkspaceLayout.column-mode-special-pages.spec.ts
+++ b/apps/desktop/src/components/layout/__tests__/tabWorkspaceLayout.column-mode-special-pages.spec.ts
@@ -4,38 +4,19 @@ import { describe, expect, it } from "vitest";
const appSource = readFileSync(new URL("../../../App.vue", import.meta.url), "utf8");
const tabBarSource = readFileSync(new URL("../AppTabBar.vue", import.meta.url), "utf8");
-function sourceBetween(source: string, start: string, end: string): string {
- const startIndex = source.indexOf(start);
- const endIndex = source.indexOf(end, startIndex + start.length);
- expect(startIndex).toBeGreaterThanOrEqual(0);
- expect(endIndex).toBeGreaterThan(startIndex);
- return source.slice(startIndex, endIndex);
-}
-
-// Regression test for #8213: AppTabBar's settings/driver special-page strip is
-// still a horizontal-only "w-full" bar. Placing it inside a flex-row /
-// flex-row-reverse workspace (left/right column tab placement) squeezes the
-// sibling content pane (EditorSettingsPage/DriverStorePage) to zero width,
-// making the settings/driver page invisible after it's reopened. The fix
-// falls back to a flex-col workspace while either special page is active.
-describe("tabWorkspaceLayoutClass column-mode special-page fallback", () => {
- const computedBlock = sourceBetween(appSource, "const tabWorkspaceLayoutClass = computed(() => {", "});");
-
- it("falls back to flex-col while the settings/driver strip is active, before the vertical branch", () => {
- const specialPageGuardIndex = computedBlock.indexOf("isSpecialPageStripActive.value");
- const verticalRowIndex = computedBlock.indexOf('isVerticalTabPlacement.value ? "flex-row" : "flex-col"');
- expect(specialPageGuardIndex).toBeGreaterThanOrEqual(0);
- expect(verticalRowIndex).toBeGreaterThan(specialPageGuardIndex);
- expect(computedBlock).toMatch(/isVerticalTabPlacement\.value\s*&&\s*isSpecialPageStripActive\.value\)\s*return\s*"flex-col"/);
+// Special pages use the global tab-bar portal so their navigation remains
+// separate from the editor groups in every tab-placement mode.
+describe("special-page workspace portal layout", () => {
+ it("provides the portal from App and renders the special-page workspace", () => {
+ expect(appSource).toContain("provide(GROUP_TAB_BAR_PORTAL");
+ expect(tabBarSource).toContain('v-show="driverStoreActive || settingsPageActive"');
+ expect(tabBarSource).toContain("data-special-page-workspace");
});
- it("derives isSpecialPageStripActive from the same flags AppTabBar uses to render", () => {
- expect(appSource).toContain("const isSpecialPageStripActive = computed(() => driverStoreActive.value || settingsStore.settingsPageActive);");
- // AppTabBar itself only renders while one of these is true - keep the guard in sync with it.
- expect(tabBarSource).toContain('v-if="driverStoreActive || settingsPageActive"');
- });
-
- it("still stretches the special-page strip full width (the part the flex-col fallback protects against)", () => {
- expect(tabBarSource).toMatch(/class="app-tab-bar[^"]*\bw-full\b/);
+ it("keeps the special-page navigation and content panes independently sized", () => {
+ expect(tabBarSource).toContain("data-special-page-navigation");
+ expect(tabBarSource).toContain("data-special-page-content");
+ expect(tabBarSource).toContain(':class="layoutClass"');
+ expect(tabBarSource).toContain("flex-1 flex-col overflow-hidden");
});
});
diff --git a/apps/desktop/src/components/layout/appTabBar.css b/apps/desktop/src/components/layout/appTabBar.css
index f92b4497a7..c352113d10 100644
--- a/apps/desktop/src/components/layout/appTabBar.css
+++ b/apps/desktop/src/components/layout/appTabBar.css
@@ -360,11 +360,17 @@
}
.vertical-tab-layout .app-tab-strip {
+ display: flex;
+ flex-direction: column;
height: auto;
min-height: 0;
width: 100%;
}
+.vertical-tab-layout .app-tab-scroll {
+ min-height: 0;
+}
+
.vertical-tab-layout .app-tab-pill {
width: 100%;
min-width: 0 !important;
diff --git a/apps/desktop/src/components/layout/groupTabBarPortal.ts b/apps/desktop/src/components/layout/groupTabBarPortal.ts
new file mode 100644
index 0000000000..fe9e7c8aeb
--- /dev/null
+++ b/apps/desktop/src/components/layout/groupTabBarPortal.ts
@@ -0,0 +1,13 @@
+import { shallowReactive, type InjectionKey, type Ref } from "vue";
+
+export interface GroupTabBarPortal {
+ active: Readonly[>;
+ targets: Map;
+}
+
+export const GROUP_TAB_BAR_PORTAL: InjectionKey = Symbol("dbx:group-tab-bar-portal");
+
+// Moving the existing bars preserves their grouping, search and scroll state.
+export function createGroupTabBarPortal(active: Readonly][>): GroupTabBarPortal {
+ return { active, targets: shallowReactive(new Map()) };
+}
diff --git a/apps/desktop/src/components/layout/sqlEditorWorkspace.css b/apps/desktop/src/components/layout/sqlEditorWorkspace.css
index 3c3a4283b4..a6d2d52e4e 100644
--- a/apps/desktop/src/components/layout/sqlEditorWorkspace.css
+++ b/apps/desktop/src/components/layout/sqlEditorWorkspace.css
@@ -1,30 +1,15 @@
/*
- * Motion for layout transitions inside the SQL editor workspace.
- *
- * Pane SIZE changes (split, result-pane collapse/expand) are smoothed by
- * splitpanes' stock pane transition, which globals.css kills for horizontal
- * splitpanes — re-enabled here for this workspace only (drag still disables
- * it via the higher-specificity rule below). The entrance effects cover the
- * content that would otherwise pop in: groups created by a split, and the
- * shared result surface re-mounting on a query tab.
- *
- * Product motion rules: transform/opacity only, ease-out-quart, 150-250ms,
- * no load choreography (groups present at first render never animate), and
- * everything degrades to instant under prefers-reduced-motion.
+ * New editor groups animate when split. Query results and their pane sizes
+ * update immediately, including when restoring a collapsed result pane.
*/
.sql-editor-workspace {
--sql-workspace-ease-out: cubic-bezier(0.25, 1, 0.5, 1);
}
-/* Pane size transitions: only the outer editor/result split animates pane
- sizes (the result-pane collapse/expand glide). Group panes never animate
- their sizes — splits use the entrance keyframes below — so they stay on
- stock splitpanes behavior (transition, disabled while dragging). */
+/* Override splitpanes' stock transition so ready results are not clipped
+ while the result pane expands. */
.sql-editor-workspace .sql-editor-workspace-split.splitpanes--ready > .splitpanes__pane {
- transition: width 0.2s ease-out, height 0.2s ease-out !important;
-}
-.sql-editor-workspace .sql-editor-workspace-split.splitpanes--ready.splitpanes--dragging > .splitpanes__pane {
transition: none !important;
}
@@ -47,28 +32,12 @@
}
}
-/* Shared result surface: rises into place while its pane expands, and fades
- out faster than the pane collapses. */
-.sql-editor-workspace .result-surface-enter-active {
- transition: opacity 180ms var(--sql-workspace-ease-out), transform 180ms var(--sql-workspace-ease-out);
-}
-.sql-editor-workspace .result-surface-enter-from {
- opacity: 0;
- transform: translateY(10px);
-}
-.sql-editor-workspace .result-surface-leave-active {
- transition: opacity 120ms var(--sql-workspace-ease-out);
-}
-.sql-editor-workspace .result-surface-leave-to {
- opacity: 0;
-}
-
/* The splitter between the editor and result panes stays in the splitpanes
- flow even when the result pane is collapsed; fade it out and disable it so
+ flow even when the result pane is collapsed; hide it and disable it so
the closed state is visually clean and cannot be dragged open into an
empty pane. */
.sql-editor-workspace-split > .splitpanes__splitter {
- transition: opacity 150ms var(--sql-workspace-ease-out);
+ transition: none;
}
.sql-editor-workspace-split.result-pane-collapsed > .splitpanes__splitter {
opacity: 0;
@@ -79,11 +48,4 @@
.sql-editor-workspace .workspace-pane-enter {
animation: none;
}
- .sql-editor-workspace .result-surface-enter-active,
- .sql-editor-workspace .result-surface-leave-active {
- transition: none;
- }
- .sql-editor-workspace .sql-editor-workspace-split.splitpanes--ready > .splitpanes__pane {
- transition: none !important;
- }
}
diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue
index 8a514a2d22..c7171b9075 100644
--- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue
+++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue
@@ -2458,7 +2458,7 @@ function openObjectSourceDialog(initialEditing: boolean, viewPackageBody = false
},
});
} else {
- queryStore.createTab(connectionId, database, `Source - ${node.label}`, "query", schema, editableSource, node.catalog, { forceNew: true });
+ queryStore.createTab(connectionId, database, `Source - ${node.label}`, "query", schema, editableSource, node.catalog, { forceNew: true, sourceView: true });
}
})
.catch((e: any) => {
diff --git a/apps/desktop/src/lib/__tests__/app/openTabsPersistence.spec.ts b/apps/desktop/src/lib/__tests__/app/openTabsPersistence.spec.ts
index 1b13cb619e..319dfbf84e 100644
--- a/apps/desktop/src/lib/__tests__/app/openTabsPersistence.spec.ts
+++ b/apps/desktop/src/lib/__tests__/app/openTabsPersistence.spec.ts
@@ -21,6 +21,17 @@ function roundTrip(tabs: QueryTab[]) {
}
describe("openTabsPersistence originalSql round-trip", () => {
+ it("preserves read-only source intent without adding editable source metadata", () => {
+ const [restored] = roundTrip([queryTab({ sourceView: true, sql: "CREATE SEQUENCE seq_users" })]);
+ expect(restored.sourceView).toBe(true);
+ expect(restored.objectSource).toBeUndefined();
+ });
+
+ it("keeps legacy query tabs without source intent compatible", () => {
+ const [restored] = roundTrip([queryTab({ sql: "SELECT 1" })]);
+ expect(restored.sourceView).toBeUndefined();
+ });
+
it("restores a clean prefilled query tab as clean (sql === originalSql)", () => {
const sql = 'SELECT * FROM "public"."users"';
const [restored] = roundTrip([queryTab({ sql, originalSql: sql })]);
diff --git a/apps/desktop/src/lib/app/openTabsPersistence.ts b/apps/desktop/src/lib/app/openTabsPersistence.ts
index 9dd76776b2..c03df61548 100644
--- a/apps/desktop/src/lib/app/openTabsPersistence.ts
+++ b/apps/desktop/src/lib/app/openTabsPersistence.ts
@@ -52,6 +52,7 @@ export interface SavedOpenTab {
structureTableName?: string;
objectBrowser?: QueryTab["objectBrowser"];
objectSource?: QueryTab["objectSource"];
+ sourceView?: boolean;
tableMeta?: QueryTab["tableMeta"];
mongoEditTarget?: QueryTab["mongoEditTarget"];
resultEvicted?: boolean;
@@ -146,6 +147,7 @@ export function serializeOpenTabs(tabs: QueryTab[]): SavedOpenTab[] {
...(tab.structureTableName !== undefined ? { structureTableName: tab.structureTableName } : {}),
objectBrowser: tab.objectBrowser,
objectSource: tab.objectSource,
+ ...(tab.sourceView ? { sourceView: true } : {}),
tableMeta: tab.tableMeta,
...(tab.mongoEditTarget !== undefined ? { mongoEditTarget: tab.mongoEditTarget } : {}),
...(tab.mode !== "data" && tab.resultEvicted ? { resultEvicted: true } : {}),
diff --git a/apps/desktop/src/lib/query/__tests__/queryOutput.spec.ts b/apps/desktop/src/lib/query/__tests__/queryOutput.spec.ts
new file mode 100644
index 0000000000..50244f0cac
--- /dev/null
+++ b/apps/desktop/src/lib/query/__tests__/queryOutput.spec.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from "vitest";
+import type { QueryResult, QueryTab } from "@/types/database";
+import { hasQueryOutput } from "../queryOutput";
+
+const emptyResult: QueryResult = { columns: ["id"], rows: [], affected_rows: 0, execution_time_ms: 1 };
+
+describe("hasQueryOutput", () => {
+ it("does not reserve output space for an idle query", () => {
+ expect(hasQueryOutput(undefined)).toBe(false);
+ expect(hasQueryOutput({ isExecuting: false, results: [], resultRuns: [] })).toBe(false);
+ });
+
+ it.each<[string, Partial]>([
+ ["running query", { isExecuting: true }],
+ ["running EXPLAIN", { isExplaining: true }],
+ ["zero-row SELECT", { result: emptyResult }],
+ ["DML summary", { result: { ...emptyResult, columns: [], affected_rows: 3 } }],
+ ["DDL summary", { result: { ...emptyResult, columns: [] } }],
+ ["query error", { result: { ...emptyResult, columns: ["Error"], rows: [["syntax error"]] } }],
+ ["multiple results", { results: [emptyResult] }],
+ ["retained result run", { resultRuns: [{ id: "run-1", title: "Result 1", sequence: 1, sql: "SELECT 1", createdAt: 1 }] }],
+ ["EXPLAIN plan", { explainPlan: { databaseType: "mysql", raw: {}, nodes: [] } }],
+ ["EXPLAIN table", { explainTableResult: emptyResult }],
+ ["EXPLAIN error", { explainError: "syntax error" }],
+ ["EXPLAIN table error", { explainTableError: "unsupported" }],
+ ["cached result", { resultEvicted: true, resultCacheState: "disk" }],
+ ["missing cached result", { resultEvicted: true, resultCacheState: "missing" }],
+ ])("retains output space for %s", (_, state) => {
+ expect(hasQueryOutput({ isExecuting: false, ...state })).toBe(true);
+ });
+});
diff --git a/apps/desktop/src/lib/query/queryOutput.ts b/apps/desktop/src/lib/query/queryOutput.ts
new file mode 100644
index 0000000000..e903747fcf
--- /dev/null
+++ b/apps/desktop/src/lib/query/queryOutput.ts
@@ -0,0 +1,7 @@
+import type { QueryTab } from "@/types/database";
+
+type QueryOutputState = Pick;
+
+export function hasQueryOutput(tab: QueryOutputState | null | undefined): boolean {
+ return !!tab && (!!tab.result || !!tab.results?.length || !!tab.resultRuns?.length || tab.resultEvicted === true || !!tab.explainPlan || !!tab.explainError || !!tab.explainTableResult || !!tab.explainTableError || tab.isExecuting === true || tab.isExplaining === true);
+}
diff --git a/apps/desktop/src/stores/__tests__/queryStore.detached.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.detached.spec.ts
index cb25f1e402..4b89af8616 100644
--- a/apps/desktop/src/stores/__tests__/queryStore.detached.spec.ts
+++ b/apps/desktop/src/stores/__tests__/queryStore.detached.spec.ts
@@ -63,6 +63,19 @@ describe("queryStore detached-tab handoff", () => {
expect(freshStore.focusedGroupId).toBe(freshStore.groups[0]?.id);
});
+ it("keeps read-only source intent across detached-window handoff", async () => {
+ const { useQueryStore } = await import("@/stores/queryStore");
+ const store = useQueryStore();
+ const id = store.createTab("pg-1", "app", "Source - seq_users", "query", "public", "CREATE SEQUENCE seq_users", undefined, { sourceView: true });
+ const handoff = await store.prepareDetachedTab(id);
+ expect(handoff.tab.sourceView).toBe(true);
+ setActivePinia(createPinia());
+ const detachedStore = useQueryStore();
+ await detachedStore.adoptDetachedTab(handoff);
+ expect(detachedStore.tabs[0]?.sourceView).toBe(true);
+ expect(detachedStore.tabs[0]?.objectSource).toBeUndefined();
+ });
+
it("removeTabAfterDetachedReady clears the owning pane and prunes it when emptied", async () => {
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
diff --git a/apps/desktop/src/stores/__tests__/queryStore.sourceView.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.sourceView.spec.ts
new file mode 100644
index 0000000000..e8bda7fe7e
--- /dev/null
+++ b/apps/desktop/src/stores/__tests__/queryStore.sourceView.spec.ts
@@ -0,0 +1,45 @@
+import { createPinia, setActivePinia } from "pinia";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+describe("queryStore source-view intent", () => {
+ beforeEach(() => {
+ vi.stubGlobal("localStorage", { getItem: vi.fn(() => null), setItem: vi.fn(), removeItem: vi.fn() });
+ setActivePinia(createPinia());
+ });
+
+ it("preserves read-only source intent when duplicating a tab", async () => {
+ const { useQueryStore } = await import("@/stores/queryStore");
+ const store = useQueryStore();
+ const id = store.createTab("pg-1", "app", "Source - seq_users", "query", "public", "CREATE SEQUENCE seq_users", undefined, { sourceView: true });
+ store.duplicateTab(id);
+ expect(store.tabs).toHaveLength(2);
+ for (const tab of store.tabs) {
+ expect(tab.sourceView).toBe(true);
+ expect(tab.objectSource).toBeUndefined();
+ }
+ });
+
+ it("marks a reused legacy source tab without relying on its title to detect intent", async () => {
+ const { useQueryStore } = await import("@/stores/queryStore");
+ const store = useQueryStore();
+ const id = store.createTab("pg-1", "app", "Source - seq_users");
+ expect(store.tabs[0]?.sourceView).toBeUndefined();
+ const reused = store.createTab("pg-1", "app", "Source - seq_users", "query", undefined, undefined, undefined, { sourceView: true });
+ expect(reused).toBe(id);
+ expect(store.tabs).toHaveLength(1);
+ expect(store.tabs[0]?.sourceView).toBe(true);
+ expect(store.tabs[0]?.objectSource).toBeUndefined();
+ });
+
+ it("marks new and reused editable object-source tabs", async () => {
+ const { useQueryStore } = await import("@/stores/queryStore");
+ const store = useQueryStore();
+ const options = { connectionId: "pg-1", database: "app", title: "Source - users", sql: "CREATE VIEW users AS SELECT 1", objectSource: { name: "users", objectType: "VIEW" as const } };
+ const id = store.openObjectSourceTab(options);
+ expect(store.tabs[0]?.sourceView).toBe(true);
+ store.tabs[0]!.sourceView = undefined;
+ expect(store.openObjectSourceTab(options)).toBe(id);
+ expect(store.tabs[0]?.sourceView).toBe(true);
+ expect(store.tabs[0]?.objectSource).toEqual(options.objectSource);
+ });
+});
diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts
index ddd473a5d1..6662c5b1a5 100644
--- a/apps/desktop/src/stores/queryStore.ts
+++ b/apps/desktop/src/stores/queryStore.ts
@@ -2244,6 +2244,7 @@ export const useQueryStore = defineStore("query", () => {
structureTableName: t.structureTableName,
objectBrowser: t.objectBrowser,
objectSource: t.objectSource,
+ sourceView: t.sourceView,
tableMeta: t.tableMeta,
mongoEditTarget: t.mongoEditTarget,
resultEvicted: t.resultEvicted,
@@ -2430,10 +2431,20 @@ export const useQueryStore = defineStore("query", () => {
return tabs.value.find((tab) => tab.connectionId === connectionId && tab.database === database && tab.title === title && tab.mode === mode && (tab.schema || "") === (schema || "") && (tab.catalog || "") === (catalog || ""));
}
- function createTab(connectionId: string, database: string, title?: string, mode: QueryTab["mode"] = "query", schema?: string, initialSql?: string, catalog?: string, options: { forceNew?: boolean; activate?: boolean; forceWordWrap?: boolean; insertAfterActive?: boolean } = {}) {
+ function createTab(
+ connectionId: string,
+ database: string,
+ title?: string,
+ mode: QueryTab["mode"] = "query",
+ schema?: string,
+ initialSql?: string,
+ catalog?: string,
+ options: { forceNew?: boolean; activate?: boolean; forceWordWrap?: boolean; insertAfterActive?: boolean; sourceView?: boolean } = {},
+ ) {
if (title && !options.forceNew) {
const existing = findTabByIdentity(connectionId, database, title, mode, schema, catalog);
if (existing) {
+ if (options.sourceView) existing.sourceView = true;
switchTab(existing.id);
return existing.id;
}
@@ -2446,6 +2457,7 @@ export const useQueryStore = defineStore("query", () => {
title: title || `query_${tabs.value.length + 1}`,
customTitle: mode === "query" && title ? true : undefined,
forceWordWrap: options.forceWordWrap,
+ sourceView: options.sourceView,
connectionId,
database,
schema,
@@ -2478,6 +2490,7 @@ export const useQueryStore = defineStore("query", () => {
(tab.objectSource.signature || "") === (options.objectSource.signature || ""),
);
if (existing) {
+ existing.sourceView = true;
switchTab(existing.id);
if (!isTabDirty(existing)) {
updateSql(existing.id, options.sql);
@@ -2486,7 +2499,7 @@ export const useQueryStore = defineStore("query", () => {
return existing.id;
}
- const id = createTab(options.connectionId, options.database, options.title, "query", options.schema, options.sql, options.catalog, { forceNew: true });
+ const id = createTab(options.connectionId, options.database, options.title, "query", options.schema, options.sql, options.catalog, { forceNew: true, sourceView: true });
setObjectSource(id, options.objectSource);
return id;
}
@@ -3682,6 +3695,7 @@ export const useQueryStore = defineStore("query", () => {
structureDraft: original.structureDraft ? cloneTabDraft(original.structureDraft) : undefined,
objectBrowser: original.objectBrowser ? { ...original.objectBrowser } : undefined,
objectSource: original.objectSource ? { ...original.objectSource } : undefined,
+ sourceView: original.sourceView,
tableMeta: original.tableMeta ? { ...original.tableMeta, columns: [...original.tableMeta.columns], primaryKeys: [...original.tableMeta.primaryKeys] } : undefined,
queryAnalysis: original.queryAnalysis ? { ...original.queryAnalysis, sources: original.queryAnalysis.sources?.map((source) => ({ ...source })), columns: original.queryAnalysis.columns.map((c) => ({ ...c })) } : undefined,
querySourceColumns: original.querySourceColumns ? [...original.querySourceColumns] : undefined,
diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts
index 92bd048d56..e2483c042a 100644
--- a/apps/desktop/src/types/database.ts
+++ b/apps/desktop/src/types/database.ts
@@ -1295,6 +1295,8 @@ export interface QueryTab {
searchQuery?: string;
viewport?: ObjectBrowserViewport;
};
+ /** Opened to view object source, including objects without editable source metadata. */
+ sourceView?: boolean;
objectSource?: {
schema?: string;
name: string;
diff --git a/packages/app-tests/sidebarContextMenuHost.test.ts b/packages/app-tests/sidebarContextMenuHost.test.ts
index 2f1cbe02c3..5e5e99ebd9 100644
--- a/packages/app-tests/sidebarContextMenuHost.test.ts
+++ b/packages/app-tests/sidebarContextMenuHost.test.ts
@@ -69,7 +69,7 @@ test("query-tab object source uses canonical identity and honors backend editabi
assert.match(openObjectSourceBody, /!\["SEQUENCE", "TRIGGER", "TYPE", "TYPE_BODY", "JOB"\]\.includes\(resolvedType\)/);
assert.match(openObjectSourceBody, /objectType: resolvedType/);
assert.match(openObjectSourceBody, /signature: node\.signature/);
- assert.match(openObjectSourceBody, /createTab\(connectionId, database, `Source - \$\{node\.label\}`, "query", schema, editableSource, node\.catalog, \{ forceNew: true \}\)/);
+ assert.match(openObjectSourceBody, /createTab\(connectionId, database, `Source - \$\{node\.label\}`, "query", schema, editableSource, node\.catalog, \{ forceNew: true, sourceView: true \}\)/);
assert.doesNotMatch(openObjectSourceBody, /queryStore\.updateSql/);
assert.doesNotMatch(openObjectSourceBody, /queryStore\.markTabClean/);
});
]