From dfafbc0ea2f84f1824785eded7fae22d930c1cbf Mon Sep 17 00:00:00 2001 From: lior Date: Wed, 29 Jul 2026 03:53:39 +0300 Subject: [PATCH 1/2] feat(files): add authorized repository browsing Expose repository status, commit history, and bounded diff details for the active session worktree. Resolve opaque worktree scopes server-side and reject or filter paths outside the authenticated workspace. --- docs/file-viewer.md | 112 +++ src/git-repository-browser.ts | 760 +++++++++++++++++++++ src/web/routes/file-routes.ts | 195 +++++- test/git-repository-browser.test.ts | 164 +++++ test/routes/file-routes-repository.test.ts | 198 ++++++ 5 files changed, 1413 insertions(+), 16 deletions(-) create mode 100644 docs/file-viewer.md create mode 100644 src/git-repository-browser.ts create mode 100644 test/git-repository-browser.test.ts create mode 100644 test/routes/file-routes-repository.test.ts 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/web/routes/file-routes.ts b/src/web/routes/file-routes.ts index b48f9e74..4cb456d8 100644 --- a/src/web/routes/file-routes.ts +++ b/src/web/routes/file-routes.ts @@ -31,11 +31,18 @@ import { getOfficePreviewPdfPath, getPreviewPdfDownloadName } from '../../docume import { sanitizeAttachmentHistoryItem } from '../../session-attachment-history.js'; import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from '../../config/attachment-guard.js'; import { isMultiUserMode, userSpacePath } from '../../config/multiuser.js'; +import { + getGitCommitDetails, + getGitDiffDetail, + getGitRepositoryOverview, + resolveRepositoryBrowseRoot, +} from '../../git-repository-browser.js'; import { CASES_DIR, canAccessOwned, findSessionOrFail, getAuthUser, + isWorkingDirAllowed, parseBody, validateSessionFilePath, } from '../route-helpers.js'; @@ -453,6 +460,30 @@ function appendDownloadFlag(url: string): string { return `${url}${url.includes('?') ? '&' : '?'}download=true`; } +async function resolveFileBrowserWorkingDir( + workingDir: string, + scope: string | undefined, + req: FastifyRequest +): Promise { + let resolvedRoot = workingDir; + if (scope) { + const repositoryRoot = await resolveRepositoryBrowseRoot(workingDir, scope); + if (repositoryRoot) { + resolvedRoot = repositoryRoot; + } else if (scope !== 'current') { + throw new Error('Repository worktree scope not found'); + } + } + if (!isWorkingDirAllowed(getAuthUser(req), resolvedRoot)) { + throw new Error('Repository worktree scope is outside the allowed workspace'); + } + return resolvedRoot; +} + +function appendFileBrowserScope(url: string, scope?: string): string { + return scope ? `${url}${url.includes('?') ? '&' : '?'}scope=${encodeURIComponent(scope)}` : url; +} + function getSessionAttachmentHistory( ctx: SessionPort & ConfigPort, sessionId: string, @@ -737,15 +768,91 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even await serveRawFile(reply, resolvedPath, fileName, extension); }); + // Repository/worktree overview for the File Viewer. Git commands run against + // a server-resolved session path; clients select only opaque worktree ids. + app.get('/api/sessions/:id/repository', async (req) => { + const { id } = req.params as { id: string }; + const { scope } = req.query as { scope?: string }; + const session = findSessionOrFail(ctx, id, req); + try { + const selectedRoot = await resolveFileBrowserWorkingDir(session.workingDir, scope, req); + const user = getAuthUser(req); + const overview = await getGitRepositoryOverview(session.workingDir, scope); + overview.worktrees = overview.worktrees.filter((worktree) => isWorkingDirAllowed(user, worktree.path)); + if (overview.repositoryRoot && !isWorkingDirAllowed(user, overview.repositoryRoot)) { + overview.repositoryRoot = selectedRoot; + } + return { + success: true, + data: overview, + }; + } catch (err) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err)); + } + }); + + app.get('/api/sessions/:id/repository/commit', async (req) => { + const { id } = req.params as { id: string }; + const { scope, commit } = req.query as { scope?: string; commit?: string }; + const session = findSessionOrFail(ctx, id, req); + if (!commit) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing commit parameter'); + } + try { + const selectedRoot = await resolveFileBrowserWorkingDir(session.workingDir, scope, req); + return { + success: true, + data: await getGitCommitDetails(selectedRoot, 'current', commit), + }; + } catch (err) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err)); + } + }); + + app.get('/api/sessions/:id/repository/diff', async (req) => { + const { id } = req.params as { id: string }; + const { + scope, + path: filePath, + commit, + } = req.query as { + scope?: string; + path?: string; + commit?: string; + }; + const session = findSessionOrFail(ctx, id, req); + if (!filePath) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter'); + } + try { + const selectedRoot = await resolveFileBrowserWorkingDir(session.workingDir, scope, req); + return { + success: true, + data: await getGitDiffDetail(selectedRoot, 'current', filePath, commit), + }; + } catch (err) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err)); + } + }); + // File tree listing app.get('/api/sessions/:id/files', async (req) => { const { id } = req.params as { id: string }; - const { depth, showHidden } = req.query as { depth?: string; showHidden?: string }; + const { depth, showHidden, scope } = req.query as { + depth?: string; + showHidden?: string; + scope?: string; + }; const session = findSessionOrFail(ctx, id, req); const maxDepth = Math.min(parseInt(depth || '5', 10), 10); const includeHidden = showHidden === 'true'; - const workingDir = session.workingDir; + let workingDir: string; + try { + workingDir = await resolveFileBrowserWorkingDir(session.workingDir, scope, req); + } catch (err) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err)); + } // Default excludes - large/generated directories const excludeDirs = new Set([ @@ -864,15 +971,33 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even // Get file content for preview (File Browser) app.get('/api/sessions/:id/file-content', async (req) => { const { id } = req.params as { id: string }; - const { path: filePath, lines, raw } = req.query as { path?: string; lines?: string; raw?: string }; + const { + path: filePath, + lines, + raw, + scope, + } = req.query as { + path?: string; + lines?: string; + raw?: string; + scope?: string; + }; const session = findSessionOrFail(ctx, id, req); if (!filePath) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter'); } - // Validate path is within working directory (security: resolve symlinks to prevent traversal) - const validated = validateSessionFilePath(session.workingDir, filePath); + let workingDir: string; + try { + workingDir = await resolveFileBrowserWorkingDir(session.workingDir, scope, req); + } catch (err) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err)); + } + + // Validate path is within the server-resolved workspace/worktree root + // (security: resolve symlinks to prevent traversal). + const validated = validateSessionFilePath(workingDir, filePath); if (!validated) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'); } @@ -936,7 +1061,10 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even ? 'audio' : null; - const fileRawUrl = `/api/sessions/${id}/file-raw?path=${encodeURIComponent(filePath)}`; + const fileRawUrl = appendFileBrowserScope( + `/api/sessions/${id}/file-raw?path=${encodeURIComponent(filePath)}`, + scope + ); if (raw === 'true' || mediaType || otherBinaryExts.has(ext)) { // Return metadata for media/binary files (no text body) @@ -1017,7 +1145,15 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even // Serve raw file content (for images/binary files) app.get('/api/sessions/:id/file-raw', async (req, reply) => { const { id } = req.params as { id: string }; - const { path: filePath, download } = req.query as { path?: string; download?: string }; + const { + path: filePath, + download, + scope, + } = req.query as { + path?: string; + download?: string; + scope?: string; + }; const session = findSessionOrFail(ctx, id, req); if (!filePath) { @@ -1025,8 +1161,17 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even return; } - // Validate path is within working directory (security: resolve symlinks to prevent traversal) - const validated = validateSessionFilePath(session.workingDir, filePath); + let workingDir: string; + try { + workingDir = await resolveFileBrowserWorkingDir(session.workingDir, scope, req); + } catch (err) { + reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err))); + return; + } + + // Validate path is within the server-resolved workspace/worktree root + // (security: resolve symlinks to prevent traversal). + const validated = validateSessionFilePath(workingDir, filePath); if (!validated) { reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found')); return; @@ -1253,15 +1398,23 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even // are converted to PDF via LibreOffice; PDF/PNG/text preview through file-raw. app.get('/api/sessions/:id/file-preview', async (req, reply) => { const { id } = req.params as { id: string }; - const { path: filePath } = req.query as { path?: string }; - const workingDir = getKnownSessionWorkingDir(ctx, id, reply, req); - if (!workingDir) return; + const { path: filePath, scope } = req.query as { path?: string; scope?: string }; + const sessionWorkingDir = getKnownSessionWorkingDir(ctx, id, reply, req); + if (!sessionWorkingDir) return; if (!filePath) { reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter')); return; } + let workingDir: string; + try { + workingDir = await resolveFileBrowserWorkingDir(sessionWorkingDir, scope, req); + } catch (err) { + reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err))); + return; + } + const validated = validateSessionFilePath(workingDir, filePath); if (!validated) { reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found')); @@ -1271,7 +1424,9 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even const ext = filePath.split('.').pop()?.toLowerCase() || ''; if (ext !== 'docx' && ext !== 'pptx') { - reply.redirect(`/api/sessions/${id}/file-raw?path=${encodeURIComponent(filePath)}`); + reply.redirect( + appendFileBrowserScope(`/api/sessions/${id}/file-raw?path=${encodeURIComponent(filePath)}`, scope) + ); return; } @@ -1281,15 +1436,23 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even // Serve a first-page thumbnail for a workspace-relative path. app.get('/api/sessions/:id/file-thumbnail', async (req, reply) => { const { id } = req.params as { id: string }; - const { path: filePath } = req.query as { path?: string }; - const workingDir = getKnownSessionWorkingDir(ctx, id, reply, req); - if (!workingDir) return; + const { path: filePath, scope } = req.query as { path?: string; scope?: string }; + const sessionWorkingDir = getKnownSessionWorkingDir(ctx, id, reply, req); + if (!sessionWorkingDir) return; if (!filePath) { reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter')); return; } + let workingDir: string; + try { + workingDir = await resolveFileBrowserWorkingDir(sessionWorkingDir, scope, req); + } catch (err) { + reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err))); + return; + } + const validated = validateSessionFilePath(workingDir, filePath); if (!validated) { reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found')); diff --git a/test/git-repository-browser.test.ts b/test/git-repository-browser.test.ts new file mode 100644 index 00000000..83719ee4 --- /dev/null +++ b/test/git-repository-browser.test.ts @@ -0,0 +1,164 @@ +import { execFileSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + discoverGitRepository, + getGitCommitDetails, + getGitDiffDetail, + getGitRepositoryOverview, + resolveRepositoryBrowseRoot, +} from '../src/git-repository-browser.js'; + +function git(cwd: string, ...args: string[]): string { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + env: { + ...process.env, + GIT_CONFIG_NOSYSTEM: '1', + }, + }).trim(); +} + +describe('git-repository-browser', () => { + let fixtureRoot: string; + let mainWorktree: string; + let featureWorktree: string; + let nestedWorkingDir: string; + let initialCommit: string; + + beforeEach(() => { + fixtureRoot = mkdtempSync(join(tmpdir(), 'codeman-git-browser-')); + mainWorktree = join(fixtureRoot, 'main-repo'); + featureWorktree = join(fixtureRoot, 'feature-worktree'); + mkdirSync(mainWorktree); + + git(mainWorktree, 'init', '-b', 'main'); + git(mainWorktree, 'config', 'user.name', 'Codeman Test'); + git(mainWorktree, 'config', 'user.email', 'codeman@example.invalid'); + mkdirSync(join(mainWorktree, 'src')); + writeFileSync(join(mainWorktree, 'README.md'), 'initial\n'); + writeFileSync(join(mainWorktree, 'src', 'app.ts'), 'export const value = 1;\n'); + git(mainWorktree, 'add', '.'); + git(mainWorktree, 'commit', '-m', 'initial commit'); + initialCommit = git(mainWorktree, 'rev-parse', 'HEAD'); + + git(mainWorktree, 'worktree', 'add', '-b', 'feature/mobile-view', featureWorktree); + nestedWorkingDir = join(featureWorktree, 'src'); + writeFileSync(join(featureWorktree, 'README.md'), 'initial\nmobile change\n'); + writeFileSync(join(featureWorktree, 'new note.md'), 'untracked line\n'); + }); + + afterEach(() => { + rmSync(fixtureRoot, { recursive: true, force: true }); + }); + + it('discovers the repository root and sibling worktrees from a nested session path', async () => { + const repository = await discoverGitRepository(nestedWorkingDir); + + expect(repository).not.toBeNull(); + expect(repository?.repositoryRoot).toBe(mainWorktree); + expect(repository?.worktrees).toHaveLength(2); + expect(repository?.worktrees.find((worktree) => worktree.main)?.path).toBe(mainWorktree); + expect(repository?.worktrees.find((worktree) => worktree.current)).toMatchObject({ + path: featureWorktree, + branch: 'feature/mobile-view', + }); + await expect(resolveRepositoryBrowseRoot(nestedWorkingDir, 'current')).resolves.toBe(featureWorktree); + }); + + it('returns compact current changes, commit history, and lazy diff content', async () => { + renameSync(join(featureWorktree, 'src', 'app.ts'), join(featureWorktree, 'src', 'mobile app.ts')); + git(featureWorktree, 'add', '-A', 'src'); + const overview = await getGitRepositoryOverview(nestedWorkingDir, 'current'); + + expect(overview.available).toBe(true); + expect(overview.changes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: 'README.md', status: 'modified', code: 'M' }), + expect.objectContaining({ path: 'new note.md', status: 'untracked', code: '?' }), + expect.objectContaining({ + path: 'src/mobile app.ts', + oldPath: 'src/app.ts', + status: 'renamed', + code: 'R', + }), + ]) + ); + expect(overview.commits[0]).toMatchObject({ + hash: initialCommit, + subject: 'initial commit', + author: 'Codeman Test', + }); + + const trackedDiff = await getGitDiffDetail(nestedWorkingDir, 'current', 'README.md'); + expect(trackedDiff).toMatchObject({ + path: 'README.md', + beforeContent: 'initial\n', + afterContent: 'initial\nmobile change\n', + additions: 1, + deletions: 0, + binary: false, + }); + expect(trackedDiff.patch).toContain('+mobile change'); + + const untrackedDiff = await getGitDiffDetail(nestedWorkingDir, 'current', 'new note.md'); + expect(untrackedDiff.beforeExists).toBe(false); + expect(untrackedDiff.afterContent).toBe('untracked line\n'); + expect(untrackedDiff.additions).toBe(1); + expect(untrackedDiff.patch).toContain('--- /dev/null'); + + const renamedDiff = await getGitDiffDetail(nestedWorkingDir, 'current', 'src/mobile app.ts'); + expect(renamedDiff).toMatchObject({ + oldPath: 'src/app.ts', + beforeContent: 'export const value = 1;\n', + afterContent: 'export const value = 1;\n', + }); + }); + + it('loads commit file changes and rejects scopes or paths outside the repository', async () => { + const details = await getGitCommitDetails(nestedWorkingDir, 'current', initialCommit); + expect(details.changes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: 'README.md', status: 'added' }), + expect.objectContaining({ path: 'src/app.ts', status: 'added' }), + ]) + ); + + const committedDiff = await getGitDiffDetail(nestedWorkingDir, 'current', 'src/app.ts', initialCommit); + expect(committedDiff.afterContent).toBe('export const value = 1;\n'); + expect(committedDiff.beforeExists).toBe(false); + + await expect(resolveRepositoryBrowseRoot(nestedWorkingDir, 'forged-scope')).rejects.toThrow('scope not found'); + await expect(getGitDiffDetail(nestedWorkingDir, 'current', '../outside.txt')).rejects.toThrow( + 'outside the selected worktree' + ); + }); + + it('rejects symlink escapes and bounds binary or oversized previews', async () => { + const outsideSecret = join(fixtureRoot, 'outside-secret.txt'); + writeFileSync(outsideSecret, 'do not expose\n'); + symlinkSync(outsideSecret, join(featureWorktree, 'secret-link.txt')); + writeFileSync(join(featureWorktree, 'binary.dat'), Buffer.from([0x00, 0x01, 0x02])); + writeFileSync(join(featureWorktree, 'large.txt'), 'x'.repeat(1024 * 1024 + 1)); + + await expect(getGitDiffDetail(nestedWorkingDir, 'current', 'secret-link.txt')).rejects.toThrow( + 'resolves outside the selected worktree' + ); + + const binary = await getGitDiffDetail(nestedWorkingDir, 'current', 'binary.dat'); + expect(binary).toMatchObject({ + binary: true, + afterContent: null, + truncated: false, + }); + + const large = await getGitDiffDetail(nestedWorkingDir, 'current', 'large.txt'); + expect(large).toMatchObject({ + afterContent: null, + truncated: true, + }); + }); +}); diff --git a/test/routes/file-routes-repository.test.ts b/test/routes/file-routes-repository.test.ts new file mode 100644 index 00000000..75283276 --- /dev/null +++ b/test/routes/file-routes-repository.test.ts @@ -0,0 +1,198 @@ +/** + * @fileoverview Repository-aware File Viewer route tests. + * + * Uses app.inject() with a real temporary Git repository; no HTTP port needed. + */ + +import { execFileSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { registerFileRoutes } from '../../src/web/routes/file-routes.js'; +import { discoverGitRepository } from '../../src/git-repository-browser.js'; +import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js'; + +function git(cwd: string, ...args: string[]): string { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + env: { + ...process.env, + GIT_CONFIG_NOSYSTEM: '1', + }, + }).trim(); +} + +describe('file-routes repository browsing', () => { + let fixtureRoot: string; + let repositoryRoot: string; + let harness: RouteTestHarness; + let savedMultiUser: string | undefined; + let savedUserSpaces: string | undefined; + + beforeEach(async () => { + savedMultiUser = process.env.CODEMAN_MULTIUSER; + savedUserSpaces = process.env.CODEMAN_USER_SPACES_DIR; + delete process.env.CODEMAN_MULTIUSER; + delete process.env.CODEMAN_USER_SPACES_DIR; + fixtureRoot = mkdtempSync(join(tmpdir(), 'codeman-file-routes-git-')); + repositoryRoot = join(fixtureRoot, 'repository'); + mkdirSync(repositoryRoot); + mkdirSync(join(repositoryRoot, 'src')); + git(repositoryRoot, 'init', '-b', 'main'); + git(repositoryRoot, 'config', 'user.name', 'Codeman Test'); + git(repositoryRoot, 'config', 'user.email', 'codeman@example.invalid'); + writeFileSync(join(repositoryRoot, 'README.md'), 'initial\n'); + writeFileSync(join(repositoryRoot, 'src', 'app.ts'), 'export const initial = true;\n'); + git(repositoryRoot, 'add', '.'); + git(repositoryRoot, 'commit', '-m', 'initial commit'); + writeFileSync(join(repositoryRoot, 'README.md'), 'initial\nchanged\n'); + + harness = await createRouteTestHarness(registerFileRoutes); + harness.ctx._session.workingDir = join(repositoryRoot, 'src'); + }); + + afterEach(async () => { + await harness.app.close(); + rmSync(fixtureRoot, { recursive: true, force: true }); + if (savedMultiUser === undefined) delete process.env.CODEMAN_MULTIUSER; + else process.env.CODEMAN_MULTIUSER = savedMultiUser; + if (savedUserSpaces === undefined) { + delete process.env.CODEMAN_USER_SPACES_DIR; + } else { + process.env.CODEMAN_USER_SPACES_DIR = savedUserSpaces; + } + }); + + it('returns repository metadata and roots the scoped file tree at the worktree', async () => { + const repositoryResponse = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/repository?scope=current`, + }); + expect(repositoryResponse.statusCode).toBe(200); + const repository = repositoryResponse.json(); + expect(repository).toMatchObject({ + success: true, + data: { + available: true, + repositoryRoot, + }, + }); + expect(repository.data.changes).toEqual( + expect.arrayContaining([expect.objectContaining({ path: 'README.md', status: 'modified' })]) + ); + + const filesResponse = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/files?scope=current&depth=2`, + }); + const files = filesResponse.json(); + expect(files.success).toBe(true); + expect(files.data.root).toBe(repositoryRoot); + expect(files.data.tree).toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'README.md', type: 'file' })]) + ); + }); + + it('uses the same worktree scope for file content and diff detail', async () => { + const legacyResponse = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=README.md`, + }); + expect(legacyResponse.json().success).toBe(false); + + const scopedResponse = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=README.md&scope=current`, + }); + expect(scopedResponse.json()).toMatchObject({ + success: true, + data: { + content: 'initial\nchanged\n', + }, + }); + + const diffResponse = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/repository/diff?scope=current&path=README.md`, + }); + expect(diffResponse.json()).toMatchObject({ + success: true, + data: { + beforeContent: 'initial\n', + afterContent: 'initial\nchanged\n', + additions: 1, + }, + }); + }); + + it('rejects a forged worktree scope', async () => { + const response = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/files?scope=forged&depth=2`, + }); + expect(response.json()).toMatchObject({ + success: false, + error: expect.stringContaining('scope not found'), + }); + }); + + it('filters and rejects linked worktrees outside a regular user workspace', async () => { + const userSpaces = join(fixtureRoot, 'user-spaces'); + const allowedRoot = join(userSpaces, 'alice', 'cases', 'allowed-repository'); + const outsideRoot = join(fixtureRoot, 'outside-worktree'); + mkdirSync(allowedRoot, { recursive: true }); + git(allowedRoot, 'init', '-b', 'main'); + git(allowedRoot, 'config', 'user.name', 'Codeman Test'); + git(allowedRoot, 'config', 'user.email', 'codeman@example.invalid'); + writeFileSync(join(allowedRoot, 'README.md'), 'allowed\n'); + git(allowedRoot, 'add', 'README.md'); + git(allowedRoot, 'commit', '-m', 'allowed root'); + git(allowedRoot, 'worktree', 'add', '-b', 'outside', outsideRoot); + writeFileSync(join(outsideRoot, 'secret.txt'), 'outside secret\n'); + + process.env.CODEMAN_MULTIUSER = '1'; + process.env.CODEMAN_USER_SPACES_DIR = userSpaces; + await harness.app.close(); + harness = await createRouteTestHarness(registerFileRoutes, { + authUser: { username: 'alice', role: 'user' }, + }); + harness.ctx._session.workingDir = allowedRoot; + harness.ctx._session.owner = 'alice'; + + const discovery = await discoverGitRepository(allowedRoot); + const outsideScope = discovery?.worktrees.find((worktree) => worktree.path === outsideRoot); + expect(outsideScope).toBeDefined(); + + const overviewResponse = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/repository?scope=current`, + }); + const overview = overviewResponse.json(); + expect(overview.success).toBe(true); + expect(overview.data.worktrees).not.toEqual( + expect.arrayContaining([expect.objectContaining({ path: outsideRoot })]) + ); + + const filesResponse = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/files?depth=1&scope=` + encodeURIComponent(outsideScope!.id), + }); + expect(filesResponse.json()).toMatchObject({ + success: false, + error: expect.stringContaining('outside the allowed workspace'), + }); + + const diffResponse = await harness.app.inject({ + method: 'GET', + url: + `/api/sessions/${harness.ctx._sessionId}/repository/diff?path=secret.txt&scope=` + + encodeURIComponent(outsideScope!.id), + }); + expect(diffResponse.json()).toMatchObject({ + success: false, + error: expect.stringContaining('outside the allowed workspace'), + }); + }); +}); From 1e52ca3d9707ecb4955059a572c865d58a21f177 Mon Sep 17 00:00:00 2001 From: lior Date: Wed, 29 Jul 2026 04:02:15 +0300 Subject: [PATCH 2/2] feat(files): add repository-aware viewer Add Files, Changes, and History views with compact or full diffs, worktree selection, and active-session synchronization. Support authenticated local work-path reassignment and a full-screen mobile diff experience. --- src/mux-interface.ts | 3 + src/session.ts | 20 +- src/tmux-manager.ts | 10 + src/web/public/app.js | 26 +- src/web/public/i18n.js | 11 + src/web/public/index.html | 54 ++- src/web/public/mobile.css | 112 ++++- src/web/public/panels-ui.js | 746 +++++++++++++++++++++++++++-- src/web/public/styles.css | 417 +++++++++++++++- src/web/routes/session-routes.ts | 34 ++ src/web/schemas.ts | 5 + test/mobile/file-viewer.test.ts | 575 ++++++++++++++++++++++ test/mobile/helpers/constants.ts | 1 + test/mocks/mock-route-context.ts | 1 + test/mocks/mock-session.ts | 11 + test/routes/session-routes.test.ts | 46 ++ test/tmux-manager.test.ts | 10 + 17 files changed, 2028 insertions(+), 54 deletions(-) create mode 100644 test/mobile/file-viewer.test.ts 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
- - - + + + +
-