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:
@@ -84,6 +84,7 @@ const awaitingPermission = ref(false) // Waiting for user permission (highest pr
|
|||||||
const showSessionStart = ref(false) // Session start animation (3s)
|
const showSessionStart = ref(false) // Session start animation (3s)
|
||||||
const showNotification = ref(false) // Notification pulse (2s)
|
const showNotification = ref(false) // Notification pulse (2s)
|
||||||
const showToolFlash = ref(false) // Tool use flash (500ms)
|
const showToolFlash = ref(false) // Tool use flash (500ms)
|
||||||
|
const keyboardVisible = ref(false) // Virtual keyboard visible
|
||||||
|
|
||||||
let statusWs: WebSocket | null = null
|
let statusWs: WebSocket | null = null
|
||||||
let statusReconnectTimeout: number | 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
|
// Start polling for token if not connected
|
||||||
const webmcp = getWebMCP()
|
const webmcp = getWebMCP()
|
||||||
if (!webmcp?.isConnected) {
|
if (!webmcp?.isConnected) {
|
||||||
@@ -387,7 +398,8 @@ watch(() => route.name, (newPage) => {
|
|||||||
'session-start': showSessionStart,
|
'session-start': showSessionStart,
|
||||||
notification: showNotification,
|
notification: showNotification,
|
||||||
'tool-flash': showToolFlash,
|
'tool-flash': showToolFlash,
|
||||||
'sheet-open': showTerminal || showVoice
|
'sheet-open': showTerminal || showVoice,
|
||||||
|
'keyboard-visible': keyboardVisible
|
||||||
}"
|
}"
|
||||||
@click="showTerminal = !showTerminal"
|
@click="showTerminal = !showTerminal"
|
||||||
:title="awaitingPermission ? `Permiso requerido: ${claudeTool || 'herramienta'}` : isProcessing ? `Claude: ${claudeTool || 'processing'}` : 'Toggle Terminal'"
|
:title="awaitingPermission ? `Permiso requerido: ${claudeTool || 'herramienta'}` : isProcessing ? `Claude: ${claudeTool || 'processing'}` : 'Toggle Terminal'"
|
||||||
@@ -460,7 +472,7 @@ watch(() => route.name, (newPage) => {
|
|||||||
<!-- Voice FAB Button -->
|
<!-- Voice FAB Button -->
|
||||||
<button
|
<button
|
||||||
class="voice-fab"
|
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"
|
@click="handleVoiceFabClick"
|
||||||
@touchstart="handleVoiceFabTouchStart"
|
@touchstart="handleVoiceFabTouchStart"
|
||||||
@touchend="handleVoiceFabTouchEnd"
|
@touchend="handleVoiceFabTouchEnd"
|
||||||
@@ -1014,7 +1026,7 @@ watch(() => route.name, (newPage) => {
|
|||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.terminal-fab {
|
.terminal-fab {
|
||||||
bottom: 16px;
|
bottom: 80px;
|
||||||
right: 16px;
|
right: 16px;
|
||||||
width: 54px;
|
width: 54px;
|
||||||
height: 54px;
|
height: 54px;
|
||||||
@@ -1038,7 +1050,7 @@ watch(() => route.name, (newPage) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.voice-fab {
|
.voice-fab {
|
||||||
bottom: 16px;
|
bottom: 80px;
|
||||||
left: 16px;
|
left: 16px;
|
||||||
width: 44px;
|
width: 44px;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
@@ -1055,7 +1067,17 @@ watch(() => route.name, (newPage) => {
|
|||||||
|
|
||||||
.terminal-fab.sheet-open,
|
.terminal-fab.sheet-open,
|
||||||
.voice-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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"/>
|
<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>
|
</svg>
|
||||||
</RouterLink>
|
</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>
|
||||||
|
|
||||||
<div class="toolbar-divider"></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'
|
||||||
1
frontend/src/composables/git/index.ts
Normal file
1
frontend/src/composables/git/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { useGitApi } from './useGitApi'
|
||||||
138
frontend/src/composables/git/useGitApi.ts
Normal file
138
frontend/src/composables/git/useGitApi.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
1075
frontend/src/pages/GitPage.vue
Normal file
1075
frontend/src/pages/GitPage.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -53,6 +53,11 @@ const router = createRouter({
|
|||||||
path: '/tools',
|
path: '/tools',
|
||||||
name: 'tools',
|
name: 'tools',
|
||||||
component: () => import('../pages/ToolsPage.vue')
|
component: () => import('../pages/ToolsPage.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/git',
|
||||||
|
name: 'git',
|
||||||
|
component: () => import('../pages/GitPage.vue')
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -21,13 +21,14 @@ import {
|
|||||||
createSourceCodeHandlers,
|
createSourceCodeHandlers,
|
||||||
createTerminalHandlers,
|
createTerminalHandlers,
|
||||||
createResponseHandlers,
|
createResponseHandlers,
|
||||||
|
createGitHandlers,
|
||||||
type ToolConfig
|
type ToolConfig
|
||||||
} from './tools/handlers'
|
} from './tools/handlers'
|
||||||
import { setRouter } from './tools/handlers/globalHandlers'
|
import { setRouter } from './tools/handlers/globalHandlers'
|
||||||
import { setGiteaCredentials, clearGiteaCredentials } from './tools/handlers/sourceCodeHandlers'
|
import { setGiteaCredentials, clearGiteaCredentials } from './tools/handlers/sourceCodeHandlers'
|
||||||
import { ALL_TOOL_METAS, getAllToolNames, type ToolCategory } from './tools/toolDefinitions'
|
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)
|
// Internal webmcp functions (not exported for external use)
|
||||||
let webmcpInstance: any = null
|
let webmcpInstance: any = null
|
||||||
@@ -115,7 +116,8 @@ function getToolConfigs(): Map<string, ToolConfig> {
|
|||||||
...createProjectCanvasHandlers(),
|
...createProjectCanvasHandlers(),
|
||||||
...createSourceCodeHandlers(),
|
...createSourceCodeHandlers(),
|
||||||
...createTerminalHandlers(),
|
...createTerminalHandlers(),
|
||||||
...createResponseHandlers()
|
...createResponseHandlers(),
|
||||||
|
...createGitHandlers()
|
||||||
]
|
]
|
||||||
|
|
||||||
for (const config of allHandlers) {
|
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'],
|
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'],
|
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'],
|
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
|
// Page to categories mapping
|
||||||
@@ -148,7 +151,8 @@ const pageCategories: Record<PageName, ToolCategory[]> = {
|
|||||||
database: ['global', 'database', 'terminal'],
|
database: ['global', 'database', 'terminal'],
|
||||||
source: ['global', 'source', 'terminal'],
|
source: ['global', 'source', 'terminal'],
|
||||||
terminal: ['global', 'terminal'],
|
terminal: ['global', 'terminal'],
|
||||||
tools: ['global', 'terminal']
|
tools: ['global', 'terminal'],
|
||||||
|
git: ['global', 'git', 'terminal']
|
||||||
}
|
}
|
||||||
|
|
||||||
let currentPage: PageName | null = null
|
let currentPage: PageName | null = null
|
||||||
|
|||||||
273
frontend/src/services/tools/handlers/gitHandlers.ts
Normal file
273
frontend/src/services/tools/handlers/gitHandlers.ts
Normal 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}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -14,13 +14,14 @@ export { createTerminalHandlers, setTerminalControls } from './terminalHandlers'
|
|||||||
export type { TerminalControls } from './terminalHandlers'
|
export type { TerminalControls } from './terminalHandlers'
|
||||||
export { createResponseHandlers, setResponseControls } from './responseHandlers'
|
export { createResponseHandlers, setResponseControls } from './responseHandlers'
|
||||||
export type { ResponseControls } from './responseHandlers'
|
export type { ResponseControls } from './responseHandlers'
|
||||||
|
export { createGitHandlers } from './gitHandlers'
|
||||||
|
|
||||||
export type ToolHandler = (args: any) => string | Promise<string>
|
export type ToolHandler = (args: any) => string | Promise<string>
|
||||||
|
|
||||||
export interface ToolConfig {
|
export interface ToolConfig {
|
||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
category: 'global' | 'canvas' | 'component' | 'theme' | 'database' | 'source' | 'project' | 'terminal'
|
category: 'global' | 'canvas' | 'component' | 'theme' | 'database' | 'source' | 'project' | 'terminal' | 'git'
|
||||||
schema: object
|
schema: object
|
||||||
handler: ToolHandler
|
handler: ToolHandler
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
export interface ToolMeta {
|
||||||
name: string
|
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' },
|
{ name: 'terminal_resize', description: 'Cambia el tamano de la ventana del terminal', category: 'terminal' },
|
||||||
|
|
||||||
// Response UI tools
|
// 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
|
// 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' },
|
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' },
|
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' },
|
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
70
frontend/src/types/git.ts
Normal 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
346
server/routes/git.ts
Normal 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 })
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { handleTables, handleStats, handleTableSchema, handleTableData, handleQu
|
|||||||
import { handleWhisperRoutes } from './whisper'
|
import { handleWhisperRoutes } from './whisper'
|
||||||
import { handleRecordingsRoutes } from './recordings'
|
import { handleRecordingsRoutes } from './recordings'
|
||||||
import { handleClaudeStatus } from './claude-status'
|
import { handleClaudeStatus } from './claude-status'
|
||||||
|
import { handleGitStatus, handleGitDiff, handleGitLog, handleGitLogCommit, handleGitCompare, handleGitBranches, handleGitCurrentBranch } from './git'
|
||||||
|
|
||||||
export async function handleRequest(req: Request): Promise<Response> {
|
export async function handleRequest(req: Request): Promise<Response> {
|
||||||
const url = new URL(req.url)
|
const url = new URL(req.url)
|
||||||
@@ -189,5 +190,35 @@ export async function handleRequest(req: Request): Promise<Response> {
|
|||||||
if (res) return res
|
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()
|
return notFoundResponse()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user