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 CommitList } from './CommitList.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 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'
|
||||
|
||||
@@ -11,6 +11,8 @@ export function useGitApi() {
|
||||
const diff = ref<DiffResult | null>(null)
|
||||
const compareResult = ref<CompareResult | null>(null)
|
||||
const selectedCommit = ref<CommitInfo | null>(null)
|
||||
const fileTree = ref<TreeNode[]>([])
|
||||
const fileContent = ref<FileContent | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
@@ -116,6 +118,42 @@ export function useGitApi() {
|
||||
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 {
|
||||
status,
|
||||
commits,
|
||||
@@ -124,6 +162,8 @@ export function useGitApi() {
|
||||
diff,
|
||||
compareResult,
|
||||
selectedCommit,
|
||||
fileTree,
|
||||
fileContent,
|
||||
loading,
|
||||
error,
|
||||
fetchStatus,
|
||||
@@ -133,6 +173,9 @@ export function useGitApi() {
|
||||
fetchBranches,
|
||||
compare,
|
||||
clearDiff,
|
||||
clearCompare
|
||||
clearCompare,
|
||||
fetchFileTree,
|
||||
fetchFileContent,
|
||||
clearFileContent
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -68,3 +68,18 @@ export interface CompareResult {
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user