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:
@@ -131,6 +131,15 @@ onMounted(() => {
|
||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
|
||||
</svg>
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink to="/git" class="toolbar-btn" :class="{ active: route.path === '/git' }" title="Git">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" 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>
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-divider"></div>
|
||||
|
||||
263
frontend/src/components/git/BranchSelector.vue
Normal file
263
frontend/src/components/git/BranchSelector.vue
Normal 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>
|
||||
243
frontend/src/components/git/CommitList.vue
Normal file
243
frontend/src/components/git/CommitList.vue
Normal 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>
|
||||
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>
|
||||
199
frontend/src/components/git/FileTree.vue
Normal file
199
frontend/src/components/git/FileTree.vue
Normal 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>
|
||||
4
frontend/src/components/git/index.ts
Normal file
4
frontend/src/components/git/index.ts
Normal 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'
|
||||
Reference in New Issue
Block a user