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

@@ -0,0 +1,308 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { FileContent } from '@/types/git'
const props = defineProps<{
file: FileContent | null
loading?: boolean
}>()
const emit = defineEmits<{
close: []
}>()
// Get language for syntax highlighting hint
const language = computed(() => {
if (!props.file?.extension) return 'text'
const langMap: Record<string, string> = {
ts: 'typescript',
tsx: 'typescript',
js: 'javascript',
jsx: 'javascript',
vue: 'vue',
json: 'json',
md: 'markdown',
css: 'css',
scss: 'scss',
html: 'html',
svg: 'xml',
yml: 'yaml',
yaml: 'yaml',
toml: 'toml',
sh: 'bash',
bash: 'bash',
py: 'python',
rs: 'rust',
go: 'go',
sql: 'sql'
}
return langMap[props.file.extension] || 'text'
})
const lines = computed(() => {
if (!props.file?.content) return []
return props.file.content.split('\n')
})
const fileName = computed(() => {
if (!props.file?.path) return ''
return props.file.path.split('/').pop() || props.file.path
})
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
</script>
<template>
<div class="file-viewer">
<div v-if="loading" class="loading-state">
<div class="spinner"></div>
Cargando archivo...
</div>
<template v-else-if="file">
<div class="file-header">
<div class="file-info">
<span class="file-name">{{ fileName }}</span>
<span class="file-path">{{ file.path }}</span>
<span class="file-size">{{ formatSize(file.size) }}</span>
</div>
<button class="close-btn" @click="emit('close')" title="Cerrar">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<div v-if="file.isBinary" class="binary-notice">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
<p>Archivo binario - no se puede mostrar el contenido</p>
<span>{{ formatSize(file.size) }}</span>
</div>
<div v-else class="code-container">
<div class="line-numbers">
<span v-for="(_, i) in lines" :key="i" class="line-number">{{ i + 1 }}</span>
</div>
<pre class="code-content"><code>{{ file.content }}</code></pre>
</div>
</template>
<div v-else class="empty-state">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
<p>Selecciona un archivo para ver su contenido</p>
</div>
</div>
</template>
<style scoped>
.file-viewer {
display: flex;
flex-direction: column;
height: 100%;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
}
.loading-state,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
flex: 1;
padding: 2rem;
color: var(--text-muted);
text-align: center;
}
.empty-state svg {
opacity: 0.5;
}
.empty-state p {
font-size: 14px;
color: var(--text-secondary);
margin: 0;
}
.spinner {
width: 24px;
height: 24px;
border: 2px solid var(--border-color);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.file-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
}
.file-info {
display: flex;
align-items: center;
gap: 1rem;
min-width: 0;
}
.file-name {
font-weight: 600;
font-size: 13px;
color: var(--text-primary);
}
.file-path {
font-size: 12px;
color: var(--text-muted);
font-family: 'SF Mono', 'Fira Code', monospace;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-size {
font-size: 11px;
color: var(--text-muted);
padding: 0.15rem 0.4rem;
background: var(--bg-hover);
border-radius: 4px;
flex-shrink: 0;
}
.close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: transparent;
border: none;
border-radius: 4px;
color: var(--text-muted);
cursor: pointer;
transition: all 0.15s;
}
.close-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.binary-notice {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
flex: 1;
padding: 2rem;
color: var(--text-muted);
text-align: center;
}
.binary-notice p {
font-size: 14px;
color: var(--text-secondary);
margin: 0;
}
.binary-notice span {
font-size: 12px;
}
.code-container {
display: flex;
flex: 1;
overflow: auto;
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
font-size: 13px;
line-height: 1.6;
}
.line-numbers {
display: flex;
flex-direction: column;
padding: 0.75rem 0;
background: var(--bg-secondary);
border-right: 1px solid var(--border-color);
text-align: right;
user-select: none;
flex-shrink: 0;
}
.line-number {
padding: 0 0.75rem;
color: var(--text-muted);
font-size: 12px;
min-width: 3ch;
}
.code-content {
flex: 1;
margin: 0;
padding: 0.75rem 1rem;
overflow-x: auto;
white-space: pre;
color: var(--text-primary);
}
.code-content code {
font-family: inherit;
}
/* Mobile */
@media (max-width: 768px) {
.file-header {
padding: 0.5rem 0.75rem;
}
.file-info {
gap: 0.5rem;
}
.file-name {
font-size: 12px;
}
.file-path {
display: none;
}
.code-container {
font-size: 11px;
line-height: 1.5;
}
.line-number {
font-size: 10px;
padding: 0 0.5rem;
}
.code-content {
padding: 0.5rem;
}
}
</style>

View File

