diff --git a/src/main/window/WindowManager.ts b/src/main/window/WindowManager.ts index 256ad09..dadb188 100644 --- a/src/main/window/WindowManager.ts +++ b/src/main/window/WindowManager.ts @@ -1,25 +1,41 @@ /** * WindowManager - Manages multiple application windows */ -import { BrowserWindow } from 'electron'; +import { BrowserWindow, screen } from 'electron'; import path from 'node:path'; import { DEFAULT_WINDOW, APP_CONFIG } from '@shared/constants'; import { IPC_CHANNELS } from '@shared/types/api'; +import type { WindowState } from '@shared/types'; +import type { PreferencesService } from '../services/PreferencesService'; +import { getPreferencesService } from '../services/PreferencesService'; declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string | undefined; declare const MAIN_WINDOW_VITE_NAME: string; const IS_DEV = typeof MAIN_WINDOW_VITE_DEV_SERVER_URL !== 'undefined' && !!MAIN_WINDOW_VITE_DEV_SERVER_URL; +/** Debounce delay for persisting window bounds during resize/move (ms) */ +const WINDOW_STATE_SAVE_DEBOUNCE_MS = 500; + export class WindowManager { private windows: Map = new Map(); private windowFilePaths: Map = new Map(); + private saveTimers: Map = new Map(); + private preferencesService?: PreferencesService; + + constructor(preferencesService?: PreferencesService) { + this.preferencesService = preferencesService; + } createWindow(): BrowserWindow { + const savedState = this.getSavedWindowState(); + const win = new BrowserWindow({ - width: DEFAULT_WINDOW.WIDTH, - height: DEFAULT_WINDOW.HEIGHT, + width: savedState.width, + height: savedState.height, + x: savedState.x, + y: savedState.y, minWidth: DEFAULT_WINDOW.MIN_WIDTH, minHeight: DEFAULT_WINDOW.MIN_HEIGHT, webPreferences: { @@ -35,6 +51,10 @@ export class WindowManager { show: false, }); + if (savedState.isMaximized) { + win.maximize(); + } + this.windows.set(win.id, win); this.windowFilePaths.set(win.id, null); @@ -56,9 +76,24 @@ export class WindowManager { }); }); + win.on('resize', () => this.scheduleWindowStateSave(win)); + win.on('move', () => this.scheduleWindowStateSave(win)); + win.on('maximize', () => this.persistWindowState(win)); + win.on('unmaximize', () => this.persistWindowState(win)); + + win.on('close', () => { + const timer = this.saveTimers.get(win.id); + if (timer) { + clearTimeout(timer); + this.saveTimers.delete(win.id); + } + this.persistWindowState(win); + }); + win.on('closed', () => { this.windows.delete(win.id); this.windowFilePaths.delete(win.id); + this.saveTimers.delete(win.id); }); if (IS_DEV) { @@ -112,6 +147,10 @@ export class WindowManager { } destroy(): void { + for (const timer of this.saveTimers.values()) { + clearTimeout(timer); + } + this.saveTimers.clear(); this.windows.clear(); this.windowFilePaths.clear(); } @@ -125,13 +164,101 @@ export class WindowManager { ); } } + + /** + * Resolve the window state to open a new window with. Falls back to + * defaults when no preferences service is wired up, and drops a saved + * position that no longer lands on a connected display. + */ + private getSavedWindowState(): WindowState { + if (!this.preferencesService) { + return { + width: DEFAULT_WINDOW.WIDTH, + height: DEFAULT_WINDOW.HEIGHT, + isMaximized: false, + }; + } + + const state = this.preferencesService.getPreferences().windowState; + + if ( + state.x !== undefined && + state.y !== undefined && + !this.isPositionOnScreen(state.x, state.y, state.width, state.height) + ) { + // Saved position is off-screen (e.g. an external monitor was + // disconnected) - keep the size but let Electron center the window. + return { + width: state.width, + height: state.height, + isMaximized: state.isMaximized, + }; + } + + return state; + } + + /** + * Check whether a window rectangle overlaps any connected display's + * work area, so we never restore a window to an invisible location. + */ + private isPositionOnScreen( + x: number, + y: number, + width: number, + height: number + ): boolean { + return screen.getAllDisplays().some((display) => { + const area = display.workArea; + return ( + x < area.x + area.width && + x + width > area.x && + y < area.y + area.height && + y + height > area.y + ); + }); + } + + private scheduleWindowStateSave(win: BrowserWindow): void { + if (!this.preferencesService || win.isDestroyed()) return; + + const existing = this.saveTimers.get(win.id); + if (existing) { + clearTimeout(existing); + } + + this.saveTimers.set( + win.id, + setTimeout(() => { + this.saveTimers.delete(win.id); + this.persistWindowState(win); + }, WINDOW_STATE_SAVE_DEBOUNCE_MS) + ); + } + + private persistWindowState(win: BrowserWindow): void { + if (!this.preferencesService || win.isDestroyed()) return; + + // getNormalBounds() reports the un-maximized bounds, so we always + // persist a sensible size to restore to even while maximized. + const bounds = win.getNormalBounds(); + const windowState: WindowState = { + width: bounds.width, + height: bounds.height, + x: bounds.x, + y: bounds.y, + isMaximized: win.isMaximized(), + }; + + void this.preferencesService.updatePreferences({ windowState }); + } } let windowManagerInstance: WindowManager | null = null; export function getWindowManager(): WindowManager { if (!windowManagerInstance) { - windowManagerInstance = new WindowManager(); + windowManagerInstance = new WindowManager(getPreferencesService()); } return windowManagerInstance; } diff --git a/src/preferences/defaults.ts b/src/preferences/defaults.ts index 83e0438..a61f6b8 100644 --- a/src/preferences/defaults.ts +++ b/src/preferences/defaults.ts @@ -13,11 +13,13 @@ * #58a6ff → oklch(70% 0.14 250) */ +import { DEFAULT_WINDOW } from '@shared/constants'; import type { CorePreferences, AppPreferences, TypographyStyle, ColorPair, + WindowState, } from '@shared/types'; /** @@ -175,6 +177,16 @@ export const DEFAULT_MERMAID_PREFERENCES: MermaidPreferences = { }, }; +/** + * Default window state - used on first launch before any window + * size/position has been recorded. + */ +export const DEFAULT_WINDOW_STATE: WindowState = { + width: DEFAULT_WINDOW.WIDTH, + height: DEFAULT_WINDOW.HEIGHT, + isMaximized: false, +}; + /** * Default app preferences (complete structure) */ @@ -184,6 +196,7 @@ export const DEFAULT_APP_PREFERENCES: AppPreferences = { plugins: { mermaid: DEFAULT_MERMAID_PREFERENCES, }, + windowState: DEFAULT_WINDOW_STATE, }; /** diff --git a/src/preferences/index.ts b/src/preferences/index.ts index 7e091bb..42ae619 100644 --- a/src/preferences/index.ts +++ b/src/preferences/index.ts @@ -3,6 +3,7 @@ export { PREFERENCES_VERSION, DEFAULT_CORE_PREFERENCES, DEFAULT_MERMAID_PREFERENCES, + DEFAULT_WINDOW_STATE, DEFAULT_APP_PREFERENCES, clonePreferences, deepMerge, diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index d89dfaa..8c14b4d 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -39,6 +39,7 @@ export type { ExternalEditorPreferences, CorePreferences, PluginPreferencesMap, + WindowState, AppPreferences, PreferencesChangeEvent, DeepPartial, diff --git a/src/shared/types/preferences.ts b/src/shared/types/preferences.ts index 2c2634f..97e28dd 100644 --- a/src/shared/types/preferences.ts +++ b/src/shared/types/preferences.ts @@ -122,6 +122,18 @@ export interface CorePreferences { */ export type PluginPreferencesMap = Record; +/** + * Persisted window size, position, and maximized state. + * x/y are omitted when no on-screen position has been recorded yet. + */ +export interface WindowState { + width: number; + height: number; + x?: number; + y?: number; + isMaximized: boolean; +} + /** * Complete preferences structure */ @@ -129,6 +141,7 @@ export interface AppPreferences { version: number; core: CorePreferences; plugins: PluginPreferencesMap; + windowState: WindowState; } /** diff --git a/tests/unit/main/ipc/handlers/FileHandler.test.ts b/tests/unit/main/ipc/handlers/FileHandler.test.ts index bbabe57..e3a6833 100644 --- a/tests/unit/main/ipc/handlers/FileHandler.test.ts +++ b/tests/unit/main/ipc/handlers/FileHandler.test.ts @@ -34,6 +34,9 @@ vi.mock('electron', () => { BrowserWindow: { fromWebContents: vi.fn(), }, + app: { + getPath: vi.fn(() => '/tmp/open-markdown-test'), + }, }; }); diff --git a/tests/unit/main/ipc/handlers/ShellHandler.test.ts b/tests/unit/main/ipc/handlers/ShellHandler.test.ts index 9b61f06..d12cb0a 100644 --- a/tests/unit/main/ipc/handlers/ShellHandler.test.ts +++ b/tests/unit/main/ipc/handlers/ShellHandler.test.ts @@ -58,6 +58,7 @@ function createMockPreferences( externalEditor: { editor, customCommand }, }, plugins: {}, + windowState: { width: 900, height: 700, isMaximized: false }, }; } diff --git a/tests/unit/main/ipc/handlers/ThemeHandler.test.ts b/tests/unit/main/ipc/handlers/ThemeHandler.test.ts index e08286b..07bfda6 100644 --- a/tests/unit/main/ipc/handlers/ThemeHandler.test.ts +++ b/tests/unit/main/ipc/handlers/ThemeHandler.test.ts @@ -82,6 +82,7 @@ function createMockPreferencesService(initialMode: ThemeMode = 'system'): MockPr externalEditor: { editor: 'none', customCommand: '' }, }, plugins: {}, + windowState: { width: 900, height: 700, isMaximized: false }, })), updatePreferences: vi.fn((updates: { core?: { theme?: { mode?: ThemeMode } } }) => { if (updates.core?.theme?.mode) { diff --git a/tests/unit/main/window/WindowManager.test.ts b/tests/unit/main/window/WindowManager.test.ts index 2178ee5..df1d44d 100644 --- a/tests/unit/main/window/WindowManager.test.ts +++ b/tests/unit/main/window/WindowManager.test.ts @@ -34,21 +34,44 @@ vi.mock('electron', () => { isDestroyed: vi.fn(() => false), isMinimized: vi.fn(() => false), isFullScreen: vi.fn(() => false), + isMaximized: vi.fn(() => false), + maximize: vi.fn(), restore: vi.fn(), focus: vi.fn(), setTitle: vi.fn(), + getNormalBounds: vi.fn(() => ({ x: 100, y: 120, width: 1024, height: 768 })), + getBounds: vi.fn(() => ({ x: 100, y: 120, width: 1024, height: 768 })), _handlers: handlers, _simulateEvent: (event: string, ...args: unknown[]) => { handlers[event]?.forEach(cb => cb(...args)); }, }; }), + screen: { + getAllDisplays: vi.fn(() => [ + { workArea: { x: 0, y: 0, width: 1920, height: 1080 } }, + ]), + }, app: { isPackaged: false, }, }; }); +interface FakePreferencesService { + getPreferences: ReturnType; + updatePreferences: ReturnType; +} + +function createFakePreferencesService( + windowState: Record = { width: 900, height: 700, isMaximized: false } +): FakePreferencesService { + return { + getPreferences: vi.fn(() => ({ windowState })), + updatePreferences: vi.fn().mockResolvedValue(undefined), + }; +} + describe('WindowManager', () => { let manager: WindowManager; @@ -194,4 +217,93 @@ describe('WindowManager', () => { expect(manager.getAllWindows()).toEqual([]); }); }); + + describe('window state persistence', () => { + it('should create the window with the saved size and position', () => { + const prefs = createFakePreferencesService({ + width: 1280, + height: 800, + x: 200, + y: 150, + isMaximized: false, + }); + const stateManager = new WindowManager(prefs as never); + + stateManager.createWindow(); + + expect(BrowserWindow).toHaveBeenCalledWith( + expect.objectContaining({ width: 1280, height: 800, x: 200, y: 150 }) + ); + stateManager.destroy(); + }); + + it('should drop an off-screen saved position but keep the size', () => { + const prefs = createFakePreferencesService({ + width: 1280, + height: 800, + x: 5000, + y: 5000, + isMaximized: false, + }); + const stateManager = new WindowManager(prefs as never); + + stateManager.createWindow(); + + expect(BrowserWindow).toHaveBeenCalledWith( + expect.objectContaining({ width: 1280, height: 800, x: undefined, y: undefined }) + ); + stateManager.destroy(); + }); + + it('should maximize the window when the saved state is maximized', () => { + const prefs = createFakePreferencesService({ + width: 900, + height: 700, + isMaximized: true, + }); + const stateManager = new WindowManager(prefs as never); + + const win = stateManager.createWindow(); + + expect(win.maximize).toHaveBeenCalled(); + stateManager.destroy(); + }); + + it('should persist window state on close', () => { + const prefs = createFakePreferencesService(); + const stateManager = new WindowManager(prefs as never); + + const win = stateManager.createWindow(); + (win as unknown as { _simulateEvent: (event: string) => void })._simulateEvent('close'); + + expect(prefs.updatePreferences).toHaveBeenCalledWith({ + windowState: { + x: 100, + y: 120, + width: 1024, + height: 768, + isMaximized: false, + }, + }); + stateManager.destroy(); + }); + + it('should persist window state on maximize', () => { + const prefs = createFakePreferencesService(); + const stateManager = new WindowManager(prefs as never); + + const win = stateManager.createWindow(); + (win as unknown as { _simulateEvent: (event: string) => void })._simulateEvent('maximize'); + + expect(prefs.updatePreferences).toHaveBeenCalled(); + stateManager.destroy(); + }); + + it('should not touch preferences when no service is provided', () => { + const win = manager.createWindow(); + expect(() => { + (win as unknown as { _simulateEvent: (event: string) => void })._simulateEvent('close'); + }).not.toThrow(); + }); + }); });