diff --git a/frontend/src/App.tool-center.test.ts b/frontend/src/App.tool-center.test.ts index 0e3f61fe..064ad835 100644 --- a/frontend/src/App.tool-center.test.ts +++ b/frontend/src/App.tool-center.test.ts @@ -13,6 +13,41 @@ const appCss = readFileSync( describe('settings center tool entries', () => { + it('captures native window bounds before maximising and before the final quit flush', () => { + const startupRestoreStart = appSource.indexOf('const restoreWindowState = async'); + const startupRestoreEnd = appSource.indexOf('if (useStore.persist.hasHydrated())', startupRestoreStart); + const startupRestoreSource = appSource.slice(startupRestoreStart, startupRestoreEnd); + const restoreNormalBoundsBeforeMaximise = startupRestoreSource.indexOf('applyRestoredWindowBounds(bounds);'); + const startupMaximiseCall = startupRestoreSource.indexOf('applyStartupWindowChrome(1);'); + + expect(startupRestoreStart).toBeGreaterThanOrEqual(0); + expect(startupRestoreEnd).toBeGreaterThan(startupRestoreStart); + expect(restoreNormalBoundsBeforeMaximise).toBeGreaterThanOrEqual(0); + expect(startupMaximiseCall).toBeGreaterThan(restoreNormalBoundsBeforeMaximise); + + const titleBarToggleStart = appSource.indexOf('const handleTitleBarWindowToggle = async'); + const titleBarToggleEnd = appSource.indexOf('const handleTitleBarDoubleClick =', titleBarToggleStart); + const titleBarToggleSource = appSource.slice(titleBarToggleStart, titleBarToggleEnd); + const captureBeforeMaximise = titleBarToggleSource.indexOf('await captureMainWindowStateRef.current();'); + const maximiseCall = titleBarToggleSource.indexOf('WindowMaximise();', captureBeforeMaximise); + + expect(titleBarToggleStart).toBeGreaterThanOrEqual(0); + expect(titleBarToggleEnd).toBeGreaterThan(titleBarToggleStart); + expect(captureBeforeMaximise).toBeGreaterThanOrEqual(0); + expect(maximiseCall).toBeGreaterThan(captureBeforeMaximise); + + const confirmedActionStart = appSource.indexOf('const runConfirmedAction = async'); + const confirmedActionEnd = appSource.indexOf('if (confirmedAction)', confirmedActionStart); + const confirmedActionSource = appSource.slice(confirmedActionStart, confirmedActionEnd); + const captureOnQuit = confirmedActionSource.indexOf('captureWindowState:'); + const flushOnQuit = confirmedActionSource.indexOf('flushAppState:'); + + expect(confirmedActionStart).toBeGreaterThanOrEqual(0); + expect(confirmedActionEnd).toBeGreaterThan(confirmedActionStart); + expect(captureOnQuit).toBeGreaterThanOrEqual(0); + expect(flushOnQuit).toBeGreaterThan(captureOnQuit); + }); + it('keeps the resize minimise probe independent from DPR debounce and clears it on unmount', () => { const scaleEffectStart = appSource.indexOf('let minimisedCheckTimer: number | null = null;'); const dprScheduleStart = appSource.indexOf('const scheduleDevicePixelRatioCheck = (trigger: WindowsScaleCheckTrigger) => {', scaleEffectStart); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1d3ba3b3..4126184d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -228,6 +228,7 @@ import { collectApplicationQuitUnsavedSQLTargets, saveApplicationQuitUnsavedSQLTargets, } from './utils/sqlEditorApplicationQuit'; +import { prepareApplicationQuitPersistence } from './utils/applicationQuitPersistence'; import { flushQueryTabDraftSnapshots } from './utils/sqlFileTabDrafts'; import { APP_APPLICATION_QUIT_MODAL_Z_INDEX, @@ -1155,6 +1156,7 @@ function App() { const windowDiagSequenceRef = React.useRef(0); const windowDiagLastSignatureRef = React.useRef(''); const windowDiagLastAtRef = React.useRef(0); + const captureMainWindowStateRef = React.useRef<() => Promise>(async () => undefined); const connectionWorkbenchState = getConnectionWorkbenchState(isStoreHydrated, hasLoadedSecureConfig); const securityUpdateStatusMeta = useMemo( () => getSecurityUpdateStatusMeta(securityUpdateStatus, t), @@ -1584,24 +1586,12 @@ function App() { }, delayMs); }; - const restoreNormalWindowBounds = async (bounds: { + const applyRestoredWindowBounds = (bounds: { width: number; height: number; x: number; y: number; }) => { - try { - if (await WindowIsFullscreen()) { - WindowUnfullscreen(); - await new Promise((resolve) => window.setTimeout(resolve, settleDelayMs)); - } - if (await WindowIsMaximised()) { - WindowUnmaximise(); - await new Promise((resolve) => window.setTimeout(resolve, settleDelayMs)); - } - } catch (e) { - console.warn('Failed to restore normal window chrome', e); - } const state = useStore.getState(); const viewport = readCurrentVisibleViewport(); const nextBounds = resolveVisibleStartupWindowBounds(bounds, viewport); @@ -1622,7 +1612,28 @@ function App() { }); WindowSetPosition(setPosition.x, setPosition.y); state.setWindowBounds(nextBounds); - state.setWindowState('normal'); + }; + + const restoreNormalWindowBounds = async (bounds: { + width: number; + height: number; + x: number; + y: number; + }) => { + try { + if (await WindowIsFullscreen()) { + WindowUnfullscreen(); + await new Promise((resolve) => window.setTimeout(resolve, settleDelayMs)); + } + if (await WindowIsMaximised()) { + WindowUnmaximise(); + await new Promise((resolve) => window.setTimeout(resolve, settleDelayMs)); + } + } catch (e) { + console.warn('Failed to restore normal window chrome', e); + } + applyRestoredWindowBounds(bounds); + useStore.getState().setWindowState('normal'); }; const restoreWindowState = async () => { @@ -1637,19 +1648,28 @@ function App() { restoredOnce = true; const state = useStore.getState(); + const bounds = state.windowBounds; const restoreMode = resolveStartupWindowRestoreMode( state.startupFullscreen, + state.windowState, ); if (restoreMode !== 'normal') { + if (bounds && bounds.width >= 400 && bounds.height >= 300) { + try { + // Seed the OS restore rectangle before maximising so a later + // unmaximise returns to the user's last normal window bounds. + applyRestoredWindowBounds(bounds); + } catch (e) { + console.warn('Failed to prepare remembered normal window bounds', e); + } + } markStartupWindowRestorePending(startupRestoreGraceMs); applyStartupWindowChrome(1); return; } - // The disabled preference is strict: restore a normal window even if - // an older build persisted an automatic maximised/fullscreen state. + // Without a remembered maximised state, restore the last normal bounds. markStartupWindowRestorePending(startupRestoreGraceMs); - const bounds = state.windowBounds; const viewport = readCurrentVisibleViewport(); try { if (!bounds || bounds.width < 400 || bounds.height < 300) { @@ -1752,6 +1772,7 @@ function App() { // 静默忽略 } }; + captureMainWindowStateRef.current = saveWindowState; const scheduleWindowStateSave = (delayMs = 120) => { if (cancelled || !hydrated) { @@ -1885,6 +1906,9 @@ function App() { }); return () => { cancelled = true; + if (captureMainWindowStateRef.current === saveWindowState) { + captureMainWindowStateRef.current = async () => undefined; + } if (eventSaveTimer !== null) { window.clearTimeout(eventSaveTimer); } @@ -2792,8 +2816,11 @@ function App() { const runConfirmedAction = async (): Promise => { let accepted = false; try { - flushQueryTabDraftSnapshots(); - await flushAppStatePersistence(); + await prepareApplicationQuitPersistence({ + captureWindowState: () => captureMainWindowStateRef.current(), + flushDrafts: flushQueryTabDraftSnapshots, + flushAppState: flushAppStatePersistence, + }); if (confirmedAction) { accepted = await confirmedAction(); } else { @@ -4421,9 +4448,17 @@ function App() { if (isMaximised) { WindowUnmaximise(); } else { + // Preserve the latest normal bounds before the native maximise transition + // makes WindowGetSize report the maximised surface. + await captureMainWindowStateRef.current(); WindowMaximise(); } - await new Promise((resolve) => window.setTimeout(resolve, 96)); + await waitForWindowCondition({ + read: async () => (await WindowIsMaximised()) !== isMaximised, + wait: (delayMs) => new Promise((resolve) => window.setTimeout(resolve, delayMs)), + maxChecks: 16, + intervalMs: 40, + }); await syncWindowStateFromRuntime(); void emitWindowDiagnostic('action:titlebar-toggle:after-set-maximise-state'); } catch (_) { diff --git a/frontend/src/utils/applicationQuitPersistence.test.ts b/frontend/src/utils/applicationQuitPersistence.test.ts new file mode 100644 index 00000000..3537dfd1 --- /dev/null +++ b/frontend/src/utils/applicationQuitPersistence.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { prepareApplicationQuitPersistence } from './applicationQuitPersistence'; + +describe('prepareApplicationQuitPersistence', () => { + it('captures the latest native window before flushing persisted app state', async () => { + const calls: string[] = []; + const captureWindowState = vi.fn(async () => { + calls.push('capture-window'); + }); + const flushDrafts = vi.fn(() => { + calls.push('flush-drafts'); + }); + const flushAppState = vi.fn(async () => { + calls.push('flush-app-state'); + }); + + await prepareApplicationQuitPersistence({ + captureWindowState, + flushDrafts, + flushAppState, + }); + + expect(calls).toEqual([ + 'capture-window', + 'flush-drafts', + 'flush-app-state', + ]); + }); +}); diff --git a/frontend/src/utils/applicationQuitPersistence.ts b/frontend/src/utils/applicationQuitPersistence.ts new file mode 100644 index 00000000..d67422b9 --- /dev/null +++ b/frontend/src/utils/applicationQuitPersistence.ts @@ -0,0 +1,15 @@ +export type ApplicationQuitPersistenceTasks = { + captureWindowState: () => Promise; + flushDrafts: () => void; + flushAppState: () => Promise; +}; + +export const prepareApplicationQuitPersistence = async ({ + captureWindowState, + flushDrafts, + flushAppState, +}: ApplicationQuitPersistenceTasks): Promise => { + await captureWindowState(); + flushDrafts(); + await flushAppState(); +}; diff --git a/frontend/src/utils/windowStartupLayout.test.ts b/frontend/src/utils/windowStartupLayout.test.ts index 6a7e6e80..ff5e7580 100644 --- a/frontend/src/utils/windowStartupLayout.test.ts +++ b/frontend/src/utils/windowStartupLayout.test.ts @@ -99,12 +99,16 @@ describe('windowStartupLayout', () => { }); }); - it('keeps startup normal when the explicit fullscreen preference is disabled', () => { - expect(resolveStartupWindowRestoreMode(false)).toBe('normal'); + it('restores the last normal window when startup maximise is disabled', () => { + expect(resolveStartupWindowRestoreMode(false, 'normal')).toBe('normal'); + }); + + it('restores a user-maximised window when startup maximise is disabled', () => { + expect(resolveStartupWindowRestoreMode(false, 'maximized')).toBe('maximised'); }); it('maximises the startup window on every desktop platform when enabled', () => { - expect(resolveStartupWindowRestoreMode(true)).toBe('maximised'); + expect(resolveStartupWindowRestoreMode(true, 'normal')).toBe('maximised'); }); it('does not accept a stale Windows WebView surface as a settled maximised window', () => { diff --git a/frontend/src/utils/windowStartupLayout.ts b/frontend/src/utils/windowStartupLayout.ts index b9410db6..9387855d 100644 --- a/frontend/src/utils/windowStartupLayout.ts +++ b/frontend/src/utils/windowStartupLayout.ts @@ -17,6 +17,7 @@ const MIN_STARTUP_WIDTH = 900; const MIN_STARTUP_HEIGHT = 600; export type StartupWindowRestoreMode = 'normal' | 'maximised'; +export type PersistedMainWindowState = 'normal' | 'maximized' | 'fullscreen'; export type StartupWindowSurfaceSnapshot = { surfaceWidth: number; @@ -31,13 +32,15 @@ export type StartupMaximisedWindowSnapshot = StartupWindowSurfaceSnapshot & { const MIN_MAXIMISED_SURFACE_COVERAGE = 0.95; -/** - * The explicit startup preference is authoritative. A disabled preference must - * not be overridden by a previously maximised window or a size heuristic. - */ +/** Force maximise when requested; otherwise restore the last observed window state. */ export const resolveStartupWindowRestoreMode = ( startupMaximised: boolean, -): StartupWindowRestoreMode => startupMaximised ? 'maximised' : 'normal'; + persistedWindowState: PersistedMainWindowState = 'normal', +): StartupWindowRestoreMode => ( + startupMaximised || persistedWindowState === 'maximized' || persistedWindowState === 'fullscreen' + ? 'maximised' + : 'normal' +); /** * Determine whether the native maximised state has also reached the WebView surface.