feat(native-window): 支持标签页拖出为原生独立窗口

- 支持 SQL、数据和结果标签拖出到跨显示器原生窗口
- 通过受认证 loopback bridge 复用主进程后端与状态
- 支持子窗口继续拆窗、聚焦、关闭、还原与异常退出恢复
- 补充多窗口协议、状态同步和跨平台回归测试
This commit is contained in:
Syngnat
2026-07-16 20:25:08 +08:00
parent d3dee295d6
commit fa9d21be8b
33 changed files with 5036 additions and 33 deletions

View File

@@ -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() {
<TabManager />
<FloatingWorkbenchWindows />
<FloatingQueryResultWindows />
<NativeDetachedWindowController />
</div>
{!isV2Ui && !aiPanelVisible && (
<>

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<string, any>;
const storeListeners = new Set<() => void>();
vi.mock('../store', () => {
const useStore = Object.assign(
(selector: (state: Record<string, any>) => unknown) => selector(storeState),
{
getState: () => storeState,
setState: (nextState: Record<string, any> | ((state: Record<string, any>) => Record<string, any>)) => {
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<HTMLButtonElement> & { icon?: React.ReactNode }) => (
<button {...props}>{icon}</button>
),
ConfigProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
Spin: () => <span data-component="spin" />,
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
theme: {
darkAlgorithm: 'dark',
defaultAlgorithm: 'light',
},
}));
vi.mock('@ant-design/icons', () => ({
CloseOutlined: () => <span data-icon="close" />,
CompressOutlined: () => <span data-icon="attach" />,
}));
vi.mock('./WorkbenchTabContent', () => ({
default: ({ tab }: { tab: TabData }) => <div data-workbench-tab={tab.id} />,
}));
vi.mock('./DataGrid', () => ({
default: () => <div data-component="data-grid" />,
}));
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(<NativeDetachedWindowApp client={client} />);
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();
});
});

View File

