🐛 fix(native-window): 修复 AI 子窗重开、设置遮挡与关闭恢复

- 单次快捷键可重新唤起停驻 AI 子窗,并过滤重复键盘事件
- 打开 AI 设置前原子隐藏子窗,避免设置窗口被遮挡
- 用可见性版本阻止延迟隐藏或设置事件覆盖新焦点
- 拒绝复用终态子进程,并恢复失败的附加与关闭操作
- 串行展示、隐藏和终态退出,消除关闭门闩并发竞态
- 增加原生窗口生命周期、结果回滚和焦点恢复回归测试
This commit is contained in:
Syngnat
2026-07-22 18:45:55 +08:00
parent 75749d2465
commit c19fcdd183
19 changed files with 2256 additions and 153 deletions

View File

@@ -401,7 +401,7 @@ describe('settings center tool entries', () => {
['switchToNextTab', 'switchActiveTabByOffset(1);'],
['switchToPreviousTab', 'switchActiveTabByOffset(-1);'],
['newConnection', 'handleCreateConnection();'],
['toggleAIPanel', 'toggleAIPanel();'],
['toggleAIPanel', 'toggleOrFocusNativeAIChatFromMainWindow()'],
['toggleLogPanel', 'handleToggleLogPanel();'],
['toggleTheme', 'selectPresetTheme('],
['openShortcutManager', "handleOpenToolCenterPane('workspace', 'shortcut-settings');"],
@@ -514,6 +514,11 @@ describe('settings center tool entries', () => {
expect(appSource).toContain("window.removeEventListener('keydown', handleGlobalShortcut, true);");
});
it('consumes repeated AI shortcut events without toggling the newly focused child again', () => {
expect(appSource).toContain("if (event.repeat && matchedAction === 'toggleAIPanel') {");
expect(appSource).toContain('event.stopImmediatePropagation();');
});
it('skips the native mac titlebar bridge when the current runtime does not expose it', () => {
expect(appSource).toContain("const backendApp = (window as any).go?.app?.App;");
expect(appSource).toContain("if (typeof backendApp?.SetMacNativeWindowControls !== 'function') {");

View File

@@ -196,6 +196,7 @@ import { safeWindowRuntimeCall } from './utils/wailsRuntime';
import {
hasNativeDetachedWindowManager,
openNativeAIChatWindow,
toggleOrFocusNativeAIChatFromMainWindow,
} from './utils/nativeDetachedWindowHost';
import {
buildApplicationQuitUnsavedSQLLabel,
@@ -4044,6 +4045,12 @@ function App() {
return;
}
if (event.repeat && matchedAction === 'toggleAIPanel') {
event.preventDefault();
event.stopImmediatePropagation();
return;
}
event.preventDefault();
event.stopPropagation();
@@ -4067,7 +4074,9 @@ function App() {
handleCreateConnection();
break;
case 'toggleAIPanel':
toggleAIPanel();
void toggleOrFocusNativeAIChatFromMainWindow().catch((error) => {
void message.error(error instanceof Error ? error.message : String(error));
});
break;
case 'toggleLogPanel':
handleToggleLogPanel();

View File

@@ -785,7 +785,7 @@ describe('NativeDetachedWindowApp', () => {
}
});
it('opens AI settings in the main window without docking or closing the native AI window', async () => {
it('parks the native AI window before opening settings in the main window', async () => {
const bootstrap: NativeDetachedWindowBootstrap = {
id: 'ai-chat',
kind: 'ai-chat',
@@ -797,9 +797,11 @@ describe('NativeDetachedWindowApp', () => {
ready: vi.fn(async () => undefined),
sync: vi.fn(async () => undefined),
attach: vi.fn(async () => undefined),
hide: vi.fn(async () => 9),
close: vi.fn(async () => undefined),
openAISettings: vi.fn(async () => undefined),
closeCurrentWindow: vi.fn(async () => undefined),
hideCurrentWindow: vi.fn(async () => undefined),
};
let renderer: TestRenderer.ReactTestRenderer;
@@ -811,14 +813,80 @@ describe('NativeDetachedWindowApp', () => {
await act(async () => {
renderer!.root.findByProps({ 'data-ai-chat-settings': true }).props.onClick();
await flushEffects();
await flushEffects();
});
expect(client.openAISettings).toHaveBeenCalledWith({ id: 'ai-chat', kind: 'ai-chat' });
expect(client.hide).toHaveBeenCalledOnce();
expect(client.openAISettings).toHaveBeenCalledWith(9);
expect(client.hide.mock.invocationCallOrder[0]).toBeLessThan(
client.openAISettings.mock.invocationCallOrder[0],
);
expect(client.hideCurrentWindow).not.toHaveBeenCalled();
expect(client.attach).not.toHaveBeenCalled();
expect(client.close).not.toHaveBeenCalled();
expect(client.closeCurrentWindow).not.toHaveBeenCalled();
});
it('unlocks the AI window and allows retry when opening settings fails', async () => {
const bootstrap: NativeDetachedWindowBootstrap = {
id: 'ai-chat',
kind: 'ai-chat',
title: 'GoNavi AI',
payload: { storeState: { appearance: { uiVersion: 'v2' }, theme: 'light' } },
};
const settingsError = new Error('parent settings unavailable');
const client = {
load: vi.fn(async () => bootstrap),
ready: vi.fn(async () => undefined),
sync: vi.fn(async () => undefined),
attach: vi.fn(async () => undefined),
hide: vi.fn()
.mockResolvedValueOnce(9)
.mockResolvedValueOnce(11),
close: vi.fn(async () => undefined),
openAISettings: vi.fn()
.mockRejectedValueOnce(settingsError)
.mockResolvedValueOnce(undefined),
closeCurrentWindow: vi.fn(async () => undefined),
hideCurrentWindow: vi.fn(async () => undefined),
};
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
let renderer: TestRenderer.ReactTestRenderer | undefined;
try {
await act(async () => {
renderer = TestRenderer.create(<NativeDetachedWindowApp client={client} />);
await flushEffects();
});
await act(async () => {
renderer!.root.findByProps({ 'data-ai-chat-settings': true }).props.onClick();
await flushEffects();
await flushEffects();
});
expect(client.openAISettings).toHaveBeenNthCalledWith(1, 9);
expect(renderer!.root.findByProps({
'data-ai-chat-presentation': 'detached',
}).props['data-ai-chat-interaction-disabled']).toBe('false');
await act(async () => {
renderer!.root.findByProps({ 'data-ai-chat-settings': true }).props.onClick();
await flushEffects();
await flushEffects();
});
expect(client.openAISettings).toHaveBeenNthCalledWith(2, 11);
expect(client.hideCurrentWindow).not.toHaveBeenCalled();
expect(renderer!.root.findByProps({
'data-ai-chat-presentation': 'detached',
}).props['data-ai-chat-interaction-disabled']).toBe('false');
} finally {
await act(async () => renderer?.unmount());
consoleError.mockRestore();
}
});
it('forwards AI child SQL actions to the main process', async () => {
const previousWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window');
const eventTarget = new EventTarget();
@@ -885,15 +953,16 @@ describe('NativeDetachedWindowApp', () => {
});
it.each([
['workbench', 'workbench:query-native-1', 'Meta+K', 'k', 'KeyK', true, false, false],
['query-result', 'query-result:query-native-1:r1', 'Meta+J', 'j', 'KeyJ', true, false, false],
['ai-chat', 'ai-chat', 'Meta+J', 'j', 'KeyJ', true, false, false],
['workbench', 'workbench:query-native-disabled', 'Meta+J', 'j', 'KeyJ', false, false, false],
['workbench', 'workbench:query-native-ime', 'Meta+J', 'j', 'KeyJ', true, true, false],
['workbench', 'workbench:query-native-composition', 'Meta+J', 'j', 'KeyJ', true, false, true],
['workbench', 'workbench:query-native-1', 'Meta+K', 'k', 'KeyK', true, false, false, false],
['query-result', 'query-result:query-native-1:r1', 'Meta+J', 'j', 'KeyJ', true, false, false, false],
['ai-chat', 'ai-chat', 'Meta+J', 'j', 'KeyJ', true, false, false, false],
['ai-chat', 'ai-chat-repeat', 'Meta+J', 'j', 'KeyJ', true, false, false, true],
['workbench', 'workbench:query-native-disabled', 'Meta+J', 'j', 'KeyJ', false, false, false, false],
['workbench', 'workbench:query-native-ime', 'Meta+J', 'j', 'KeyJ', true, true, false, false],
['workbench', 'workbench:query-native-composition', 'Meta+J', 'j', 'KeyJ', true, false, true, false],
] as const)(
'handles the configured AI shortcut in a detached %s window (%s)',
async (kind, id, combo, key, code, enabled, isComposing, compositionActive) => {
async (kind, id, combo, key, code, enabled, isComposing, compositionActive, repeat) => {
const previousWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window');
const eventTarget = new EventTarget();
Object.defineProperty(globalThis, 'window', {
@@ -981,6 +1050,7 @@ describe('NativeDetachedWindowApp', () => {
altKey: { value: false },
shiftKey: { value: false },
isComposing: { value: isComposing },
repeat: { value: repeat },
});
await act(async () => {
if (compositionActive) {
@@ -990,9 +1060,10 @@ describe('NativeDetachedWindowApp', () => {
await flushEffects();
});
const shouldHandle = enabled && !isComposing && !compositionActive;
expect(event.defaultPrevented).toBe(shouldHandle);
if (shouldHandle) {
const shouldConsume = enabled && !isComposing && !compositionActive;
const shouldForward = shouldConsume && !repeat;
expect(event.defaultPrevented).toBe(shouldConsume);
if (shouldForward) {
expect(client.hostEvent).toHaveBeenCalledWith(expect.objectContaining({
id,
kind,
@@ -1307,6 +1378,218 @@ describe('NativeDetachedWindowApp', () => {
}
});
it('unlocks a parked AI child when a newer focus arrives before native hide returns', async () => {
const previousWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window');
const eventTarget = new EventTarget();
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: Object.assign(eventTarget, {
clearTimeout: globalThis.clearTimeout,
innerWidth: 440,
outerHeight: 720,
outerWidth: 440,
screenX: 80,
screenY: 60,
setTimeout: globalThis.setTimeout,
}),
});
const bootstrap: NativeDetachedWindowBootstrap = {
id: 'ai-chat',
kind: 'ai-chat',
title: 'GoNavi AI',
payload: { storeState: { appearance: { uiVersion: 'v2' }, theme: 'light' } },
};
let markNativeHideStarted: (() => void) | undefined;
const nativeHideStarted = new Promise<void>((resolve) => {
markNativeHideStarted = resolve;
});
let releaseFirstNativeHide: (() => void) | undefined;
let markSecondNativeHideStarted: (() => void) | undefined;
const secondNativeHideStarted = new Promise<void>((resolve) => {
markSecondNativeHideStarted = resolve;
});
const client = {
load: vi.fn(async () => bootstrap),
ready: vi.fn(async () => undefined),
sync: vi.fn(async () => undefined),
attach: vi.fn(async () => undefined),
hide: vi.fn()
.mockResolvedValueOnce(9)
.mockResolvedValueOnce(11),
close: vi.fn(async () => undefined),
openAISettings: vi.fn(async () => undefined),
closeCurrentWindow: vi.fn(async () => undefined),
hideCurrentWindow: vi.fn((visibilityRevision: number) => {
if (visibilityRevision === 9) {
markNativeHideStarted?.();
return new Promise<void>((resolve) => {
releaseFirstNativeHide = resolve;
});
}
markSecondNativeHideStarted?.();
return new Promise<void>(() => undefined);
}),
};
try {
let renderer: TestRenderer.ReactTestRenderer;
await act(async () => {
renderer = TestRenderer.create(<NativeDetachedWindowApp client={client} />);
await flushEffects();
});
await act(async () => {
renderer!.root.findByProps({ 'data-ai-chat-close': true }).props.onClick();
await flushEffects();
});
await nativeHideStarted;
expect(renderer!.root.findByProps({
'data-ai-chat-presentation': 'detached',
}).props['data-ai-chat-interaction-disabled']).toBe('true');
await act(async () => {
runtimeEventListeners.get('gonavi:native-detached-command')?.({
id: 'ai-chat',
action: 'focus',
payload: { visibilityRevision: 10 },
});
await flushEffects();
});
expect(renderer!.root.findByProps({
'data-ai-chat-presentation': 'detached',
}).props['data-ai-chat-interaction-disabled']).toBe('false');
await act(async () => {
runtimeEventListeners.get('gonavi:native-detached-command')?.({
id: 'ai-chat',
action: 'hide',
payload: { visibilityRevision: 9 },
});
const staleHideEvent = new Event('gonavi:native-detached-request-hide');
Object.defineProperty(staleHideEvent, 'detail', {
value: { visibilityRevision: 9 },
});
eventTarget.dispatchEvent(staleHideEvent);
await flushEffects();
});
expect(renderer!.root.findByProps({
'data-ai-chat-presentation': 'detached',
}).props['data-ai-chat-interaction-disabled']).toBe('false');
await act(async () => {
renderer!.root.findByProps({ 'data-ai-chat-close': true }).props.onClick();
await flushEffects();
});
await secondNativeHideStarted;
expect(renderer!.root.findByProps({
'data-ai-chat-presentation': 'detached',
}).props['data-ai-chat-interaction-disabled']).toBe('true');
await act(async () => {
releaseFirstNativeHide?.();
await flushEffects();
});
expect(renderer!.root.findByProps({
'data-ai-chat-presentation': 'detached',
}).props['data-ai-chat-interaction-disabled']).toBe('true');
await act(async () => {
runtimeEventListeners.get('gonavi:native-detached-command')?.({
id: 'ai-chat',
action: 'focus',
payload: { visibilityRevision: 12 },
});
await flushEffects();
});
expect(renderer!.root.findByProps({
'data-ai-chat-presentation': 'detached',
}).props['data-ai-chat-interaction-disabled']).toBe('false');
await act(async () => renderer!.unmount());
} finally {
if (previousWindowDescriptor) {
Object.defineProperty(globalThis, 'window', previousWindowDescriptor);
} else {
Reflect.deleteProperty(globalThis, 'window');
}
}
});
it('does not start a cancelled AI hide when focus arrives before its effect starts', async () => {
const previousWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window');
const eventTarget = new EventTarget();
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: Object.assign(eventTarget, {
clearTimeout: globalThis.clearTimeout,
innerWidth: 440,
outerHeight: 720,
outerWidth: 440,
screenX: 80,
screenY: 60,
setTimeout: globalThis.setTimeout,
}),
});
const bootstrap: NativeDetachedWindowBootstrap = {
id: 'ai-chat',
kind: 'ai-chat',
title: 'GoNavi AI',
payload: { storeState: { appearance: { uiVersion: 'v2' }, theme: 'light' } },
};
const client = {
load: vi.fn(async () => bootstrap),
ready: vi.fn(async () => undefined),
sync: vi.fn(async () => undefined),
attach: vi.fn(async () => undefined),
hide: vi.fn(async () => 9),
close: vi.fn(async () => undefined),
openAISettings: vi.fn(async () => undefined),
closeCurrentWindow: vi.fn(async () => undefined),
hideCurrentWindow: vi.fn(async () => undefined),
};
try {
let renderer: TestRenderer.ReactTestRenderer;
await act(async () => {
renderer = TestRenderer.create(<NativeDetachedWindowApp client={client} />);
await flushEffects();
});
const flushableRenderer = renderer! as TestRenderer.ReactTestRenderer & {
unstable_flushSync: (callback: () => void) => void;
};
flushableRenderer.unstable_flushSync(() => {
renderer!.root.findByProps({ 'data-ai-chat-close': true }).props.onClick();
});
expect(renderer!.root.findByProps({
'data-ai-chat-presentation': 'detached',
}).props['data-ai-chat-interaction-disabled']).toBe('true');
runtimeEventListeners.get('gonavi:native-detached-command')?.({
id: 'ai-chat',
action: 'focus',
payload: { visibilityRevision: 10 },
});
await act(async () => {
await flushEffects();
});
expect(renderer!.root.findByProps({
'data-ai-chat-presentation': 'detached',
}).props['data-ai-chat-interaction-disabled']).toBe('false');
expect(client.hide).not.toHaveBeenCalled();
expect(client.sync).not.toHaveBeenCalled();
expect(client.hideCurrentWindow).not.toHaveBeenCalled();
await act(async () => renderer!.unmount());
} finally {
if (previousWindowDescriptor) {
Object.defineProperty(globalThis, 'window', previousWindowDescriptor);
} else {
Reflect.deleteProperty(globalThis, 'window');
}
}
});
it('lets a graceful close preempt an in-flight AI hide', async () => {
const previousWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window');
const eventTarget = new EventTarget();
@@ -1373,6 +1656,11 @@ describe('NativeDetachedWindowApp', () => {
await act(async () => {
eventTarget.dispatchEvent(new Event('gonavi:native-detached-request-close'));
runtimeEventListeners.get('gonavi:native-detached-command')?.({
id: 'ai-chat',
action: 'focus',
payload: { visibilityRevision: 16 },
});
releaseHide?.();
await flushEffects();
await flushEffects();
@@ -1623,6 +1911,7 @@ describe('NativeDetachedWindowApp', () => {
id: 'ai-chat',
kind: 'ai-chat',
revision: expect.any(Number),
rollbackAction: 'close',
}));
expect(client.cancelClose).toHaveBeenCalledOnce();
expect(client.close).not.toHaveBeenCalled();
@@ -1640,6 +1929,142 @@ describe('NativeDetachedWindowApp', () => {
}
});
it('recovers the AI child when the local close fails and allows a second attach', async () => {
const bootstrap: NativeDetachedWindowBootstrap = {
id: 'ai-chat',
kind: 'ai-chat',
title: 'GoNavi AI',
actionRevision: 40,
payload: { storeState: { appearance: { uiVersion: 'v2' }, theme: 'light' } },
};
const closeError = new Error('native close failed');
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),
cancelCloseRequest: vi.fn(async () => undefined),
openAISettings: vi.fn(async () => undefined),
closeCurrentWindow: vi.fn()
.mockRejectedValueOnce(closeError)
.mockResolvedValueOnce(undefined),
cancelClose: vi.fn(async () => undefined),
};
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
let renderer: TestRenderer.ReactTestRenderer | undefined;
try {
await act(async () => {
renderer = TestRenderer.create(<NativeDetachedWindowApp client={client} />);
await flushEffects();
});
await act(async () => {
renderer!.root.findByProps({ 'data-ai-chat-attach': true }).props.onClick();
await flushEffects();
await flushEffects();
});
expect(client.attach).toHaveBeenCalledWith(expect.objectContaining({ revision: 41 }));
expect(client.closeCurrentWindow).toHaveBeenCalledOnce();
expect(client.cancelCloseRequest).toHaveBeenCalledWith(expect.objectContaining({
id: 'ai-chat',
kind: 'ai-chat',
revision: expect.any(Number),
rollbackAction: 'attach',
}));
expect(client.cancelClose).toHaveBeenCalledOnce();
expect(renderer!.root.findByProps({
'data-ai-chat-presentation': 'detached',
}).props['data-ai-chat-interaction-disabled']).toBe('false');
await act(async () => {
renderer!.root.findByProps({ 'data-ai-chat-attach': true }).props.onClick();
await flushEffects();
await flushEffects();
});
expect(client.attach).toHaveBeenNthCalledWith(2, expect.objectContaining({ revision: 43 }));
expect(client.closeCurrentWindow).toHaveBeenCalledTimes(2);
expect(client.cancelCloseRequest).toHaveBeenCalledOnce();
expect(client.cancelClose).toHaveBeenCalledOnce();
expect(consoleError).toHaveBeenCalledWith(
'[Native Detached Window] Failed to close native window',
closeError,
);
} finally {
await act(async () => {
renderer?.unmount();
});
consoleError.mockRestore();
}
});
it('offers an explicit close retry when either side of close rollback cannot be confirmed', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
try {
for (const failureTarget of ['parent', 'local'] as const) {
const bootstrap: NativeDetachedWindowBootstrap = {
id: 'ai-chat',
kind: 'ai-chat',
title: 'GoNavi AI',
payload: { storeState: { appearance: { uiVersion: 'v2' }, theme: 'light' } },
};
const closeError = new Error(`${failureTarget} convergence failed`);
const cancelError = new Error(`${failureTarget} cancel failed`);
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),
cancelCloseRequest: failureTarget === 'parent'
? vi.fn(async () => { throw cancelError; })
: vi.fn(async () => undefined),
openAISettings: vi.fn(async () => undefined),
closeCurrentWindow: vi.fn()
.mockRejectedValueOnce(closeError)
.mockRejectedValueOnce(closeError)
.mockResolvedValueOnce(undefined),
cancelClose: failureTarget === 'local'
? vi.fn(async () => { throw cancelError; })
: vi.fn(async () => undefined),
};
let renderer: TestRenderer.ReactTestRenderer | undefined;
await act(async () => {
renderer = TestRenderer.create(<NativeDetachedWindowApp client={client} />);
await flushEffects();
});
await act(async () => {
renderer!.root.findByProps({ 'data-ai-chat-attach': true }).props.onClick();
await flushEffects();
await flushEffects();
});
expect(client.attach).toHaveBeenCalledOnce();
expect(client.cancelCloseRequest).toHaveBeenCalledTimes(failureTarget === 'parent' ? 2 : 1);
expect(client.cancelClose).toHaveBeenCalledOnce();
expect(client.closeCurrentWindow).toHaveBeenCalledTimes(2);
expect(renderer!.root.findByProps({
'data-ai-chat-presentation': 'detached',
}).props['data-ai-chat-interaction-disabled']).toBe('true');
await act(async () => {
renderer!.root.findByProps({ 'data-native-close-recovery': true }).props.onClick();
await flushEffects();
});
expect(client.attach).toHaveBeenCalledOnce();
expect(client.closeCurrentWindow).toHaveBeenCalledTimes(3);
await act(async () => renderer?.unmount());
}
} finally {
consoleError.mockRestore();
}
});
it('keeps the AI child open when its terminal guard rejects attach and close', async () => {
const previousWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window');
const eventTarget = new EventTarget();

View File

@@ -25,8 +25,8 @@ import {
fetchNativeDetachedWindowBootstrap,
hydrateNativeDetachedStore,
hideCurrentNativeDetachedWindow,
hideCurrentNativeDetachedWindowForAISettings,
hideNativeDetachedWindow,
openNativeDetachedAISettings,
presentCurrentNativeDetachedWindow,
readyNativeDetachedWindow,
sendNativeDetachedHostEvent,
@@ -65,6 +65,7 @@ const NativeDetachedWindowController = React.lazy(
export const NATIVE_DETACHED_SYNC_DEBOUNCE_MS = 180;
export const NATIVE_DETACHED_PAINT_FALLBACK_MS = 250;
const NATIVE_DETACHED_CANCEL_CLOSE_ATTEMPTS = 2;
export const waitForNativeDetachedContentPaint = (): Promise<void> => {
if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') {
@@ -95,7 +96,7 @@ type NativeDetachedWindowClient = {
hide?: (payload: NativeDetachedWindowActionPayload) => Promise<number>;
close: (payload: NativeDetachedWindowActionPayload) => Promise<void>;
cancelCloseRequest?: (payload: NativeDetachedWindowActionPayload) => Promise<void>;
openAISettings: (payload: NativeDetachedWindowActionPayload) => Promise<void>;
openAISettings: (visibilityRevision: number) => Promise<void>;
hostEvent?: (payload: NativeDetachedWindowActionPayload) => Promise<void>;
closeCurrentWindow: () => Promise<void>;
hideCurrentWindow?: (visibilityRevision: number) => Promise<void>;
@@ -111,7 +112,7 @@ const defaultClient: NativeDetachedWindowClient = {
hide: hideNativeDetachedWindow,
close: closeNativeDetachedWindow,
cancelCloseRequest: cancelNativeDetachedWindowClose,
openAISettings: openNativeDetachedAISettings,
openAISettings: hideCurrentNativeDetachedWindowForAISettings,
hostEvent: sendNativeDetachedHostEvent,
closeCurrentWindow: closeCurrentNativeDetachedWindow,
hideCurrentWindow: hideCurrentNativeDetachedWindow,
@@ -314,11 +315,17 @@ const NativeDetachedWindowApp: React.FC<NativeDetachedWindowAppProps> = ({
const [controllerEnabled, setControllerEnabled] = useState(false);
const markContentReady = useCallback(() => setContentReady(true), []);
const [terminalAction, setTerminalAction] = useState<'attach' | 'hide' | 'close' | null>(null);
const [terminalCloseRecoveryAvailable, setTerminalCloseRecoveryAvailable] = useState(false);
const [terminalCloseRecoveryPending, setTerminalCloseRecoveryPending] = useState(false);
const terminalActionStartedRef = useRef(false);
const terminalActionRequestedRef = useRef(false);
const terminalActionGenerationRef = useRef(0);
const terminalCloseRecoveryPendingRef = useRef(false);
const openAISettingsAfterHideRef = useRef(false);
const activeTerminalActionRef = useRef<'attach' | 'hide' | 'close' | null>(null);
const closePreemptionRequestedRef = useRef(false);
const hideVisibilityRevisionRef = useRef(0);
const lastFocusVisibilityRevisionRef = useRef(0);
const aiTerminalGuardRef = useRef<(() => Promise<boolean>) | null>(null);
const resultSessionRef = useRef<QueryEditorResultSessionSnapshot | null>(null);
const queryResultWindowRef = useRef<DetachedQueryResultWindow | null>(null);
@@ -378,6 +385,10 @@ const NativeDetachedWindowApp: React.FC<NativeDetachedWindowAppProps> = ({
void client.load()
.then((nextBootstrap) => {
if (!active) return;
const bootstrapActionRevision = Math.trunc(Number(nextBootstrap.actionRevision));
actionRevisionRef.current = Number.isFinite(bootstrapActionRevision) && bootstrapActionRevision > 0
? bootstrapActionRevision
: 0;
setContentReady(false);
setControllerEnabled(false);
hydrateNativeDetachedStore(useStore, nextBootstrap.payload.storeState);
@@ -419,6 +430,43 @@ const NativeDetachedWindowApp: React.FC<NativeDetachedWindowAppProps> = ({
return EventsOn(
NATIVE_DETACHED_WINDOW_COMMAND_EVENT,
(command: NativeDetachedHostStateCommand) => {
const isCurrentAIWindow = bootstrap.kind === 'ai-chat'
&& String(command?.id || '') === bootstrap.id;
const visibilityRevision = Math.trunc(Number(command?.payload?.visibilityRevision));
if (isCurrentAIWindow && Number.isFinite(visibilityRevision) && visibilityRevision > 0) {
if (
command.action === 'hide'
&& visibilityRevision > lastFocusVisibilityRevisionRef.current
) {
hideVisibilityRevisionRef.current = Math.max(
hideVisibilityRevisionRef.current,
visibilityRevision,
);
} else if (
command.action === 'focus'
&& visibilityRevision > lastFocusVisibilityRevisionRef.current
) {
lastFocusVisibilityRevisionRef.current = visibilityRevision;
if (
activeTerminalActionRef.current !== 'attach'
&& activeTerminalActionRef.current !== 'close'
&& !closePreemptionRequestedRef.current
&& visibilityRevision > hideVisibilityRevisionRef.current
) {
// WindowHide can suspend the WebView before React commits the
// terminal-action cleanup. A newer native focus is the resume
// boundary, so invalidate the old hide and unlock the live tree.
terminalActionGenerationRef.current += 1;
terminalActionStartedRef.current = false;
terminalActionRequestedRef.current = false;
activeTerminalActionRef.current = null;
openAISettingsAfterHideRef.current = false;
hideVisibilityRevisionRef.current = 0;
setTerminalCloseRecoveryAvailable(false);
setTerminalAction((current) => current === 'hide' ? null : current);
}
}
}
hostStateRevisionRef.current = applyNativeDetachedHostStateCommand(
useStore,
bootstrap.id,
@@ -479,6 +527,7 @@ const NativeDetachedWindowApp: React.FC<NativeDetachedWindowAppProps> = ({
if (!isShortcutMatch(event, binding.combo)) return;
event.preventDefault();
event.stopImmediatePropagation();
if (event.repeat) return;
hostEventSequenceRef.current += 1;
void client.hostEvent?.({
id: bootstrap.id,
@@ -712,17 +761,39 @@ const NativeDetachedWindowApp: React.FC<NativeDetachedWindowAppProps> = ({
visibilityRevision = 0,
) => {
if (!bootstrap) return;
if (action !== 'hide') openAISettingsAfterHideRef.current = false;
const normalizedVisibilityRevision = Math.trunc(Number(visibilityRevision));
if (
action === 'hide'
&& Number.isFinite(normalizedVisibilityRevision)
&& normalizedVisibilityRevision > 0
&& normalizedVisibilityRevision <= lastFocusVisibilityRevisionRef.current
) {
return;
}
if (terminalActionRequestedRef.current) {
if (
action === 'hide'
&& activeTerminalActionRef.current === 'hide'
&& Number.isFinite(normalizedVisibilityRevision)
&& normalizedVisibilityRevision > 0
) {
hideVisibilityRevisionRef.current = Math.max(
hideVisibilityRevisionRef.current,
normalizedVisibilityRevision,
);
}
if (action === 'close' && activeTerminalActionRef.current === 'hide') {
closePreemptionRequestedRef.current = true;
}
return;
}
if (action === 'hide' && bootstrap.kind !== 'ai-chat') return;
terminalActionGenerationRef.current += 1;
setTerminalCloseRecoveryAvailable(false);
terminalActionRequestedRef.current = true;
activeTerminalActionRef.current = action;
closePreemptionRequestedRef.current = false;
const normalizedVisibilityRevision = Math.trunc(Number(visibilityRevision));
hideVisibilityRevisionRef.current = action === 'hide'
&& Number.isFinite(normalizedVisibilityRevision)
&& normalizedVisibilityRevision > 0
@@ -737,11 +808,14 @@ const NativeDetachedWindowApp: React.FC<NativeDetachedWindowAppProps> = ({
}, [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]);
if (
!bootstrap
|| bootstrap.kind !== 'ai-chat'
|| terminalActionRequestedRef.current
) return;
openAISettingsAfterHideRef.current = true;
requestTerminalAction('hide');
}, [bootstrap, requestTerminalAction]);
useEffect(() => {
if (typeof window === 'undefined') return undefined;
@@ -775,11 +849,17 @@ const NativeDetachedWindowApp: React.FC<NativeDetachedWindowAppProps> = ({
!bootstrap
|| !terminalAction
|| terminalActionStartedRef.current
|| !terminalActionRequestedRef.current
|| activeTerminalActionRef.current !== terminalAction
|| (bootstrap.kind !== 'ai-chat' && contentMounted)
) {
return;
}
terminalActionStartedRef.current = true;
const terminalActionGeneration = terminalActionGenerationRef.current;
const isCurrentTerminalAction = () => (
terminalActionGenerationRef.current === terminalActionGeneration
);
// Workbench content has unmounted before this effect runs, so QueryEditor
// has published its final result session to the cache.
@@ -807,14 +887,92 @@ const NativeDetachedWindowApp: React.FC<NativeDetachedWindowAppProps> = ({
closeActionSubmitted = true;
actionToRun = 'close';
};
const rollbackTerminalAction = async () => {
setTerminalCloseRecoveryAvailable(false);
let parentCancelError: unknown;
let parentCancelSucceeded = !client.cancelCloseRequest;
for (
let attempt = 0;
client.cancelCloseRequest && attempt < NATIVE_DETACHED_CANCEL_CLOSE_ATTEMPTS;
attempt += 1
) {
const cancelWorkbench = readWorkbenchSyncData();
const cancelPayload = {
...buildActionPayload(
bootstrap,
readCurrentTab(),
currentSession,
false,
readUnsyncedSqlLogs(),
nextActionRevision(),
cancelWorkbench.workbenchState,
cancelWorkbench.workbenchStateBase,
cancelWorkbench.openedTabs,
sqlLogsClearPendingRef.current,
queryResultWindowRef.current,
),
rollbackAction: actionToRun,
} satisfies NativeDetachedWindowActionPayload;
try {
await client.cancelCloseRequest(cancelPayload);
parentCancelSucceeded = true;
break;
} catch (error) {
parentCancelError = error;
}
}
if (!parentCancelSucceeded) {
console.error(
'[Native Detached Window] Failed to cancel parent close fallback',
parentCancelError,
);
}
if (!isCurrentTerminalAction()) return;
let localCancelError: unknown;
let localCancelSucceeded = false;
try {
await client.cancelClose?.();
localCancelSucceeded = true;
} catch (error) {
localCancelError = error;
console.error(
'[Native Detached Window] Failed to cancel local close fallback',
localCancelError,
);
}
if (!isCurrentTerminalAction()) return;
if (!parentCancelSucceeded || !localCancelSucceeded) {
try {
await client.closeCurrentWindow();
} catch (convergenceError) {
console.error(
'[Native Detached Window] Failed to converge after close rollback',
convergenceError,
);
setTerminalCloseRecoveryAvailable(true);
}
return;
}
terminalActionStartedRef.current = false;
terminalActionRequestedRef.current = false;
activeTerminalActionRef.current = null;
closePreemptionRequestedRef.current = false;
hideVisibilityRevisionRef.current = 0;
setTerminalCloseRecoveryAvailable(false);
setTerminalAction(null);
setContentMounted(true);
};
try {
await actionQueueRef.current;
if (!isCurrentTerminalAction()) return;
if (bootstrap.kind === 'ai-chat') {
const canTerminate = await aiTerminalGuardRef.current?.();
if (!isCurrentTerminalAction()) return;
if (canTerminate === false) {
throw new Error('AI stream did not stop before the detached window handoff');
}
await flushAIChatSessionPersistence();
if (!isCurrentTerminalAction()) return;
}
actionToRun = closePreemptionRequestedRef.current ? 'close' : terminalAction;
if (actionToRun === 'attach' && bootstrap.kind === 'workbench') {
@@ -870,17 +1028,26 @@ const NativeDetachedWindowApp: React.FC<NativeDetachedWindowAppProps> = ({
let visibilityRevision = hideVisibilityRevisionRef.current;
if (visibilityRevision > 0) {
await client.sync(payload);
if (!isCurrentTerminalAction()) return;
} else {
if (!client.hide) throw new Error('Native detached hide action is unavailable');
visibilityRevision = await client.hide(payload);
if (!isCurrentTerminalAction()) return;
hideVisibilityRevisionRef.current = Math.max(
hideVisibilityRevisionRef.current,
visibilityRevision,
);
}
if (closePreemptionRequestedRef.current) {
await submitPreemptingClose();
} else if (openAISettingsAfterHideRef.current) {
await client.openAISettings(visibilityRevision);
} else {
if (!client.hideCurrentWindow) {
throw new Error('Native detached hide control is unavailable');
}
await client.hideCurrentWindow(visibilityRevision);
if (!isCurrentTerminalAction()) return;
if (closePreemptionRequestedRef.current) {
await submitPreemptingClose();
}
@@ -890,6 +1057,7 @@ const NativeDetachedWindowApp: React.FC<NativeDetachedWindowAppProps> = ({
closeActionSubmitted = true;
}
} catch (error) {
if (!isCurrentTerminalAction()) return;
console.error(`[Native Detached Window] Failed to ${actionToRun}`, error);
if (closePreemptionRequestedRef.current && !closeActionSubmitted) {
try {
@@ -898,12 +1066,18 @@ const NativeDetachedWindowApp: React.FC<NativeDetachedWindowAppProps> = ({
console.error('[Native Detached Window] Failed to continue with requested close', closeError);
}
}
if (actionToRun === 'hide' && !closeActionSubmitted) {
if (
actionToRun === 'hide'
&& !closeActionSubmitted
&& !openAISettingsAfterHideRef.current
) {
const visibilityRevision = hideVisibilityRevisionRef.current;
if (visibilityRevision > 0) {
try {
await client.hideCurrentWindow?.(visibilityRevision);
if (!isCurrentTerminalAction()) return;
} catch (localHideError) {
if (!isCurrentTerminalAction()) return;
console.error('[Native Detached Window] Failed to apply requested hide', localHideError);
}
}
@@ -915,61 +1089,32 @@ const NativeDetachedWindowApp: React.FC<NativeDetachedWindowAppProps> = ({
}
}
}
if (!isCurrentTerminalAction()) return;
if (actionToRun === 'hide' && !closeActionSubmitted) {
openAISettingsAfterHideRef.current = false;
terminalActionStartedRef.current = false;
terminalActionRequestedRef.current = false;
activeTerminalActionRef.current = null;
closePreemptionRequestedRef.current = false;
hideVisibilityRevisionRef.current = 0;
setTerminalCloseRecoveryAvailable(false);
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);
await rollbackTerminalAction();
return;
}
}
if (!isCurrentTerminalAction()) return;
if (actionToRun === 'hide') {
openAISettingsAfterHideRef.current = false;
terminalActionStartedRef.current = false;
terminalActionRequestedRef.current = false;
activeTerminalActionRef.current = null;
closePreemptionRequestedRef.current = false;
hideVisibilityRevisionRef.current = 0;
setTerminalCloseRecoveryAvailable(false);
setTerminalAction(null);
return;
}
@@ -977,6 +1122,7 @@ const NativeDetachedWindowApp: React.FC<NativeDetachedWindowAppProps> = ({
await client.closeCurrentWindow();
} catch (error) {
console.error('[Native Detached Window] Failed to close native window', error);
await rollbackTerminalAction();
}
})();
}, [
@@ -992,6 +1138,23 @@ const NativeDetachedWindowApp: React.FC<NativeDetachedWindowAppProps> = ({
terminalAction,
]);
const retryTerminalClose = useCallback(() => {
if (!terminalCloseRecoveryAvailable || terminalCloseRecoveryPendingRef.current) return;
terminalCloseRecoveryPendingRef.current = true;
setTerminalCloseRecoveryPending(true);
void client.closeCurrentWindow()
.then(() => {
setTerminalCloseRecoveryAvailable(false);
})
.catch((error) => {
console.error('[Native Detached Window] Failed to retry native window close', error);
})
.finally(() => {
terminalCloseRecoveryPendingRef.current = false;
setTerminalCloseRecoveryPending(false);
});
}, [client, terminalCloseRecoveryAvailable]);
const requestWindowClose = useCallback(() => {
requestTerminalAction(bootstrap?.kind === 'ai-chat' ? 'hide' : 'close');
}, [bootstrap?.kind, requestTerminalAction]);
@@ -1037,6 +1200,7 @@ const NativeDetachedWindowApp: React.FC<NativeDetachedWindowAppProps> = ({
>
<style>{`
.gn-native-detached-window {
position: relative;
width: 100%;
height: 100%;
min-width: 0;
@@ -1074,6 +1238,13 @@ const NativeDetachedWindowApp: React.FC<NativeDetachedWindowAppProps> = ({
gap: 2px;
--wails-draggable: no-drag;
}
.gn-native-detached-close-recovery {
position: absolute;
top: 6px;
right: 6px;
z-index: ${APP_OVERLAY_Z_INDEX_BASE + 1};
--wails-draggable: no-drag;
}
.gn-native-detached-body {
flex: 1 1 auto;
min-width: 0;
@@ -1145,6 +1316,20 @@ const NativeDetachedWindowApp: React.FC<NativeDetachedWindowAppProps> = ({
--wails-draggable: no-drag;
}
`}</style>
{terminalCloseRecoveryAvailable ? (
<Tooltip title={chromeLabels.close}>
<Button
className="gn-native-detached-close-recovery"
type="text"
size="small"
icon={<CloseOutlined />}
aria-label={chromeLabels.close}
data-native-close-recovery
loading={terminalCloseRecoveryPending}
onClick={retryTerminalClose}
/>
</Tooltip>
) : null}
{bootstrap?.kind !== 'ai-chat' ? <div className="gn-native-detached-chrome">
<div className="gn-native-detached-title" title={bootstrap?.title || ''}>
{bootstrap?.title || ''}

View File

@@ -509,7 +509,7 @@ describe('NativeDetachedWindowController', () => {
}
});
it('routes AI settings requests to the main window without docking the child', () => {
it('parks the native AI child before routing settings to the main window', () => {
const onOpenAISettings = vi.fn();
useStore.setState({
aiPanelVisible: true,
@@ -520,9 +520,11 @@ describe('NativeDetachedWindowController', () => {
id: 'ai-chat',
kind: 'ai-chat',
action: 'open-ai-settings',
payload: { visibilityRevision: 7 },
}, undefined, { onOpenAISettings });
expect(onOpenAISettings).toHaveBeenCalledOnce();
expect(useStore.getState().aiPanelVisible).toBe(false);
expect(useStore.getState().detachedAIChatWindow).not.toBeNull();
applyNativeDetachedWindowEvent({
@@ -818,6 +820,63 @@ describe('NativeDetachedWindowController', () => {
expect(useStore.getState().aiChatHistory['session-1'][0]?.content).toBe('kept');
});
it('restores main-store visibility when the native AI child is focused again', () => {
const detachedAIChatWindow = {
x: 20,
y: 30,
width: 440,
height: 720,
zIndex: 1203,
};
useStore.setState({
aiPanelVisible: false,
detachedAIChatWindow,
});
applyNativeDetachedWindowEvent({
id: 'ai-chat',
kind: 'ai-chat',
action: 'focus',
payload: { visibilityRevision: 8 },
});
expect(useStore.getState().aiPanelVisible).toBe(true);
expect(useStore.getState().detachedAIChatWindow).toBe(detachedAIChatWindow);
applyNativeDetachedWindowEvent({
id: 'ai-chat',
kind: 'ai-chat',
action: 'hide',
payload: { visibilityRevision: 7 },
});
expect(useStore.getState().aiPanelVisible).toBe(true);
});
it('ignores delayed AI settings events older than the latest native focus', () => {
const onOpenAISettings = vi.fn();
useStore.setState({
aiPanelVisible: false,
detachedAIChatWindow: { x: 20, y: 30, width: 440, height: 720, zIndex: 1203 },
});
applyNativeDetachedWindowEvent({
id: 'ai-chat',
kind: 'ai-chat',
action: 'focus',
payload: { visibilityRevision: 8 },
});
applyNativeDetachedWindowEvent({
id: 'ai-chat',
kind: 'ai-chat',
action: 'open-ai-settings',
payload: { visibilityRevision: 7 },
}, undefined, { onOpenAISettings });
expect(onOpenAISettings).not.toHaveBeenCalled();
expect(useStore.getState().aiPanelVisible).toBe(true);
expect(useStore.getState().detachedAIChatWindow).not.toBeNull();
});
it('ignores a delayed hide event older than the latest native focus', () => {
useStore.setState({
aiPanelVisible: true,
@@ -996,6 +1055,76 @@ describe('NativeDetachedWindowController', () => {
}
});
it('re-detaches an inline result when a failed native close cancels attach', () => {
const resultWindow = {
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 {
useStore.setState({ detachedQueryResultWindows: [resultWindow] });
applyNativeDetachedWindowEvent({
id: resultWindow.id,
kind: 'query-result',
action: 'attach',
});
expect(useStore.getState().detachedQueryResultWindows).toEqual([]);
applyNativeDetachedWindowEvent({
id: resultWindow.id,
kind: 'query-result',
action: 'cancel-close',
payload: { resultWindow, rollbackAction: 'attach' },
});
expect(useStore.getState().detachedQueryResultWindows).toEqual([
expect.objectContaining({ id: resultWindow.id, result: resultWindow.result }),
]);
expect(dispatchEvent).toHaveBeenCalledTimes(2);
expect(dispatchEvent.mock.calls[0][0]).toMatchObject({
type: 'gonavi:restore-query-result',
detail: {
sourceQueryTabId: 'query-a',
result: resultWindow.result,
},
});
expect(dispatchEvent.mock.calls[1][0]).toMatchObject({
type: 'gonavi:redetach-query-result',
detail: {
windowId: resultWindow.id,
sourceQueryTabId: 'query-a',
resultKey: 'r1',
},
});
} 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: [{

View File

@@ -10,6 +10,7 @@ import {
forwardNativeDetachedHostEvent,
hasNativeDetachedWindowManager,
hideNativeDetachedWindowById,
recordNativeDetachedVisibilityRevision,
shouldApplyNativeDetachedHideRevision,
syncNativeAIChatHostState,
syncNativeDetachedShortcutOptions,
@@ -20,6 +21,7 @@ import {
mergeNativeDetachedAIContextsDelta,
mergeNativeDetachedStoreDelta,
NATIVE_DETACHED_HOST_EVENT_NAMES,
NATIVE_DETACHED_QUERY_RESULT_REDETACH_EVENT,
type NativeDetachedHostEvent,
type NativeDetachedHostEventName,
type NativeDetachedStoreSnapshot,
@@ -40,6 +42,7 @@ export type NativeDetachedWindowEvent = {
| 'opened'
| 'sync'
| 'attach'
| 'focus'
| 'hide'
| 'close'
| 'cancel-close'
@@ -183,6 +186,7 @@ const restoreQueryResult = (windowId: string): void => {
if (!restored || typeof window === 'undefined') return;
window.dispatchEvent(new CustomEvent('gonavi:restore-query-result', {
detail: {
windowId,
sourceQueryTabId: restored.sourceQueryTabId,
result: restored.result,
},
@@ -246,12 +250,45 @@ export const applyNativeDetachedWindowEvent = (
if (event.action === 'open-ai-settings') {
if (event.kind === 'ai-chat') {
const visibilityRevision = Math.trunc(Number(event.payload?.visibilityRevision));
const latestVisibilityRevision = recordNativeDetachedVisibilityRevision(
id,
visibilityRevision,
);
if (
Number.isFinite(visibilityRevision)
&& visibilityRevision > 0
&& visibilityRevision < latestVisibilityRevision
) return;
const state = useStore.getState();
if (state.detachedAIChatWindow) {
state.setAIPanelVisible(false);
}
showMainWindow();
callbacks.onOpenAISettings?.();
}
return;
}
if (event.action === 'focus') {
if (event.kind === 'ai-chat') {
const visibilityRevision = Math.trunc(Number(event.payload?.visibilityRevision));
const latestVisibilityRevision = recordNativeDetachedVisibilityRevision(
id,
visibilityRevision,
);
if (
Number.isFinite(visibilityRevision)
&& visibilityRevision > 0
&& visibilityRevision < latestVisibilityRevision
) return;
if (!useStore.getState().aiPanelVisible) {
useStore.setState({ aiPanelVisible: true });
}
}
return;
}
if (event.action === 'host-event') {
const hostEvent = event.payload?.hostEvent;
if (
@@ -360,6 +397,20 @@ export const applyNativeDetachedWindowEvent = (
if (latest.tabs.some((item) => item.id === tabId) && !latest.isWorkbenchTabDetached(tabId)) {
latest.detachWorkbenchTab(tabId);
}
} else if (
event.payload?.rollbackAction === 'attach'
&& event.payload.resultWindow
&& typeof window !== 'undefined'
) {
const resultWindow = event.payload.resultWindow;
const windowId = String(resultWindow.id || '').trim();
const sourceQueryTabId = String(resultWindow.sourceQueryTabId || '').trim();
const resultKey = String(resultWindow.result?.key || '').trim();
if (windowId === id && sourceQueryTabId && resultKey) {
window.dispatchEvent(new CustomEvent(NATIVE_DETACHED_QUERY_RESULT_REDETACH_EVENT, {
detail: { windowId, sourceQueryTabId, resultKey },
}));
}
}
return;
}

View File

@@ -2979,6 +2979,95 @@ describe('QueryEditor external SQL save', () => {
expect(dataGridState.latestProps?.data).toEqual(expect.arrayContaining([expect.objectContaining({ a: 1 })]));
});
it('removes only the inline result inserted by a rolled-back native attach', async () => {
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab()} />);
});
const restoreRegistrations = (window.addEventListener as any).mock.calls
.filter(([eventName]: [string]) => eventName === 'gonavi:restore-query-result');
const redetachRegistrations = (window.addEventListener as any).mock.calls
.filter(([eventName]: [string]) => eventName === 'gonavi:redetach-query-result');
expect(restoreRegistrations).toHaveLength(1);
expect(redetachRegistrations).toHaveLength(1);
await act(async () => {
restoreRegistrations[0][1](new CustomEvent('gonavi:restore-query-result', {
detail: {
windowId: 'query-result:tab-1:result-restored',
sourceQueryTabId: 'tab-1',
result: {
key: 'result-restored',
sql: 'select 1 as a',
columns: ['a'],
rows: [{ a: 1 }],
pkColumns: [],
readOnly: true,
},
},
}));
redetachRegistrations[0][1](new CustomEvent('gonavi:redetach-query-result', {
detail: {
windowId: 'query-result:tab-1:result-restored',
sourceQueryTabId: 'tab-1',
resultKey: 'result-restored',
},
}));
});
expect(renderer.root.findAll((node) =>
String(node.props?.className || '').split(/\s+/).includes('query-result-tab-label'),
)).toHaveLength(0);
await act(async () => {
restoreRegistrations[0][1](new CustomEvent('gonavi:restore-query-result', {
detail: {
sourceQueryTabId: 'tab-1',
result: {
key: 'result-existing',
sql: 'select existing',
columns: ['value'],
rows: [{ value: 'existing' }],
pkColumns: [],
readOnly: true,
},
},
}));
restoreRegistrations[0][1](new CustomEvent('gonavi:restore-query-result', {
detail: {
windowId: 'query-result:tab-1:result-existing',
sourceQueryTabId: 'tab-1',
result: {
key: 'result-existing',
sql: 'select detached',
columns: ['value'],
rows: [{ value: 'detached' }],
pkColumns: [],
readOnly: true,
},
},
}));
redetachRegistrations[0][1](new CustomEvent('gonavi:redetach-query-result', {
detail: {
windowId: 'query-result:tab-1:result-existing',
sourceQueryTabId: 'tab-1',
resultKey: 'result-existing',
},
}));
});
expect(renderer.root.findAll((node) =>
String(node.props?.className || '').split(/\s+/).includes('query-result-tab-label'),
)).toHaveLength(1);
expect(dataGridState.latestProps?.data).toEqual([
expect.objectContaining({ value: 'existing' }),
]);
await act(async () => {
renderer.unmount();
});
});
it('closes the final result and synchronously hides the log tab on the next command', async () => {
storeState.appearance.uiVersion = 'v2';
backendApp.DBQueryMulti.mockResolvedValueOnce({

View File

@@ -70,7 +70,10 @@ import {
} from '../utils/queryEditorResultSessionCache';
import { buildEditableTriggerSql } from '../utils/triggerEditSql';
import { openNativeQueryResultWindow } from '../utils/nativeDetachedWindowHost';
import { isNativeDetachedWindow } from '../utils/nativeDetachedWindowClient';
import {
isNativeDetachedWindow,
NATIVE_DETACHED_QUERY_RESULT_REDETACH_EVENT,
} from '../utils/nativeDetachedWindowClient';
import {
getColumnDefinitionComment,
getColumnDefinitionKey,
@@ -1274,6 +1277,10 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
);
const resultSetsRef = useRef(resultSets);
const activeResultKeyRef = useRef(activeResultKey);
const nativeRestoredResultRefs = useRef(new Map<
string,
{ resultKey: string; result: ResultSet }
>());
resultSetsRef.current = resultSets;
activeResultKeyRef.current = activeResultKey;
const [loading, setLoading] = useState(false);
@@ -8740,43 +8747,78 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
if (!restored || typeof restored !== 'object') return;
const restoredKey = String(restored.key || '').trim();
if (!restoredKey) return;
setResultSets((prev) => {
if (prev.some((item) => item.key === restoredKey)) {
return prev;
}
return [
...prev,
{
key: restoredKey,
sql: String(restored.sql || ''),
exportSql: restored.exportSql,
sourceStatementIndex: restored.sourceStatementIndex,
statementResultIndex: restored.statementResultIndex,
rows: Array.isArray(restored.rows) ? restored.rows : [],
columns: Array.isArray(restored.columns) ? restored.columns : [],
messages: Array.isArray(restored.messages) ? restored.messages : undefined,
resultType: restored.resultType === 'message' ? 'message' : 'grid',
tableName: restored.tableName,
metadataDbName: restored.metadataDbName,
metadataTableName: restored.metadataTableName,
ddlDbName: restored.ddlDbName,
ddlTableName: restored.ddlTableName,
pkColumns: Array.isArray(restored.pkColumns) ? restored.pkColumns : [],
editLocator: restored.editLocator,
readOnly: restored.readOnly !== false,
showRowNumberColumn: restored.showRowNumberColumn,
truncated: restored.truncated,
} as ResultSet,
const windowId = String(detail.windowId || '').trim();
const expectedWindowId = `query-result:${sourceQueryTabId}:${restoredKey}`;
if (!resultSetsRef.current.some((item) => item.key === restoredKey)) {
const restoredResult = {
key: restoredKey,
sql: String(restored.sql || ''),
exportSql: restored.exportSql,
sourceStatementIndex: restored.sourceStatementIndex,
statementResultIndex: restored.statementResultIndex,
rows: Array.isArray(restored.rows) ? restored.rows : [],
columns: Array.isArray(restored.columns) ? restored.columns : [],
messages: Array.isArray(restored.messages) ? restored.messages : undefined,
resultType: restored.resultType === 'message' ? 'message' : 'grid',
tableName: restored.tableName,
metadataDbName: restored.metadataDbName,
metadataTableName: restored.metadataTableName,
ddlDbName: restored.ddlDbName,
ddlTableName: restored.ddlTableName,
pkColumns: Array.isArray(restored.pkColumns) ? restored.pkColumns : [],
editLocator: restored.editLocator,
readOnly: restored.readOnly !== false,
showRowNumberColumn: restored.showRowNumberColumn,
truncated: restored.truncated,
} as ResultSet;
const nextResultSets = [
...resultSetsRef.current,
restoredResult,
];
});
resultSetsRef.current = nextResultSets;
setResultSets(nextResultSets);
if (windowId === expectedWindowId) {
nativeRestoredResultRefs.current.set(windowId, {
resultKey: restoredKey,
result: restoredResult,
});
}
} else if (windowId) {
nativeRestoredResultRefs.current.delete(windowId);
}
activeResultKeyRef.current = restoredKey;
setActiveResultKey(restoredKey);
updateResultPanelVisibility(true);
};
const handleRedetachQueryResult = (event: Event) => {
const detail = (event as CustomEvent).detail || {};
const sourceQueryTabId = String(detail.sourceQueryTabId || '').trim();
if (sourceQueryTabId !== tab.id) return;
const resultKey = String(detail.resultKey || '').trim();
const windowId = String(detail.windowId || '').trim();
if (!resultKey || windowId !== `query-result:${sourceQueryTabId}:${resultKey}`) return;
const restoredResult = nativeRestoredResultRefs.current.get(windowId);
nativeRestoredResultRefs.current.delete(windowId);
if (
!restoredResult
|| restoredResult.resultKey !== resultKey
|| resultSetsRef.current.find((item) => item.key === resultKey) !== restoredResult.result
) return;
handleCloseResult(resultKey);
};
window.addEventListener('gonavi:restore-query-result', handleRestoreQueryResult as EventListener);
window.addEventListener(
NATIVE_DETACHED_QUERY_RESULT_REDETACH_EVENT,
handleRedetachQueryResult as EventListener,
);
return () => {
window.removeEventListener('gonavi:restore-query-result', handleRestoreQueryResult as EventListener);
window.removeEventListener(
NATIVE_DETACHED_QUERY_RESULT_REDETACH_EVENT,
handleRedetachQueryResult as EventListener,
);
};
}, [tab.id, updateResultPanelVisibility]);
}, [isV2Ui, tab.id, updateResultPanelVisibility]);
const toggleQueryResultsPanelShortcutLabel =
toggleQueryResultsPanelShortcutBinding.enabled && toggleQueryResultsPanelShortcutBinding.combo

View File

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

View File

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

View File

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

View File

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

View File

@@ -180,13 +180,17 @@ func (b *Bridge) control(request controlRequest) OperationResult {
// Action acknowledges child readiness or forwards sync, hide, attach, or close
// state to the main window.
func (b *Bridge) Action(action string, payload any) OperationResult {
return b.action(action, payload, true)
}
func (b *Bridge) action(action string, payload any, grantForeground bool) OperationResult {
normalizedAction := strings.ToLower(strings.TrimSpace(action))
if normalizedAction == "ready" {
if result := b.presentFrontendReady(); !result.Success {
return result
}
}
if normalizedAction == "open-ai-settings" && b.allowParentForeground != nil {
if grantForeground && normalizedAction == "open-ai-settings" && b.allowParentForeground != nil {
// The detached child owns the current user interaction on Windows. Grant
// the parent permission immediately before it attempts to take focus. This
// is best-effort so an OS rejection never blocks the settings action itself.
@@ -201,10 +205,13 @@ func (b *Bridge) Action(action string, payload any) OperationResult {
if status != http.StatusOK {
return operationFailure(fmt.Sprintf("detached action failed with status %d", status))
}
if result.Success {
if result.Success && (result.Applied == nil || *result.Applied) {
b.mu.Lock()
if normalizedAction == "attach" || normalizedAction == "close" {
switch normalizedAction {
case "attach", "close":
b.terminal = normalizedAction
case "cancel-close":
b.terminal = ""
}
b.mu.Unlock()
}
@@ -520,6 +527,7 @@ func (b *Bridge) emitRuntimeEvent(name string, args ...any) {
// Wails window.
type Control struct {
mu sync.RWMutex
visibilityOpMu sync.Mutex
ctx context.Context
bridge *Bridge
closeGate closeGate
@@ -581,6 +589,7 @@ func (c *Control) markDOMReady(ctx context.Context) {
if c == nil {
return
}
c.visibilityOpMu.Lock()
c.mu.Lock()
if c.ctx == nil {
c.ctx = ctx
@@ -588,22 +597,29 @@ func (c *Control) markDOMReady(ctx context.Context) {
c.domReady = true
presentation := c.takeInitialPresentationLocked()
c.mu.Unlock()
presentation.run()
c.runVisibilityPresentationLocked(presentation)
}
func (c *Control) markFrontendReady() OperationResult {
if c == nil {
return operationFailure("native window control is unavailable")
}
c.visibilityOpMu.Lock()
c.mu.Lock()
if c.closeCommitted {
c.mu.Unlock()
c.visibilityOpMu.Unlock()
return operationFailure("native window close is already committed")
}
if !c.domReady || c.ctx == nil || c.showWindow == nil {
c.mu.Unlock()
c.visibilityOpMu.Unlock()
return operationFailure("native window DOM is not ready")
}
c.frontendReady = true
presentation := c.takeInitialPresentationLocked()
c.mu.Unlock()
presentation.run()
c.runVisibilityPresentationLocked(presentation)
return OperationResult{Success: true}
}
@@ -614,13 +630,21 @@ func (c *Control) Present() OperationResult {
if c == nil {
return operationFailure("native window control is unavailable")
}
c.visibilityOpMu.Lock()
c.mu.Lock()
if c.closeCommitted {
c.mu.Unlock()
c.visibilityOpMu.Unlock()
return operationFailure("native window close is already committed")
}
if !c.domReady || c.ctx == nil || c.showWindow == nil {
c.mu.Unlock()
c.visibilityOpMu.Unlock()
return operationFailure("native window DOM is not ready")
}
if c.visible {
c.mu.Unlock()
c.visibilityOpMu.Unlock()
return OperationResult{Success: true}
}
c.visible = true
@@ -633,7 +657,7 @@ func (c *Control) Present() OperationResult {
presentation.bridge = c.bridge
}
c.mu.Unlock()
presentation.run()
c.runVisibilityPresentationLocked(presentation)
return OperationResult{Success: true}
}
@@ -645,23 +669,37 @@ type childWindowPresentation struct {
visibilityRevision uint64
}
func (p childWindowPresentation) run() {
func (p childWindowPresentation) runNative() {
if p.show != nil {
p.show(p.ctx)
}
if p.focus != nil {
p.focus(p.ctx)
if p.bridge != nil && p.visibilityRevision > 0 {
// The parent must retain its pending focus until the native focus
// callback has actually run. A failed acknowledgement deliberately
// leaves that pending command available for the next SSE reconnect.
_ = p.bridge.acknowledgeFocus(p.ctx, p.visibilityRevision)
}
}
}
func (p childWindowPresentation) acknowledgeFocus() {
if p.focus == nil || p.bridge == nil || p.visibilityRevision == 0 {
return
}
// The parent must retain its pending focus until the native focus callback
// has actually run. A failed acknowledgement deliberately leaves that
// pending command available for the next SSE reconnect.
_ = p.bridge.acknowledgeFocus(p.ctx, p.visibilityRevision)
}
// runVisibilityPresentationLocked keeps state selection and native effects in
// one visibility operation, then releases the lock before parent RPC.
func (c *Control) runVisibilityPresentationLocked(presentation childWindowPresentation) {
func() {
defer c.visibilityOpMu.Unlock()
presentation.runNative()
}()
presentation.acknowledgeFocus()
}
func (c *Control) takeInitialPresentationLocked() childWindowPresentation {
if c.visible || !c.domReady || !c.frontendReady || c.ctx == nil || c.showWindow == nil {
if c.closeCommitted || c.visible || !c.domReady || !c.frontendReady || c.ctx == nil || c.showWindow == nil {
return childWindowPresentation{}
}
c.visible = true
@@ -680,6 +718,8 @@ func (c *Control) Close() OperationResult {
if c == nil {
return operationFailure("native window control is unavailable")
}
c.visibilityOpMu.Lock()
defer c.visibilityOpMu.Unlock()
c.mu.Lock()
ctx := c.ctx
quit := c.quit
@@ -705,6 +745,8 @@ func (c *Control) Hide(visibilityRevision uint64) OperationResult {
if c == nil {
return operationFailure("native window control is unavailable")
}
c.visibilityOpMu.Lock()
defer c.visibilityOpMu.Unlock()
c.mu.Lock()
if visibilityRevision < c.visibilityRevision {
currentRevision := c.visibilityRevision
@@ -736,6 +778,53 @@ func (c *Control) Hide(visibilityRevision uint64) OperationResult {
return OperationResult{Success: true, VisibilityRevision: visibilityRevision}
}
// HideForAISettings parks the child before asking the parent to render its
// settings modal. The request runs in Go after WindowHide, so WebView suspension
// cannot leave the modal behind the detached window.
func (c *Control) HideForAISettings(visibilityRevision uint64) OperationResult {
if c == nil || c.bridge == nil {
return operationFailure("native window control is unavailable")
}
bridge := c.bridge
if bridge.allowParentForeground != nil {
// Windows requires the currently foreground child to grant activation to
// its parent before the child is hidden.
_ = bridge.allowParentForeground()
}
hideResult := c.Hide(visibilityRevision)
if !hideResult.Success {
return hideResult
}
if hideResult.VisibilityRevision != visibilityRevision {
failure := operationFailure("open AI settings was superseded by a newer window focus")
failure.VisibilityRevision = hideResult.VisibilityRevision
return failure
}
actionResult := bridge.action("open-ai-settings", map[string]any{
"id": bridge.windowID,
"kind": bridge.kind,
"visibilityRevision": visibilityRevision,
}, false)
if actionResult.Success && (actionResult.Applied == nil || *actionResult.Applied) {
return OperationResult{
Success: true,
ID: bridge.windowID,
VisibilityRevision: visibilityRevision,
}
}
// Do not strand the user in a hidden child when the parent request fails.
restoreResult := bridge.FocusWindow(bridge.windowID)
if restoreResult.Success {
_ = c.FocusRevision(restoreResult.VisibilityRevision)
} else {
_ = c.FocusRevision(visibilityRevision)
}
failure := operationFailure(fmt.Sprintf("open AI settings failed: %s", actionResult.Message))
failure.VisibilityRevision = visibilityRevision
return failure
}
// CancelClose keeps the child alive after a failed final frontend flush. It
// also invalidates any native-close fallback so a retry starts a fresh grace
// period instead of inheriting the old timeout.
@@ -799,34 +888,42 @@ func (c *Control) scheduleCloseFallback(fallbackCtx context.Context) {
c.closeFallbackGeneration++
generation := c.closeFallbackGeneration
c.closeFallback = time.AfterFunc(delay, func() {
c.mu.Lock()
if c.closeCommitted ||
c.closeFallbackGeneration != generation ||
c.closeGate.isAllowed() {
c.closeFallback = nil
c.mu.Unlock()
return
}
c.closeFallback = nil
c.closeCommitted = true
ctx := c.ctx
quit := c.quit
c.mu.Unlock()
c.closeGate.allow()
if c.bridge != nil {
c.bridge.notifyClosing()
}
if ctx == nil {
ctx = fallbackCtx
}
if ctx != nil && quit != nil {
quit(ctx)
}
c.runCloseFallback(generation, fallbackCtx)
})
c.mu.Unlock()
}
func (c *Control) runCloseFallback(generation uint64, fallbackCtx context.Context) {
c.visibilityOpMu.Lock()
defer c.visibilityOpMu.Unlock()
c.mu.Lock()
if c.closeFallbackGeneration != generation {
c.mu.Unlock()
return
}
if c.closeCommitted || c.closeGate.isAllowed() {
c.closeFallback = nil
c.mu.Unlock()
return
}
c.closeFallback = nil
c.closeCommitted = true
ctx := c.ctx
quit := c.quit
c.mu.Unlock()
c.closeGate.allow()
if c.bridge != nil {
c.bridge.notifyClosing()
}
if ctx == nil {
ctx = fallbackCtx
}
if ctx != nil && quit != nil {
quit(ctx)
}
}
func (c *Control) invalidateCloseFallbackLocked() {
c.closeFallbackGeneration++
if c.closeFallback != nil {
@@ -851,10 +948,17 @@ func (c *Control) FocusRevision(visibilityRevision uint64) OperationResult {
if c == nil {
return operationFailure("native window control is unavailable")
}
c.visibilityOpMu.Lock()
c.mu.Lock()
if c.closeCommitted {
c.mu.Unlock()
c.visibilityOpMu.Unlock()
return operationFailure("native window close is already committed")
}
if visibilityRevision < c.visibilityRevision {
currentRevision := c.visibilityRevision
c.mu.Unlock()
c.visibilityOpMu.Unlock()
return OperationResult{
Success: true,
Message: "stale native window focus ignored",
@@ -866,6 +970,7 @@ func (c *Control) FocusRevision(visibilityRevision uint64) OperationResult {
focus := c.focusWindow
if ctx == nil || focus == nil {
c.mu.Unlock()
c.visibilityOpMu.Unlock()
return operationFailure("native window is not ready")
}
if !c.visible {
@@ -873,7 +978,7 @@ func (c *Control) FocusRevision(visibilityRevision uint64) OperationResult {
c.focusPendingRevision = visibilityRevision
presentation := c.takeInitialPresentationLocked()
c.mu.Unlock()
presentation.run()
c.runVisibilityPresentationLocked(presentation)
return OperationResult{Success: true, VisibilityRevision: visibilityRevision}
}
c.focusPending = false
@@ -885,6 +990,6 @@ func (c *Control) FocusRevision(visibilityRevision uint64) OperationResult {
visibilityRevision: visibilityRevision,
}
c.mu.Unlock()
presentation.run()
c.runVisibilityPresentationLocked(presentation)
return OperationResult{Success: true, VisibilityRevision: visibilityRevision}
}

View File

@@ -156,6 +156,96 @@ func TestControlCancelCloseInvalidatesFallbackAndAllowsRetry(t *testing.T) {
}
}
func TestBridgeCancelCloseResetsTerminalFallback(t *testing.T) {
requests := make(chan actionRequest, 3)
bridge := newBridge(ChildOptions{
ParentURL: "http://127.0.0.1:43119",
Token: "test-token",
ID: "ai-chat",
Kind: "ai-chat",
})
bridge.client.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) {
defer r.Body.Close()
var request actionRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Errorf("decode fallback action: %v", err)
}
requests <- request
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{"success":true,"id":"ai-chat"}`)),
Header: make(http.Header),
}, nil
})
control := newControl(bridge)
if result := bridge.Action("attach", map[string]any{"revision": 1}); !result.Success {
t.Fatalf("attach Action result = %#v", result)
}
if result := bridge.Action("cancel-close", map[string]any{"revision": 2}); !result.Success {
t.Fatalf("cancel-close Action result = %#v", result)
}
if result := control.CancelClose(); !result.Success {
t.Fatalf("CancelClose result = %#v", result)
}
bridge.notifyClosing()
for _, expectedAction := range []string{"attach", "cancel-close", "close"} {
select {
case request := <-requests:
if request.Action != expectedAction {
t.Fatalf("action = %q, want %q", request.Action, expectedAction)
}
case <-time.After(time.Second):
t.Fatalf("missing %q action after terminal rollback", expectedAction)
}
}
}
func TestBridgeIgnoredTerminalActionDoesNotSuppressCloseFallback(t *testing.T) {
requests := make(chan actionRequest, 2)
bridge := newBridge(ChildOptions{
ParentURL: "http://127.0.0.1:43119",
Token: "test-token",
ID: "ai-chat",
Kind: "ai-chat",
})
bridge.client.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) {
defer r.Body.Close()
var request actionRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Errorf("decode action: %v", err)
}
requests <- request
body := `{"success":true,"id":"ai-chat"}`
if request.Action == "attach" {
body = `{"success":true,"applied":false,"message":"stale detached action ignored","id":"ai-chat"}`
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(body)),
Header: make(http.Header),
}, nil
})
result := bridge.Action("attach", map[string]any{"revision": 1})
if !result.Success || result.Applied == nil || *result.Applied {
t.Fatalf("ignored attach result = %#v", result)
}
bridge.notifyClosing()
for _, expectedAction := range []string{"attach", "close"} {
select {
case request := <-requests:
if request.Action != expectedAction {
t.Fatalf("action = %q, want %q", request.Action, expectedAction)
}
case <-time.After(time.Second):
t.Fatalf("missing %q action after ignored terminal action", expectedAction)
}
}
}
func TestNotifyClosingStillSendsOneFallbackAction(t *testing.T) {
requests := make(chan actionRequest, 2)
bridge := newBridge(ChildOptions{

View File

@@ -1,13 +1,201 @@
package nativewindow
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestControlHidesBeforeOpeningAISettingsInParent(t *testing.T) {
bridge := newBridge(ChildOptions{
ParentURL: "http://127.0.0.1:43119",
Token: "test-token",
ID: "ai-chat",
Kind: "ai-chat",
})
control := newControl(bridge)
InitializeControl(control, context.Background())
steps := make([]string, 0, 3)
bridge.allowParentForeground = func() error {
steps = append(steps, "allow-parent-foreground")
return nil
}
control.hideWindow = func(context.Context) {
steps = append(steps, "hide-window")
}
bridge.client.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) {
if request.URL.Path != ActionPath {
t.Fatalf("unexpected request path %q", request.URL.Path)
}
steps = append(steps, "post-action")
return successfulForegroundActionResponse(), nil
})
result := control.HideForAISettings(7)
if !result.Success || result.VisibilityRevision != 7 {
t.Fatalf("HideForAISettings result = %#v", result)
}
if got := strings.Join(steps, ","); got != "allow-parent-foreground,hide-window,post-action" {
t.Fatalf("HideForAISettings sequence = %q", got)
}
}
func TestControlDoesNotOpenAISettingsAfterHideIsSupersededByFocus(t *testing.T) {
bridge := newBridge(ChildOptions{
ParentURL: "http://127.0.0.1:43119",
Token: "test-token",
ID: "ai-chat",
Kind: "ai-chat",
})
control := newControl(bridge)
InitializeControl(control, context.Background())
control.visibilityRevision = 8
control.visible = true
hides := 0
posts := 0
control.hideWindow = func(context.Context) { hides++ }
bridge.client.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) {
posts++
return successfulForegroundActionResponse(), nil
})
result := control.HideForAISettings(7)
if result.Success || !strings.Contains(result.Message, "superseded") {
t.Fatalf("stale HideForAISettings result = %#v", result)
}
if hides != 0 || posts != 0 {
t.Fatalf("stale settings action reached native/parent: hides=%d posts=%d", hides, posts)
}
}
func TestControlRestoresAIWindowWhenOpeningSettingsFails(t *testing.T) {
bridge := newBridge(ChildOptions{
ParentURL: "http://127.0.0.1:43119",
Token: "test-token",
ID: "ai-chat",
Kind: "ai-chat",
})
control := newControl(bridge)
ctx := context.Background()
InitializeControl(control, ctx)
steps := make([]string, 0, 8)
control.showWindow = func(context.Context) { steps = append(steps, "show-window") }
control.hideWindow = func(context.Context) { steps = append(steps, "hide-window") }
control.focusWindow = func(context.Context) { steps = append(steps, "focus-window") }
control.markDOMReady(ctx)
if result := control.markFrontendReady(); !result.Success {
t.Fatalf("markFrontendReady result = %#v", result)
}
steps = steps[:0]
bridge.allowParentForeground = func() error {
steps = append(steps, "allow-parent-foreground")
return nil
}
bridge.client.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) {
switch request.URL.Path {
case ActionPath:
steps = append(steps, "post-action")
return nil, errors.New("parent unavailable")
case ControlPath:
steps = append(steps, "focus-parent")
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(
`{"success":true,"id":"ai-chat","visibilityRevision":8}`,
)),
Header: make(http.Header),
}, nil
case CommandStatePath:
steps = append(steps, "ack-focus")
return successfulForegroundActionResponse(), nil
default:
t.Fatalf("unexpected request path %q", request.URL.Path)
return nil, nil
}
})
result := control.HideForAISettings(7)
if result.Success || !strings.Contains(result.Message, "parent unavailable") {
t.Fatalf("failed HideForAISettings result = %#v", result)
}
if got := strings.Join(steps, ","); got != "allow-parent-foreground,hide-window,post-action,focus-parent,show-window,focus-window,ack-focus" {
t.Fatalf("failed HideForAISettings recovery sequence = %q", got)
}
control.mu.RLock()
visible := control.visible
revision := control.visibilityRevision
control.mu.RUnlock()
if !visible || revision != 8 {
t.Fatalf("restored visibility = visible %v revision %d, want true/8", visible, revision)
}
}
func TestControlRestoresParentAndChildVisibilityThroughAuthenticatedSelfFocus(t *testing.T) {
manager := newHTTPTestManager(t)
manager.windows["ai-chat"] = &windowEntry{
info: WindowInfo{
ID: "ai-chat",
Kind: "ai-chat",
Title: "GoNavi AI",
Hidden: true,
},
visibilityRevision: 8,
}
bridge := newBridge(ChildOptions{
ParentURL: "http://127.0.0.1:43119",
Token: manager.token,
ID: "ai-chat",
Kind: "ai-chat",
})
bridge.client.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) {
request.RemoteAddr = "127.0.0.1:51003"
recorder := httptest.NewRecorder()
manager.authenticatedHandler().ServeHTTP(recorder, request)
return recorder.Result(), nil
})
control := newControl(bridge)
ctx := context.Background()
InitializeControl(control, ctx)
control.showWindow = func(context.Context) {}
control.hideWindow = func(context.Context) {}
control.focusWindow = func(context.Context) {}
control.markDOMReady(ctx)
if result := control.markFrontendReady(); !result.Success {
t.Fatalf("markFrontendReady result = %#v", result)
}
result := control.HideForAISettings(7)
if result.Success || !strings.Contains(result.Message, "ignored after a newer visibility action") {
t.Fatalf("superseded HideForAISettings result = %#v", result)
}
manager.mu.RLock()
managerEntry := manager.windows["ai-chat"]
managerHidden := managerEntry.info.Hidden
managerRevision := managerEntry.visibilityRevision
pendingFocusRevision := managerEntry.pendingFocusRevision
manager.mu.RUnlock()
if managerHidden || managerRevision != 9 || pendingFocusRevision != 0 {
t.Fatalf(
"restored manager visibility = hidden %v revision %d pending %d, want false/9/0",
managerHidden,
managerRevision,
pendingFocusRevision,
)
}
control.mu.RLock()
childVisible := control.visible
childRevision := control.visibilityRevision
control.mu.RUnlock()
if !childVisible || childRevision != 9 {
t.Fatalf("restored child visibility = visible %v revision %d, want true/9", childVisible, childRevision)
}
}
func TestBridgeAllowsParentForegroundImmediatelyBeforeOpeningAISettings(t *testing.T) {
bridge := newBridge(ChildOptions{
ParentURL: "http://127.0.0.1:43119",

View File

@@ -41,6 +41,7 @@ const (
const (
defaultGracefulCloseTimeout = 10 * time.Second
defaultOpenReadyTimeout = 10 * time.Second
staleDetachedActionMessage = "stale detached action ignored"
)
type processExit struct {
@@ -295,7 +296,7 @@ func (m *Manager) open(request OpenRequest, ownerID string) OperationResult {
return operationFailure("native window manager is not running")
}
if existing, exists := m.windows[request.ID]; exists {
if existing.info.CloseSent {
if windowEntryIsTerminating(existing) {
m.mu.Unlock()
return closingWindowRetryFailure(request.ID)
}
@@ -422,7 +423,7 @@ func (m *Manager) Focus(id string) OperationResult {
m.mu.Unlock()
return operationFailure("native window was not found")
}
if entry.info.CloseSent {
if windowEntryIsTerminating(entry) {
m.mu.Unlock()
return closingWindowRetryFailure(id)
}
@@ -434,6 +435,7 @@ func (m *Manager) Focus(id string) OperationResult {
entry.info.Hidden = false
entry.pendingFocusRevision = entry.visibilityRevision
visibilityRevision := entry.visibilityRevision
kind := entry.info.Kind
bounds := windowBoundsFromInfo(entry.info)
emitToChild := m.emitToChild
shared := m.shared
@@ -449,6 +451,14 @@ func (m *Manager) Focus(id string) OperationResult {
shared.EmitTo(id, CommandEventName, command)
}
if wasHidden {
m.emitDetached(Event{
ID: id,
Kind: kind,
Action: "focus",
Payload: visibilityCommandPayload{
VisibilityRevision: visibilityRevision,
},
})
publishDetachedDockMenuSnapshot(m)
}
return OperationResult{
@@ -591,6 +601,10 @@ func (m *Manager) CancelClose(id string) OperationResult {
m.mu.Unlock()
return operationFailure("native window was not found")
}
if m.closing || entry.exitReason == ExitReasonParentShutdown {
m.mu.Unlock()
return operationFailure("native window manager shutdown cannot be cancelled")
}
m.cancelCloseLocked(entry)
m.mu.Unlock()
publishDetachedDockMenuSnapshot(m)
@@ -601,11 +615,36 @@ func (m *Manager) cancelCloseLocked(entry *windowEntry) {
entry.closeGeneration++
entry.info.CloseSent = false
switch entry.exitReason {
case ExitReasonRequested, ExitReasonParentShutdown, ExitReasonAttached, ExitReasonWindowClosed:
case ExitReasonRequested, ExitReasonAttached, ExitReasonWindowClosed:
entry.exitReason = ""
}
}
func (m *Manager) cancelCloseActionLocked(entry *windowEntry, rollbackAction string) bool {
if m.closing || entry == nil || entry.exitReason == ExitReasonParentShutdown {
return false
}
switch rollbackAction {
case "attach":
if entry.info.CloseSent || (entry.exitReason != "" && entry.exitReason != ExitReasonAttached) {
return false
}
case "close":
validRequestedClose := entry.info.CloseSent && entry.exitReason == ExitReasonRequested
validWindowClose := !entry.info.CloseSent && entry.exitReason == ExitReasonWindowClosed
alreadyOpen := !entry.info.CloseSent && entry.exitReason == ""
if !validRequestedClose && !validWindowClose && !alreadyOpen {
return false
}
case "":
// Compatibility with children started before rollbackAction was added.
default:
return false
}
m.cancelCloseLocked(entry)
return true
}
// CloseAll requests graceful shutdown of every detached child.
func (m *Manager) CloseAll() OperationResult {
if m == nil {
@@ -831,6 +870,10 @@ func operationFailure(message string) OperationResult {
return OperationResult{Success: false, Message: message}
}
func windowEntryIsTerminating(entry *windowEntry) bool {
return entry != nil && (entry.info.CloseSent || entry.exitReason != "")
}
func closingWindowRetryFailure(id string) OperationResult {
return OperationResult{
Success: false,
@@ -993,7 +1036,13 @@ func (m *Manager) handleBootstrap(w http.ResponseWriter, r *http.Request) {
http.Error(w, "unknown detached window", http.StatusNotFound)
return
}
bootstrap := Bootstrap{ID: entry.info.ID, Kind: entry.info.Kind, Title: entry.info.Title, Payload: entry.payload}
bootstrap := Bootstrap{
ID: entry.info.ID,
Kind: entry.info.Kind,
Title: entry.info.Title,
Payload: entry.payload,
ActionRevision: entry.actionRevision,
}
m.mu.Unlock()
w.Header().Set("Content-Type", "application/json; charset=utf-8")
@@ -1146,6 +1195,7 @@ func (m *Manager) handleAction(w http.ResponseWriter, r *http.Request) {
id := strings.TrimSpace(r.Header.Get(HeaderWindowID))
revision := positiveActionRevision(request.Payload)
requestedAISettingsVisibilityRevision := uint64(0)
m.mu.Lock()
entry, exists := m.windows[id]
if !exists {
@@ -1153,6 +1203,37 @@ func (m *Manager) handleAction(w http.ResponseWriter, r *http.Request) {
http.Error(w, "unknown detached window", http.StatusNotFound)
return
}
if request.Action == "open-ai-settings" {
requestedAISettingsVisibilityRevision = positiveVisibilityRevision(request.Payload)
if requestedAISettingsVisibilityRevision == 0 ||
requestedAISettingsVisibilityRevision != entry.visibilityRevision ||
!entry.info.Hidden {
visibilityRevision := entry.visibilityRevision
m.mu.Unlock()
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(OperationResult{
Success: true,
Applied: operationApplied(false),
ID: id,
Message: "open AI settings ignored after a newer visibility action",
VisibilityRevision: visibilityRevision,
})
return
}
}
if request.Action == "cancel-close" && (m.closing || entry.exitReason == ExitReasonParentShutdown) {
visibilityRevision := entry.visibilityRevision
m.mu.Unlock()
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(OperationResult{
Success: true,
Applied: operationApplied(false),
ID: id,
Message: "detached close cancellation ignored while parent is closing",
VisibilityRevision: visibilityRevision,
})
return
}
if actionUsesRevision(request.Action) && revision > 0 {
if revision <= entry.actionRevision {
visibilityRevision := entry.visibilityRevision
@@ -1160,8 +1241,9 @@ func (m *Manager) handleAction(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(OperationResult{
Success: true,
Applied: operationApplied(false),
ID: id,
Message: "stale detached action ignored",
Message: staleDetachedActionMessage,
VisibilityRevision: visibilityRevision,
})
return
@@ -1169,7 +1251,7 @@ func (m *Manager) handleAction(w http.ResponseWriter, r *http.Request) {
entry.actionRevision = revision
}
eventAction := request.Action
visibilityRevision := uint64(0)
visibilityRevision := requestedAISettingsVisibilityRevision
if request.Action == "ready" {
entry.info.Ready = true
entry.readyOnce.Do(func() {
@@ -1178,7 +1260,9 @@ func (m *Manager) handleAction(w http.ResponseWriter, r *http.Request) {
}
})
} else if request.Action == "attach" {
entry.exitReason = ExitReasonAttached
if entry.exitReason == "" {
entry.exitReason = ExitReasonAttached
}
entry.pendingFocusRevision = 0
} else if request.Action == "close" && entry.exitReason == "" {
entry.exitReason = ExitReasonWindowClosed
@@ -1206,7 +1290,19 @@ func (m *Manager) handleAction(w http.ResponseWriter, r *http.Request) {
}
request.Payload = withVisibilityRevision(request.Payload, visibilityRevision)
} else if request.Action == "cancel-close" {
m.cancelCloseLocked(entry)
if !m.cancelCloseActionLocked(entry, rollbackActionFromPayload(request.Payload)) {
visibilityRevision = entry.visibilityRevision
m.mu.Unlock()
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(OperationResult{
Success: true,
Applied: operationApplied(false),
ID: id,
Message: "detached close cancellation no longer matches the active close",
VisibilityRevision: visibilityRevision,
})
return
}
}
info := entry.info
ownerID := entry.ownerID
@@ -1224,15 +1320,40 @@ func (m *Manager) handleAction(w http.ResponseWriter, r *http.Request) {
})
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
applied := revisionedActionApplied(request.Action, revision)
if request.Action == "open-ai-settings" {
applied = operationApplied(true)
}
_ = json.NewEncoder(w).Encode(OperationResult{
Success: true,
Applied: applied,
ID: id,
VisibilityRevision: visibilityRevision,
})
}
func actionUsesRevision(action string) bool {
return action == "sync" || action == "attach" || action == "close" || action == "hide"
return action == "sync" || action == "attach" || action == "close" || action == "hide" || action == "cancel-close"
}
func operationApplied(value bool) *bool {
return &value
}
func revisionedActionApplied(action string, revision int64) *bool {
if !actionUsesRevision(action) || revision <= 0 {
return nil
}
return operationApplied(true)
}
func rollbackActionFromPayload(payload any) string {
record, ok := payload.(map[string]any)
if !ok {
return ""
}
rollbackAction, _ := record["rollbackAction"].(string)
return strings.ToLower(strings.TrimSpace(rollbackAction))
}
func positiveVisibilityRevision(payload any) uint64 {
@@ -1355,7 +1476,7 @@ func (m *Manager) handleControl(w http.ResponseWriter, r *http.Request) {
})
}
case "focus":
if !m.ownsWindow(request.ID, ownerID) {
if !m.canFocusWindow(request.ID, ownerID) {
result = operationFailure("native window is not owned by this window")
break
}
@@ -1429,6 +1550,18 @@ func (m *Manager) ownsWindow(id string, ownerID string) bool {
return exists && entry.ownerID == strings.TrimSpace(ownerID)
}
func (m *Manager) canFocusWindow(id string, ownerID string) bool {
id = strings.TrimSpace(id)
ownerID = strings.TrimSpace(ownerID)
if id == "" || ownerID == "" {
return false
}
m.mu.RLock()
defer m.mu.RUnlock()
entry, exists := m.windows[id]
return exists && (id == ownerID || entry.ownerID == ownerID)
}
func (m *Manager) closeOwned(ownerID string) OperationResult {
ownerID = strings.TrimSpace(ownerID)
if ownerID == "" {

View File

@@ -486,6 +486,13 @@ func TestManagerHideIsIdempotentAndFocusAdvancesVisibilityRevision(t *testing.T)
},
}
commands := make(chan childCommand, 3)
events := make(chan Event, 2)
manager.runtimeCtx = context.Background()
manager.emitToWails = func(_ context.Context, name string, args ...any) {
if name == MainEventName && len(args) == 1 {
events <- args[0].(Event)
}
}
manager.emitToChild = func(targetID string, name string, args ...any) {
if targetID != "ai-chat" || name != CommandEventName {
t.Fatalf("unexpected target event %q %q", targetID, name)
@@ -522,6 +529,11 @@ func TestManagerHideIsIdempotentAndFocusAdvancesVisibilityRevision(t *testing.T)
focusCommand.Payload.(visibilityCommandPayload).VisibilityRevision != 2 {
t.Fatalf("focus command = %#v", focusCommand)
}
focusEvent := receiveEvent(t, events)
if focusEvent.ID != "ai-chat" || focusEvent.Kind != "ai-chat" || focusEvent.Action != "focus" ||
positiveVisibilityRevision(focusEvent.Payload) != 2 {
t.Fatalf("focus lifecycle event = %#v", focusEvent)
}
manager.mu.RLock()
hidden := manager.windows["ai-chat"].info.Hidden
manager.mu.RUnlock()
@@ -762,6 +774,10 @@ func TestStaleHideActionCannotOverrideNewerFocus(t *testing.T) {
if result := manager.Focus("ai-chat"); !result.Success || result.VisibilityRevision != 2 {
t.Fatalf("Focus result = %#v", result)
}
focusEvent := receiveEvent(t, events)
if focusEvent.Action != "focus" || positiveVisibilityRevision(focusEvent.Payload) != 2 {
t.Fatalf("Focus event = %#v", focusEvent)
}
body := strings.NewReader(
`{"action":"hide","payload":{"id":"ai-chat","kind":"ai-chat","revision":2,"visibilityRevision":1}}`,
)
@@ -824,9 +840,10 @@ func TestAuthenticatedHostStateEndpointReturnsRetainedSnapshot(t *testing.T) {
func TestAuthenticatedHandlerRequiresLoopbackTokenAndRegisteredWindow(t *testing.T) {
manager := newHTTPTestManager(t)
manager.windows["window-1"] = &windowEntry{
info: WindowInfo{ID: "window-1", Kind: "query-result", Title: "Result"},
payload: map[string]any{"value": "shared"},
ready: make(chan struct{}),
info: WindowInfo{ID: "window-1", Kind: "query-result", Title: "Result"},
payload: map[string]any{"value": "shared"},
actionRevision: 17,
ready: make(chan struct{}),
}
handler := manager.authenticatedHandler()
@@ -862,7 +879,9 @@ func TestAuthenticatedHandlerRequiresLoopbackTokenAndRegisteredWindow(t *testing
if validRecorder.Code != http.StatusOK {
t.Fatalf("valid bootstrap status = %d body=%s", validRecorder.Code, validRecorder.Body.String())
}
if !strings.Contains(validRecorder.Body.String(), `"id":"window-1"`) || !strings.Contains(validRecorder.Body.String(), `"value":"shared"`) {
if !strings.Contains(validRecorder.Body.String(), `"id":"window-1"`) ||
!strings.Contains(validRecorder.Body.String(), `"value":"shared"`) ||
!strings.Contains(validRecorder.Body.String(), `"actionRevision":17`) {
t.Fatalf("unexpected bootstrap body: %s", validRecorder.Body.String())
}
select {
@@ -888,8 +907,9 @@ func TestAuthenticatedHandlerRequiresLoopbackTokenAndRegisteredWindow(t *testing
func TestOpenAISettingsActionIsForwardedWithoutClosingTheChild(t *testing.T) {
manager := newHTTPTestManager(t)
manager.windows["ai-chat"] = &windowEntry{
info: WindowInfo{ID: "ai-chat", Kind: "ai-chat", Title: "GoNavi AI"},
ready: make(chan struct{}),
info: WindowInfo{ID: "ai-chat", Kind: "ai-chat", Title: "GoNavi AI", Hidden: true},
ready: make(chan struct{}),
visibilityRevision: 7,
}
events := make(chan Event, 1)
manager.runtimeCtx = context.Background()
@@ -899,7 +919,7 @@ func TestOpenAISettingsActionIsForwardedWithoutClosingTheChild(t *testing.T) {
}
}
body := strings.NewReader(`{"action":"open-ai-settings","payload":{"id":"ai-chat","kind":"ai-chat"}}`)
body := strings.NewReader(`{"action":"open-ai-settings","payload":{"id":"ai-chat","kind":"ai-chat","visibilityRevision":7}}`)
request := authenticatedRequest(manager, http.MethodPost, ActionPath, "ai-chat", body)
recorder := httptest.NewRecorder()
manager.authenticatedHandler().ServeHTTP(recorder, request)
@@ -918,6 +938,50 @@ func TestOpenAISettingsActionIsForwardedWithoutClosingTheChild(t *testing.T) {
}
}
func TestOpenAISettingsActionIsIgnoredAfterANewerFocus(t *testing.T) {
manager := newHTTPTestManager(t)
manager.windows["ai-chat"] = &windowEntry{
info: WindowInfo{ID: "ai-chat", Kind: "ai-chat", Hidden: true},
ready: make(chan struct{}),
visibilityRevision: 7,
}
events := make(chan Event, 1)
manager.runtimeCtx = context.Background()
manager.emitToWails = func(_ context.Context, name string, args ...any) {
if name == MainEventName && len(args) == 1 {
events <- args[0].(Event)
}
}
focusResult := manager.Focus("ai-chat")
if !focusResult.Success || focusResult.VisibilityRevision != 8 {
t.Fatalf("Focus result = %#v", focusResult)
}
focusEvent := receiveEvent(t, events)
if focusEvent.Action != "focus" || positiveVisibilityRevision(focusEvent.Payload) != 8 {
t.Fatalf("Focus event = %#v", focusEvent)
}
body := strings.NewReader(`{"action":"open-ai-settings","payload":{"id":"ai-chat","kind":"ai-chat","visibilityRevision":7}}`)
request := authenticatedRequest(manager, http.MethodPost, ActionPath, "ai-chat", body)
recorder := httptest.NewRecorder()
manager.authenticatedHandler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("stale open-ai-settings status = %d body=%s", recorder.Code, recorder.Body.String())
}
var result OperationResult
if err := json.NewDecoder(recorder.Body).Decode(&result); err != nil {
t.Fatalf("decode stale open-ai-settings result: %v", err)
}
if !result.Success || result.Applied == nil || *result.Applied {
t.Fatalf("stale open-ai-settings result = %#v", result)
}
select {
case event := <-events:
t.Fatalf("stale open-ai-settings emitted event: %#v", event)
default:
}
}
func TestChildControlOpensAndRoutesAnOwnedNativeWindow(t *testing.T) {
manager := newHTTPTestManager(t)
manager.started = true
@@ -1240,6 +1304,90 @@ func TestCancelCloseActionClearsPendingStateAndNotifiesMainWindow(t *testing.T)
}
}
func TestCancelCloseActionCannotRollbackNewerTerminalState(t *testing.T) {
manager := newHTTPTestManager(t)
manager.windows["workbench:query-1"] = &windowEntry{
info: WindowInfo{ID: "workbench:query-1", Kind: "workbench", CloseSent: true},
exitReason: ExitReasonRequested,
actionRevision: 12,
closeGeneration: 4,
}
events := make(chan Event, 1)
manager.runtimeCtx = context.Background()
manager.emitToWails = func(_ context.Context, name string, args ...any) {
if name == MainEventName && len(args) == 1 {
events <- args[0].(Event)
}
}
body := strings.NewReader(`{"action":"cancel-close","payload":{"id":"workbench:query-1","revision":11,"rollbackAction":"attach"}}`)
request := authenticatedRequest(manager, http.MethodPost, ActionPath, "workbench:query-1", body)
recorder := httptest.NewRecorder()
manager.authenticatedHandler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("stale cancel-close status = %d body=%s", recorder.Code, recorder.Body.String())
}
var result OperationResult
if err := json.NewDecoder(recorder.Body).Decode(&result); err != nil {
t.Fatalf("decode stale cancel-close result: %v", err)
}
if !result.Success || result.Applied == nil || *result.Applied {
t.Fatalf("stale cancel-close result = %#v, want ignored success", result)
}
manager.mu.RLock()
entry := manager.windows["workbench:query-1"]
closeSent := entry.info.CloseSent
exitReason := entry.exitReason
revision := entry.actionRevision
closeGeneration := entry.closeGeneration
manager.mu.RUnlock()
if !closeSent || exitReason != ExitReasonRequested || revision != 12 || closeGeneration != 4 {
t.Fatalf(
"stale cancel changed state: closeSent=%v reason=%q revision=%d generation=%d",
closeSent,
exitReason,
revision,
closeGeneration,
)
}
select {
case event := <-events:
t.Fatalf("stale cancel emitted event: %#v", event)
case <-time.After(25 * time.Millisecond):
}
}
func TestCancelCloseActionCannotCancelParentShutdown(t *testing.T) {
manager := newHTTPTestManager(t)
manager.closing = true
manager.windows["ai-chat"] = &windowEntry{
info: WindowInfo{ID: "ai-chat", Kind: "ai-chat", CloseSent: true},
exitReason: ExitReasonParentShutdown,
actionRevision: 8,
closeGeneration: 5,
}
body := strings.NewReader(`{"action":"cancel-close","payload":{"id":"ai-chat","revision":9,"rollbackAction":"close"}}`)
request := authenticatedRequest(manager, http.MethodPost, ActionPath, "ai-chat", body)
recorder := httptest.NewRecorder()
manager.authenticatedHandler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("shutdown cancel-close status = %d body=%s", recorder.Code, recorder.Body.String())
}
var result OperationResult
if err := json.NewDecoder(recorder.Body).Decode(&result); err != nil {
t.Fatalf("decode shutdown cancel-close result: %v", err)
}
if !result.Success || result.Applied == nil || *result.Applied {
t.Fatalf("shutdown cancel-close result = %#v, want ignored success", result)
}
entry := manager.windows["ai-chat"]
if !entry.info.CloseSent || entry.exitReason != ExitReasonParentShutdown ||
entry.actionRevision != 8 || entry.closeGeneration != 5 {
t.Fatalf("shutdown cancellation changed entry: %#v", entry)
}
}
func TestHostEventActionIsForwardedWithoutChangingTerminalState(t *testing.T) {
manager := newHTTPTestManager(t)
manager.windows["ai-chat"] = &windowEntry{
@@ -1407,6 +1555,37 @@ func TestActionRevisionPreventsStaleTerminalTransition(t *testing.T) {
}
}
func TestManagerDoesNotReuseChildAfterTerminalActionIsAccepted(t *testing.T) {
for _, action := range []string{"attach", "close"} {
t.Run(action, func(t *testing.T) {
manager := newHTTPTestManager(t)
manager.started = true
manager.endpoint = "http://127.0.0.1:43119"
manager.windows["ai-chat"] = &windowEntry{
info: WindowInfo{ID: "ai-chat", Kind: "ai-chat", Title: "GoNavi AI"},
}
body := strings.NewReader(fmt.Sprintf(
`{"action":%q,"payload":{"revision":1}}`,
action,
))
request := authenticatedRequest(manager, http.MethodPost, ActionPath, "ai-chat", body)
recorder := httptest.NewRecorder()
manager.authenticatedHandler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("%s action status = %d body=%s", action, recorder.Code, recorder.Body.String())
}
if result := manager.Focus("ai-chat"); result.Success || !strings.Contains(result.Message, "retry") {
t.Errorf("Focus after %s result = %#v, want retry failure", action, result)
}
if result := manager.Open(OpenRequest{ID: "ai-chat", Kind: "ai-chat", Title: "GoNavi AI"}); result.Success || !strings.Contains(result.Message, "retry") {
t.Errorf("Open after %s result = %#v, want retry failure", action, result)
}
})
}
}
func TestManagerShutdownAllowsGracefulChildExitBeforeKilling(t *testing.T) {
manager := newHTTPTestManager(t)
manager.shutdownGracePeriod = 250 * time.Millisecond

View File

@@ -89,10 +89,11 @@ type WindowInfo struct {
// Bootstrap is fetched by the child after Wails has installed its native
// runtime and bindings.
type Bootstrap struct {
ID string `json:"id"`
Kind string `json:"kind"`
Title string `json:"title"`
Payload any `json:"payload,omitempty"`
ID string `json:"id"`
Kind string `json:"kind"`
Title string `json:"title"`
Payload any `json:"payload,omitempty"`
ActionRevision int64 `json:"actionRevision,omitempty"`
}
// OperationResult is returned by the Wails-bound Manager commands.
@@ -102,6 +103,9 @@ type OperationResult struct {
ID string `json:"id,omitempty"`
Bounds *WindowBounds `json:"bounds,omitempty"`
VisibilityRevision uint64 `json:"visibilityRevision,omitempty"`
// Applied is set only for revisioned child actions. A nil value preserves
// compatibility with ordinary manager/control results and older parents.
Applied *bool `json:"applied,omitempty"`
}
// HostStateRequest carries main-window state that an active detached child

View File

@@ -9,6 +9,7 @@ import (
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestDetachedChildQueuesFocusUntilFrontendReadyHandshake(t *testing.T) {
@@ -223,6 +224,339 @@ func TestDetachedChildIgnoresLateHideAfterNewerFocus(t *testing.T) {
}
}
func TestDetachedChildSerializesNativeHideBeforeNewerFocus(t *testing.T) {
_, control := newVisibilityTestChild()
ctx := context.Background()
InitializeControl(control, ctx)
steps := make(chan string, 8)
hideStarted := make(chan struct{})
releaseHide := make(chan struct{})
control.showWindow = func(context.Context) { steps <- "show" }
control.hideWindow = func(context.Context) {
steps <- "hide-start"
close(hideStarted)
<-releaseHide
steps <- "hide-end"
}
control.focusWindow = func(context.Context) { steps <- "focus" }
control.markDOMReady(ctx)
if result := control.markFrontendReady(); !result.Success {
t.Fatalf("markFrontendReady result = %#v", result)
}
if step := <-steps; step != "show" {
t.Fatalf("initial presentation step = %q, want show", step)
}
hideDone := make(chan OperationResult, 1)
go func() {
hideDone <- control.Hide(1)
}()
select {
case <-hideStarted:
case <-time.After(time.Second):
t.Fatal("native hide did not start")
}
focusCallStarted := make(chan struct{})
focusDone := make(chan OperationResult, 1)
go func() {
close(focusCallStarted)
focusDone <- control.FocusRevision(2)
}()
<-focusCallStarted
select {
case result := <-focusDone:
close(releaseHide)
<-hideDone
t.Fatalf("newer focus completed before the older native hide: %#v", result)
case <-time.After(25 * time.Millisecond):
}
close(releaseHide)
if result := <-hideDone; !result.Success || result.VisibilityRevision != 1 {
t.Fatalf("Hide result = %#v", result)
}
if result := <-focusDone; !result.Success || result.VisibilityRevision != 2 {
t.Fatalf("FocusRevision result = %#v", result)
}
sequence := make([]string, 0, 4)
for len(sequence) < 4 {
select {
case step := <-steps:
sequence = append(sequence, step)
case <-time.After(time.Second):
t.Fatalf("native visibility sequence stopped at %#v", sequence)
}
}
if got := strings.Join(sequence, ","); got != "hide-start,hide-end,show,focus" {
t.Fatalf("native visibility sequence = %q", got)
}
}
func TestDetachedChildDoesNotBlockHideWhileAcknowledgingFocus(t *testing.T) {
bridge, control := newVisibilityTestChild()
ctx := context.Background()
InitializeControl(control, ctx)
control.showWindow = func(context.Context) {}
control.focusWindow = func(context.Context) {}
hideCalled := make(chan struct{}, 1)
control.hideWindow = func(context.Context) { hideCalled <- struct{}{} }
control.markDOMReady(ctx)
if result := control.markFrontendReady(); !result.Success {
t.Fatalf("markFrontendReady result = %#v", result)
}
acknowledgementStarted := make(chan struct{})
releaseAcknowledgement := make(chan struct{})
bridge.client.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) {
if request.URL.Path == CommandStatePath {
close(acknowledgementStarted)
<-releaseAcknowledgement
}
return successfulVisibilityResponse(), nil
})
focusDone := make(chan OperationResult, 1)
go func() {
focusDone <- control.FocusRevision(1)
}()
select {
case <-acknowledgementStarted:
case <-time.After(time.Second):
t.Fatal("focus acknowledgement did not start")
}
hideDone := make(chan OperationResult, 1)
go func() {
hideDone <- control.Hide(2)
}()
select {
case result := <-hideDone:
if !result.Success || result.VisibilityRevision != 2 {
t.Fatalf("Hide result = %#v", result)
}
case <-time.After(25 * time.Millisecond):
close(releaseAcknowledgement)
<-focusDone
<-hideDone
t.Fatal("native hide waited for the focus acknowledgement network request")
}
select {
case <-hideCalled:
case <-time.After(time.Second):
t.Fatal("native hide callback was not called")
}
close(releaseAcknowledgement)
if result := <-focusDone; !result.Success || result.VisibilityRevision != 1 {
t.Fatalf("FocusRevision result = %#v", result)
}
}
func TestDetachedChildClosePreventsConcurrentHideFromCancellingExit(t *testing.T) {
_, control := newVisibilityTestChild()
ctx := context.Background()
InitializeControl(control, ctx)
quitStarted := make(chan struct{})
releaseQuit := make(chan struct{})
control.quit = func(context.Context) {
close(quitStarted)
<-releaseQuit
}
hideCalled := make(chan struct{}, 1)
control.hideWindow = func(context.Context) { hideCalled <- struct{}{} }
closeDone := make(chan OperationResult, 1)
go func() {
closeDone <- control.Close()
}()
select {
case <-quitStarted:
case <-time.After(time.Second):
t.Fatal("native close did not reach quit")
}
if !control.closeGate.isAllowed() {
t.Fatal("close gate was not opened before quit")
}
hideDone := make(chan OperationResult, 1)
go func() {
hideDone <- control.Hide(1)
}()
select {
case result := <-hideDone:
close(releaseQuit)
<-closeDone
t.Fatalf("hide completed while native close was in progress: %#v", result)
case <-time.After(25 * time.Millisecond):
}
close(releaseQuit)
if result := <-closeDone; !result.Success {
t.Fatalf("Close result = %#v", result)
}
if result := <-hideDone; result.Success || !strings.Contains(result.Message, "already committed") {
t.Fatalf("Hide result after committed close = %#v", result)
}
if !control.closeGate.isAllowed() {
t.Fatal("concurrent hide cancelled the committed close gate")
}
select {
case <-hideCalled:
t.Fatal("concurrent hide reached the native window after close committed")
default:
}
}
func TestDetachedChildCloseFallbackPreventsConcurrentHideFromCancellingExit(t *testing.T) {
_, control := newVisibilityTestChild()
ctx := context.Background()
InitializeControl(control, ctx)
control.closeFallbackDelay = time.Millisecond
quitStarted := make(chan struct{})
releaseQuit := make(chan struct{})
control.quit = func(context.Context) {
close(quitStarted)
<-releaseQuit
}
hideCalled := make(chan struct{}, 1)
control.hideWindow = func(context.Context) { hideCalled <- struct{}{} }
control.scheduleCloseFallback(ctx)
select {
case <-quitStarted:
case <-time.After(time.Second):
t.Fatal("native close fallback did not reach quit")
}
if !control.closeGate.isAllowed() {
t.Fatal("close fallback did not open the gate before quit")
}
hideDone := make(chan OperationResult, 1)
go func() {
hideDone <- control.Hide(1)
}()
select {
case result := <-hideDone:
close(releaseQuit)
t.Fatalf("hide completed while native close fallback was in progress: %#v", result)
case <-time.After(25 * time.Millisecond):
}
close(releaseQuit)
if result := <-hideDone; result.Success || !strings.Contains(result.Message, "already committed") {
t.Fatalf("Hide result after fallback committed close = %#v", result)
}
if !control.closeGate.isAllowed() {
t.Fatal("concurrent hide cancelled the fallback close gate")
}
select {
case <-hideCalled:
t.Fatal("concurrent hide reached the native window after fallback close committed")
default:
}
}
func TestDetachedChildStaleCloseFallbackKeepsNewerTimer(t *testing.T) {
_, control := newVisibilityTestChild()
ctx := context.Background()
InitializeControl(control, ctx)
control.closeFallbackDelay = time.Hour
quits := 0
control.quit = func(context.Context) { quits++ }
control.mu.Lock()
control.closeFallbackGeneration = 1
control.closeFallback = time.AfterFunc(time.Hour, func() {})
control.mu.Unlock()
control.visibilityOpMu.Lock()
staleFallbackDone := make(chan struct{})
go func() {
control.runCloseFallback(1, ctx)
close(staleFallbackDone)
}()
if result := control.CancelClose(); !result.Success {
control.visibilityOpMu.Unlock()
t.Fatalf("CancelClose result = %#v", result)
}
control.scheduleCloseFallback(ctx)
control.mu.RLock()
newGeneration := control.closeFallbackGeneration
newFallback := control.closeFallback
control.mu.RUnlock()
if newGeneration != 3 || newFallback == nil {
control.visibilityOpMu.Unlock()
t.Fatalf("new fallback state = generation %d timer %p, want 3/non-nil", newGeneration, newFallback)
}
control.visibilityOpMu.Unlock()
select {
case <-staleFallbackDone:
case <-time.After(time.Second):
t.Fatal("stale close fallback did not complete")
}
control.mu.RLock()
retainedFallback := control.closeFallback
retainedGeneration := control.closeFallbackGeneration
closeCommitted := control.closeCommitted
control.mu.RUnlock()
if retainedFallback != newFallback || retainedGeneration != newGeneration {
t.Fatalf(
"fallback after stale callback = generation %d timer %p, want %d/%p",
retainedGeneration,
retainedFallback,
newGeneration,
newFallback,
)
}
if closeCommitted || control.closeGate.isAllowed() || quits != 0 {
t.Fatalf(
"stale fallback exit state = committed %v gate %v quits %d, want false/false/0",
closeCommitted,
control.closeGate.isAllowed(),
quits,
)
}
if result := control.CancelClose(); !result.Success {
t.Fatalf("final CancelClose result = %#v", result)
}
}
func TestDetachedChildDoesNotPresentOrFocusAfterCloseCommitted(t *testing.T) {
_, control := newVisibilityTestChild()
ctx := context.Background()
InitializeControl(control, ctx)
shows := 0
focuses := 0
control.showWindow = func(context.Context) { shows++ }
control.focusWindow = func(context.Context) { focuses++ }
control.quit = func(context.Context) {}
control.markDOMReady(ctx)
if result := control.Close(); !result.Success {
t.Fatalf("Close result = %#v", result)
}
if result := control.markFrontendReady(); result.Success {
t.Fatalf("markFrontendReady after close = %#v, want failure", result)
}
if result := control.Present(); result.Success {
t.Fatalf("Present after close = %#v, want failure", result)
}
if result := control.FocusRevision(1); result.Success {
t.Fatalf("FocusRevision after close = %#v, want failure", result)
}
if shows != 0 || focuses != 0 {
t.Fatalf("post-close presentation = show %d focus %d, want 0/0", shows, focuses)
}
}
func TestDetachedChildPresentsBeforePaintReadyWithoutShowingTwice(t *testing.T) {
bridge, control := newVisibilityTestChild()
ctx := context.Background()