@@ -0,0 +1,290 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { TreeNode } from '@/types/git'
const props = defineProps<{
nodes: TreeNode[]
selectedPath?: string | null
loading?: boolean
}>()
const emit = defineEmits<{
select: [path: string]
}>()
const expandedDirs = ref<Set<string>>(new Set())
function toggleDir(path: string) {
if (expandedDirs.value.has(path)) {
expandedDirs.value.delete(path)
} else {
expandedDirs.value.add(path)
}
expandedDirs.value = new Set(expandedDirs.value)
}
function isExpanded(path: string): boolean {
return expandedDirs.value.has(path)
}
function handleSelect(node: TreeNode) {
if (node.type === 'directory') {
toggleDir(node.path)
} else {
emit('select', node.path)
}
}
function getFileIcon(name: string): string {
const ext = name.split('.').pop()?.toLowerCase() || ''
const icons: Record<string, string> = {
ts: 'ts',
tsx: 'tsx',
js: 'js',
jsx: 'jsx',
vue: 'vue',
json: 'json',
md: 'md',
css: 'css',
scss: 'scss',
html: 'html',
svg: 'svg',
png: 'img',
jpg: 'img',
jpeg: 'img',
gif: 'img',
ico: 'img',
gitignore: 'git',
env: 'env',
yml: 'yml',
yaml: 'yml',
toml: 'toml',
lock: 'lock'
}
return icons[ext] || 'file'
}
</script>
<template>
<div class="project-tree">
<div v-if="loading" class="loading">
<div class="spinner"></div>
Cargando archivos...
</div>
<div v-else-if="nodes.length === 0" class="empty">
Sin archivos
</div>
<template v-else>
<div
v-for="node in nodes"
:key="node.path"
class="tree-node"
>
<div
:class="['node-row', { selected: selectedPath === node.path }]"
@click="handleSelect(node)"
>
<!-- Directory -->
<template v-if="node.type === 'directory'">
<span class="expand-icon">
<svg
:class="{ expanded: isExpanded(node.path) }"
width="12"
height="12"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M8 5l8 7-8 7z" />
</svg>
</span>
<span class="folder-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path v-if="isExpanded(node.path)" d="M19 20H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h6l2 2h6a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2z" />
<path v-else d="M3 6a2 2 0 0 1 2-2h6l2 2h6a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6z" />
</svg>
</span>
</template>
<!-- File -->
<template v-else>
<span class="expand-icon spacer"></span>
<span :class="['file-icon', getFileIcon(node.name)]">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
</span>
</template>
<span class="node-name">{{ node.name }}</span>
</div>
<!-- Recursive children -->
<div v-if="node.type === 'directory' && isExpanded(node.path) && node.children" class="children">
<ProjectTree
:nodes="node.children"
:selected-path="selectedPath"
@select="emit('select', $event)"
/>
</div>
</div>
</template>
</div>
</template>
<style scoped>
.project-tree {
font-size: 13px;
user-select: none;
}
.loading,
.empty {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 1.5rem;
color: var(--text-muted);
font-size: 12px;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid var(--border-color);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.tree-node {
/* Container for each node */
}
.node-row {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.3rem 0.5rem;
cursor: pointer;
border-radius: 4px;
transition: background 0.15s;
}
.node-row:hover {
background: var(--bg-hover);
}
.node-row.selected {
background: rgba(99, 102, 241, 0.15);
}
.expand-icon {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--text-muted);
transition: transform 0.15s;
}
.expand-icon.spacer {
visibility: hidden;
}
.expand-icon svg {
transition: transform 0.15s;
}
.expand-icon svg.expanded {
transform: rotate(90deg);
}
.folder-icon {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: #f59e0b;
}
.file-icon {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--text-muted);
}
/* File type colors */
.file-icon.ts,
.file-icon.tsx {
color: #3178c6;
}
.file-icon.js,
.file-icon.jsx {
color: #f7df1e;
}
.file-icon.vue {
color: #42b883;
}
.file-icon.json {
color: #f5a623;
}
.file-icon.md {
color: #519aba;
}
.file-icon.css,
.file-icon.scss {
color: #563d7c;
}
.file-icon.html {
color: #e34c26;
}
.file-icon.svg,
.file-icon.img {
color: #a855f7;
}
.file-icon.git {
color: #f05032;
}
.file-icon.env {
color: #ecd53f;
}
.file-icon.yml {
color: #cb171e;
}
.file-icon.lock {
color: #6b7280;
}
.node-name {
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.children {
padding-left: 1rem;
}
</style>

View File

@@ -2,3 +2,5 @@ export { default as DiffViewer } from './DiffViewer.vue'
export { default as FileTree } from './FileTree.vue' export { default as FileTree } from './FileTree.vue'
export { default as CommitList } from './CommitList.vue' export { default as CommitList } from './CommitList.vue'
export { default as BranchSelector } from './BranchSelector.vue' export { default as BranchSelector } from './BranchSelector.vue'
export { default as ProjectTree } from './ProjectTree.vue'
export { default as FileViewer } from './FileViewer.vue'

View File

@@ -1,5 +1,5 @@
import { ref } from 'vue' import { ref } from 'vue'
import type { GitStatus, CommitInfo, FileDiff, BranchInfo, CompareResult, DiffResult } from '@/types/git' import type { GitStatus, CommitInfo, FileDiff, BranchInfo, CompareResult, DiffResult, TreeNode, FileContent } from '@/types/git'
const API_BASE = '/api/git' const API_BASE = '/api/git'
@@ -11,6 +11,8 @@ export function useGitApi() {
const diff = ref<DiffResult | null>(null) const diff = ref<DiffResult | null>(null)
const compareResult = ref<CompareResult | null>(null) const compareResult = ref<CompareResult | null>(null)
const selectedCommit = ref<CommitInfo | null>(null) const selectedCommit = ref<CommitInfo | null>(null)
const fileTree = ref<TreeNode[]>([])
const fileContent = ref<FileContent | null>(null)
const loading = ref(false) const loading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
@@ -116,6 +118,42 @@ export function useGitApi() {
compareResult.value = null compareResult.value = null
} }
async function fetchFileTree(path?: string) {
loading.value = true
error.value = null
try {
const params = new URLSearchParams()
if (path) params.set('path', path)
const res = await fetch(`${API_BASE}/tree?${params}`)
if (!res.ok) throw new Error('Failed to fetch file tree')
fileTree.value = await res.json()
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
}
}
async function fetchFileContent(path: string) {
loading.value = true
error.value = null
try {
const params = new URLSearchParams({ path })
const res = await fetch(`${API_BASE}/file?${params}`)
if (!res.ok) throw new Error('Failed to fetch file content')
fileContent.value = await res.json()
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
}
}
function clearFileContent() {
fileContent.value = null
}
return { return {
status, status,
commits, commits,
@@ -124,6 +162,8 @@ export function useGitApi() {
diff, diff,
compareResult, compareResult,
selectedCommit, selectedCommit,
fileTree,
fileContent,
loading, loading,
error, error,
fetchStatus, fetchStatus,
@@ -133,6 +173,9 @@ export function useGitApi() {
fetchBranches, fetchBranches,
compare, compare,
clearDiff, clearDiff,
clearCompare clearCompare,
fetchFileTree,
fetchFileContent,
clearFileContent
} }
} }

View File

@@ -1,9 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue' import { ref, computed, onMounted, watch } from 'vue'
import { useGitApi } from '@/composables/git' import { useGitApi } from '@/composables/git'
import { DiffViewer, FileTree, CommitList, BranchSelector } from '@/components/git' import { DiffViewer, FileTree, CommitList, BranchSelector, ProjectTree, FileViewer } from '@/components/git'
type TabName = 'status' | 'history' | 'compare' type TabName = 'status' | 'history' | 'compare' | 'files'
const { const {
status, status,
@@ -13,6 +13,8 @@ const {
diff, diff,
compareResult, compareResult,
selectedCommit, selectedCommit,
fileTree,
fileContent,
loading, loading,
error, error,
fetchStatus, fetchStatus,
@@ -21,7 +23,11 @@ const {
fetchCommit, fetchCommit,
fetchBranches, fetchBranches,
compare, compare,
clearCompare clearDiff,
clearCompare,
fetchFileTree,
fetchFileContent,
clearFileContent
} = useGitApi() } = useGitApi()
const activeTab = ref<TabName>('status') const activeTab = ref<TabName>('status')
@@ -33,6 +39,9 @@ const expandedFiles = ref<Set<string>>(new Set())
const compareBase = ref('') const compareBase = ref('')
const compareHead = ref('') const compareHead = ref('')
// Files tab state
const selectedFilePath = ref<string | null>(null)
// Load initial data // Load initial data
onMounted(async () => { onMounted(async () => {
await Promise.all([ await Promise.all([
@@ -51,6 +60,8 @@ watch(activeTab, async (tab) => {
await fetchDiff() await fetchDiff()
} else if (tab === 'history') { } else if (tab === 'history') {
await fetchLog(30) await fetchLog(30)
} else if (tab === 'files') {
await fetchFileTree()
} }
}) })
@@ -107,6 +118,18 @@ async function refresh() {
await fetchDiff() await fetchDiff()
} }
// Handle file selection in files tab
async function handleFileTreeSelect(path: string) {
selectedFilePath.value = path
await fetchFileContent(path)
}
// Close file viewer
function closeFileViewer() {
selectedFilePath.value = null
clearFileContent()
}
// Total changes count // Total changes count
const totalChanges = computed(() => { const totalChanges = computed(() => {
if (!status.value) return 0 if (!status.value) return 0
@@ -190,6 +213,15 @@ const totalChanges = computed(() => {
</svg> </svg>
Compare Compare
</button> </button>
<button
:class="['tab', { active: activeTab === 'files' }]"
@click="activeTab = 'files'"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
</svg>
Files
</button>
</div> </div>
<!-- Tab content --> <!-- Tab content -->
@@ -396,6 +428,37 @@ const totalChanges = computed(() => {
<p>Selecciona dos ramas o commits para comparar</p> <p>Selecciona dos ramas o commits para comparar</p>
</div> </div>
</div> </div>
<!-- Files Tab -->
<div v-if="activeTab === 'files'" class="files-tab">
<div class="files-layout">
<div class="file-tree-panel">
<div class="panel-header">
<span>Project Files</span>
<button class="refresh-tree-btn" @click="fetchFileTree" :disabled="loading" title="Refresh">
<svg :class="{ spinning: loading }" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
</button>
</div>
<div class="tree-container">
<ProjectTree
:nodes="fileTree"
:selected-path="selectedFilePath"
:loading="loading && !fileTree.length"
@select="handleFileTreeSelect"
/>
</div>
</div>
<div class="file-content-panel">
<FileViewer
:file="fileContent"
:loading="loading && selectedFilePath !== null"
@close="closeFileViewer"
/>
</div>
</div>
</div>
</div> </div>
<!-- Error message --> <!-- Error message -->
@@ -873,6 +936,78 @@ const totalChanges = computed(() => {
color: #ef4444; color: #ef4444;
} }
/* Files tab */
.files-tab {
height: 100%;
}
.files-layout {
display: grid;
grid-template-columns: 280px 1fr;
gap: 1rem;
height: 100%;
}
.file-tree-panel {
display: flex;
flex-direction: column;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
overflow: hidden;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color);
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
}
.refresh-tree-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background: transparent;
border: none;
border-radius: 4px;
color: var(--text-muted);
cursor: pointer;
transition: all 0.15s;
}
.refresh-tree-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.refresh-tree-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.refresh-tree-btn svg.spinning {
animation: spin 1s linear infinite;
}
.tree-container {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
}
.file-content-panel {
display: flex;
flex-direction: column;
min-height: 0;
}
/* Error message */ /* Error message */
.error-message { .error-message {
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
@@ -893,6 +1028,10 @@ const totalChanges = computed(() => {
.history-layout { .history-layout {
grid-template-columns: 280px 1fr; grid-template-columns: 280px 1fr;
} }
.files-layout {
grid-template-columns: 240px 1fr;
}
} }
@media (max-width: 768px) { @media (max-width: 768px) {
@@ -1071,5 +1210,31 @@ const totalChanges = computed(() => {
padding: 0.5rem; padding: 0.5rem;
font-size: 12px; font-size: 12px;
} }
.files-layout {
display: flex;
flex-direction: column;
gap: 0.5rem;
height: 100%;
}
.file-tree-panel {
max-height: 250px;
flex-shrink: 0;
}
.panel-header {
padding: 0.5rem 0.75rem;
font-size: 12px;
}
.tree-container {
padding: 0.25rem;
}
.file-content-panel {
flex: 1;
min-height: 0;
}
} }
</style> </style>

View File

@@ -68,3 +68,18 @@ export interface CompareResult {
deletions: number deletions: number
} }
} }
export interface TreeNode {
name: string
path: string
type: 'file' | 'directory'
children?: TreeNode[]
}
export interface FileContent {
path: string
isBinary: boolean
content: string | null
size: number
extension?: string
}

View File

@@ -1,9 +1,10 @@
import { jsonResponse, errorResponse } from '../utils/cors' import { jsonResponse, errorResponse } from '../utils/cors'
import { WORKING_DIR } from '../config'
// Execute git command and return stdout // Execute git command and return stdout
async function execGit(args: string[]): Promise<{ stdout: string; stderr: string; exitCode: number }> { async function execGit(args: string[]): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const proc = Bun.spawn(['git', ...args], { const proc = Bun.spawn(['git', ...args], {
cwd: process.cwd(), cwd: WORKING_DIR,
stdout: 'pipe', stdout: 'pipe',
stderr: 'pipe' stderr: 'pipe'
}) })
@@ -344,3 +345,132 @@ export async function handleGitCurrentBranch() {
return jsonResponse({ branch: stdout }) 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 { handleWhisperRoutes } from './whisper'
import { handleRecordingsRoutes } from './recordings' import { handleRecordingsRoutes } from './recordings'
import { handleClaudeStatus } from './claude-status' 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> { export async function handleRequest(req: Request): Promise<Response> {
const url = new URL(req.url) const url = new URL(req.url)
@@ -220,5 +220,13 @@ export async function handleRequest(req: Request): Promise<Response> {
return handleGitCurrentBranch() 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() return notFoundResponse()
} }