@@ -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<NativeDetachedWindowBootstrap>;
ready: (payload: NativeDetachedWindowActionPayload) => Promise<void>;
sync: (payload: NativeDetachedWindowActionPayload) => Promise<void>;
attach: (payload: NativeDetachedWindowActionPayload) => Promise<void>;
close: (payload: NativeDetachedWindowActionPayload) => Promise<void>;
closeCurrentWindow: () => Promise<void>;
};
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 (
<textarea
className="gn-native-detached-message"
readOnly
value={messageText}
/>
);
}
return (
<DataGrid
data={result.rows || []}
columnNames={result.columns || []}
loading={false}
tableName={result.metadataTableName || result.tableName}
pkColumns={result.pkColumns || []}
editLocator={result.editLocator as any}
readOnly={result.readOnly !== false}
connectionId={windowState.connectionId}
dbName={result.metadataDbName || windowState.dbName || ''}
resultSql={result.exportSql || result.sql}
exportScope="queryResult"
showRowNumberColumn={result.showRowNumberColumn}
isActive
/>
);
};
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 ? <WorkbenchTabContent tab={tab} isActive /> : null;
}
return bootstrap.payload.resultWindow
? <NativeDetachedQueryResult windowState={bootstrap.payload.resultWindow} />
: null;
};
const NativeDetachedWindowApp: React.FC<NativeDetachedWindowAppProps> = ({
client = defaultClient,
}) => {
const i18n = useOptionalI18n();
const translate = i18n?.t ?? defaultTranslate;
const [bootstrap, setBootstrap] = useState<NativeDetachedWindowBootstrap | null>(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<QueryEditorResultSessionSnapshot | null>(null);
const syncedSqlLogIdsRef = useRef<Set<string>>(new Set());
const syncTimerRef = useRef<number | null>(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 (
<ConfigProvider
locale={getAntdLocale(i18n?.language ?? 'en-US')}
componentSize={componentSize}
theme={{
algorithm: isDark ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
token: {
fontSize: Math.max(10, Number(fontSize) || 14),
colorPrimary: uiVersion === 'v2'
? (isDark ? '#22c55e' : '#16a34a')
: (isDark ? '#f6c453' : '#1677ff'),
},
}}
>
{bootstrap ? (
<NativeDetachedWindowController currentWindowId={bootstrap.id} />
) : null}
<div
className="gn-native-detached-window"
data-kind={bootstrap?.kind || 'loading'}
>
<style>{`
.gn-native-detached-window {
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
color: ${isDark ? '#f3f4f6' : '#111827'};
background: ${isDark ? 'var(--gn-bg-app, #0c0e12)' : 'var(--gn-bg-app, #f6f6f4)'};
}
.gn-native-detached-chrome {
flex: 0 0 36px;
min-width: 0;
display: flex;
align-items: center;
gap: 4px;
padding: 0 6px 0 12px;
border-bottom: 1px solid ${isDark ? 'rgba(255,255,255,0.10)' : 'rgba(15,23,42,0.10)'};
background: ${isDark ? 'var(--gn-bg-chrome, #14171c)' : 'var(--gn-bg-chrome, #ececea)'};
user-select: none;
--wails-draggable: drag;
}
.gn-native-detached-title {
min-width: 0;
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
font-weight: 600;
}
.gn-native-detached-actions {
flex: 0 0 auto;
display: inline-flex;
gap: 2px;
--wails-draggable: no-drag;
}
.gn-native-detached-body {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
background: ${isDark ? 'var(--gn-bg-panel, #161a21)' : 'var(--gn-bg-panel, #ffffff)'};
}
.gn-native-detached-loading,
.gn-native-detached-error {
flex: 1 1 auto;
display: grid;
place-items: center;
min-width: 0;
min-height: 0;
padding: 24px;
}
.gn-native-detached-error {
color: ${isDark ? '#fca5a5' : '#b91c1c'};
overflow: auto;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.gn-native-detached-message {
flex: 1 1 auto;
width: 100%;
min-width: 0;
min-height: 0;
box-sizing: border-box;
margin: 0;
padding: 12px;
border: 0;
resize: none;
outline: none;
color: inherit;
background: transparent;
font-family: var(--gn-font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace);
font-size: 12px;
line-height: 1.5;
}
`}</style>
<div className="gn-native-detached-chrome">
<div className="gn-native-detached-title" title={bootstrap?.title || ''}>
{bootstrap?.title || ''}
</div>
<div className="gn-native-detached-actions">
<Tooltip title={chromeLabels.attach}>
<Button
type="text"
size="small"
icon={<CompressOutlined />}
aria-label={chromeLabels.attach}
disabled={!bootstrap || Boolean(terminalAction)}
onClick={() => requestTerminalAction('attach')}
/>
</Tooltip>
<Tooltip title={chromeLabels.close}>
<Button
type="text"
size="small"
icon={<CloseOutlined />}
aria-label={chromeLabels.close}
disabled={!bootstrap || Boolean(terminalAction)}
onClick={() => requestTerminalAction('close')}
/>
</Tooltip>
</div>
</div>
<div className="gn-native-detached-body">
{loadError ? (
<div className="gn-native-detached-error" role="alert">{loadError}</div>
) : !bootstrap ? (
<div className="gn-native-detached-loading"><Spin /></div>
) : contentMounted ? (
<NativeDetachedWindowContent bootstrap={bootstrap} />
) : null}
</div>
</div>
</ConfigProvider>
);
};
export default NativeDetachedWindowApp;

View File

@@ -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');
}
}
});
});

View File

