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