Skip to content

Commit 1d873aa

Browse files
committed
worktrees
1 parent fe287a2 commit 1d873aa

File tree

3 files changed

+456
-99
lines changed

3 files changed

+456
-99
lines changed

main.ts

Lines changed: 244 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { app, BrowserWindow, ipcMain, dialog } from "electron";
22
import * as pty from "node-pty";
33
import * as os from "os";
4+
import * as path from "path";
5+
import * as fs from "fs";
46
import { simpleGit } from "simple-git";
57
import Store from "electron-store";
68

@@ -10,10 +12,79 @@ interface SessionConfig {
1012
codingAgent: string;
1113
}
1214

15+
interface PersistedSession {
16+
id: string;
17+
number: number;
18+
name: string;
19+
config: SessionConfig;
20+
worktreePath: string;
21+
createdAt: number;
22+
}
23+
1324
let mainWindow: BrowserWindow;
14-
const sessions = new Map<string, pty.IPty>();
25+
const activePtyProcesses = new Map<string, pty.IPty>();
1526
const store = new Store();
1627

28+
// Helper functions for session management
29+
function getPersistedSessions(): PersistedSession[] {
30+
return (store as any).get("sessions", []);
31+
}
32+
33+
function savePersistedSessions(sessions: PersistedSession[]) {
34+
(store as any).set("sessions", sessions);
35+
}
36+
37+
function getNextSessionNumber(): number {
38+
const sessions = getPersistedSessions();
39+
if (sessions.length === 0) return 1;
40+
return Math.max(...sessions.map(s => s.number)) + 1;
41+
}
42+
43+
// Git worktree helper functions
44+
async function createWorktree(projectDir: string, parentBranch: string, sessionNumber: number): Promise<string> {
45+
const git = simpleGit(projectDir);
46+
const fleetcodeDir = path.join(projectDir, ".fleetcode");
47+
const worktreeName = `session${sessionNumber}`;
48+
const worktreePath = path.join(fleetcodeDir, worktreeName);
49+
const branchName = `fleetcode/session${sessionNumber}`;
50+
51+
// Create .fleetcode directory if it doesn't exist
52+
if (!fs.existsSync(fleetcodeDir)) {
53+
fs.mkdirSync(fleetcodeDir, { recursive: true });
54+
}
55+
56+
// Check if worktree already exists and remove it
57+
if (fs.existsSync(worktreePath)) {
58+
try {
59+
await git.raw(["worktree", "remove", worktreePath, "--force"]);
60+
} catch (error) {
61+
console.error("Error removing existing worktree:", error);
62+
}
63+
}
64+
65+
// Delete the branch if it exists
66+
try {
67+
await git.raw(["branch", "-D", branchName]);
68+
} catch (error) {
69+
// Branch doesn't exist, that's fine
70+
}
71+
72+
// Create new worktree with a new branch from parent branch
73+
// This creates a new branch named "fleetcode/session<N>" starting from the parent branch
74+
await git.raw(["worktree", "add", "-b", branchName, worktreePath, parentBranch]);
75+
76+
return worktreePath;
77+
}
78+
79+
async function removeWorktree(projectDir: string, worktreePath: string) {
80+
const git = simpleGit(projectDir);
81+
try {
82+
await git.raw(["worktree", "remove", worktreePath, "--force"]);
83+
} catch (error) {
84+
console.error("Error removing worktree:", error);
85+
}
86+
}
87+
1788
// Open directory picker
1889
ipcMain.handle("select-directory", async () => {
1990
const result = await dialog.showOpenDialog(mainWindow, {
@@ -54,90 +125,207 @@ ipcMain.on("save-settings", (_event, config: SessionConfig) => {
54125
});
55126

56127
// Create new session
57-
ipcMain.on("create-session", (event, config: SessionConfig) => {
58-
const sessionId = `session-${Date.now()}`;
59-
const shell = os.platform() === "darwin" ? "zsh" : "bash";
128+
ipcMain.on("create-session", async (event, config: SessionConfig) => {
129+
try {
130+
const sessionNumber = getNextSessionNumber();
131+
const sessionId = `session-${Date.now()}`;
132+
const sessionName = `Session ${sessionNumber}`;
133+
134+
// Create git worktree
135+
const worktreePath = await createWorktree(config.projectDir, config.parentBranch, sessionNumber);
136+
137+
// Create persisted session metadata
138+
const persistedSession: PersistedSession = {
139+
id: sessionId,
140+
number: sessionNumber,
141+
name: sessionName,
142+
config,
143+
worktreePath,
144+
createdAt: Date.now(),
145+
};
146+
147+
// Save to store
148+
const sessions = getPersistedSessions();
149+
sessions.push(persistedSession);
150+
savePersistedSessions(sessions);
151+
152+
// Spawn PTY in worktree directory
153+
const shell = os.platform() === "darwin" ? "zsh" : "bash";
154+
const ptyProcess = pty.spawn(shell, [], {
155+
name: "xterm-color",
156+
cols: 80,
157+
rows: 30,
158+
cwd: worktreePath,
159+
env: process.env,
160+
});
161+
162+
activePtyProcesses.set(sessionId, ptyProcess);
163+
164+
let terminalReady = false;
165+
let dataBuffer = "";
166+
167+
ptyProcess.onData((data) => {
168+
mainWindow.webContents.send("session-output", sessionId, data);
169+
170+
// Detect when terminal is ready
171+
if (!terminalReady) {
172+
dataBuffer += data;
173+
174+
// Method 1: Look for bracketed paste mode enable sequence
175+
if (dataBuffer.includes("\x1b[?2004h")) {
176+
terminalReady = true;
177+
178+
// Auto-run the selected coding agent
179+
if (config.codingAgent === "claude") {
180+
ptyProcess.write("claude\r");
181+
} else if (config.codingAgent === "codex") {
182+
ptyProcess.write("codex\r");
183+
}
184+
}
185+
186+
// Method 2: Fallback - look for common prompt indicators
187+
else if (dataBuffer.includes("$ ") || dataBuffer.includes("% ") ||
188+
dataBuffer.includes("> ") || dataBuffer.includes("➜ ") ||
189+
dataBuffer.includes("➜ ") ||
190+
dataBuffer.includes("✗ ") || dataBuffer.includes("✓ ") ||
191+
dataBuffer.endsWith("$") || dataBuffer.endsWith("%") ||
192+
dataBuffer.endsWith(">") || dataBuffer.endsWith("➜") ||
193+
dataBuffer.endsWith("✗") || dataBuffer.endsWith("✓")) {
194+
terminalReady = true;
195+
196+
// Auto-run the selected coding agent
197+
if (config.codingAgent === "claude") {
198+
ptyProcess.write("claude\r");
199+
} else if (config.codingAgent === "codex") {
200+
ptyProcess.write("codex\r");
201+
}
202+
}
203+
}
204+
});
60205

206+
event.reply("session-created", sessionId, persistedSession);
207+
} catch (error) {
208+
console.error("Error creating session:", error);
209+
const errorMessage = error instanceof Error ? error.message : String(error);
210+
event.reply("session-error", errorMessage);
211+
}
212+
});
213+
214+
// Handle session input
215+
ipcMain.on("session-input", (_event, sessionId: string, data: string) => {
216+
const ptyProcess = activePtyProcesses.get(sessionId);
217+
if (ptyProcess) {
218+
ptyProcess.write(data);
219+
}
220+
});
221+
222+
// Handle session resize
223+
ipcMain.on("session-resize", (_event, sessionId: string, cols: number, rows: number) => {
224+
const ptyProcess = activePtyProcesses.get(sessionId);
225+
if (ptyProcess) {
226+
ptyProcess.resize(cols, rows);
227+
}
228+
});
229+
230+
// Reopen session (spawn new PTY for existing session)
231+
ipcMain.on("reopen-session", (event, sessionId: string) => {
232+
// Check if PTY already active
233+
if (activePtyProcesses.has(sessionId)) {
234+
event.reply("session-reopened", sessionId);
235+
return;
236+
}
237+
238+
// Find persisted session
239+
const sessions = getPersistedSessions();
240+
const session = sessions.find(s => s.id === sessionId);
241+
242+
if (!session) {
243+
console.error("Session not found:", sessionId);
244+
return;
245+
}
246+
247+
// Spawn new PTY in worktree directory
248+
const shell = os.platform() === "darwin" ? "zsh" : "bash";
61249
const ptyProcess = pty.spawn(shell, [], {
62250
name: "xterm-color",
63251
cols: 80,
64252
rows: 30,
65-
cwd: config.projectDir || process.env.HOME,
253+
cwd: session.worktreePath,
66254
env: process.env,
67255
});
68256

69-
sessions.set(sessionId, ptyProcess);
257+
activePtyProcesses.set(sessionId, ptyProcess);
70258

71259
let terminalReady = false;
72260
let dataBuffer = "";
73261

74262
ptyProcess.onData((data) => {
75263
mainWindow.webContents.send("session-output", sessionId, data);
76264

77-
// Detect when terminal is ready
265+
// Detect when terminal is ready and auto-run agent
78266
if (!terminalReady) {
79267
dataBuffer += data;
80268

81-
// Method 1: Look for bracketed paste mode enable sequence
82-
// This is sent by modern shells (zsh, bash) when ready for input
83-
if (dataBuffer.includes("\x1b[?2004h")) {
269+
if (dataBuffer.includes("\x1b[?2004h") ||
270+
dataBuffer.includes("$ ") || dataBuffer.includes("% ") ||
271+
dataBuffer.includes("➜ ") || dataBuffer.includes("✗ ") ||
272+
dataBuffer.includes("✓ ")) {
84273
terminalReady = true;
85274

86-
// Auto-run the selected coding agent
87-
if (config.codingAgent === "claude") {
275+
if (session.config.codingAgent === "claude") {
88276
ptyProcess.write("claude\r");
89-
} else if (config.codingAgent === "codex") {
90-
ptyProcess.write("codex\r");
91-
}
92-
}
93-
94-
// Method 2: Fallback - look for common prompt indicators
95-
// In case bracketed paste mode is disabled
96-
else if (dataBuffer.includes("$ ") || dataBuffer.includes("% ") ||
97-
dataBuffer.includes("> ") || dataBuffer.includes("➜ ") ||
98-
dataBuffer.includes("➜ ") ||
99-
dataBuffer.includes("✗ ") || dataBuffer.includes("✓ ") ||
100-
dataBuffer.endsWith("$") || dataBuffer.endsWith("%") ||
101-
dataBuffer.endsWith(">") || dataBuffer.endsWith("➜") ||
102-
dataBuffer.endsWith("✗") || dataBuffer.endsWith("✓")) {
103-
terminalReady = true;
104-
105-
// Auto-run the selected coding agent
106-
if (config.codingAgent === "claude") {
107-
ptyProcess.write("claude\r");
108-
} else if (config.codingAgent === "codex") {
277+
} else if (session.config.codingAgent === "codex") {
109278
ptyProcess.write("codex\r");
110279
}
111280
}
112281
}
113282
});
114283

115-
event.reply("session-created", sessionId, config);
284+
event.reply("session-reopened", sessionId);
116285
});
117286

118-
// Handle session input
119-
ipcMain.on("session-input", (_event, sessionId: string, data: string) => {
120-
const session = sessions.get(sessionId);
121-
if (session) {
122-
session.write(data);
287+
// Close session (kill PTY but keep session)
288+
ipcMain.on("close-session", (_event, sessionId: string) => {
289+
const ptyProcess = activePtyProcesses.get(sessionId);
290+
if (ptyProcess) {
291+
ptyProcess.kill();
292+
activePtyProcesses.delete(sessionId);
123293
}
124294
});
125295

126-
// Handle session resize
127-
ipcMain.on("session-resize", (_event, sessionId: string, cols: number, rows: number) => {
128-
const session = sessions.get(sessionId);
129-
if (session) {
130-
session.resize(cols, rows);
296+
// Delete session (kill PTY, remove worktree, delete from store)
297+
ipcMain.on("delete-session", async (_event, sessionId: string) => {
298+
// Kill PTY if active
299+
const ptyProcess = activePtyProcesses.get(sessionId);
300+
if (ptyProcess) {
301+
ptyProcess.kill();
302+
activePtyProcesses.delete(sessionId);
131303
}
132-
});
133304

134-
// Handle session close
135-
ipcMain.on("close-session", (_event, sessionId: string) => {
136-
const session = sessions.get(sessionId);
137-
if (session) {
138-
session.kill();
139-
sessions.delete(sessionId);
305+
// Find and remove from persisted sessions
306+
const sessions = getPersistedSessions();
307+
const sessionIndex = sessions.findIndex(s => s.id === sessionId);
308+
309+
if (sessionIndex === -1) {
310+
console.error("Session not found:", sessionId);
311+
return;
140312
}
313+
314+
const session = sessions[sessionIndex];
315+
316+
// Remove git worktree
317+
await removeWorktree(session.config.projectDir, session.worktreePath);
318+
319+
// Remove from store
320+
sessions.splice(sessionIndex, 1);
321+
savePersistedSessions(sessions);
322+
323+
mainWindow.webContents.send("session-deleted", sessionId);
324+
});
325+
326+
// Get all persisted sessions
327+
ipcMain.handle("get-all-sessions", () => {
328+
return getPersistedSessions();
141329
});
142330

143331
const createWindow = () => {
@@ -151,6 +339,12 @@ const createWindow = () => {
151339
});
152340

153341
mainWindow.loadFile("index.html");
342+
343+
// Load persisted sessions once window is ready
344+
mainWindow.webContents.on("did-finish-load", () => {
345+
const sessions = getPersistedSessions();
346+
mainWindow.webContents.send("load-persisted-sessions", sessions);
347+
});
154348
};
155349

156350
app.whenReady().then(() => {

0 commit comments

Comments
 (0)