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

@@ -84,6 +84,7 @@ const awaitingPermission = ref(false) // Waiting for user permission (highest pr
const showSessionStart = ref(false) // Session start animation (3s)
const showNotification = ref(false) // Notification pulse (2s)
const showToolFlash = ref(false) // Tool use flash (500ms)
const keyboardVisible = ref(false) // Virtual keyboard visible
let statusWs: WebSocket | null = null
let statusReconnectTimeout: number | null = null
@@ -311,6 +312,16 @@ onMounted(async () => {
}
})
// Detect virtual keyboard on mobile
if (window.visualViewport) {
const initialHeight = window.visualViewport.height
window.visualViewport.addEventListener('resize', () => {
const currentHeight = window.visualViewport!.height
// If viewport shrinks significantly, keyboard is likely open
keyboardVisible.value = currentHeight < initialHeight * 0.75
})
}
// Start polling for token if not connected
const webmcp = getWebMCP()
if (!webmcp?.isConnected) {
@@ -387,7 +398,8 @@ watch(() => route.name, (newPage) => {
'session-start': showSessionStart,
notification: showNotification,
'tool-flash': showToolFlash,
'sheet-open': showTerminal || showVoice
'sheet-open': showTerminal || showVoice,
'keyboard-visible': keyboardVisible
}"
@click="showTerminal = !showTerminal"
:title="awaitingPermission ? `Permiso requerido: ${claudeTool || 'herramienta'}` : isProcessing ? `Claude: ${claudeTool || 'processing'}` : 'Toggle Terminal'"
@@ -460,7 +472,7 @@ watch(() => route.name, (newPage) => {
<!-- Voice FAB Button -->
<button
class="voice-fab"
:class="{ active: showVoice, 'sheet-open': showTerminal || showVoice, 'ptt-active': voicePTTActive }"
:class="{ active: showVoice, 'sheet-open': showTerminal || showVoice, 'ptt-active': voicePTTActive, 'keyboard-visible': keyboardVisible }"
@click="handleVoiceFabClick"
@touchstart="handleVoiceFabTouchStart"
@touchend="handleVoiceFabTouchEnd"
@@ -1014,7 +1026,7 @@ watch(() => route.name, (newPage) => {
@media (max-width: 768px) {
.terminal-fab {
bottom: 16px;
bottom: 80px;
right: 16px;
width: 54px;
height: 54px;
@@ -1038,7 +1050,7 @@ watch(() => route.name, (newPage) => {
}
.voice-fab {
bottom: 16px;
bottom: 80px;
left: 16px;
width: 44px;
height: 44px;
@@ -1055,7 +1067,17 @@ watch(() => route.name, (newPage) => {
.terminal-fab.sheet-open,
.voice-fab.sheet-open {
bottom: calc(10vh + 16px);
bottom: calc(15vh + 100px);
}
.terminal-fab.keyboard-visible,
.voice-fab.keyboard-visible {
bottom: 35vh;
}
.terminal-fab.keyboard-visible.sheet-open,
.voice-fab.keyboard-visible.sheet-open {
bottom: 45vh;
}
}

View File

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

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'

View File

@@ -0,0 +1 @@
export { useGitApi } from './useGitApi'

View File

@@ -0,0 +1,138 @@
import { ref } from 'vue'
import type { GitStatus, CommitInfo, FileDiff, BranchInfo, CompareResult, DiffResult } from '@/types/git'
const API_BASE = '/api/git'
export function useGitApi() {
const status = ref<GitStatus | null>(null)
const commits = ref<CommitInfo[]>([])
const branches = ref<BranchInfo[]>([])
const currentBranch = ref<string>('')
const diff = ref<DiffResult | null>(null)
const compareResult = ref<CompareResult | null>(null)
const selectedCommit = ref<CommitInfo | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
async function fetchStatus() {
loading.value = true
error.value = null
try {
const res = await fetch(`${API_BASE}/status`)
if (!res.ok) throw new Error('Failed to fetch status')
status.value = await res.json()
currentBranch.value = status.value?.branch || ''
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
}
}
async function fetchDiff(options?: { staged?: boolean; file?: string }) {
loading.value = true
error.value = null
try {
const params = new URLSearchParams()
if (options?.staged) params.set('staged', 'true')
if (options?.file) params.set('file', options.file)
const res = await fetch(`${API_BASE}/diff?${params}`)
if (!res.ok) throw new Error('Failed to fetch diff')
diff.value = await res.json()
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
}
}
async function fetchLog(limit = 50, offset = 0, append = false) {
loading.value = true
error.value = null
try {
const res = await fetch(`${API_BASE}/log?limit=${limit}&offset=${offset}`)
if (!res.ok) throw new Error('Failed to fetch log')
const data = await res.json()
commits.value = append ? [...commits.value, ...data] : data
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
}
}
async function fetchCommit(sha: string) {
loading.value = true
error.value = null
try {
const res = await fetch(`${API_BASE}/log/${sha}`)
if (!res.ok) throw new Error('Failed to fetch commit')
selectedCommit.value = await res.json()
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
}
}
async function fetchBranches() {
error.value = null
try {
const res = await fetch(`${API_BASE}/branches`)
if (!res.ok) throw new Error('Failed to fetch branches')
branches.value = await res.json()
} catch (e: any) {
error.value = e.message
}
}
async function compare(base: string, head: string) {
loading.value = true
error.value = null
try {
const res = await fetch(`${API_BASE}/compare`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ base, head })
})
if (!res.ok) {
const err = await res.json()
throw new Error(err.error || 'Failed to compare')
}
compareResult.value = await res.json()
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
}
}
function clearDiff() {
diff.value = null
}
function clearCompare() {
compareResult.value = null
}
return {
status,
commits,
branches,
currentBranch,
diff,
compareResult,
selectedCommit,
loading,
error,
fetchStatus,
fetchDiff,
fetchLog,
fetchCommit,
fetchBranches,
compare,
clearDiff,
clearCompare
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -53,6 +53,11 @@ const router = createRouter({
path: '/tools',
name: 'tools',
component: () => import('../pages/ToolsPage.vue')
},
{
path: '/git',
name: 'git',
component: () => import('../pages/GitPage.vue')
}
]
})

View File

@@ -21,13 +21,14 @@ import {
createSourceCodeHandlers,
createTerminalHandlers,
createResponseHandlers,
createGitHandlers,
type ToolConfig
} from './tools/handlers'
import { setRouter } from './tools/handlers/globalHandlers'
import { setGiteaCredentials, clearGiteaCredentials } from './tools/handlers/sourceCodeHandlers'
import { ALL_TOOL_METAS, getAllToolNames, type ToolCategory } from './tools/toolDefinitions'
export type PageName = 'home' | 'canvas' | 'components' | 'themes' | 'projects' | 'project-canvas' | 'database' | 'source' | 'terminal' | 'tools'
export type PageName = 'home' | 'canvas' | 'components' | 'themes' | 'projects' | 'project-canvas' | 'database' | 'source' | 'terminal' | 'tools' | 'git'
// Internal webmcp functions (not exported for external use)
let webmcpInstance: any = null
@@ -115,7 +116,8 @@ function getToolConfigs(): Map<string, ToolConfig> {
...createProjectCanvasHandlers(),
...createSourceCodeHandlers(),
...createTerminalHandlers(),
...createResponseHandlers()
...createResponseHandlers(),
...createGitHandlers()
]
for (const config of allHandlers) {
@@ -134,7 +136,8 @@ const categoryTools: Record<ToolCategory, string[]> = {
database: ['list_tables', 'get_table_schema', 'get_table_data', 'get_database_stats', 'execute_query'],
source: ['get_repo_info', 'list_repo_files', 'read_repo_file', 'search_repo_code'],
project: ['list_canvases', 'create_canvas', 'get_canvas', 'update_canvas', 'delete_canvas', 'clone_canvas', 'add_component_to_canvas', 'remove_component_from_canvas', 'get_canvas_components'],
terminal: ['terminal_open', 'terminal_close', 'terminal_toggle', 'terminal_move', 'terminal_resize', 'bubbleResponse']
terminal: ['terminal_open', 'terminal_close', 'terminal_toggle', 'terminal_move', 'terminal_resize', 'bubbleResponse'],
git: ['get_git_status', 'get_git_diff', 'compare_commits', 'git_log', 'get_git_branches']
}
// Page to categories mapping
@@ -148,7 +151,8 @@ const pageCategories: Record<PageName, ToolCategory[]> = {
database: ['global', 'database', 'terminal'],
source: ['global', 'source', 'terminal'],
terminal: ['global', 'terminal'],
tools: ['global', 'terminal']
tools: ['global', 'terminal'],
git: ['global', 'git', 'terminal']
}
let currentPage: PageName | null = null

View File

@@ -0,0 +1,273 @@
import type { ToolConfig } from './index'
const API_BASE = ''
export function createGitHandlers(): ToolConfig[] {
return [
{
name: 'get_git_status',
description: 'Obtiene el estado actual del repositorio Git (archivos staged, unstaged, untracked)',
category: 'git' as any,
schema: {
type: 'object',
properties: {}
},
handler: async () => {
try {
const res = await fetch(`${API_BASE}/api/git/status`)
if (!res.ok) {
const err = await res.json()
return `Error: ${err.error}`
}
const status = await res.json()
let result = `Rama: ${status.branch}\n`
if (status.ahead > 0 || status.behind > 0) {
result += `Estado: `
if (status.ahead > 0) result += `${status.ahead} ahead `
if (status.behind > 0) result += `${status.behind} behind`
result += '\n'
}
result += '\n'
if (status.staged.length > 0) {
result += `Staged (${status.staged.length}):\n`
result += status.staged.map((f: any) => ` [${f.status.toUpperCase()[0]}] ${f.path}`).join('\n')
result += '\n\n'
}
if (status.unstaged.length > 0) {
result += `Unstaged (${status.unstaged.length}):\n`
result += status.unstaged.map((f: any) => ` [${f.status.toUpperCase()[0]}] ${f.path}`).join('\n')
result += '\n\n'
}
if (status.untracked.length > 0) {
result += `Untracked (${status.untracked.length}):\n`
result += status.untracked.map((f: string) => ` [?] ${f}`).join('\n')
result += '\n'
}
if (status.staged.length === 0 && status.unstaged.length === 0 && status.untracked.length === 0) {
result += 'Working directory clean - no hay cambios pendientes'
}
return result.trim()
} catch (e: any) {
return `Error: ${e.message}`
}
}
},
{
name: 'get_git_diff',
description: 'Obtiene el diff de cambios no commiteados',
category: 'git' as any,
schema: {
type: 'object',
properties: {
file: {
type: 'string',
description: 'Ruta del archivo especifico para ver diff (opcional)'
},
staged: {
type: 'boolean',
description: 'Si true, muestra solo cambios en staging area'
}
}
},
handler: async (args: { file?: string; staged?: boolean }) => {
try {
const params = new URLSearchParams()
if (args.staged) params.set('staged', 'true')
if (args.file) params.set('file', args.file)
const res = await fetch(`${API_BASE}/api/git/diff?${params}`)
if (!res.ok) {
const err = await res.json()
return `Error: ${err.error}`
}
const data = await res.json()
if (!data.raw || data.raw.trim() === '') {
return 'Sin cambios'
}
// Return raw diff for readability
let result = ''
if (args.file) {
result += `Diff de: ${args.file}\n`
}
if (args.staged) {
result += '(Cambios staged)\n'
}
result += '\n' + data.raw
return result
} catch (e: any) {
return `Error: ${e.message}`
}
}
},
{
name: 'compare_commits',
description: 'Compara dos commits o ramas mostrando las diferencias',
category: 'git' as any,
schema: {
type: 'object',
properties: {
base: {
type: 'string',
description: 'SHA del commit o nombre de rama base (ej: main, HEAD~5)'
},
head: {
type: 'string',
description: 'SHA del commit o nombre de rama a comparar (ej: feature-branch, HEAD)'
}
},
required: ['base', 'head']
},
handler: async (args: { base: string; head: string }) => {
try {
const res = await fetch(`${API_BASE}/api/git/compare`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(args)
})
if (!res.ok) {
const err = await res.json()
return `Error: ${err.error}`
}
const data = await res.json()
let result = `Comparando: ${args.base}...${args.head}\n\n`
result += `Estadisticas:\n`
result += ` Archivos modificados: ${data.stats.filesChanged}\n`
result += ` Lineas agregadas: +${data.stats.additions}\n`
result += ` Lineas eliminadas: -${data.stats.deletions}\n\n`
if (data.files.length > 0) {
result += `Archivos:\n`
result += data.files.map((f: any) => {
const statusMap: Record<string, string> = {
added: 'A',
modified: 'M',
deleted: 'D',
renamed: 'R'
}
return ` [${statusMap[f.status] || '?'}] ${f.path}`
}).join('\n')
}
return result
} catch (e: any) {
return `Error: ${e.message}`
}
}
},
{
name: 'git_log',
description: 'Obtiene el historial de commits del repositorio',
category: 'git' as any,
schema: {
type: 'object',
properties: {
limit: {
type: 'number',
description: 'Numero maximo de commits a retornar (default: 10)'
},
author: {
type: 'string',
description: 'Filtrar commits por autor'
},
since: {
type: 'string',
description: 'Fecha inicial en formato YYYY-MM-DD'
}
}
},
handler: async (args: { limit?: number; author?: string; since?: string }) => {
try {
const params = new URLSearchParams()
params.set('limit', String(args.limit || 10))
if (args.author) params.set('author', args.author)
if (args.since) params.set('since', args.since)
const res = await fetch(`${API_BASE}/api/git/log?${params}`)
if (!res.ok) {
const err = await res.json()
return `Error: ${err.error}`
}
const commits = await res.json()
if (commits.length === 0) {
return 'Sin commits'
}
const result = commits.map((c: any) => {
const date = new Date(c.timestamp * 1000)
const dateStr = date.toLocaleDateString('es-ES', {
year: 'numeric',
month: 'short',
day: 'numeric'
})
return `${c.shortSha} | ${dateStr} | ${c.author}\n ${c.message}`
}).join('\n\n')
return `Ultimos ${commits.length} commits:\n\n${result}`
} catch (e: any) {
return `Error: ${e.message}`
}
}
},
{
name: 'get_git_branches',
description: 'Lista todas las ramas del repositorio',
category: 'git' as any,
schema: {
type: 'object',
properties: {}
},
handler: async () => {
try {
const res = await fetch(`${API_BASE}/api/git/branches`)
if (!res.ok) {
const err = await res.json()
return `Error: ${err.error}`
}
const branches = await res.json()
if (branches.length === 0) {
return 'No hay ramas'
}
const local = branches.filter((b: any) => !b.isRemote)
const remote = branches.filter((b: any) => b.isRemote)
let result = ''
if (local.length > 0) {
result += `Ramas locales:\n`
result += local.map((b: any) => ` ${b.isCurrent ? '* ' : ' '}${b.name}`).join('\n')
result += '\n\n'
}
if (remote.length > 0) {
result += `Ramas remotas:\n`
result += remote.map((b: any) => ` ${b.name}`).join('\n')
}
return result.trim()
} catch (e: any) {
return `Error: ${e.message}`
}
}
}
]
}

View File

@@ -14,13 +14,14 @@ export { createTerminalHandlers, setTerminalControls } from './terminalHandlers'
export type { TerminalControls } from './terminalHandlers'
export { createResponseHandlers, setResponseControls } from './responseHandlers'
export type { ResponseControls } from './responseHandlers'
export { createGitHandlers } from './gitHandlers'
export type ToolHandler = (args: any) => string | Promise<string>
export interface ToolConfig {
name: string
description: string
category: 'global' | 'canvas' | 'component' | 'theme' | 'database' | 'source' | 'project' | 'terminal'
category: 'global' | 'canvas' | 'component' | 'theme' | 'database' | 'source' | 'project' | 'terminal' | 'git'
schema: object
handler: ToolHandler
}

View File

@@ -1,4 +1,4 @@
export type ToolCategory = 'global' | 'canvas' | 'component' | 'theme' | 'database' | 'source' | 'project' | 'terminal'
export type ToolCategory = 'global' | 'canvas' | 'component' | 'theme' | 'database' | 'source' | 'project' | 'terminal' | 'git'
export interface ToolMeta {
name: string
@@ -68,7 +68,14 @@ export const ALL_TOOL_METAS: ToolMeta[] = [
{ name: 'terminal_resize', description: 'Cambia el tamano de la ventana del terminal', category: 'terminal' },
// Response UI tools
{ name: 'bubbleResponse', description: 'Muestra un mensaje del agente en la UI', category: 'terminal' }
{ name: 'bubbleResponse', description: 'Muestra un mensaje del agente en la UI', category: 'terminal' },
// Git tools
{ name: 'get_git_status', description: 'Obtiene el estado actual del repositorio Git', category: 'git' },
{ name: 'get_git_diff', description: 'Obtiene el diff de cambios no commiteados', category: 'git' },
{ name: 'compare_commits', description: 'Compara dos commits o ramas', category: 'git' },
{ name: 'git_log', description: 'Obtiene el historial de commits', category: 'git' },
{ name: 'get_git_branches', description: 'Lista todas las ramas del repositorio', category: 'git' }
]
// Get all tool names
@@ -95,5 +102,6 @@ export const CATEGORY_INFO: Record<ToolCategory, { label: string; color: string;
database: { label: 'Database', color: '#3b82f6', icon: 'M12 2C7 2 3 3.5 3 5v14c0 1.5 4 3 9 3s9-1.5 9-3V5c0-1.5-4-3-9-3z' },
source: { label: 'Source', color: '#8b5cf6', icon: 'M16 18l6-6-6-6M8 6l-6 6 6 6' },
project: { label: 'Project', color: '#06b6d4', icon: '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' },
terminal: { label: 'Terminal', color: '#22c55e', icon: 'M4 17l6-6-6-6M12 19h8' }
terminal: { label: 'Terminal', color: '#22c55e', icon: 'M4 17l6-6-6-6M12 19h8' },
git: { label: 'Git', color: '#f97316', icon: 'M6 3v12M18 9a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM6 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM18 9a9 9 0 0 1-9 9' }
}

70
frontend/src/types/git.ts Normal file
View File

@@ -0,0 +1,70 @@
export interface FileChange {
path: string
status: 'added' | 'modified' | 'deleted' | 'renamed' | 'copied' | 'untracked'
oldPath?: string
staged: boolean
}
export interface GitStatus {
branch: string
staged: FileChange[]
unstaged: FileChange[]
untracked: string[]
ahead: number
behind: number
}
export interface CommitInfo {
sha: string
shortSha: string
author: string
email: string
timestamp: number
message: string
body?: string
files?: FileDiff[]
}
export interface DiffLine {
type: 'context' | 'add' | 'delete'
content: string
}
export interface DiffHunk {
oldStart: number
oldLines: number
newStart: number
newLines: number
header?: string
lines: DiffLine[]
}
export interface FileDiff {
path: string
oldPath?: string
status: 'added' | 'modified' | 'deleted' | 'renamed'
isBinary: boolean
hunks: DiffHunk[]
}
export interface DiffResult {
raw: string
files: FileDiff[]
}
export interface BranchInfo {
name: string
isCurrent: boolean
isRemote: boolean
}
export interface CompareResult {
base: string
head: string
files: FileDiff[]
stats: {
filesChanged: number
additions: number
deletions: number
}
}

346
server/routes/git.ts Normal file
View File

@@ -0,0 +1,346 @@
import { jsonResponse, errorResponse } from '../utils/cors'
// Execute git command and return stdout
async function execGit(args: string[]): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const proc = Bun.spawn(['git', ...args], {
cwd: process.cwd(),
stdout: 'pipe',
stderr: 'pipe'
})
const stdout = await new Response(proc.stdout).text()
const stderr = await new Response(proc.stderr).text()
const exitCode = await proc.exited
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode }
}
// Parse git status porcelain output
function parseStatus(output: string): { staged: any[]; unstaged: any[]; untracked: string[] } {
const staged: any[] = []
const unstaged: any[] = []
const untracked: string[] = []
if (!output) return { staged, unstaged, untracked }
const lines = output.split('\n').filter(Boolean)
for (const line of lines) {
const indexStatus = line[0]
const workTreeStatus = line[1]
const path = line.slice(3)
// Untracked
if (indexStatus === '?' && workTreeStatus === '?') {
untracked.push(path)
continue
}
// Staged changes
if (indexStatus !== ' ' && indexStatus !== '?') {
staged.push({
path,
status: parseStatusCode(indexStatus),
staged: true
})
}
// Unstaged changes
if (workTreeStatus !== ' ' && workTreeStatus !== '?') {
unstaged.push({
path,
status: parseStatusCode(workTreeStatus),
staged: false
})
}
}
return { staged, unstaged, untracked }
}
function parseStatusCode(code: string): string {
const map: Record<string, string> = {
'M': 'modified',
'A': 'added',
'D': 'deleted',
'R': 'renamed',
'C': 'copied',
'U': 'unmerged',
'?': 'untracked'
}
return map[code] || 'unknown'
}
// GET /api/git/status
export async function handleGitStatus() {
const { stdout: branch } = await execGit(['branch', '--show-current'])
const { stdout: statusOutput, exitCode } = await execGit(['status', '--porcelain', '-uall'])
if (exitCode !== 0) {
return errorResponse('Not a git repository', 400)
}
const { staged, unstaged, untracked } = parseStatus(statusOutput)
// Get ahead/behind info
let ahead = 0
let behind = 0
const { stdout: trackingInfo } = await execGit(['rev-list', '--left-right', '--count', '@{upstream}...HEAD'])
if (trackingInfo) {
const parts = trackingInfo.split('\t')
if (parts.length === 2) {
behind = parseInt(parts[0]) || 0
ahead = parseInt(parts[1]) || 0
}
}
return jsonResponse({
branch,
staged,
unstaged,
untracked,
ahead,
behind
})
}
// GET /api/git/diff
export async function handleGitDiff(url: URL) {
const staged = url.searchParams.get('staged') === 'true'
const file = url.searchParams.get('file')
const args = ['diff']
if (staged) args.push('--cached')
if (file) args.push('--', file)
const { stdout, exitCode } = await execGit(args)
if (exitCode !== 0) {
return errorResponse('Failed to get diff', 400)
}
// Parse diff into structured format
const files = parseDiff(stdout)
return jsonResponse({
raw: stdout,
files
})
}
// Parse unified diff format
function parseDiff(diffOutput: string): any[] {
if (!diffOutput) return []
const files: any[] = []
const fileDiffs = diffOutput.split(/(?=^diff --git)/m).filter(Boolean)
for (const fileDiff of fileDiffs) {
const lines = fileDiff.split('\n')
const headerMatch = lines[0]?.match(/^diff --git a\/(.+) b\/(.+)$/)
if (!headerMatch) continue
const oldPath = headerMatch[1]
const newPath = headerMatch[2]
// Determine status
let status = 'modified'
if (lines.some(l => l.startsWith('new file'))) status = 'added'
if (lines.some(l => l.startsWith('deleted file'))) status = 'deleted'
if (lines.some(l => l.startsWith('rename '))) status = 'renamed'
// Check if binary
const isBinary = lines.some(l => l.includes('Binary files'))
// Parse hunks
const hunks: any[] = []
let currentHunk: any = null
for (const line of lines) {
const hunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/)
if (hunkMatch) {
if (currentHunk) hunks.push(currentHunk)
currentHunk = {
oldStart: parseInt(hunkMatch[1]),
oldLines: parseInt(hunkMatch[2]) || 1,
newStart: parseInt(hunkMatch[3]),
newLines: parseInt(hunkMatch[4]) || 1,
header: hunkMatch[5].trim(),
lines: []
}
continue
}
if (currentHunk && (line.startsWith('+') || line.startsWith('-') || line.startsWith(' '))) {
currentHunk.lines.push({
type: line.startsWith('+') ? 'add' : line.startsWith('-') ? 'delete' : 'context',
content: line.slice(1)
})
}
}
if (currentHunk) hunks.push(currentHunk)
files.push({
path: newPath,
oldPath: oldPath !== newPath ? oldPath : undefined,
status,
isBinary,
hunks
})
}
return files
}
// GET /api/git/log
export async function handleGitLog(url: URL) {
const limit = parseInt(url.searchParams.get('limit') || '50')
const offset = parseInt(url.searchParams.get('offset') || '0')
const author = url.searchParams.get('author')
const since = url.searchParams.get('since')
const args = [
'log',
`--skip=${offset}`,
`-n${limit}`,
'--format=%H|%h|%an|%ae|%at|%s'
]
if (author) args.push(`--author=${author}`)
if (since) args.push(`--since=${since}`)
const { stdout, exitCode } = await execGit(args)
if (exitCode !== 0) {
return errorResponse('Failed to get log', 400)
}
if (!stdout) {
return jsonResponse([])
}
const commits = stdout.split('\n').filter(Boolean).map(line => {
const [sha, shortSha, author, email, timestamp, message] = line.split('|')
return {
sha,
shortSha,
author,
email,
timestamp: parseInt(timestamp),
message
}
})
return jsonResponse(commits)
}
// GET /api/git/log/:sha
export async function handleGitLogCommit(sha: string) {
const { stdout: details, exitCode } = await execGit([
'show',
sha,
'--format=%H|%h|%an|%ae|%at|%s%n%b',
'--stat'
])
if (exitCode !== 0) {
return errorResponse('Commit not found', 404)
}
const lines = details.split('\n')
const [sha_, shortSha, author, email, timestamp, message] = lines[0].split('|')
// Get body (everything between first line and stats)
let body = ''
let statsStart = lines.findIndex(l => l.match(/^\s+\d+ files? changed/))
if (statsStart === -1) statsStart = lines.length
body = lines.slice(1, statsStart).join('\n').trim()
// Get diff for this commit
const { stdout: diffOutput } = await execGit(['show', sha, '--format='])
const files = parseDiff(diffOutput)
return jsonResponse({
sha: sha_,
shortSha,
author,
email,
timestamp: parseInt(timestamp),
message,
body,
files
})
}
// POST /api/git/compare
export async function handleGitCompare(req: Request) {
const body = await req.json()
const { base, head } = body
if (!base || !head) {
return errorResponse('base and head are required', 400)
}
const { stdout, exitCode, stderr } = await execGit(['diff', `${base}...${head}`])
if (exitCode !== 0) {
return errorResponse(stderr || 'Failed to compare', 400)
}
const files = parseDiff(stdout)
// Calculate stats
let additions = 0
let deletions = 0
for (const file of files) {
for (const hunk of file.hunks || []) {
for (const line of hunk.lines || []) {
if (line.type === 'add') additions++
if (line.type === 'delete') deletions++
}
}
}
return jsonResponse({
base,
head,
files,
stats: {
filesChanged: files.length,
additions,
deletions
}
})
}
// GET /api/git/branches
export async function handleGitBranches() {
const { stdout, exitCode } = await execGit(['branch', '-a', '--format=%(refname:short)|%(HEAD)'])
if (exitCode !== 0) {
return errorResponse('Failed to get branches', 400)
}
if (!stdout) {
return jsonResponse([])
}
const branches = stdout.split('\n').filter(Boolean).map(line => {
const [name, isCurrent] = line.split('|')
return {
name: name.trim(),
isCurrent: isCurrent === '*',
isRemote: name.startsWith('origin/')
}
})
return jsonResponse(branches)
}
// GET /api/git/branch/current
export async function handleGitCurrentBranch() {
const { stdout, exitCode } = await execGit(['branch', '--show-current'])
if (exitCode !== 0) {
return errorResponse('Not a git repository', 400)
}
return jsonResponse({ branch: stdout })
}

View File

@@ -10,6 +10,7 @@ import { handleTables, handleStats, handleTableSchema, handleTableData, handleQu
import { handleWhisperRoutes } from './whisper'
import { handleRecordingsRoutes } from './recordings'
import { handleClaudeStatus } from './claude-status'
import { handleGitStatus, handleGitDiff, handleGitLog, handleGitLogCommit, handleGitCompare, handleGitBranches, handleGitCurrentBranch } from './git'
export async function handleRequest(req: Request): Promise<Response> {
const url = new URL(req.url)
@@ -189,5 +190,35 @@ export async function handleRequest(req: Request): Promise<Response> {
if (res) return res
}
// Git
if (path === '/api/git/status' && req.method === 'GET') {
return handleGitStatus()
}
if (path === '/api/git/diff' && req.method === 'GET') {
return handleGitDiff(url)
}
if (path === '/api/git/log' && req.method === 'GET') {
return handleGitLog(url)
}
const gitLogCommitMatch = path.match(/^\/api\/git\/log\/([a-f0-9]+)$/)
if (gitLogCommitMatch && req.method === 'GET') {
return handleGitLogCommit(gitLogCommitMatch[1])
}
if (path === '/api/git/compare' && req.method === 'POST') {
return handleGitCompare(req)
}
if (path === '/api/git/branches' && req.method === 'GET') {
return handleGitBranches()
}
if (path === '/api/git/branch/current' && req.method === 'GET') {
return handleGitCurrentBranch()
}
return notFoundResponse()
}