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
9 changes: 6 additions & 3 deletions apps/desktop/src/lib/trpc/routers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
14 changes: 13 additions & 1 deletion apps/desktop/src/lib/trpc/routers/projects/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() }))
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
13 changes: 12 additions & 1 deletion apps/desktop/src/lib/trpc/routers/window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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();
}),

Expand Down
18 changes: 18 additions & 0 deletions apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
21 changes: 21 additions & 0 deletions apps/desktop/src/main/lib/terminal-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
const exitPromises: Promise<void>[] = [];

Expand Down
35 changes: 30 additions & 5 deletions apps/desktop/src/main/windows/main.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<typeof createIPCHandler> | 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;
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -271,6 +275,7 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => {
return () => {
isUnmounted = true;
inputDisposable.dispose();
cleanupKeyboard();
cleanupFocus?.();
cleanupResize();
cleanupQuerySuppression();
Expand All @@ -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(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 &&
Expand Down Expand Up @@ -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(
Expand Down