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,9 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
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 {
status,
@@ -13,6 +13,8 @@ const {
diff,
compareResult,
selectedCommit,
fileTree,
fileContent,
loading,
error,
fetchStatus,
@@ -21,7 +23,11 @@ const {
fetchCommit,
fetchBranches,
compare,
clearCompare
clearDiff,
clearCompare,
fetchFileTree,
fetchFileContent,
clearFileContent
} = useGitApi()
const activeTab = ref<TabName>('status')
@@ -33,6 +39,9 @@ const expandedFiles = ref<Set<string>>(new Set())
const compareBase = ref('')
const compareHead = ref('')
// Files tab state
const selectedFilePath = ref<string | null>(null)
// Load initial data
onMounted(async () => {
await Promise.all([
@@ -51,6 +60,8 @@ watch(activeTab, async (tab) => {
await fetchDiff()
} else if (tab === 'history') {
await fetchLog(30)
} else if (tab === 'files') {
await fetchFileTree()
}
})
@@ -107,6 +118,18 @@ async function refresh() {
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
const totalChanges = computed(() => {
if (!status.value) return 0
@@ -190,6 +213,15 @@ const totalChanges = computed(() => {
</svg>
Compare
</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>
<!-- Tab content -->
@@ -396,6 +428,37 @@ const totalChanges = computed(() => {
<p>Selecciona dos ramas o commits para comparar</p>
</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>
<!-- Error message -->
@@ -873,6 +936,78 @@ const totalChanges = computed(() => {
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 {
padding: 0.75rem 1rem;
@@ -893,6 +1028,10 @@ const totalChanges = computed(() => {
.history-layout {
grid-template-columns: 280px 1fr;
}
.files-layout {
grid-template-columns: 240px 1fr;
}
}
@media (max-width: 768px) {
@@ -1071,5 +1210,31 @@ const totalChanges = computed(() => {
padding: 0.5rem;
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>