feat: Add Git page with branch selector, commit history, and diff viewer

Includes FileTree, CommitList, BranchSelector and DiffViewer components,
Git API routes, and mobile keyboard visibility handling for FAB buttons
This commit is contained in:
2026-02-14 05:49:16 -06:00
parent 2133e2d057
commit a856fefd98
18 changed files with 3015 additions and 13 deletions

346
server/routes/git.ts Normal file
View File

@@ -0,0 +1,346 @@
import { jsonResponse, errorResponse } from '../utils/cors'
// 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: process.cwd(),
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 })
}