Files
MyGoNavi/frontend/src/components/NativeDetachedWindowApp.tsx

1203 lines
44 KiB
TypeScript

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 { EventsOn } from '../../wailsjs/runtime';
import { t as defaultTranslate } from '../i18n';
import { getAntdLocale } from '../i18n/frameworkLocale';
import { useOptionalI18n } from '../i18n/provider';
import { flushAIChatSessionPersistence, type SqlLog, useStore } from '../store';
import type { TabData } from '../types';
import type { DetachedQueryResultWindow } from '../utils/detachedWindow';
import {
attachNativeDetachedWindow,
advanceNativeDetachedStoreSource,
applyNativeDetachedHostStateCommand,
buildNativeDetachedAIChatSyncStoreSnapshot,
buildNativeDetachedChangedWorkbenchStoreSnapshot,
buildNativeDetachedStoreSnapshot,
buildNativeDetachedSyncStoreSnapshot,
buildNativeDetachedWorkbenchMutableStoreSnapshot,
cancelNativeDetachedWindowClose,
cancelCurrentNativeDetachedWindowClose,
closeCurrentNativeDetachedWindow,
closeNativeDetachedWindow,
fetchNativeDetachedWindowBootstrap,
hydrateNativeDetachedStore,
hideCurrentNativeDetachedWindow,
hideNativeDetachedWindow,
openNativeDetachedAISettings,
presentCurrentNativeDetachedWindow,
readyNativeDetachedWindow,
sendNativeDetachedHostEvent,
syncNativeDetachedWindow,
type NativeDetachedHostEvent,
type NativeDetachedHostEventName,
type NativeDetachedStoreSnapshot,
type NativeDetachedWindowActionPayload,
type NativeDetachedWindowBootstrap,
NATIVE_DETACHED_WINDOW_COMMAND_EVENT,
type NativeDetachedHostStateCommand,
} from '../utils/nativeDetachedWindowClient';
import { isMacLikePlatform } from '../utils/appearance';
import {
peekQueryEditorResultSession,
saveQueryEditorResultSession,
subscribeQueryEditorResultSession,
type QueryEditorResultSessionSnapshot,
} from '../utils/queryEditorResultSessionCache';
import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import { resolveLiveQueryTab, resolveLiveQueryTabs } from '../utils/liveQueryTabs';
import { subscribeQueryTabDraftChanges } from '../utils/sqlFileTabDrafts';
import {
getShortcutPlatform,
installGlobalImeCompositionTracking,
isShortcutMatch,
resolveShortcutBinding,
} from '../utils/shortcuts';
const AIChatPanel = React.lazy(() => import('./AIChatPanel'));
const DataGrid = React.lazy(() => import('./DataGrid'));
const WorkbenchTabContent = React.lazy(() => import('./WorkbenchTabContent'));
const NativeDetachedWindowController = React.lazy(
() => import('./NativeDetachedWindowController'),
);
export const NATIVE_DETACHED_SYNC_DEBOUNCE_MS = 180;
export const NATIVE_DETACHED_PAINT_FALLBACK_MS = 250;
export const waitForNativeDetachedContentPaint = (): Promise<void> => {
if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') {
return Promise.resolve();
}
return new Promise((resolve) => {
let settled = false;
let fallbackTimer: ReturnType<typeof setTimeout> | undefined;
const finish = () => {
if (settled) return;
settled = true;
if (fallbackTimer !== undefined) clearTimeout(fallbackTimer);
resolve();
};
fallbackTimer = setTimeout(finish, NATIVE_DETACHED_PAINT_FALLBACK_MS);
window.requestAnimationFrame(() => {
window.requestAnimationFrame(finish);
});
});
};
type NativeDetachedWindowClient = {
load: () => Promise<NativeDetachedWindowBootstrap>;
present?: () => Promise<void>;
ready: (payload: NativeDetachedWindowActionPayload) => Promise<void>;
sync: (payload: NativeDetachedWindowActionPayload) => Promise<void>;
attach: (payload: NativeDetachedWindowActionPayload) => Promise<void>;
hide?: (payload: NativeDetachedWindowActionPayload) => Promise<number>;
close: (payload: NativeDetachedWindowActionPayload) => Promise<void>;
cancelCloseRequest?: (payload: NativeDetachedWindowActionPayload) => Promise<void>;
openAISettings: (payload: NativeDetachedWindowActionPayload) => Promise<void>;
hostEvent?: (payload: NativeDetachedWindowActionPayload) => Promise<void>;
closeCurrentWindow: () => Promise<void>;
hideCurrentWindow?: (visibilityRevision: number) => Promise<void>;
cancelClose?: () => Promise<void>;
};
const defaultClient: NativeDetachedWindowClient = {
load: fetchNativeDetachedWindowBootstrap,
present: presentCurrentNativeDetachedWindow,
ready: readyNativeDetachedWindow,
sync: syncNativeDetachedWindow,
attach: attachNativeDetachedWindow,
hide: hideNativeDetachedWindow,
close: closeNativeDetachedWindow,
cancelCloseRequest: cancelNativeDetachedWindowClose,
openAISettings: openNativeDetachedAISettings,
hostEvent: sendNativeDetachedHostEvent,
closeCurrentWindow: closeCurrentNativeDetachedWindow,
hideCurrentWindow: hideCurrentNativeDetachedWindow,
cancelClose: cancelCurrentNativeDetachedWindowClose,
};
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[] = [],
revision?: number,
workbenchState?: NativeDetachedStoreSnapshot,
workbenchStateBase?: NativeDetachedStoreSnapshot,
openedTabs: TabData[] = [],
clearSqlLogs = false,
resultWindow?: DetachedQueryResultWindow | null,
): NativeDetachedWindowActionPayload => {
const storeState = bootstrap.kind === 'ai-chat'
? buildNativeDetachedAIChatSyncStoreSnapshot(useStore.getState(), newSqlLogs)
: buildNativeDetachedSyncStoreSnapshot(
useStore.getState(),
bootstrap.kind === 'workbench' ? bootstrap.payload.tab?.id || '' : '',
newSqlLogs,
);
const screenX = typeof window === 'undefined' ? Number.NaN : Number(window.screenX);
const screenY = typeof window === 'undefined' ? Number.NaN : Number(window.screenY);
const width = typeof window === 'undefined'
? Number.NaN
: Number(window.outerWidth || window.innerWidth);
const height = typeof window === 'undefined'
? Number.NaN
: Number(window.outerHeight || window.innerHeight);
const bounds = [screenX, screenY, width, height].every(Number.isFinite)
&& width > 0
&& height > 0
? {
x: Math.round(screenX),
y: Math.round(screenY),
width: Math.round(width),
height: Math.round(height),
}
: undefined;
return {
id: bootstrap.id,
kind: bootstrap.kind,
...(revision && revision > 0 ? { revision } : {}),
...(bounds ? { bounds } : {}),
...(workbenchState && Object.keys(workbenchState).length > 0 ? { workbenchState } : {}),
...(workbenchState && Object.keys(workbenchState).length > 0
? { workbenchStateBase: workbenchStateBase ?? {} }
: {}),
...(openedTabs.length > 0 ? { openedTabs } : {}),
...(clearSqlLogs ? { clearSqlLogs: true } : {}),
...(bootstrap.kind === 'workbench' || Object.keys(storeState).length > 0
? { storeState }
: {}),
...(tab ? { tab } : {}),
...(bootstrap.kind === 'query-result' && resultWindow ? { resultWindow } : {}),
...(bootstrap.kind === 'workbench' && includeResultSession
? { resultSession: resultSession ?? null }
: {}),
};
};
const NativeDetachedQueryResult: React.FC<{
windowState: DetachedQueryResultWindow;
onDataChange: (rows: Array<Record<string, unknown>>) => void;
}> = ({ windowState, onDataChange }) => {
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 || ''}
ddlDbName={result.ddlDbName}
ddlTableName={result.ddlTableName}
resultSql={result.exportSql || result.sql}
exportScope="queryResult"
showRowNumberColumn={result.showRowNumberColumn}
onDataChange={onDataChange}
isActive
/>
);
};
const NativeDetachedWindowContent: React.FC<{
bootstrap: NativeDetachedWindowBootstrap;
onContentReady: () => void;
onAttach: () => void;
onClose: () => void;
onOpenSettings: () => void;
onRegisterAITerminalGuard: (guard: (() => Promise<boolean>) | null) => void;
onQueryResultDataChange: (rows: Array<Record<string, unknown>>) => void;
interactionDisabled?: boolean;
}> = ({
bootstrap,
onContentReady,
onAttach,
onClose,
onOpenSettings,
onRegisterAITerminalGuard,
onQueryResultDataChange,
interactionDisabled = false,
}) => {
const tabFromStore = useStore((state) => bootstrap.payload.tab
? state.tabs.find((item) => item.id === bootstrap.payload.tab?.id)
: undefined);
const tab = tabFromStore || bootstrap.payload.tab;
const themeMode = useStore((state) => state.theme);
const uiVersion = useStore((state) => state.appearance.uiVersion);
if (bootstrap.kind === 'workbench') {
return tab
? <WorkbenchTabContent tab={tab} isActive onContentReady={onContentReady} />
: null;
}
if (bootstrap.kind === 'query-result') {
return bootstrap.payload.resultWindow
? (
<>
<NativeDetachedQueryResult
windowState={bootstrap.payload.resultWindow}
onDataChange={onQueryResultDataChange}
/>
<NativeDetachedContentReady onReady={onContentReady} />
</>
)
: null;
}
const isDark = themeMode === 'dark';
return (
<div className="gn-native-detached-ai-chat">
<AIChatPanel
width={typeof window === 'undefined' ? 440 : window.innerWidth}
darkMode={isDark}
bgColor={isDark ? '#161a21' : '#ffffff'}
overlayTheme={buildOverlayWorkbenchTheme(isDark, {
disableBackdropFilter: true,
uiVersion,
})}
presentation="detached"
onClose={onClose}
onAttach={onAttach}
onOpenSettings={onOpenSettings}
onRegisterTerminalGuard={onRegisterAITerminalGuard}
interactionDisabled={interactionDisabled}
/>
<NativeDetachedContentReady onReady={onContentReady} />
</div>
);
};
const NativeDetachedContentReady: React.FC<{ onReady: () => void }> = ({ onReady }) => {
useEffect(() => {
onReady();
}, [onReady]);
return 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 [contentReady, setContentReady] = useState(false);
const [controllerEnabled, setControllerEnabled] = useState(false);
const markContentReady = useCallback(() => setContentReady(true), []);
const [terminalAction, setTerminalAction] = useState<'attach' | 'hide' | 'close' | null>(null);
const terminalActionStartedRef = useRef(false);
const terminalActionRequestedRef = useRef(false);
const activeTerminalActionRef = useRef<'attach' | 'hide' | 'close' | null>(null);
const closePreemptionRequestedRef = useRef(false);
const hideVisibilityRevisionRef = useRef(0);
const aiTerminalGuardRef = useRef<(() => Promise<boolean>) | null>(null);
const resultSessionRef = useRef<QueryEditorResultSessionSnapshot | null>(null);
const queryResultWindowRef = useRef<DetachedQueryResultWindow | null>(null);
const queryResultDirtyGenerationRef = useRef(0);
const scheduleSyncRef = useRef<(includeResultSession?: boolean) => void>(() => undefined);
const syncedSqlLogIdsRef = useRef<Set<string>>(new Set());
const sqlLogsClearPendingRef = useRef(false);
const syncTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const syncIncludesResultSessionRef = useRef(false);
const hostStateRevisionRef = useRef(0);
const actionRevisionRef = useRef(0);
const actionQueueRef = useRef<Promise<void>>(Promise.resolve());
const processedHostEventIdsRef = useRef<Set<string>>(new Set());
const previousHostAIContextsRef = useRef<unknown>({});
const hostEventSequenceRef = useRef(0);
const workbenchStateSourceRef = useRef<NativeDetachedStoreSnapshot>({});
const syncedWorkbenchTabIdsRef = useRef<Set<string>>(new Set());
const handleQueryResultDataChange = useCallback((rows: Array<Record<string, unknown>>) => {
const resultWindow = queryResultWindowRef.current;
if (!resultWindow) return;
queryResultWindowRef.current = {
...resultWindow,
result: { ...resultWindow.result, rows },
};
queryResultDirtyGenerationRef.current += 1;
scheduleSyncRef.current(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);
const shortcutOptions = useStore((state) => state.shortcutOptions);
useEffect(() => {
if (typeof window === 'undefined') return undefined;
return installGlobalImeCompositionTracking(
window,
typeof document === 'undefined' ? null : document,
);
}, []);
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;
setContentReady(false);
setControllerEnabled(false);
hydrateNativeDetachedStore(useStore, nextBootstrap.payload.storeState);
queryResultWindowRef.current = nextBootstrap.payload.resultWindow ?? null;
previousHostAIContextsRef.current = useStore.getState().aiContexts;
workbenchStateSourceRef.current = buildNativeDetachedWorkbenchMutableStoreSnapshot(
useStore.getState(),
);
syncedWorkbenchTabIdsRef.current = new Set(
(useStore.getState().tabs || []).map((tab) => String(tab.id || '').trim()).filter(Boolean),
);
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) return undefined;
return EventsOn(
NATIVE_DETACHED_WINDOW_COMMAND_EVENT,
(command: NativeDetachedHostStateCommand) => {
hostStateRevisionRef.current = applyNativeDetachedHostStateCommand(
useStore,
bootstrap.id,
hostStateRevisionRef.current,
command,
{
processedEventIds: processedHostEventIdsRef.current,
previousHostAIContextsRef,
dispatchHostEvent: (hostEvent) => {
if (typeof window === 'undefined') return;
window.dispatchEvent(new CustomEvent(hostEvent.name, {
detail: hostEvent.detail,
}));
},
},
);
},
);
}, [bootstrap]);
useEffect(() => {
if (!bootstrap || typeof window === 'undefined' || !client.hostEvent) return undefined;
const eventNames: NativeDetachedHostEventName[] = bootstrap.kind === 'ai-chat'
? [
'gonavi:insert-sql',
'gonavi:jvm-apply-ai-plan',
'gonavi:jvm-apply-diagnostic-plan',
]
: ['gonavi:ai:inject-prompt'];
const forwardToHost = (event: Event) => {
hostEventSequenceRef.current += 1;
const hostEvent: NativeDetachedHostEvent = {
id: `${bootstrap.id}:${Date.now()}:${hostEventSequenceRef.current}`,
name: event.type as NativeDetachedHostEventName,
detail: (event as CustomEvent<unknown>).detail,
};
void client.hostEvent?.({
id: bootstrap.id,
kind: bootstrap.kind,
hostEvent,
}).catch((error) => {
console.warn('[Native Detached Window] Failed to forward event to host', error);
});
};
eventNames.forEach((eventName) => window.addEventListener(eventName, forwardToHost));
return () => {
eventNames.forEach((eventName) => window.removeEventListener(eventName, forwardToHost));
};
}, [bootstrap, client]);
useEffect(() => {
if (!bootstrap || typeof window === 'undefined' || !client.hostEvent) return undefined;
const platform = getShortcutPlatform(isMacLikePlatform());
const binding = resolveShortcutBinding(shortcutOptions, 'toggleAIPanel', platform);
if (!binding.enabled) return undefined;
const handleToggleAIShortcut = (event: KeyboardEvent) => {
if (!isShortcutMatch(event, binding.combo)) return;
event.preventDefault();
event.stopImmediatePropagation();
hostEventSequenceRef.current += 1;
void client.hostEvent?.({
id: bootstrap.id,
kind: bootstrap.kind,
hostEvent: {
id: `${bootstrap.id}:${Date.now()}:${hostEventSequenceRef.current}`,
name: 'gonavi:shortcut:toggle-ai-panel',
},
}).catch((error) => {
console.warn('[Native Detached Window] Failed to forward AI shortcut to host', error);
});
};
const listenerOptions = { capture: true };
window.addEventListener('keydown', handleToggleAIShortcut, listenerOptions);
return () => window.removeEventListener('keydown', handleToggleAIShortcut, listenerOptions);
}, [bootstrap, client, shortcutOptions]);
useEffect(() => {
if (!bootstrap || !contentMounted || !contentReady) return undefined;
let active = true;
void Promise.resolve(client.present?.())
.then(() => waitForNativeDetachedContentPaint())
.then(() => {
if (!active) return undefined;
return client.ready({ id: bootstrap.id, kind: bootstrap.kind }).then(() => {
if (active) setControllerEnabled(true);
});
})
.catch((error) => {
if (!active) return;
setLoadError(error instanceof Error ? error.message : String(error));
});
return () => {
active = false;
};
}, [bootstrap, client, contentMounted, contentReady]);
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 resolveLiveQueryTab(
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 readWorkbenchSyncData = useCallback(() => {
if (bootstrap?.kind !== 'workbench' && bootstrap?.kind !== 'query-result') {
return {
workbenchState: {},
workbenchStateBase: {},
openedTabs: [] as TabData[],
};
}
const state = useStore.getState();
const workbenchState = buildNativeDetachedChangedWorkbenchStoreSnapshot(
state,
workbenchStateSourceRef.current,
);
const workbenchStateBase = buildNativeDetachedStoreSnapshot(Object.fromEntries(
Object.keys(workbenchState).map((key) => [key, workbenchStateSourceRef.current[key]]),
));
return {
workbenchState,
workbenchStateBase,
openedTabs: bootstrap.kind === 'workbench'
? resolveLiveQueryTabs(state.tabs.filter(
(tab) => !syncedWorkbenchTabIdsRef.current.has(String(tab.id || '').trim()),
))
: [],
};
}, [bootstrap?.kind]);
const markWorkbenchStateSynced = useCallback((
workbenchState: NativeDetachedStoreSnapshot,
openedTabs: TabData[],
): void => {
workbenchStateSourceRef.current = advanceNativeDetachedStoreSource(
workbenchStateSourceRef.current,
workbenchState,
);
for (const tab of openedTabs) {
const id = String(tab.id || '').trim();
if (id) syncedWorkbenchTabIdsRef.current.add(id);
}
}, []);
const nextActionRevision = useCallback((): number => {
actionRevisionRef.current += 1;
return actionRevisionRef.current;
}, []);
const enqueueAction = useCallback(<T,>(operation: () => Promise<T>): Promise<T> => {
const result = actionQueueRef.current.catch(() => undefined).then(operation);
actionQueueRef.current = result.then(() => undefined, () => undefined);
return result;
}, []);
const scheduleSync = useCallback((includeResultSession = false) => {
if (!bootstrap || terminalAction) return;
syncIncludesResultSessionRef.current = syncIncludesResultSessionRef.current || includeResultSession;
if (syncTimerRef.current !== null) {
clearTimeout(syncTimerRef.current);
}
syncTimerRef.current = setTimeout(() => {
syncTimerRef.current = null;
void enqueueAction(async () => {
const shouldIncludeResultSession = syncIncludesResultSessionRef.current;
syncIncludesResultSessionRef.current = false;
const newSqlLogs = readUnsyncedSqlLogs();
const clearSqlLogs = sqlLogsClearPendingRef.current;
const queryResultDirtyGeneration = queryResultDirtyGenerationRef.current;
const queryResultChanged = queryResultDirtyGeneration > 0;
const { workbenchState, workbenchStateBase, openedTabs } = readWorkbenchSyncData();
if (
bootstrap.kind === 'query-result'
&& newSqlLogs.length === 0
&& !clearSqlLogs
&& !queryResultChanged
&& Object.keys(workbenchState).length === 0
) return;
await client.sync(buildActionPayload(
bootstrap,
readCurrentTab(),
resultSessionRef.current,
shouldIncludeResultSession,
newSqlLogs,
nextActionRevision(),
workbenchState,
workbenchStateBase,
openedTabs,
clearSqlLogs,
queryResultWindowRef.current,
));
if (
queryResultChanged
&& queryResultDirtyGenerationRef.current === queryResultDirtyGeneration
) {
queryResultDirtyGenerationRef.current = 0;
}
if (clearSqlLogs) {
syncedSqlLogIdsRef.current.clear();
sqlLogsClearPendingRef.current = false;
}
markSqlLogsSynced(newSqlLogs);
markWorkbenchStateSynced(workbenchState, openedTabs);
}).catch((error) => {
console.warn('[Native Detached Window] Failed to sync tab state', error);
});
}, NATIVE_DETACHED_SYNC_DEBOUNCE_MS);
}, [
bootstrap,
client,
enqueueAction,
markSqlLogsSynced,
markWorkbenchStateSynced,
nextActionRevision,
readCurrentTab,
readUnsyncedSqlLogs,
readWorkbenchSyncData,
terminalAction,
]);
scheduleSyncRef.current = scheduleSync;
useEffect(() => {
if (!bootstrap) {
return undefined;
}
const unsubscribeStore = useStore.subscribe((state, previousState) => {
if (
(previousState?.sqlLogs?.length || 0) > 0
&& (state.sqlLogs?.length || 0) === 0
) {
sqlLogsClearPendingRef.current = true;
}
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;
const unsubscribeQueryDrafts = bootstrap.kind === 'workbench'
? subscribeQueryTabDraftChanges(() => scheduleSync(false))
: () => undefined;
return () => {
unsubscribeStore();
unsubscribeResultSession();
unsubscribeQueryDrafts();
if (syncTimerRef.current !== null) {
clearTimeout(syncTimerRef.current);
syncTimerRef.current = null;
}
};
}, [bootstrap, scheduleSync]);
const requestTerminalAction = useCallback((
action: 'attach' | 'hide' | 'close',
visibilityRevision = 0,
) => {
if (!bootstrap) return;
if (terminalActionRequestedRef.current) {
if (action === 'close' && activeTerminalActionRef.current === 'hide') {
closePreemptionRequestedRef.current = true;
}
return;
}
if (action === 'hide' && bootstrap.kind !== 'ai-chat') return;
terminalActionRequestedRef.current = true;
activeTerminalActionRef.current = action;
closePreemptionRequestedRef.current = false;
const normalizedVisibilityRevision = Math.trunc(Number(visibilityRevision));
hideVisibilityRevisionRef.current = action === 'hide'
&& Number.isFinite(normalizedVisibilityRevision)
&& normalizedVisibilityRevision > 0
? normalizedVisibilityRevision
: 0;
if (syncTimerRef.current !== null) {
clearTimeout(syncTimerRef.current);
syncTimerRef.current = null;
}
if (bootstrap.kind !== 'ai-chat') setContentMounted(false);
setTerminalAction(action);
}, [bootstrap]);
const requestOpenAISettings = useCallback(() => {
if (!bootstrap || bootstrap.kind !== 'ai-chat') return;
void client.openAISettings({ id: bootstrap.id, kind: bootstrap.kind }).catch((error) => {
console.error('[Native Detached Window] Failed to open AI settings', error);
});
}, [bootstrap, client]);
useEffect(() => {
if (typeof window === 'undefined') return undefined;
const handleGracefulCloseRequest = () => requestTerminalAction('close');
const handleHideRequest = (event: Event) => requestTerminalAction(
'hide',
Number((event as CustomEvent<{ visibilityRevision?: unknown }>).detail?.visibilityRevision),
);
window.addEventListener(
'gonavi:native-detached-request-close',
handleGracefulCloseRequest as EventListener,
);
window.addEventListener(
'gonavi:native-detached-request-hide',
handleHideRequest as EventListener,
);
return () => {
window.removeEventListener(
'gonavi:native-detached-request-close',
handleGracefulCloseRequest as EventListener,
);
window.removeEventListener(
'gonavi:native-detached-request-hide',
handleHideRequest as EventListener,
);
};
}, [requestTerminalAction]);
useEffect(() => {
if (
!bootstrap
|| !terminalAction
|| terminalActionStartedRef.current
|| (bootstrap.kind !== 'ai-chat' && contentMounted)
) {
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;
void (async () => {
let actionToRun = terminalAction;
let closeActionSubmitted = false;
const submitPreemptingClose = async () => {
const closeWorkbench = readWorkbenchSyncData();
await client.close(buildActionPayload(
bootstrap,
readCurrentTab(),
currentSession,
false,
readUnsyncedSqlLogs(),
nextActionRevision(),
closeWorkbench.workbenchState,
closeWorkbench.workbenchStateBase,
closeWorkbench.openedTabs,
sqlLogsClearPendingRef.current,
queryResultWindowRef.current,
));
closeActionSubmitted = true;
actionToRun = 'close';
};
try {
await actionQueueRef.current;
if (bootstrap.kind === 'ai-chat') {
const canTerminate = await aiTerminalGuardRef.current?.();
if (canTerminate === false) {
throw new Error('AI stream did not stop before the detached window handoff');
}
await flushAIChatSessionPersistence();
}
actionToRun = closePreemptionRequestedRef.current ? 'close' : terminalAction;
if (actionToRun === 'attach' && bootstrap.kind === 'workbench') {
const finalSqlLogs = readUnsyncedSqlLogs();
const clearSqlLogs = sqlLogsClearPendingRef.current;
const finalWorkbench = readWorkbenchSyncData();
try {
await client.sync(buildActionPayload(
bootstrap,
readCurrentTab(),
currentSession,
true,
finalSqlLogs,
nextActionRevision(),
finalWorkbench.workbenchState,
finalWorkbench.workbenchStateBase,
finalWorkbench.openedTabs,
clearSqlLogs,
queryResultWindowRef.current,
));
if (clearSqlLogs) {
syncedSqlLogIdsRef.current.clear();
sqlLogsClearPendingRef.current = false;
}
markSqlLogsSynced(finalSqlLogs);
markWorkbenchStateSynced(
finalWorkbench.workbenchState,
finalWorkbench.openedTabs,
);
} 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);
}
}
const terminalWorkbench = readWorkbenchSyncData();
const payload = buildActionPayload(
bootstrap,
readCurrentTab(),
currentSession,
actionToRun === 'attach',
readUnsyncedSqlLogs(),
nextActionRevision(),
terminalWorkbench.workbenchState,
terminalWorkbench.workbenchStateBase,
terminalWorkbench.openedTabs,
sqlLogsClearPendingRef.current,
queryResultWindowRef.current,
);
if (actionToRun === 'attach') {
await client.attach(payload);
} else if (actionToRun === 'hide') {
let visibilityRevision = hideVisibilityRevisionRef.current;
if (visibilityRevision > 0) {
await client.sync(payload);
} else {
if (!client.hide) throw new Error('Native detached hide action is unavailable');
visibilityRevision = await client.hide(payload);
}
if (closePreemptionRequestedRef.current) {
await submitPreemptingClose();
} else {
if (!client.hideCurrentWindow) {
throw new Error('Native detached hide control is unavailable');
}
await client.hideCurrentWindow(visibilityRevision);
if (closePreemptionRequestedRef.current) {
await submitPreemptingClose();
}
}
} else {
await client.close(payload);
closeActionSubmitted = true;
}
} catch (error) {
console.error(`[Native Detached Window] Failed to ${actionToRun}`, error);
if (closePreemptionRequestedRef.current && !closeActionSubmitted) {
try {
await submitPreemptingClose();
} catch (closeError) {
console.error('[Native Detached Window] Failed to continue with requested close', closeError);
}
}
if (actionToRun === 'hide' && !closeActionSubmitted) {
const visibilityRevision = hideVisibilityRevisionRef.current;
if (visibilityRevision > 0) {
try {
await client.hideCurrentWindow?.(visibilityRevision);
} catch (localHideError) {
console.error('[Native Detached Window] Failed to apply requested hide', localHideError);
}
}
if (closePreemptionRequestedRef.current && !closeActionSubmitted) {
try {
await submitPreemptingClose();
} catch (closeError) {
console.error('[Native Detached Window] Failed to continue with requested close', closeError);
}
}
}
if (actionToRun === 'hide' && !closeActionSubmitted) {
terminalActionStartedRef.current = false;
terminalActionRequestedRef.current = false;
activeTerminalActionRef.current = null;
closePreemptionRequestedRef.current = false;
hideVisibilityRevisionRef.current = 0;
setTerminalAction(null);
return;
}
if (!closeActionSubmitted) {
const cancelWorkbench = readWorkbenchSyncData();
const cancelPayload = buildActionPayload(
bootstrap,
readCurrentTab(),
currentSession,
false,
readUnsyncedSqlLogs(),
nextActionRevision(),
cancelWorkbench.workbenchState,
cancelWorkbench.workbenchStateBase,
cancelWorkbench.openedTabs,
sqlLogsClearPendingRef.current,
queryResultWindowRef.current,
);
try {
await client.cancelCloseRequest?.(cancelPayload);
} catch (parentCancelError) {
console.error(
'[Native Detached Window] Failed to cancel parent close fallback',
parentCancelError,
);
}
try {
await client.cancelClose?.();
} catch (localCancelError) {
console.error(
'[Native Detached Window] Failed to cancel local close fallback',
localCancelError,
);
}
terminalActionStartedRef.current = false;
terminalActionRequestedRef.current = false;
activeTerminalActionRef.current = null;
closePreemptionRequestedRef.current = false;
setTerminalAction(null);
setContentMounted(true);
return;
}
}
if (actionToRun === 'hide') {
terminalActionStartedRef.current = false;
terminalActionRequestedRef.current = false;
activeTerminalActionRef.current = null;
closePreemptionRequestedRef.current = false;
hideVisibilityRevisionRef.current = 0;
setTerminalAction(null);
return;
}
try {
await client.closeCurrentWindow();
} catch (error) {
console.error('[Native Detached Window] Failed to close native window', error);
}
})();
}, [
bootstrap,
client,
contentMounted,
markSqlLogsSynced,
markWorkbenchStateSynced,
nextActionRevision,
readCurrentTab,
readUnsyncedSqlLogs,
readWorkbenchSyncData,
terminalAction,
]);
const requestWindowClose = useCallback(() => {
requestTerminalAction(bootstrap?.kind === 'ai-chat' ? 'hide' : 'close');
}, [bootstrap?.kind, requestTerminalAction]);
const chromeLabels = useMemo(() => ({
attach: bootstrap?.kind === 'workbench'
? translate('tab_manager.detached.restore')
: bootstrap?.kind === 'ai-chat'
? translate('ai_chat.detached.action.dock')
: translate('query_editor.results_panel.detached.restore'),
close: bootstrap?.kind === 'workbench'
? translate('tab_manager.detached.close')
: bootstrap?.kind === 'ai-chat'
? translate('ai_chat.header.tooltip.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 && controllerEnabled ? (
<React.Suspense fallback={null}>
<NativeDetachedWindowController currentWindowId={bootstrap.id} />
</React.Suspense>
) : 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;
}
.gn-native-detached-ai-chat {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
display: flex;
overflow: hidden;
}
.gn-native-detached-ai-chat .ai-chat-panel {
width: 100% !important;
height: 100%;
min-width: 0;
border-left: 0 !important;
}
.gn-native-detached-ai-chat .ai-resize-handle {
display: none !important;
}
.gn-native-detached-ai-chat .ai-chat-header {
cursor: move;
user-select: none;
--wails-draggable: drag;
}
.gn-native-detached-ai-chat .ai-chat-header button,
.gn-native-detached-ai-chat .ai-chat-header a,
.gn-native-detached-ai-chat .ai-chat-header input,
.gn-native-detached-ai-chat .ai-chat-header textarea,
.gn-native-detached-ai-chat .ai-chat-header-right,
.gn-native-detached-ai-chat .gn-v2-ai-mode-tabs {
--wails-draggable: no-drag;
}
`}</style>
{bootstrap?.kind !== 'ai-chat' ? <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={requestWindowClose}
/>
</Tooltip>
</div>
</div> : null}
<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 ? (
<React.Suspense
fallback={<div className="gn-native-detached-loading"><Spin /></div>}
>
<NativeDetachedWindowContent
bootstrap={bootstrap}
onContentReady={markContentReady}
onAttach={() => requestTerminalAction('attach')}
onClose={requestWindowClose}
onOpenSettings={requestOpenAISettings}
onRegisterAITerminalGuard={(guard) => {
aiTerminalGuardRef.current = guard;
}}
interactionDisabled={Boolean(terminalAction)}
onQueryResultDataChange={handleQueryResultDataChange}
/>
</React.Suspense>
) : null}
</div>
</div>
</ConfigProvider>
);
};
export default NativeDetachedWindowApp;