Skip to content
Open
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
108 changes: 108 additions & 0 deletions electron/editorWindowState.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
clampRectToWorkArea,
loadEditorWindowState,
resolveEditorCreation,
saveEditorWindowState,
shouldTrackEditorWindow,
} from "./editorWindowState";

const temps: string[] = [];

afterEach(() => {
for (const dir of temps) rmSync(dir, { recursive: true, force: true });
temps.length = 0;
});

function tmp(): string {
const dir = mkdtempSync(path.join(os.tmpdir(), "os-editor-win-"));
temps.push(dir);
return dir;
}

describe("editor window state", () => {
it("returns null when the file is missing", () => {
expect(loadEditorWindowState(tmp())).toBeNull();
});

it("clamps an off-screen rect onto the display workArea", () => {
const clamped = clampRectToWorkArea(
{ x: -4000, y: 50, width: 1200, height: 800 },
{ x: 0, y: 0, width: 1920, height: 1080 },
);
expect(clamped.x).toBe(0);
expect(clamped.y).toBe(50);
expect(clamped.width).toBe(1200);
expect(clamped.height).toBe(800);
});

it("still maximizes when nothing is saved, and never loads or saves the bench", () => {
const missing = resolveEditorCreation({ isBench: false, saved: null });
expect(missing.maximize).toBe(true);
expect(missing.persist).toBe(true);
expect(missing.bounds).toEqual({ width: 1200, height: 800 });

expect(shouldTrackEditorWindow({ windowType: "bench" })).toBe(false);
const bench = resolveEditorCreation({ isBench: true, saved: null });
expect(bench.persist).toBe(false);
expect(bench.maximize).toBe(true);

const dir = tmp();
saveEditorWindowState(dir, { x: 10, y: 20, width: 1280, height: 720, maximized: false });
expect(shouldTrackEditorWindow({ windowType: "bench" })).toBe(false);
expect(shouldTrackEditorWindow({})).toBe(true);
});

it("round-trips a save through load", () => {
const dir = tmp();
const state = { x: 10, y: 20, width: 1280, height: 720, maximized: true };
saveEditorWindowState(dir, state);
expect(loadEditorWindowState(dir)).toEqual(state);
});

it("returns null on a corrupted file instead of throwing", () => {
const dir = tmp();
writeFileSync(path.join(dir, "editor-window.json"), "{ not json");
expect(loadEditorWindowState(dir)).toBeNull();
});

it("rejects garbage fields rather than restoring a broken rect", () => {
const dir = tmp();
writeFileSync(
path.join(dir, "editor-window.json"),
JSON.stringify({ x: Number.NaN, y: 20, width: "1280", height: 720, maximized: false }),
);
expect(loadEditorWindowState(dir)).toBeNull();
});

it("rejects zero or negative dimensions instead of clamping them up", () => {
// A width:0 record is garbage the app never writes; letting it through
// would restore a min-size non-maximized window instead of the default.
for (const dims of [
{ width: 0, height: 720 },
{ width: 1280, height: 0 },
{ width: -1280, height: 720 },
]) {
const dir = tmp();
writeFileSync(
path.join(dir, "editor-window.json"),
JSON.stringify({ x: 10, y: 20, ...dims, maximized: false }),
);
expect(loadEditorWindowState(dir)).toBeNull();
}
});

it("rejects a non-boolean maximized rather than coercing it", () => {
for (const maximized of ["true", 1, null, undefined]) {
const dir = tmp();
writeFileSync(
path.join(dir, "editor-window.json"),
JSON.stringify({ x: 10, y: 20, width: 1280, height: 720, maximized }),
);
expect(loadEditorWindowState(dir)).toBeNull();
}
});
});
126 changes: 126 additions & 0 deletions electron/editorWindowState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// Persist/restore the editor window's normal bounds and maximized flag.
// The export bench must never load or save this file — it measures a 1200×800
// maximized window on purpose.

import { existsSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";

export const DEFAULT_EDITOR_SIZE = { width: 1200, height: 800 };
export const EDITOR_WINDOW_MIN = { width: 800, height: 600 };

export interface EditorWindowRect {
x: number;
y: number;
width: number;
height: number;
}

export interface EditorWindowState extends EditorWindowRect {
maximized: boolean;
}

export interface DisplayWorkArea {
x: number;
y: number;
width: number;
height: number;
}

export function editorWindowStatePath(userData: string): string {
return path.join(userData, "editor-window.json");
}

function isFiniteNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}

