From 1d873aa82aff65042b32fb883152ee3dfc22d283 Mon Sep 17 00:00:00 2001 From: Amrit Subramanian Date: Thu, 2 Oct 2025 21:16:50 -0400 Subject: [PATCH] worktrees --- main.ts | 302 ++++++++++++++++++++++++++++++++++++++++++---------- renderer.ts | 250 ++++++++++++++++++++++++++++++++++--------- styles.css | 15 ++- 3 files changed, 462 insertions(+), 105 deletions(-) diff --git a/main.ts b/main.ts index 2a142fb..de8a3f1 100644 --- a/main.ts +++ b/main.ts @@ -1,6 +1,8 @@ import { app, BrowserWindow, ipcMain, dialog } from "electron"; import * as pty from "node-pty"; import * as os from "os"; +import * as path from "path"; +import * as fs from "fs"; import { simpleGit } from "simple-git"; import Store from "electron-store"; @@ -10,10 +12,79 @@ interface SessionConfig { codingAgent: string; } +interface PersistedSession { + id: string; + number: number; + name: string; + config: SessionConfig; + worktreePath: string; + createdAt: number; +} + let mainWindow: BrowserWindow; -const sessions = new Map(); +const activePtyProcesses = new Map(); const store = new Store(); +// Helper functions for session management +function getPersistedSessions(): PersistedSession[] { + return (store as any).get("sessions", []); +} + +function savePersistedSessions(sessions: PersistedSession[]) { + (store as any).set("sessions", sessions); +} + +function getNextSessionNumber(): number { + const sessions = getPersistedSessions(); + if (sessions.length === 0) return 1; + return Math.max(...sessions.map(s => s.number)) + 1; +} + +// Git worktree helper functions +async function createWorktree(projectDir: string, parentBranch: string, sessionNumber: number): Promise { + const git = simpleGit(projectDir); + const fleetcodeDir = path.join(projectDir, ".fleetcode"); + const worktreeName = `session${sessionNumber}`; + const worktreePath = path.join(fleetcodeDir, worktreeName); + const branchName = `fleetcode/session${sessionNumber}`; + + // Create .fleetcode directory if it doesn't exist + if (!fs.existsSync(fleetcodeDir)) { + fs.mkdirSync(fleetcodeDir, { recursive: true }); + } + + // Check if worktree already exists and remove it + if (fs.existsSync(worktreePath)) { + try { + await git.raw(["worktree", "remove", worktreePath, "--force"]); + } catch (error) { + console.error("Error removing existing worktree:", error); + } + } + + // Delete the branch if it exists + try { + await git.raw(["branch", "-D", branchName]); + } catch (error) { + // Branch doesn't exist, that's fine + } + + // Create new worktree with a new branch from parent branch + // This creates a new branch named "fleetcode/session" starting from the parent branch + await git.raw(["worktree", "add", "-b", branchName, worktreePath, parentBranch]); + + return worktreePath; +} + +async function removeWorktree(projectDir: string, worktreePath: string) { + const git = simpleGit(projectDir); + try { + await git.raw(["worktree", "remove", worktreePath, "--force"]); + } catch (error) { + console.error("Error removing worktree:", error); + } +} + // Open directory picker ipcMain.handle("select-directory", async () => { const result = await dialog.showOpenDialog(mainWindow, { @@ -54,19 +125,136 @@ ipcMain.on("save-settings", (_event, config: SessionConfig) => { }); // Create new session -ipcMain.on("create-session", (event, config: SessionConfig) => { - const sessionId = `session-${Date.now()}`; - const shell = os.platform() === "darwin" ? "zsh" : "bash"; +ipcMain.on("create-session", async (event, config: SessionConfig) => { + try { + const sessionNumber = getNextSessionNumber(); + const sessionId = `session-${Date.now()}`; + const sessionName = `Session ${sessionNumber}`; + // Create git worktree + const worktreePath = await createWorktree(config.projectDir, config.parentBranch, sessionNumber); + + // Create persisted session metadata + const persistedSession: PersistedSession = { + id: sessionId, + number: sessionNumber, + name: sessionName, + config, + worktreePath, + createdAt: Date.now(), + }; + + // Save to store + const sessions = getPersistedSessions(); + sessions.push(persistedSession); + savePersistedSessions(sessions); + + // Spawn PTY in worktree directory + const shell = os.platform() === "darwin" ? "zsh" : "bash"; + const ptyProcess = pty.spawn(shell, [], { + name: "xterm-color", + cols: 80, + rows: 30, + cwd: worktreePath, + env: process.env, + }); + + activePtyProcesses.set(sessionId, ptyProcess); + + let terminalReady = false; + let dataBuffer = ""; + + ptyProcess.onData((data) => { + mainWindow.webContents.send("session-output", sessionId, data); + + // Detect when terminal is ready + if (!terminalReady) { + dataBuffer += data; + + // Method 1: Look for bracketed paste mode enable sequence + if (dataBuffer.includes("\x1b[?2004h")) { + terminalReady = true; + + // Auto-run the selected coding agent + if (config.codingAgent === "claude") { + ptyProcess.write("claude\r"); + } else if (config.codingAgent === "codex") { + ptyProcess.write("codex\r"); + } + } + + // Method 2: Fallback - look for common prompt indicators + else if (dataBuffer.includes("$ ") || dataBuffer.includes("% ") || + dataBuffer.includes("> ") || dataBuffer.includes("➜ ") || + dataBuffer.includes("➜ ") || + dataBuffer.includes("✗ ") || dataBuffer.includes("✓ ") || + dataBuffer.endsWith("$") || dataBuffer.endsWith("%") || + dataBuffer.endsWith(">") || dataBuffer.endsWith("➜") || + dataBuffer.endsWith("✗") || dataBuffer.endsWith("✓")) { + terminalReady = true; + + // Auto-run the selected coding agent + if (config.codingAgent === "claude") { + ptyProcess.write("claude\r"); + } else if (config.codingAgent === "codex") { + ptyProcess.write("codex\r"); + } + } + } + }); + + event.reply("session-created", sessionId, persistedSession); + } catch (error) { + console.error("Error creating session:", error); + const errorMessage = error instanceof Error ? error.message : String(error); + event.reply("session-error", errorMessage); + } +}); + +// Handle session input +ipcMain.on("session-input", (_event, sessionId: string, data: string) => { + const ptyProcess = activePtyProcesses.get(sessionId); + if (ptyProcess) { + ptyProcess.write(data); + } +}); + +// Handle session resize +ipcMain.on("session-resize", (_event, sessionId: string, cols: number, rows: number) => { + const ptyProcess = activePtyProcesses.get(sessionId); + if (ptyProcess) { + ptyProcess.resize(cols, rows); + } +}); + +// Reopen session (spawn new PTY for existing session) +ipcMain.on("reopen-session", (event, sessionId: string) => { + // Check if PTY already active + if (activePtyProcesses.has(sessionId)) { + event.reply("session-reopened", sessionId); + return; + } + + // Find persisted session + const sessions = getPersistedSessions(); + const session = sessions.find(s => s.id === sessionId); + + if (!session) { + console.error("Session not found:", sessionId); + return; + } + + // Spawn new PTY in worktree directory + const shell = os.platform() === "darwin" ? "zsh" : "bash"; const ptyProcess = pty.spawn(shell, [], { name: "xterm-color", cols: 80, rows: 30, - cwd: config.projectDir || process.env.HOME, + cwd: session.worktreePath, env: process.env, }); - sessions.set(sessionId, ptyProcess); + activePtyProcesses.set(sessionId, ptyProcess); let terminalReady = false; let dataBuffer = ""; @@ -74,72 +262,72 @@ ipcMain.on("create-session", (event, config: SessionConfig) => { ptyProcess.onData((data) => { mainWindow.webContents.send("session-output", sessionId, data); - // Detect when terminal is ready + // Detect when terminal is ready and auto-run agent if (!terminalReady) { dataBuffer += data; - // Method 1: Look for bracketed paste mode enable sequence - // This is sent by modern shells (zsh, bash) when ready for input - if (dataBuffer.includes("\x1b[?2004h")) { + if (dataBuffer.includes("\x1b[?2004h") || + dataBuffer.includes("$ ") || dataBuffer.includes("% ") || + dataBuffer.includes("➜ ") || dataBuffer.includes("✗ ") || + dataBuffer.includes("✓ ")) { terminalReady = true; - // Auto-run the selected coding agent - if (config.codingAgent === "claude") { + if (session.config.codingAgent === "claude") { ptyProcess.write("claude\r"); - } else if (config.codingAgent === "codex") { - ptyProcess.write("codex\r"); - } - } - - // Method 2: Fallback - look for common prompt indicators - // In case bracketed paste mode is disabled - else if (dataBuffer.includes("$ ") || dataBuffer.includes("% ") || - dataBuffer.includes("> ") || dataBuffer.includes("➜ ") || - dataBuffer.includes("➜ ") || - dataBuffer.includes("✗ ") || dataBuffer.includes("✓ ") || - dataBuffer.endsWith("$") || dataBuffer.endsWith("%") || - dataBuffer.endsWith(">") || dataBuffer.endsWith("➜") || - dataBuffer.endsWith("✗") || dataBuffer.endsWith("✓")) { - terminalReady = true; - - // Auto-run the selected coding agent - if (config.codingAgent === "claude") { - ptyProcess.write("claude\r"); - } else if (config.codingAgent === "codex") { + } else if (session.config.codingAgent === "codex") { ptyProcess.write("codex\r"); } } } }); - event.reply("session-created", sessionId, config); + event.reply("session-reopened", sessionId); }); -// Handle session input -ipcMain.on("session-input", (_event, sessionId: string, data: string) => { - const session = sessions.get(sessionId); - if (session) { - session.write(data); - } -}); - -// Handle session resize -ipcMain.on("session-resize", (_event, sessionId: string, cols: number, rows: number) => { - const session = sessions.get(sessionId); - if (session) { - session.resize(cols, rows); - } -}); - -// Handle session close +// Close session (kill PTY but keep session) ipcMain.on("close-session", (_event, sessionId: string) => { - const session = sessions.get(sessionId); - if (session) { - session.kill(); - sessions.delete(sessionId); + const ptyProcess = activePtyProcesses.get(sessionId); + if (ptyProcess) { + ptyProcess.kill(); + activePtyProcesses.delete(sessionId); } }); +// Delete session (kill PTY, remove worktree, delete from store) +ipcMain.on("delete-session", async (_event, sessionId: string) => { + // Kill PTY if active + const ptyProcess = activePtyProcesses.get(sessionId); + if (ptyProcess) { + ptyProcess.kill(); + activePtyProcesses.delete(sessionId); + } + + // Find and remove from persisted sessions + const sessions = getPersistedSessions(); + const sessionIndex = sessions.findIndex(s => s.id === sessionId); + + if (sessionIndex === -1) { + console.error("Session not found:", sessionId); + return; + } + + const session = sessions[sessionIndex]; + + // Remove git worktree + await removeWorktree(session.config.projectDir, session.worktreePath); + + // Remove from store + sessions.splice(sessionIndex, 1); + savePersistedSessions(sessions); + + mainWindow.webContents.send("session-deleted", sessionId); +}); + +// Get all persisted sessions +ipcMain.handle("get-all-sessions", () => { + return getPersistedSessions(); +}); + const createWindow = () => { mainWindow = new BrowserWindow({ width: 1400, @@ -151,6 +339,12 @@ const createWindow = () => { }); mainWindow.loadFile("index.html"); + + // Load persisted sessions once window is ready + mainWindow.webContents.on("did-finish-load", () => { + const sessions = getPersistedSessions(); + mainWindow.webContents.send("load-persisted-sessions", sessions); + }); }; app.whenReady().then(() => { diff --git a/renderer.ts b/renderer.ts index 27cd523..649f0b1 100644 --- a/renderer.ts +++ b/renderer.ts @@ -8,19 +8,30 @@ interface SessionConfig { codingAgent: string; } -interface Session { +interface PersistedSession { id: string; - terminal: Terminal; - fitAddon: FitAddon; - element: HTMLDivElement; + number: number; name: string; config: SessionConfig; + worktreePath: string; + createdAt: number; +} + +interface Session { + id: string; + terminal: Terminal | null; + fitAddon: FitAddon | null; + element: HTMLDivElement | null; + name: string; + config: SessionConfig; + worktreePath: string; + hasActivePty: boolean; } const sessions = new Map(); let activeSessionId: string | null = null; -function createSession(sessionId: string, name: string, config: SessionConfig) { +function createTerminalUI(sessionId: string) { const term = new Terminal({ cursorBlink: true, fontSize: 14, @@ -50,26 +61,6 @@ function createSession(sessionId: string, name: string, config: SessionConfig) { ipcRenderer.send("session-input", sessionId, data); }); - const session: Session = { - id: sessionId, - terminal: term, - fitAddon, - element: sessionElement, - name, - config, - }; - - sessions.set(sessionId, session); - - // Add to sidebar - addToSidebar(sessionId, name); - - // Add tab - addTab(sessionId, name); - - // Switch to this session - switchToSession(sessionId); - // Handle resize const resizeHandler = () => { if (activeSessionId === sessionId) { @@ -79,10 +70,72 @@ function createSession(sessionId: string, name: string, config: SessionConfig) { }; window.addEventListener("resize", resizeHandler); + return { terminal: term, fitAddon, element: sessionElement }; +} + +function addSession(persistedSession: PersistedSession, hasActivePty: boolean) { + const session: Session = { + id: persistedSession.id, + terminal: null, + fitAddon: null, + element: null, + name: persistedSession.name, + config: persistedSession.config, + worktreePath: persistedSession.worktreePath, + hasActivePty, + }; + + sessions.set(persistedSession.id, session); + + // Add to sidebar + addToSidebar(persistedSession.id, persistedSession.name, hasActivePty); + + // Only add tab if terminal is active + if (hasActivePty) { + addTab(persistedSession.id, persistedSession.name); + } + return session; } -function addToSidebar(sessionId: string, name: string) { +function activateSession(sessionId: string) { + const session = sessions.get(sessionId); + if (!session) return; + + // If terminal UI doesn't exist yet, create it + if (!session.terminal) { + const ui = createTerminalUI(sessionId); + session.terminal = ui.terminal; + session.fitAddon = ui.fitAddon; + session.element = ui.element; + } + + session.hasActivePty = true; + updateSessionState(sessionId, true); + + // Add tab if it doesn't exist + if (!document.getElementById(`tab-${sessionId}`)) { + addTab(sessionId, session.name); + } + + // Switch to this session + switchToSession(sessionId); +} + +function updateSessionState(sessionId: string, isActive: boolean) { + const sidebarItem = document.getElementById(`sidebar-${sessionId}`); + const indicator = sidebarItem?.querySelector(".session-indicator"); + + if (indicator) { + if (isActive) { + indicator.classList.add("active"); + } else { + indicator.classList.remove("active"); + } + } +} + +function addToSidebar(sessionId: string, name: string, hasActivePty: boolean) { const list = document.getElementById("session-list"); if (!list) return; @@ -90,25 +143,42 @@ function addToSidebar(sessionId: string, name: string) { item.id = `sidebar-${sessionId}`; item.className = "session-list-item"; item.innerHTML = ` - ${name} - +
+ + ${name} +
+ `; item.addEventListener("click", (e) => { - if (!(e.target as HTMLElement).classList.contains("session-close-btn")) { - switchToSession(sessionId); + const target = e.target as HTMLElement; + if (!target.classList.contains("session-delete-btn")) { + handleSessionClick(sessionId); } }); - const closeBtn = item.querySelector(".session-close-btn"); - closeBtn?.addEventListener("click", (e) => { + const deleteBtn = item.querySelector(".session-delete-btn"); + deleteBtn?.addEventListener("click", (e) => { e.stopPropagation(); - closeSession(sessionId); + deleteSession(sessionId); }); list.appendChild(item); } +function handleSessionClick(sessionId: string) { + const session = sessions.get(sessionId); + if (!session) return; + + if (session.hasActivePty) { + // Just switch to it + switchToSession(sessionId); + } else { + // Reopen the session + ipcRenderer.send("reopen-session", sessionId); + } +} + function addTab(sessionId: string, name: string) { const tabsContainer = document.getElementById("tabs"); if (!tabsContainer) return; @@ -139,14 +209,16 @@ function addTab(sessionId: string, name: string) { function switchToSession(sessionId: string) { // Hide all sessions sessions.forEach((session, id) => { - session.element.classList.remove("active"); + if (session.element) { + session.element.classList.remove("active"); + } document.getElementById(`tab-${id}`)?.classList.remove("active"); document.getElementById(`sidebar-${id}`)?.classList.remove("active"); }); // Show active session const session = sessions.get(sessionId); - if (session) { + if (session && session.element && session.terminal && session.fitAddon) { session.element.classList.add("active"); document.getElementById(`tab-${sessionId}`)?.classList.add("active"); document.getElementById(`sidebar-${sessionId}`)?.classList.add("active"); @@ -155,8 +227,10 @@ function switchToSession(sessionId: string) { // Focus and resize session.terminal.focus(); setTimeout(() => { - session.fitAddon.fit(); - ipcRenderer.send("session-resize", sessionId, session.terminal.cols, session.terminal.rows); + if (session.fitAddon && session.terminal) { + session.fitAddon.fit(); + ipcRenderer.send("session-resize", sessionId, session.terminal.cols, session.terminal.rows); + } }, 0); } } @@ -165,23 +239,70 @@ function closeSession(sessionId: string) { const session = sessions.get(sessionId); if (!session) return; + // Remove terminal UI + if (session.element) { + session.element.remove(); + } + if (session.terminal) { + session.terminal.dispose(); + } + + // Remove tab + document.getElementById(`tab-${sessionId}`)?.remove(); + + // Update session state + session.terminal = null; + session.fitAddon = null; + session.element = null; + session.hasActivePty = false; + + // Update UI indicator + updateSessionState(sessionId, false); + + // Close PTY in main process + ipcRenderer.send("close-session", sessionId); + + // Switch to another active session + if (activeSessionId === sessionId) { + const activeSessions = Array.from(sessions.values()).filter(s => s.hasActivePty); + if (activeSessions.length > 0) { + switchToSession(activeSessions[0].id); + } else { + activeSessionId = null; + } + } +} + +function deleteSession(sessionId: string) { + const session = sessions.get(sessionId); + if (!session) return; + + // Confirm deletion + if (!confirm(`Delete ${session.name}? This will remove the git worktree.`)) { + return; + } + // Remove from UI - session.element.remove(); + if (session.element) { + session.element.remove(); + } + if (session.terminal) { + session.terminal.dispose(); + } document.getElementById(`tab-${sessionId}`)?.remove(); document.getElementById(`sidebar-${sessionId}`)?.remove(); - // Dispose terminal - session.terminal.dispose(); + // Remove from sessions map sessions.delete(sessionId); - // Close in main process - ipcRenderer.send("close-session", sessionId); + // Delete in main process (handles worktree removal) + ipcRenderer.send("delete-session", sessionId); // Switch to another session if (activeSessionId === sessionId) { - const remainingSessions = Array.from(sessions.keys()); + const remainingSessions = Array.from(sessions.values()).filter(s => s.hasActivePty); if (remainingSessions.length > 0) { - switchToSession(remainingSessions[0]); + switchToSession(remainingSessions[0].id); } else { activeSessionId = null; } @@ -191,15 +312,48 @@ function closeSession(sessionId: string) { // Handle session output ipcRenderer.on("session-output", (_event, sessionId: string, data: string) => { const session = sessions.get(sessionId); - if (session) { + if (session && session.terminal) { session.terminal.write(data); } }); // Handle session created -ipcRenderer.on("session-created", (_event, sessionId: string, config: SessionConfig) => { - const name = `Session ${sessions.size + 1}`; - createSession(sessionId, name, config); +ipcRenderer.on("session-created", (_event, sessionId: string, persistedSession: any) => { + const session = addSession(persistedSession, true); + activateSession(sessionId); +}); + +// Handle session reopened +ipcRenderer.on("session-reopened", (_event, sessionId: string) => { + activateSession(sessionId); +}); + +// Handle session deleted +ipcRenderer.on("session-deleted", (_event, sessionId: string) => { + const session = sessions.get(sessionId); + if (session) { + if (session.element) session.element.remove(); + if (session.terminal) session.terminal.dispose(); + document.getElementById(`tab-${sessionId}`)?.remove(); + document.getElementById(`sidebar-${sessionId}`)?.remove(); + sessions.delete(sessionId); + + if (activeSessionId === sessionId) { + const remainingSessions = Array.from(sessions.values()).filter(s => s.hasActivePty); + if (remainingSessions.length > 0) { + switchToSession(remainingSessions[0].id); + } else { + activeSessionId = null; + } + } + } +}); + +// Load persisted sessions on startup +ipcRenderer.on("load-persisted-sessions", (_event, persistedSessions: PersistedSession[]) => { + persistedSessions.forEach(ps => { + addSession(ps, false); + }); }); // Modal handling diff --git a/styles.css b/styles.css index 602845e..1058397 100644 --- a/styles.css +++ b/styles.css @@ -41,13 +41,22 @@ @apply bg-gray-700; } - .session-close-btn { - @apply text-gray-500 hover:text-red-500 ml-2; + .session-indicator { + @apply w-2 h-2 rounded-full bg-gray-600; + transition: background-color 0.2s; + } + + .session-indicator.active { + @apply bg-green-500; + } + + .session-delete-btn { + @apply text-gray-500 hover:text-red-500; opacity: 0; transition: opacity 0.2s; } - .session-list-item:hover .session-close-btn { + .session-list-item:hover .session-delete-btn { opacity: 1; }