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:
308
frontend/src/components/git/FileViewer.vue
Normal file
308
frontend/src/components/git/FileViewer.vue
Normal 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>
|
||||||
290
frontend/src/components/git/ProjectTree.vue
Normal file
290
frontend/src/components/git/ProjectTree.vue
Normal 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>
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user