diff --git a/cli/src/modules/common/gitHandlers.ts b/cli/src/modules/common/gitHandlers.ts new file mode 100644 index 00000000..0ebb244a --- /dev/null +++ b/cli/src/modules/common/gitHandlers.ts @@ -0,0 +1,135 @@ +import { execFile, type ExecFileOptions } from 'child_process' +import { promisify } from 'util' +import { RpcHandlerManager } from '../../api/rpc/RpcHandlerManager' +import { validatePath } from './pathSecurity' + +const execFileAsync = promisify(execFile) + +interface GitStatusRequest { + cwd?: string + timeout?: number +} + +interface GitDiffNumstatRequest { + cwd?: string + staged?: boolean + timeout?: number +} + +interface GitDiffFileRequest { + cwd?: string + filePath: string + staged?: boolean + timeout?: number +} + +interface GitCommandResponse { + success: boolean + stdout?: string + stderr?: string + exitCode?: number + error?: string +} + +function resolveCwd(requestedCwd: string | undefined, workingDirectory: string): { cwd: string; error?: string } { + const cwd = requestedCwd ?? workingDirectory + const validation = validatePath(cwd, workingDirectory) + if (!validation.valid) { + return { cwd, error: validation.error ?? 'Invalid working directory' } + } + return { cwd } +} + +function validateFilePath(filePath: string, workingDirectory: string): string | null { + const validation = validatePath(filePath, workingDirectory) + if (!validation.valid) { + return validation.error ?? 'Invalid file path' + } + return null +} + +async function runGitCommand( + args: string[], + cwd: string, + timeout?: number +): Promise { + try { + const options: ExecFileOptions = { + cwd, + timeout: timeout ?? 10_000 + } + const { stdout, stderr } = await execFileAsync('git', args, options) + return { + success: true, + stdout: stdout ? stdout.toString() : '', + stderr: stderr ? stderr.toString() : '', + exitCode: 0 + } + } catch (error) { + const execError = error as NodeJS.ErrnoException & { + stdout?: string + stderr?: string + code?: number | string + killed?: boolean + } + + if (execError.code === 'ETIMEDOUT' || execError.killed) { + return { + success: false, + stdout: execError.stdout ? execError.stdout.toString() : '', + stderr: execError.stderr ? execError.stderr.toString() : '', + exitCode: typeof execError.code === 'number' ? execError.code : -1, + error: 'Command timed out' + } + } + + return { + success: false, + stdout: execError.stdout ? execError.stdout.toString() : '', + stderr: execError.stderr ? execError.stderr.toString() : execError.message || 'Command failed', + exitCode: typeof execError.code === 'number' ? execError.code : 1, + error: execError.message || 'Command failed' + } + } +} + +export function registerGitHandlers(rpcHandlerManager: RpcHandlerManager, workingDirectory: string): void { + rpcHandlerManager.registerHandler('git-status', async (data) => { + const resolved = resolveCwd(data.cwd, workingDirectory) + if (resolved.error) { + return { success: false, error: resolved.error } + } + return await runGitCommand( + ['status', '--porcelain=v2', '--branch', '--untracked-files=all'], + resolved.cwd, + data.timeout + ) + }) + + rpcHandlerManager.registerHandler('git-diff-numstat', async (data) => { + const resolved = resolveCwd(data.cwd, workingDirectory) + if (resolved.error) { + return { success: false, error: resolved.error } + } + const args = data.staged + ? ['diff', '--cached', '--numstat'] + : ['diff', '--numstat'] + return await runGitCommand(args, resolved.cwd, data.timeout) + }) + + rpcHandlerManager.registerHandler('git-diff-file', async (data) => { + const resolved = resolveCwd(data.cwd, workingDirectory) + if (resolved.error) { + return { success: false, error: resolved.error } + } + const fileError = validateFilePath(data.filePath, workingDirectory) + if (fileError) { + return { success: false, error: fileError } + } + + const args = data.staged + ? ['diff', '--cached', '--no-ext-diff', '--', data.filePath] + : ['diff', '--no-ext-diff', '--', data.filePath] + return await runGitCommand(args, resolved.cwd, data.timeout) + }) +} diff --git a/cli/src/modules/common/registerCommonHandlers.ts b/cli/src/modules/common/registerCommonHandlers.ts index 756fecdb..50b738bc 100644 --- a/cli/src/modules/common/registerCommonHandlers.ts +++ b/cli/src/modules/common/registerCommonHandlers.ts @@ -3,10 +3,11 @@ import { exec, ExecOptions } from 'child_process'; import { promisify } from 'util'; import { readFile, writeFile, readdir, stat } from 'fs/promises'; import { createHash } from 'crypto'; -import { join } from 'path'; +import { join, resolve } from 'path'; import { run as runRipgrep } from '@/modules/ripgrep/index'; import { run as runDifftastic } from '@/modules/difftastic/index'; import { RpcHandlerManager } from '../../api/rpc/RpcHandlerManager'; +import { registerGitHandlers } from './gitHandlers'; import { validatePath } from './pathSecurity'; const execAsync = promisify(exec); @@ -203,7 +204,8 @@ export function registerCommonHandlers(rpcHandlerManager: RpcHandlerManager, wor } try { - const buffer = await readFile(data.path); + const resolvedPath = resolve(workingDirectory, data.path); + const buffer = await readFile(resolvedPath); const content = buffer.toString('base64'); return { success: true, content }; } catch (error) { @@ -480,4 +482,6 @@ export function registerCommonHandlers(rpcHandlerManager: RpcHandlerManager, wor }; } }); -} \ No newline at end of file + + registerGitHandlers(rpcHandlerManager, workingDirectory); +} diff --git a/server/src/sync/syncEngine.ts b/server/src/sync/syncEngine.ts index 4689ec5c..82012931 100644 --- a/server/src/sync/syncEngine.ts +++ b/server/src/sync/syncEngine.ts @@ -112,6 +112,20 @@ export type FetchMessagesResult = | { ok: true; messages: DecryptedMessage[] } | { ok: false; status: number | null; error: string } +export type RpcCommandResponse = { + success: boolean + stdout?: string + stderr?: string + exitCode?: number + error?: string +} + +export type RpcReadFileResponse = { + success: boolean + content?: string + error?: string +} + export type SyncEventType = | 'session-added' | 'session-updated' @@ -649,6 +663,26 @@ export class SyncEngine { } } + async getGitStatus(sessionId: string, cwd?: string): Promise { + return await this.sessionRpc(sessionId, 'git-status', { cwd }) as RpcCommandResponse + } + + async getGitDiffNumstat(sessionId: string, options: { cwd?: string; staged?: boolean }): Promise { + return await this.sessionRpc(sessionId, 'git-diff-numstat', options) as RpcCommandResponse + } + + async getGitDiffFile(sessionId: string, options: { cwd?: string; filePath: string; staged?: boolean }): Promise { + return await this.sessionRpc(sessionId, 'git-diff-file', options) as RpcCommandResponse + } + + async readSessionFile(sessionId: string, path: string): Promise { + return await this.sessionRpc(sessionId, 'readFile', { path }) as RpcReadFileResponse + } + + async runRipgrep(sessionId: string, args: string[], cwd?: string): Promise { + return await this.sessionRpc(sessionId, 'ripgrep', { args, cwd }) as RpcCommandResponse + } + private async sessionRpc(sessionId: string, method: string, params: unknown): Promise { return await this.rpcCall(`${sessionId}:${method}`, params) } diff --git a/server/src/web/routes/git.ts b/server/src/web/routes/git.ts new file mode 100644 index 00000000..e548deb4 --- /dev/null +++ b/server/src/web/routes/git.ts @@ -0,0 +1,183 @@ +import { Hono } from 'hono' +import { z } from 'zod' +import type { SyncEngine } from '../../sync/syncEngine' +import type { WebAppEnv } from '../middleware/auth' +import { requireSessionFromParam, requireSyncEngine } from './guards' + +const fileSearchSchema = z.object({ + query: z.string().optional(), + limit: z.coerce.number().int().min(1).max(500).optional() +}) + +const filePathSchema = z.object({ + path: z.string().min(1) +}) + +function parseBooleanParam(value: string | undefined): boolean | undefined { + if (value === 'true') return true + if (value === 'false') return false + return undefined +} + +async function runRpc(fn: () => Promise): Promise { + try { + return await fn() + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } +} + +export function createGitRoutes(getSyncEngine: () => SyncEngine | null): Hono { + const app = new Hono() + + app.get('/sessions/:id/git-status', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + + const sessionResult = requireSessionFromParam(c, engine) + if (sessionResult instanceof Response) { + return sessionResult + } + + const sessionPath = sessionResult.session.metadata?.path + if (!sessionPath) { + return c.json({ success: false, error: 'Session path not available' }) + } + + const result = await runRpc(() => engine.getGitStatus(sessionResult.sessionId, sessionPath)) + return c.json(result) + }) + + app.get('/sessions/:id/git-diff-numstat', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + + const sessionResult = requireSessionFromParam(c, engine) + if (sessionResult instanceof Response) { + return sessionResult + } + + const sessionPath = sessionResult.session.metadata?.path + if (!sessionPath) { + return c.json({ success: false, error: 'Session path not available' }) + } + + const staged = parseBooleanParam(c.req.query('staged')) + const result = await runRpc(() => engine.getGitDiffNumstat(sessionResult.sessionId, { cwd: sessionPath, staged })) + return c.json(result) + }) + + app.get('/sessions/:id/git-diff-file', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + + const sessionResult = requireSessionFromParam(c, engine) + if (sessionResult instanceof Response) { + return sessionResult + } + + const sessionPath = sessionResult.session.metadata?.path + if (!sessionPath) { + return c.json({ success: false, error: 'Session path not available' }) + } + + const parsed = filePathSchema.safeParse(c.req.query()) + if (!parsed.success) { + return c.json({ error: 'Invalid file path' }, 400) + } + + const staged = parseBooleanParam(c.req.query('staged')) + const result = await runRpc(() => engine.getGitDiffFile(sessionResult.sessionId, { + cwd: sessionPath, + filePath: parsed.data.path, + staged + })) + return c.json(result) + }) + + app.get('/sessions/:id/file', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + + const sessionResult = requireSessionFromParam(c, engine) + if (sessionResult instanceof Response) { + return sessionResult + } + + const sessionPath = sessionResult.session.metadata?.path + if (!sessionPath) { + return c.json({ success: false, error: 'Session path not available' }) + } + + const parsed = filePathSchema.safeParse(c.req.query()) + if (!parsed.success) { + return c.json({ error: 'Invalid file path' }, 400) + } + + const result = await runRpc(() => engine.readSessionFile(sessionResult.sessionId, parsed.data.path)) + return c.json(result) + }) + + app.get('/sessions/:id/files', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + + const sessionResult = requireSessionFromParam(c, engine) + if (sessionResult instanceof Response) { + return sessionResult + } + + const sessionPath = sessionResult.session.metadata?.path + if (!sessionPath) { + return c.json({ success: false, error: 'Session path not available' }) + } + + const parsed = fileSearchSchema.safeParse(c.req.query()) + if (!parsed.success) { + return c.json({ error: 'Invalid query' }, 400) + } + + const query = parsed.data.query?.trim() ?? '' + const limit = parsed.data.limit ?? 200 + const args = ['--files'] + if (query) { + args.push('--iglob', `*${query}*`) + } + + const result = await runRpc(() => engine.runRipgrep(sessionResult.sessionId, args, sessionPath)) + if (!('success' in result) || !result.success || !result.stdout) { + return c.json({ success: false, error: result.error ?? 'Failed to list files' }) + } + + const files = result.stdout + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .slice(0, limit) + .map((fullPath) => { + const parts = fullPath.split('/') + const fileName = parts[parts.length - 1] || fullPath + const filePath = parts.slice(0, -1).join('/') + return { + fileName, + filePath, + fullPath, + fileType: 'file' as const + } + }) + + return c.json({ success: true, files }) + }) + + return app +} diff --git a/server/src/web/server.ts b/server/src/web/server.ts index a842116f..b2ba2841 100644 --- a/server/src/web/server.ts +++ b/server/src/web/server.ts @@ -13,6 +13,7 @@ import { createSessionsRoutes } from './routes/sessions' import { createMessagesRoutes } from './routes/messages' import { createPermissionsRoutes } from './routes/permissions' import { createMachinesRoutes } from './routes/machines' +import { createGitRoutes } from './routes/git' import { createCliRoutes } from './routes/cli' import type { SSEManager } from '../sse/sseManager' import type { Server as BunServer } from 'bun' @@ -66,6 +67,7 @@ function createWebApp(options: { app.route('/api', createMessagesRoutes(options.getSyncEngine)) app.route('/api', createPermissionsRoutes(options.getSyncEngine)) app.route('/api', createMachinesRoutes(options.getSyncEngine)) + app.route('/api', createGitRoutes(options.getSyncEngine)) const { distDir, indexHtmlPath } = findWebappDistDir() diff --git a/web/src/api/client.ts b/web/src/api/client.ts index c4cd9e67..10f57575 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1,5 +1,8 @@ import type { AuthResponse, + FileReadResponse, + FileSearchResponse, + GitCommandResponse, MachinesResponse, MessagesResponse, SpawnResponse, @@ -71,6 +74,43 @@ export class ApiClient { return await this.request(url) } + async getGitStatus(sessionId: string): Promise { + return await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/git-status`) + } + + async getGitDiffNumstat(sessionId: string, staged: boolean): Promise { + const params = new URLSearchParams() + params.set('staged', staged ? 'true' : 'false') + return await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/git-diff-numstat?${params.toString()}`) + } + + async getGitDiffFile(sessionId: string, path: string, staged?: boolean): Promise { + const params = new URLSearchParams() + params.set('path', path) + if (staged !== undefined) { + params.set('staged', staged ? 'true' : 'false') + } + return await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/git-diff-file?${params.toString()}`) + } + + async searchSessionFiles(sessionId: string, query: string, limit?: number): Promise { + const params = new URLSearchParams() + if (query) { + params.set('query', query) + } + if (limit !== undefined) { + params.set('limit', `${limit}`) + } + const qs = params.toString() + return await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/files${qs ? `?${qs}` : ''}`) + } + + async readSessionFile(sessionId: string, path: string): Promise { + const params = new URLSearchParams() + params.set('path', path) + return await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/file?${params.toString()}`) + } + async sendMessage(sessionId: string, text: string, localId?: string | null): Promise { await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/messages`, { method: 'POST', diff --git a/web/src/components/FileIcon.tsx b/web/src/components/FileIcon.tsx new file mode 100644 index 00000000..711f236d --- /dev/null +++ b/web/src/components/FileIcon.tsx @@ -0,0 +1,57 @@ +import { useMemo } from 'react' + +const EXTENSION_COLORS: Record = { + ts: '#3178c6', + tsx: '#3178c6', + js: '#f7df1e', + jsx: '#f7df1e', + json: '#f59e0b', + md: '#64748b', + mdx: '#64748b', + css: '#2563eb', + scss: '#db2777', + html: '#f97316', + yml: '#ef4444', + yaml: '#ef4444', + sh: '#10b981', + bash: '#10b981', + py: '#3776ab', + go: '#0ea5e9', + rs: '#f97316', +} + +function getFileExtension(fileName: string): string { + const trimmed = fileName.trim() + if (trimmed.startsWith('.') && trimmed.indexOf('.', 1) === -1) { + return trimmed.slice(1).toLowerCase() + } + const parts = trimmed.split('.') + if (parts.length <= 1) return '' + return parts[parts.length - 1]?.toLowerCase() ?? '' +} + +export function FileIcon(props: { fileName: string; size?: number }) { + const size = props.size ?? 20 + const color = useMemo(() => { + const ext = getFileExtension(props.fileName) + return EXTENSION_COLORS[ext] ?? 'var(--app-hint)' + }, [props.fileName]) + + return ( + + + + + ) +} diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index 39dbd7c2..12c1b535 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useRef } from 'react' +import { useNavigate } from '@tanstack/react-router' import { AssistantRuntimeProvider } from '@assistant-ui/react' import type { ApiClient } from '@/api/client' import type { DecryptedMessage, ModelMode, PermissionMode, Session } from '@/types/api' @@ -30,6 +31,7 @@ export function SessionChat(props: { onRetryMessage?: (localId: string) => void }) { const { haptic } = usePlatform() + const navigate = useNavigate() const controlsDisabled = !props.session.active const normalizedCacheRef = useRef>(new Map()) const blocksByIdRef = useRef>(new Map()) @@ -106,6 +108,13 @@ export function SessionChat(props: { props.onRefresh() }, [abortSession, props.onRefresh]) + const handleViewFiles = useCallback(() => { + navigate({ + to: '/sessions/$sessionId/files', + params: { sessionId: props.session.id } + }) + }, [navigate, props.session.id]) + const runtime = useHappyRuntime({ session: props.session, blocks: reconciled.blocks, @@ -166,6 +175,7 @@ export function SessionChat(props: { {controlsDisabled ? ( diff --git a/web/src/components/SessionHeader.tsx b/web/src/components/SessionHeader.tsx index 3913a9d1..0b37879c 100644 --- a/web/src/components/SessionHeader.tsx +++ b/web/src/components/SessionHeader.tsx @@ -16,9 +16,30 @@ function getSessionTitle(session: Session): string { return session.id.slice(0, 8) } +function FilesIcon(props: { className?: string }) { + return ( + + + + + ) +} + export function SessionHeader(props: { session: Session onBack: () => void + onViewFiles?: () => void }) { const title = useMemo(() => getSessionTitle(props.session), [props.session]) @@ -60,6 +81,17 @@ export function SessionHeader(props: { {props.session.metadata?.path ?? props.session.id} + + {props.onViewFiles ? ( + + ) : null} ) diff --git a/web/src/hooks/queries/useGitStatusFiles.ts b/web/src/hooks/queries/useGitStatusFiles.ts new file mode 100644 index 00000000..b150fc70 --- /dev/null +++ b/web/src/hooks/queries/useGitStatusFiles.ts @@ -0,0 +1,59 @@ +import { useQuery } from '@tanstack/react-query' +import type { ApiClient } from '@/api/client' +import type { GitStatusFiles } from '@/types/api' +import { buildGitStatusFiles } from '@/lib/gitParsers' +import { queryKeys } from '@/lib/query-keys' + +export function useGitStatusFiles(api: ApiClient | null, sessionId: string | null): { + status: GitStatusFiles | null + error: string | null + isLoading: boolean + refetch: () => Promise +} { + const resolvedSessionId = sessionId ?? 'unknown' + const query = useQuery({ + queryKey: queryKeys.gitStatus(resolvedSessionId), + queryFn: async () => { + if (!api || !sessionId) { + throw new Error('Session unavailable') + } + + const statusResult = await api.getGitStatus(sessionId) + if (!statusResult.success) { + return { + status: null, + error: statusResult.error ?? statusResult.stderr ?? 'Git status unavailable' + } + } + + const [unstagedResult, stagedResult] = await Promise.all([ + api.getGitDiffNumstat(sessionId, false), + api.getGitDiffNumstat(sessionId, true) + ]) + + const status = buildGitStatusFiles( + statusResult.stdout ?? '', + unstagedResult.success ? (unstagedResult.stdout ?? '') : '', + stagedResult.success ? (stagedResult.stdout ?? '') : '' + ) + + const errors: string[] = [] + if (!unstagedResult.success) { + errors.push(`Unstaged diff unavailable: ${unstagedResult.error ?? unstagedResult.stderr ?? 'unknown error'}`) + } + if (!stagedResult.success) { + errors.push(`Staged diff unavailable: ${stagedResult.error ?? stagedResult.stderr ?? 'unknown error'}`) + } + + return { status, error: errors.length ? errors.join(' ') : null } + }, + enabled: Boolean(api && sessionId), + }) + + return { + status: query.data?.status ?? null, + error: query.data?.error ?? null, + isLoading: query.isLoading, + refetch: query.refetch + } +} diff --git a/web/src/hooks/queries/useSessionFileSearch.ts b/web/src/hooks/queries/useSessionFileSearch.ts new file mode 100644 index 00000000..a794ceff --- /dev/null +++ b/web/src/hooks/queries/useSessionFileSearch.ts @@ -0,0 +1,42 @@ +import { useQuery } from '@tanstack/react-query' +import type { ApiClient } from '@/api/client' +import type { FileSearchItem } from '@/types/api' +import { queryKeys } from '@/lib/query-keys' + +export function useSessionFileSearch( + api: ApiClient | null, + sessionId: string | null, + query: string, + options?: { limit?: number; enabled?: boolean } +): { + files: FileSearchItem[] + error: string | null + isLoading: boolean + refetch: () => Promise +} { + const resolvedSessionId = sessionId ?? 'unknown' + const limit = options?.limit ?? 200 + const enabled = options?.enabled ?? Boolean(api && sessionId) + + const result = useQuery({ + queryKey: queryKeys.sessionFiles(resolvedSessionId, query), + queryFn: async () => { + if (!api || !sessionId) { + throw new Error('Session unavailable') + } + const response = await api.searchSessionFiles(sessionId, query, limit) + if (!response.success) { + return { files: [], error: response.error ?? 'Failed to search files' } + } + return { files: response.files ?? [], error: null } + }, + enabled, + }) + + return { + files: result.data?.files ?? [], + error: result.data?.error ?? null, + isLoading: result.isLoading, + refetch: result.refetch + } +} diff --git a/web/src/index.css b/web/src/index.css index d4f762b0..9ed34bfd 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -24,6 +24,13 @@ --app-diff-removed-bg: #ffeef0; --app-diff-removed-text: #24292e; + /* Git status colors (light) */ + --app-git-staged-color: #34C759; + --app-git-unstaged-color: #FF9500; + --app-git-deleted-color: #FF3B30; + --app-git-renamed-color: #2563eb; + --app-git-untracked-color: #8E8E93; + /* Badge colors (light) */ --app-badge-warning-bg: rgba(245, 158, 11, 0.2); --app-badge-warning-text: #b45309; @@ -58,6 +65,13 @@ --app-diff-removed-bg: #3f1b23; --app-diff-removed-text: #c9d1d9; + /* Git status colors (dark) */ + --app-git-staged-color: #4ade80; + --app-git-unstaged-color: #f59e0b; + --app-git-deleted-color: #f87171; + --app-git-renamed-color: #60a5fa; + --app-git-untracked-color: #9ca3af; + /* Badge colors (dark) */ --app-badge-warning-bg: rgba(251, 191, 36, 0.2); --app-badge-warning-text: #fbbf24; diff --git a/web/src/lib/gitParsers.ts b/web/src/lib/gitParsers.ts new file mode 100644 index 00000000..cefde184 --- /dev/null +++ b/web/src/lib/gitParsers.ts @@ -0,0 +1,346 @@ +import type { GitFileStatus, GitStatusFiles } from '@/types/api' + +export type GitFileEntryV2 = { + path: string + index: string + workingDir: string + from?: string +} + +export type GitBranchInfo = { + oid?: string + head?: string + upstream?: string + ahead?: number + behind?: number +} + +export type GitStatusSummaryV2 = { + files: GitFileEntryV2[] + notAdded: string[] + ignored: string[] + branch: GitBranchInfo +} + +export type DiffFileStat = { + file: string + changes: number + insertions: number + deletions: number + binary: boolean +} + +export type DiffSummary = { + files: DiffFileStat[] + insertions: number + deletions: number + changes: number + changed: number +} + +const BRANCH_OID_REGEX = /^# branch\.oid (.+)$/ +const BRANCH_HEAD_REGEX = /^# branch\.head (.+)$/ +const BRANCH_UPSTREAM_REGEX = /^# branch\.upstream (.+)$/ +const BRANCH_AB_REGEX = /^# branch\.ab \+(\d+) -(\d+)$/ + +const ORDINARY_CHANGE_REGEX = /^1 (.)(.) (.{4}) (\d{6}) (\d{6}) (\d{6}) ([0-9a-f]+) ([0-9a-f]+) (.+)$/ +const RENAME_COPY_REGEX = /^2 (.)(.) (.{4}) (\d{6}) (\d{6}) (\d{6}) ([0-9a-f]+) ([0-9a-f]+) ([RC])(\d{1,3}) (.+)\t(.+)$/ +const UNMERGED_REGEX = /^u (.)(.) (.{4}) (\d{6}) (\d{6}) (\d{6}) (\d{6}) ([0-9a-f]+) ([0-9a-f]+) ([0-9a-f]+) (.+)$/ +const UNTRACKED_REGEX = /^\? (.+)$/ +const IGNORED_REGEX = /^! (.+)$/ + +const NUMSTAT_REGEX = /^(\d+|-)\t(\d+|-)\t(.*)$/ + +export function parseStatusSummaryV2(statusOutput: string): GitStatusSummaryV2 { + const lines = statusOutput.trim().split('\n').filter((line) => line.length > 0) + + const result: GitStatusSummaryV2 = { + files: [], + notAdded: [], + ignored: [], + branch: {} + } + + for (const line of lines) { + if (line.startsWith('# branch.oid ')) { + const match = BRANCH_OID_REGEX.exec(line) + if (match) result.branch.oid = match[1] + continue + } + if (line.startsWith('# branch.head ')) { + const match = BRANCH_HEAD_REGEX.exec(line) + if (match) result.branch.head = match[1] + continue + } + if (line.startsWith('# branch.upstream ')) { + const match = BRANCH_UPSTREAM_REGEX.exec(line) + if (match) result.branch.upstream = match[1] + continue + } + if (line.startsWith('# branch.ab ')) { + const match = BRANCH_AB_REGEX.exec(line) + if (match) { + result.branch.ahead = parseInt(match[1], 10) + result.branch.behind = parseInt(match[2], 10) + } + continue + } + + if (line.startsWith('1 ')) { + const match = ORDINARY_CHANGE_REGEX.exec(line) + if (match) { + const entry = parseOrdinaryChange(match) + if (entry) result.files.push(entry) + } + continue + } + + if (line.startsWith('2 ')) { + const match = RENAME_COPY_REGEX.exec(line) + if (match) { + const entry = parseRenameCopy(match) + if (entry) result.files.push(entry) + } + continue + } + + if (line.startsWith('u ')) { + const match = UNMERGED_REGEX.exec(line) + if (match) { + const entry = parseUnmerged(match) + if (entry) result.files.push(entry) + } + continue + } + + if (line.startsWith('? ')) { + const match = UNTRACKED_REGEX.exec(line) + if (match) result.notAdded.push(match[1]) + continue + } + + if (line.startsWith('! ')) { + const match = IGNORED_REGEX.exec(line) + if (match) result.ignored.push(match[1]) + } + } + + return result +} + +export function parseNumStat(numStatOutput: string): DiffSummary { + const lines = numStatOutput.trim().split('\n').filter((line) => line.length > 0) + + const result: DiffSummary = { + files: [], + insertions: 0, + deletions: 0, + changes: 0, + changed: 0 + } + + for (const line of lines) { + const match = NUMSTAT_REGEX.exec(line) + if (!match) continue + const insertionsStr = match[1] + const deletionsStr = match[2] + const file = match[3] + + const isBinary = insertionsStr === '-' || deletionsStr === '-' + const insertions = isBinary ? 0 : parseInt(insertionsStr, 10) + const deletions = isBinary ? 0 : parseInt(deletionsStr, 10) + const changes = insertions + deletions + + result.files.push({ + file, + changes, + insertions, + deletions, + binary: isBinary + }) + result.insertions += insertions + result.deletions += deletions + result.changes += changes + result.changed += 1 + } + + return result +} + +export function createDiffStatsMap(summary: DiffSummary): Record { + const stats: Record = {} + + for (const file of summary.files) { + const paths = normalizeNumstatPath(file.file) + const stat = { + added: file.insertions, + removed: file.deletions, + binary: file.binary + } + stats[file.file] = stat + if (paths.newPath && paths.newPath !== file.file) { + stats[paths.newPath] = stat + } + if (paths.oldPath && paths.oldPath !== file.file && paths.oldPath !== paths.newPath) { + stats[paths.oldPath] = stat + } + } + + return stats +} + +export function getCurrentBranchV2(summary: GitStatusSummaryV2): string | null { + const head = summary.branch.head + if (!head || head === '(detached)' || head === '(initial)') return null + return head +} + +export function buildGitStatusFiles( + statusOutput: string, + unstagedDiffOutput: string, + stagedDiffOutput: string +): GitStatusFiles { + const statusSummary = parseStatusSummaryV2(statusOutput) + const branchName = getCurrentBranchV2(statusSummary) + + const unstagedDiff = parseNumStat(unstagedDiffOutput) + const stagedDiff = parseNumStat(stagedDiffOutput) + const unstagedStats = createDiffStatsMap(unstagedDiff) + const stagedStats = createDiffStatsMap(stagedDiff) + + const stagedFiles: GitFileStatus[] = [] + const unstagedFiles: GitFileStatus[] = [] + + for (const file of statusSummary.files) { + const parts = file.path.split('/') + const fileName = parts[parts.length - 1] || file.path + const filePath = parts.slice(0, -1).join('/') + + if (file.index !== ' ' && file.index !== '.' && file.index !== '?') { + const status = getFileStatus(file.index) + const stats = stagedStats[file.path] ?? { added: 0, removed: 0, binary: false } + stagedFiles.push({ + fileName, + filePath, + fullPath: file.path, + status, + isStaged: true, + linesAdded: stats.added, + linesRemoved: stats.removed, + oldPath: file.from + }) + } + + if (file.workingDir !== ' ' && file.workingDir !== '.') { + const status = getFileStatus(file.workingDir) + const stats = unstagedStats[file.path] ?? { added: 0, removed: 0, binary: false } + unstagedFiles.push({ + fileName, + filePath, + fullPath: file.path, + status, + isStaged: false, + linesAdded: stats.added, + linesRemoved: stats.removed, + oldPath: file.from + }) + } + } + + for (const untrackedPath of statusSummary.notAdded) { + const cleanPath = untrackedPath.endsWith('/') ? untrackedPath.slice(0, -1) : untrackedPath + const parts = cleanPath.split('/') + const fileName = parts[parts.length - 1] || cleanPath + const filePath = parts.slice(0, -1).join('/') + + if (untrackedPath.endsWith('/')) { + continue + } + + unstagedFiles.push({ + fileName, + filePath, + fullPath: cleanPath, + status: 'untracked', + isStaged: false, + linesAdded: 0, + linesRemoved: 0 + }) + } + + return { + stagedFiles, + unstagedFiles, + branch: branchName, + totalStaged: stagedFiles.length, + totalUnstaged: unstagedFiles.length + } +} + +function parseOrdinaryChange(matches: string[]): GitFileEntryV2 | null { + if (!matches[1] || !matches[2] || !matches[9]) return null + return { + index: matches[1], + workingDir: matches[2], + path: matches[9] + } +} + +function parseRenameCopy(matches: string[]): GitFileEntryV2 | null { + if (!matches[1] || !matches[2] || !matches[11] || !matches[12]) return null + return { + index: matches[1], + workingDir: matches[2], + from: matches[11], + path: matches[12] + } +} + +function parseUnmerged(matches: string[]): GitFileEntryV2 | null { + if (!matches[1] || !matches[2] || !matches[11]) return null + return { + index: matches[1], + workingDir: matches[2], + path: matches[11] + } +} + +function getFileStatus(statusChar: string): GitFileStatus['status'] { + switch (statusChar) { + case 'M': + return 'modified' + case 'A': + return 'added' + case 'D': + return 'deleted' + case 'R': + case 'C': + return 'renamed' + case '?': + return 'untracked' + case 'U': + return 'conflicted' + default: + return 'modified' + } +} + +function normalizeNumstatPath(rawPath: string): { newPath: string; oldPath?: string } { + const trimmed = rawPath.trim() + if (trimmed.includes('{') && trimmed.includes('=>') && trimmed.includes('}')) { + const newPath = trimmed.replace(/\{([^{}]+?)\s*=>\s*([^{}]+?)\}/g, (_, oldPart: string, newPart: string) => newPart.trim()) + const oldPath = trimmed.replace(/\{([^{}]+?)\s*=>\s*([^{}]+?)\}/g, (_, oldPart: string) => oldPart.trim()) + return { newPath, oldPath } + } + + if (trimmed.includes('=>')) { + const parts = trimmed.split(/\s*=>\s*/) + const oldPath = parts[0]?.trim() + const newPath = parts[parts.length - 1]?.trim() + if (newPath) { + return { newPath, oldPath } + } + } + + return { newPath: trimmed } +} diff --git a/web/src/lib/query-keys.ts b/web/src/lib/query-keys.ts index d0a95d56..2d1a0e1b 100644 --- a/web/src/lib/query-keys.ts +++ b/web/src/lib/query-keys.ts @@ -3,4 +3,13 @@ export const queryKeys = { session: (sessionId: string) => ['session', sessionId] as const, messages: (sessionId: string) => ['messages', sessionId] as const, machines: ['machines'] as const, + gitStatus: (sessionId: string) => ['git-status', sessionId] as const, + sessionFiles: (sessionId: string, query: string) => ['session-files', sessionId, query] as const, + sessionFile: (sessionId: string, path: string) => ['session-file', sessionId, path] as const, + gitFileDiff: (sessionId: string, path: string, staged?: boolean) => [ + 'git-file-diff', + sessionId, + path, + staged ? 'staged' : 'unstaged' + ] as const, } diff --git a/web/src/router.tsx b/web/src/router.tsx index 1cc7d0de..933fbf1e 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -21,6 +21,8 @@ import { useSession } from '@/hooks/queries/useSession' import { useSessions } from '@/hooks/queries/useSessions' import { useSendMessage } from '@/hooks/mutations/useSendMessage' import { queryKeys } from '@/lib/query-keys' +import FilesPage from '@/routes/sessions/files' +import FilePage from '@/routes/sessions/file' function SessionsPage() { const { api } = useAppContext() @@ -193,6 +195,26 @@ const sessionRoute = createRoute({ component: SessionPage, }) +const sessionFilesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/sessions/$sessionId/files', + component: FilesPage, +}) + +const sessionFileRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/sessions/$sessionId/file', + validateSearch: (search: Record) => ({ + path: typeof search.path === 'string' ? search.path : '', + staged: search.staged === true || search.staged === 'true' + ? true + : search.staged === false || search.staged === 'false' + ? false + : undefined + }), + component: FilePage, +}) + const machinesRoute = createRoute({ getParentRoute: () => rootRoute, path: '/machines', @@ -209,6 +231,8 @@ export const routeTree = rootRoute.addChildren([ indexRoute, sessionsRoute, sessionRoute, + sessionFilesRoute, + sessionFileRoute, machinesRoute, spawnRoute, ]) diff --git a/web/src/routes/sessions/file.tsx b/web/src/routes/sessions/file.tsx new file mode 100644 index 00000000..4abdd50a --- /dev/null +++ b/web/src/routes/sessions/file.tsx @@ -0,0 +1,252 @@ +import { useEffect, useMemo, useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { useParams, useSearch } from '@tanstack/react-router' +import type { GitCommandResponse } from '@/types/api' +import { FileIcon } from '@/components/FileIcon' +import { useAppContext } from '@/lib/app-context' +import { useAppGoBack } from '@/hooks/useAppGoBack' +import { queryKeys } from '@/lib/query-keys' +import { langAlias, useShikiHighlighter } from '@/lib/shiki' + +function decodeBase64(value: string): { text: string; ok: boolean } { + try { + return { text: atob(value), ok: true } + } catch { + try { + return { text: decodeURIComponent(escape(atob(value))), ok: true } + } catch { + return { text: '', ok: false } + } + } +} + +function decodePath(value: string): string { + if (!value) return '' + const decoded = decodeBase64(value) + return decoded.ok ? decoded.text : value +} + +function BackIcon(props: { className?: string }) { + return ( + + + + ) +} + +function DiffDisplay(props: { diffContent: string }) { + const lines = props.diffContent.split('\n') + + return ( +
+ {lines.map((line, index) => { + const isAdd = line.startsWith('+') && !line.startsWith('+++') + const isRemove = line.startsWith('-') && !line.startsWith('---') + const isHunk = line.startsWith('@@') + const isHeader = line.startsWith('+++') || line.startsWith('---') + + const className = [ + 'whitespace-pre-wrap px-3 py-0.5 text-xs font-mono', + isAdd ? 'bg-[var(--app-diff-added-bg)] text-[var(--app-diff-added-text)]' : '', + isRemove ? 'bg-[var(--app-diff-removed-bg)] text-[var(--app-diff-removed-text)]' : '', + isHunk ? 'bg-[var(--app-subtle-bg)] text-[var(--app-hint)] font-semibold' : '', + isHeader ? 'text-[var(--app-hint)] font-semibold' : '' + ].filter(Boolean).join(' ') + + const style = isAdd + ? { borderLeft: '2px solid var(--app-git-staged-color)' } + : isRemove + ? { borderLeft: '2px solid var(--app-git-deleted-color)' } + : undefined + + return ( +
+ {line || ' '} +
+ ) + })} +
+ ) +} + +function resolveLanguage(path: string): string | undefined { + const parts = path.split('.') + if (parts.length <= 1) return undefined + const ext = parts[parts.length - 1]?.toLowerCase() + if (!ext) return undefined + return langAlias[ext] ?? ext +} + +function isBinaryContent(content: string): boolean { + if (!content) return false + if (content.includes('\0')) return true + const nonPrintable = content.split('').filter((char) => { + const code = char.charCodeAt(0) + return code < 32 && code !== 9 && code !== 10 && code !== 13 + }).length + return nonPrintable / content.length > 0.1 +} + +function extractCommandError(result: GitCommandResponse | undefined): string | null { + if (!result) return null + if (result.success) return null + return result.error ?? result.stderr ?? 'Failed to load diff' +} + +export default function FilePage() { + const { api } = useAppContext() + const goBack = useAppGoBack() + const { sessionId } = useParams({ from: '/sessions/$sessionId/file' }) + const search = useSearch({ from: '/sessions/$sessionId/file' }) + const encodedPath = typeof search.path === 'string' ? search.path : '' + const staged = search.staged + + const filePath = useMemo(() => decodePath(encodedPath), [encodedPath]) + const fileName = filePath.split('/').pop() || filePath || 'File' + + const diffQuery = useQuery({ + queryKey: queryKeys.gitFileDiff(sessionId, filePath, staged), + queryFn: async () => { + if (!api || !sessionId || !filePath) { + throw new Error('Missing session or path') + } + return await api.getGitDiffFile(sessionId, filePath, staged) + }, + enabled: Boolean(api && sessionId && filePath) + }) + + const fileQuery = useQuery({ + queryKey: queryKeys.sessionFile(sessionId, filePath), + queryFn: async () => { + if (!api || !sessionId || !filePath) { + throw new Error('Missing session or path') + } + return await api.readSessionFile(sessionId, filePath) + }, + enabled: Boolean(api && sessionId && filePath) + }) + + const diffContent = diffQuery.data?.success ? (diffQuery.data.stdout ?? '') : '' + const diffError = extractCommandError(diffQuery.data) + const diffSuccess = diffQuery.data?.success === true + const diffFailed = diffQuery.data?.success === false + + const fileContentResult = fileQuery.data + const decodedContentResult = fileContentResult?.success && fileContentResult.content + ? decodeBase64(fileContentResult.content) + : { text: '', ok: true } + const decodedContent = decodedContentResult.text + const binaryFile = fileContentResult?.success + ? !decodedContentResult.ok || isBinaryContent(decodedContent) + : false + + const language = useMemo(() => resolveLanguage(filePath), [filePath]) + const highlighted = useShikiHighlighter(decodedContent, language) + + const [displayMode, setDisplayMode] = useState<'diff' | 'file'>('diff') + + useEffect(() => { + if (diffSuccess && !diffContent) { + setDisplayMode('file') + return + } + if (diffFailed) { + setDisplayMode('file') + } + }, [diffSuccess, diffFailed, diffContent]) + + const loading = diffQuery.isLoading || fileQuery.isLoading + const fileError = fileContentResult && !fileContentResult.success + ? (fileContentResult.error ?? 'Failed to read file') + : null + const missingPath = !filePath + const diffErrorMessage = diffError ? `Diff unavailable: ${diffError}` : null + + return ( +
+
+
+ +
+
{fileName}
+
{filePath || 'Unknown path'}
+
+
+
+ +
+ + {filePath} +
+ + {diffContent ? ( +
+ + +
+ ) : null} + +
+ {diffErrorMessage ? ( +
+ {diffErrorMessage} +
+ ) : null} + {missingPath ? ( +
No file path provided.
+ ) : loading ? ( +
Loading file...
+ ) : fileError ? ( +
{fileError}
+ ) : binaryFile ? ( +
+ This looks like a binary file. It cannot be displayed. +
+ ) : displayMode === 'diff' && diffContent ? ( + + ) : displayMode === 'diff' && diffError ? ( +
{diffError}
+ ) : displayMode === 'file' ? ( + decodedContent ? ( +
+                            {highlighted ?? decodedContent}
+                        
+ ) : ( +
File is empty.
+ ) + ) : ( +
No changes to display.
+ )} +
+
+ ) +} diff --git a/web/src/routes/sessions/files.tsx b/web/src/routes/sessions/files.tsx new file mode 100644 index 00000000..dc4d8b05 --- /dev/null +++ b/web/src/routes/sessions/files.tsx @@ -0,0 +1,375 @@ +import { useCallback, useMemo, useState } from 'react' +import { useNavigate, useParams } from '@tanstack/react-router' +import type { FileSearchItem, GitFileStatus } from '@/types/api' +import { FileIcon } from '@/components/FileIcon' +import { useAppContext } from '@/lib/app-context' +import { useAppGoBack } from '@/hooks/useAppGoBack' +import { useGitStatusFiles } from '@/hooks/queries/useGitStatusFiles' +import { useSession } from '@/hooks/queries/useSession' +import { useSessionFileSearch } from '@/hooks/queries/useSessionFileSearch' + +function encodePath(value: string): string { + try { + return btoa(value) + } catch { + return btoa(unescape(encodeURIComponent(value))) + } +} + +function BackIcon(props: { className?: string }) { + return ( + + + + ) +} + +function RefreshIcon(props: { className?: string }) { + return ( + + + + + ) +} + +function SearchIcon(props: { className?: string }) { + return ( + + + + + ) +} + +function GitBranchIcon(props: { className?: string }) { + return ( + + + + + + + ) +} + +function FolderIcon(props: { className?: string }) { + return ( + + + + ) +} + +function StatusBadge(props: { status: GitFileStatus['status'] }) { + const { label, color } = useMemo(() => { + switch (props.status) { + case 'added': + return { label: 'A', color: 'var(--app-git-staged-color)' } + case 'deleted': + return { label: 'D', color: 'var(--app-git-deleted-color)' } + case 'renamed': + return { label: 'R', color: 'var(--app-git-renamed-color)' } + case 'untracked': + return { label: '?', color: 'var(--app-git-untracked-color)' } + case 'conflicted': + return { label: 'U', color: 'var(--app-git-deleted-color)' } + default: + return { label: 'M', color: 'var(--app-git-unstaged-color)' } + } + }, [props.status]) + + return ( + + {label} + + ) +} + +function LineChanges(props: { added: number; removed: number }) { + if (!props.added && !props.removed) return null + + return ( + + {props.added ? ( + +{props.added} + ) : null} + {props.removed ? ( + -{props.removed} + ) : null} + + ) +} + +function GitFileRow(props: { + file: GitFileStatus + onOpen: () => void + showDivider: boolean +}) { + const subtitle = props.file.filePath || 'project root' + + return ( + + ) +} + +function SearchResultRow(props: { + file: FileSearchItem + onOpen: () => void + showDivider: boolean +}) { + const subtitle = props.file.filePath || 'project root' + const icon = props.file.fileType === 'file' + ? + : + + return ( + + ) +} + +export default function FilesPage() { + const { api } = useAppContext() + const navigate = useNavigate() + const goBack = useAppGoBack() + const { sessionId } = useParams({ from: '/sessions/$sessionId/files' }) + const { session } = useSession(api, sessionId) + const [searchQuery, setSearchQuery] = useState('') + + const { + status: gitStatus, + error: gitError, + isLoading: gitLoading, + refetch: refetchGit + } = useGitStatusFiles(api, sessionId) + + const shouldSearch = Boolean(searchQuery) + || (gitStatus ? (gitStatus.totalStaged === 0 && gitStatus.totalUnstaged === 0) : Boolean(gitError)) + + const searchResults = useSessionFileSearch(api, sessionId, searchQuery, { + enabled: shouldSearch && !gitLoading + }) + + const handleOpenFile = useCallback((path: string, staged?: boolean) => { + const search = staged === undefined + ? { path: encodePath(path) } + : { path: encodePath(path), staged } + navigate({ + to: '/sessions/$sessionId/file', + params: { sessionId }, + search + }) + }, [navigate, sessionId]) + + const branchLabel = gitStatus?.branch ?? 'detached' + const subtitle = session?.metadata?.path ?? sessionId + const showGitErrorBanner = Boolean(gitError) + + return ( +
+
+
+ +
+
Files
+
{subtitle}
+
+ +
+
+ +
+
+ + setSearchQuery(event.target.value)} + placeholder="Search files" + className="w-full bg-transparent text-sm text-[var(--app-fg)] placeholder:text-[var(--app-hint)] focus:outline-none" + autoCapitalize="none" + autoCorrect="off" + /> +
+
+ + {!gitLoading && gitStatus ? ( +
+
+ + {branchLabel} +
+
+ {gitStatus.totalStaged} staged, {gitStatus.totalUnstaged} unstaged +
+
+ ) : null} + +
+ {showGitErrorBanner ? ( +
+ {gitError} +
+ ) : null} + {gitLoading ? ( +
Loading Git status...
+ ) : shouldSearch ? ( + searchResults.isLoading ? ( +
Loading files...
+ ) : searchResults.error ? ( +
{searchResults.error}
+ ) : searchResults.files.length === 0 ? ( +
+ {searchQuery ? 'No files match your search.' : 'No files found in this project.'} +
+ ) : ( +
+ {searchResults.files.map((file, index) => ( + handleOpenFile(file.fullPath)} + showDivider={index < searchResults.files.length - 1} + /> + ))} +
+ ) + ) : ( +
+ {gitStatus?.stagedFiles.length ? ( +
+
+ Staged Changes ({gitStatus.stagedFiles.length}) +
+ {gitStatus.stagedFiles.map((file, index) => ( + handleOpenFile(file.fullPath, file.isStaged)} + showDivider={index < gitStatus.stagedFiles.length - 1 || gitStatus.unstagedFiles.length > 0} + /> + ))} +
+ ) : null} + + {gitStatus?.unstagedFiles.length ? ( +
+
+ Unstaged Changes ({gitStatus.unstagedFiles.length}) +
+ {gitStatus.unstagedFiles.map((file, index) => ( + handleOpenFile(file.fullPath, file.isStaged)} + showDivider={index < gitStatus.unstagedFiles.length - 1} + /> + ))} +
+ ) : null} + + {gitStatus && gitStatus.stagedFiles.length === 0 && gitStatus.unstagedFiles.length === 0 ? ( +
+ No changes detected. Use search to browse files. +
+ ) : null} +
+ )} +
+
+ ) +} diff --git a/web/src/types/api.ts b/web/src/types/api.ts index ed860803..31c9cafb 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -124,6 +124,52 @@ export type SpawnResponse = | { type: 'success'; sessionId: string } | { type: 'error'; message: string } +export type GitCommandResponse = { + success: boolean + stdout?: string + stderr?: string + exitCode?: number + error?: string +} + +export type FileSearchItem = { + fileName: string + filePath: string + fullPath: string + fileType: 'file' | 'folder' +} + +export type FileSearchResponse = { + success: boolean + files?: FileSearchItem[] + error?: string +} + +export type FileReadResponse = { + success: boolean + content?: string + error?: string +} + +export type GitFileStatus = { + fileName: string + filePath: string + fullPath: string + status: 'modified' | 'added' | 'deleted' | 'renamed' | 'untracked' | 'conflicted' + isStaged: boolean + linesAdded: number + linesRemoved: number + oldPath?: string +} + +export type GitStatusFiles = { + stagedFiles: GitFileStatus[] + unstagedFiles: GitFileStatus[] + branch: string | null + totalStaged: number + totalUnstaged: number +} + export type SyncEvent = | { type: 'session-added'; sessionId: string; data?: unknown } | { type: 'session-updated'; sessionId: string; data?: unknown }