mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-11 17:23:48 +08:00
1203 lines
44 KiB
TypeScript
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;
|