export function parseEditorWindowState(raw: unknown): EditorWindowState | null {
if (!raw || typeof raw !== "object") return null;
const rec = raw as Record<string, unknown>;
// Zero/negative dimensions and a non-boolean `maximized` are garbage the
// app never writes; restoring them would clamp into a small non-maximized
// window instead of the documented default. Reject the record whole.
if (
!isFiniteNumber(rec.x) ||
!isFiniteNumber(rec.y) ||
!isFiniteNumber(rec.width) ||
!isFiniteNumber(rec.height) ||
rec.width <= 0 ||
rec.height <= 0 ||
typeof rec.maximized !== "boolean"
) {
return null;
}
return {
x: rec.x,
y: rec.y,
width: rec.width,
height: rec.height,
maximized: rec.maximized,
};
}

export function loadEditorWindowState(userData: string): EditorWindowState | null {
const file = editorWindowStatePath(userData);
if (!existsSync(file)) return null;
try {
return parseEditorWindowState(JSON.parse(readFileSync(file, "utf8")));
} catch {
return null;
}
}

export function saveEditorWindowState(userData: string, state: EditorWindowState): void {
try {
writeFileSync(editorWindowStatePath(userData), `${JSON.stringify(state)}\n`, "utf8");
} catch {
// Best-effort; a failed write must not block close.
}
}

export function clampRectToWorkArea(
rect: EditorWindowRect,
workArea: DisplayWorkArea,
min = EDITOR_WINDOW_MIN,
): EditorWindowRect {
const width = Math.min(Math.max(rect.width, min.width), Math.max(min.width, workArea.width));
const height = Math.min(Math.max(rect.height, min.height), Math.max(min.height, workArea.height));
const maxX = workArea.x + Math.max(0, workArea.width - width);
const maxY = workArea.y + Math.max(0, workArea.height - height);
return {
x: Math.min(Math.max(rect.x, workArea.x), maxX),
y: Math.min(Math.max(rect.y, workArea.y), maxY),
width,
height,
};
}

export function shouldTrackEditorWindow(query: Record<string, string>): boolean {
return query.windowType !== "bench";
}

export function resolveEditorCreation(input: {
isBench: boolean;
saved: EditorWindowState | null;
}): {
bounds: { x?: number; y?: number; width: number; height: number };
maximize: boolean;
persist: boolean;
} {
if (input.isBench) {
return { bounds: { ...DEFAULT_EDITOR_SIZE }, maximize: true, persist: false };
}
if (!input.saved) {
return { bounds: { ...DEFAULT_EDITOR_SIZE }, maximize: true, persist: true };
}
return {
bounds: {
x: input.saved.x,
y: input.saved.y,
width: input.saved.width,
height: input.saved.height,
},
maximize: input.saved.maximized,
persist: true,
};
}
39 changes: 35 additions & 4 deletions electron/windows.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { BrowserWindow, ipcMain, screen } from "electron";
import { app, BrowserWindow, ipcMain, screen } from "electron";
import {
clampRectToWorkArea,
loadEditorWindowState,
resolveEditorCreation,
saveEditorWindowState,
shouldTrackEditorWindow,
} from "./editorWindowState";

const __dirname = path.dirname(fileURLToPath(import.meta.url));

Expand Down Expand Up @@ -392,10 +399,18 @@ export function createHudOverlayWindow(): BrowserWindow {
*/
export function createEditorWindow(query: Record<string, string> = {}): BrowserWindow {
const isMac = process.platform === "darwin";
const persist = shouldTrackEditorWindow(query);
const loaded = persist ? loadEditorWindowState(app.getPath("userData")) : null;
const saved = loaded
? {
...clampRectToWorkArea(loaded, screen.getDisplayMatching(loaded).workArea),
maximized: loaded.maximized,
}
: null;
const creation = resolveEditorCreation({ isBench: query.windowType === "bench", saved });

const win = new BrowserWindow({
width: 1200,
height: 800,
...creation.bounds,
minWidth: 800,
minHeight: 600,
// Seamless titlebar on every platform: the app's own topbar IS the titlebar
Expand Down Expand Up @@ -425,7 +440,23 @@ export function createEditorWindow(query: Record<string, string> = {}): BrowserW
},
});

win.maximize();
if (creation.maximize) win.maximize();
if (creation.persist) {
const persistState = () => {
if (win.isDestroyed()) return;
const bounds = win.getNormalBounds();
saveEditorWindowState(app.getPath("userData"), {
x: bounds.x,
y: bounds.y,
width: bounds.width,
height: bounds.height,
maximized: win.isMaximized(),
});
};
win.on("moved", persistState);
win.on("resized", persistState);
win.on("close", persistState);
}

// The editor renders its own File/Edit/View menu bar in the custom titlebar,
// so hide the native OS menu bar on Windows/Linux (it stays reachable via Alt).
Expand Down
Loading