Files
agent-ui/frontend/src/components/git/DiffViewer.vue
josedario87 a856fefd98 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
2026-02-14 05:49:16 -06:00

311 lines
6.2 KiB
Vue

<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>