**Layout refactor (Phase 1):** - UnifiedTopBar replacing Header.tsx — chat title editing, project folder, commit/push split button, Git branch+dirty badge, terminal/file-tree toggles - PanelZone renders FileTree, GitPanel, PreviewPanel side-by-side on the right - Independent panel toggle states in PanelContext - ResizeHandle unified across all panels - Removed old RightPanel, Header, DocPreview components **Git backend (Phase 2):** - src/lib/git/service.ts — execFile-based Git operations with timeout + injection safety - API routes: status, branches, checkout, log, commit, push, commit-detail, worktrees, derive - Worktree derive creates DB session inheriting source session settings **Git frontend (Phase 3):** - GitPanel with 4 collapsible sections: Status, Branches, History, Worktrees - CommitDialog with commit vs commit-and-push modes - GitBranchSelector with inline error, worktree-occupied branches disabled - GitWorktreeSection with switch-to/derive actions, current/dirty indicators - useGitStatus polled in AppShell, derived into context (no redundant setState) **Terminal (Phase 4):** - electron/terminal-manager.ts — child_process.spawn fallback (PTY upgrade documented) - IPC handlers + TerminalDrawer/TerminalInstance components **Polish (Phase 5):** - Push inline feedback with 4s auto-dismiss - Branch detection fix, commit diff exit-code handling - Removed redundant git state: currentBranch/gitDirtyCount now derived, not setState - useTerminal isElectron uses lazy initializer instead of effect - i18n keys (en + zh), user tutorial docs, handover document Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
172 lines
4.7 KiB
TypeScript
172 lines
4.7 KiB
TypeScript
import { spawn, type ChildProcessWithoutNullStreams } from 'child_process';
|
|
|
|
export interface TerminalCreateOptions {
|
|
cwd: string;
|
|
cols: number;
|
|
rows: number;
|
|
env?: Record<string, string>;
|
|
}
|
|
|
|
interface TerminalInstance {
|
|
process: ChildProcessWithoutNullStreams;
|
|
cwd: string;
|
|
}
|
|
|
|
/**
|
|
* TerminalManager — manages terminal processes.
|
|
*
|
|
* KNOWN LIMITATION: Uses child_process.spawn with stdio: 'pipe' instead of
|
|
* a real PTY (node-pty). This means:
|
|
* - resize() is a no-op (COLUMNS/LINES env vars are set at creation only)
|
|
* - Full-screen programs (vim, htop) won't render correctly
|
|
* - readline line-editing may behave differently
|
|
*
|
|
* TODO: Upgrade to node-pty for full PTY support. Requires:
|
|
* 1. npm install node-pty
|
|
* 2. scripts/after-pack.js: add node-pty to rebuild list
|
|
* 3. electron-builder.yml: add "**/node-pty/**" to asarUnpack
|
|
* 4. scripts/build-electron.mjs: add node-pty to esbuild externals
|
|
*/
|
|
export class TerminalManager {
|
|
private terminals = new Map<string, TerminalInstance>();
|
|
private onData: ((id: string, data: string) => void) | null = null;
|
|
private onExit: ((id: string, code: number) => void) | null = null;
|
|
|
|
setOnData(handler: (id: string, data: string) => void) {
|
|
this.onData = handler;
|
|
}
|
|
|
|
setOnExit(handler: (id: string, code: number) => void) {
|
|
this.onExit = handler;
|
|
}
|
|
|
|
create(id: string, opts: TerminalCreateOptions): void {
|
|
if (this.terminals.has(id)) {
|
|
this.kill(id);
|
|
}
|
|
|
|
const shell = this.getShell();
|
|
const shellArgs = this.getShellArgs();
|
|
|
|
const env: Record<string, string> = {
|
|
...process.env as Record<string, string>,
|
|
...opts.env,
|
|
TERM: 'xterm-256color',
|
|
COLORTERM: 'truecolor',
|
|
COLUMNS: String(opts.cols),
|
|
LINES: String(opts.rows),
|
|
};
|
|
|
|
const child = spawn(shell, shellArgs, {
|
|
cwd: opts.cwd,
|
|
env,
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
});
|
|
|
|
child.stdout.on('data', (data: Buffer) => {
|
|
this.onData?.(id, data.toString());
|
|
});
|
|
|
|
child.stderr.on('data', (data: Buffer) => {
|
|
this.onData?.(id, data.toString());
|
|
});
|
|
|
|
child.on('exit', (code) => {
|
|
this.terminals.delete(id);
|
|
this.onExit?.(id, code ?? 0);
|
|
});
|
|
|
|
child.on('error', (err) => {
|
|
console.error(`[terminal:${id}] error:`, err);
|
|
this.terminals.delete(id);
|
|
this.onExit?.(id, 1);
|
|
});
|
|
|
|
this.terminals.set(id, { process: child, cwd: opts.cwd });
|
|
}
|
|
|
|
write(id: string, data: string): void {
|
|
const terminal = this.terminals.get(id);
|
|
if (!terminal) return;
|
|
terminal.process.stdin.write(data);
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
resize(id: string, _cols: number, _rows: number): void {
|
|
// With spawn (non-PTY), resize is a no-op.
|
|
// Would use pty.resize() with node-pty.
|
|
}
|
|
|
|
kill(id: string): void {
|
|
const terminal = this.terminals.get(id);
|
|
if (!terminal) return;
|
|
try {
|
|
terminal.process.kill();
|
|
} catch {
|
|
// already dead
|
|
}
|
|
this.terminals.delete(id);
|
|
}
|
|
|
|
killAll(): void {
|
|
for (const [id] of this.terminals) {
|
|
this.kill(id);
|
|
}
|
|
}
|
|
|
|
private getShell(): string {
|
|
if (process.platform === 'win32') {
|
|
return this.findGitBash() || process.env.COMSPEC || 'cmd.exe';
|
|
}
|
|
return process.env.SHELL || '/bin/zsh';
|
|
}
|
|
|
|
private getShellArgs(): string[] {
|
|
if (process.platform === 'win32') {
|
|
return [];
|
|
}
|
|
// Interactive login shell
|
|
return ['-il'];
|
|
}
|
|
|
|
private findGitBash(): string | null {
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
const fs = require('fs');
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
const path = require('path');
|
|
|
|
// Check env var
|
|
const envBash = process.env.CLAUDE_CODE_GIT_BASH_PATH;
|
|
if (envBash && fs.existsSync(envBash)) return envBash;
|
|
|
|
// Common paths
|
|
const commonPaths = [
|
|
'C:\\Program Files\\Git\\bin\\bash.exe',
|
|
'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
|
|
];
|
|
for (const p of commonPaths) {
|
|
if (fs.existsSync(p)) return p;
|
|
}
|
|
|
|
// Try to find via 'where git'
|
|
try {
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
const { execFileSync } = require('child_process');
|
|
const whereResult = execFileSync('where', ['git'], {
|
|
timeout: 3000, encoding: 'utf-8', shell: true, stdio: 'pipe',
|
|
});
|
|
for (const line of whereResult.trim().split(/\r?\n/)) {
|
|
const gitExe = line.trim();
|
|
if (!gitExe) continue;
|
|
const gitDir = path.dirname(path.dirname(gitExe));
|
|
const bashPath = path.join(gitDir, 'bin', 'bash.exe');
|
|
if (fs.existsSync(bashPath)) return bashPath;
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|