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,263 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import type { BranchInfo } from '@/types/git'
const props = defineProps<{
branches: BranchInfo[]
selected: string
label?: string
}>()
const emit = defineEmits<{
select: [branch: string]
}>()
const isOpen = ref(false)
const search = ref('')
const dropdownRef = ref<HTMLElement | null>(null)
const filteredBranches = computed(() => {
const q = search.value.toLowerCase()
return props.branches.filter(b => b.name.toLowerCase().includes(q))
})
const localBranches = computed(() => filteredBranches.value.filter(b => !b.isRemote))
const remoteBranches = computed(() => filteredBranches.value.filter(b => b.isRemote))
function selectBranch(name: string) {
emit('select', name)
isOpen.value = false
search.value = ''
}
function handleClickOutside(e: MouseEvent) {
if (dropdownRef.value && !dropdownRef.value.contains(e.target as Node)) {
isOpen.value = false
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
</script>
<template>
<div ref="dropdownRef" class="branch-selector">
<label v-if="label" class="selector-label">{{ label }}</label>
<button class="selector-button" @click="isOpen = !isOpen">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="6" y1="3" x2="6" y2="15" />
<circle cx="18" cy="6" r="3" />
<circle cx="6" cy="18" r="3" />
<path d="M18 9a9 9 0 0 1-9 9" />
</svg>
<span class="selected-branch">{{ selected || 'Select branch' }}</span>
<svg class="chevron" :class="{ open: isOpen }" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
<div v-if="isOpen" class="dropdown">
<input
v-model="search"
type="text"
class="search-input"
placeholder="Filter branches..."
@click.stop
/>
<div class="branch-list">
<div v-if="localBranches.length > 0" class="branch-group">
<div class="group-header">Local</div>
<div
v-for="branch in localBranches"
:key="branch.name"
:class="['branch-item', { current: branch.isCurrent, selected: branch.name === selected }]"
@click="selectBranch(branch.name)"
>
<span v-if="branch.isCurrent" class="current-marker"></span>
{{ branch.name }}
</div>
</div>
<div v-if="remoteBranches.length > 0" class="branch-group">
<div class="group-header">Remote</div>
<div
v-for="branch in remoteBranches"
:key="branch.name"
:class="['branch-item', { selected: branch.name === selected }]"
@click="selectBranch(branch.name)"
>
{{ branch.name }}
</div>
</div>
<div v-if="filteredBranches.length === 0" class="no-results">
No branches found
</div>
</div>
</div>
</div>
</template>
<style scoped>
.branch-selector {
position: relative;
}
.selector-label {
display: block;
font-size: 12px;
color: var(--text-muted);
margin-bottom: 0.25rem;
}
.selector-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-primary);
cursor: pointer;
min-width: 180px;
transition: all 0.15s;
}
.selector-button:hover {
background: var(--bg-hover);
border-color: var(--accent);
}
.selected-branch {
flex: 1;
text-align: left;
font-size: 13px;
}
.chevron {
transition: transform 0.15s;
}
.chevron.open {
transform: rotate(180deg);
}
.dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 0.25rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
z-index: 100;
overflow: hidden;
}
.search-input {
width: 100%;
padding: 0.75rem;
background: transparent;
border: none;
border-bottom: 1px solid var(--border-color);
color: var(--text-primary);
font-size: 13px;
outline: none;
}
.search-input::placeholder {
color: var(--text-muted);
}
.branch-list {
max-height: 300px;
overflow-y: auto;
}
.branch-group {
padding: 0.5rem 0;
}
.group-header {
padding: 0.25rem 0.75rem;
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.branch-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
cursor: pointer;
font-size: 13px;
color: var(--text-secondary);
transition: all 0.15s;
}
.branch-item:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.branch-item.selected {
background: rgba(99, 102, 241, 0.1);
color: var(--accent);
}
.branch-item.current {
color: var(--text-primary);
}
.current-marker {
color: #22c55e;
font-size: 8px;
}
.no-results {
padding: 1rem;
text-align: center;
color: var(--text-muted);
font-size: 13px;
}
@media (max-width: 768px) {
.selector-button {
min-width: 100%;
padding: 0.5rem;
}
.selected-branch {
font-size: 12px;
}
.dropdown {
position: fixed;
top: auto;
bottom: 0;
left: 0;
right: 0;
border-radius: 16px 16px 0 0;
max-height: 60vh;
}
.branch-list {
max-height: 50vh;
}
.branch-item {
padding: 0.75rem 1rem;
}
}
</style>

