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:
310
frontend/src/components/git/DiffViewer.vue
Normal file
310
frontend/src/components/git/DiffViewer.vue
Normal 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>
|
||||
Reference in New Issue
Block a user