diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 0c8710e928d..20cfdff8191 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -8,9 +8,12 @@ config({ path: resolve(__dirname, "../../../../.env"), override: true }); import path from "node:path"; import { app } from "electron"; import { makeAppSetup } from "lib/electron-app/factories/app/setup"; +import { registerDeepLinkIpcs } from "main/lib/deep-link-ipcs"; import { deepLinkManager } from "main/lib/deep-link-manager"; +import { registerPortIpcs } from "main/lib/port-ipcs"; import { getPort } from "main/lib/port-manager"; -import { MainWindow } from "./windows/main"; +import windowManager from "main/lib/window-manager"; +import { registerWorkspaceIPCs } from "main/lib/workspace-ipcs"; // Protocol scheme for deep linking const PROTOCOL_SCHEME = "superset"; @@ -19,11 +22,9 @@ const PROTOCOL_SCHEME = "superset"; // In development, we need to provide the execPath and args if (process.defaultApp) { if (process.argv.length >= 2) { - app.setAsDefaultProtocolClient( - PROTOCOL_SCHEME, - process.execPath, - [path.resolve(process.argv[1])], - ); + app.setAsDefaultProtocolClient(PROTOCOL_SCHEME, process.execPath, [ + path.resolve(process.argv[1]), + ]); } } else { app.setAsDefaultProtocolClient(PROTOCOL_SCHEME); @@ -46,5 +47,11 @@ app.on("open-url", (event, url) => { console.log(`Using dev server port: ${port}`); await app.whenReady(); - await makeAppSetup(MainWindow); + + // Register IPC handlers once at startup (not per-window) + registerWorkspaceIPCs(); + registerPortIpcs(); + registerDeepLinkIpcs(); + + await makeAppSetup(() => windowManager.createWindow()); })(); diff --git a/apps/desktop/src/main/lib/menu.ts b/apps/desktop/src/main/lib/menu.ts index 93a19da3fca..6b711740ed6 100644 --- a/apps/desktop/src/main/lib/menu.ts +++ b/apps/desktop/src/main/lib/menu.ts @@ -1,4 +1,5 @@ import { app, type BrowserWindow, dialog, Menu } from "electron"; +import windowManager from "./window-manager"; import workspaceManager from "./workspace-manager"; export function createApplicationMenu(mainWindow: BrowserWindow) { @@ -6,6 +7,23 @@ export function createApplicationMenu(mainWindow: BrowserWindow) { { label: "File", submenu: [ + { + label: "New Window", + accelerator: "CmdOrCtrl+Shift+N", + click: async () => { + try { + await windowManager.createWindow(); + } catch (error) { + console.error("[Menu] Failed to create new window:", error); + dialog.showErrorBox( + "Error", + "Failed to create new window: " + + (error instanceof Error ? error.message : "Unknown error"), + ); + } + }, + }, + { type: "separator" }, { label: "Open Repository...", accelerator: "CmdOrCtrl+O", diff --git a/apps/desktop/src/main/lib/terminal-ipcs.ts b/apps/desktop/src/main/lib/terminal-ipcs.ts index 74ac4142459..d0e13800b9f 100644 --- a/apps/desktop/src/main/lib/terminal-ipcs.ts +++ b/apps/desktop/src/main/lib/terminal-ipcs.ts @@ -1,91 +1,115 @@ -import { type BrowserWindow, ipcMain, shell } from "electron"; +import { + BrowserWindow, + type BrowserWindow as BrowserWindowType, + ipcMain, + shell, +} from "electron"; import tmuxManager from "./tmux-manager"; -export function registerTerminalIPCs(mainWindow: BrowserWindow) { - // Set main window reference - tmuxManager.setMainWindow(mainWindow); - - // Initialize tmux manager (restore sessions) - tmuxManager.initialize().catch((error) => { - console.error("[Terminal IPC] Failed to initialize tmux manager:", error); - }); - - // Create terminal (or reattach to existing tmux session) - ipcMain.handle( - "terminal-create", - async ( - _event, - options: { - id?: string; - cols?: number; - rows?: number; - cwd?: string; - command?: string; +let ipcHandlersRegistered = false; + +export function registerTerminalIPCs(window: BrowserWindowType) { + // Initialize tmux manager (restore sessions) only once + if (!ipcHandlersRegistered) { + tmuxManager.initialize().catch((error) => { + console.error("[Terminal IPC] Failed to initialize tmux manager:", error); + }); + } + + // Register IPC handlers only once globally + if (!ipcHandlersRegistered) { + // Create terminal (or reattach to existing tmux session) + ipcMain.handle( + "terminal-create", + async ( + event, + options: { + id?: string; + cols?: number; + rows?: number; + cwd?: string; + command?: string; + }, + ) => { + // Get the window that sent this request + const senderWindow = BrowserWindow.fromWebContents(event.sender); + const terminalId = await tmuxManager.create(options); + + // Register this window to receive output from this terminal + if (senderWindow) { + tmuxManager.registerTerminalWindow(terminalId, senderWindow); + } + + return terminalId; + }, + ); + + // Send input to terminal + ipcMain.on( + "terminal-input", + (_event, message: { id: string; data: string }) => { + tmuxManager.write(message.id, message.data); + }, + ); + + // Resize terminal with sequence tracking + ipcMain.on( + "terminal-resize", + ( + _event, + message: { id: string; cols: number; rows: number; seq: number }, + ) => { + tmuxManager.resize(message.id, message.cols, message.rows, message.seq); + }, + ); + + // Send signal to terminal foreground process + ipcMain.on( + "terminal-signal", + (_event, message: { id: string; signal: string }) => { + tmuxManager.signal(message.id, message.signal); }, - ) => { - return await tmuxManager.create(options); - }, - ); - - // Send input to terminal - ipcMain.on( - "terminal-input", - (_event, message: { id: string; data: string }) => { - tmuxManager.write(message.id, message.data); - }, - ); - - // Resize terminal with sequence tracking - ipcMain.on( - "terminal-resize", - ( - _event, - message: { id: string; cols: number; rows: number; seq: number }, - ) => { - tmuxManager.resize(message.id, message.cols, message.rows, message.seq); - }, - ); - - // Send signal to terminal foreground process - ipcMain.on( - "terminal-signal", - (_event, message: { id: string; signal: string }) => { - tmuxManager.signal(message.id, message.signal); - }, - ); - - // Detach from terminal (keep tmux session alive) - ipcMain.on("terminal-detach", (_event, id: string) => { - tmuxManager.detach(id); - }); - - // Execute command in terminal - ipcMain.on( - "terminal-execute-command", - (_event, message: { id: string; command: string }) => { - tmuxManager.executeCommand(message.id, message.command); - }, - ); - - // Kill terminal (destroy tmux session completely) - ipcMain.on("terminal-kill", (_event, id: string) => { - tmuxManager.kill(id); - }); - - // Get terminal history - ipcMain.handle("terminal-get-history", (_event, id: string) => { - return tmuxManager.getHistory(id); - }); - - // Open external URLs - ipcMain.handle("open-external", async (_event, url: string) => { - await shell.openExternal(url); - }); - - // Clean up on app quit + ); + + // Detach from terminal (keep tmux session alive) + ipcMain.on("terminal-detach", (_event, id: string) => { + tmuxManager.detach(id); + }); + + // Execute command in terminal + ipcMain.on( + "terminal-execute-command", + (_event, message: { id: string; command: string }) => { + tmuxManager.executeCommand(message.id, message.command); + }, + ); + + // Kill terminal (destroy tmux session completely) + ipcMain.on("terminal-kill", (_event, id: string) => { + tmuxManager.kill(id); + }); + + // Get terminal history + ipcMain.handle("terminal-get-history", (_event, id: string) => { + return tmuxManager.getHistory(id); + }); + + // Open external URLs + ipcMain.handle("open-external", async (_event, url: string) => { + await shell.openExternal(url); + }); + + ipcHandlersRegistered = true; + } + + // Clean up when this window closes const cleanup = () => { - // tmuxManager.killAll(); + console.log("[Terminal IPC] Cleaning up window terminal registrations"); + tmuxManager.unregisterWindow(window); }; + // Register cleanup on window close + window.on("closed", cleanup); + return cleanup; } diff --git a/apps/desktop/src/main/lib/tmux-manager.ts b/apps/desktop/src/main/lib/tmux-manager.ts index 4853f1115d3..8920cdee604 100644 --- a/apps/desktop/src/main/lib/tmux-manager.ts +++ b/apps/desktop/src/main/lib/tmux-manager.ts @@ -31,7 +31,7 @@ interface ActiveSession { class TmuxManager { private static instance: TmuxManager; private sessions: Map; - private mainWindow: BrowserWindow | null = null; + private terminalWindows: Map> = new Map(); private sessionRegistryPath: string; // tmux socket name - unique per user private readonly TMUX_SOCKET = "onlook"; @@ -52,8 +52,50 @@ class TmuxManager { return TmuxManager.instance; } - setMainWindow(window: BrowserWindow | null): void { - this.mainWindow = window; + /** + * Register a window to receive output from a specific terminal + */ + registerTerminalWindow(terminalId: string, window: BrowserWindow): void { + if (!this.terminalWindows.has(terminalId)) { + this.terminalWindows.set(terminalId, new Set()); + } + this.terminalWindows.get(terminalId)?.add(window); + console.log( + `[TmuxManager] Registered window for terminal ${terminalId}, now ${this.terminalWindows.get(terminalId)?.size} window(s)`, + ); + } + + /** + * Unregister a window from receiving output from a specific terminal + */ + unregisterTerminalWindow(terminalId: string, window: BrowserWindow): void { + const windows = this.terminalWindows.get(terminalId); + if (windows) { + windows.delete(window); + console.log( + `[TmuxManager] Unregistered window from terminal ${terminalId}, now ${windows.size} window(s)`, + ); + if (windows.size === 0) { + this.terminalWindows.delete(terminalId); + } + } + } + + /** + * Unregister a window from all terminals (when window closes) + */ + unregisterWindow(window: BrowserWindow): void { + let count = 0; + for (const [terminalId, windows] of this.terminalWindows.entries()) { + if (windows.has(window)) { + windows.delete(window); + count++; + if (windows.size === 0) { + this.terminalWindows.delete(terminalId); + } + } + } + console.log(`[TmuxManager] Unregistered window from ${count} terminal(s)`); } /** @@ -596,13 +638,20 @@ class TmuxManager { } /** - * Emit terminal data to renderer + * Emit terminal data to all windows viewing this terminal */ private emitMessage(sid: string, data: string): void { - this.mainWindow?.webContents.send("terminal-on-data", { - id: sid, - data, - }); + const windows = this.terminalWindows.get(sid); + if (windows && windows.size > 0) { + for (const window of windows) { + if (!window.isDestroyed()) { + window.webContents.send("terminal-on-data", { + id: sid, + data, + }); + } + } + } } /** diff --git a/apps/desktop/src/main/lib/window-ipcs.ts b/apps/desktop/src/main/lib/window-ipcs.ts new file mode 100644 index 00000000000..b230208e24f --- /dev/null +++ b/apps/desktop/src/main/lib/window-ipcs.ts @@ -0,0 +1,17 @@ +import { ipcMain } from "electron"; +import windowManager from "./window-manager"; + +export function registerWindowIPCs() { + ipcMain.handle("window-create", async () => { + try { + await windowManager.createWindow(); + return { success: true }; + } catch (error) { + console.error("[Window IPC] Failed to create window:", error); + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }; + } + }); +} diff --git a/apps/desktop/src/main/lib/window-manager.ts b/apps/desktop/src/main/lib/window-manager.ts new file mode 100644 index 00000000000..de3effda6e0 --- /dev/null +++ b/apps/desktop/src/main/lib/window-manager.ts @@ -0,0 +1,27 @@ +import type { BrowserWindow } from "electron"; +import { MainWindow } from "../windows/main"; + +class WindowManager { + private windows: Set = new Set(); + + async createWindow(): Promise { + const window = await MainWindow(); + this.windows.add(window); + + window.on("closed", () => { + this.windows.delete(window); + }); + + return window; + } + + getWindows(): BrowserWindow[] { + return Array.from(this.windows); + } + + getWindowCount(): number { + return this.windows.size; + } +} + +export default new WindowManager(); diff --git a/apps/desktop/src/main/windows/main.ts b/apps/desktop/src/main/windows/main.ts index 3abb01267f9..5d86b44c2b1 100644 --- a/apps/desktop/src/main/windows/main.ts +++ b/apps/desktop/src/main/windows/main.ts @@ -1,19 +1,15 @@ import { join } from "node:path"; -import { BrowserWindow, ipcMain, screen, shell } from "electron"; +import { screen } from "electron"; import { createWindow } from "lib/electron-app/factories/windows/create"; -import { ENVIRONMENT } from "shared/constants"; import { displayName } from "~/package.json"; import { createApplicationMenu } from "../lib/menu"; -import { registerDeepLinkIpcs } from "../lib/deep-link-ipcs"; import { portDetector } from "../lib/port-detector"; -import { registerPortIpcs } from "../lib/port-ipcs"; import { registerTerminalIPCs } from "../lib/terminal-ipcs"; import { getActiveWorkspaceId, updateDetectedPorts, } from "../lib/workspace/workspace-operations"; -import { registerWorkspaceIPCs } from "../lib/workspace-ipcs"; import workspaceManager from "../lib/workspace-manager"; export async function MainWindow() { @@ -40,11 +36,8 @@ export async function MainWindow() { }, }); - // Register IPC handlers + // Register terminal IPCs for this window const cleanupTerminal = registerTerminalIPCs(window); - registerWorkspaceIPCs(); - registerPortIpcs(); - registerDeepLinkIpcs(); // Set up port detection listeners portDetector.on("port-detected", async (event: any) => { @@ -125,12 +118,11 @@ export async function MainWindow() { }); window.on("close", () => { - // Clean up terminal processes + // Clean up terminal processes for this window cleanupTerminal(); - for (const window of BrowserWindow.getAllWindows()) { - window.destroy(); - } + // Note: Don't destroy other windows - let them close independently + // Each window manages its own lifecycle }); return window;