View File

@@ -0,0 +1,243 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { CommitInfo } from '@/types/git'
const props = defineProps<{
commits: CommitInfo[]
selectedSha?: string | null
loading?: boolean
}>()
const emit = defineEmits<{
select: [sha: string]
loadMore: []
}>()
function formatDate(timestamp: number) {
const date = new Date(timestamp * 1000)
const now = new Date()
const diff = now.getTime() - date.getTime()
// Less than 1 day
if (diff < 86400000) {
const hours = Math.floor(diff / 3600000)
if (hours < 1) {
const mins = Math.floor(diff / 60000)
return mins < 1 ? 'just now' : `${mins}m ago`
}
return `${hours}h ago`
}
// Less than 7 days
if (diff < 604800000) {
const days = Math.floor(diff / 86400000)
return `${days}d ago`
}
// Format as date
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined
})
}
function getInitials(name: string) {
return name
.split(' ')
.map(n => n[0])
.join('')
.toUpperCase()
.slice(0, 2)
}
function getAvatarColor(email: string) {
let hash = 0
for (let i = 0; i < email.length; i++) {
hash = email.charCodeAt(i) + ((hash << 5) - hash)
}
const hue = hash % 360
return `hsl(${hue}, 60%, 45%)`
}
</script>
<template>
<div class="commit-list">
<div v-if="commits.length === 0 && !loading" class="empty-state">
Sin commits
</div>
<div
v-for="commit in commits"
:key="commit.sha"
:class="['commit-item', { selected: selectedSha === commit.sha }]"
@click="emit('select', commit.sha)"
>
<div class="commit-avatar" :style="{ background: getAvatarColor(commit.email) }">
{{ getInitials(commit.author) }}
</div>
<div class="commit-info">
<div class="commit-message">{{ commit.message }}</div>
<div class="commit-meta">
<span class="commit-sha">{{ commit.shortSha }}</span>
<span class="commit-author">{{ commit.author }}</span>
<span class="commit-date">{{ formatDate(commit.timestamp) }}</span>
</div>
</div>
</div>
<div v-if="loading" class="loading">
<div class="spinner"></div>
Cargando...
</div>
<button
v-else-if="commits.length > 0"
class="load-more"
@click="emit('loadMore')"
>
Cargar más
</button>
</div>
</template>
<style scoped>
.commit-list {
display: flex;
flex-direction: column;
}
.empty-state {
color: var(--text-muted);
text-align: center;
padding: 2rem;
}
.commit-item {
display: flex;
gap: 0.75rem;
padding: 0.75rem 1rem;
cursor: pointer;
border-bottom: 1px solid var(--border-color);
transition: background 0.15s;
}
.commit-item:hover {
background: var(--bg-hover);
}
.commit-item.selected {
background: rgba(99, 102, 241, 0.1);
border-left: 2px solid var(--accent);
}
.commit-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 11px;
font-weight: 600;
flex-shrink: 0;
}
.commit-info {
flex: 1;
min-width: 0;
}
.commit-message {
color: var(--text-primary);
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 0.25rem;
}
.commit-meta {
display: flex;
gap: 0.75rem;
font-size: 12px;
color: var(--text-muted);
}
.commit-sha {
font-family: 'SF Mono', 'Fira Code', monospace;
color: var(--accent);
}
.loading {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 1rem;
color: var(--text-muted);
}
.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); }
}
.load-more {
margin: 1rem;
padding: 0.5rem 1rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s;
}
.load-more:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
@media (max-width: 768px) {
.commit-item {
padding: 0.5rem 0.75rem;
gap: 0.5rem;
}
.commit-avatar {
width: 28px;
height: 28px;
font-size: 10px;
}
.commit-message {
font-size: 12px;
}
.commit-meta {
font-size: 11px;
gap: 0.5rem;
flex-wrap: wrap;
}
.commit-meta .commit-author {
display: none;
}
.load-more {
margin: 0.5rem;
padding: 0.4rem 0.75rem;
font-size: 12px;
}
}
</style>

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>

