feat: Add Git page with branch selector, commit history, and diff viewer

Includes FileTree, CommitList, BranchSelector and DiffViewer components,
Git API routes, and mobile keyboard visibility handling for FAB buttons
This commit is contained in:
2026-02-14 05:49:16 -06:00
parent 2133e2d057
commit a856fefd98
18 changed files with 3015 additions and 13 deletions

View File

@@ -0,0 +1,310 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { FileDiff, DiffHunk } from '@/types/git'
const props = defineProps<{
files: FileDiff[]
expandedFiles?: Set<string>
}>()
const emit = defineEmits<{
toggleFile: [path: string]
}>()
function isExpanded(path: string): boolean {
return props.expandedFiles?.has(path) ?? true
}
function getStatusBadge(status: string) {
const badges: Record<string, { label: string; class: string }> = {
added: { label: 'A', class: 'badge-added' },
modified: { label: 'M', class: 'badge-modified' },
deleted: { label: 'D', class: 'badge-deleted' },
renamed: { label: 'R', class: 'badge-renamed' }
}
return badges[status] || { label: '?', class: '' }
}
function getLineNumbers(hunk: DiffHunk) {
let oldLine = hunk.oldStart
let newLine = hunk.newStart
return hunk.lines.map(line => {
const result = {
old: line.type === 'add' ? '' : oldLine,
new: line.type === 'delete' ? '' : newLine
}
if (line.type !== 'add') oldLine++
if (line.type !== 'delete') newLine++
return result
})
}
</script>
<template>
<div class="diff-viewer">
<div v-if="files.length === 0" class="empty-state">
No hay cambios para mostrar
</div>
<div v-for="file in files" :key="file.path" class="file-diff">
<div class="file-header" @click="emit('toggleFile', file.path)">
<span :class="['status-badge', getStatusBadge(file.status).class]">
{{ getStatusBadge(file.status).label }}
</span>
<span class="file-path">{{ file.path }}</span>
<span v-if="file.oldPath" class="old-path">
(from {{ file.oldPath }})
</span>
<svg v-if="file.isBinary" class="binary-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="18" rx="2" />
<path d="M8 7v10M12 7v10M16 7v10" />
</svg>
<span class="toggle-icon">
{{ isExpanded(file.path) ? '▼' : '▶' }}
</span>
</div>
<div v-if="isExpanded(file.path) && !file.isBinary" class="file-content">
<div v-for="(hunk, hunkIdx) in file.hunks" :key="hunkIdx" class="hunk">
<div class="hunk-header">
@@ -{{ hunk.oldStart }},{{ hunk.oldLines }} +{{ hunk.newStart }},{{ hunk.newLines }} @@
<span v-if="hunk.header" class="hunk-context">{{ hunk.header }}</span>
</div>
<div class="hunk-lines">
<div
v-for="(line, lineIdx) in hunk.lines"
:key="lineIdx"
:class="['diff-line', `line-${line.type}`]"
>
<span class="line-number old">{{ getLineNumbers(hunk)[lineIdx].old }}</span>
<span class="line-number new">{{ getLineNumbers(hunk)[lineIdx].new }}</span>
<span class="line-prefix">{{ line.type === 'add' ? '+' : line.type === 'delete' ? '-' : ' ' }}</span>
<span class="line-content">{{ line.content }}</span>
</div>
</div>
</div>
</div>
<div v-else-if="file.isBinary" class="binary-notice">
Archivo binario - no se puede mostrar diff
</div>
</div>
</div>
</template>
<style scoped>
.diff-viewer {
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 13px;
line-height: 1.5;
}
.empty-state {
color: var(--text-muted);
text-align: center;
padding: 2rem;
}
.file-diff {
border: 1px solid var(--border-color);
border-radius: 8px;
margin-bottom: 1rem;
overflow: hidden;
}
.file-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: var(--bg-secondary);
cursor: pointer;
user-select: none;
}
.file-header:hover {
background: var(--bg-hover);
}
.status-badge {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
}
.badge-added {
background: rgba(34, 197, 94, 0.2);
color: #22c55e;
}
.badge-modified {
background: rgba(234, 179, 8, 0.2);
color: #eab308;
}
.badge-deleted {
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
.badge-renamed {
background: rgba(168, 85, 247, 0.2);
color: #a855f7;
}
.file-path {
color: var(--text-primary);
flex: 1;
}
.old-path {
color: var(--text-muted);
font-size: 12px;
}
.binary-icon {
color: var(--text-muted);
}
.toggle-icon {
color: var(--text-muted);
font-size: 10px;
}
.file-content {
background: var(--bg-primary);
}
.hunk {
border-top: 1px solid var(--border-color);
}
.hunk-header {
padding: 0.5rem 1rem;
background: var(--bg-hover);
color: var(--accent);
font-size: 12px;
}
.hunk-context {
color: var(--text-muted);
margin-left: 0.5rem;
}
.hunk-lines {
overflow-x: auto;
}
.diff-line {
display: flex;
white-space: pre;
}
.line-number {
min-width: 45px;
padding: 0 0.5rem;
text-align: right;
color: var(--text-muted);
background: var(--bg-secondary);
user-select: none;
border-right: 1px solid var(--border-color);
}
.line-number.old {
border-right: none;
}
.line-prefix {
width: 20px;
text-align: center;
user-select: none;
}
.line-content {
flex: 1;
padding-right: 1rem;
}
.line-context {
background: transparent;
}
.line-add {
background: rgba(34, 197, 94, 0.1);
}
.line-add .line-prefix,
.line-add .line-content {
color: #22c55e;
}
.line-add .line-number.new {
background: rgba(34, 197, 94, 0.15);
}
.line-delete {
background: rgba(239, 68, 68, 0.1);
}
.line-delete .line-prefix,
.line-delete .line-content {
color: #ef4444;
}
.line-delete .line-number.old {
background: rgba(239, 68, 68, 0.15);
}
.binary-notice {
padding: 1rem;
color: var(--text-muted);
text-align: center;
font-style: italic;
}
@media (max-width: 768px) {
.diff-viewer {
font-size: 11px;
}
.file-header {
padding: 0.5rem 0.75rem;
}
.file-path {
font-size: 12px;
word-break: break-all;
}
.status-badge {
width: 18px;
height: 18px;
font-size: 10px;
}
.hunk-header {
padding: 0.4rem 0.75rem;
font-size: 11px;
}
.line-number {
min-width: 32px;
padding: 0 0.25rem;
font-size: 10px;
}
.line-prefix {
width: 16px;
}
.line-content {
padding-right: 0.5rem;
}
}
</style>