Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 131 additions & 4 deletions src/main/window/WindowManager.ts
Original file line number Diff line number Diff line change
@@ -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<number, BrowserWindow> = new Map();
private windowFilePaths: Map<number, string | null> = new Map();
private saveTimers: Map<number, NodeJS.Timeout> = 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: {
Expand All @@ -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);

Expand All @@ -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) {
Expand Down Expand Up @@ -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();
}
Expand All @@ -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;
}
Expand Down
13 changes: 13 additions & 0 deletions src/preferences/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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)
*/
Expand All @@ -184,6 +196,7 @@ export const DEFAULT_APP_PREFERENCES: AppPreferences = {
plugins: {
mermaid: DEFAULT_MERMAID_PREFERENCES,
},
windowState: DEFAULT_WINDOW_STATE,
};

/**
Expand Down
1 change: 1 addition & 0 deletions src/preferences/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export {
PREFERENCES_VERSION,
DEFAULT_CORE_PREFERENCES,
DEFAULT_MERMAID_PREFERENCES,
DEFAULT_WINDOW_STATE,
DEFAULT_APP_PREFERENCES,
clonePreferences,
deepMerge,
Expand Down
1 change: 1 addition & 0 deletions src/shared/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export type {
ExternalEditorPreferences,
CorePreferences,
PluginPreferencesMap,
WindowState,
AppPreferences,
PreferencesChangeEvent,
DeepPartial,
Expand Down
13 changes: 13 additions & 0 deletions src/shared/types/preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,13 +122,26 @@ export interface CorePreferences {
*/
export type PluginPreferencesMap = Record<string, unknown>;

/**
* 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
*/
export interface AppPreferences {
version: number;
core: CorePreferences;
plugins: PluginPreferencesMap;
windowState: WindowState;
}

/**
Expand Down
3 changes: 3 additions & 0 deletions tests/unit/main/ipc/handlers/FileHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ vi.mock('electron', () => {
BrowserWindow: {
fromWebContents: vi.fn(),
},
app: {
getPath: vi.fn(() => '/tmp/open-markdown-test'),
},
};
});

Expand Down
1 change: 1 addition & 0 deletions tests/unit/main/ipc/handlers/ShellHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ function createMockPreferences(
externalEditor: { editor, customCommand },
},
plugins: {},
windowState: { width: 900, height: 700, isMaximized: false },
};
}

Expand Down
1 change: 1 addition & 0 deletions tests/unit/main/ipc/handlers/ThemeHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading
Loading