worktrees
This commit is contained in:
parent
fe287a2f53
commit
1d873aa82a
302
main.ts
302
main.ts
|
|
@ -1,6 +1,8 @@
|
||||||
import { app, BrowserWindow, ipcMain, dialog } from "electron";
|
import { app, BrowserWindow, ipcMain, dialog } from "electron";
|
||||||
import * as pty from "node-pty";
|
import * as pty from "node-pty";
|
||||||
import * as os from "os";
|
import * as os from "os";
|
||||||
|
import * as path from "path";
|
||||||
|
import * as fs from "fs";
|
||||||
import { simpleGit } from "simple-git";
|
import { simpleGit } from "simple-git";
|
||||||
import Store from "electron-store";
|
import Store from "electron-store";
|
||||||
|
|
||||||
|
|
@ -10,10 +12,79 @@ interface SessionConfig {
|
||||||
codingAgent: string;
|
codingAgent: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PersistedSession {
|
||||||
|
id: string;
|
||||||
|
number: number;
|
||||||
|
name: string;
|
||||||
|
config: SessionConfig;
|
||||||
|
worktreePath: string;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
let mainWindow: BrowserWindow;
|
let mainWindow: BrowserWindow;
|
||||||
const sessions = new Map<string, pty.IPty>();
|
const activePtyProcesses = new Map<string, pty.IPty>();
|
||||||
const store = new Store();
|
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<string> {
|
||||||
|
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<N>" 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
|
// Open directory picker
|
||||||
ipcMain.handle("select-directory", async () => {
|
ipcMain.handle("select-directory", async () => {
|
||||||
const result = await dialog.showOpenDialog(mainWindow, {
|
const result = await dialog.showOpenDialog(mainWindow, {
|
||||||
|
|
@ -54,19 +125,136 @@ ipcMain.on("save-settings", (_event, config: SessionConfig) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create new session
|
// Create new session
|
||||||
ipcMain.on("create-session", (event, config: SessionConfig) => {
|
ipcMain.on("create-session", async (event, config: SessionConfig) => {
|
||||||
const sessionId = `session-${Date.now()}`;
|
try {
|
||||||
const shell = os.platform() === "darwin" ? "zsh" : "bash";
|
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, [], {
|
const ptyProcess = pty.spawn(shell, [], {
|
||||||
name: "xterm-color",
|
name: "xterm-color",
|
||||||
cols: 80,
|
cols: 80,
|
||||||
rows: 30,
|
rows: 30,
|
||||||
cwd: config.projectDir || process.env.HOME,
|
cwd: session.worktreePath,
|
||||||
env: process.env,
|
env: process.env,
|
||||||
});
|
});
|
||||||
|
|
||||||
sessions.set(sessionId, ptyProcess);
|
activePtyProcesses.set(sessionId, ptyProcess);
|
||||||
|
|
||||||
let terminalReady = false;
|
let terminalReady = false;
|
||||||
let dataBuffer = "";
|
let dataBuffer = "";
|
||||||
|
|
@ -74,72 +262,72 @@ ipcMain.on("create-session", (event, config: SessionConfig) => {
|
||||||
ptyProcess.onData((data) => {
|
ptyProcess.onData((data) => {
|
||||||
mainWindow.webContents.send("session-output", sessionId, data);
|
mainWindow.webContents.send("session-output", sessionId, data);
|
||||||
|
|
||||||
// Detect when terminal is ready
|
// Detect when terminal is ready and auto-run agent
|
||||||
if (!terminalReady) {
|
if (!terminalReady) {
|
||||||
dataBuffer += data;
|
dataBuffer += data;
|
||||||
|
|
||||||
// Method 1: Look for bracketed paste mode enable sequence
|
if (dataBuffer.includes("\x1b[?2004h") ||
|
||||||
// This is sent by modern shells (zsh, bash) when ready for input
|
dataBuffer.includes("$ ") || dataBuffer.includes("% ") ||
|
||||||
if (dataBuffer.includes("\x1b[?2004h")) {
|
dataBuffer.includes("➜ ") || dataBuffer.includes("✗ ") ||
|
||||||
|
dataBuffer.includes("✓ ")) {
|
||||||
terminalReady = true;
|
terminalReady = true;
|
||||||
|
|
||||||
// Auto-run the selected coding agent
|
if (session.config.codingAgent === "claude") {
|
||||||
if (config.codingAgent === "claude") {
|
|
||||||
ptyProcess.write("claude\r");
|
ptyProcess.write("claude\r");
|
||||||
} else if (config.codingAgent === "codex") {
|
} else if (session.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") {
|
|
||||||
ptyProcess.write("codex\r");
|
ptyProcess.write("codex\r");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
event.reply("session-created", sessionId, config);
|
event.reply("session-reopened", sessionId);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle session input
|
// Close session (kill PTY but keep session)
|
||||||
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
|
|
||||||
ipcMain.on("close-session", (_event, sessionId: string) => {
|
ipcMain.on("close-session", (_event, sessionId: string) => {
|
||||||
const session = sessions.get(sessionId);
|
const ptyProcess = activePtyProcesses.get(sessionId);
|
||||||
if (session) {
|
if (ptyProcess) {
|
||||||
session.kill();
|
ptyProcess.kill();
|
||||||
sessions.delete(sessionId);
|
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 = () => {
|
const createWindow = () => {
|
||||||
mainWindow = new BrowserWindow({
|
mainWindow = new BrowserWindow({
|
||||||
width: 1400,
|
width: 1400,
|
||||||
|
|
@ -151,6 +339,12 @@ const createWindow = () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
mainWindow.loadFile("index.html");
|
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(() => {
|
app.whenReady().then(() => {
|
||||||
|
|
|
||||||
250
renderer.ts
250
renderer.ts
|
|
@ -8,19 +8,30 @@ interface SessionConfig {
|
||||||
codingAgent: string;
|
codingAgent: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Session {
|
interface PersistedSession {
|
||||||
id: string;
|
id: string;
|
||||||
terminal: Terminal;
|
number: number;
|
||||||
fitAddon: FitAddon;
|
|
||||||
element: HTMLDivElement;
|
|
||||||
name: string;
|
name: string;
|
||||||
config: SessionConfig;
|
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<string, Session>();
|
const sessions = new Map<string, Session>();
|
||||||
let activeSessionId: string | null = null;
|
let activeSessionId: string | null = null;
|
||||||
|
|
||||||
function createSession(sessionId: string, name: string, config: SessionConfig) {
|
function createTerminalUI(sessionId: string) {
|
||||||
const term = new Terminal({
|
const term = new Terminal({
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
|
|
@ -50,26 +61,6 @@ function createSession(sessionId: string, name: string, config: SessionConfig) {
|
||||||
ipcRenderer.send("session-input", sessionId, data);
|
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
|
// Handle resize
|
||||||
const resizeHandler = () => {
|
const resizeHandler = () => {
|
||||||
if (activeSessionId === sessionId) {
|
if (activeSessionId === sessionId) {
|
||||||
|
|
@ -79,10 +70,72 @@ function createSession(sessionId: string, name: string, config: SessionConfig) {
|
||||||
};
|
};
|
||||||
window.addEventListener("resize", resizeHandler);
|
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;
|
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");
|
const list = document.getElementById("session-list");
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
|
|
||||||
|
|
@ -90,25 +143,42 @@ function addToSidebar(sessionId: string, name: string) {
|
||||||
item.id = `sidebar-${sessionId}`;
|
item.id = `sidebar-${sessionId}`;
|
||||||
item.className = "session-list-item";
|
item.className = "session-list-item";
|
||||||
item.innerHTML = `
|
item.innerHTML = `
|
||||||
<span class="truncate">${name}</span>
|
<div class="flex items-center space-x-2 flex-1">
|
||||||
<button class="session-close-btn" data-id="${sessionId}">×</button>
|
<span class="session-indicator ${hasActivePty ? 'active' : ''}"></span>
|
||||||
|
<span class="truncate">${name}</span>
|
||||||
|
</div>
|
||||||
|
<button class="session-delete-btn" data-id="${sessionId}" title="Delete session">×</button>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
item.addEventListener("click", (e) => {
|
item.addEventListener("click", (e) => {
|
||||||
if (!(e.target as HTMLElement).classList.contains("session-close-btn")) {
|
const target = e.target as HTMLElement;
|
||||||
switchToSession(sessionId);
|
if (!target.classList.contains("session-delete-btn")) {
|
||||||
|
handleSessionClick(sessionId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const closeBtn = item.querySelector(".session-close-btn");
|
const deleteBtn = item.querySelector(".session-delete-btn");
|
||||||
closeBtn?.addEventListener("click", (e) => {
|
deleteBtn?.addEventListener("click", (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
closeSession(sessionId);
|
deleteSession(sessionId);
|
||||||
});
|
});
|
||||||
|
|
||||||
list.appendChild(item);
|
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) {
|
function addTab(sessionId: string, name: string) {
|
||||||
const tabsContainer = document.getElementById("tabs");
|
const tabsContainer = document.getElementById("tabs");
|
||||||
if (!tabsContainer) return;
|
if (!tabsContainer) return;
|
||||||
|
|
@ -139,14 +209,16 @@ function addTab(sessionId: string, name: string) {
|
||||||
function switchToSession(sessionId: string) {
|
function switchToSession(sessionId: string) {
|
||||||
// Hide all sessions
|
// Hide all sessions
|
||||||
sessions.forEach((session, id) => {
|
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(`tab-${id}`)?.classList.remove("active");
|
||||||
document.getElementById(`sidebar-${id}`)?.classList.remove("active");
|
document.getElementById(`sidebar-${id}`)?.classList.remove("active");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Show active session
|
// Show active session
|
||||||
const session = sessions.get(sessionId);
|
const session = sessions.get(sessionId);
|
||||||
if (session) {
|
if (session && session.element && session.terminal && session.fitAddon) {
|
||||||
session.element.classList.add("active");
|
session.element.classList.add("active");
|
||||||
document.getElementById(`tab-${sessionId}`)?.classList.add("active");
|
document.getElementById(`tab-${sessionId}`)?.classList.add("active");
|
||||||
document.getElementById(`sidebar-${sessionId}`)?.classList.add("active");
|
document.getElementById(`sidebar-${sessionId}`)?.classList.add("active");
|
||||||
|
|
@ -155,8 +227,10 @@ function switchToSession(sessionId: string) {
|
||||||
// Focus and resize
|
// Focus and resize
|
||||||
session.terminal.focus();
|
session.terminal.focus();
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
session.fitAddon.fit();
|
if (session.fitAddon && session.terminal) {
|
||||||
ipcRenderer.send("session-resize", sessionId, session.terminal.cols, session.terminal.rows);
|
session.fitAddon.fit();
|
||||||
|
ipcRenderer.send("session-resize", sessionId, session.terminal.cols, session.terminal.rows);
|
||||||
|
}
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -165,23 +239,70 @@ function closeSession(sessionId: string) {
|
||||||
const session = sessions.get(sessionId);
|
const session = sessions.get(sessionId);
|
||||||
if (!session) return;
|
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
|
// 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(`tab-${sessionId}`)?.remove();
|
||||||
document.getElementById(`sidebar-${sessionId}`)?.remove();
|
document.getElementById(`sidebar-${sessionId}`)?.remove();
|
||||||
|
|
||||||
// Dispose terminal
|
// Remove from sessions map
|
||||||
session.terminal.dispose();
|
|
||||||
sessions.delete(sessionId);
|
sessions.delete(sessionId);
|
||||||
|
|
||||||
// Close in main process
|
// Delete in main process (handles worktree removal)
|
||||||
ipcRenderer.send("close-session", sessionId);
|
ipcRenderer.send("delete-session", sessionId);
|
||||||
|
|
||||||
// Switch to another session
|
// Switch to another session
|
||||||
if (activeSessionId === sessionId) {
|
if (activeSessionId === sessionId) {
|
||||||
const remainingSessions = Array.from(sessions.keys());
|
const remainingSessions = Array.from(sessions.values()).filter(s => s.hasActivePty);
|
||||||
if (remainingSessions.length > 0) {
|
if (remainingSessions.length > 0) {
|
||||||
switchToSession(remainingSessions[0]);
|
switchToSession(remainingSessions[0].id);
|
||||||
} else {
|
} else {
|
||||||
activeSessionId = null;
|
activeSessionId = null;
|
||||||
}
|
}
|
||||||
|
|
@ -191,15 +312,48 @@ function closeSession(sessionId: string) {
|
||||||
// Handle session output
|
// Handle session output
|
||||||
ipcRenderer.on("session-output", (_event, sessionId: string, data: string) => {
|
ipcRenderer.on("session-output", (_event, sessionId: string, data: string) => {
|
||||||
const session = sessions.get(sessionId);
|
const session = sessions.get(sessionId);
|
||||||
if (session) {
|
if (session && session.terminal) {
|
||||||
session.terminal.write(data);
|
session.terminal.write(data);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle session created
|
// Handle session created
|
||||||
ipcRenderer.on("session-created", (_event, sessionId: string, config: SessionConfig) => {
|
ipcRenderer.on("session-created", (_event, sessionId: string, persistedSession: any) => {
|
||||||
const name = `Session ${sessions.size + 1}`;
|
const session = addSession(persistedSession, true);
|
||||||
createSession(sessionId, name, config);
|
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
|
// Modal handling
|
||||||
|
|
|
||||||
15
styles.css
15
styles.css
|
|
@ -41,13 +41,22 @@
|
||||||
@apply bg-gray-700;
|
@apply bg-gray-700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.session-close-btn {
|
.session-indicator {
|
||||||
@apply text-gray-500 hover:text-red-500 ml-2;
|
@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;
|
opacity: 0;
|
||||||
transition: opacity 0.2s;
|
transition: opacity 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.session-list-item:hover .session-close-btn {
|
.session-list-item:hover .session-delete-btn {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue