diff --git a/apps/desktop/src/lib/trpc/routers/index.ts b/apps/desktop/src/lib/trpc/routers/index.ts index 44817a87322..b8a465ceb20 100644 --- a/apps/desktop/src/lib/trpc/routers/index.ts +++ b/apps/desktop/src/lib/trpc/routers/index.ts @@ -12,11 +12,14 @@ import { createWorkspacesRouter } from "./workspaces"; /** * Main application router * Combines all domain-specific routers into a single router + * + * Uses a getter function to access the current window, allowing + * window recreation on macOS without stale references. */ -export const createAppRouter = (window: BrowserWindow) => { +export const createAppRouter = (getWindow: () => BrowserWindow | null) => { return router({ - window: createWindowRouter(window), - projects: createProjectsRouter(window), + window: createWindowRouter(getWindow), + projects: createProjectsRouter(getWindow), workspaces: createWorkspacesRouter(), terminal: createTerminalRouter(), notifications: createNotificationsRouter(), diff --git a/apps/desktop/src/lib/trpc/routers/projects/projects.ts b/apps/desktop/src/lib/trpc/routers/projects/projects.ts index 1ab9c6e0958..c98244f61c0 100644 --- a/apps/desktop/src/lib/trpc/routers/projects/projects.ts +++ b/apps/desktop/src/lib/trpc/routers/projects/projects.ts @@ -80,7 +80,7 @@ function extractRepoName(urlInput: string): string | null { return repoSegment; } -export const createProjectsRouter = (window: BrowserWindow) => { +export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { return router({ get: publicProcedure .input(z.object({ id: z.string() })) @@ -95,6 +95,10 @@ export const createProjectsRouter = (window: BrowserWindow) => { }), openNew: publicProcedure.mutation(async () => { + const window = getWindow(); + if (!window) { + return { canceled: false, error: "No window available" }; + } const result = await dialog.showOpenDialog(window, { properties: ["openDirectory"], title: "Open Project", @@ -169,6 +173,14 @@ export const createProjectsRouter = (window: BrowserWindow) => { let targetDir = input.targetDirectory; if (!targetDir) { + const window = getWindow(); + if (!window) { + return { + canceled: false as const, + success: false as const, + error: "No window available", + }; + } const result = await dialog.showOpenDialog(window, { properties: ["openDirectory", "createDirectory"], title: "Select Clone Destination", diff --git a/apps/desktop/src/lib/trpc/routers/window.ts b/apps/desktop/src/lib/trpc/routers/window.ts index 2c4d4fe5ee2..d28d5fd2ffc 100644 --- a/apps/desktop/src/lib/trpc/routers/window.ts +++ b/apps/desktop/src/lib/trpc/routers/window.ts @@ -5,15 +5,22 @@ import { publicProcedure, router } from ".."; /** * Window router for window controls * Handles minimize, maximize, close, and platform detection + * + * Uses a getter function to always access the current window, + * allowing window recreation on macOS without stale references. */ -export const createWindowRouter = (window: BrowserWindow) => { +export const createWindowRouter = (getWindow: () => BrowserWindow | null) => { return router({ minimize: publicProcedure.mutation(() => { + const window = getWindow(); + if (!window) return { success: false }; window.minimize(); return { success: true }; }), maximize: publicProcedure.mutation(() => { + const window = getWindow(); + if (!window) return { success: false, isMaximized: false }; if (window.isMaximized()) { window.unmaximize(); } else { @@ -23,11 +30,15 @@ export const createWindowRouter = (window: BrowserWindow) => { }), close: publicProcedure.mutation(() => { + const window = getWindow(); + if (!window) return { success: false }; window.close(); return { success: true }; }), isMaximized: publicProcedure.query(() => { + const window = getWindow(); + if (!window) return false; return window.isMaximized(); }), diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts index 3e6da1b1cfe..2ff186dd9f6 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts @@ -179,6 +179,24 @@ export async function createWorktree( const errorMessage = error instanceof Error ? error.message : String(error); const lowerError = errorMessage.toLowerCase(); + // Check for git lock file errors (e.g., .git/config.lock, .git/index.lock) + const isLockError = + lowerError.includes("could not lock") || + lowerError.includes("unable to lock") || + (lowerError.includes(".lock") && lowerError.includes("file exists")); + + if (isLockError) { + console.error( + `Git lock file error during worktree creation: ${errorMessage}`, + ); + throw new Error( + `Failed to create worktree: The git repository is locked by another process. ` + + `This usually happens when another git operation is in progress, or a previous operation crashed. ` + + `Please wait for the other operation to complete, or manually remove the lock file ` + + `(e.g., .git/config.lock or .git/index.lock) if you're sure no git operations are running.`, + ); + } + // Broad check for LFS-related errors: // - "git-lfs" / "filter-process" (original) // - "smudge filter" (more specific than just "smudge" to avoid false positives) diff --git a/apps/desktop/src/main/lib/terminal-manager.ts b/apps/desktop/src/main/lib/terminal-manager.ts index ada4b65a4a5..251b38cedf8 100644 --- a/apps/desktop/src/main/lib/terminal-manager.ts +++ b/apps/desktop/src/main/lib/terminal-manager.ts @@ -402,6 +402,27 @@ export class TerminalManager extends EventEmitter { ).length; } + /** + * Remove terminal stream subscription listeners without killing terminals. + * Used when window closes on macOS to prevent duplicate listeners + * when window reopens. + * + * Only removes data:* and exit:* listeners (from tRPC subscriptions), + * preserving any other listeners that may be registered. + * + * Note: On app quit, cleanup() has its own timeout fallback if + * listeners are removed early. + */ + detachAllListeners(): void { + const eventNames = this.eventNames(); + for (const event of eventNames) { + const name = String(event); + if (name.startsWith("data:") || name.startsWith("exit:")) { + this.removeAllListeners(event); + } + } + } + async cleanup(): Promise { const exitPromises: Promise[] = []; diff --git a/apps/desktop/src/main/windows/main.ts b/apps/desktop/src/main/windows/main.ts index 57588ab1011..8c395052a6a 100644 --- a/apps/desktop/src/main/windows/main.ts +++ b/apps/desktop/src/main/windows/main.ts @@ -1,4 +1,5 @@ import { join } from "node:path"; +import type { BrowserWindow } from "electron"; import { Notification, screen } from "electron"; import { createWindow } from "lib/electron-app/factories/windows/create"; import { createAppRouter } from "lib/trpc/routers"; @@ -12,6 +13,16 @@ import { notificationsApp, notificationsEmitter, } from "../lib/notifications/server"; +import { terminalManager } from "../lib/terminal-manager"; + +// Singleton IPC handler to prevent duplicate handlers on window reopen (macOS) +let ipcHandler: ReturnType | null = null; + +// Current window reference - updated on window create/close +let currentWindow: BrowserWindow | null = null; + +// Getter for routers to access current window without stale references +const getWindow = () => currentWindow; export async function MainWindow() { const { width, height } = screen.getPrimaryDisplay().workAreaSize; @@ -44,11 +55,19 @@ export async function MainWindow() { // Create application menu createApplicationMenu(window); - // Set up tRPC handler - createIPCHandler({ - router: createAppRouter(window), - windows: [window], - }); + // Update current window reference for router getter + currentWindow = window; + + // Set up tRPC handler - reuse existing handler on macOS window reopen + // Router uses getWindow() to always access current window + if (ipcHandler) { + ipcHandler.attachWindow(window); + } else { + ipcHandler = createIPCHandler({ + router: createAppRouter(getWindow), + windows: [window], + }); + } // Start notifications HTTP server const server = notificationsApp.listen( @@ -97,6 +116,12 @@ export async function MainWindow() { window.on("close", () => { server.close(); notificationsEmitter.removeAllListeners(); + // Remove terminal listeners to prevent duplicates when window reopens on macOS + terminalManager.detachAllListeners(); + // Detach window from IPC handler (handler stays alive for window reopen) + ipcHandler?.detachWindow(window); + // Clear current window reference + currentWindow = null; }); return window; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx index b3e2f81ef98..0f801aa0654 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx @@ -2,7 +2,7 @@ import "@xterm/xterm/css/xterm.css"; import type { FitAddon } from "@xterm/addon-fit"; import type { SearchAddon } from "@xterm/addon-search"; import type { Terminal as XTerm } from "@xterm/xterm"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { trpc } from "renderer/lib/trpc"; import { useWindowsStore } from "renderer/stores/tabs/store"; @@ -99,11 +99,13 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { }); // Handler to set focused pane when terminal gains focus - const handleTerminalFocus = useCallback(() => { + // Use ref to avoid triggering full terminal recreation when focus handler changes + const handleTerminalFocusRef = useRef(() => {}); + handleTerminalFocusRef.current = () => { if (pane?.windowId) { setFocusedPane(pane.windowId, paneId); } - }, [pane?.windowId, paneId, setFocusedPane]); + }; // Auto-close search when terminal loses focus useEffect(() => { @@ -245,7 +247,7 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { const inputDisposable = xterm.onData(handleTerminalInput); // Intercept keyboard events to handle app hotkeys and provide iTerm-like line continuation UX - setupKeyboardHandler(xterm, { + const cleanupKeyboard = setupKeyboardHandler(xterm, { onShiftEnter: () => { if (!isExitedRef.current) { // Use shell's native continuation syntax to avoid shell-specific parsing @@ -257,8 +259,10 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { }, }); - // Setup focus listener to track focused pane - const cleanupFocus = setupFocusListener(xterm, handleTerminalFocus); + // Setup focus listener to track focused pane (use ref to get latest handler) + const cleanupFocus = setupFocusListener(xterm, () => + handleTerminalFocusRef.current(), + ); const cleanupResize = setupResizeHandlers( container, xterm, @@ -271,6 +275,7 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { return () => { isUnmounted = true; inputDisposable.dispose(); + cleanupKeyboard(); cleanupFocus?.(); cleanupResize(); cleanupQuerySuppression(); @@ -281,14 +286,7 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { xtermRef.current = null; searchAddonRef.current = null; }; - }, [ - paneId, - workspaceId, - workspaceCwd, - paneName, - terminalTheme, - handleTerminalFocus, - ]); + }, [paneId, workspaceId, workspaceCwd, paneName, terminalTheme]); // Sync theme changes to xterm instance for live theme switching useEffect(() => { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts index 1b1914a69f7..7700ad24f6c 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts @@ -139,12 +139,14 @@ export interface KeyboardHandlerOptions { * - Shortcut forwarding: App hotkeys are re-dispatched to document for react-hotkeys-hook * - Shift+Enter: Creates a line continuation (like iTerm) instead of executing * - Cmd+K: Clears the terminal + * + * Returns a cleanup function to remove the handler. */ export function setupKeyboardHandler( xterm: XTerm, options: KeyboardHandlerOptions = {}, -): void { - xterm.attachCustomKeyEventHandler((event: KeyboardEvent) => { +): () => void { + const handler = (event: KeyboardEvent): boolean => { const isShiftEnter = event.key === "Enter" && event.shiftKey && @@ -200,7 +202,14 @@ export function setupKeyboardHandler( } return true; - }); + }; + + xterm.attachCustomKeyEventHandler(handler); + + // Return cleanup function that removes the handler by setting a no-op + return () => { + xterm.attachCustomKeyEventHandler(() => true); + }; } export function setupFocusListener(