From fa9d21be8b47ce14b52ff677ed4b3fd235e29cc5 Mon Sep 17 00:00:00 2001
From: Syngnat
Date: Thu, 16 Jul 2026 20:25:08 +0800
Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(native-window):=20=E6=94=AF?=
=?UTF-8?q?=E6=8C=81=E6=A0=87=E7=AD=BE=E9=A1=B5=E6=8B=96=E5=87=BA=E4=B8=BA?=
=?UTF-8?q?=E5=8E=9F=E7=94=9F=E7=8B=AC=E7=AB=8B=E7=AA=97=E5=8F=A3?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 支持 SQL、数据和结果标签拖出到跨显示器原生窗口
- 通过受认证 loopback bridge 复用主进程后端与状态
- 支持子窗口继续拆窗、聚焦、关闭、还原与异常退出恢复
- 补充多窗口协议、状态同步和跨平台回归测试
---
frontend/src/App.tsx | 2 +
.../components/FloatingQueryResultWindows.tsx | 3 +-
.../components/FloatingWorkbenchWindows.tsx | 3 +-
.../NativeDetachedWindowApp.test.tsx | 156 ++++
.../components/NativeDetachedWindowApp.tsx | 508 ++++++++++++
.../NativeDetachedWindowController.test.ts | 398 +++++++++
.../NativeDetachedWindowController.tsx | 232 ++++++
frontend/src/components/QueryEditor.tsx | 40 +-
.../components/QueryEditorResultsPanel.tsx | 36 +-
frontend/src/components/TabManager.tsx | 54 +-
frontend/src/main.tsx | 6 +-
frontend/src/utils/detachedWindow.test.ts | 33 +
frontend/src/utils/detachedWindow.ts | 36 +
.../utils/nativeDetachedWindowClient.test.ts | 236 ++++++
.../src/utils/nativeDetachedWindowClient.ts | 376 +++++++++
.../utils/nativeDetachedWindowHost.test.ts | 132 +++
.../src/utils/nativeDetachedWindowHost.ts | 238 ++++++
.../utils/queryEditorResultSessionCache.ts | 37 +-
.../nativewindow/activation_policy_darwin.go | 31 +
.../nativewindow/activation_policy_other.go | 5 +
internal/nativewindow/bridge.go | 395 +++++++++
internal/nativewindow/child.go | 193 +++++
internal/nativewindow/manager.go | 775 ++++++++++++++++++
internal/nativewindow/manager_test.go | 486 +++++++++++
internal/nativewindow/process.go | 59 ++
internal/nativewindow/runtime_script.go | 110 +++
internal/nativewindow/runtime_script_test.go | 37 +
internal/nativewindow/types.go | 125 +++
internal/nativewindow/window_bounds_darwin.go | 66 ++
internal/nativewindow/window_bounds_other.go | 14 +
internal/webserver/server.go | 148 +++-
internal/webserver/server_test.go | 65 ++
main.go | 34 +-
33 files changed, 5036 insertions(+), 33 deletions(-)
create mode 100644 frontend/src/components/NativeDetachedWindowApp.test.tsx
create mode 100644 frontend/src/components/NativeDetachedWindowApp.tsx
create mode 100644 frontend/src/components/NativeDetachedWindowController.test.ts
create mode 100644 frontend/src/components/NativeDetachedWindowController.tsx
create mode 100644 frontend/src/utils/nativeDetachedWindowClient.test.ts
create mode 100644 frontend/src/utils/nativeDetachedWindowClient.ts
create mode 100644 frontend/src/utils/nativeDetachedWindowHost.test.ts
create mode 100644 frontend/src/utils/nativeDetachedWindowHost.ts
create mode 100644 internal/nativewindow/activation_policy_darwin.go
create mode 100644 internal/nativewindow/activation_policy_other.go
create mode 100644 internal/nativewindow/bridge.go
create mode 100644 internal/nativewindow/child.go
create mode 100644 internal/nativewindow/manager.go
create mode 100644 internal/nativewindow/manager_test.go
create mode 100644 internal/nativewindow/process.go
create mode 100644 internal/nativewindow/runtime_script.go
create mode 100644 internal/nativewindow/runtime_script_test.go
create mode 100644 internal/nativewindow/types.go
create mode 100644 internal/nativewindow/window_bounds_darwin.go
create mode 100644 internal/nativewindow/window_bounds_other.go
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 971cc3e4..3a54cb7f 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -11,6 +11,7 @@ import TabManager from './components/TabManager';
import FloatingWorkbenchWindows from './components/FloatingWorkbenchWindows';
import FloatingAIChatWindow from './components/FloatingAIChatWindow';
import FloatingQueryResultWindows from './components/FloatingQueryResultWindows';
+import NativeDetachedWindowController from './components/NativeDetachedWindowController';
import ConnectionModal from './components/ConnectionModal';
import SnippetSettingsModal from './components/SnippetSettingsModal';
import ConnectionPackagePasswordModal from './components/ConnectionPackagePasswordModal';
@@ -7052,6 +7053,7 @@ function App() {
+
{!isV2Ui && !aiPanelVisible && (
<>
diff --git a/frontend/src/components/FloatingQueryResultWindows.tsx b/frontend/src/components/FloatingQueryResultWindows.tsx
index b95ae29b..dc120be2 100644
--- a/frontend/src/components/FloatingQueryResultWindows.tsx
+++ b/frontend/src/components/FloatingQueryResultWindows.tsx
@@ -4,6 +4,7 @@ import { CloseOutlined, CompressOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import { t } from '../i18n';
import DataGrid from './DataGrid';
+import { hasNativeDetachedWindowManager } from '../utils/nativeDetachedWindowHost';
import {
clamp,
DEFAULT_DETACHED_WINDOW_MIN_HEIGHT,
@@ -119,7 +120,7 @@ const FloatingQueryResultWindows: React.FC = () => {
const windows = useMemo(() => detachedQueryResultWindows, [detachedQueryResultWindows]);
- if (windows.length === 0) {
+ if (hasNativeDetachedWindowManager() || windows.length === 0) {
return null;
}
diff --git a/frontend/src/components/FloatingWorkbenchWindows.tsx b/frontend/src/components/FloatingWorkbenchWindows.tsx
index 10defbe1..3e160133 100644
--- a/frontend/src/components/FloatingWorkbenchWindows.tsx
+++ b/frontend/src/components/FloatingWorkbenchWindows.tsx
@@ -15,6 +15,7 @@ import {
resolveDetachedWindowTitle,
} from '../utils/detachedWindow';
import WorkbenchTabContent from './WorkbenchTabContent';
+import { hasNativeDetachedWindowManager } from '../utils/nativeDetachedWindowHost';
const getTabKindLabel = (type: string): string => {
if (type === 'query') return t('tab_manager.kind_badge.query');
@@ -175,7 +176,7 @@ const FloatingWorkbenchWindows: React.FC = () => {
window.addEventListener('pointercancel', stop);
}, [focusDetachedWorkbenchTab, updateDetachedWorkbenchBounds]);
- if (windowModels.length === 0) {
+ if (hasNativeDetachedWindowManager() || windowModels.length === 0) {
return null;
}
diff --git a/frontend/src/components/NativeDetachedWindowApp.test.tsx b/frontend/src/components/NativeDetachedWindowApp.test.tsx
new file mode 100644
index 00000000..23efde36
--- /dev/null
+++ b/frontend/src/components/NativeDetachedWindowApp.test.tsx
@@ -0,0 +1,156 @@
+import React from 'react';
+import TestRenderer, { act } from 'react-test-renderer';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import type { TabData } from '../types';
+import type { NativeDetachedWindowBootstrap } from '../utils/nativeDetachedWindowClient';
+
+const queryTab: TabData = {
+ id: 'query-native-1',
+ title: 'Detached query',
+ type: 'query',
+ connectionId: 'connection-1',
+ dbName: 'main',
+ query: 'select 1',
+};
+
+let storeState: Record;
+const storeListeners = new Set<() => void>();
+
+vi.mock('../store', () => {
+ const useStore = Object.assign(
+ (selector: (state: Record) => unknown) => selector(storeState),
+ {
+ getState: () => storeState,
+ setState: (nextState: Record | ((state: Record) => Record)) => {
+ storeState = typeof nextState === 'function' ? nextState(storeState) : nextState;
+ storeListeners.forEach((listener) => listener());
+ },
+ subscribe: (listener: () => void) => {
+ storeListeners.add(listener);
+ return () => storeListeners.delete(listener);
+ },
+ },
+ );
+ return { useStore };
+});
+
+vi.mock('../i18n/provider', () => ({
+ useOptionalI18n: () => null,
+}));
+
+vi.mock('../i18n', () => ({
+ t: (key: string) => key,
+}));
+
+vi.mock('antd', () => ({
+ Button: ({ icon, ...props }: React.ButtonHTMLAttributes & { icon?: React.ReactNode }) => (
+
+ ),
+ ConfigProvider: ({ children }: { children: React.ReactNode }) => <>{children}>,
+ Spin: () => ,
+ Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}>,
+ theme: {
+ darkAlgorithm: 'dark',
+ defaultAlgorithm: 'light',
+ },
+}));
+
+vi.mock('@ant-design/icons', () => ({
+ CloseOutlined: () => ,
+ CompressOutlined: () => ,
+}));
+
+vi.mock('./WorkbenchTabContent', () => ({
+ default: ({ tab }: { tab: TabData }) => ,
+}));
+
+vi.mock('./DataGrid', () => ({
+ default: () => ,
+}));
+
+import NativeDetachedWindowApp from './NativeDetachedWindowApp';
+
+const flushEffects = async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+};
+
+describe('NativeDetachedWindowApp', () => {
+ beforeEach(() => {
+ storeListeners.clear();
+ storeState = {
+ tabs: [],
+ theme: 'light',
+ appearance: { uiVersion: 'v2' },
+ fontSize: 14,
+ updateQueryTabDraft: vi.fn(),
+ };
+ });
+
+ it('hydrates and attaches a workbench tab through the native action client', async () => {
+ const bootstrap: NativeDetachedWindowBootstrap = {
+ id: 'native-window-1',
+ kind: 'workbench',
+ title: queryTab.title,
+ payload: {
+ storeState: {
+ tabs: [queryTab],
+ theme: 'dark',
+ appearance: { uiVersion: 'v2' },
+ fontSize: 15,
+ },
+ tab: queryTab,
+ resultSession: {
+ resultSets: [],
+ activeResultKey: '',
+ isResultPanelVisible: true,
+ },
+ },
+ };
+ const client = {
+ load: vi.fn(async () => bootstrap),
+ ready: vi.fn(async () => undefined),
+ sync: vi.fn(async () => undefined),
+ attach: vi.fn(async () => undefined),
+ close: vi.fn(async () => undefined),
+ closeCurrentWindow: vi.fn(async () => undefined),
+ };
+
+ let renderer: TestRenderer.ReactTestRenderer;
+ await act(async () => {
+ renderer = TestRenderer.create();
+ await flushEffects();
+ });
+
+ expect(storeState.tabs).toEqual([queryTab]);
+ expect(storeState.theme).toBe('dark');
+ expect(typeof storeState.updateQueryTabDraft).toBe('function');
+ expect(client.ready).toHaveBeenCalledWith(expect.objectContaining({
+ id: bootstrap.id,
+ kind: 'workbench',
+ }));
+ expect(renderer!.root.findByProps({ 'data-workbench-tab': queryTab.id })).toBeTruthy();
+
+ const attachButton = renderer!.root.findByProps({
+ 'aria-label': 'tab_manager.detached.restore',
+ });
+ await act(async () => {
+ attachButton.props.onClick();
+ await flushEffects();
+ });
+
+ expect(client.sync).toHaveBeenCalledWith(expect.objectContaining({
+ id: bootstrap.id,
+ kind: 'workbench',
+ tab: queryTab,
+ }));
+ expect(client.attach).toHaveBeenCalledWith(expect.objectContaining({
+ id: bootstrap.id,
+ kind: 'workbench',
+ tab: queryTab,
+ }));
+ expect(client.close).not.toHaveBeenCalled();
+ expect(client.closeCurrentWindow).toHaveBeenCalledOnce();
+ });
+});
diff --git a/frontend/src/components/NativeDetachedWindowApp.tsx b/frontend/src/components/NativeDetachedWindowApp.tsx
new file mode 100644
index 00000000..2e1fd5c3
--- /dev/null
+++ b/frontend/src/components/NativeDetachedWindowApp.tsx
@@ -0,0 +1,508 @@
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { Button, ConfigProvider, Spin, Tooltip, theme as antdTheme } from 'antd';
+import { CloseOutlined, CompressOutlined } from '@ant-design/icons';
+
+import { t as defaultTranslate } from '../i18n';
+import { getAntdLocale } from '../i18n/frameworkLocale';
+import { useOptionalI18n } from '../i18n/provider';
+import { type SqlLog, useStore } from '../store';
+import type { TabData } from '../types';
+import type { DetachedQueryResultWindow } from '../utils/detachedWindow';
+import {
+ attachNativeDetachedWindow,
+ buildNativeDetachedSyncStoreSnapshot,
+ closeCurrentNativeDetachedWindow,
+ closeNativeDetachedWindow,
+ fetchNativeDetachedWindowBootstrap,
+ hydrateNativeDetachedStore,
+ readyNativeDetachedWindow,
+ syncNativeDetachedWindow,
+ type NativeDetachedWindowActionPayload,
+ type NativeDetachedWindowBootstrap,
+} from '../utils/nativeDetachedWindowClient';
+import {
+ peekQueryEditorResultSession,
+ saveQueryEditorResultSession,
+ subscribeQueryEditorResultSession,
+ type QueryEditorResultSessionSnapshot,
+} from '../utils/queryEditorResultSessionCache';
+import DataGrid from './DataGrid';
+import WorkbenchTabContent from './WorkbenchTabContent';
+import NativeDetachedWindowController from './NativeDetachedWindowController';
+
+export const NATIVE_DETACHED_SYNC_DEBOUNCE_MS = 180;
+
+type NativeDetachedWindowClient = {
+ load: () => Promise;
+ ready: (payload: NativeDetachedWindowActionPayload) => Promise;
+ sync: (payload: NativeDetachedWindowActionPayload) => Promise;
+ attach: (payload: NativeDetachedWindowActionPayload) => Promise;
+ close: (payload: NativeDetachedWindowActionPayload) => Promise;
+ closeCurrentWindow: () => Promise;
+};
+
+const defaultClient: NativeDetachedWindowClient = {
+ load: fetchNativeDetachedWindowBootstrap,
+ ready: readyNativeDetachedWindow,
+ sync: syncNativeDetachedWindow,
+ attach: attachNativeDetachedWindow,
+ close: closeNativeDetachedWindow,
+ closeCurrentWindow: closeCurrentNativeDetachedWindow,
+};
+
+export interface NativeDetachedWindowAppProps {
+ client?: NativeDetachedWindowClient;
+}
+
+const isAffectedRowsResult = (columns: string[]): boolean =>
+ columns.length === 1 && columns[0] === 'affectedRows';
+
+const buildActionPayload = (
+ bootstrap: NativeDetachedWindowBootstrap,
+ tab?: TabData,
+ resultSession?: QueryEditorResultSessionSnapshot | null,
+ includeResultSession = false,
+ newSqlLogs: SqlLog[] = [],
+): NativeDetachedWindowActionPayload => {
+ const storeState = buildNativeDetachedSyncStoreSnapshot(
+ useStore.getState(),
+ bootstrap.kind === 'workbench' ? bootstrap.payload.tab?.id || '' : '',
+ newSqlLogs,
+ );
+ return {
+ id: bootstrap.id,
+ kind: bootstrap.kind,
+ ...(bootstrap.kind === 'workbench' || Object.keys(storeState).length > 0
+ ? { storeState }
+ : {}),
+ ...(tab ? { tab } : {}),
+ ...(bootstrap.kind === 'workbench' && includeResultSession
+ ? { resultSession: resultSession ?? null }
+ : {}),
+ };
+};
+
+const NativeDetachedQueryResult: React.FC<{
+ windowState: DetachedQueryResultWindow;
+}> = ({ windowState }) => {
+ const result = windowState.result;
+ const isMessage = result.resultType === 'message' || isAffectedRowsResult(result.columns || []);
+ const messageText = (result.messages || []).join('\n')
+ || (isAffectedRowsResult(result.columns || [])
+ ? String(result.rows?.[0]?.affectedRows ?? '')
+ : '');
+
+ if (isMessage) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+};
+
+const NativeDetachedWindowContent: React.FC<{
+ bootstrap: NativeDetachedWindowBootstrap;
+}> = ({ bootstrap }) => {
+ const tabFromStore = useStore((state) => bootstrap.payload.tab
+ ? state.tabs.find((item) => item.id === bootstrap.payload.tab?.id)
+ : undefined);
+ const tab = tabFromStore || bootstrap.payload.tab;
+
+ if (bootstrap.kind === 'workbench') {
+ return tab ? : null;
+ }
+ return bootstrap.payload.resultWindow
+ ?
+ : null;
+};
+
+const NativeDetachedWindowApp: React.FC = ({
+ client = defaultClient,
+}) => {
+ const i18n = useOptionalI18n();
+ const translate = i18n?.t ?? defaultTranslate;
+ const [bootstrap, setBootstrap] = useState(null);
+ const [loadError, setLoadError] = useState('');
+ const [contentMounted, setContentMounted] = useState(true);
+ const [terminalAction, setTerminalAction] = useState<'attach' | 'close' | null>(null);
+ const terminalActionStartedRef = useRef(false);
+ const resultSessionRef = useRef(null);
+ const syncedSqlLogIdsRef = useRef>(new Set());
+ const syncTimerRef = useRef(null);
+ const syncIncludesResultSessionRef = useRef(false);
+
+ const themeMode = useStore((state) => state.theme);
+ const uiVersion = useStore((state) => state.appearance.uiVersion);
+ const fontSize = useStore((state) => state.fontSize);
+ const uiScale = useStore((state) => state.uiScale);
+
+ useEffect(() => {
+ const persist = (useStore as any).persist;
+ if (typeof persist?.setOptions !== 'function') return;
+ persist.setOptions({
+ storage: {
+ getItem: () => null,
+ setItem: () => undefined,
+ removeItem: () => undefined,
+ },
+ });
+ }, []);
+
+ useEffect(() => {
+ let active = true;
+ void client.load()
+ .then((nextBootstrap) => {
+ if (!active) return;
+ hydrateNativeDetachedStore(useStore, nextBootstrap.payload.storeState);
+ syncedSqlLogIdsRef.current = new Set(
+ (useStore.getState().sqlLogs || [])
+ .map((log) => String(log.id || '').trim())
+ .filter(Boolean),
+ );
+ if (nextBootstrap.kind === 'workbench' && nextBootstrap.payload.tab) {
+ resultSessionRef.current = nextBootstrap.payload.resultSession ?? null;
+ if (nextBootstrap.payload.resultSession) {
+ saveQueryEditorResultSession(
+ nextBootstrap.payload.tab.id,
+ nextBootstrap.payload.resultSession,
+ );
+ }
+ }
+ setBootstrap(nextBootstrap);
+ })
+ .catch((error) => {
+ if (!active) return;
+ setLoadError(error instanceof Error ? error.message : String(error));
+ });
+ return () => {
+ active = false;
+ };
+ }, [client]);
+
+ useEffect(() => {
+ if (!bootstrap || !contentMounted) return;
+ void client.ready({ id: bootstrap.id, kind: bootstrap.kind }).catch((error) => {
+ setLoadError(error instanceof Error ? error.message : String(error));
+ });
+ }, [bootstrap, client, contentMounted]);
+
+ useEffect(() => {
+ if (typeof document === 'undefined') return;
+ document.body.setAttribute('data-theme', themeMode === 'dark' ? 'dark' : 'light');
+ document.body.setAttribute('data-ui-version', uiVersion);
+ document.body.style.color = themeMode === 'dark' ? '#ffffff' : '#111827';
+ document.body.style.fontSize = `${Math.max(10, Number(fontSize) || 14)}px`;
+ document.documentElement.style.colorScheme = themeMode === 'dark' ? 'dark' : 'light';
+ }, [fontSize, themeMode, uiVersion]);
+
+ const readCurrentTab = useCallback((): TabData | undefined => {
+ const bootstrapTab = bootstrap?.payload.tab;
+ if (!bootstrapTab) return undefined;
+ return useStore.getState().tabs.find((item) => item.id === bootstrapTab.id)
+ || bootstrapTab;
+ }, [bootstrap]);
+
+ const readUnsyncedSqlLogs = useCallback((): SqlLog[] => {
+ const syncedIds = syncedSqlLogIdsRef.current;
+ return (useStore.getState().sqlLogs || []).filter((log) => {
+ const id = String(log.id || '').trim();
+ return id !== '' && !syncedIds.has(id);
+ });
+ }, []);
+
+ const markSqlLogsSynced = useCallback((logs: SqlLog[]): void => {
+ for (const log of logs) {
+ const id = String(log.id || '').trim();
+ if (id) syncedSqlLogIdsRef.current.add(id);
+ }
+ }, []);
+
+ const scheduleSync = useCallback((includeResultSession = false) => {
+ if (!bootstrap || terminalAction) return;
+ syncIncludesResultSessionRef.current = syncIncludesResultSessionRef.current || includeResultSession;
+ if (syncTimerRef.current !== null) {
+ window.clearTimeout(syncTimerRef.current);
+ }
+ syncTimerRef.current = window.setTimeout(() => {
+ syncTimerRef.current = null;
+ const shouldIncludeResultSession = syncIncludesResultSessionRef.current;
+ syncIncludesResultSessionRef.current = false;
+ const newSqlLogs = readUnsyncedSqlLogs();
+ if (bootstrap.kind === 'query-result' && newSqlLogs.length === 0) return;
+ void client.sync(buildActionPayload(
+ bootstrap,
+ readCurrentTab(),
+ resultSessionRef.current,
+ shouldIncludeResultSession,
+ newSqlLogs,
+ )).then(() => {
+ markSqlLogsSynced(newSqlLogs);
+ }).catch((error) => {
+ console.warn('[Native Detached Window] Failed to sync tab state', error);
+ });
+ }, NATIVE_DETACHED_SYNC_DEBOUNCE_MS);
+ }, [bootstrap, client, markSqlLogsSynced, readCurrentTab, readUnsyncedSqlLogs, terminalAction]);
+
+ useEffect(() => {
+ if (!bootstrap) {
+ return undefined;
+ }
+ const unsubscribeStore = useStore.subscribe(() => scheduleSync(false));
+ const unsubscribeResultSession = bootstrap.kind === 'workbench' && bootstrap.payload.tab
+ ? subscribeQueryEditorResultSession(
+ bootstrap.payload.tab.id,
+ (snapshot) => {
+ // QueryEditor consumes the initial cache entry during mount. Keep
+ // the last non-null snapshot for the final attach action.
+ if (snapshot) {
+ resultSessionRef.current = snapshot;
+ scheduleSync(false);
+ }
+ },
+ )
+ : () => undefined;
+ return () => {
+ unsubscribeStore();
+ unsubscribeResultSession();
+ if (syncTimerRef.current !== null) {
+ window.clearTimeout(syncTimerRef.current);
+ syncTimerRef.current = null;
+ }
+ };
+ }, [bootstrap, scheduleSync]);
+
+ const requestTerminalAction = useCallback((action: 'attach' | 'close') => {
+ if (!bootstrap || terminalAction) return;
+ if (syncTimerRef.current !== null) {
+ window.clearTimeout(syncTimerRef.current);
+ syncTimerRef.current = null;
+ }
+ setContentMounted(false);
+ setTerminalAction(action);
+ }, [bootstrap, terminalAction]);
+
+ useEffect(() => {
+ if (!bootstrap || !terminalAction || contentMounted || terminalActionStartedRef.current) {
+ return;
+ }
+ terminalActionStartedRef.current = true;
+
+ // Workbench content has unmounted before this effect runs, so QueryEditor
+ // has published its final result session to the cache.
+ const currentSession = bootstrap.payload.tab
+ ? peekQueryEditorResultSession(bootstrap.payload.tab.id) || resultSessionRef.current
+ : null;
+ const payload = buildActionPayload(
+ bootstrap,
+ readCurrentTab(),
+ currentSession,
+ terminalAction === 'attach',
+ readUnsyncedSqlLogs(),
+ );
+ void (async () => {
+ try {
+ if (terminalAction === 'attach' && bootstrap.kind === 'workbench') {
+ try {
+ await client.sync(payload);
+ } catch (error) {
+ // The attach request carries the same final tab/session payload, so
+ // a failed best-effort sync must not prevent the user from restoring.
+ console.warn('[Native Detached Window] Final sync before attach failed', error);
+ }
+ }
+ if (terminalAction === 'attach') {
+ await client.attach(payload);
+ } else {
+ await client.close(payload);
+ }
+ } catch (error) {
+ console.error(`[Native Detached Window] Failed to ${terminalAction}`, error);
+ terminalActionStartedRef.current = false;
+ setTerminalAction(null);
+ setContentMounted(true);
+ return;
+ }
+ try {
+ await client.closeCurrentWindow();
+ } catch (error) {
+ console.error('[Native Detached Window] Failed to close native window', error);
+ }
+ })();
+ }, [bootstrap, client, contentMounted, readCurrentTab, readUnsyncedSqlLogs, terminalAction]);
+
+ const chromeLabels = useMemo(() => ({
+ attach: bootstrap?.kind === 'workbench'
+ ? translate('tab_manager.detached.restore')
+ : translate('query_editor.results_panel.detached.restore'),
+ close: bootstrap?.kind === 'workbench'
+ ? translate('tab_manager.detached.close')
+ : translate('query_editor.results_panel.detached.close'),
+ }), [bootstrap?.kind, translate]);
+
+ const isDark = themeMode === 'dark';
+ const componentSize = uiScale <= 0.92 ? 'small' : (uiScale >= 1.12 ? 'large' : 'middle');
+ return (
+
+ {bootstrap ? (
+
+ ) : null}
+
+
+
+
+ {bootstrap?.title || ''}
+
+
+
+ }
+ aria-label={chromeLabels.attach}
+ disabled={!bootstrap || Boolean(terminalAction)}
+ onClick={() => requestTerminalAction('attach')}
+ />
+
+
+ }
+ aria-label={chromeLabels.close}
+ disabled={!bootstrap || Boolean(terminalAction)}
+ onClick={() => requestTerminalAction('close')}
+ />
+
+
+
+
+ {loadError ? (
+
{loadError}
+ ) : !bootstrap ? (
+
+ ) : contentMounted ? (
+
+ ) : null}
+
+
+
+ );
+};
+
+export default NativeDetachedWindowApp;
diff --git a/frontend/src/components/NativeDetachedWindowController.test.ts b/frontend/src/components/NativeDetachedWindowController.test.ts
new file mode 100644
index 00000000..c00d6dfa
--- /dev/null
+++ b/frontend/src/components/NativeDetachedWindowController.test.ts
@@ -0,0 +1,398 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { useStore } from '../store';
+import { peekQueryEditorResultSession } from '../utils/queryEditorResultSessionCache';
+import {
+ applyNativeDetachedWindowEvent,
+ type NativeDetachedWindowEvent,
+} from './NativeDetachedWindowController';
+
+const buildQueryTab = (id: string, query: string) => ({
+ id,
+ title: id,
+ type: 'query' as const,
+ connectionId: 'conn-1',
+ query,
+});
+
+describe('NativeDetachedWindowController', () => {
+ beforeEach(() => {
+ useStore.setState({
+ tabs: [buildQueryTab('query-a', 'select 1'), buildQueryTab('query-b', 'select 2')],
+ activeTabId: 'query-a',
+ detachedWorkbenchWindows: [
+ { tabId: 'query-a', x: 10, y: 10, width: 800, height: 600, zIndex: 1201 },
+ { tabId: 'query-b', x: 30, y: 30, width: 800, height: 600, zIndex: 1202 },
+ ],
+ detachedQueryResultWindows: [],
+ sqlLogs: [],
+ });
+ });
+
+ it('syncs only the detached tab and its live result session', () => {
+ applyNativeDetachedWindowEvent({
+ id: 'workbench:query-a',
+ kind: 'workbench',
+ action: 'sync',
+ payload: {
+ storeState: { sqlEditorPendingTransactions: { 'query-a': { transactionId: 'tx-1' } } },
+ tab: buildQueryTab('query-a', 'select 42'),
+ resultSession: {
+ activeResultKey: 'result-1',
+ resultSets: [{
+ key: 'result-1',
+ sql: 'select 42',
+ rows: [{ value: 42 }],
+ columns: ['value'],
+ pkColumns: [],
+ readOnly: true,
+ }],
+ },
+ },
+ });
+
+ expect(useStore.getState().tabs.find((tab) => tab.id === 'query-a')?.query).toBe('select 42');
+ expect(useStore.getState().tabs.find((tab) => tab.id === 'query-b')?.query).toBe('select 2');
+ expect((useStore.getState().sqlEditorPendingTransactions['query-a'] as any)?.transactionId).toBe('tx-1');
+ expect(peekQueryEditorResultSession('query-a')?.resultSets[0]?.rows).toEqual([{ value: 42 }]);
+ });
+
+ it('merges new child SQL logs by id without duplicating existing entries', () => {
+ useStore.getState().addSqlLog({
+ id: 'log-existing',
+ timestamp: 1,
+ sql: 'select 1',
+ status: 'success',
+ duration: 1,
+ });
+
+ applyNativeDetachedWindowEvent({
+ id: 'workbench:query-a',
+ kind: 'workbench',
+ action: 'sync',
+ payload: {
+ storeState: {
+ sqlLogs: [
+ {
+ id: 'log-new',
+ timestamp: 2,
+ sql: 'select 2',
+ status: 'success',
+ duration: 2,
+ },
+ {
+ id: 'log-existing',
+ timestamp: 1,
+ sql: 'select 1',
+ status: 'success',
+ duration: 1,
+ },
+ ],
+ },
+ },
+ });
+
+ expect(useStore.getState().sqlLogs.map((log) => log.id)).toEqual([
+ 'log-new',
+ 'log-existing',
+ ]);
+ });
+
+ it('merges audit logs produced by an editable detached result window', () => {
+ applyNativeDetachedWindowEvent({
+ id: 'query-result:query-a:r-edit',
+ kind: 'query-result',
+ action: 'sync',
+ payload: {
+ storeState: {
+ sqlLogs: [{
+ id: 'log-result-edit',
+ timestamp: 3,
+ sql: 'update users set name = ?',
+ status: 'success',
+ duration: 4,
+ affectedRows: 1,
+ }],
+ },
+ },
+ });
+
+ expect(useStore.getState().sqlLogs.map((log) => log.id)).toEqual(['log-result-edit']);
+ });
+
+ it('tracks a result window opened by its owning detached SQL window', () => {
+ const resultWindow = {
+ id: 'query-result:query-a:r-nested',
+ sourceQueryTabId: 'query-a',
+ connectionId: 'conn-1',
+ title: 'Nested result',
+ x: 2100,
+ y: -120,
+ width: 900,
+ height: 620,
+ zIndex: 1201,
+ result: {
+ key: 'r-nested',
+ sql: 'select 7',
+ rows: [{ value: 7 }],
+ columns: ['value'],
+ pkColumns: [],
+ readOnly: true,
+ },
+ };
+ const event: NativeDetachedWindowEvent = {
+ id: resultWindow.id,
+ kind: 'query-result',
+ action: 'opened',
+ payload: {
+ ownerWindowId: 'workbench:query-a',
+ resultWindow,
+ },
+ };
+
+ applyNativeDetachedWindowEvent(event);
+ expect(useStore.getState().detachedQueryResultWindows).toEqual([
+ expect.objectContaining({ id: resultWindow.id }),
+ ]);
+ useStore.setState({ detachedQueryResultWindows: [] });
+
+ applyNativeDetachedWindowEvent(event, resultWindow.id);
+ applyNativeDetachedWindowEvent(event, 'workbench:query-b');
+ expect(useStore.getState().detachedQueryResultWindows).toEqual([]);
+
+ applyNativeDetachedWindowEvent(event, 'workbench:query-a');
+ expect(useStore.getState().detachedQueryResultWindows).toEqual([
+ expect.objectContaining({ id: resultWindow.id }),
+ ]);
+ });
+
+ it('reattaches one tab without closing it or disturbing peer windows', () => {
+ applyNativeDetachedWindowEvent({
+ id: 'workbench:query-a',
+ kind: 'workbench',
+ action: 'attach',
+ payload: { tab: buildQueryTab('query-a', 'select 9') },
+ });
+
+ expect(useStore.getState().tabs.map((tab) => tab.id)).toEqual(['query-a', 'query-b']);
+ expect(useStore.getState().detachedWorkbenchWindows.map((item) => item.tabId)).toEqual(['query-b']);
+ expect(useStore.getState().activeTabId).toBe('query-a');
+ });
+
+ it('closes only the tab whose native window sent an explicit close action', () => {
+ const event: NativeDetachedWindowEvent = {
+ id: 'workbench:query-a',
+ kind: 'workbench',
+ action: 'close',
+ };
+ applyNativeDetachedWindowEvent(event);
+
+ expect(useStore.getState().tabs.map((tab) => tab.id)).toEqual(['query-b']);
+ expect(useStore.getState().detachedWorkbenchWindows.map((item) => item.tabId)).toEqual(['query-b']);
+ });
+
+ it('reattaches a workbench tab when its child process exits unexpectedly', () => {
+ applyNativeDetachedWindowEvent({
+ id: 'workbench:query-a',
+ kind: 'workbench',
+ action: 'close',
+ payload: { reason: 'process-error', exited: true, error: 'exit status 9' },
+ });
+
+ expect(useStore.getState().tabs.map((tab) => tab.id)).toEqual(['query-a', 'query-b']);
+ expect(useStore.getState().detachedWorkbenchWindows.map((item) => item.tabId)).toEqual(['query-b']);
+ expect(useStore.getState().activeTabId).toBe('query-a');
+ });
+
+ it('restores instead of deleting when the process exit races a window-close action', () => {
+ applyNativeDetachedWindowEvent({
+ id: 'workbench:query-a',
+ kind: 'workbench',
+ action: 'close',
+ payload: { reason: 'window-closed', exited: true },
+ });
+
+ expect(useStore.getState().tabs.map((tab) => tab.id)).toEqual(['query-a', 'query-b']);
+ expect(useStore.getState().detachedWorkbenchWindows.map((item) => item.tabId)).toEqual(['query-b']);
+ });
+
+ it('ignores the child process exit that follows a successful reattach', () => {
+ applyNativeDetachedWindowEvent({
+ id: 'workbench:query-a',
+ kind: 'workbench',
+ action: 'attach',
+ payload: { tab: buildQueryTab('query-a', 'select 9') },
+ });
+ applyNativeDetachedWindowEvent({
+ id: 'workbench:query-a',
+ kind: 'workbench',
+ action: 'close',
+ payload: { reason: 'attached', exited: true },
+ });
+
+ expect(useStore.getState().tabs.map((tab) => tab.id)).toEqual(['query-a', 'query-b']);
+ expect(useStore.getState().detachedWorkbenchWindows.map((item) => item.tabId)).toEqual(['query-b']);
+ });
+
+ it('keeps a docked tab when a just-ready child exits before detach state commits', () => {
+ useStore.setState({
+ detachedWorkbenchWindows: useStore.getState().detachedWorkbenchWindows.filter(
+ (item) => item.tabId !== 'query-a',
+ ),
+ });
+ applyNativeDetachedWindowEvent({
+ id: 'workbench:query-a',
+ kind: 'workbench',
+ action: 'close',
+ payload: { reason: 'process-error', exited: true },
+ });
+
+ expect(useStore.getState().tabs.map((tab) => tab.id)).toEqual(['query-a', 'query-b']);
+ });
+
+ it('restores a result snapshot without closing its source query tab', () => {
+ useStore.setState({
+ detachedQueryResultWindows: [{
+ id: 'query-result:query-a:r1',
+ sourceQueryTabId: 'query-a',
+ connectionId: 'conn-1',
+ title: 'Result 1',
+ x: 10,
+ y: 10,
+ width: 800,
+ height: 600,
+ zIndex: 1201,
+ result: {
+ key: 'r1',
+ sql: 'select 42',
+ rows: [{ value: 42 }],
+ columns: ['value'],
+ pkColumns: [],
+ readOnly: true,
+ },
+ }],
+ });
+ const dispatchEvent = vi.fn();
+ const previousWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window');
+ Object.defineProperty(globalThis, 'window', {
+ configurable: true,
+ value: { dispatchEvent },
+ });
+ try {
+ applyNativeDetachedWindowEvent({
+ id: 'query-result:query-a:r1',
+ kind: 'query-result',
+ action: 'attach',
+ });
+ expect(useStore.getState().detachedQueryResultWindows).toEqual([]);
+ expect(useStore.getState().tabs.map((tab) => tab.id)).toEqual(['query-a', 'query-b']);
+ expect(dispatchEvent).toHaveBeenCalledOnce();
+ expect(dispatchEvent.mock.calls[0][0].detail.result.rows).toEqual([{ value: 42 }]);
+ } finally {
+ if (previousWindowDescriptor) {
+ Object.defineProperty(globalThis, 'window', previousWindowDescriptor);
+ } else {
+ Reflect.deleteProperty(globalThis, 'window');
+ }
+ }
+ });
+
+ it('restores a detached result when its child process crashes', () => {
+ useStore.setState({
+ detachedQueryResultWindows: [{
+ id: 'query-result:query-a:r1',
+ sourceQueryTabId: 'query-a',
+ connectionId: 'conn-1',
+ title: 'Result 1',
+ x: 10,
+ y: 10,
+ width: 800,
+ height: 600,
+ zIndex: 1201,
+ result: {
+ key: 'r1',
+ sql: 'select 42',
+ rows: [{ value: 42 }],
+ columns: ['value'],
+ pkColumns: [],
+ readOnly: true,
+ },
+ }],
+ });
+ const dispatchEvent = vi.fn();
+ const previousWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window');
+ Object.defineProperty(globalThis, 'window', {
+ configurable: true,
+ value: { dispatchEvent },
+ });
+ try {
+ applyNativeDetachedWindowEvent({
+ id: 'query-result:query-a:r1',
+ kind: 'query-result',
+ action: 'close',
+ payload: { reason: 'process-error', exited: true },
+ });
+
+ expect(useStore.getState().detachedQueryResultWindows).toEqual([]);
+ expect(dispatchEvent).toHaveBeenCalledOnce();
+ expect(dispatchEvent.mock.calls[0][0].detail.result.rows).toEqual([{ value: 42 }]);
+ } finally {
+ if (previousWindowDescriptor) {
+ Object.defineProperty(globalThis, 'window', previousWindowDescriptor);
+ } else {
+ Reflect.deleteProperty(globalThis, 'window');
+ }
+ }
+ });
+
+ it('routes result restoration to the detached SQL window that owns it', () => {
+ const resultWindow = {
+ id: 'query-result:query-a:r-owned',
+ sourceQueryTabId: 'query-a',
+ connectionId: 'conn-1',
+ title: 'Owned result',
+ x: 10,
+ y: 10,
+ width: 800,
+ height: 600,
+ zIndex: 1201,
+ result: {
+ key: 'r-owned',
+ sql: 'select 8',
+ rows: [{ value: 8 }],
+ columns: ['value'],
+ pkColumns: [],
+ readOnly: true,
+ },
+ };
+ useStore.setState({ detachedQueryResultWindows: [resultWindow] });
+ const dispatchEvent = vi.fn();
+ const previousWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window');
+ Object.defineProperty(globalThis, 'window', {
+ configurable: true,
+ value: { dispatchEvent },
+ });
+ try {
+ const event: NativeDetachedWindowEvent = {
+ id: resultWindow.id,
+ kind: 'query-result',
+ action: 'attach',
+ payload: { ownerWindowId: 'workbench:query-a' },
+ };
+ applyNativeDetachedWindowEvent(event, 'workbench:query-b');
+ expect(dispatchEvent).not.toHaveBeenCalled();
+ expect(useStore.getState().detachedQueryResultWindows).toHaveLength(1);
+
+ applyNativeDetachedWindowEvent(event, 'workbench:query-a');
+ expect(dispatchEvent).toHaveBeenCalledOnce();
+ expect(dispatchEvent.mock.calls[0][0].detail.result.rows).toEqual([{ value: 8 }]);
+ expect(useStore.getState().detachedQueryResultWindows).toEqual([]);
+ } finally {
+ if (previousWindowDescriptor) {
+ Object.defineProperty(globalThis, 'window', previousWindowDescriptor);
+ } else {
+ Reflect.deleteProperty(globalThis, 'window');
+ }
+ }
+ });
+});
diff --git a/frontend/src/components/NativeDetachedWindowController.tsx b/frontend/src/components/NativeDetachedWindowController.tsx
new file mode 100644
index 00000000..fef5270b
--- /dev/null
+++ b/frontend/src/components/NativeDetachedWindowController.tsx
@@ -0,0 +1,232 @@
+import { useEffect } from 'react';
+
+import { EventsOn, WindowShow } from '../../wailsjs/runtime';
+import { type SqlLog, useStore } from '../store';
+import type { TabData } from '../types';
+import type { DetachedQueryResultWindow } from '../utils/detachedWindow';
+import {
+ closeNativeDetachedWindowById,
+ hasNativeDetachedWindowManager,
+} from '../utils/nativeDetachedWindowHost';
+import type { NativeDetachedWindowKind } from '../utils/nativeDetachedWindowClient';
+import {
+ saveQueryEditorResultSession,
+ type QueryEditorResultSessionSnapshot,
+} from '../utils/queryEditorResultSessionCache';
+
+export const NATIVE_DETACHED_WINDOW_EVENT = 'gonavi:native-detached-event';
+
+export type NativeDetachedWindowEvent = {
+ id: string;
+ kind: NativeDetachedWindowKind;
+ action: 'opened' | 'sync' | 'attach' | 'close';
+ payload?: {
+ tab?: TabData;
+ storeState?: Record;
+ resultSession?: QueryEditorResultSessionSnapshot | null;
+ resultWindow?: DetachedQueryResultWindow;
+ ownerWindowId?: string;
+ [key: string]: unknown;
+ };
+};
+
+const replaceSyncedTab = (tab: TabData): void => {
+ useStore.setState((state) => {
+ if (!state.tabs.some((item) => item.id === tab.id)) return state;
+ return {
+ tabs: state.tabs.map((item) => item.id === tab.id ? { ...item, ...tab, id: item.id } : item),
+ };
+ });
+};
+
+const mergeSyncedSqlLogs = (snapshot: Record): void => {
+ const incomingLogs = Array.isArray(snapshot.sqlLogs) ? snapshot.sqlLogs : [];
+ if (incomingLogs.length > 0) {
+ const existingIds = new Set(useStore.getState().sqlLogs.map((log) => log.id));
+ const newLogs = incomingLogs.filter((item): item is SqlLog => {
+ if (!item || typeof item !== 'object') return false;
+ const id = String((item as { id?: unknown }).id || '').trim();
+ if (!id || existingIds.has(id)) return false;
+ existingIds.add(id);
+ return true;
+ });
+ for (const log of [...newLogs].reverse()) {
+ useStore.getState().addSqlLog(log);
+ }
+ }
+};
+
+const mergeSyncedTabRuntimeState = (
+ tabId: string,
+ snapshot: Record,
+): void => {
+ const pendingPatch = snapshot.sqlEditorPendingTransactions;
+ if (!pendingPatch || typeof pendingPatch !== 'object') return;
+ const value = (pendingPatch as Record)[tabId];
+ useStore.setState((state) => {
+ const next = { ...state.sqlEditorPendingTransactions };
+ if (value === null || value === undefined) {
+ delete next[tabId];
+ } else {
+ next[tabId] = value as (typeof next)[string];
+ }
+ return { sqlEditorPendingTransactions: next };
+ });
+};
+
+const restoreQueryResult = (windowId: string): void => {
+ const restored = useStore.getState().attachQueryResultWindow(windowId);
+ if (!restored || typeof window === 'undefined') return;
+ window.dispatchEvent(new CustomEvent('gonavi:restore-query-result', {
+ detail: {
+ sourceQueryTabId: restored.sourceQueryTabId,
+ result: restored.result,
+ },
+ }));
+};
+
+const showMainWindow = (): void => {
+ if (typeof window !== 'undefined' && typeof (window as any).runtime?.WindowShow === 'function') {
+ void WindowShow();
+ }
+};
+
+export const applyNativeDetachedWindowEvent = (
+ event: NativeDetachedWindowEvent,
+ currentWindowId?: string,
+): void => {
+ const id = String(event?.id || '').trim();
+ if (!id || (event.kind !== 'workbench' && event.kind !== 'query-result')) return;
+
+ const localWindowId = String(currentWindowId || '').trim();
+ const ownerWindowId = String(event.payload?.ownerWindowId || '').trim();
+ if (localWindowId) {
+ if (event.kind === 'query-result') {
+ // Result lifecycle belongs to its source SQL window. The result window
+ // process itself receives the same broadcast but must not mutate a copy.
+ if (ownerWindowId !== localWindowId) return;
+ } else if (id !== localWindowId && ownerWindowId !== localWindowId) {
+ return;
+ }
+ }
+
+ if (event.action === 'opened') {
+ const resultWindow = event.payload?.resultWindow;
+ if (
+ event.kind === 'query-result'
+ && resultWindow
+ && typeof resultWindow === 'object'
+ && String(resultWindow.id || '').trim() === id
+ ) {
+ useStore.getState().detachQueryResultWindow(resultWindow);
+ }
+ return;
+ }
+
+ const tab = event.payload?.tab;
+ const eventTabId = tab?.id || id.replace(/^workbench:/, '');
+ if (event.payload?.storeState) {
+ mergeSyncedSqlLogs(event.payload.storeState);
+ if (event.kind === 'workbench') {
+ mergeSyncedTabRuntimeState(eventTabId, event.payload.storeState);
+ }
+ }
+ if (event.kind === 'workbench' && tab) {
+ replaceSyncedTab(tab);
+ if (tab.type === 'query' && event.payload?.resultSession) {
+ saveQueryEditorResultSession(tab.id, event.payload.resultSession);
+ }
+ }
+
+ if (event.action === 'sync') return;
+ if (event.action === 'attach') {
+ if (event.kind === 'workbench') {
+ const tabId = tab?.id || id.replace(/^workbench:/, '');
+ useStore.getState().attachWorkbenchTab(tabId);
+ } else {
+ restoreQueryResult(id);
+ }
+ showMainWindow();
+ return;
+ }
+
+ if (event.kind === 'workbench') {
+ const tabId = tab?.id || id.replace(/^workbench:/, '');
+ const reason = String(event.payload?.reason || '').trim();
+ const stillDetached = useStore.getState().detachedWorkbenchWindows.some(
+ (item) => item.tabId === tabId,
+ );
+ if (reason === 'attached' || reason === 'parent-shutdown' || reason === 'requested') {
+ return;
+ }
+ if (event.payload?.exited === true) {
+ if (stillDetached) {
+ useStore.getState().attachWorkbenchTab(tabId);
+ showMainWindow();
+ }
+ return;
+ }
+ if (useStore.getState().tabs.some((item) => item.id === tabId)) {
+ useStore.getState().closeTab(tabId);
+ }
+ } else {
+ const reason = String(event.payload?.reason || '').trim();
+ const stillDetached = useStore.getState().detachedQueryResultWindows.some(
+ (item) => item.id === id,
+ );
+ if (reason === 'attached' || reason === 'parent-shutdown' || reason === 'requested') {
+ return;
+ }
+ if (event.payload?.exited === true) {
+ if (stillDetached) {
+ restoreQueryResult(id);
+ showMainWindow();
+ }
+ return;
+ }
+ useStore.getState().closeDetachedQueryResultWindow(id);
+ }
+};
+
+const currentNativeWindowIds = (): Set => {
+ const state = useStore.getState();
+ return new Set([
+ ...state.detachedWorkbenchWindows.map((item) => `workbench:${item.tabId}`),
+ ...state.detachedQueryResultWindows.map((item) => item.id),
+ ]);
+};
+
+export interface NativeDetachedWindowControllerProps {
+ currentWindowId?: string;
+}
+
+const NativeDetachedWindowController = ({
+ currentWindowId,
+}: NativeDetachedWindowControllerProps = {}): null => {
+ useEffect(() => {
+ if (!hasNativeDetachedWindowManager()) return undefined;
+
+ const off = EventsOn(NATIVE_DETACHED_WINDOW_EVENT, (payload: NativeDetachedWindowEvent) => {
+ applyNativeDetachedWindowEvent(payload, currentWindowId);
+ });
+ let previousIds = currentNativeWindowIds();
+ const unsubscribeStore = useStore.subscribe(() => {
+ const nextIds = currentNativeWindowIds();
+ for (const id of previousIds) {
+ if (!nextIds.has(id)) {
+ void closeNativeDetachedWindowById(id).catch(() => undefined);
+ }
+ }
+ previousIds = nextIds;
+ });
+
+ return () => {
+ off();
+ unsubscribeStore();
+ };
+ }, [currentWindowId]);
+
+ return null;
+};
+
+export default NativeDetachedWindowController;
diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx
index 06853f36..96a9acf5 100644
--- a/frontend/src/components/QueryEditor.tsx
+++ b/frontend/src/components/QueryEditor.tsx
@@ -64,6 +64,8 @@ import {
takeQueryEditorResultSession,
} from '../utils/queryEditorResultSessionCache';
import { buildEditableTriggerSql } from '../utils/triggerEditSql';
+import { openNativeQueryResultWindow } from '../utils/nativeDetachedWindowHost';
+import { isNativeDetachedWindow } from '../utils/nativeDetachedWindowClient';
import {
getColumnDefinitionComment,
getColumnDefinitionKey,
@@ -1427,6 +1429,23 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
);
const isResultPanelVisibleRef = useRef(isResultPanelVisible);
isResultPanelVisibleRef.current = isResultPanelVisible;
+ const publishesDetachedResultSession = useMemo(() => isNativeDetachedWindow(), []);
+
+ useEffect(() => {
+ const captureSession = (event: Event) => {
+ const requestedTabId = String((event as CustomEvent).detail?.tabId || '').trim();
+ if (requestedTabId !== tab.id) return;
+ saveQueryEditorResultSession(tab.id, {
+ resultSets: resultSetsRef.current,
+ activeResultKey: activeResultKeyRef.current,
+ isResultPanelVisible: isResultPanelVisibleRef.current,
+ });
+ };
+ window.addEventListener('gonavi:capture-query-result-session', captureSession);
+ return () => {
+ window.removeEventListener('gonavi:capture-query-result-session', captureSession);
+ };
+ }, [tab.id]);
useEffect(() => {
// Keep result panel state across detach/attach remounts of the same tab.
@@ -1438,6 +1457,15 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
});
};
}, [tab.id]);
+
+ useEffect(() => {
+ if (!publishesDetachedResultSession) return;
+ saveQueryEditorResultSession(tab.id, {
+ resultSets,
+ activeResultKey,
+ isResultPanelVisible,
+ });
+ }, [activeResultKey, isResultPanelVisible, publishesDetachedResultSession, resultSets, tab.id]);
const shortcutOptions = useStore(state => state.shortcutOptions);
const activeShortcutPlatform = getShortcutPlatform(isMacLikePlatform());
const runQueryShortcutBinding = useMemo(
@@ -8470,7 +8498,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
? translate('query_editor.results_panel.tab.message', { index: index + 1 })
: translate('query_editor.results_panel.detached.title', { index: index + 1 });
const windowId = `query-result:${tab.id}:${target.key}`;
- useStore.getState().detachQueryResultWindow({
+ const detachedWindow = {
id: windowId,
sourceQueryTabId: tab.id,
connectionId: currentConnectionId || tab.connectionId || '',
@@ -8500,8 +8528,14 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
showRowNumberColumn: target.showRowNumberColumn,
truncated: target.truncated,
},
- });
- handleCloseResult(key);
+ };
+ void openNativeQueryResultWindow(detachedWindow)
+ .then((opened) => {
+ if (opened) handleCloseResult(key);
+ })
+ .catch((error) => {
+ message.error(error instanceof Error ? error.message : String(error));
+ });
};
React.useEffect(() => {
diff --git a/frontend/src/components/QueryEditorResultsPanel.tsx b/frontend/src/components/QueryEditorResultsPanel.tsx
index 54b156d6..3ff680fc 100644
--- a/frontend/src/components/QueryEditorResultsPanel.tsx
+++ b/frontend/src/components/QueryEditorResultsPanel.tsx
@@ -10,7 +10,8 @@ import { buildQueryResultColumnPinScope } from '../utils/queryResultColumnPinSco
import { t as defaultTranslate } from '../i18n';
import { useOptionalI18n } from '../i18n/provider';
import {
- resolveResultDetachPreferredBounds,
+ resolveNativeDetachPreferredBounds,
+ shouldDetachAtScreenPoint,
shouldDetachTabByDrag,
type DetachedWindowBounds,
} from '../utils/detachedWindow';
@@ -123,6 +124,10 @@ const QueryEditorResultsPanel: React.FC = ({
title: string;
startX: number;
startY: number;
+ startScreenX: number;
+ startScreenY: number;
+ pointerId: number;
+ captureTarget: HTMLElement;
active: boolean;
} | null>(null);
@@ -148,8 +153,17 @@ const QueryEditorResultsPanel: React.FC = ({
title,
startX: event.clientX,
startY: event.clientY,
+ startScreenX: event.screenX,
+ startScreenY: event.screenY,
+ pointerId: event.pointerId,
+ captureTarget: event.currentTarget,
active: false,
};
+ try {
+ event.currentTarget.setPointerCapture(event.pointerId);
+ } catch {
+ // Some embedded WebViews do not expose pointer capture for tab labels.
+ }
const previousUserSelect = document.body.style.userSelect;
const previousWebkitUserSelect = (document.body.style as CSSStyleDeclaration & { webkitUserSelect?: string }).webkitUserSelect || '';
@@ -180,6 +194,7 @@ const QueryEditorResultsPanel: React.FC = ({
};
const clearListeners = () => {
+ const drag = resultTabDragRef.current;
window.removeEventListener('pointermove', handleMove);
window.removeEventListener('pointerup', handleUp);
window.removeEventListener('pointercancel', handleUp);
@@ -190,6 +205,9 @@ const QueryEditorResultsPanel: React.FC = ({
(document.body.style as CSSStyleDeclaration & { webkitUserSelect?: string }).webkitUserSelect = previousWebkitUserSelect;
document.documentElement.classList.remove('gn-result-tab-detaching');
}
+ if (drag?.captureTarget.hasPointerCapture?.(drag.pointerId)) {
+ drag.captureTarget.releasePointerCapture(drag.pointerId);
+ }
resultTabDragRef.current = null;
setDraggingResultKey(null);
setDetachDragPreview(null);
@@ -225,7 +243,19 @@ const QueryEditorResultsPanel: React.FC = ({
return;
}
const dy = upEvent.clientY - drag.startY;
- const shouldDetach = drag.active && shouldDetachTabByDrag(dy);
+ const releaseScreenX = Number.isFinite(upEvent.screenX)
+ ? upEvent.screenX
+ : drag.startScreenX + (upEvent.clientX - drag.startX);
+ const releaseScreenY = Number.isFinite(upEvent.screenY)
+ ? upEvent.screenY
+ : drag.startScreenY + (upEvent.clientY - drag.startY);
+ const releasedOutsideHost = shouldDetachAtScreenPoint(releaseScreenX, releaseScreenY, {
+ x: window.screenX,
+ y: window.screenY,
+ width: window.outerWidth || window.innerWidth,
+ height: window.outerHeight || window.innerHeight,
+ });
+ const shouldDetach = drag.active && (shouldDetachTabByDrag(dy) || releasedOutsideHost);
if (drag.active) {
upEvent.preventDefault();
clearNativeSelection();
@@ -233,7 +263,7 @@ const QueryEditorResultsPanel: React.FC = ({
// 先清预览再打开真实窗口,避免叠两层
clearListeners();
if (shouldDetach) {
- onOpenResultInWindow(key, resolveResultDetachPreferredBounds(upEvent.clientX, upEvent.clientY));
+ onOpenResultInWindow(key, resolveNativeDetachPreferredBounds(releaseScreenX, releaseScreenY));
}
};
diff --git a/frontend/src/components/TabManager.tsx b/frontend/src/components/TabManager.tsx
index e913f052..45f9a302 100644
--- a/frontend/src/components/TabManager.tsx
+++ b/frontend/src/components/TabManager.tsx
@@ -35,9 +35,12 @@ import DetachDragPreview, {
type DetachDragPreviewState,
} from './DetachDragPreview';
import {
- resolveResultDetachPreferredBounds,
+ resolveNativeDetachPreferredBounds,
+ resolveNativeDetachReleasePoint,
+ shouldDetachAtScreenPoint,
shouldDetachTabByDrag,
} from '../utils/detachedWindow';
+import { openNativeWorkbenchTabWindow } from '../utils/nativeDetachedWindowHost';
const getTabKindLabel = (tab: TabData): string => {
if (tab.type === 'query') return t('tab_manager.kind_badge.query');
@@ -503,12 +506,21 @@ const DraggableTabNode: React.FC = ({ node }) => {
touchAction: 'none',
zIndex: isDragging ? 2 : node.props.style?.zIndex,
};
+ const handlePointerDown = listeners?.onPointerDown as React.PointerEventHandler | undefined;
return React.cloneElement(node, {
ref: setNodeRef,
style,
...attributes,
...listeners,
+ onPointerDown: (event: React.PointerEvent) => {
+ try {
+ event.currentTarget.setPointerCapture(event.pointerId);
+ } catch {
+ // Pointer capture is not exposed by every embedded WebView build.
+ }
+ handlePointerDown?.(event);
+ },
className: `${node.props.className || ''} tab-dnd-node${isDragging ? ' is-dragging' : ''}`,
});
};
@@ -534,7 +546,6 @@ const TabManager: React.FC = React.memo(() => {
const closeTabsToRight = useStore(state => state.closeTabsToRight);
const closeAllTabs = useStore(state => state.closeAllTabs);
const moveTab = useStore(state => state.moveTab);
- const detachWorkbenchTab = useStore(state => state.detachWorkbenchTab);
const setAIPanelVisible = useStore(state => state.setAIPanelVisible);
const detachedTabIdSet = useMemo(
() => new Set(detachedWorkbenchWindows.map((windowState) => windowState.tabId)),
@@ -553,6 +564,8 @@ const TabManager: React.FC = React.memo(() => {
title: string;
startX: number;
startY: number;
+ startScreenX: number;
+ startScreenY: number;
} | null>(null);
const suppressClickUntilRef = useRef(0);
const sensors = useSensors(
@@ -563,6 +576,11 @@ const TabManager: React.FC = React.memo(() => {
const isV2Ui = appearance.uiVersion === 'v2';
const hasTabs = tabs.length > 0;
const hasDockedTabs = dockedTabs.length > 0;
+ const detachTabToWindow = useCallback((tabId: string, preferred?: { x?: number; y?: number; width?: number; height?: number }) => {
+ void openNativeWorkbenchTabWindow(tabId, preferred).catch((error) => {
+ message.error(error instanceof Error ? error.message : String(error));
+ });
+ }, []);
const dockedActiveTabId = useMemo(() => {
if (activeTabId && dockedTabs.some((tab) => tab.id === activeTabId)) {
return activeTabId;
@@ -739,8 +757,14 @@ const TabManager: React.FC = React.memo(() => {
const pointerEvent = event.activatorEvent as PointerEvent | MouseEvent | undefined;
const startX = typeof pointerEvent?.clientX === 'number' ? pointerEvent.clientX : 0;
const startY = typeof pointerEvent?.clientY === 'number' ? pointerEvent.clientY : 0;
+ const startScreenX = typeof pointerEvent?.screenX === 'number'
+ ? pointerEvent.screenX
+ : window.screenX + startX;
+ const startScreenY = typeof pointerEvent?.screenY === 'number'
+ ? pointerEvent.screenY
+ : window.screenY + startY;
detachDragSessionRef.current = sourceId
- ? { tabId: sourceId, title, startX, startY }
+ ? { tabId: sourceId, title, startX, startY, startScreenX, startScreenY }
: null;
document.documentElement.classList.add('gn-workbench-tab-detaching');
};
@@ -769,12 +793,22 @@ const TabManager: React.FC = React.memo(() => {
if (!sourceId) {
return;
}
- if (shouldDetachTabByDrag(deltaY, targetId || null)) {
+ const release = resolveNativeDetachReleasePoint({
+ startScreenX: session?.startScreenX ?? window.screenX,
+ startScreenY: session?.startScreenY ?? window.screenY,
+ deltaX,
+ deltaY,
+ });
+ const releasedOutsideHost = shouldDetachAtScreenPoint(release.screenX, release.screenY, {
+ x: window.screenX,
+ y: window.screenY,
+ width: window.outerWidth || window.innerWidth,
+ height: window.outerHeight || window.innerHeight,
+ });
+ if (shouldDetachTabByDrag(deltaY, targetId || null) || releasedOutsideHost) {
suppressClickUntilRef.current = Date.now() + 120;
- const releaseX = (session?.startX ?? 0) + deltaX;
- const releaseY = (session?.startY ?? 0) + deltaY;
- const preferred = resolveResultDetachPreferredBounds(releaseX, releaseY);
- detachWorkbenchTab(sourceId, preferred);
+ const preferred = resolveNativeDetachPreferredBounds(release.screenX, release.screenY);
+ detachTabToWindow(sourceId, preferred);
return;
}
if (!targetId || sourceId === targetId) {
@@ -874,7 +908,7 @@ const TabManager: React.FC = React.memo(() => {
{
key: 'open-in-window',
label: t('tab_manager.menu.open_in_window'),
- onClick: () => detachWorkbenchTab(tab.id),
+ onClick: () => detachTabToWindow(tab.id),
},
{ type: 'divider' },
{
@@ -921,7 +955,7 @@ const TabManager: React.FC = React.memo(() => {
closable: !isV2Ui,
children: ,
};
- }), [dockedTabs, dockedActiveTabId, tabs, connections, appearance.tabDisplay, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs, closeTab, closeTabsWithSQLFilePrompt, detachWorkbenchTab, isV2Ui, languagePreference]);
+ }), [dockedTabs, dockedActiveTabId, tabs, connections, appearance.tabDisplay, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs, closeTab, closeTabsWithSQLFilePrompt, detachTabToWindow, isV2Ui, languagePreference]);
const queryCapableConnections = useMemo(
() => connections.filter((connection) => getDataSourceCapabilities(connection.config).supportsQueryEditor),
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index d8ba446f..48f2e9f4 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -1,6 +1,7 @@
import React, { useSyncExternalStore } from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
+import NativeDetachedWindowApp from './components/NativeDetachedWindowApp'
// import './index.css' // Optional global styles
import { setCurrentLanguage, t } from './i18n'
@@ -8,6 +9,7 @@ import { I18nProvider } from './i18n/provider'
import { applyDayjsLocale } from './i18n/runtime'
import { useStore } from './store'
import { cloneBrowserMockValue, duplicateBrowserMockConnection, resolveBrowserMockSecretFlag } from './utils/browserMockConnections'
+import { isNativeDetachedWindow } from './utils/nativeDetachedWindowClient'
const resolveDevHarnessMode = (): string => {
if (typeof window === 'undefined') {
@@ -935,7 +937,9 @@ const Root = ({ rootComponent }: { rootComponent: React.ReactNode }) => {
};
const renderRoot = async () => {
- let rootComponent: React.ReactNode = ;
+ let rootComponent: React.ReactNode = isNativeDetachedWindow()
+ ?
+ : ;
if (devHarnessMode === 'datagrid-perf') {
const { default: PerfDataGridHarness } = await import('./dev/PerfDataGridHarness');
rootComponent = ;
diff --git a/frontend/src/utils/detachedWindow.test.ts b/frontend/src/utils/detachedWindow.test.ts
index 14f7add1..4b6ed8a4 100644
--- a/frontend/src/utils/detachedWindow.test.ts
+++ b/frontend/src/utils/detachedWindow.test.ts
@@ -4,8 +4,11 @@ import {
DETACH_TAB_DRAG_Y_THRESHOLD,
nextDetachedZIndex,
resolveDetachedWindowTitle,
+ resolveNativeDetachPreferredBounds,
+ resolveNativeDetachReleasePoint,
resolveResultDetachPreferredBounds,
shouldDetachTabByDrag,
+ shouldDetachAtScreenPoint,
toAIChatDetachedBoundsMemory,
} from './detachedWindow';
@@ -45,6 +48,36 @@ describe('detachedWindow helpers', () => {
expect(resolveResultDetachPreferredBounds(10, 10)).toEqual({ x: 16, y: 16 });
});
+ it('keeps virtual-desktop coordinates when detaching to another display', () => {
+ expect(resolveNativeDetachPreferredBounds(-1600, 120)).toEqual({ x: -1720, y: 96 });
+ expect(resolveNativeDetachPreferredBounds(2200, 120)).toEqual({ x: 2080, y: 96 });
+ expect(resolveNativeDetachPreferredBounds(600, -500)).toEqual({ x: 480, y: -524 });
+ });
+
+ it('resolves drag release in screen coordinates instead of WebView coordinates', () => {
+ expect(resolveNativeDetachReleasePoint({
+ startScreenX: 1320,
+ startScreenY: 80,
+ deltaX: 1100,
+ deltaY: 60,
+ })).toEqual({ screenX: 2420, screenY: 140 });
+ expect(resolveNativeDetachReleasePoint({
+ startScreenX: 40,
+ startScreenY: 80,
+ deltaX: -900,
+ deltaY: -300,
+ })).toEqual({ screenX: -860, screenY: -220 });
+ });
+
+ it('detaches when the pointer leaves the host window in any screen direction', () => {
+ const host = { x: 100, y: 80, width: 1200, height: 800 };
+ expect(shouldDetachAtScreenPoint(80, 300, host)).toBe(true);
+ expect(shouldDetachAtScreenPoint(1500, 300, host)).toBe(true);
+ expect(shouldDetachAtScreenPoint(500, 40, host)).toBe(true);
+ expect(shouldDetachAtScreenPoint(500, 1000, host)).toBe(true);
+ expect(shouldDetachAtScreenPoint(600, 300, host)).toBe(false);
+ });
+
it('snapshots AI chat detached bounds for size memory', () => {
expect(
toAIChatDetachedBoundsMemory({
diff --git a/frontend/src/utils/detachedWindow.ts b/frontend/src/utils/detachedWindow.ts
index 08bdc79f..c4b5c63b 100644
--- a/frontend/src/utils/detachedWindow.ts
+++ b/frontend/src/utils/detachedWindow.ts
@@ -155,6 +155,42 @@ export const resolveResultDetachPreferredBounds = (
y: Math.max(DETACHED_WINDOW_VIEWPORT_PADDING, Math.round(clientY - 24)),
});
+export const resolveNativeDetachReleasePoint = (input: {
+ startScreenX: number;
+ startScreenY: number;
+ deltaX: number;
+ deltaY: number;
+}): { screenX: number; screenY: number } => ({
+ screenX: Math.round(Number(input.startScreenX) + Number(input.deltaX)),
+ screenY: Math.round(Number(input.startScreenY) + Number(input.deltaY)),
+});
+
+/** Native windows use virtual-desktop coordinates, which may be negative. */
+export const resolveNativeDetachPreferredBounds = (
+ screenX: number,
+ screenY: number,
+): Partial> => ({
+ x: Math.round(Number(screenX) - 120),
+ y: Math.round(Number(screenY) - 24),
+});
+
+export const shouldDetachAtScreenPoint = (
+ screenX: number,
+ screenY: number,
+ hostBounds: { x: number; y: number; width: number; height: number },
+): boolean => {
+ const x = Number(screenX);
+ const y = Number(screenY);
+ const left = Number(hostBounds.x);
+ const top = Number(hostBounds.y);
+ const width = Number(hostBounds.width);
+ const height = Number(hostBounds.height);
+ if (![x, y, left, top, width, height].every(Number.isFinite) || width <= 0 || height <= 0) {
+ return false;
+ }
+ return x < left || x > left + width || y < top || y > top + height;
+};
+
export const resolveDetachedWindowTitle = (params: {
kindLabel: string;
objectLabel?: string;
diff --git a/frontend/src/utils/nativeDetachedWindowClient.test.ts b/frontend/src/utils/nativeDetachedWindowClient.test.ts
new file mode 100644
index 00000000..1431ed3d
--- /dev/null
+++ b/frontend/src/utils/nativeDetachedWindowClient.test.ts
@@ -0,0 +1,236 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import type { TabData } from '../types';
+import type { DetachedQueryResultWindow } from './detachedWindow';
+import {
+ attachNativeDetachedWindow,
+ buildNativeDetachedQueryResultPayload,
+ buildNativeDetachedStoreSnapshot,
+ buildNativeDetachedSyncStoreSnapshot,
+ buildNativeDetachedWorkbenchPayload,
+ hydrateNativeDetachedStore,
+ isNativeDetachedWindow,
+} from './nativeDetachedWindowClient';
+
+const queryTab: TabData = {
+ id: 'query-1',
+ title: 'Query 1',
+ type: 'query',
+ connectionId: 'connection-1',
+ dbName: 'main',
+ query: 'select 1',
+};
+
+describe('nativeDetachedWindowClient', () => {
+ it('builds a workbench payload without Zustand actions or nested functions', () => {
+ const state = {
+ tabs: [queryTab],
+ theme: 'dark',
+ updateQueryTabDraft: () => undefined,
+ nested: {
+ value: 42,
+ callback: () => undefined,
+ },
+ sqlLogs: [{ id: 'log-1', sql: 'select 1' }],
+ };
+
+ const payload = buildNativeDetachedWorkbenchPayload(state, queryTab, {
+ resultSets: [],
+ activeResultKey: '',
+ isResultPanelVisible: true,
+ });
+
+ expect(payload.tab).toEqual(queryTab);
+ expect(payload.storeState).toEqual({
+ tabs: [queryTab],
+ activeTabId: queryTab.id,
+ detachedWorkbenchWindows: [],
+ detachedQueryResultWindows: [],
+ detachedAIChatWindow: null,
+ theme: 'dark',
+ nested: { value: 42 },
+ sqlLogs: [{ id: 'log-1', sql: 'select 1' }],
+ sqlEditorPendingTransactions: {},
+ });
+ expect(JSON.stringify(payload)).not.toContain('updateQueryTabDraft');
+ expect(JSON.stringify(payload)).not.toContain('callback');
+ });
+
+ it('skips heavyweight runtime state before recursively cloning a workbench snapshot', () => {
+ const state: Record = {
+ tabs: [queryTab],
+ theme: 'dark',
+ };
+ for (const key of ['aiChatHistory', 'aiContexts', 'jvmDiagnosticOutputs']) {
+ Object.defineProperty(state, key, {
+ enumerable: true,
+ get: () => {
+ throw new Error(`${key} should not be read`);
+ },
+ });
+ }
+
+ const payload = buildNativeDetachedWorkbenchPayload(state, queryTab);
+
+ expect(payload.storeState.theme).toBe('dark');
+ expect(payload.storeState).not.toHaveProperty('aiChatHistory');
+ expect(payload.storeState).not.toHaveProperty('aiContexts');
+ expect(payload.storeState).not.toHaveProperty('jvmDiagnosticOutputs');
+ });
+
+ it('hydrates snapshot data while retaining the current store actions', () => {
+ const currentAction = vi.fn();
+ let currentState = {
+ theme: 'light',
+ tabs: [] as TabData[],
+ updateQueryTabDraft: currentAction,
+ };
+ const store = {
+ getState: () => currentState,
+ setState: (nextState: typeof currentState) => {
+ currentState = nextState;
+ },
+ };
+
+ hydrateNativeDetachedStore(store, {
+ theme: 'dark',
+ tabs: [queryTab],
+ updateQueryTabDraft: 'remote action must not replace the local function',
+ unknownKey: 'ignored',
+ });
+
+ expect(currentState.theme).toBe('dark');
+ expect(currentState.tabs).toEqual([queryTab]);
+ expect(currentState.updateQueryTabDraft).toBe(currentAction);
+ expect(currentState).not.toHaveProperty('unknownKey');
+ });
+
+ it('syncs editor preferences and transaction state without resending all tabs', () => {
+ expect(buildNativeDetachedSyncStoreSnapshot({
+ tabs: [queryTab],
+ queryOptions: { showQueryResultsPanel: true },
+ sqlEditorPendingTransactions: { [queryTab.id]: { transactionId: 'tx-1' } },
+ sqlLogs: [{ sql: 'select 1' }],
+ closeTab: () => undefined,
+ }, queryTab.id)).toEqual({
+ sqlEditorPendingTransactions: { [queryTab.id]: { transactionId: 'tx-1' } },
+ });
+
+ expect(buildNativeDetachedSyncStoreSnapshot({
+ sqlEditorPendingTransactions: {},
+ }, queryTab.id, [{ id: 'log-new', sql: 'select 2' }])).toEqual({
+ sqlEditorPendingTransactions: { [queryTab.id]: null },
+ sqlLogs: [{ id: 'log-new', sql: 'select 2' }],
+ });
+ });
+
+ it('builds an isolated JSON-safe query result snapshot', () => {
+ const resultWindow: DetachedQueryResultWindow = {
+ id: 'result-1',
+ sourceQueryTabId: queryTab.id,
+ connectionId: queryTab.connectionId,
+ dbName: queryTab.dbName,
+ title: 'Result 1',
+ x: -1200,
+ y: 100,
+ width: 900,
+ height: 620,
+ zIndex: 1201,
+ result: {
+ key: 'result-set-1',
+ sql: 'select 1 as value',
+ rows: [{ value: 1, ignored: () => undefined }],
+ columns: ['value'],
+ pkColumns: [],
+ readOnly: true,
+ },
+ };
+
+ const payload = buildNativeDetachedQueryResultPayload(
+ { tabs: [queryTab], closeTab: () => undefined },
+ resultWindow,
+ );
+
+ expect(payload.resultWindow?.x).toBe(-1200);
+ expect(payload.resultWindow?.result.rows).toEqual([{ value: 1 }]);
+ expect(payload.resultWindow?.result).not.toBe(resultWindow.result);
+ expect(payload.storeState).toEqual({
+ tabs: [],
+ activeTabId: null,
+ detachedWorkbenchWindows: [],
+ detachedQueryResultWindows: [],
+ detachedAIChatWindow: null,
+ sqlLogs: [],
+ sqlEditorPendingTransactions: {},
+ });
+ });
+
+ it('posts attach actions with the detached window identity', async () => {
+ const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => (
+ new Response(null, { status: 204 })
+ ));
+
+ await attachNativeDetachedWindow(
+ { id: 'window-1', kind: 'workbench', tab: queryTab },
+ fetchMock as typeof fetch,
+ );
+
+ expect(fetchMock).toHaveBeenCalledOnce();
+ const [url, init] = fetchMock.mock.calls[0];
+ expect(url).toBe('/__gonavi/detached/action');
+ expect(init?.method).toBe('POST');
+ expect(JSON.parse(String(init?.body))).toEqual({
+ action: 'attach',
+ payload: { id: 'window-1', kind: 'workbench', tab: queryTab },
+ });
+ });
+
+ it('uses the child Go bridge for terminal actions so attach is not followed by close', async () => {
+ const action = vi.fn(async () => ({ success: true }));
+ const previousWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window');
+ Object.defineProperty(globalThis, 'window', {
+ configurable: true,
+ value: { __GONAVI_DETACHED__: { action } },
+ });
+ try {
+ await attachNativeDetachedWindow({ id: 'window-1', kind: 'workbench', tab: queryTab });
+ expect(action).toHaveBeenCalledWith('attach', {
+ id: 'window-1',
+ kind: 'workbench',
+ tab: queryTab,
+ });
+ } finally {
+ if (previousWindowDescriptor) {
+ Object.defineProperty(globalThis, 'window', previousWindowDescriptor);
+ } else {
+ Reflect.deleteProperty(globalThis, 'window');
+ }
+ }
+ });
+
+ it('detects injected flags and the detached query parameter', () => {
+ expect(isNativeDetachedWindow({ pathname: '/', search: '?__gonavi_detached=window-1' })).toBe(true);
+ expect(isNativeDetachedWindow({ pathname: '/', search: '' })).toBe(false);
+
+ const previousWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window');
+ Object.defineProperty(globalThis, 'window', {
+ configurable: true,
+ value: { __GONAVI_NATIVE_DETACHED__: true },
+ });
+ try {
+ expect(isNativeDetachedWindow({ pathname: '/', search: '' })).toBe(true);
+ } finally {
+ if (previousWindowDescriptor) {
+ Object.defineProperty(globalThis, 'window', previousWindowDescriptor);
+ } else {
+ Reflect.deleteProperty(globalThis, 'window');
+ }
+ }
+ });
+
+ it('omits circular values instead of breaking bootstrap serialization', () => {
+ const state: Record = { theme: 'dark' };
+ state.self = state;
+ expect(buildNativeDetachedStoreSnapshot(state)).toEqual({ theme: 'dark' });
+ });
+});
diff --git a/frontend/src/utils/nativeDetachedWindowClient.ts b/frontend/src/utils/nativeDetachedWindowClient.ts
new file mode 100644
index 00000000..ddc91aab
--- /dev/null
+++ b/frontend/src/utils/nativeDetachedWindowClient.ts
@@ -0,0 +1,376 @@
+import type { TabData } from '../types';
+import type {
+ DetachedQueryResultWindow,
+ DetachedQueryResultSnapshot,
+} from './detachedWindow';
+import type { QueryEditorResultSessionSnapshot } from './queryEditorResultSessionCache';
+
+export const NATIVE_DETACHED_BOOTSTRAP_URL = '/__gonavi/detached/bootstrap';
+export const NATIVE_DETACHED_ACTION_URL = '/__gonavi/detached/action';
+export const NATIVE_DETACHED_WINDOW_QUERY_PARAM = '__gonavi_detached';
+
+export type NativeDetachedWindowKind = 'workbench' | 'query-result';
+export type NativeDetachedWindowAction = 'ready' | 'sync' | 'attach' | 'close';
+export type NativeDetachedStoreSnapshot = Record;
+
+export interface NativeDetachedWindowPayload {
+ storeState: NativeDetachedStoreSnapshot;
+ tab?: TabData;
+ resultWindow?: DetachedQueryResultWindow;
+ resultSession?: QueryEditorResultSessionSnapshot | null;
+}
+
+export interface NativeDetachedWindowBootstrap {
+ id: string;
+ kind: NativeDetachedWindowKind;
+ title: string;
+ payload: NativeDetachedWindowPayload;
+}
+
+export interface NativeDetachedWindowActionPayload {
+ id: string;
+ kind: NativeDetachedWindowKind;
+ storeState?: NativeDetachedStoreSnapshot;
+ tab?: TabData;
+ resultSession?: QueryEditorResultSessionSnapshot | null;
+}
+
+export const buildNativeDetachedSyncStoreSnapshot = (
+ state: object,
+ tabId: string,
+ newSqlLogs: unknown[] = [],
+): NativeDetachedStoreSnapshot => {
+ const record = state as Record;
+ const pending = record.sqlEditorPendingTransactions;
+ const pendingRecord = pending && typeof pending === 'object'
+ ? pending as Record
+ : {};
+ return buildNativeDetachedStoreSnapshot({
+ ...(tabId
+ ? {
+ sqlEditorPendingTransactions: {
+ [tabId]: Object.prototype.hasOwnProperty.call(pendingRecord, tabId)
+ ? pendingRecord[tabId]
+ : null,
+ },
+ }
+ : {}),
+ ...(newSqlLogs.length > 0 ? { sqlLogs: newSqlLogs } : {}),
+ });
+};
+
+export interface NativeDetachedWindowActionRequest {
+ action: NativeDetachedWindowAction;
+ payload: NativeDetachedWindowActionPayload;
+}
+
+type FetchLike = typeof fetch;
+
+type StoreApiLike = {
+ getState: () => TState;
+ setState: (nextState: TState, replace?: boolean) => void;
+};
+
+const OMIT_VALUE = Symbol('gonavi.native-detached.omit');
+const UNSAFE_OBJECT_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
+const WORKBENCH_BOOTSTRAP_OMITTED_KEYS = new Set([
+ 'aiChatHistory',
+ 'aiChatSessions',
+ 'aiContexts',
+ 'jvmDiagnosticOutputs',
+ 'tabs',
+ 'detachedWorkbenchWindows',
+ 'detachedQueryResultWindows',
+ 'detachedAIChatWindow',
+ 'sqlEditorPendingTransactions',
+]);
+const QUERY_RESULT_BOOTSTRAP_OMITTED_KEYS = new Set([
+ ...WORKBENCH_BOOTSTRAP_OMITTED_KEYS,
+ 'sqlLogs',
+]);
+
+const cloneSerializableValue = (
+ value: unknown,
+ ancestors: WeakSet
") {
+ return strings.Replace(indexHTML, "", scriptTag+"\n", 1)
+ }
+ return injectScript(indexHTML, scriptPath)
+}
+
func (s *Server) handleInvoke(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
diff --git a/internal/webserver/server_test.go b/internal/webserver/server_test.go
index 0cc81301..cf6bc9e3 100644
--- a/internal/webserver/server_test.go
+++ b/internal/webserver/server_test.go
@@ -2,9 +2,16 @@ package webserver
import (
"encoding/json"
+ "io/fs"
+ "net/http"
+ "net/http/httptest"
"reflect"
"strings"
"testing"
+ "testing/fstest"
+
+ aiservice "GoNavi-Wails/internal/ai/service"
+ appcore "GoNavi-Wails/internal/app"
)
type webserverTestReceiver struct{}
@@ -17,6 +24,10 @@ func (webserverTestReceiver) Sum(left int, right int) int {
return left + right
}
+func (webserverTestReceiver) OpenSQLFile() string {
+ return "desktop-method-reached"
+}
+
func TestInjectRuntimeBridgeAddsScriptOnce(t *testing.T) {
indexHTML := "