diff --git a/docs/file-viewer.md b/docs/file-viewer.md new file mode 100644 index 00000000..d77e9f50 --- /dev/null +++ b/docs/file-viewer.md @@ -0,0 +1,112 @@ +# File Viewer + +The File Viewer is a session-scoped repository browser for files, current Git +changes, and commit history. Its browser UI lives in +`src/web/public/panels-ui.js`; file and repository HTTP routes live in +`src/web/routes/file-routes.ts`; Git command/parsing logic lives in +`src/git-repository-browser.ts`. + +## Views + +- **Files** lists the selected scope's directory tree and opens the existing + file preview for text, images, audio/video, PDF, SVG, and Office documents. +- **Changes** lists the selected worktree's current staged, unstaged, and + untracked paths. It refreshes every five seconds only while the panel and + Changes tab are visible. +- **History** lists the latest 30 commits. Expanding a commit lazily loads its + changed paths; selecting a path lazily loads that file's patch and snapshots. +- **Compact diff** renders unified-diff hunks. **Full diff** renders the whole + after-file, highlights additions, and inserts deleted hunk rows at their + corresponding anchors. Deleted files use the before-file. + +## Session And Worktree Scope + +The selected session remains the ownership boundary. For a Git-backed session: + +1. `git rev-parse --show-toplevel` maps a nested `session.workingDir` to the + current worktree root. +2. `git worktree list --porcelain -z` discovers the main repository worktree + and its linked worktrees. +3. The File Viewer defaults to the selected session's current worktree root. + The selector exposes the main/root worktree and every linked worktree. +4. The browser sends only a short scope ID. On every scoped request, the server + rediscovers the repository and accepts the ID only when it exactly matches a + current worktree entry. + +For a non-Git session, `scope=current` falls back to `session.workingDir` and +Changes/History report that Git is unavailable. + +On startup, tmux recovery reads `#{pane_current_path}` with the pane PID. Newly +discovered local sessions use that path instead of the Codeman server's process +directory. Local metadata written by the older process-directory fallback is +self-healed when the saved path still equals `process.cwd()`. Tracked remote and +Docker sessions retain their persisted execution metadata. + +The pencil action in the File Viewer header lets the user synchronize an +already-running local session with a different host work path. Applying it +updates `Session.workingDir`, mux recovery metadata, Bash path resolution, +Ralph's plan watcher, image watching, and persisted/SSE session state. It does +not change the cwd of the running child process. Remote and Docker sessions are +excluded because a host-only metadata edit cannot coherently change their +execution roots. + +Unscoped file URLs retain the original `session.workingDir` confinement. This +is load-bearing for attachments and existing API consumers: repository +discovery must not silently broaden every session file route. + +## HTTP API + +All routes require access to the owning session through `findSessionOrFail`. + +| Route | Purpose | +| ----------------------------------------------------------------------- | ---------------------------------------------- | +| `PUT /api/sessions/:id/working-directory` | Synchronize a local session's host work path | +| `GET /api/sessions/:id/repository?scope=` | Worktrees, current changes, recent commits | +| `GET /api/sessions/:id/repository/commit?scope=&commit=` | One commit and its changed paths | +| `GET /api/sessions/:id/repository/diff?scope=&path=&commit=` | Lazy patch plus bounded before/after snapshots | +| `GET /api/sessions/:id/files?...&scope=` | Directory tree rooted at a validated worktree | +| `GET /api/sessions/:id/file-{content,raw,preview,thumbnail}?...&scope=` | Existing preview routes in that same scope | + +Git is executed with `spawn()` argument arrays, no shell, optional locks +disabled, a ten-second timeout, and bounded stdout/stderr. Patches are capped at +2 MiB and marked truncated. Before/after text snapshots are capped at 1 MiB; +binary files are detected before decoding. File paths are normalized, confined +to the selected worktree, and realpath-checked before working-tree reads. + +Git status uses porcelain v2 with NUL delimiters and explicit rename detection. +Git still models an unstaged delete plus an untracked destination as separate +changes; it can report a rename once the move exists in the index. Do not add +client-side filename/similarity guessing. + +## Client Lifecycle + +`loadFileBrowser()` owns an `AbortController` and monotonically increasing +generation. A response may paint only when its generation, session ID, and +active tab still match. This prevents a slower previous session from replacing +the selected session's tree or repository state. + +Switching sessions synchronously changes File Viewer ownership and resets scope +to `current` before terminal resize/history loading begins. An open viewer +starts loading the new repository immediately; a closed viewer invalidates its +old repository state without issuing a request. Reopening then loads the active +session. The selected Files/Changes/History view is preserved. Manual refresh +preserves filters and expanded folders. Closing the panel aborts its request +and stops repository polling. + +A successful work-path edit and a work-path change received through +`session:updated` use the same forced invalidation. The selected scope resets to +`current`, in-flight requests are aborted, and an open viewer reloads once. + +On phones, the opt-in File Viewer header button remains available and opens a +safe-area-aware bottom sheet. Diff preview uses the full visual viewport. + +## Focused Tests + +- `test/git-repository-browser.test.ts`: real temporary repository and linked + worktree, nested discovery, status/history/diffs, rename parsing, and scope + traversal rejection. +- `test/routes/file-routes-repository.test.ts`: session route ownership and + scoped-vs-legacy file access. +- `test/mobile/file-viewer.test.ts`: stale tab-switch response rejection, + worktree selector defaults, Changes and History interactions, compact/full + diff rendering, and phone viewport bounds. diff --git a/src/git-repository-browser.ts b/src/git-repository-browser.ts new file mode 100644 index 00000000..5acc1811 --- /dev/null +++ b/src/git-repository-browser.ts @@ -0,0 +1,760 @@ +import { spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs/promises'; +import { basename, isAbsolute, relative, resolve } from 'node:path'; + +const GIT_METADATA_LIMIT = 2 * 1024 * 1024; +const GIT_DIFF_LIMIT = 2 * 1024 * 1024; +const FILE_PREVIEW_LIMIT = 1024 * 1024; +const GIT_TIMEOUT_MS = 10_000; +const COMMIT_LIMIT = 30; + +interface GitCommandResult { + stdout: Buffer; + stderr: Buffer; + code: number; + truncated: boolean; +} + +interface RunGitOptions { + allowedExitCodes?: number[]; + maxBytes?: number; + truncate?: boolean; +} + +export interface GitWorktreeScope { + id: string; + path: string; + name: string; + branch: string | null; + head: string; + current: boolean; + main: boolean; + locked: boolean; +} + +export interface GitRepositoryDiscovery { + repositoryRoot: string; + currentScopeId: string; + worktrees: GitWorktreeScope[]; +} + +export interface GitChange { + path: string; + oldPath?: string; + code: string; + status: 'modified' | 'added' | 'deleted' | 'renamed' | 'copied' | 'untracked' | 'conflicted'; + staged: boolean; + unstaged: boolean; + additions: number | null; + deletions: number | null; + binary: boolean; +} + +export interface GitCommitSummary { + hash: string; + shortHash: string; + author: string; + authoredAt: string; + subject: string; +} + +export interface GitRepositoryOverview { + available: boolean; + repositoryRoot: string | null; + selectedScopeId: string | null; + worktrees: GitWorktreeScope[]; + changes: GitChange[]; + commits: GitCommitSummary[]; +} + +export interface GitCommitDetails extends GitCommitSummary { + changes: GitChange[]; +} + +export interface GitDiffDetail { + path: string; + oldPath?: string; + commit: string | null; + label: string; + patch: string; + beforeContent: string | null; + afterContent: string | null; + beforeExists: boolean; + afterExists: boolean; + binary: boolean; + truncated: boolean; + additions: number; + deletions: number; +} + +interface GitScopeResolution { + repository: GitRepositoryDiscovery; + scope: GitWorktreeScope; +} + +interface FileSnapshot { + exists: boolean; + binary: boolean; + truncated: boolean; + content: string | null; +} + +export class GitRepositoryScopeError extends Error { + constructor(message: string) { + super(message); + this.name = 'GitRepositoryScopeError'; + } +} + +async function runGit(cwd: string, args: string[], options: RunGitOptions = {}): Promise { + const allowedExitCodes = options.allowedExitCodes ?? [0]; + const maxBytes = options.maxBytes ?? GIT_METADATA_LIMIT; + const truncate = options.truncate ?? false; + + return new Promise((resolvePromise, reject) => { + const child = spawn('git', ['-c', 'core.quotepath=false', '-c', 'color.ui=false', ...args], { + cwd, + env: { + ...process.env, + GIT_OPTIONAL_LOCKS: '0', + LC_ALL: 'C', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + let stdoutBytes = 0; + let stderrBytes = 0; + let truncated = false; + let limitError: Error | null = null; + let timedOut = false; + let settled = false; + + const append = (chunks: Buffer[], chunk: Buffer, currentBytes: number): number => { + const remaining = Math.max(0, maxBytes - currentBytes); + if (remaining > 0) chunks.push(chunk.subarray(0, remaining)); + if (chunk.length > remaining) { + if (truncate) { + truncated = true; + child.kill(); + } else { + limitError = new Error(`Git output exceeded ${maxBytes} bytes`); + child.kill(); + } + } + return currentBytes + Math.min(chunk.length, remaining); + }; + + child.stdout.on('data', (chunk: Buffer) => { + stdoutBytes = append(stdout, Buffer.from(chunk), stdoutBytes); + }); + child.stderr.on('data', (chunk: Buffer) => { + stderrBytes = append(stderr, Buffer.from(chunk), stderrBytes); + }); + + const timer = setTimeout(() => { + timedOut = true; + child.kill(); + }, GIT_TIMEOUT_MS); + timer.unref?.(); + + child.once('error', (err) => { + if (settled) return; + settled = true; + clearTimeout(timer); + reject(err); + }); + + child.once('close', (code) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (timedOut) { + reject(new Error(`Git command timed out after ${GIT_TIMEOUT_MS}ms`)); + return; + } + if (limitError) { + reject(limitError); + return; + } + + const result: GitCommandResult = { + stdout: Buffer.concat(stdout), + stderr: Buffer.concat(stderr), + code: code ?? (truncated ? 0 : 1), + truncated, + }; + if (!truncated && !allowedExitCodes.includes(result.code)) { + const detail = result.stderr.toString('utf8').trim(); + reject(new Error(detail || `Git exited with code ${result.code}`)); + return; + } + resolvePromise(result); + }); + }); +} + +function scopeId(path: string): string { + return createHash('sha256').update(path).digest('hex').slice(0, 16); +} + +function parseWorktrees(output: string, currentRoot: string): GitWorktreeScope[] { + return output + .split('\0\0') + .filter(Boolean) + .map((record, index) => { + const fields = record.split('\0'); + const values = new Map(); + let locked = false; + for (const field of fields) { + const separator = field.indexOf(' '); + const key = separator === -1 ? field : field.slice(0, separator); + const value = separator === -1 ? '' : field.slice(separator + 1); + values.set(key, value); + if (key === 'locked') locked = true; + } + const path = values.get('worktree') || ''; + const branchRef = values.get('branch'); + return { + id: scopeId(path), + path, + name: basename(path) || path, + branch: branchRef?.replace(/^refs\/heads\//, '') || null, + head: values.get('HEAD') || '', + current: resolve(path) === resolve(currentRoot), + main: index === 0, + locked, + }; + }) + .filter((scope) => Boolean(scope.path)); +} + +export async function discoverGitRepository(workingDir: string): Promise { + try { + const rootResult = await runGit(workingDir, ['rev-parse', '--show-toplevel'], { + allowedExitCodes: [0, 128], + }); + if (rootResult.code !== 0) return null; + const currentRoot = rootResult.stdout.toString('utf8').trim(); + if (!currentRoot) return null; + + const worktreeResult = await runGit(currentRoot, ['worktree', 'list', '--porcelain', '-z']); + const worktrees = parseWorktrees(worktreeResult.stdout.toString('utf8'), currentRoot); + if (worktrees.length === 0) return null; + const currentScope = worktrees.find((scope) => scope.current) ?? worktrees[0]; + return { + repositoryRoot: worktrees[0].path, + currentScopeId: currentScope.id, + worktrees, + }; + } catch { + return null; + } +} + +async function resolveScope(workingDir: string, requestedScope?: string): Promise { + const repository = await discoverGitRepository(workingDir); + if (!repository) return null; + const scope = + !requestedScope || requestedScope === 'current' + ? repository.worktrees.find((candidate) => candidate.id === repository.currentScopeId) + : repository.worktrees.find((candidate) => candidate.id === requestedScope); + if (!scope) { + throw new GitRepositoryScopeError('Repository worktree scope not found'); + } + return { repository, scope }; +} + +export async function resolveRepositoryBrowseRoot(workingDir: string, requestedScope?: string): Promise { + const resolution = await resolveScope(workingDir, requestedScope); + return resolution?.scope.path ?? null; +} + +function parseStatusCode(code: string): GitChange['status'] { + if (code === '??') return 'untracked'; + if (code.includes('U')) return 'conflicted'; + if (code.includes('R')) return 'renamed'; + if (code.includes('C')) return 'copied'; + if (code.includes('D')) return 'deleted'; + if (code.includes('A')) return 'added'; + return 'modified'; +} + +function shortStatusCode(status: GitChange['status']): string { + switch (status) { + case 'untracked': + return '?'; + case 'conflicted': + return 'U'; + case 'renamed': + return 'R'; + case 'copied': + return 'C'; + case 'deleted': + return 'D'; + case 'added': + return 'A'; + default: + return 'M'; + } +} + +function parseStatus(output: string): GitChange[] { + const records = output.split('\0'); + const changes: GitChange[] = []; + for (let i = 0; i < records.length; i++) { + const record = records[i]; + if (!record) continue; + + let xy = '..'; + let path = ''; + let oldPath: string | undefined; + if (record.startsWith('1 ')) { + const fields = record.split(' '); + xy = fields[1] || '..'; + path = fields.slice(8).join(' '); + } else if (record.startsWith('2 ')) { + const fields = record.split(' '); + xy = fields[1] || '..'; + path = fields.slice(9).join(' '); + oldPath = records[++i] || undefined; + } else if (record.startsWith('? ')) { + xy = '??'; + path = record.slice(2); + } else if (record.startsWith('u ')) { + const fields = record.split(' '); + xy = 'UU'; + path = fields.slice(10).join(' '); + } else { + continue; + } + + const status = parseStatusCode(xy); + changes.push({ + path, + ...(oldPath ? { oldPath } : {}), + code: shortStatusCode(status), + status, + staged: xy[0] !== '.' && xy[0] !== '?', + unstaged: xy[1] !== '.' || xy === '??', + additions: null, + deletions: null, + binary: false, + }); + } + return changes; +} + +function parseNumstat( + output: string +): Map { + const stats = new Map(); + const records = output.split('\0'); + for (let i = 0; i < records.length; i++) { + const record = records[i]; + if (!record) continue; + const firstTab = record.indexOf('\t'); + const secondTab = record.indexOf('\t', firstTab + 1); + if (firstTab === -1 || secondTab === -1) continue; + const added = record.slice(0, firstTab); + const deleted = record.slice(firstTab + 1, secondTab); + let path = record.slice(secondTab + 1); + if (!path) { + // Rename/copy numstat records place old and new names in the next fields. + i += 2; + path = records[i] || ''; + } + if (!path) continue; + const binary = added === '-' || deleted === '-'; + stats.set(path, { + additions: binary ? null : Number.parseInt(added, 10) || 0, + deletions: binary ? null : Number.parseInt(deleted, 10) || 0, + binary, + }); + } + return stats; +} + +async function hasHead(cwd: string): Promise { + const result = await runGit(cwd, ['rev-parse', '--verify', 'HEAD'], { + allowedExitCodes: [0, 128], + }); + return result.code === 0; +} + +async function getChanges(cwd: string): Promise { + const statusResult = await runGit(cwd, ['status', '--porcelain=v2', '-z', '--untracked-files=normal', '--renames']); + const changes = parseStatus(statusResult.stdout.toString('utf8')); + if (changes.length === 0 || !(await hasHead(cwd))) return changes; + + const statResult = await runGit(cwd, ['diff', '--no-ext-diff', '--numstat', '-z', 'HEAD', '--']); + const stats = parseNumstat(statResult.stdout.toString('utf8')); + for (const change of changes) { + const stat = stats.get(change.path); + if (!stat) continue; + change.additions = stat.additions; + change.deletions = stat.deletions; + change.binary = stat.binary; + } + return changes.sort((a, b) => a.path.localeCompare(b.path)); +} + +function parseCommitRecord(record: string): GitCommitSummary | null { + const [hash, shortHash, author, authoredAt, ...subjectParts] = record.split('\x1f'); + if (!hash || !shortHash) return null; + return { + hash, + shortHash, + author: author || '', + authoredAt: authoredAt || '', + subject: subjectParts.join('\x1f'), + }; +} + +async function getRecentCommits(cwd: string): Promise { + if (!(await hasHead(cwd))) return []; + const result = await runGit(cwd, [ + 'log', + `-${COMMIT_LIMIT}`, + '--date=iso-strict', + '--pretty=format:%H%x1f%h%x1f%an%x1f%aI%x1f%s%x00', + ]); + return result.stdout + .toString('utf8') + .split('\0') + .map(parseCommitRecord) + .filter((commit): commit is GitCommitSummary => commit !== null); +} + +export async function getGitRepositoryOverview( + workingDir: string, + requestedScope?: string +): Promise { + const resolution = await resolveScope(workingDir, requestedScope); + if (!resolution) { + return { + available: false, + repositoryRoot: null, + selectedScopeId: null, + worktrees: [], + changes: [], + commits: [], + }; + } + + const [changes, commits] = await Promise.all([ + getChanges(resolution.scope.path), + getRecentCommits(resolution.scope.path), + ]); + return { + available: true, + repositoryRoot: resolution.repository.repositoryRoot, + selectedScopeId: resolution.scope.id, + worktrees: resolution.repository.worktrees, + changes, + commits, + }; +} + +function parseNameStatus(output: string): GitChange[] { + const fields = output.split('\0').filter(Boolean); + const changes: GitChange[] = []; + for (let i = 0; i < fields.length; ) { + const rawCode = fields[i++]; + const code = rawCode[0] || 'M'; + let oldPath: string | undefined; + let path = fields[i++] || ''; + if (code === 'R' || code === 'C') { + oldPath = path; + path = fields[i++] || ''; + } + const status = parseStatusCode(`${code}.`); + changes.push({ + path, + ...(oldPath ? { oldPath } : {}), + code: shortStatusCode(status), + status, + staged: true, + unstaged: false, + additions: null, + deletions: null, + binary: false, + }); + } + return changes; +} + +function assertCommitHash(commit: string): void { + if (!/^[a-f0-9]{40,64}$/i.test(commit)) { + throw new GitRepositoryScopeError('Invalid commit identifier'); + } +} + +async function ensureCommit(cwd: string, commit: string): Promise { + assertCommitHash(commit); + const result = await runGit(cwd, ['cat-file', '-e', `${commit}^{commit}`], { + allowedExitCodes: [0, 128], + }); + if (result.code !== 0) { + throw new GitRepositoryScopeError('Commit not found'); + } +} + +async function getCommitChanges(cwd: string, commit: string): Promise { + await ensureCommit(cwd, commit); + const result = await runGit(cwd, [ + 'diff-tree', + '--root', + '--no-commit-id', + '--name-status', + '-r', + '-z', + '-M', + commit, + ]); + return parseNameStatus(result.stdout.toString('utf8')); +} + +export async function getGitCommitDetails( + workingDir: string, + requestedScope: string | undefined, + commit: string +): Promise { + const resolution = await resolveScope(workingDir, requestedScope); + if (!resolution) throw new GitRepositoryScopeError('Session is not inside a Git repository'); + await ensureCommit(resolution.scope.path, commit); + const [summaryResult, changes] = await Promise.all([ + runGit(resolution.scope.path, [ + 'show', + '-s', + '--date=iso-strict', + '--pretty=format:%H%x1f%h%x1f%an%x1f%aI%x1f%s', + commit, + ]), + getCommitChanges(resolution.scope.path, commit), + ]); + const summary = parseCommitRecord(summaryResult.stdout.toString('utf8')); + if (!summary) throw new GitRepositoryScopeError('Commit metadata is unavailable'); + return { ...summary, changes }; +} + +function normalizeRepositoryPath(root: string, filePath: string): string { + if (!filePath || filePath.includes('\0') || isAbsolute(filePath)) { + throw new GitRepositoryScopeError('Invalid repository file path'); + } + const normalized = filePath.replace(/\\/g, '/').replace(/^\.\/+/, ''); + const absolute = resolve(root, normalized); + const fromRoot = relative(resolve(root), absolute); + if ( + !fromRoot || + fromRoot === '..' || + fromRoot.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`) || + isAbsolute(fromRoot) + ) { + throw new GitRepositoryScopeError('Repository file path is outside the selected worktree'); + } + return normalized; +} + +function bufferLooksBinary(buffer: Buffer): boolean { + const sniffLength = Math.min(buffer.length, 8192); + for (let i = 0; i < sniffLength; i++) { + if (buffer[i] === 0) return true; + } + return false; +} + +async function readWorkingTreeFile(root: string, filePath: string): Promise { + const normalized = normalizeRepositoryPath(root, filePath); + const absolute = resolve(root, normalized); + try { + const [realRoot, realFile] = await Promise.all([fs.realpath(root), fs.realpath(absolute)]); + const fromRoot = relative(realRoot, realFile); + if ( + fromRoot === '..' || + fromRoot.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`) || + isAbsolute(fromRoot) + ) { + throw new GitRepositoryScopeError('Repository file resolves outside the selected worktree'); + } + const stat = await fs.stat(realFile); + if (!stat.isFile()) return { exists: false, binary: false, truncated: false, content: null }; + if (stat.size > FILE_PREVIEW_LIMIT) { + return { exists: true, binary: false, truncated: true, content: null }; + } + const content = await fs.readFile(realFile); + const binary = bufferLooksBinary(content); + return { + exists: true, + binary, + truncated: false, + content: binary ? null : content.toString('utf8'), + }; + } catch (err) { + if (err instanceof GitRepositoryScopeError) throw err; + const code = (err as NodeJS.ErrnoException).code; + if (code === 'ENOENT' || code === 'ENOTDIR') { + return { exists: false, binary: false, truncated: false, content: null }; + } + throw err; + } +} + +async function readGitBlob(cwd: string, revision: string, filePath: string): Promise { + const object = `${revision}:${filePath}`; + const exists = await runGit(cwd, ['cat-file', '-e', object], { + allowedExitCodes: [0, 128], + }); + if (exists.code !== 0) return { exists: false, binary: false, truncated: false, content: null }; + + const sizeResult = await runGit(cwd, ['cat-file', '-s', object]); + const size = Number.parseInt(sizeResult.stdout.toString('utf8').trim(), 10); + if (Number.isFinite(size) && size > FILE_PREVIEW_LIMIT) { + return { exists: true, binary: false, truncated: true, content: null }; + } + const contentResult = await runGit(cwd, ['show', '--no-textconv', object], { + maxBytes: FILE_PREVIEW_LIMIT + 1, + }); + const binary = bufferLooksBinary(contentResult.stdout); + return { + exists: true, + binary, + truncated: false, + content: binary ? null : contentResult.stdout.toString('utf8'), + }; +} + +function countPatchChanges(patch: string): { additions: number; deletions: number } { + let additions = 0; + let deletions = 0; + for (const line of patch.split('\n')) { + if (line.startsWith('+++') || line.startsWith('---')) continue; + if (line.startsWith('+')) additions++; + if (line.startsWith('-')) deletions++; + } + return { additions, deletions }; +} + +function synthesizeUntrackedPatch(filePath: string, content: string): string { + const lines = content.split('\n'); + if (content.endsWith('\n')) lines.pop(); + if (lines.length === 0) { + return [ + `diff --git a/${filePath} b/${filePath}`, + 'new file mode 100644', + '--- /dev/null', + `+++ b/${filePath}`, + ].join('\n'); + } + const body = lines.map((line) => `+${line}`).join('\n'); + return [ + `diff --git a/${filePath} b/${filePath}`, + 'new file mode 100644', + '--- /dev/null', + `+++ b/${filePath}`, + `@@ -0,0 +1,${lines.length} @@`, + body, + ].join('\n'); +} + +export async function getGitDiffDetail( + workingDir: string, + requestedScope: string | undefined, + filePath: string, + commit?: string +): Promise { + const resolution = await resolveScope(workingDir, requestedScope); + if (!resolution) throw new GitRepositoryScopeError('Session is not inside a Git repository'); + const cwd = resolution.scope.path; + const normalizedPath = normalizeRepositoryPath(cwd, filePath); + + let oldPath: string | undefined; + let before: FileSnapshot; + let after: FileSnapshot; + let patchResult: GitCommandResult; + let label: string; + + if (commit) { + await ensureCommit(cwd, commit); + const changes = await getCommitChanges(cwd, commit); + const change = changes.find((candidate) => candidate.path === normalizedPath); + oldPath = change?.oldPath; + const parentResult = await runGit(cwd, ['rev-parse', '--verify', `${commit}^`], { + allowedExitCodes: [0, 128], + }); + const parent = parentResult.code === 0 ? parentResult.stdout.toString('utf8').trim() : null; + before = parent + ? await readGitBlob(cwd, parent, oldPath || normalizedPath) + : { exists: false, binary: false, truncated: false, content: null }; + after = await readGitBlob(cwd, commit, normalizedPath); + patchResult = await runGit( + cwd, + [ + 'show', + '--format=', + '--no-ext-diff', + '--no-color', + '--find-renames', + '--unified=3', + commit, + '--', + ...(oldPath ? [oldPath] : []), + normalizedPath, + ], + { maxBytes: GIT_DIFF_LIMIT, truncate: true } + ); + label = `${commit.slice(0, 8)} · ${normalizedPath}`; + } else { + const changes = await getChanges(cwd); + const change = changes.find((candidate) => candidate.path === normalizedPath); + oldPath = change?.oldPath; + const repositoryHasHead = await hasHead(cwd); + before = repositoryHasHead + ? await readGitBlob(cwd, 'HEAD', oldPath || normalizedPath) + : { exists: false, binary: false, truncated: false, content: null }; + after = await readWorkingTreeFile(cwd, normalizedPath); + patchResult = repositoryHasHead + ? await runGit( + cwd, + [ + 'diff', + '--no-ext-diff', + '--no-color', + '--find-renames', + '--unified=3', + 'HEAD', + '--', + ...(oldPath ? [oldPath] : []), + normalizedPath, + ], + { maxBytes: GIT_DIFF_LIMIT, truncate: true } + ) + : { stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), code: 0, truncated: false }; + if (patchResult.stdout.length === 0 && !before.exists && after.content !== null) { + patchResult = { + ...patchResult, + stdout: Buffer.from(synthesizeUntrackedPatch(normalizedPath, after.content)), + }; + } + label = `Working tree · ${normalizedPath}`; + } + + const patch = patchResult.stdout.toString('utf8'); + const counts = countPatchChanges(patch); + return { + path: normalizedPath, + ...(oldPath ? { oldPath } : {}), + commit: commit || null, + label, + patch, + beforeContent: before.content, + afterContent: after.content, + beforeExists: before.exists, + afterExists: after.exists, + binary: before.binary || after.binary || /Binary files|GIT binary patch/.test(patch), + truncated: patchResult.truncated || before.truncated || after.truncated, + additions: counts.additions, + deletions: counts.deletions, + }; +} diff --git a/src/mux-interface.ts b/src/mux-interface.ts index d6e58d4e..d6825389 100644 --- a/src/mux-interface.ts +++ b/src/mux-interface.ts @@ -193,6 +193,9 @@ export interface TerminalMultiplexer extends EventEmitter { /** Update the display name of a session */ updateSessionName(sessionId: string, name: string): boolean; + /** Update the persisted working directory of a session */ + updateSessionWorkingDir(sessionId: string, workingDir: string): boolean; + /** Mark session as attached/detached */ setAttached(sessionId: string, attached: boolean): void; diff --git a/src/session.ts b/src/session.ts index 98e05940..968ae6d8 100644 --- a/src/session.ts +++ b/src/session.ts @@ -289,7 +289,7 @@ export interface ClaudeMessage { */ export class Session extends EventEmitter { readonly id: string; - readonly workingDir: string; + workingDir: string; readonly createdAt: number; readonly mode: SessionMode; @@ -689,6 +689,24 @@ export class Session extends EventEmitter { this._owner = username; } + /** + * Synchronize Codeman's workspace metadata with an already-running local + * session. This intentionally does not attempt to change the child process cwd. + */ + setWorkingDir(workingDir: string): void { + if (workingDir !== this.workingDir) { + this.workingDir = workingDir; + this._bashToolParser.setWorkingDir(workingDir); + if (!isExternalCliMode(this.mode)) { + this._ralphTracker.setWorkingDir(workingDir); + } + } + if (this._muxSession) { + this._muxSession.workingDir = workingDir; + } + this._mux?.updateSessionWorkingDir(this.id, workingDir); + } + // Adopt a Claude conversation ID observed from an external source (e.g. hook // payload). In interactive PTY mode Claude CLI emits no JSON to stdout, so // `_handleJsonMessage` never sees `session_id`; hooks are the only signal diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index ca29478e..5899d0f8 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -2247,6 +2247,16 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { return true; } + updateSessionWorkingDir(sessionId: string, workingDir: string): boolean { + const session = this.sessions.get(sessionId); + if (!session) { + return false; + } + session.workingDir = workingDir; + this.saveSessions(); + return true; + } + /** * Reconcile tracked sessions with actual running tmux sessions. */ diff --git a/src/web/public/app.js b/src/web/public/app.js index 6f953be8..9d1652f8 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -588,7 +588,19 @@ class CodemanApp { this.fileBrowserFilter = ''; this.fileBrowserAllExpanded = false; this.fileBrowserDragListeners = null; + this.fileBrowserView = 'files'; + this.fileBrowserRepositoryData = null; + this.fileBrowserScopeId = 'current'; + this.fileBrowserSessionId = null; + this.fileBrowserLoadGeneration = 0; + this.fileBrowserAbortController = null; + this.fileBrowserCommitCache = new Map(); + this.fileBrowserExpandedCommit = null; + this.fileBrowserAutoRefreshTimer = null; this.filePreviewContent = ''; + this.fileDiffData = null; + this.fileDiffMode = 'compact'; + this.fileDiffLoadGeneration = 0; // Toast container cache (methods in panels-ui.js) this._toastContainer = null; @@ -1573,6 +1585,9 @@ class CodemanApp { const session = data.session || data; const oldSession = this.sessions.get(session.id); const claudeSessionIdJustSet = session.claudeSessionId && (!oldSession || !oldSession.claudeSessionId); + const workingDirectoryChanged = Boolean( + oldSession && session.workingDir && oldSession.workingDir !== session.workingDir + ); this.sessions.set(session.id, session); this.renderSessionTabs(); this.updateCost(); @@ -1592,6 +1607,11 @@ class CodemanApp { this.updateConnectionLines(); }); } + const locallySavingWorkingDirectory = + this._pendingWorkingDirectoryChange?.sessionId === session.id; + if (workingDirectoryChanged && session.id === this.activeSessionId && !locallySavingWorkingDirectory) { + void this.syncFileBrowserSession?.(session.id, { force: true }); + } } _onSessionDeleted(data) { @@ -4092,6 +4112,7 @@ class CodemanApp { this._cleanupPreviousSession(sessionId); this.activeSessionId = sessionId; + this.syncFileBrowserSession?.(sessionId); try { localStorage.setItem('codeman-active-session', sessionId); } catch {} // Narrow SSE filter to the active session — server stops streaming // session:terminal events for other sessions to this client. Cuts @@ -4449,8 +4470,11 @@ class CodemanApp { if (settings.showFileBrowser) { const fileBrowserPanel = this.$('fileBrowserPanel'); if (fileBrowserPanel) { + const wasVisible = fileBrowserPanel.classList.contains('visible'); fileBrowserPanel.classList.add('visible'); - this.loadFileBrowser(sessionId); + if (!wasVisible || this.fileBrowserSessionId !== sessionId) { + this.loadFileBrowser(sessionId); + } // Attach drag listeners if not already attached if (!this.fileBrowserDragListeners) { const header = fileBrowserPanel.querySelector('.file-browser-header'); diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 4ea11c50..9a190e40 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -68,6 +68,17 @@ 'Open attachment history': '打开附件历史', 'File Viewer': '文件查看器', 'Open file viewer': '打开文件查看器', + Repository: '代码仓库', + 'Refresh repository': '刷新代码仓库', + 'Expand or collapse all directories': '展开或折叠所有目录', + 'Close file viewer': '关闭文件查看器', + 'Repository view': '代码仓库视图', + 'Repository worktree': '代码仓库工作树', + Changes: '更改', + 'Diff view': '差异视图', + Compact: '紧凑', + Full: '完整', + 'Close preview': '关闭预览', 'Open Codeman across all displays': '在所有显示器上打开 {name}', 'Ultracode / Workflow agents': 'Ultracode / Workflow 智能体', 'Open ultracode workflow agents': '打开 Ultracode 工作流智能体', diff --git a/src/web/public/index.html b/src/web/public/index.html index 224280db..57e83928 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -134,7 +134,7 @@ - +
@@ -394,14 +394,24 @@

Resume Conversation

- Files + Repository
- - - + + + +
-