- Add file watcher on .git directory in terminal server - Broadcast git-change events to connected clients - Frontend auto-refreshes when changes detected - Visual indicator shows realtime connection status
1333 lines
30 KiB
Vue
1333 lines
30 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
|
import { useGitApi } from '@/composables/git'
|
|
import { DiffViewer, FileTree, CommitList, BranchSelector, ProjectTree, FileViewer } from '@/components/git'
|
|
import { endpoints } from '@/config/endpoints'
|
|
|
|
type TabName = 'status' | 'history' | 'compare' | 'files'
|
|
|
|
const {
|
|
status,
|
|
commits,
|
|
branches,
|
|
currentBranch,
|
|
diff,
|
|
compareResult,
|
|
selectedCommit,
|
|
fileTree,
|
|
fileContent,
|
|
loading,
|
|
error,
|
|
fetchStatus,
|
|
fetchDiff,
|
|
fetchLog,
|
|
fetchCommit,
|
|
fetchBranches,
|
|
compare,
|
|
clearDiff,
|
|
clearCompare,
|
|
fetchFileTree,
|
|
fetchFileContent,
|
|
clearFileContent
|
|
} = useGitApi()
|
|
|
|
const activeTab = ref<TabName>('status')
|
|
const selectedFile = ref<string | null>(null)
|
|
const selectedStaged = ref<boolean>(false)
|
|
const expandedFiles = ref<Set<string>>(new Set())
|
|
|
|
// Compare tab state
|
|
const compareBase = ref('')
|
|
const compareHead = ref('')
|
|
|
|
// Files tab state
|
|
const selectedFilePath = ref<string | null>(null)
|
|
|
|
// Realtime WebSocket connection
|
|
let gitSocket: WebSocket | null = null
|
|
const isRealtime = ref(false)
|
|
|
|
function connectGitWatcher() {
|
|
if (gitSocket?.readyState === WebSocket.OPEN) return
|
|
|
|
gitSocket = new WebSocket(endpoints.terminal)
|
|
|
|
gitSocket.onopen = () => {
|
|
isRealtime.value = true
|
|
console.log('[Git] Realtime connected')
|
|
}
|
|
|
|
gitSocket.onmessage = (event) => {
|
|
try {
|
|
const msg = JSON.parse(event.data)
|
|
if (msg.type === 'git-change') {
|
|
console.log('[Git] Change detected, refreshing...')
|
|
refreshCurrentTab()
|
|
}
|
|
} catch {
|
|
// Ignore non-JSON messages
|
|
}
|
|
}
|
|
|
|
gitSocket.onclose = () => {
|
|
isRealtime.value = false
|
|
console.log('[Git] Realtime disconnected, reconnecting...')
|
|
setTimeout(connectGitWatcher, 2000)
|
|
}
|
|
|
|
gitSocket.onerror = () => {
|
|
isRealtime.value = false
|
|
}
|
|
}
|
|
|
|
function disconnectGitWatcher() {
|
|
if (gitSocket) {
|
|
gitSocket.close()
|
|
gitSocket = null
|
|
}
|
|
}
|
|
|
|
async function refreshCurrentTab() {
|
|
if (activeTab.value === 'status') {
|
|
await fetchStatus()
|
|
if (selectedFile.value) {
|
|
await fetchDiff({ file: selectedFile.value, staged: selectedStaged.value })
|
|
}
|
|
} else if (activeTab.value === 'history') {
|
|
await fetchLog(30)
|
|
} else if (activeTab.value === 'files') {
|
|
await fetchFileTree()
|
|
}
|
|
}
|
|
|
|
// Load initial data
|
|
onMounted(async () => {
|
|
await Promise.all([
|
|
fetchStatus(),
|
|
fetchLog(30),
|
|
fetchBranches()
|
|
])
|
|
// Load initial diff
|
|
await fetchDiff()
|
|
// Connect realtime watcher
|
|
connectGitWatcher()
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
disconnectGitWatcher()
|
|
})
|
|
|
|
// Refresh when tab changes
|
|
watch(activeTab, async (tab) => {
|
|
if (tab === 'status') {
|
|
await fetchStatus()
|
|
await fetchDiff()
|
|
} else if (tab === 'history') {
|
|
await fetchLog(30)
|
|
} else if (tab === 'files') {
|
|
await fetchFileTree()
|
|
}
|
|
})
|
|
|
|
// Handle file selection in status tab
|
|
async function handleFileSelect(path: string, staged: boolean) {
|
|
selectedFile.value = path
|
|
selectedStaged.value = staged
|
|
await fetchDiff({ file: path, staged })
|
|
}
|
|
|
|
// Select file from list
|
|
async function selectFile(path: string, staged: boolean) {
|
|
selectedFile.value = path
|
|
selectedStaged.value = staged
|
|
await fetchDiff({ file: path, staged })
|
|
}
|
|
|
|
// Clear selection
|
|
function clearSelection() {
|
|
selectedFile.value = null
|
|
clearDiff()
|
|
}
|
|
|
|
// Handle commit selection in history tab
|
|
async function handleCommitSelect(sha: string) {
|
|
await fetchCommit(sha)
|
|
}
|
|
|
|
// Load more commits
|
|
async function handleLoadMore() {
|
|
await fetchLog(30, commits.value.length, true)
|
|
}
|
|
|
|
// Toggle file expansion in diff viewer
|
|
function toggleFile(path: string) {
|
|
if (expandedFiles.value.has(path)) {
|
|
expandedFiles.value.delete(path)
|
|
} else {
|
|
expandedFiles.value.add(path)
|
|
}
|
|
expandedFiles.value = new Set(expandedFiles.value)
|
|
}
|
|
|
|
// Run comparison
|
|
async function runCompare() {
|
|
if (compareBase.value && compareHead.value) {
|
|
await compare(compareBase.value, compareHead.value)
|
|
}
|
|
}
|
|
|
|
// Refresh status
|
|
async function refresh() {
|
|
await fetchStatus()
|
|
await fetchDiff()
|
|
}
|
|
|
|
// Handle file selection in files tab
|
|
async function handleFileTreeSelect(path: string) {
|
|
selectedFilePath.value = path
|
|
await fetchFileContent(path)
|
|
}
|
|
|
|
// Close file viewer
|
|
function closeFileViewer() {
|
|
selectedFilePath.value = null
|
|
clearFileContent()
|
|
}
|
|
|
|
// Total changes count
|
|
const totalChanges = computed(() => {
|
|
if (!status.value) return 0
|
|
return status.value.staged.length + status.value.unstaged.length + status.value.untracked.length
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="git-page">
|
|
<!-- Sidebar -->
|
|
<aside class="sidebar">
|
|
<div class="sidebar-header">
|
|
<h2>Git</h2>
|
|
<div class="header-actions">
|
|
<span :class="['realtime-indicator', { connected: isRealtime }]" :title="isRealtime ? 'Realtime: connected' : 'Realtime: disconnected'">
|
|
<svg width="8" height="8" viewBox="0 0 8 8"><circle cx="4" cy="4" r="4" fill="currentColor"/></svg>
|
|
</span>
|
|
<button class="refresh-btn" @click="refresh" :disabled="loading" title="Refresh">
|
|
<svg :class="{ spinning: loading }" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="branch-info">
|
|
<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="branch-name">{{ currentBranch || 'No branch' }}</span>
|
|
<span v-if="status?.ahead" class="ahead-behind">
|
|
↑{{ status.ahead }}
|
|
</span>
|
|
<span v-if="status?.behind" class="ahead-behind">
|
|
↓{{ status.behind }}
|
|
</span>
|
|
</div>
|
|
|
|
<div v-if="activeTab === 'status' && status" class="file-tree-container">
|
|
<FileTree
|
|
:staged="status.staged"
|
|
:unstaged="status.unstaged"
|
|
:untracked="status.untracked"
|
|
:selected-file="selectedFile"
|
|
@select="handleFileSelect"
|
|
/>
|
|
</div>
|
|
</aside>
|
|
|
|
<!-- Main content -->
|
|
<main class="main-content">
|
|
<!-- Tab bar -->
|
|
<div class="tab-bar">
|
|
<button
|
|
:class="['tab', { active: activeTab === 'status' }]"
|
|
@click="activeTab = 'status'"
|
|
>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
|
<polyline points="14 2 14 8 20 8" />
|
|
</svg>
|
|
Status
|
|
<span v-if="totalChanges > 0" class="tab-badge">{{ totalChanges }}</span>
|
|
</button>
|
|
<button
|
|
:class="['tab', { active: activeTab === 'history' }]"
|
|
@click="activeTab = 'history'"
|
|
>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<circle cx="12" cy="12" r="10" />
|
|
<polyline points="12 6 12 12 16 14" />
|
|
</svg>
|
|
History
|
|
</button>
|
|
<button
|
|
:class="['tab', { active: activeTab === 'compare' }]"
|
|
@click="activeTab = 'compare'"
|
|
>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<line x1="18" y1="20" x2="18" y2="10" />
|
|
<line x1="12" y1="20" x2="12" y2="4" />
|
|
<line x1="6" y1="20" x2="6" y2="14" />
|
|
</svg>
|
|
Compare
|
|
</button>
|
|
<button
|
|
:class="['tab', { active: activeTab === 'files' }]"
|
|
@click="activeTab = 'files'"
|
|
>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
|
</svg>
|
|
Files
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Tab content -->
|
|
<div class="tab-content">
|
|
<!-- Status Tab -->
|
|
<div v-if="activeTab === 'status'" class="status-tab">
|
|
<div v-if="loading && !diff" class="loading-state">
|
|
<div class="spinner"></div>
|
|
Cargando...
|
|
</div>
|
|
<template v-else-if="status">
|
|
<!-- Staged files -->
|
|
<div v-if="status.staged.length > 0" class="file-section">
|
|
<div class="section-header staged">
|
|
<span class="section-dot"></span>
|
|
<span class="section-label">Staged</span>
|
|
<span class="section-count">{{ status.staged.length }}</span>
|
|
</div>
|
|
<div class="file-list">
|
|
<div
|
|
v-for="file in status.staged"
|
|
:key="'staged-' + file.path"
|
|
:class="['file-row', { selected: selectedFile === file.path && selectedStaged }]"
|
|
@click="selectFile(file.path, true)"
|
|
>
|
|
<span :class="['file-badge', file.status]">{{ file.status[0].toUpperCase() }}</span>
|
|
<span class="file-name">{{ file.path }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Unstaged files -->
|
|
<div v-if="status.unstaged.length > 0" class="file-section">
|
|
<div class="section-header unstaged">
|
|
<span class="section-dot"></span>
|
|
<span class="section-label">Unstaged</span>
|
|
<span class="section-count">{{ status.unstaged.length }}</span>
|
|
</div>
|
|
<div class="file-list">
|
|
<div
|
|
v-for="file in status.unstaged"
|
|
:key="'unstaged-' + file.path"
|
|
:class="['file-row', { selected: selectedFile === file.path && !selectedStaged }]"
|
|
@click="selectFile(file.path, false)"
|
|
>
|
|
<span :class="['file-badge', file.status]">{{ file.status[0].toUpperCase() }}</span>
|
|
<span class="file-name">{{ file.path }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Untracked files -->
|
|
<div v-if="status.untracked.length > 0" class="file-section">
|
|
<div class="section-header untracked">
|
|
<span class="section-dot"></span>
|
|
<span class="section-label">Untracked</span>
|
|
<span class="section-count">{{ status.untracked.length }}</span>
|
|
</div>
|
|
<div class="file-list">
|
|
<div
|
|
v-for="file in status.untracked"
|
|
:key="'untracked-' + file"
|
|
class="file-row untracked-file"
|
|
>
|
|
<span class="file-badge untracked">?</span>
|
|
<span class="file-name">{{ file }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Diff viewer for selected file -->
|
|
<div v-if="selectedFile" class="diff-section">
|
|
<div class="diff-header">
|
|
<span class="diff-file-name">{{ selectedFile }}</span>
|
|
<span class="diff-type">{{ selectedStaged ? '(staged)' : '(unstaged)' }}</span>
|
|
<button class="close-diff" @click="clearSelection">✕</button>
|
|
</div>
|
|
<div v-if="loading" class="diff-loading">
|
|
<div class="spinner"></div>
|
|
Cargando diff...
|
|
</div>
|
|
<div v-else-if="diff?.files?.length">
|
|
<DiffViewer
|
|
:files="diff.files"
|
|
:expanded-files="expandedFiles"
|
|
@toggle-file="toggleFile"
|
|
/>
|
|
</div>
|
|
<div v-else class="diff-empty">
|
|
No hay cambios para mostrar
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Empty state when no changes -->
|
|
<div v-if="!status.staged.length && !status.unstaged.length && !status.untracked.length" class="empty-state">
|
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
|
|
<polyline points="22 4 12 14.01 9 11.01" />
|
|
</svg>
|
|
<p>Working directory clean</p>
|
|
<span>No hay cambios pendientes</span>
|
|
</div>
|
|
</template>
|
|
<div v-else class="empty-state">
|
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
|
|
<polyline points="22 4 12 14.01 9 11.01" />
|
|
</svg>
|
|
<p>Working directory clean</p>
|
|
<span>No hay cambios pendientes</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- History Tab -->
|
|
<div v-if="activeTab === 'history'" class="history-tab">
|
|
<div class="history-layout">
|
|
<div class="commit-list-container">
|
|
<CommitList
|
|
:commits="commits"
|
|
:selected-sha="selectedCommit?.sha"
|
|
:loading="loading"
|
|
@select="handleCommitSelect"
|
|
@load-more="handleLoadMore"
|
|
/>
|
|
</div>
|
|
<div v-if="selectedCommit" class="commit-detail">
|
|
<div class="commit-header">
|
|
<h3>{{ selectedCommit.message }}</h3>
|
|
<div class="commit-meta">
|
|
<span class="sha">{{ selectedCommit.shortSha }}</span>
|
|
<span>{{ selectedCommit.author }}</span>
|
|
<span>{{ new Date(selectedCommit.timestamp * 1000).toLocaleString() }}</span>
|
|
</div>
|
|
<p v-if="selectedCommit.body" class="commit-body">{{ selectedCommit.body }}</p>
|
|
</div>
|
|
<DiffViewer
|
|
v-if="selectedCommit.files?.length"
|
|
:files="selectedCommit.files"
|
|
:expanded-files="expandedFiles"
|
|
@toggle-file="toggleFile"
|
|
/>
|
|
</div>
|
|
<div v-else class="select-commit-hint">
|
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
<circle cx="12" cy="12" r="10" />
|
|
<line x1="12" y1="8" x2="12" y2="12" />
|
|
<line x1="12" y1="16" x2="12.01" y2="16" />
|
|
</svg>
|
|
<p>Selecciona un commit para ver los detalles</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Compare Tab -->
|
|
<div v-if="activeTab === 'compare'" class="compare-tab">
|
|
<div class="compare-header">
|
|
<BranchSelector
|
|
:branches="branches"
|
|
:selected="compareBase"
|
|
label="Base"
|
|
@select="compareBase = $event"
|
|
/>
|
|
<span class="compare-arrow">→</span>
|
|
<BranchSelector
|
|
:branches="branches"
|
|
:selected="compareHead"
|
|
label="Head"
|
|
@select="compareHead = $event"
|
|
/>
|
|
<button
|
|
class="compare-btn"
|
|
:disabled="!compareBase || !compareHead || loading"
|
|
@click="runCompare"
|
|
>
|
|
Comparar
|
|
</button>
|
|
</div>
|
|
|
|
<div v-if="compareResult" class="compare-result">
|
|
<div class="compare-stats">
|
|
<span class="stat">
|
|
<strong>{{ compareResult.stats.filesChanged }}</strong> archivos
|
|
</span>
|
|
<span class="stat additions">
|
|
+{{ compareResult.stats.additions }}
|
|
</span>
|
|
<span class="stat deletions">
|
|
-{{ compareResult.stats.deletions }}
|
|
</span>
|
|
</div>
|
|
<DiffViewer
|
|
:files="compareResult.files"
|
|
:expanded-files="expandedFiles"
|
|
@toggle-file="toggleFile"
|
|
/>
|
|
</div>
|
|
|
|
<div v-else-if="!compareBase || !compareHead" class="compare-hint">
|
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
<line x1="18" y1="20" x2="18" y2="10" />
|
|
<line x1="12" y1="20" x2="12" y2="4" />
|
|
<line x1="6" y1="20" x2="6" y2="14" />
|
|
</svg>
|
|
<p>Selecciona dos ramas o commits para comparar</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Files Tab -->
|
|
<div v-if="activeTab === 'files'" class="files-tab">
|
|
<div class="files-layout">
|
|
<div class="file-tree-panel">
|
|
<div class="panel-header">
|
|
<span>Project Files</span>
|
|
<button class="refresh-tree-btn" @click="fetchFileTree" :disabled="loading" title="Refresh">
|
|
<svg :class="{ spinning: loading }" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div class="tree-container">
|
|
<ProjectTree
|
|
:nodes="fileTree"
|
|
:selected-path="selectedFilePath"
|
|
:loading="loading && !fileTree.length"
|
|
@select="handleFileTreeSelect"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div class="file-content-panel">
|
|
<FileViewer
|
|
:file="fileContent"
|
|
:loading="loading && selectedFilePath !== null"
|
|
@close="closeFileViewer"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Error message -->
|
|
<div v-if="error" class="error-message">
|
|
{{ error }}
|
|
</div>
|
|
</main>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.git-page {
|
|
display: flex;
|
|
flex: 1;
|
|
overflow: hidden;
|
|
background: var(--bg-primary);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
/* Sidebar */
|
|
.sidebar {
|
|
width: 280px;
|
|
background: var(--bg-secondary);
|
|
border-right: 1px solid var(--border-color);
|
|
display: flex;
|
|
flex-direction: column;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.sidebar-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 1rem;
|
|
border-bottom: 1px solid var(--border-color);
|
|
}
|
|
|
|
.sidebar-header h2 {
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
margin: 0;
|
|
}
|
|
|
|
.header-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.realtime-indicator {
|
|
color: var(--text-muted);
|
|
opacity: 0.5;
|
|
transition: all 0.3s;
|
|
}
|
|
|
|
.realtime-indicator.connected {
|
|
color: #22c55e;
|
|
opacity: 1;
|
|
animation: pulse-glow 2s infinite;
|
|
}
|
|
|
|
@keyframes pulse-glow {
|
|
0%, 100% { filter: drop-shadow(0 0 2px currentColor); }
|
|
50% { filter: drop-shadow(0 0 6px currentColor); }
|
|
}
|
|
|
|
.refresh-btn {
|
|
width: 28px;
|
|
height: 28px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: transparent;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 6px;
|
|
color: var(--text-secondary);
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
}
|
|
|
|
.refresh-btn:hover {
|
|
background: var(--bg-hover);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.refresh-btn:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.refresh-btn svg.spinning {
|
|
animation: spin 1s linear infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
.branch-info {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.75rem 1rem;
|
|
background: var(--bg-hover);
|
|
font-size: 13px;
|
|
}
|
|
|
|
.branch-name {
|
|
font-weight: 500;
|
|
}
|
|
|
|
.ahead-behind {
|
|
font-size: 11px;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.file-tree-container {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 0.5rem;
|
|
}
|
|
|
|
/* Main content */
|
|
.main-content {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-width: 0;
|
|
}
|
|
|
|
/* Tab bar */
|
|
.tab-bar {
|
|
display: flex;
|
|
gap: 0.25rem;
|
|
padding: 0.5rem 1rem;
|
|
background: var(--bg-secondary);
|
|
border-bottom: 1px solid var(--border-color);
|
|
}
|
|
|
|
.tab {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.5rem 1rem;
|
|
background: transparent;
|
|
border: none;
|
|
border-radius: 6px;
|
|
color: var(--text-secondary);
|
|
font-size: 13px;
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
}
|
|
|
|
.tab:hover {
|
|
background: var(--bg-hover);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.tab.active {
|
|
background: var(--accent);
|
|
color: white;
|
|
}
|
|
|
|
.tab-badge {
|
|
background: rgba(255, 255, 255, 0.2);
|
|
padding: 0.1rem 0.4rem;
|
|
border-radius: 10px;
|
|
font-size: 11px;
|
|
}
|
|
|
|
.tab.active .tab-badge {
|
|
background: rgba(255, 255, 255, 0.3);
|
|
}
|
|
|
|
/* Tab content */
|
|
.tab-content {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 1rem;
|
|
}
|
|
|
|
/* File sections */
|
|
.file-section {
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.section-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.5rem 0;
|
|
font-size: 12px;
|
|
font-weight: 500;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
color: var(--text-secondary);
|
|
border-bottom: 1px solid var(--border-color);
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
|
|
.section-dot {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
}
|
|
|
|
.section-header.staged .section-dot {
|
|
background: #22c55e;
|
|
}
|
|
|
|
.section-header.unstaged .section-dot {
|
|
background: #eab308;
|
|
}
|
|
|
|
.section-header.untracked .section-dot {
|
|
background: #6b7280;
|
|
}
|
|
|
|
.section-count {
|
|
background: var(--bg-hover);
|
|
padding: 0.1rem 0.4rem;
|
|
border-radius: 10px;
|
|
font-size: 11px;
|
|
}
|
|
|
|
.file-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.file-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.4rem 0.5rem;
|
|
cursor: pointer;
|
|
border-radius: 4px;
|
|
transition: background 0.15s;
|
|
}
|
|
|
|
.file-row:hover {
|
|
background: var(--bg-hover);
|
|
}
|
|
|
|
.file-row.selected {
|
|
background: rgba(99, 102, 241, 0.15);
|
|
}
|
|
|
|
.file-row.untracked-file {
|
|
cursor: default;
|
|
opacity: 0.7;
|
|
}
|
|
|
|
.file-badge {
|
|
width: 18px;
|
|
height: 18px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border-radius: 3px;
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.file-badge.added {
|
|
background: rgba(34, 197, 94, 0.2);
|
|
color: #22c55e;
|
|
}
|
|
|
|
.file-badge.modified {
|
|
background: rgba(234, 179, 8, 0.2);
|
|
color: #eab308;
|
|
}
|
|
|
|
.file-badge.deleted {
|
|
background: rgba(239, 68, 68, 0.2);
|
|
color: #ef4444;
|
|
}
|
|
|
|
.file-badge.renamed {
|
|
background: rgba(168, 85, 247, 0.2);
|
|
color: #a855f7;
|
|
}
|
|
|
|
.file-badge.untracked {
|
|
background: rgba(107, 114, 128, 0.2);
|
|
color: #6b7280;
|
|
}
|
|
|
|
.file-name {
|
|
font-size: 13px;
|
|
font-family: 'SF Mono', 'Fira Code', monospace;
|
|
color: var(--text-primary);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
/* Diff section */
|
|
.diff-section {
|
|
margin-top: 1rem;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.diff-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 0.5rem 0.75rem;
|
|
background: var(--bg-secondary);
|
|
border-bottom: 1px solid var(--border-color);
|
|
}
|
|
|
|
.diff-file-name {
|
|
font-size: 13px;
|
|
font-family: 'SF Mono', 'Fira Code', monospace;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.close-diff {
|
|
background: none;
|
|
border: none;
|
|
color: var(--text-muted);
|
|
cursor: pointer;
|
|
padding: 0.25rem;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.close-diff:hover {
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.diff-type {
|
|
font-size: 11px;
|
|
color: var(--text-muted);
|
|
flex: 1;
|
|
}
|
|
|
|
.diff-loading,
|
|
.diff-empty {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 0.5rem;
|
|
padding: 2rem;
|
|
color: var(--text-muted);
|
|
font-size: 13px;
|
|
}
|
|
|
|
/* Loading and empty states */
|
|
.loading-state,
|
|
.empty-state,
|
|
.select-commit-hint,
|
|
.compare-hint {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 0.75rem;
|
|
padding: 3rem;
|
|
color: var(--text-muted);
|
|
text-align: center;
|
|
}
|
|
|
|
.empty-state svg,
|
|
.select-commit-hint svg,
|
|
.compare-hint svg {
|
|
opacity: 0.5;
|
|
}
|
|
|
|
.empty-state p,
|
|
.select-commit-hint p,
|
|
.compare-hint p {
|
|
font-size: 15px;
|
|
color: var(--text-secondary);
|
|
margin: 0;
|
|
}
|
|
|
|
.empty-state span {
|
|
font-size: 13px;
|
|
}
|
|
|
|
.spinner {
|
|
width: 24px;
|
|
height: 24px;
|
|
border: 2px solid var(--border-color);
|
|
border-top-color: var(--accent);
|
|
border-radius: 50%;
|
|
animation: spin 0.8s linear infinite;
|
|
}
|
|
|
|
/* History tab */
|
|
.history-layout {
|
|
display: grid;
|
|
grid-template-columns: 350px 1fr;
|
|
gap: 1rem;
|
|
height: 100%;
|
|
}
|
|
|
|
.commit-list-container {
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
background: var(--bg-secondary);
|
|
}
|
|
|
|
.commit-detail {
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.commit-header {
|
|
padding: 1rem;
|
|
border-bottom: 1px solid var(--border-color);
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.commit-header h3 {
|
|
margin: 0 0 0.5rem 0;
|
|
font-size: 16px;
|
|
}
|
|
|
|
.commit-meta {
|
|
display: flex;
|
|
gap: 1rem;
|
|
font-size: 12px;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.commit-meta .sha {
|
|
font-family: 'SF Mono', 'Fira Code', monospace;
|
|
color: var(--accent);
|
|
}
|
|
|
|
.commit-body {
|
|
margin-top: 0.75rem;
|
|
color: var(--text-secondary);
|
|
font-size: 13px;
|
|
white-space: pre-wrap;
|
|
}
|
|
|
|
/* Compare tab */
|
|
.compare-header {
|
|
display: flex;
|
|
align-items: flex-end;
|
|
gap: 1rem;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.compare-arrow {
|
|
padding-bottom: 0.5rem;
|
|
color: var(--text-muted);
|
|
font-size: 18px;
|
|
}
|
|
|
|
.compare-btn {
|
|
padding: 0.5rem 1.5rem;
|
|
background: var(--accent);
|
|
border: none;
|
|
border-radius: 6px;
|
|
color: white;
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
}
|
|
|
|
.compare-btn:hover:not(:disabled) {
|
|
filter: brightness(1.1);
|
|
}
|
|
|
|
.compare-btn:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.compare-stats {
|
|
display: flex;
|
|
gap: 1.5rem;
|
|
padding: 0.75rem 1rem;
|
|
background: var(--bg-secondary);
|
|
border-radius: 8px;
|
|
margin-bottom: 1rem;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.stat {
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.stat.additions {
|
|
color: #22c55e;
|
|
}
|
|
|
|
.stat.deletions {
|
|
color: #ef4444;
|
|
}
|
|
|
|
/* Files tab */
|
|
.files-tab {
|
|
height: 100%;
|
|
}
|
|
|
|
.files-layout {
|
|
display: grid;
|
|
grid-template-columns: 280px 1fr;
|
|
gap: 1rem;
|
|
height: 100%;
|
|
}
|
|
|
|
.file-tree-panel {
|
|
display: flex;
|
|
flex-direction: column;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
background: var(--bg-secondary);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.panel-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 0.75rem 1rem;
|
|
border-bottom: 1px solid var(--border-color);
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.refresh-tree-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 24px;
|
|
height: 24px;
|
|
background: transparent;
|
|
border: none;
|
|
border-radius: 4px;
|
|
color: var(--text-muted);
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
}
|
|
|
|
.refresh-tree-btn:hover {
|
|
background: var(--bg-hover);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.refresh-tree-btn:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.refresh-tree-btn svg.spinning {
|
|
animation: spin 1s linear infinite;
|
|
}
|
|
|
|
.tree-container {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 0.5rem;
|
|
}
|
|
|
|
.file-content-panel {
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-height: 0;
|
|
}
|
|
|
|
/* Error message */
|
|
.error-message {
|
|
padding: 0.75rem 1rem;
|
|
background: rgba(239, 68, 68, 0.1);
|
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
|
border-radius: 6px;
|
|
color: #ef4444;
|
|
font-size: 13px;
|
|
margin: 1rem;
|
|
}
|
|
|
|
/* Responsive */
|
|
@media (max-width: 1024px) {
|
|
.sidebar {
|
|
width: 240px;
|
|
}
|
|
|
|
.history-layout {
|
|
grid-template-columns: 280px 1fr;
|
|
}
|
|
|
|
.files-layout {
|
|
grid-template-columns: 240px 1fr;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.git-page {
|
|
flex-direction: column;
|
|
}
|
|
|
|
.sidebar {
|
|
width: 100%;
|
|
flex-shrink: 0;
|
|
border-right: none;
|
|
border-bottom: 1px solid var(--border-color);
|
|
}
|
|
|
|
.sidebar-header {
|
|
padding: 0.5rem 0.75rem;
|
|
}
|
|
|
|
.sidebar-header h2 {
|
|
font-size: 14px;
|
|
}
|
|
|
|
.branch-info {
|
|
padding: 0.4rem 0.75rem;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.file-tree-container {
|
|
display: none;
|
|
}
|
|
|
|
.main-content {
|
|
flex: 1;
|
|
min-height: 0;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.tab-bar {
|
|
padding: 0.4rem 0.5rem;
|
|
gap: 0.25rem;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.tab {
|
|
padding: 0.4rem 0.6rem;
|
|
font-size: 11px;
|
|
gap: 0.35rem;
|
|
}
|
|
|
|
.tab svg {
|
|
width: 12px;
|
|
height: 12px;
|
|
}
|
|
|
|
.tab-badge {
|
|
padding: 0 0.3rem;
|
|
font-size: 10px;
|
|
}
|
|
|
|
.file-section {
|
|
margin-bottom: 0.75rem;
|
|
}
|
|
|
|
.section-header {
|
|
padding: 0.4rem 0;
|
|
font-size: 11px;
|
|
}
|
|
|
|
.file-row {
|
|
padding: 0.35rem 0.4rem;
|
|
}
|
|
|
|
.file-badge {
|
|
width: 16px;
|
|
height: 16px;
|
|
font-size: 10px;
|
|
}
|
|
|
|
.file-name {
|
|
font-size: 12px;
|
|
}
|
|
|
|
.diff-header {
|
|
padding: 0.4rem 0.5rem;
|
|
}
|
|
|
|
.diff-file-name {
|
|
font-size: 11px;
|
|
}
|
|
|
|
.tab-content {
|
|
flex: 1;
|
|
padding: 0.5rem;
|
|
overflow-y: auto;
|
|
min-height: 0;
|
|
}
|
|
|
|
.history-layout {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
height: 100%;
|
|
}
|
|
|
|
.commit-list-container {
|
|
max-height: 200px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.commit-detail {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
min-height: 0;
|
|
}
|
|
|
|
.commit-header {
|
|
padding: 0.5rem;
|
|
}
|
|
|
|
.commit-header h3 {
|
|
font-size: 13px;
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
|
|
.commit-meta {
|
|
gap: 0.5rem;
|
|
font-size: 11px;
|
|
}
|
|
|
|
.compare-header {
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
align-items: stretch;
|
|
}
|
|
|
|
.compare-arrow {
|
|
display: none;
|
|
}
|
|
|
|
.compare-btn {
|
|
width: 100%;
|
|
padding: 0.5rem;
|
|
}
|
|
|
|
.compare-stats {
|
|
padding: 0.5rem;
|
|
gap: 0.5rem;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.empty-state,
|
|
.select-commit-hint,
|
|
.compare-hint {
|
|
padding: 1.5rem 0.75rem;
|
|
}
|
|
|
|
.empty-state svg,
|
|
.select-commit-hint svg,
|
|
.compare-hint svg {
|
|
width: 32px;
|
|
height: 32px;
|
|
}
|
|
|
|
.empty-state p,
|
|
.select-commit-hint p,
|
|
.compare-hint p {
|
|
font-size: 13px;
|
|
}
|
|
|
|
.empty-state span {
|
|
font-size: 12px;
|
|
}
|
|
|
|
.error-message {
|
|
margin: 0.5rem;
|
|
padding: 0.5rem;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.files-layout {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
height: 100%;
|
|
}
|
|
|
|
.file-tree-panel {
|
|
max-height: 250px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.panel-header {
|
|
padding: 0.5rem 0.75rem;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.tree-container {
|
|
padding: 0.25rem;
|
|
}
|
|
|
|
.file-content-panel {
|
|
flex: 1;
|
|
min-height: 0;
|
|
}
|
|
}
|
|
</style>
|