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.
This commit is contained in:
2026-02-14 10:51:17 -06:00
parent a856fefd98
commit 6167dfa440
8 changed files with 968 additions and 7 deletions

View File

@@ -1,9 +1,10 @@
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: process.cwd(),
cwd: WORKING_DIR,
stdout: 'pipe',
stderr: 'pipe'
})
@@ -344,3 +345,132 @@ export async function handleGitCurrentBranch() {
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)
}
}

View File

@@ -10,7 +10,7 @@ import { handleTables, handleStats, handleTableSchema, handleTableData, handleQu
import { handleWhisperRoutes } from './whisper'
import { handleRecordingsRoutes } from './recordings'
import { handleClaudeStatus } from './claude-status'
import { handleGitStatus, handleGitDiff, handleGitLog, handleGitLogCommit, handleGitCompare, handleGitBranches, handleGitCurrentBranch } from './git'
import { handleGitStatus, handleGitDiff, handleGitLog, handleGitLogCommit, handleGitCompare, handleGitBranches, handleGitCurrentBranch, handleGitTree, handleGitFile } from './git'
export async function handleRequest(req: Request): Promise<Response> {
const url = new URL(req.url)
@@ -220,5 +220,13 @@ export async function handleRequest(req: Request): Promise<Response> {
return handleGitCurrentBranch()
}
if (path === '/api/git/tree' && req.method === 'GET') {
return handleGitTree(url)
}
if (path === '/api/git/file' && req.method === 'GET') {
return handleGitFile(url)
}
return notFoundResponse()
}