mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-22 00:42:47 +08:00
✨ feat(native-window): 支持标签页拖出为原生独立窗口
- 支持 SQL、数据和结果标签拖出到跨显示器原生窗口 - 通过受认证 loopback bridge 复用主进程后端与状态 - 支持子窗口继续拆窗、聚焦、关闭、还原与异常退出恢复 - 补充多窗口协议、状态同步和跨平台回归测试
This commit is contained in:
@@ -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 && (
|
||||
<>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
156
frontend/src/components/NativeDetachedWindowApp.test.tsx
Normal file
156
frontend/src/components/NativeDetachedWindowApp.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
508
frontend/src/components/NativeDetachedWindowApp.tsx
Normal file
508
frontend/src/components/NativeDetachedWindowApp.tsx
Normal 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;
|
||||
398
frontend/src/components/NativeDetachedWindowController.test.ts
Normal file
398
frontend/src/components/NativeDetachedWindowController.test.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
232
frontend/src/components/NativeDetachedWindowController.tsx
Normal file
232
frontend/src/components/NativeDetachedWindowController.tsx
Normal 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;
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 />;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
236
frontend/src/utils/nativeDetachedWindowClient.test.ts
Normal file
236
frontend/src/utils/nativeDetachedWindowClient.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
376
frontend/src/utils/nativeDetachedWindowClient.ts
Normal file
376
frontend/src/utils/nativeDetachedWindowClient.ts
Normal 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();
|
||||
}
|
||||
};
|
||||
132
frontend/src/utils/nativeDetachedWindowHost.test.ts
Normal file
132
frontend/src/utils/nativeDetachedWindowHost.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
238
frontend/src/utils/nativeDetachedWindowHost.ts
Normal file
238
frontend/src/utils/nativeDetachedWindowHost.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user