@@ -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<string, unknown>;
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<string, unknown>): 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<string, unknown>,
): void => {
const pendingPatch = snapshot.sqlEditorPendingTransactions;
if (!pendingPatch || typeof pendingPatch !== 'object') return;
const value = (pendingPatch as Record<string, unknown>)[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<string> => {
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;

View File

@@ -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(() => {

View File

@@ -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<QueryEditorResultsPanelProps> = ({
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<QueryEditorResultsPanelProps> = ({
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<QueryEditorResultsPanelProps> = ({
};
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<QueryEditorResultsPanelProps> = ({
(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<QueryEditorResultsPanelProps> = ({
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<QueryEditorResultsPanelProps> = ({
// 先清预览再打开真实窗口,避免叠两层
clearListeners();
if (shouldDetach) {
onOpenResultInWindow(key, resolveResultDetachPreferredBounds(upEvent.clientX, upEvent.clientY));
onOpenResultInWindow(key, resolveNativeDetachPreferredBounds(releaseScreenX, releaseScreenY));
}
};

View File

@@ -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<DraggableTabNodeProps> = ({ node }) => {
touchAction: 'none',
zIndex: isDragging ? 2 : node.props.style?.zIndex,
};
const handlePointerDown = listeners?.onPointerDown as React.PointerEventHandler<HTMLElement> | undefined;
return React.cloneElement(node, {
ref: setNodeRef,
style,
...attributes,
...listeners,
onPointerDown: (event: React.PointerEvent<HTMLElement>) => {
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<number>(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: <WorkbenchTabContent tab={tab} isActive={tabIsActive} />,
};
}), [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),

View File

@@ -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 = <App />;
let rootComponent: React.ReactNode = isNativeDetachedWindow()
? <NativeDetachedWindowApp />
: <App />;
if (devHarnessMode === 'datagrid-perf') {
const { default: PerfDataGridHarness } = await import('./dev/PerfDataGridHarness');
rootComponent = <PerfDataGridHarness />;

View File

@@ -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({

View File

@@ -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<Pick<DetachedWindowBounds, 'x' | 'y'>> => ({
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;

View File

@@ -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<string, unknown> = {
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<string, unknown> = { theme: 'dark' };
state.self = state;
expect(buildNativeDetachedStoreSnapshot(state)).toEqual({ theme: 'dark' });
});
});

View File

@@ -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<string, unknown>;
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<string, unknown>;
const pending = record.sqlEditorPendingTransactions;
const pendingRecord = pending && typeof pending === 'object'
? pending as Record<string, unknown>
: {};
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<TState extends object> = {
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<object>,
): unknown | typeof OMIT_VALUE => {
if (value === undefined || typeof value === 'function' || typeof value === 'symbol') {
return OMIT_VALUE;
}
if (value === null || typeof value === 'string' || typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
return Number.isFinite(value) ? value : null;
}
if (typeof value === 'bigint') {
return String(value);
}
if (value instanceof Date) {
return value.toISOString();
}
if (typeof value !== 'object') {
return OMIT_VALUE;
}
if (ancestors.has(value)) {
return OMIT_VALUE;
}
ancestors.add(value);
try {
if (Array.isArray(value)) {
const result: unknown[] = [];
for (const item of value) {
const cloned = cloneSerializableValue(item, ancestors);
if (cloned !== OMIT_VALUE) {
result.push(cloned);
}
}
return result;
}
const result: Record<string, unknown> = {};
for (const [key, item] of Object.entries(value)) {
if (UNSAFE_OBJECT_KEYS.has(key)) continue;
const cloned = cloneSerializableValue(item, ancestors);
if (cloned !== OMIT_VALUE) {
result[key] = cloned;
}
}
return result;
} finally {
ancestors.delete(value);
}
};
/** Build the JSON-safe, data-only part of a Zustand state object. */
export const buildNativeDetachedStoreSnapshot = (
state: object,
): NativeDetachedStoreSnapshot => {
const cloned = cloneSerializableValue(state, new WeakSet());
return cloned && cloned !== OMIT_VALUE && !Array.isArray(cloned)
? cloned as NativeDetachedStoreSnapshot
: {};
};
const buildFilteredStoreSnapshot = (
state: object,
omittedKeys: ReadonlySet<string>,
): NativeDetachedStoreSnapshot => {
const source = state as Record<string, unknown>;
const filtered: Record<string, unknown> = {};
for (const key of Object.keys(source)) {
if (omittedKeys.has(key)) continue;
filtered[key] = source[key];
}
return buildNativeDetachedStoreSnapshot(filtered);
};
export const buildNativeDetachedWorkbenchPayload = (
state: object,
tab: TabData,
resultSession?: QueryEditorResultSessionSnapshot | null,
): NativeDetachedWindowPayload => {
const storeState = buildFilteredStoreSnapshot(state, WORKBENCH_BOOTSTRAP_OMITTED_KEYS);
const source = state as Record<string, unknown>;
const allPending = source.sqlEditorPendingTransactions;
const pendingRecord = allPending && typeof allPending === 'object'
? allPending as Record<string, unknown>
: {};
storeState.tabs = [tab];
storeState.activeTabId = tab.id;
storeState.detachedWorkbenchWindows = [];
storeState.detachedQueryResultWindows = [];
storeState.detachedAIChatWindow = null;
storeState.sqlEditorPendingTransactions = buildNativeDetachedStoreSnapshot(
Object.prototype.hasOwnProperty.call(pendingRecord, tab.id)
? { [tab.id]: pendingRecord[tab.id] }
: {},
);
return {
storeState,
tab,
resultSession: resultSession ?? null,
};
};
export const buildNativeDetachedQueryResultPayload = (
state: object,
resultWindow: DetachedQueryResultWindow,
): NativeDetachedWindowPayload => {
const storeState = buildFilteredStoreSnapshot(state, QUERY_RESULT_BOOTSTRAP_OMITTED_KEYS);
storeState.tabs = [];
storeState.activeTabId = null;
storeState.detachedWorkbenchWindows = [];
storeState.detachedQueryResultWindows = [];
storeState.detachedAIChatWindow = null;
storeState.sqlLogs = [];
storeState.sqlEditorPendingTransactions = {};
return {
storeState,
resultWindow: {
...resultWindow,
result: buildNativeDetachedQueryResultSnapshot(resultWindow.result),
},
};
};
export const buildNativeDetachedQueryResultSnapshot = (
result: DetachedQueryResultSnapshot,
): DetachedQueryResultSnapshot => {
const cloned = cloneSerializableValue(result, new WeakSet());
return cloned && cloned !== OMIT_VALUE && !Array.isArray(cloned)
? cloned as DetachedQueryResultSnapshot
: {
key: '',
sql: '',
rows: [],
columns: [],
pkColumns: [],
readOnly: true,
};
};
/** Merge bootstrap state without replacing any action currently installed by Zustand. */
export const mergeNativeDetachedStoreState = <TState extends object>(
currentState: TState,
snapshot: NativeDetachedStoreSnapshot,
): TState => {
const nextState = { ...currentState } as Record<string, unknown>;
const currentRecord = currentState as Record<string, unknown>;
const safeSnapshot = buildNativeDetachedStoreSnapshot(snapshot);
for (const [key, value] of Object.entries(safeSnapshot)) {
if (UNSAFE_OBJECT_KEYS.has(key)) continue;
if (!Object.prototype.hasOwnProperty.call(currentRecord, key)) continue;
if (typeof currentRecord[key] === 'function') continue;
nextState[key] = value;
}
return nextState as TState;
};
export const hydrateNativeDetachedStore = <TState extends object>(
store: StoreApiLike<TState>,
snapshot: NativeDetachedStoreSnapshot,
): TState => {
const nextState = mergeNativeDetachedStoreState(store.getState(), snapshot);
store.setState(nextState, true);
return nextState;
};
export const isNativeDetachedWindow = (
locationLike?: Pick<Location, 'pathname' | 'search'>,
): boolean => {
if (typeof window !== 'undefined') {
const runtimeWindow = window as typeof window & {
__GONAVI_NATIVE_DETACHED__?: unknown;
__GONAVI_DETACHED__?: unknown;
};
if (runtimeWindow.__GONAVI_NATIVE_DETACHED__ || runtimeWindow.__GONAVI_DETACHED__) {
return true;
}
}
const locationValue = locationLike
?? (typeof window !== 'undefined' ? window.location : undefined);
if (!locationValue) return false;
if (locationValue.pathname.startsWith('/__gonavi/detached/window')) return true;
const params = new URLSearchParams(locationValue.search);
const value = params.get(NATIVE_DETACHED_WINDOW_QUERY_PARAM);
return value !== null && value !== '' && value !== '0' && value !== 'false';
};
const requireSuccessfulResponse = async (response: Response): Promise<Response> => {
if (response.ok) return response;
const body = await response.text().catch(() => '');
throw new Error(
`Native detached window request failed (${response.status})${body ? `: ${body}` : ''}`,
);
};
export const fetchNativeDetachedWindowBootstrap = async (
fetchImpl: FetchLike = fetch,
): Promise<NativeDetachedWindowBootstrap> => {
const response = await requireSuccessfulResponse(await fetchImpl(
NATIVE_DETACHED_BOOTSTRAP_URL,
{
method: 'GET',
credentials: 'same-origin',
headers: { Accept: 'application/json' },
},
));
const bootstrap = await response.json() as NativeDetachedWindowBootstrap;
if (!bootstrap || typeof bootstrap.id !== 'string' || !bootstrap.id.trim()) {
throw new Error('Native detached window bootstrap is missing an id');
}
if (bootstrap.kind !== 'workbench' && bootstrap.kind !== 'query-result') {
throw new Error('Native detached window bootstrap has an invalid kind');
}
if (
!bootstrap.payload
|| !bootstrap.payload.storeState
|| typeof bootstrap.payload.storeState !== 'object'
|| Array.isArray(bootstrap.payload.storeState)
) {
throw new Error('Native detached window bootstrap is missing storeState');
}
return bootstrap;
};
export const postNativeDetachedWindowAction = async (
action: NativeDetachedWindowAction,
payload: NativeDetachedWindowActionPayload,
fetchImpl?: FetchLike,
): Promise<void> => {
const nativeAction = !fetchImpl && typeof window !== 'undefined'
? (window as any).__GONAVI_DETACHED__?.action
: undefined;
if (typeof nativeAction === 'function') {
const result = await nativeAction(action, payload);
if (result?.success === false) {
throw new Error(String(result.message || `Native detached ${action} failed`));
}
return;
}
const request = fetchImpl ?? fetch;
await requireSuccessfulResponse(await request(NATIVE_DETACHED_ACTION_URL, {
method: 'POST',
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({ action, payload } satisfies NativeDetachedWindowActionRequest),
}));
};
export const syncNativeDetachedWindow = (
payload: NativeDetachedWindowActionPayload,
fetchImpl?: FetchLike,
): Promise<void> => postNativeDetachedWindowAction('sync', payload, fetchImpl);
export const readyNativeDetachedWindow = (
payload: NativeDetachedWindowActionPayload,
fetchImpl?: FetchLike,
): Promise<void> => postNativeDetachedWindowAction('ready', payload, fetchImpl);
export const attachNativeDetachedWindow = (
payload: NativeDetachedWindowActionPayload,
fetchImpl?: FetchLike,
): Promise<void> => postNativeDetachedWindowAction('attach', payload, fetchImpl);
export const closeNativeDetachedWindow = (
payload: NativeDetachedWindowActionPayload,
fetchImpl?: FetchLike,
): Promise<void> => postNativeDetachedWindowAction('close', payload, fetchImpl);
export const closeCurrentNativeDetachedWindow = async (): Promise<void> => {
const nativeClose = typeof window !== 'undefined'
? (window as any).go?.nativewindow?.Control?.Close
: undefined;
if (typeof nativeClose === 'function') {
await nativeClose();
return;
}
if (typeof window !== 'undefined' && typeof window.close === 'function') {
window.close();
}
};

View File

@@ -0,0 +1,132 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useStore } from '../store';
import {
openNativeQueryResultWindow,
openNativeWorkbenchTabWindow,
type NativeDetachedWindowManager,
} from './nativeDetachedWindowHost';
const buildTab = (id: string) => ({
id,
title: `Query ${id}`,
type: 'query' as const,
connectionId: 'conn-1',
dbName: 'main',
query: 'select 1',
});
describe('nativeDetachedWindowHost', () => {
let manager: NativeDetachedWindowManager;
beforeEach(() => {
manager = {
Open: vi.fn().mockResolvedValue({ success: true }),
Focus: vi.fn().mockResolvedValue({ success: true }),
Close: vi.fn().mockResolvedValue({ success: true }),
CloseAll: vi.fn().mockResolvedValue({ success: true }),
};
useStore.setState({
tabs: [buildTab('query-1')],
activeTabId: 'query-1',
detachedWorkbenchWindows: [],
detachedQueryResultWindows: [],
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it('hides the docked tab only after the native window opens', async () => {
let resolveOpen: ((value: { success: boolean }) => void) | undefined;
const pending = new Promise<{ success: boolean }>((resolve) => {
resolveOpen = resolve;
});
vi.mocked(manager.Open).mockReturnValueOnce(pending);
const opening = openNativeWorkbenchTabWindow('query-1', { x: -1600, y: 120 }, manager);
expect(useStore.getState().isWorkbenchTabDetached('query-1')).toBe(false);
resolveOpen?.({ success: true });
await expect(opening).resolves.toBe(true);
expect(useStore.getState().isWorkbenchTabDetached('query-1')).toBe(true);
expect(manager.Open).toHaveBeenCalledWith(expect.objectContaining({
id: 'workbench:query-1',
kind: 'workbench',
x: -1600,
y: 120,
}));
});
it('keeps the tab docked when native window creation fails', async () => {
vi.mocked(manager.Open).mockResolvedValueOnce({ success: false, message: 'open failed' });
await expect(
openNativeWorkbenchTabWindow('query-1', { x: 2000, y: -300 }, manager),
).rejects.toThrow('open failed');
expect(useStore.getState().isWorkbenchTabDetached('query-1')).toBe(false);
});
it('focuses an existing window instead of opening a duplicate', async () => {
await openNativeWorkbenchTabWindow('query-1', undefined, manager);
await openNativeWorkbenchTabWindow('query-1', undefined, manager);
expect(manager.Open).toHaveBeenCalledTimes(1);
expect(manager.Focus).toHaveBeenCalledTimes(2);
expect(manager.Focus).toHaveBeenCalledWith('workbench:query-1');
});
it('restores the source tab when a just-ready child exits before detach commits', async () => {
vi.mocked(manager.Focus).mockResolvedValueOnce({
success: false,
message: 'native window was not found',
});
await expect(openNativeWorkbenchTabWindow('query-1', undefined, manager))
.rejects.toThrow('native window was not found');
expect(useStore.getState().isWorkbenchTabDetached('query-1')).toBe(false);
expect(useStore.getState().tabs.map((tab) => tab.id)).toContain('query-1');
});
it('opens many tabs with unique native window ids without a hard cap', async () => {
const tabs = Array.from({ length: 32 }, (_, index) => buildTab(`query-${index + 1}`));
useStore.setState({ tabs, detachedWorkbenchWindows: [] });
await Promise.all(tabs.map((tab) => openNativeWorkbenchTabWindow(tab.id, undefined, manager)));
const ids = vi.mocked(manager.Open).mock.calls.map(([request]) => request.id);
expect(ids).toHaveLength(32);
expect(new Set(ids).size).toBe(32);
expect(useStore.getState().detachedWorkbenchWindows).toHaveLength(32);
});
it('opens a query result snapshot as a native window on another display', async () => {
await expect(openNativeQueryResultWindow({
id: 'query-result:query-1:r1',
sourceQueryTabId: 'query-1',
connectionId: 'conn-1',
dbName: 'main',
title: 'Result 1',
x: 2100,
y: -240,
result: {
key: 'r1',
sql: 'select 42',
rows: [{ value: 42 }],
columns: ['value'],
pkColumns: [],
readOnly: true,
},
}, manager)).resolves.toBe(true);
expect(manager.Open).toHaveBeenCalledWith(expect.objectContaining({
id: 'query-result:query-1:r1',
kind: 'query-result',
x: 2100,
y: -240,
}));
expect(useStore.getState().detachedQueryResultWindows).toHaveLength(1);
});
});

View File

@@ -0,0 +1,238 @@
import { useStore } from '../store';
import type { TabData } from '../types';
import {
DEFAULT_DETACHED_WINDOW_HEIGHT,
DEFAULT_DETACHED_WINDOW_MIN_HEIGHT,
DEFAULT_DETACHED_WINDOW_MIN_WIDTH,
DEFAULT_DETACHED_WINDOW_WIDTH,
type DetachedQueryResultWindow,
type DetachedWindowBounds,
} from './detachedWindow';
import {
buildNativeDetachedQueryResultPayload,
buildNativeDetachedWorkbenchPayload,
type NativeDetachedWindowKind,
type NativeDetachedWindowPayload,
} from './nativeDetachedWindowClient';
import { peekQueryEditorResultSession } from './queryEditorResultSessionCache';
export type NativeDetachedWindowOperationResult = {
success: boolean;
message?: string;
id?: string;
};
export type NativeDetachedWindowOpenRequest = {
id: string;
kind: NativeDetachedWindowKind;
title: string;
x: number;
y: number;
width: number;
height: number;
payload: NativeDetachedWindowPayload;
};
export type NativeDetachedWindowManager = {
Open: (request: NativeDetachedWindowOpenRequest) => Promise<NativeDetachedWindowOperationResult>;
Focus: (id: string) => Promise<NativeDetachedWindowOperationResult>;
Close: (id: string) => Promise<NativeDetachedWindowOperationResult>;
CloseAll: () => Promise<NativeDetachedWindowOperationResult>;
};
export type NativeQueryResultWindowInput = Omit<
DetachedQueryResultWindow,
keyof DetachedWindowBounds
> & Partial<DetachedWindowBounds>;
const openingWindows = new Map<string, Promise<boolean>>();
export const resolveNativeDetachedWindowManager = (): NativeDetachedWindowManager | null => {
if (typeof window === 'undefined') return null;
const manager = (window as any).go?.nativewindow?.Manager;
if (
typeof manager?.Open !== 'function'
|| typeof manager?.Focus !== 'function'
|| typeof manager?.Close !== 'function'
) {
return null;
}
return manager as NativeDetachedWindowManager;
};
export const hasNativeDetachedWindowManager = (): boolean =>
resolveNativeDetachedWindowManager() !== null;
const getNativeWindowBounds = (
preferred?: Partial<Pick<DetachedWindowBounds, 'x' | 'y' | 'width' | 'height'>>,
): Pick<DetachedWindowBounds, 'x' | 'y' | 'width' | 'height'> => {
const viewportWidth = typeof window === 'undefined' ? DEFAULT_DETACHED_WINDOW_WIDTH : window.innerWidth;
const viewportHeight = typeof window === 'undefined' ? DEFAULT_DETACHED_WINDOW_HEIGHT : window.innerHeight;
const width = Math.max(
DEFAULT_DETACHED_WINDOW_MIN_WIDTH,
Math.round(Number(preferred?.width) || Math.min(DEFAULT_DETACHED_WINDOW_WIDTH, viewportWidth * 0.9)),
);
const height = Math.max(
DEFAULT_DETACHED_WINDOW_MIN_HEIGHT,
Math.round(Number(preferred?.height) || Math.min(DEFAULT_DETACHED_WINDOW_HEIGHT, viewportHeight * 0.86)),
);
const baseX = typeof window === 'undefined' ? 80 : window.screenX + Math.max(32, Math.round((window.innerWidth - width) / 2));
const baseY = typeof window === 'undefined' ? 80 : window.screenY + Math.max(32, Math.round((window.innerHeight - height) / 2));
return {
x: Number.isFinite(Number(preferred?.x)) ? Math.round(Number(preferred?.x)) : baseX,
y: Number.isFinite(Number(preferred?.y)) ? Math.round(Number(preferred?.y)) : baseY,
width,
height,
};
};
const assertOpened = (result: NativeDetachedWindowOperationResult | undefined): void => {
if (result?.success) return;
throw new Error(String(result?.message || 'Failed to open native detached window'));
};
const focusExistingWindow = async (
manager: NativeDetachedWindowManager,
id: string,
): Promise<boolean> => {
const result = await manager.Focus(id);
if (!result?.success) {
throw new Error(String(result?.message || 'Failed to focus native detached window'));
}
return true;
};
const openOnce = (
manager: NativeDetachedWindowManager,
request: NativeDetachedWindowOpenRequest,
afterOpen: () => boolean,
rollbackAfterExit: () => void,
): Promise<boolean> => {
const existing = openingWindows.get(request.id);
if (existing) {
return existing.then(async (opened) => {
if (opened) await focusExistingWindow(manager, request.id);
return opened;
});
}
const opening = (async () => {
const result = await manager.Open(request);
assertOpened(result);
const opened = afterOpen();
if (!opened) return false;
// A child can acknowledge ready and exit before the Wails Open promise is
// delivered to JavaScript. Verify it after committing detached state so
// either this rollback or the process-exit event restores the source.
try {
await focusExistingWindow(manager, request.id);
} catch (error) {
rollbackAfterExit();
throw error;
}
return true;
})();
openingWindows.set(request.id, opening);
void opening.finally(() => {
if (openingWindows.get(request.id) === opening) {
openingWindows.delete(request.id);
}
}).catch(() => undefined);
return opening;
};
const resolveWorkbenchTitle = (tab: TabData): string =>
String(tab.title || tab.tableName || tab.viewName || tab.id).trim() || tab.id;
export const openNativeWorkbenchTabWindow = async (
tabId: string,
preferred?: Partial<Pick<DetachedWindowBounds, 'x' | 'y' | 'width' | 'height'>>,
managerOverride?: NativeDetachedWindowManager,
): Promise<boolean> => {
const id = String(tabId || '').trim();
const state = useStore.getState();
const tab = state.tabs.find((item) => item.id === id);
if (!tab) return false;
const manager = managerOverride ?? resolveNativeDetachedWindowManager();
if (!manager) {
state.detachWorkbenchTab(id, preferred);
return true;
}
const windowId = `workbench:${id}`;
if (state.isWorkbenchTabDetached(id)) {
return focusExistingWindow(manager, windowId);
}
const bounds = getNativeWindowBounds(preferred);
if (tab.type === 'query' && typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('gonavi:capture-query-result-session', {
detail: { tabId: tab.id },
}));
}
const request: NativeDetachedWindowOpenRequest = {
id: windowId,
kind: 'workbench',
title: resolveWorkbenchTitle(tab),
...bounds,
payload: buildNativeDetachedWorkbenchPayload(
state,
tab,
tab.type === 'query' ? peekQueryEditorResultSession(tab.id) : null,
),
};
return openOnce(manager, request, () => {
const latest = useStore.getState();
if (!latest.tabs.some((item) => item.id === id)) {
void manager.Close(windowId);
return false;
}
latest.detachWorkbenchTab(id, preferred ?? bounds);
return true;
}, () => {
useStore.getState().attachWorkbenchTab(id);
});
};
export const openNativeQueryResultWindow = async (
windowState: NativeQueryResultWindowInput,
managerOverride?: NativeDetachedWindowManager,
): Promise<boolean> => {
const id = String(windowState.id || '').trim();
if (!id) return false;
const state = useStore.getState();
const manager = managerOverride ?? resolveNativeDetachedWindowManager();
if (!manager) {
state.detachQueryResultWindow(windowState);
return true;
}
if (state.detachedQueryResultWindows.some((item) => item.id === id)) {
return focusExistingWindow(manager, id);
}
const bounds = getNativeWindowBounds(windowState);
return openOnce(manager, {
id,
kind: 'query-result',
title: windowState.title,
...bounds,
payload: buildNativeDetachedQueryResultPayload(state, {
...windowState,
...bounds,
zIndex: Number(windowState.zIndex) || 1201,
}),
}, () => {
useStore.getState().detachQueryResultWindow({ ...windowState, ...bounds });
return true;
}, () => {
useStore.getState().closeDetachedQueryResultWindow(id);
});
};
export const closeNativeDetachedWindowById = async (id: string): Promise<void> => {
const manager = resolveNativeDetachedWindowManager();
if (!manager) return;
const result = await manager.Close(String(id || '').trim());
if (!result?.success && result?.message) {
throw new Error(result.message);
}
};

View File

@@ -1,12 +1,20 @@
import type { QueryEditorResultSet } from '../components/QueryEditorResultsPanel';
type QueryEditorResultSessionSnapshot = {
export type QueryEditorResultSessionSnapshot = {
resultSets: QueryEditorResultSet[];
activeResultKey: string;
isResultPanelVisible?: boolean;
};
const cache = new Map<string, QueryEditorResultSessionSnapshot>();
const listeners = new Map<string, Set<(snapshot: QueryEditorResultSessionSnapshot | null) => void>>();
const notifyQueryEditorResultSession = (
tabId: string,
snapshot: QueryEditorResultSessionSnapshot | null,
): void => {
listeners.get(tabId)?.forEach((listener) => listener(snapshot));
};
export const saveQueryEditorResultSession = (
tabId: string,
@@ -14,11 +22,13 @@ export const saveQueryEditorResultSession = (
): void => {
const id = String(tabId || '').trim();
if (!id) return;
cache.set(id, {
const nextSnapshot = {
resultSets: Array.isArray(snapshot.resultSets) ? snapshot.resultSets : [],
activeResultKey: String(snapshot.activeResultKey || ''),
isResultPanelVisible: snapshot.isResultPanelVisible,
});
};
cache.set(id, nextSnapshot);
notifyQueryEditorResultSession(id, nextSnapshot);
};
export const takeQueryEditorResultSession = (
@@ -29,6 +39,7 @@ export const takeQueryEditorResultSession = (
const snapshot = cache.get(id) || null;
if (snapshot) {
cache.delete(id);
notifyQueryEditorResultSession(id, null);
}
return snapshot;
};
@@ -45,4 +56,24 @@ export const clearQueryEditorResultSession = (tabId: string): void => {
const id = String(tabId || '').trim();
if (!id) return;
cache.delete(id);
notifyQueryEditorResultSession(id, null);
};
export const subscribeQueryEditorResultSession = (
tabId: string,
listener: (snapshot: QueryEditorResultSessionSnapshot | null) => void,
): (() => void) => {
const id = String(tabId || '').trim();
if (!id) return () => undefined;
const tabListeners = listeners.get(id) || new Set();
tabListeners.add(listener);
listeners.set(id, tabListeners);
return () => {
const current = listeners.get(id);
if (!current) return;
current.delete(listener);
if (current.size === 0) {
listeners.delete(id);
}
};
};