feat(tabs): detach SQL and data tabs into separate windows

This commit is contained in:
DengQingNian authored and GitHub committed 2026-09-03 01:45:08 +08:00
1 parent 717dcc1294
commit d38be39e03
17 files changed
+957 -21

No files matched your search

@@ -0,0 +1,24 @@
import type { QueryTab } from "@/types/database";
import type { SavedOpenTab } from "@/lib/app/openTabsPersistence";
export type DetachedOutputView = "result" | "summary" | "explain" | "chart" | "messages";
export interface DetachedTabRuntimeState {
editorViewport?: QueryTab["editorViewport"];
editorSelection?: QueryTab["editorSelection"];
txnSessionId?: string;
txnAutoRolledBack?: boolean;
oracleTxnPossiblyDirty?: boolean;
activeOutputView?: DetachedOutputView;
}
export interface DetachedTabHandoff {
schemaVersion: 1;
tabId: string;
sourceWindowLabel: string;
revision: number;
tab: SavedOpenTab;
runtime: DetachedTabRuntimeState;
resultCacheKey?: string;
updatedAt: number;
}
@@ -0,0 +1,77 @@
import { detachedWindowLabel, detachedWindowUrl } from "@/lib/app/windowContext";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
export interface DetachedWindowOpenPosition {
x: number;
y: number;
}
export interface DetachedWindowOpenResult {
opened: boolean;
error?: string;
}
function errorMessage(error: unknown): string {
if (typeof error === "string") return error;
if (error && typeof error === "object" && "message" in error && typeof error.message === "string") return error.message;
return String(error || "Unknown window creation error");
}
export async function openDetachedTabWindow(tabId: string, title: string, position?: DetachedWindowOpenPosition): Promise<DetachedWindowOpenResult> {
if (!isTauriRuntime()) return { opened: false, error: "Detached windows are only available in the desktop app." };
try {
const { WebviewWindow } = await import("@tauri-apps/api/webviewWindow");
const label = detachedWindowLabel(tabId);
const existing = await WebviewWindow.getByLabel(label);
if (existing) {
await existing.show();
await existing.setFocus();
return { opened: true };
}
const child = new WebviewWindow(label, {
url: detachedWindowUrl(tabId),
title,
width: 1100,
height: 720,
minWidth: 760,
minHeight: 600,
resizable: true,
visible: false,
decorations: false,
center: false,
});
return await new Promise((resolve) => {
let settled = false;
const finish = (result: DetachedWindowOpenResult) => {
if (settled) return;
settled = true;
resolve(result);
};
void child.once("tauri://created", async () => {
try {
if (position) {
const { PhysicalPosition } = await import("@tauri-apps/api/dpi");
await child.setPosition(new PhysicalPosition(Math.round(position.x - 120), Math.round(position.y - 20)));
}
await child.show();
await child.setFocus();
finish({ opened: true });
} catch (error) {
finish({ opened: false, error: errorMessage(error) });
}
});
void child.once("tauri://error", (event) => {
const error = errorMessage(event?.payload);
console.error("[DBX][detached-tab:create:error]", error);
finish({ opened: false, error });
});
setTimeout(() => finish({ opened: false, error: "Timed out while creating the detached window." }), 10_000);
});
} catch (error) {
const message = errorMessage(error);
console.error("[DBX][detached-tab:create:error]", error);
return { opened: false, error: message };
}
}
+46
View File
@@ -0,0 +1,46 @@
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
export type WindowContext = { kind: "main"; windowLabel: "main" } | { kind: "detached-tab"; windowLabel: string; tabId: string };
const DETACHED_WINDOW_PARAM = "dbxDetachedTab";
let cachedContext: WindowContext | undefined;
export function detachedWindowLabel(tabId: string): string {
return `detached-tab-${tabId}`;
}
export function resolveWindowContext(): WindowContext {
if (cachedContext) return cachedContext;
if (!isTauriRuntime()) {
cachedContext = { kind: "main", windowLabel: "main" };
return cachedContext;
}
const tabId = new URLSearchParams(window.location.search).get(DETACHED_WINDOW_PARAM)?.trim();
if (tabId) {
// The window label is not available synchronously from the Tauri API. The
// deterministic label is also what the creator uses, so it is sufficient
// for routing and event targeting during bootstrap.
cachedContext = { kind: "detached-tab", windowLabel: detachedWindowLabel(tabId), tabId };
} else {
cachedContext = { kind: "main", windowLabel: "main" };
}
return cachedContext;
}
export function isDetachedWindow(): boolean {
return resolveWindowContext().kind === "detached-tab";
}
export function detachedTabId(): string | undefined {
const context = resolveWindowContext();
return context.kind === "detached-tab" ? context.tabId : undefined;
}
export function detachedWindowUrl(tabId: string): string {
const url = new URL(window.location.href);
url.search = new URLSearchParams({ [DETACHED_WINDOW_PARAM]: tabId }).toString();
url.hash = "";
return url.toString();
}
+5
View File
@@ -328,6 +328,11 @@ export const loadEditorSettings = forward("loadEditorSettings");
export const saveEditorSettings = forward("saveEditorSettings");
export const loadOpenTabsState = forward("loadOpenTabsState");
export const saveOpenTabsState = forward("saveOpenTabsState");
export const saveDetachedTabHandoff = forward("saveDetachedTabHandoff");
export const loadDetachedTabHandoff = forward("loadDetachedTabHandoff");
export const listDetachedTabHandoffs = forward("listDetachedTabHandoffs");
export const deleteDetachedTabHandoff = forward("deleteDetachedTabHandoff");
export const approveDetachedWindowClose = forward("approveDetachedWindowClose");
export const loadSavedSqlEditorPositions = forward("loadSavedSqlEditorPositions");
export const saveSavedSqlEditorPositions = forward("saveSavedSqlEditorPositions");
export const loadTransferTaskLibrary = forward("loadTransferTaskLibrary");
+15
View File
@@ -48,6 +48,7 @@ import type {
SshConfigHostEntry,
TunnelProfile,
} from "@/types/database";
import type { DetachedTabHandoff } from "@/lib/app/detachedTabHandoff";
import { normalizeRustMongoCommand, type MongoCommand } from "@/lib/mongo/mongoShellCommand";
import { BackendErrorException, type BackendError } from "@/lib/backend/errorUtils";
import { decodeMeilisearchDocumentPage, decodeMeilisearchSearchResult, type MeilisearchDocumentPage, type MeilisearchDocumentPageWire, type MeilisearchSearchResult, type MeilisearchSearchWireResult } from "@/lib/backend/meilisearchTransport";
@@ -1897,6 +1898,20 @@ export async function saveOpenTabsState(payload: OpenTabsStatePayload): Promise<
await saveBrowserAppState("open_tabs", payload);
}
export async function saveDetachedTabHandoff(_tabId: string, _handoff: DetachedTabHandoff): Promise<void> {}
export async function loadDetachedTabHandoff(_tabId: string): Promise<DetachedTabHandoff | null> {
return null;
}
export async function listDetachedTabHandoffs(): Promise<DetachedTabHandoff[]> {
return [];
}
export async function deleteDetachedTabHandoff(_tabId: string): Promise<void> {}
export async function approveDetachedWindowClose(): Promise<void> {}
export async function loadSavedSqlEditorPositions(): Promise<unknown[] | null> {
const value = await loadBrowserAppState("saved_sql_editor_positions");
return Array.isArray(value) ? value : null;
+21
View File
@@ -1,4 +1,5 @@
import { invoke } from "@tauri-apps/api/core";
import type { DetachedTabHandoff } from "@/lib/app/detachedTabHandoff";
import { BackendErrorException, type BackendError } from "@/lib/backend/errorUtils";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { normalizeRustMongoCommand, type MongoCommand } from "@/lib/mongo/mongoShellCommand";
@@ -739,6 +740,26 @@ export async function saveOpenTabsState(payload: OpenTabsStatePayload): Promise<
return invoke("save_open_tabs_state", { payload });
}
export async function saveDetachedTabHandoff(tabId: string, handoff: DetachedTabHandoff): Promise<void> {
return invoke("save_detached_tab_handoff", { tabId, handoff });
}
export async function loadDetachedTabHandoff(tabId: string): Promise<DetachedTabHandoff | null> {
return invoke("load_detached_tab_handoff", { tabId });
}
export async function listDetachedTabHandoffs(): Promise<DetachedTabHandoff[]> {
return invoke("list_detached_tab_handoffs");
}
export async function deleteDetachedTabHandoff(tabId: string): Promise<void> {
return invoke("delete_detached_tab_handoff", { tabId });
}
export async function approveDetachedWindowClose(): Promise<void> {
return invoke("approve_detached_window_close");
}
export async function loadSavedSqlEditorPositions(): Promise<unknown[] | null> {
return invoke("load_saved_sql_editor_positions");
}