Files
agent-ui/server/routes/git.ts
josedario87 6167dfa440 feat: Add project file tree viewer to Git page
Add Files tab with browsable project structure and file content viewer.
New components: ProjectTree for navigation, FileViewer for content display.
Backend endpoints: /api/git/tree and /api/git/file.
2026-02-14 10:51:17 -06:00

477 lines
12 KiB
TypeScript

import { jsonResponse, errorResponse } from '../utils/cors'
import { WORKING_DIR } from '../config'
// Execute git command and return stdout
async function execGit(args: string[]): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const proc = Bun.spawn(['git', ...args], {
cwd: WORKING_DIR,
stdout: 'pipe',
stderr: 'pipe'
})
const stdout = await new Response(proc.stdout).text()
const stderr = await new Response(proc.stderr).text()
const exitCode = await proc.exited
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode }
}
// Parse git status porcelain output
function parseStatus(output: string): { staged: any[]; unstaged: any[]; untracked: string[] } {
const staged: any[] = []
const unstaged: any[] = []
const untracked: string[] = []
if (!output) return { staged, unstaged, untracked }
const lines = output.split('\n').filter(Boolean)
for (const line of lines) {
const indexStatus = line[0]
const workTreeStatus = line[1]
const path = line.slice(3)
// Untracked
if (indexStatus === '?' && workTreeStatus === '?') {
untracked.push(path)
continue
}
// Staged changes
if (indexStatus !== ' ' && indexStatus !== '?') {
staged.push({
path,
status: parseStatusCode(indexStatus),
staged: true
})
}
// Unstaged changes
if (workTreeStatus !== ' ' && workTreeStatus !== '?') {
unstaged.push({
path,
status: parseStatusCode(workTreeStatus),
staged: false
})
}
}
return { staged, unstaged, untracked }
}
function parseStatusCode(code: string): string {
const map: Record<string, string> = {
'M': 'modified',
'A': 'added',
'D': 'deleted',
'R': 'renamed',
'C': 'copied',
'U': 'unmerged',
'?': 'untracked'
}
return map[code] || 'unknown'
}
// GET /api/git/status
export async function handleGitStatus() {
const { stdout: branch } = await execGit(['branch', '--show-current'])
const { stdout: statusOutput, exitCode } = await execGit(['status', '--porcelain', '-uall'])
if (exitCode !== 0) {
return errorResponse('Not a git repository', 400)
}
const { staged, unstaged, untracked } = parseStatus(statusOutput)
// Get ahead/behind info
let ahead = 0
let behind = 0
const { stdout: trackingInfo } = await execGit(['rev-list', '--left-right', '--count', '@{upstream}...HEAD'])
if (trackingInfo) {
const parts = trackingInfo.split('\t')
if (parts.length === 2) {
behind = parseInt(parts[0]) || 0
ahead = parseInt(parts[1]) || 0
}
}
return jsonResponse({
branch,
staged,
unstaged,
untracked,
ahead,
behind
})
}
// GET /api/git/diff
export async function handleGitDiff(url: URL) {
const staged = url.searchParams.get('staged') === 'true'
const file = url.searchParams.get('file')
const args = ['diff']
if (staged) args.push('--cached')
if (file) args.push('--', file)
const { stdout, exitCode } = await execGit(args)
if (exitCode !== 0) {
return errorResponse('Failed to get diff', 400)
}
// Parse diff into structured format
const files = parseDiff(stdout)
return jsonResponse({
raw: stdout,
files
})
}
// Parse unified diff format
function parseDiff(diffOutput: string): any[] {
if (!diffOutput) return []
const files: any[] = []
const fileDiffs = diffOutput.split(/(?=^diff --git)/m).filter(Boolean)
for (const fileDiff of fileDiffs) {
const lines = fileDiff.split('\n')
const headerMatch = lines[0]?.match(/^diff --git a\/(.+) b\/(.+)$/)
if (!headerMatch) continue
const oldPath = headerMatch[1]
const newPath = headerMatch[2]
// Determine status
let status = 'modified'
if (lines.some(l => l.startsWith('new file'))) status = 'added'
if (lines.some(l => l.startsWith('deleted file'))) status = 'deleted'
if (lines.some(l => l.startsWith('rename '))) status = 'renamed'
// Check if binary
const isBinary = lines.some(l => l.includes('Binary files'))
// Parse hunks
const hunks: any[] = []
let currentHunk: any = null
for (const line of lines) {
const hunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/)
if (hunkMatch) {
if (currentHunk) hunks.push(currentHunk)
currentHunk = {
oldStart: parseInt(hunkMatch[1]),
oldLines: parseInt(hunkMatch[2]) || 1,
newStart: parseInt(hunkMatch[3]),
newLines: parseInt(hunkMatch[4]) || 1,
header: hunkMatch[5].trim(),
lines: []
}
continue
}
if (currentHunk && (line.startsWith('+') || line.startsWith('-') || line.startsWith(' '))) {
currentHunk.lines.push({
type: line.startsWith('+') ? 'add' : line.startsWith('-') ? 'delete' : 'context',
content: line.slice(1)
})
}
}
if (currentHunk) hunks.push(currentHunk)
files.push({
path: newPath,
oldPath: oldPath !== newPath ? oldPath : undefined,
status,
isBinary,
hunks
})
}
return files
}
// GET /api/git/log
export async function handleGitLog(url: URL) {
const limit = parseInt(url.searchParams.get('limit') || '50')
const offset = parseInt(url.searchParams.get('offset') || '0')
const author = url.searchParams.get('author')
const since = url.searchParams.get('since')
const args = [
'log',
`--skip=${offset}`,
`-n${limit}`,
'--format=%H|%h|%an|%ae|%at|%s'
]
if (author) args.push(`--author=${author}`)
if (since) args.push(`--since=${since}`)
const { stdout, exitCode } = await execGit(args)
if (exitCode !== 0) {
return errorResponse('Failed to get log', 400)
}
if (!stdout) {
return jsonResponse([])
}
const commits = stdout.split('\n').filter(Boolean).map(line => {
const [sha, shortSha, author, email, timestamp, message] = line.split('|')
return {
sha,
shortSha,
author,
email,
timestamp: parseInt(timestamp),
message
}
})
return jsonResponse(commits)
}
// GET /api/git/log/:sha
export async function handleGitLogCommit(sha: string) {
const { stdout: details, exitCode } = await execGit([
'show',
sha,
'--format=%H|%h|%an|%ae|%at|%s%n%b',
'--stat'
])
if (exitCode !== 0) {
return errorResponse('Commit not found', 404)
}
const lines = details.split('\n')
const [sha_, shortSha, author, email, timestamp, message] = lines[0].split('|')
// Get body (everything between first line and stats)
let body = ''
let statsStart = lines.findIndex(l => l.match(/^\s+\d+ files? changed/))
if (statsStart === -1) statsStart = lines.length
body = lines.slice(1, statsStart).join('\n').trim()
// Get diff for this commit
const { stdout: diffOutput } = await execGit(['show', sha, '--format='])
const files = parseDiff(diffOutput)
return jsonResponse({
sha: sha_,
shortSha,
author,
email,
timestamp: parseInt(timestamp),
message,
body,
files
})
}
// POST /api/git/compare
export async function handleGitCompare(req: Request) {
const body = await req.json()
const { base, head } = body
if (!base || !head) {
return errorResponse('base and head are required', 400)
}
const { stdout, exitCode, stderr } = await execGit(['diff', `${base}...${head}`])
if (exitCode !== 0) {
return errorResponse(stderr || 'Failed to compare', 400)
}
const files = parseDiff(stdout)
// Calculate stats
let additions = 0
let deletions = 0
for (const file of files) {
for (const hunk of file.hunks || []) {
for (const line of hunk.lines || []) {
if (line.type === 'add') additions++
if (line.type === 'delete') deletions++
}
}
}
return jsonResponse({
base,
head,
files,
stats: {
filesChanged: files.length,
additions,
deletions
}
})
}
// GET /api/git/branches
export async function handleGitBranches() {
const { stdout, exitCode } = await execGit(['branch', '-a', '--format=%(refname:short)|%(HEAD)'])
if (exitCode !== 0) {
return errorResponse('Failed to get branches', 400)
}
if (!stdout) {
return jsonResponse([])
}
const branches = stdout.split('\n').filter(Boolean).map(line => {
const [name, isCurrent] = line.split('|')
return {
name: name.trim(),
isCurrent: isCurrent === '*',
isRemote: name.startsWith('origin/')
}
})
return jsonResponse(branches)
}
// GET /api/git/branch/current
export async function handleGitCurrentBranch() {
const { stdout, exitCode } = await execGit(['branch', '--show-current'])
if (exitCode !== 0) {
return errorResponse('Not a git repository', 400)
}
return jsonResponse({ branch: stdout })
}
// GET /api/git/tree - Get project file tree
export async function handleGitTree(url: URL) {
// Use git ls-tree for tracked files, combined with untracked
const { stdout: trackedOutput, exitCode } = await execGit([
'ls-tree', '-r', '--name-only', 'HEAD'
])
if (exitCode !== 0) {
return errorResponse('Failed to get file tree', 400)
}
// Get untracked files too
const { stdout: untrackedOutput } = await execGit([
'ls-files', '--others', '--exclude-standard'
])
const trackedFiles = trackedOutput.split('\n').filter(Boolean)
const untrackedFiles = untrackedOutput.split('\n').filter(Boolean)
const allFiles = [...new Set([...trackedFiles, ...untrackedFiles])].sort()
// Build tree structure
const tree = buildFileTree(allFiles)
return jsonResponse(tree)
}
// Build hierarchical tree from flat file paths
function buildFileTree(files: string[]): any[] {
const root: any = { children: {} }
// Build nested structure
for (const filePath of files) {
const parts = filePath.split('/')
let current = root
for (let i = 0; i < parts.length; i++) {
const name = parts[i]
const isFile = i === parts.length - 1
const currentPath = parts.slice(0, i + 1).join('/')
if (!current.children[name]) {
current.children[name] = {
name,
path: currentPath,
type: isFile ? 'file' : 'directory',
children: isFile ? undefined : {}
}
}
current = current.children[name]
}
}
// Convert to array format recursively
function toArray(node: any): any[] {
if (!node.children) return []
const items = Object.values(node.children) as any[]
// Convert children objects to arrays
for (const item of items) {
if (item.type === 'directory' && item.children) {
item.children = toArray(item)
}
}
// Sort: directories first, then alphabetically
return items.sort((a, b) => {
if (a.type !== b.type) {
return a.type === 'directory' ? -1 : 1
}
return a.name.localeCompare(b.name)
})
}
return toArray(root)
}
// GET /api/git/file - Get file content
export async function handleGitFile(url: URL) {
const filePath = url.searchParams.get('path')
if (!filePath) {
return errorResponse('path is required', 400)
}
// Security: prevent directory traversal
if (filePath.includes('..') || filePath.startsWith('/')) {
return errorResponse('Invalid path', 400)
}
const fullPath = `${WORKING_DIR}/${filePath}`
try {
const file = Bun.file(fullPath)
const exists = await file.exists()
if (!exists) {
return errorResponse('File not found', 404)
}
// Check if binary
const content = await file.text()
const isBinary = /[\x00-\x08\x0E-\x1F]/.test(content.slice(0, 1000))
if (isBinary) {
return jsonResponse({
path: filePath,
isBinary: true,
content: null,
size: file.size
})
}
// Get file extension for syntax highlighting hint
const ext = filePath.split('.').pop()?.toLowerCase() || ''
return jsonResponse({
path: filePath,
isBinary: false,
content,
size: file.size,
extension: ext
})
} catch (e: any) {
return errorResponse(e.message, 500)
}
}