feat(desktop): support multiple windows

Closes #250
This commit is contained in:
Arielle Ostankova
2026-06-05 21:08:24 +08:00
committed by GitHub
parent 1377adbece
commit df635b570a
12 changed files with 362 additions and 62 deletions
+23 -3
View File
@@ -6,6 +6,10 @@ interface ShowContextMenuInput {
hasSelection?: boolean;
}
interface ApplicationMenuOptions {
onNewWindow: () => void;
}
function withBrowserWindow(
callback: (win: BrowserWindow) => void,
): (_item: Electron.MenuItem, baseWin: Electron.BaseWindow | undefined) => void {
@@ -41,10 +45,12 @@ function reloadFocusedContentsOrWindow(win: BrowserWindow, options?: { ignoreCac
win.webContents.reload();
}
export function setupApplicationMenu(): void {
function buildApplicationMenuTemplate(
options: ApplicationMenuOptions,
): Electron.MenuItemConstructorOptions[] {
const isMac = process.platform === "darwin";
const template: Electron.MenuItemConstructorOptions[] = [
return [
...(isMac
? [
{
@@ -63,6 +69,18 @@ export function setupApplicationMenu(): void {
},
]
: []),
{
label: "File",
submenu: [
{
label: "New Window",
accelerator: "CmdOrCtrl+Shift+N",
click: () => {
options.onNewWindow();
},
},
],
},
{
label: "Edit",
submenu: [
@@ -130,8 +148,10 @@ export function setupApplicationMenu(): void {
],
},
];
}
const menu = Menu.buildFromTemplate(template);
export function setupApplicationMenu(options: ApplicationMenuOptions): void {
const menu = Menu.buildFromTemplate(buildApplicationMenuTemplate(options));
Menu.setApplicationMenu(menu);
ipcMain.handle("paseo:menu:showContextMenu", (event, input?: ShowContextMenuInput) => {
+89 -38
View File
@@ -53,6 +53,7 @@ import {
setWorkspaceActivePaseoBrowserId,
} from "./features/browser-webviews.js";
import { parseOpenProjectPathFromArgv } from "./open-project-routing.js";
import { PendingOpenProjectStore } from "./pending-open-project-store.js";
import { getDesktopSettingsStore } from "./settings/desktop-settings-electron.js";
import { clampWindowStateToWorkAreas, createWindowStateStore } from "./settings/window-state.js";
import {
@@ -94,7 +95,6 @@ function preventUnsafeBrowserWebviewNavigation(
event.preventDefault();
}
}
const OPEN_PROJECT_EVENT = "paseo:event:open-project";
const BROWSER_SHORTCUT_EVENT = "paseo:event:browser-shortcut";
const BROWSER_FORWARDED_KEY_EVENT = "paseo:event:browser-forwarded-key";
@@ -257,6 +257,12 @@ let pendingOpenProjectPath = parseOpenProjectPathFromArgv({
isDefaultApp: process.defaultApp,
});
// Each window pulls its own pending open-project path on mount, keyed by
// webContents id, so deep-linked windows (second-instance launches, the
// in-app "Open in new window" action) land on the right project without
// racing a global.
const pendingOpenProjectStore = new PendingOpenProjectStore();
if (PASEO_DEBUG) {
log.info("[open-project] argv:", process.argv);
log.info("[open-project] isDefaultApp:", process.defaultApp);
@@ -265,10 +271,13 @@ if (PASEO_DEBUG) {
// The renderer pulls the pending path on mount via IPC — this avoids
// a race where the push event arrives before React registers its listener.
ipcMain.handle("paseo:get-pending-open-project", () => {
log.info("[open-project] renderer requested pending path:", pendingOpenProjectPath);
const result = pendingOpenProjectPath;
pendingOpenProjectPath = null;
ipcMain.handle("paseo:get-pending-open-project", (event) => {
const webContentsId = event.sender.id;
const result = pendingOpenProjectStore.take(webContentsId);
log.info("[open-project] renderer requested pending path:", {
webContentsId,
pendingPath: result,
});
return result;
});
@@ -326,7 +335,10 @@ ipcMain.handle("paseo:browser:clear-partition", async (_event, browserId: unknow
});
protocol.registerSchemesAsPrivileged([
{ scheme: APP_SCHEME, privileges: { standard: true, secure: true, supportFetchAPI: true } },
{
scheme: APP_SCHEME,
privileges: { standard: true, secure: true, supportFetchAPI: true },
},
]);
// ---------------------------------------------------------------------------
@@ -395,12 +407,24 @@ function getWorkAreasPrimaryFirst(): Electron.Rectangle[] {
return [primary, ...others].map((display) => display.workArea);
}
async function createMainWindow(): Promise<void> {
async function createWindow(
options: {
pendingOpenProjectPath?: string | null;
restoreWindowState?: boolean;
} = {},
): Promise<BrowserWindow> {
const iconPath = getWindowIconPath();
const systemTheme = resolveSystemWindowTheme();
const windowStateStore = createWindowStateStore({ userDataPath: app.getPath("userData") });
const savedWindowState = await windowStateStore.load();
// Only the first window of a session restores and persists saved geometry.
// Additional windows (⌘N, second-instance, "Open in new window") open at the
// default size and let the OS cascade them, so they neither stack on top of
// the restored window nor fight over the single window-state store.
const restoreWindowState = options.restoreWindowState ?? false;
const windowStateStore = restoreWindowState
? createWindowStateStore({ userDataPath: app.getPath("userData") })
: null;
const savedWindowState = windowStateStore ? await windowStateStore.load() : null;
const restoredWindowState = savedWindowState
? clampWindowStateToWorkAreas(savedWindowState, getWorkAreasPrimaryFirst())
: null;
@@ -424,6 +448,12 @@ async function createMainWindow(): Promise<void> {
},
});
const webContentsId = mainWindow.webContents.id;
pendingOpenProjectStore.set(webContentsId, options.pendingOpenProjectPath);
mainWindow.on("closed", () => {
pendingOpenProjectStore.delete(webContentsId);
});
if (devWorktreeName) {
app.dock?.setBadge(devWorktreeName);
}
@@ -434,7 +464,9 @@ async function createMainWindow(): Promise<void> {
setupDarwinCompositorWatchdog(mainWindow);
setupWindowResizeEvents(mainWindow);
setupWindowStatePersistence(mainWindow, windowStateStore);
if (windowStateStore) {
setupWindowStatePersistence(mainWindow, windowStateStore);
}
setupDefaultContextMenu(mainWindow);
setupDragDropPrevention(mainWindow);
mainWindow.webContents.on("will-attach-webview", (event, webPreferences, params) => {
@@ -531,31 +563,27 @@ async function createMainWindow(): Promise<void> {
const { loadReactDevTools } = await import("./features/react-devtools.js");
await loadReactDevTools();
await mainWindow.loadURL(DEV_SERVER_URL);
return;
return mainWindow;
}
await mainWindow.loadURL(`${APP_SCHEME}://app/`);
}
function sendOpenProjectEvent(win: BrowserWindow, projectPath: string): void {
const send = () => {
log.info("[open-project] sending event to renderer:", projectPath);
win.webContents.send(OPEN_PROJECT_EVENT, { path: projectPath });
};
if (win.webContents.isLoadingMainFrame()) {
log.info("[open-project] waiting for did-finish-load before sending event");
win.webContents.once("did-finish-load", send);
return;
}
send();
return mainWindow;
}
// ---------------------------------------------------------------------------
// App lifecycle
// ---------------------------------------------------------------------------
// Resolves once bootstrap() has registered the custom protocol handler and IPC
// handlers and created the first window. second-instance window creation waits
// on this rather than app.whenReady(): in packaged mode createWindow loads
// `paseo://app/`, which fails if the protocol handler isn't registered yet, and
// a second instance can arrive mid-cold-start.
let resolveBootstrapComplete: () => void;
const bootstrapComplete = new Promise<void>((resolve) => {
resolveBootstrapComplete = resolve;
});
function setupSingleInstanceLock(): boolean {
if (DISABLE_SINGLE_INSTANCE_LOCK) {
log.info("[single-instance] disabled by PASEO_DISABLE_SINGLE_INSTANCE_LOCK");
@@ -575,15 +603,14 @@ function setupSingleInstanceLock(): boolean {
isDefaultApp: false,
});
log.info("[open-project] second-instance openProjectPath:", openProjectPath);
const win = BrowserWindow.getAllWindows()[0];
if (win) {
win.show();
if (win.isMinimized()) win.restore();
win.focus();
if (openProjectPath) {
sendOpenProjectEvent(win, openProjectPath);
}
}
// Relaunching the app (CLI `paseo [path]`, double-click, etc.) opens a new
// window rather than focusing the existing one. Wait for bootstrap (not just
// app.whenReady) so the protocol + IPC handlers exist before the window loads.
void bootstrapComplete
.then(() => createWindow({ pendingOpenProjectPath: openProjectPath }))
.catch((error) => {
log.error("[window] failed to create window from second-instance", error);
});
});
return true;
@@ -689,7 +716,13 @@ async function bootstrap(): Promise<void> {
});
applyAppIcon();
setupApplicationMenu();
setupApplicationMenu({
onNewWindow: () => {
void createWindow().catch((error) => {
log.error("[window] failed to create window from menu", error);
});
},
});
ensureNotificationCenterRegistration();
if (await runDesktopSmokeIfRequested()) {
return;
@@ -701,11 +734,29 @@ async function bootstrap(): Promise<void> {
registerOpenerHandlers();
registerEditorTargetHandlers();
await createMainWindow();
// In-app "Open in new window": opens a window that lands on the given project
// via the same open-project flow as a CLI launch (no move, no ownership).
ipcMain.handle("paseo:window:openNew", async (_event, options?: unknown) => {
const pendingPath =
options && typeof options === "object" && "pendingOpenProjectPath" in options
? (options as { pendingOpenProjectPath?: unknown }).pendingOpenProjectPath
: null;
await createWindow({
pendingOpenProjectPath: typeof pendingPath === "string" ? pendingPath : null,
});
});
// The first window of the session restores and persists saved geometry.
await createWindow({ pendingOpenProjectPath, restoreWindowState: true });
pendingOpenProjectPath = null;
// Protocol + IPC handlers and the first window now exist: release any
// second-instance launches that arrived during cold start.
resolveBootstrapComplete();
app.on("activate", async () => {
if (BrowserWindow.getAllWindows().length === 0) {
await createMainWindow();
await createWindow({ restoreWindowState: true });
}
});
}
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { PendingOpenProjectStore } from "./pending-open-project-store";
describe("PendingOpenProjectStore", () => {
it("stores pending paths per window and consumes them independently", () => {
const store = new PendingOpenProjectStore();
store.set(101, "/tmp/project-a");
store.set(202, "/tmp/project-b");
expect(store.take(101)).toBe("/tmp/project-a");
expect(store.take(202)).toBe("/tmp/project-b");
});
it("clears a pending path after it is consumed", () => {
const store = new PendingOpenProjectStore();
store.set(101, "/tmp/project-a");
expect(store.take(101)).toBe("/tmp/project-a");
expect(store.take(101)).toBeNull();
});
it("ignores empty and whitespace-only paths", () => {
const store = new PendingOpenProjectStore();
store.set(101, " ");
expect(store.take(101)).toBeNull();
});
});
@@ -0,0 +1,32 @@
export class PendingOpenProjectStore {
private readonly pendingPathByWebContentsId = new Map<number, string>();
set(webContentsId: number, projectPath: string | null | undefined): void {
const normalizedPath = this.normalizeProjectPath(projectPath);
if (!normalizedPath) {
this.pendingPathByWebContentsId.delete(webContentsId);
return;
}
this.pendingPathByWebContentsId.set(webContentsId, normalizedPath);
}
take(webContentsId: number): string | null {
const projectPath = this.pendingPathByWebContentsId.get(webContentsId) ?? null;
this.pendingPathByWebContentsId.delete(webContentsId);
return projectPath;
}
delete(webContentsId: number): void {
this.pendingPathByWebContentsId.delete(webContentsId);
}
private normalizeProjectPath(projectPath: string | null | undefined): string | null {
if (typeof projectPath !== "string") {
return null;
}
const trimmedPath = projectPath.trim();
return trimmedPath ? trimmedPath : null;
}
}
+2
View File
@@ -20,6 +20,8 @@ contextBridge.exposeInMainWorld("paseoDesktop", {
},
},
window: {
openNew: (options?: { pendingOpenProjectPath?: string | null }) =>
ipcRenderer.invoke("paseo:window:openNew", options),
getCurrentWindow: () => ({
toggleMaximize: () => ipcRenderer.invoke("paseo:window:toggleMaximize"),
isFullscreen: () => ipcRenderer.invoke("paseo:window:isFullscreen"),
+11 -9
View File
@@ -240,17 +240,19 @@ export function registerWindowManager(): void {
}
export function setupWindowResizeEvents(win: BrowserWindow): void {
win.on("resize", () => {
// A resize/fullscreen event can fire while the window is tearing down; sending
// to a destroyed webContents throws. Guard so multi-window close doesn't surface
// "Object has been destroyed" exceptions.
const notifyResized = () => {
if (win.isDestroyed() || win.webContents.isDestroyed()) {
return;
}
win.webContents.send("paseo:window:resized", {});
});
};
win.on("enter-full-screen", () => {
win.webContents.send("paseo:window:resized", {});
});
win.on("leave-full-screen", () => {
win.webContents.send("paseo:window:resized", {});
});
win.on("resize", notifyResized);
win.on("enter-full-screen", notifyResized);
win.on("leave-full-screen", notifyResized);
}
/**