View File

@@ -0,0 +1,199 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { FileChange } from '@/types/git'
const props = defineProps<{
staged: FileChange[]
unstaged: FileChange[]
untracked: string[]
selectedFile?: string | null
}>()
const emit = defineEmits<{
select: [path: string, staged: boolean]
}>()
function getStatusIcon(status: string) {
const icons: Record<string, { icon: string; class: string }> = {
added: { icon: 'A', class: 'status-added' },
modified: { icon: 'M', class: 'status-modified' },
deleted: { icon: 'D', class: 'status-deleted' },
renamed: { icon: 'R', class: 'status-renamed' },
untracked: { icon: '?', class: 'status-untracked' }
}
return icons[status] || { icon: '?', class: '' }
}
const hasChanges = computed(() => {
return props.staged.length > 0 || props.unstaged.length > 0 || props.untracked.length > 0
})
</script>
<template>
<div class="file-tree">
<div v-if="!hasChanges" class="empty-state">
Sin cambios
</div>
<!-- Staged changes -->
<div v-if="staged.length > 0" class="section">
<div class="section-header">
<span class="section-icon staged"></span>
Staged ({{ staged.length }})
</div>
<div
v-for="file in staged"
:key="'staged-' + file.path"
:class="['file-item', { selected: selectedFile === file.path }]"
@click="emit('select', file.path, true)"
>
<span :class="['status-icon', getStatusIcon(file.status).class]">
{{ getStatusIcon(file.status).icon }}
</span>
<span class="file-name">{{ file.path }}</span>
</div>
</div>
<!-- Unstaged changes -->
<div v-if="unstaged.length > 0" class="section">
<div class="section-header">
<span class="section-icon unstaged"></span>
Unstaged ({{ unstaged.length }})
</div>
<div
v-for="file in unstaged"
:key="'unstaged-' + file.path"
:class="['file-item', { selected: selectedFile === file.path }]"
@click="emit('select', file.path, false)"
>
<span :class="['status-icon', getStatusIcon(file.status).class]">
{{ getStatusIcon(file.status).icon }}
</span>
<span class="file-name">{{ file.path }}</span>
</div>
</div>
<!-- Untracked files -->
<div v-if="untracked.length > 0" class="section">
<div class="section-header">
<span class="section-icon untracked"></span>
Untracked ({{ untracked.length }})
</div>
<div
v-for="path in untracked"
:key="'untracked-' + path"
:class="['file-item', { selected: selectedFile === path }]"
@click="emit('select', path, false)"
>
<span :class="['status-icon', 'status-untracked']">?</span>
<span class="file-name">{{ path }}</span>
</div>
</div>
</div>
</template>
<style scoped>
.file-tree {
font-size: 13px;
}
.empty-state {
color: var(--text-muted);
text-align: center;
padding: 1rem;
}
.section {
margin-bottom: 1rem;
}
.section-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0;
color: var(--text-secondary);
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.section-icon {
font-size: 8px;
}
.section-icon.staged {
color: #22c55e;
}
.section-icon.unstaged {
color: #eab308;
}
.section-icon.untracked {
color: var(--text-muted);
}
.file-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.75rem;
cursor: pointer;
border-radius: 4px;
transition: background 0.15s;
}
.file-item:hover {
background: var(--bg-hover);
}
.file-item.selected {
background: var(--accent);
background: rgba(99, 102, 241, 0.15);
}
.status-icon {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 600;
border-radius: 3px;
}
.status-added {
background: rgba(34, 197, 94, 0.2);
color: #22c55e;
}
.status-modified {
background: rgba(234, 179, 8, 0.2);
color: #eab308;
}
.status-deleted {
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
.status-renamed {
background: rgba(168, 85, 247, 0.2);
color: #a855f7;
}
.status-untracked {
background: rgba(161, 161, 170, 0.2);
color: var(--text-muted);
}
.file-name {
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View File

@@ -0,0 +1,4 @@
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'