feat(tabs): detach SQL and data tabs into separate windows
This commit is contained in:
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 };
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user