mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user