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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user