mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-22 00:42:47 +08:00
🐛 fix(native-window): 修复 AI 子窗重开、设置遮挡与关闭恢复
- 单次快捷键可重新唤起停驻 AI 子窗,并过滤重复键盘事件 - 打开 AI 设置前原子隐藏子窗,避免设置窗口被遮挡 - 用可见性版本阻止延迟隐藏或设置事件覆盖新焦点 - 拒绝复用终态子进程,并恢复失败的附加与关闭操作 - 串行展示、隐藏和终态退出,消除关闭门闩并发竞态 - 增加原生窗口生命周期、结果回滚和焦点恢复回归测试
This commit is contained in:
@@ -15,8 +15,10 @@ import {
|
||||
buildNativeDetachedSyncStoreSnapshot,
|
||||
buildNativeDetachedWorkbenchMutableStoreSnapshot,
|
||||
buildNativeDetachedWorkbenchPayload,
|
||||
closeCurrentNativeDetachedWindow,
|
||||
fetchNativeDetachedWindowBootstrap,
|
||||
hideCurrentNativeDetachedWindow,
|
||||
hideCurrentNativeDetachedWindowForAISettings,
|
||||
hideNativeDetachedWindow,
|
||||
hydrateNativeDetachedStore,
|
||||
isNativeDetachedWindow,
|
||||
@@ -578,6 +580,32 @@ describe('nativeDetachedWindowClient', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a terminal action that the parent ignored as stale', async () => {
|
||||
const action = vi.fn(async () => ({
|
||||
success: true,
|
||||
applied: false,
|
||||
message: 'stale detached action ignored',
|
||||
}));
|
||||
const previousWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: { __GONAVI_DETACHED__: { action } },
|
||||
});
|
||||
try {
|
||||
await expect(attachNativeDetachedWindow({
|
||||
id: 'window-1',
|
||||
kind: 'workbench',
|
||||
tab: queryTab,
|
||||
})).rejects.toThrow('stale detached action ignored');
|
||||
} finally {
|
||||
if (previousWindowDescriptor) {
|
||||
Object.defineProperty(globalThis, 'window', previousWindowDescriptor);
|
||||
} else {
|
||||
Reflect.deleteProperty(globalThis, 'window');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('reuses the child runtime bootstrap cache instead of fetching the payload twice', async () => {
|
||||
const bootstrap = {
|
||||
id: 'workbench:query-1',
|
||||
@@ -687,6 +715,48 @@ describe('nativeDetachedWindowClient', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('uses the atomic native hide-and-open control for AI settings', async () => {
|
||||
const hideForAISettings = vi.fn(async () => ({ success: true }));
|
||||
const previousWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: { go: { nativewindow: { Control: { HideForAISettings: hideForAISettings } } } },
|
||||
});
|
||||
try {
|
||||
await hideCurrentNativeDetachedWindowForAISettings(13);
|
||||
expect(hideForAISettings).toHaveBeenCalledWith(13);
|
||||
} finally {
|
||||
if (previousWindowDescriptor) {
|
||||
Object.defineProperty(globalThis, 'window', previousWindowDescriptor);
|
||||
} else {
|
||||
Reflect.deleteProperty(globalThis, 'window');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects when the native close control reports a failure', async () => {
|
||||
const close = vi.fn(async () => ({
|
||||
success: false,
|
||||
message: 'native window is not ready',
|
||||
}));
|
||||
const previousWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: { go: { nativewindow: { Control: { Close: close } } } },
|
||||
});
|
||||
try {
|
||||
await expect(closeCurrentNativeDetachedWindow())
|
||||
.rejects.toThrow('native window is not ready');
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
} 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);
|
||||
|
||||
@@ -13,6 +13,7 @@ export const NATIVE_DETACHED_BOOTSTRAP_URL = '/__gonavi/detached/bootstrap';
|
||||
export const NATIVE_DETACHED_ACTION_URL = '/__gonavi/detached/action';
|
||||
export { NATIVE_DETACHED_WINDOW_QUERY_PARAM } from './nativeDetachedWindowRoute';
|
||||
export const NATIVE_DETACHED_WINDOW_COMMAND_EVENT = 'gonavi:native-detached-command';
|
||||
export const NATIVE_DETACHED_QUERY_RESULT_REDETACH_EVENT = 'gonavi:redetach-query-result';
|
||||
|
||||
export const NATIVE_DETACHED_HOST_EVENTS_KEY = '__gonaviNativeHostEvents';
|
||||
|
||||
@@ -59,12 +60,14 @@ export interface NativeDetachedWindowBootstrap {
|
||||
kind: NativeDetachedWindowKind;
|
||||
title: string;
|
||||
payload: NativeDetachedWindowPayload;
|
||||
actionRevision?: number;
|
||||
}
|
||||
|
||||
export interface NativeDetachedWindowActionPayload {
|
||||
id: string;
|
||||
kind: NativeDetachedWindowKind;
|
||||
revision?: number;
|
||||
rollbackAction?: 'attach' | 'hide' | 'close';
|
||||
storeState?: NativeDetachedStoreSnapshot;
|
||||
tab?: TabData;
|
||||
resultWindow?: DetachedQueryResultWindow;
|
||||
@@ -113,6 +116,7 @@ export interface NativeDetachedWindowActionRequest {
|
||||
|
||||
export interface NativeDetachedWindowActionResult {
|
||||
success: boolean;
|
||||
applied?: boolean;
|
||||
message?: string;
|
||||
id?: string;
|
||||
visibilityRevision?: number;
|
||||
@@ -123,6 +127,7 @@ export interface NativeDetachedHostStateCommand {
|
||||
action: 'sync-host-state' | string;
|
||||
payload?: {
|
||||
revision?: number;
|
||||
visibilityRevision?: number;
|
||||
storeState?: NativeDetachedStoreSnapshot;
|
||||
};
|
||||
}
|
||||
@@ -917,8 +922,12 @@ export const postNativeDetachedWindowAction = async (
|
||||
if (result?.success === false) {
|
||||
throw new Error(String(result.message || `Native detached ${action} failed`));
|
||||
}
|
||||
if (result?.applied === false) {
|
||||
throw new Error(String(result.message || `Native detached ${action} was ignored`));
|
||||
}
|
||||
return {
|
||||
success: result?.success !== false,
|
||||
...(typeof result?.applied === 'boolean' ? { applied: result.applied } : {}),
|
||||
...(result?.message ? { message: String(result.message) } : {}),
|
||||
...(result?.id ? { id: String(result.id) } : {}),
|
||||
...(Number.isFinite(Number(result?.visibilityRevision))
|
||||
@@ -942,6 +951,9 @@ export const postNativeDetachedWindowAction = async (
|
||||
if (result?.success === false) {
|
||||
throw new Error(String(result.message || `Native detached ${action} failed`));
|
||||
}
|
||||
if (result?.applied === false) {
|
||||
throw new Error(String(result.message || `Native detached ${action} was ignored`));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -1008,7 +1020,10 @@ export const closeCurrentNativeDetachedWindow = async (): Promise<void> => {
|
||||
? (window as any).go?.nativewindow?.Control?.Close
|
||||
: undefined;
|
||||
if (typeof nativeClose === 'function') {
|
||||
await nativeClose();
|
||||
const result = await nativeClose();
|
||||
if (result?.success === false) {
|
||||
throw new Error(String(result.message || 'Failed to close native detached window'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (typeof window !== 'undefined' && typeof window.close === 'function') {
|
||||
@@ -1031,6 +1046,21 @@ export const hideCurrentNativeDetachedWindow = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const hideCurrentNativeDetachedWindowForAISettings = async (
|
||||
visibilityRevision: number,
|
||||
): Promise<void> => {
|
||||
const hideForAISettings = typeof window !== 'undefined'
|
||||
? (window as any).go?.nativewindow?.Control?.HideForAISettings
|
||||
: undefined;
|
||||
if (typeof hideForAISettings !== 'function') {
|
||||
throw new Error('Native detached AI settings control is unavailable');
|
||||
}
|
||||
const result = await hideForAISettings(Math.trunc(visibilityRevision));
|
||||
if (result?.success === false) {
|
||||
throw new Error(String(result.message || 'Failed to open AI settings from native window'));
|
||||
}
|
||||
};
|
||||
|
||||
export const cancelCurrentNativeDetachedWindowClose = async (): Promise<void> => {
|
||||
const cancelClose = typeof window !== 'undefined'
|
||||
? (window as any).go?.nativewindow?.Control?.CancelClose
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
shouldApplyNativeDetachedHideRevision,
|
||||
syncNativeAIChatHostState,
|
||||
syncNativeDetachedShortcutOptions,
|
||||
toggleOrFocusNativeAIChatFromMainWindow,
|
||||
type NativeDetachedWindowManager,
|
||||
} from './nativeDetachedWindowHost';
|
||||
import { clearQueryTabDraft, setQueryTabDraft } from './sqlFileTabDrafts';
|
||||
@@ -289,6 +290,28 @@ describe('nativeDetachedWindowHost', () => {
|
||||
expect(shouldApplyNativeDetachedHideRevision('ai-chat', 3)).toBe(false);
|
||||
});
|
||||
|
||||
it('focuses an already-visible native AI child from the main shortcut without closing it first', async () => {
|
||||
useStore.setState({
|
||||
aiPanelVisible: true,
|
||||
detachedAIChatWindow: {
|
||||
x: 20,
|
||||
y: 30,
|
||||
width: 440,
|
||||
height: 720,
|
||||
zIndex: 1201,
|
||||
coordinateSpace: 'screen',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(toggleOrFocusNativeAIChatFromMainWindow(manager)).resolves.toBe(true);
|
||||
|
||||
expect(useStore.getState().aiPanelVisible).toBe(true);
|
||||
expect(manager.Focus).toHaveBeenCalledOnce();
|
||||
expect(manager.Focus).toHaveBeenCalledWith('ai-chat');
|
||||
expect(manager.Hide).not.toHaveBeenCalled();
|
||||
expect(manager.Open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resends the latest AI shortcut after native open completes', async () => {
|
||||
const initialShortcutOptions = useStore.getState().shortcutOptions;
|
||||
const latestShortcutOptions = {
|
||||
|
||||
@@ -538,6 +538,18 @@ export const openNativeAIChatWindow = async (
|
||||
return opened;
|
||||
};
|
||||
|
||||
export const toggleOrFocusNativeAIChatFromMainWindow = async (
|
||||
managerOverride?: NativeDetachedWindowManager,
|
||||
): Promise<boolean> => {
|
||||
const state = useStore.getState();
|
||||
const manager = managerOverride ?? resolveNativeDetachedWindowManager();
|
||||
if (manager && state.aiPanelVisible && state.detachedAIChatWindow) {
|
||||
return openNativeAIChatWindow(undefined, manager);
|
||||
}
|
||||
state.toggleAIPanel();
|
||||
return useStore.getState().aiPanelVisible;
|
||||
};
|
||||
|
||||
const refreshNativeAIChatWindow = async (
|
||||
manager: NativeDetachedWindowManager,
|
||||
): Promise<boolean> => {
|
||||
|
||||
Reference in New Issue
Block a user