feat: add git integration with file browsing and diff viewing

Add comprehensive git support across the stack:
- CLI: register git RPC handlers (status, diff numstat, diff file)
- Server: create git routes with proper session path resolution
- Web: add file browser, diff viewer, and git status visualization
- Add FileIcon component and git parser utilities
- Add TanStack Router routes for /files and /file pages
- Add git-themed CSS variables for light and dark modes
This commit is contained in:
weishu
2025-12-20 23:00:26 +08:00
parent 17eeba10d6
commit 8ebd4f6ab9
18 changed files with 1667 additions and 3 deletions
+34
View File
@@ -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<RpcCommandResponse> {
return await this.sessionRpc(sessionId, 'git-status', { cwd }) as RpcCommandResponse
}
async getGitDiffNumstat(sessionId: string, options: { cwd?: string; staged?: boolean }): Promise<RpcCommandResponse> {
return await this.sessionRpc(sessionId, 'git-diff-numstat', options) as RpcCommandResponse
}
async getGitDiffFile(sessionId: string, options: { cwd?: string; filePath: string; staged?: boolean }): Promise<RpcCommandResponse> {
return await this.sessionRpc(sessionId, 'git-diff-file', options) as RpcCommandResponse
}
async readSessionFile(sessionId: string, path: string): Promise<RpcReadFileResponse> {
return await this.sessionRpc(sessionId, 'readFile', { path }) as RpcReadFileResponse
}
async runRipgrep(sessionId: string, args: string[], cwd?: string): Promise<RpcCommandResponse> {
return await this.sessionRpc(sessionId, 'ripgrep', { args, cwd }) as RpcCommandResponse
}
private async sessionRpc(sessionId: string, method: string, params: unknown): Promise<unknown> {
return await this.rpcCall(`${sessionId}:${method}`, params)
}
+183
View File
@@ -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<T>(fn: () => Promise<T>): Promise<T | { success: false; error: string }> {
try {
return await fn()
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
export function createGitRoutes(getSyncEngine: () => SyncEngine | null): Hono<WebAppEnv> {
const app = new Hono<WebAppEnv>()
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
}
+2
View File
@@ -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()