feat: Add tree file view for git status, AgentBar dock, and settings updates
- Add StatusTree component with collapsible directory hierarchy for staged/unstaged/untracked files - Replace flat file lists in GitPage with tree view showing file type icons and git status badges - Add AgentBar arc dock with per-agent terminal frame and voice modal - Update ejecutor settings with hooks for claude-status reporting
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
|
||||
import { ref, reactive, onMounted, onBeforeUnmount, computed } from 'vue'
|
||||
import { endpoints } from '../config/endpoints'
|
||||
|
||||
// Web Speech API types
|
||||
interface SpeechRecognitionEvent extends Event {
|
||||
@@ -23,6 +24,18 @@ interface SpeechRecognition extends EventTarget {
|
||||
abort(): void
|
||||
}
|
||||
|
||||
type ClaudeStatus = 'idle' | 'processing' | 'toolUse' | 'toolDone' | 'reading' | 'writing' | 'sessionStart' | 'subagentStart' | 'subagentStop' | 'notification' | 'permissionRequest' | 'thinking'
|
||||
|
||||
interface AgentStatusState {
|
||||
isProcessing: boolean
|
||||
isReading: boolean
|
||||
isWriting: boolean
|
||||
awaitingPermission: boolean
|
||||
showToolFlash: boolean
|
||||
showNotification: boolean
|
||||
currentTool: string | null
|
||||
}
|
||||
|
||||
interface UiConfig {
|
||||
label: string
|
||||
shortLabel: string
|
||||
@@ -43,6 +56,171 @@ interface Agent {
|
||||
const agents = ref<Agent[]>([])
|
||||
const loading = ref(true)
|
||||
|
||||
// Per-agent status tracking
|
||||
const agentStatuses = reactive<Record<string, AgentStatusState>>({})
|
||||
const agentTimers = new Map<string, Record<string, number>>()
|
||||
|
||||
function getAgentStatus(agentId: string): AgentStatusState {
|
||||
if (!agentStatuses[agentId]) {
|
||||
agentStatuses[agentId] = {
|
||||
isProcessing: false,
|
||||
isReading: false,
|
||||
isWriting: false,
|
||||
awaitingPermission: false,
|
||||
showToolFlash: false,
|
||||
showNotification: false,
|
||||
currentTool: null
|
||||
}
|
||||
}
|
||||
return agentStatuses[agentId]
|
||||
}
|
||||
|
||||
function getTimers(agentId: string): Record<string, number> {
|
||||
if (!agentTimers.has(agentId)) {
|
||||
agentTimers.set(agentId, {})
|
||||
}
|
||||
return agentTimers.get(agentId)!
|
||||
}
|
||||
|
||||
function clearAgentTimer(agentId: string, key: string) {
|
||||
const timers = getTimers(agentId)
|
||||
if (timers[key]) {
|
||||
clearTimeout(timers[key])
|
||||
delete timers[key]
|
||||
}
|
||||
}
|
||||
|
||||
function setAgentTimer(agentId: string, key: string, fn: () => void, ms: number) {
|
||||
clearAgentTimer(agentId, key)
|
||||
const timers = getTimers(agentId)
|
||||
timers[key] = window.setTimeout(fn, ms)
|
||||
}
|
||||
|
||||
// WebSocket for claude-status
|
||||
let statusWs: WebSocket | null = null
|
||||
let reconnectTimeout: number | null = null
|
||||
|
||||
function connectStatusWs() {
|
||||
if (statusWs?.readyState === WebSocket.OPEN) return
|
||||
|
||||
console.log('[AgentBar] Connecting to', endpoints.claudeStatus)
|
||||
statusWs = new WebSocket(endpoints.claudeStatus)
|
||||
|
||||
statusWs.onopen = () => {
|
||||
console.log('[AgentBar] WebSocket OPEN')
|
||||
}
|
||||
|
||||
statusWs.onerror = (err) => {
|
||||
console.error('[AgentBar] WebSocket ERROR', err)
|
||||
}
|
||||
|
||||
statusWs.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data)
|
||||
console.log('[AgentBar] WS message:', msg.type, msg.status || '', msg.agent || '')
|
||||
|
||||
if (msg.type !== 'claude-status') return
|
||||
|
||||
const status = msg.status as ClaudeStatus
|
||||
const agentName = (msg.agent || 'main') as string
|
||||
const tool = msg.tool || null
|
||||
|
||||
console.log(`[AgentBar] Status: ${status}, agent: ${agentName}, tool: ${tool}`)
|
||||
|
||||
// Find matching agent by id, name, or label (case-insensitive)
|
||||
const agent = enabledAgents.value.find(a =>
|
||||
a.id.toLowerCase() === agentName.toLowerCase() ||
|
||||
a.name.toLowerCase() === agentName.toLowerCase() ||
|
||||
a.uiConfig?.label.toLowerCase() === agentName.toLowerCase()
|
||||
)
|
||||
if (!agent) {
|
||||
console.log(`[AgentBar] No matching enabled agent for "${agentName}", enabled:`, enabledAgents.value.map(a => a.id))
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`[AgentBar] Matched agent: ${agent.id}, applying status: ${status}`)
|
||||
const s = getAgentStatus(agent.id)
|
||||
s.currentTool = tool
|
||||
|
||||
switch (status) {
|
||||
case 'processing':
|
||||
case 'thinking':
|
||||
s.isProcessing = true
|
||||
// Auto-reset safety (2min)
|
||||
setAgentTimer(agent.id, 'processing', () => {
|
||||
s.isProcessing = false
|
||||
}, 120000)
|
||||
break
|
||||
|
||||
case 'idle':
|
||||
s.isProcessing = false
|
||||
s.isReading = false
|
||||
s.isWriting = false
|
||||
s.awaitingPermission = false
|
||||
clearAgentTimer(agent.id, 'processing')
|
||||
break
|
||||
|
||||
case 'permissionRequest':
|
||||
s.awaitingPermission = true
|
||||
break
|
||||
|
||||
case 'reading':
|
||||
s.isReading = true
|
||||
triggerToolFlash(agent.id)
|
||||
break
|
||||
|
||||
case 'writing':
|
||||
s.isWriting = true
|
||||
triggerToolFlash(agent.id)
|
||||
break
|
||||
|
||||
case 'toolUse':
|
||||
triggerToolFlash(agent.id)
|
||||
break
|
||||
|
||||
case 'toolDone':
|
||||
s.isReading = false
|
||||
s.isWriting = false
|
||||
s.awaitingPermission = false
|
||||
break
|
||||
|
||||
case 'notification':
|
||||
s.showNotification = true
|
||||
setAgentTimer(agent.id, 'notification', () => {
|
||||
s.showNotification = false
|
||||
}, 2000)
|
||||
break
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
statusWs.onclose = () => {
|
||||
reconnectTimeout = window.setTimeout(connectStatusWs, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
function triggerToolFlash(agentId: string) {
|
||||
const s = getAgentStatus(agentId)
|
||||
s.showToolFlash = true
|
||||
setAgentTimer(agentId, 'toolFlash', () => {
|
||||
s.showToolFlash = false
|
||||
}, 500)
|
||||
}
|
||||
|
||||
// Bubble CSS classes based on agent status
|
||||
function bubbleClasses(agent: Agent) {
|
||||
const s = agentStatuses[agent.id]
|
||||
if (!s) return {}
|
||||
return {
|
||||
processing: s.isProcessing && !s.isReading && !s.isWriting && !s.awaitingPermission,
|
||||
reading: s.isReading,
|
||||
writing: s.isWriting,
|
||||
permission: s.awaitingPermission,
|
||||
notification: s.showNotification,
|
||||
'tool-flash': s.showToolFlash
|
||||
}
|
||||
}
|
||||
|
||||
// Terminal frame state
|
||||
const terminalAgent = ref<Agent | null>(null)
|
||||
const showTerminal = ref(false)
|
||||
@@ -64,18 +242,43 @@ const enabledAgents = computed(() =>
|
||||
agents.value.filter(a => a.uiConfig?.enabled)
|
||||
)
|
||||
|
||||
// Dynamic glow based on agent colors
|
||||
const barGlowStyle = computed(() => {
|
||||
const list = enabledAgents.value
|
||||
if (!list.length) return {}
|
||||
const glows = list.map(a => {
|
||||
const c = a.uiConfig?.color || '#6366f1'
|
||||
return `0 -6px 30px ${c}50, 0 -2px 15px ${c}30`
|
||||
})
|
||||
return {
|
||||
boxShadow: `${glows.join(', ')}, 0 -10px 50px rgba(0,0,0,0.4)`
|
||||
// Check if agent has any active animation status
|
||||
function isAnimating(agent: Agent): boolean {
|
||||
const s = agentStatuses[agent.id]
|
||||
if (!s) return false
|
||||
return s.isProcessing || s.isReading || s.isWriting || s.awaitingPermission || s.showNotification || s.showToolFlash
|
||||
}
|
||||
|
||||
// Per-bubble glow style (dynamic based on status)
|
||||
// NOTE: When animating, boxShadow is NOT set inline — CSS animations handle it with !important
|
||||
function bubbleStyle(agent: Agent) {
|
||||
const c = agent.uiConfig?.color || '#6366f1'
|
||||
const s = agentStatuses[agent.id]
|
||||
|
||||
// Override gradient for special states (only background, NO boxShadow — let CSS animate it)
|
||||
if (s?.awaitingPermission) {
|
||||
return { background: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)' }
|
||||
}
|
||||
})
|
||||
if (s?.isWriting) {
|
||||
return { background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)' }
|
||||
}
|
||||
if (s?.isReading) {
|
||||
return { background: 'linear-gradient(135deg, #06b6d4 0%, #0891b2 100%)' }
|
||||
}
|
||||
if (s?.isProcessing) {
|
||||
return { background: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)' }
|
||||
}
|
||||
|
||||
return {
|
||||
background: agent.uiConfig?.gradient || c,
|
||||
boxShadow: `0 4px 15px ${c}66, 0 8px 30px ${c}4D, inset 0 1px 0 rgba(255,255,255,0.2)`
|
||||
}
|
||||
}
|
||||
|
||||
function bubbleHoverShadow(agent: Agent) {
|
||||
const c = agent.uiConfig?.color || '#6366f1'
|
||||
return `0 8px 25px ${c}80, 0 15px 40px ${c}59, inset 0 1px 0 rgba(255,255,255,0.25)`
|
||||
}
|
||||
|
||||
async function fetchAgents() {
|
||||
try {
|
||||
@@ -172,7 +375,7 @@ function stopRecognition() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Pill interaction ---
|
||||
// --- Bubble interaction ---
|
||||
function handlePointerDown(agent: Agent) {
|
||||
holdTriggered = false
|
||||
holdTimer = window.setTimeout(() => {
|
||||
@@ -198,43 +401,81 @@ function handlePointerLeave() {
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive
|
||||
const isMobile = ref(window.innerWidth < 768)
|
||||
function onResize() {
|
||||
isMobile.value = window.innerWidth < 768
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchAgents()
|
||||
window.addEventListener('resize', onResize)
|
||||
connectStatusWs()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', onResize)
|
||||
stopRecognition()
|
||||
recognition = null
|
||||
statusWs?.close()
|
||||
if (reconnectTimeout) clearTimeout(reconnectTimeout)
|
||||
// Clear all agent timers
|
||||
for (const [, timers] of agentTimers) {
|
||||
for (const key of Object.keys(timers)) {
|
||||
clearTimeout(timers[key])
|
||||
}
|
||||
}
|
||||
agentTimers.clear()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Agent Bar — arc dock -->
|
||||
<div v-if="enabledAgents.length" class="agent-bar">
|
||||
<div class="agent-bar-inner" :style="barGlowStyle">
|
||||
<button
|
||||
v-for="agent in enabledAgents"
|
||||
:key="agent.id"
|
||||
class="agent-pill"
|
||||
:style="{ background: agent.uiConfig?.gradient || agent.uiConfig?.color }"
|
||||
@pointerdown.prevent="handlePointerDown(agent)"
|
||||
@pointerup="handlePointerUp(agent)"
|
||||
@pointerleave="handlePointerLeave"
|
||||
@contextmenu.prevent
|
||||
>
|
||||
<span class="pill-label">
|
||||
{{ isMobile ? agent.uiConfig?.shortLabel : agent.uiConfig?.label }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Agent Bubbles — one per agent, like the terminal FAB -->
|
||||
<div v-if="enabledAgents.length" class="agent-bubbles">
|
||||
<button
|
||||
v-for="agent in enabledAgents"
|
||||
:key="agent.id"
|
||||
class="agent-bubble"
|
||||
:class="bubbleClasses(agent)"
|
||||
:data-agent="agent.id"
|
||||
:style="bubbleStyle(agent)"
|
||||
:title="agentStatuses[agent.id]?.awaitingPermission ? `Permiso requerido: ${agentStatuses[agent.id]?.currentTool || 'herramienta'}` : agentStatuses[agent.id]?.isProcessing ? `${agent.uiConfig?.label}: ${agentStatuses[agent.id]?.currentTool || 'processing'}` : agent.uiConfig?.label || agent.name"
|
||||
@pointerdown.prevent="handlePointerDown(agent)"
|
||||
@pointerup="handlePointerUp(agent)"
|
||||
@pointerleave="handlePointerLeave"
|
||||
@contextmenu.prevent
|
||||
@mouseenter="!isAnimating(agent) && (($event.currentTarget as HTMLElement).style.boxShadow = bubbleHoverShadow(agent))"
|
||||
@mouseleave="!isAnimating(agent) && (($event.currentTarget as HTMLElement).style.boxShadow = bubbleStyle(agent).boxShadow || '')"
|
||||
>
|
||||
<!-- Permission alert icon -->
|
||||
<svg v-if="agentStatuses[agent.id]?.awaitingPermission" class="bubble-status-icon permission-icon" xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
|
||||
<line x1="12" y1="9" x2="12" y2="13"/>
|
||||
<line x1="12" y1="17" x2="12.01" y2="17"/>
|
||||
</svg>
|
||||
|
||||
<!-- Ejecutor processing: ember ring -->
|
||||
<div v-else-if="agent.id === 'ejecutor' && agentStatuses[agent.id]?.isProcessing && !agentStatuses[agent.id]?.isReading && !agentStatuses[agent.id]?.isWriting" class="ember-ring">
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
|
||||
<!-- Default processing: thinking dots -->
|
||||
<div v-else-if="agentStatuses[agent.id]?.isProcessing && !agentStatuses[agent.id]?.isReading && !agentStatuses[agent.id]?.isWriting" class="thinking-dots">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
|
||||
<!-- Reading icon (eye) -->
|
||||
<svg v-else-if="agentStatuses[agent.id]?.isReading" class="bubble-status-icon" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
|
||||
<!-- Writing icon (pencil) -->
|
||||
<svg v-else-if="agentStatuses[agent.id]?.isWriting" class="bubble-status-icon" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 19l7-7 3 3-7 7-3-3z"/>
|
||||
<path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"/>
|
||||
<path d="M2 2l7.586 7.586"/>
|
||||
<circle cx="11" cy="11" r="2"/>
|
||||
</svg>
|
||||
|
||||
<!-- Default: agent label -->
|
||||
<span v-else class="bubble-label">{{ agent.uiConfig?.shortLabel }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Terminal Frame Modal -->
|
||||
@@ -315,122 +556,386 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ====================== AGENT BAR — ARC DOCK ====================== */
|
||||
.agent-bar {
|
||||
/* ====================== AGENT BUBBLES ====================== */
|
||||
.agent-bubbles {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 9990;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
z-index: 9998;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.agent-bar-inner {
|
||||
.agent-bubble {
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
overflow: hidden;
|
||||
/* Arc: wide elliptical curve at top, flat bottom */
|
||||
border-radius: 50% 50% 0 0 / 22px 22px 0 0;
|
||||
/* Top edge highlight */
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.15);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Sheen line following the arc */
|
||||
.agent-bar-inner::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 15%;
|
||||
right: 15%;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent);
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.agent-pill {
|
||||
flex: 1;
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.5px;
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
border-radius: 18px;
|
||||
color: white;
|
||||
border: 2px solid rgba(255, 255, 255, 0.15);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
white-space: nowrap;
|
||||
transition: filter 0.15s ease, brightness 0.15s ease;
|
||||
transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
overflow: visible;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
touch-action: manipulation;
|
||||
-webkit-touch-callout: none;
|
||||
touch-action: manipulation;
|
||||
position: relative;
|
||||
/* Safe area for phones with gesture bar */
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
|
||||
/* Divider between pills */
|
||||
.agent-pill + .agent-pill {
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.18);
|
||||
/* Hover glow ring */
|
||||
.agent-bubble::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -3px;
|
||||
border-radius: 21px;
|
||||
background: inherit;
|
||||
filter: blur(8px);
|
||||
z-index: -1;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.agent-pill:hover {
|
||||
filter: brightness(1.15);
|
||||
.agent-bubble:hover {
|
||||
transform: translateY(-3px) scale(1.05);
|
||||
}
|
||||
|
||||
.agent-pill:active {
|
||||
filter: brightness(0.9);
|
||||
.agent-bubble:hover::before {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.pill-label {
|
||||
line-height: 1;
|
||||
.agent-bubble:active {
|
||||
transform: scale(0.95);
|
||||
transition-duration: 0.1s;
|
||||
}
|
||||
|
||||
/* ====================== STATUS ANIMATIONS (DEFAULT) ====================== */
|
||||
|
||||
/* Kill transition on all animated states */
|
||||
.agent-bubble.processing,
|
||||
.agent-bubble.reading,
|
||||
.agent-bubble.writing,
|
||||
.agent-bubble.permission,
|
||||
.agent-bubble.notification {
|
||||
transition: background 0.3s ease !important;
|
||||
}
|
||||
|
||||
/* Processing - Orange pulsing glow */
|
||||
.agent-bubble.processing {
|
||||
animation: ab-processing-pulse 2s ease-in-out infinite !important;
|
||||
}
|
||||
|
||||
/* Reading - Cyan scanning wobble */
|
||||
.agent-bubble.reading {
|
||||
animation: ab-reading-scan 1.5s ease-in-out infinite !important;
|
||||
}
|
||||
|
||||
/* Writing - Green pulse scale */
|
||||
.agent-bubble.writing {
|
||||
animation: ab-writing-pulse 0.8s ease-in-out infinite !important;
|
||||
}
|
||||
|
||||
/* Permission - Red alert pulse (highest priority) */
|
||||
.agent-bubble.permission {
|
||||
animation: ab-permission-pulse 1s ease-in-out infinite !important;
|
||||
transform: scale(1.1) !important;
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
/* Notification - Yellow bounce */
|
||||
.agent-bubble.notification {
|
||||
animation: ab-notification-bounce 0.5s ease-in-out 4 !important;
|
||||
}
|
||||
|
||||
/* Tool flash - White expanding ring */
|
||||
.agent-bubble.tool-flash::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -4px;
|
||||
border-radius: 22px;
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
animation: ab-tool-flash 0.5s ease-out forwards !important;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Thinking dots (default) */
|
||||
.thinking-dots {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.thinking-dots span {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
animation: ab-thinking-dot 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.thinking-dots span:nth-child(1) { animation-delay: 0s; }
|
||||
.thinking-dots span:nth-child(2) { animation-delay: 0.2s; }
|
||||
.thinking-dots span:nth-child(3) { animation-delay: 0.4s; }
|
||||
|
||||
/* Status icons inside bubbles */
|
||||
.bubble-status-icon {
|
||||
animation: ab-icon-breathe 1s ease-in-out infinite;
|
||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.3));
|
||||
}
|
||||
|
||||
.permission-icon {
|
||||
animation: ab-permission-shake 0.5s ease-in-out infinite;
|
||||
filter: drop-shadow(0 0 8px rgba(255, 255, 255, 0.8));
|
||||
}
|
||||
|
||||
/* ====================== EJECUTOR — UNIQUE ANIMATIONS ====================== */
|
||||
|
||||
/* Ejecutor Processing: Heartbeat — double pump with ember glow */
|
||||
.agent-bubble[data-agent="ejecutor"].processing {
|
||||
animation: ej-heartbeat 1.2s ease-in-out infinite !important;
|
||||
border-color: rgba(255, 100, 50, 0.5) !important;
|
||||
}
|
||||
|
||||
/* Ejecutor Reading: Infrared scan — red sweep line */
|
||||
.agent-bubble[data-agent="ejecutor"].reading {
|
||||
animation: ej-infrared 1s linear infinite !important;
|
||||
}
|
||||
|
||||
/* Ejecutor Writing: Forge — intense white-hot pulse */
|
||||
.agent-bubble[data-agent="ejecutor"].writing {
|
||||
animation: ej-forge 0.6s ease-in-out infinite !important;
|
||||
border-color: rgba(255, 200, 50, 0.6) !important;
|
||||
}
|
||||
|
||||
/* Ejecutor Notification: Glitch — rapid jitter + skew */
|
||||
.agent-bubble[data-agent="ejecutor"].notification {
|
||||
animation: ej-glitch 0.15s steps(2) 8 !important;
|
||||
}
|
||||
|
||||
/* Ejecutor Tool Flash: Crimson shockwave — double red ring */
|
||||
.agent-bubble[data-agent="ejecutor"].tool-flash::after {
|
||||
background: rgba(239, 68, 68, 0.5) !important;
|
||||
animation: ej-shockwave 0.6s ease-out forwards !important;
|
||||
border: 1px solid rgba(255, 100, 50, 0.8);
|
||||
}
|
||||
|
||||
.agent-bubble[data-agent="ejecutor"].tool-flash::before {
|
||||
content: '' !important;
|
||||
position: absolute !important;
|
||||
inset: -2px !important;
|
||||
border-radius: 20px !important;
|
||||
background: rgba(255, 150, 50, 0.3) !important;
|
||||
filter: none !important;
|
||||
opacity: 1 !important;
|
||||
animation: ej-shockwave-inner 0.4s ease-out forwards !important;
|
||||
pointer-events: none !important;
|
||||
z-index: 1 !important;
|
||||
}
|
||||
|
||||
/* Ember ring — two orbiting arcs inside the bubble */
|
||||
.ember-ring {
|
||||
position: relative;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.ember-ring span {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.ember-ring span:nth-child(1) {
|
||||
border-top-color: #fff;
|
||||
border-right-color: rgba(255, 200, 50, 0.8);
|
||||
animation: ej-ember-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.ember-ring span:nth-child(2) {
|
||||
border-bottom-color: rgba(255, 100, 50, 0.9);
|
||||
border-left-color: rgba(255, 50, 50, 0.6);
|
||||
animation: ej-ember-spin 1.2s linear infinite reverse;
|
||||
}
|
||||
|
||||
/* ====================== EJECUTOR KEYFRAMES ====================== */
|
||||
|
||||
/* Heartbeat: double-pump scale like a real heartbeat */
|
||||
@keyframes ej-heartbeat {
|
||||
0% { transform: scale(1) !important; box-shadow: 0 4px 20px rgba(239, 68, 68, 0.3) !important; }
|
||||
14% { transform: scale(1.15) !important; box-shadow: 0 4px 35px rgba(239, 68, 68, 0.7) !important; }
|
||||
28% { transform: scale(1) !important; box-shadow: 0 4px 20px rgba(239, 68, 68, 0.3) !important; }
|
||||
42% { transform: scale(1.22) !important; box-shadow: 0 4px 45px rgba(255, 100, 50, 0.8) !important; }
|
||||
70% { transform: scale(1) !important; box-shadow: 0 4px 15px rgba(239, 68, 68, 0.2) !important; }
|
||||
100% { transform: scale(1) !important; box-shadow: 0 4px 20px rgba(239, 68, 68, 0.3) !important; }
|
||||
}
|
||||
|
||||
/* Infrared scan: red glow sweeps around the border */
|
||||
@keyframes ej-infrared {
|
||||
0% {
|
||||
box-shadow: 0 -8px 20px rgba(239, 68, 68, 0.6), 0 8px 20px rgba(239, 68, 68, 0.1) !important;
|
||||
transform: rotate(0deg) !important;
|
||||
}
|
||||
25% {
|
||||
box-shadow: 8px 0 20px rgba(239, 68, 68, 0.6), -8px 0 20px rgba(239, 68, 68, 0.1) !important;
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 8px 20px rgba(239, 68, 68, 0.6), 0 -8px 20px rgba(239, 68, 68, 0.1) !important;
|
||||
}
|
||||
75% {
|
||||
box-shadow: -8px 0 20px rgba(239, 68, 68, 0.6), 8px 0 20px rgba(239, 68, 68, 0.1) !important;
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 -8px 20px rgba(239, 68, 68, 0.6), 0 8px 20px rgba(239, 68, 68, 0.1) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Forge: intense white-hot to red pulse */
|
||||
@keyframes ej-forge {
|
||||
0%, 100% {
|
||||
transform: scale(1) !important;
|
||||
box-shadow: 0 4px 20px rgba(239, 68, 68, 0.4) !important;
|
||||
filter: brightness(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.06) !important;
|
||||
box-shadow: 0 4px 30px rgba(255, 200, 50, 0.8), 0 0 15px rgba(255, 255, 255, 0.3) !important;
|
||||
filter: brightness(1.3);
|
||||
}
|
||||
}
|
||||
|
||||
/* Glitch: rapid random jitter + skew */
|
||||
@keyframes ej-glitch {
|
||||
0% { transform: translate(0, 0) skewX(0deg) !important; }
|
||||
25% { transform: translate(-3px, 1px) skewX(-4deg) !important; box-shadow: -3px 0 8px rgba(0, 255, 255, 0.4), 3px 0 8px rgba(255, 0, 50, 0.4) !important; }
|
||||
50% { transform: translate(2px, -2px) skewX(3deg) !important; box-shadow: 3px 0 8px rgba(0, 255, 255, 0.4), -3px 0 8px rgba(255, 0, 50, 0.4) !important; }
|
||||
75% { transform: translate(-1px, 2px) skewX(-2deg) !important; }
|
||||
100%{ transform: translate(0, 0) skewX(0deg) !important; box-shadow: 0 4px 15px rgba(239, 68, 68, 0.4) !important; }
|
||||
}
|
||||
|
||||
/* Crimson shockwave: expanding red ring */
|
||||
@keyframes ej-shockwave {
|
||||
0% { opacity: 1; transform: scale(1); }
|
||||
100% { opacity: 0; transform: scale(1.6); }
|
||||
}
|
||||
|
||||
/* Inner shockwave: smaller faster ring */
|
||||
@keyframes ej-shockwave-inner {
|
||||
0% { opacity: 0.8; transform: scale(1); }
|
||||
100% { opacity: 0; transform: scale(1.3); }
|
||||
}
|
||||
|
||||
/* Ember spinning arcs */
|
||||
@keyframes ej-ember-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ====================== DEFAULT KEYFRAMES ====================== */
|
||||
|
||||
@keyframes ab-processing-pulse {
|
||||
0%, 100% { box-shadow: 0 8px 24px rgba(245, 158, 11, 0.4) !important; }
|
||||
50% { box-shadow: 0 8px 40px rgba(245, 158, 11, 0.8) !important; }
|
||||
}
|
||||
|
||||
@keyframes ab-reading-scan {
|
||||
0%, 100% {
|
||||
box-shadow: 0 8px 24px rgba(6, 182, 212, 0.4) !important;
|
||||
transform: rotate(0deg) !important;
|
||||
}
|
||||
25% { transform: rotate(-3deg) !important; }
|
||||
75% { transform: rotate(3deg) !important; }
|
||||
50% { box-shadow: 0 8px 40px rgba(6, 182, 212, 0.8) !important; }
|
||||
}
|
||||
|
||||
@keyframes ab-writing-pulse {
|
||||
0%, 100% {
|
||||
transform: scale(1) !important;
|
||||
box-shadow: 0 8px 24px rgba(16, 185, 129, 0.4) !important;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.08) !important;
|
||||
box-shadow: 0 8px 32px rgba(16, 185, 129, 0.7) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ab-permission-pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7) !important;
|
||||
transform: scale(1.1) !important;
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 15px rgba(239, 68, 68, 0) !important;
|
||||
transform: scale(1.05) !important;
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0) !important;
|
||||
transform: scale(1.1) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ab-permission-shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-2px); }
|
||||
75% { transform: translateX(2px); }
|
||||
}
|
||||
|
||||
@keyframes ab-notification-bounce {
|
||||
0%, 100% { transform: translateY(0) !important; }
|
||||
50% { transform: translateY(-10px) !important; }
|
||||
}
|
||||
|
||||
@keyframes ab-tool-flash {
|
||||
0% { opacity: 1; transform: scale(1); }
|
||||
100% { opacity: 0; transform: scale(1.4); }
|
||||
}
|
||||
|
||||
@keyframes ab-thinking-dot {
|
||||
0%, 80%, 100% { transform: scale(0.6); opacity: 0.5; }
|
||||
40% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes ab-icon-breathe {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
/* ====================== LABELS ====================== */
|
||||
|
||||
.bubble-label {
|
||||
font-weight: 800;
|
||||
font-size: 15px;
|
||||
letter-spacing: 0.5px;
|
||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ---- Responsive: Tablet (768+) ---- */
|
||||
@media (min-width: 768px) {
|
||||
.agent-bar-inner {
|
||||
max-width: 480px;
|
||||
height: 56px;
|
||||
border-radius: 50% 50% 0 0 / 26px 26px 0 0;
|
||||
/* Mobile */
|
||||
@media (max-width: 768px) {
|
||||
.agent-bubbles {
|
||||
bottom: 80px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.agent-pill {
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Responsive: Desktop (1200+) ---- */
|
||||
@media (min-width: 1200px) {
|
||||
.agent-bar-inner {
|
||||
max-width: 580px;
|
||||
height: 62px;
|
||||
border-radius: 50% 50% 0 0 / 30px 30px 0 0;
|
||||
.agent-bubble {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.agent-pill {
|
||||
font-size: 16px;
|
||||
letter-spacing: 0.8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Responsive: 4K / TV (2400+) ---- */
|
||||
@media (min-width: 2400px) {
|
||||
.agent-bar-inner {
|
||||
max-width: 740px;
|
||||
height: 76px;
|
||||
border-radius: 50% 50% 0 0 / 38px 38px 0 0;
|
||||
.agent-bubble::before {
|
||||
border-radius: 19px;
|
||||
}
|
||||
|
||||
.agent-pill {
|
||||
font-size: 20px;
|
||||
letter-spacing: 1px;
|
||||
.bubble-label {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
365
frontend/src/components/git/StatusTree.vue
Normal file
365
frontend/src/components/git/StatusTree.vue
Normal file
@@ -0,0 +1,365 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
interface StatusFile {
|
||||
path: string
|
||||
status: string
|
||||
}
|
||||
|
||||
interface StatusNode {
|
||||
name: string
|
||||
path: string
|
||||
type: 'file' | 'directory'
|
||||
status?: string
|
||||
children?: StatusNode[]
|
||||
fileCount?: number
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
files?: StatusFile[]
|
||||
nodes?: StatusNode[]
|
||||
selectedPath?: string | null
|
||||
depth?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [path: string]
|
||||
}>()
|
||||
|
||||
const expandedDirs = ref<Set<string>>(new Set())
|
||||
|
||||
function countFiles(nodes: StatusNode[]): number {
|
||||
let count = 0
|
||||
for (const n of nodes) {
|
||||
if (n.type === 'file') count++
|
||||
else if (n.children) count += countFiles(n.children)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
function buildTree(files: StatusFile[]): StatusNode[] {
|
||||
const root: Record<string, any> = {}
|
||||
|
||||
for (const file of files) {
|
||||
const parts = file.path.split('/')
|
||||
let current = root
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i]
|
||||
if (i === parts.length - 1) {
|
||||
current[part] = { __file: true, status: file.status, path: file.path }
|
||||
} else {
|
||||
if (!current[part] || current[part].__file) {
|
||||
current[part] = {}
|
||||
}
|
||||
current = current[part]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toNodes(obj: Record<string, any>, parentPath: string): StatusNode[] {
|
||||
const dirs: StatusNode[] = []
|
||||
const fileNodes: StatusNode[] = []
|
||||
|
||||
for (const [name, value] of Object.entries(obj)) {
|
||||
if (value.__file) {
|
||||
fileNodes.push({
|
||||
name,
|
||||
path: value.path,
|
||||
type: 'file',
|
||||
status: value.status
|
||||
})
|
||||
} else {
|
||||
const dirPath = parentPath ? `${parentPath}/${name}` : name
|
||||
const children = toNodes(value, dirPath)
|
||||
const fileCount = countFiles(children)
|
||||
dirs.push({
|
||||
name,
|
||||
path: dirPath,
|
||||
type: 'directory',
|
||||
children,
|
||||
fileCount
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Compact single-child directories
|
||||
for (let i = 0; i < dirs.length; i++) {
|
||||
const dir = dirs[i]
|
||||
while (
|
||||
dir.children &&
|
||||
dir.children.length === 1 &&
|
||||
dir.children[0].type === 'directory'
|
||||
) {
|
||||
const child = dir.children[0]
|
||||
dir.name = `${dir.name}/${child.name}`
|
||||
dir.path = child.path
|
||||
dir.children = child.children
|
||||
dir.fileCount = child.fileCount
|
||||
}
|
||||
}
|
||||
|
||||
return [...dirs, ...fileNodes]
|
||||
}
|
||||
|
||||
return toNodes(root, '')
|
||||
}
|
||||
|
||||
// Build tree from files prop (top-level), or use nodes prop (recursive)
|
||||
const displayNodes = computed(() => {
|
||||
if (props.nodes) return props.nodes
|
||||
if (props.files) return buildTree(props.files)
|
||||
return []
|
||||
})
|
||||
|
||||
// Auto-expand all dirs when files change (top-level only)
|
||||
watch(() => props.files, (files) => {
|
||||
if (!files || props.depth) return
|
||||
expandedDirs.value = new Set()
|
||||
expandAllNodes(displayNodes.value)
|
||||
}, { immediate: true })
|
||||
|
||||
function expandAllNodes(nodes: StatusNode[]) {
|
||||
for (const node of nodes) {
|
||||
if (node.type === 'directory') {
|
||||
expandedDirs.value.add(node.path)
|
||||
if (node.children) expandAllNodes(node.children)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDir(path: string) {
|
||||
if (expandedDirs.value.has(path)) {
|
||||
expandedDirs.value.delete(path)
|
||||
} else {
|
||||
expandedDirs.value.add(path)
|
||||
}
|
||||
expandedDirs.value = new Set(expandedDirs.value)
|
||||
}
|
||||
|
||||
function isExpanded(path: string): boolean {
|
||||
return expandedDirs.value.has(path)
|
||||
}
|
||||
|
||||
function handleClick(node: StatusNode) {
|
||||
if (node.type === 'directory') {
|
||||
toggleDir(node.path)
|
||||
} else {
|
||||
emit('select', node.path)
|
||||
}
|
||||
}
|
||||
|
||||
function getFileIcon(name: string): string {
|
||||
const ext = name.split('.').pop()?.toLowerCase() || ''
|
||||
const icons: Record<string, string> = {
|
||||
ts: 'ts', tsx: 'tsx', js: 'js', jsx: 'jsx',
|
||||
vue: 'vue', json: 'json', md: 'md',
|
||||
css: 'css', scss: 'scss', html: 'html',
|
||||
svg: 'svg', png: 'img', jpg: 'img', jpeg: 'img',
|
||||
gif: 'img', ico: 'img', gitignore: 'git',
|
||||
env: 'env', yml: 'yml', yaml: 'yml',
|
||||
toml: 'toml', lock: 'lock'
|
||||
}
|
||||
return icons[ext] || 'file'
|
||||
}
|
||||
|
||||
function badgeLabel(status: string): string {
|
||||
if (status === 'untracked') return '?'
|
||||
return (status[0] || '?').toUpperCase()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="status-tree">
|
||||
<div v-for="node in displayNodes" :key="node.path" class="tree-node">
|
||||
<!-- Directory row -->
|
||||
<div
|
||||
v-if="node.type === 'directory'"
|
||||
class="node-row dir-row"
|
||||
@click="handleClick(node)"
|
||||
>
|
||||
<span class="expand-icon">
|
||||
<svg
|
||||
:class="{ expanded: isExpanded(node.path) }"
|
||||
width="12" height="12" viewBox="0 0 24 24" fill="currentColor"
|
||||
>
|
||||
<path d="M8 5l8 7-8 7z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="folder-icon">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path v-if="isExpanded(node.path)" d="M19 20H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h6l2 2h6a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2z" />
|
||||
<path v-else d="M3 6a2 2 0 0 1 2-2h6l2 2h6a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="node-name">{{ node.name }}</span>
|
||||
<span class="dir-count">{{ node.fileCount }}</span>
|
||||
</div>
|
||||
|
||||
<!-- File row -->
|
||||
<div
|
||||
v-else
|
||||
:class="['node-row', 'file-row', { selected: selectedPath === node.path }]"
|
||||
@click="handleClick(node)"
|
||||
>
|
||||
<span class="expand-icon spacer"></span>
|
||||
<span :class="['status-badge', node.status]">{{ badgeLabel(node.status || '') }}</span>
|
||||
<span :class="['file-icon', getFileIcon(node.name)]">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="node-name">{{ node.name }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Recursive children -->
|
||||
<div v-if="node.type === 'directory' && isExpanded(node.path) && node.children" class="children">
|
||||
<StatusTree
|
||||
:nodes="node.children"
|
||||
:selected-path="selectedPath"
|
||||
:depth="(depth || 0) + 1"
|
||||
@select="emit('select', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.status-tree {
|
||||
font-size: 13px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.node-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.node-row:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.node-row.selected {
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.expand-icon.spacer {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.expand-icon svg {
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
.expand-icon svg.expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.folder-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.file-icon.ts, .file-icon.tsx { color: #3178c6; }
|
||||
.file-icon.js, .file-icon.jsx { color: #f7df1e; }
|
||||
.file-icon.vue { color: #42b883; }
|
||||
.file-icon.json { color: #f5a623; }
|
||||
.file-icon.md { color: #519aba; }
|
||||
.file-icon.css, .file-icon.scss { color: #563d7c; }
|
||||
.file-icon.html { color: #e34c26; }
|
||||
.file-icon.svg, .file-icon.img { color: #a855f7; }
|
||||
.file-icon.git { color: #f05032; }
|
||||
.file-icon.env { color: #ecd53f; }
|
||||
.file-icon.yml { color: #cb171e; }
|
||||
.file-icon.lock { color: #6b7280; }
|
||||
|
||||
.node-name {
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.dir-count {
|
||||
margin-left: auto;
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-muted);
|
||||
padding: 0.05rem 0.35rem;
|
||||
border-radius: 8px;
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-badge.added {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.status-badge.modified {
|
||||
background: rgba(234, 179, 8, 0.2);
|
||||
color: #eab308;
|
||||
}
|
||||
|
||||
.status-badge.deleted {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.status-badge.renamed {
|
||||
background: rgba(168, 85, 247, 0.2);
|
||||
color: #a855f7;
|
||||
}
|
||||
|
||||
.status-badge.untracked {
|
||||
background: rgba(107, 114, 128, 0.2);
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.children {
|
||||
padding-left: 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -4,3 +4,4 @@ export { default as CommitList } from './CommitList.vue'
|
||||
export { default as BranchSelector } from './BranchSelector.vue'
|
||||
export { default as ProjectTree } from './ProjectTree.vue'
|
||||
export { default as FileViewer } from './FileViewer.vue'
|
||||
export { default as StatusTree } from './StatusTree.vue'
|
||||
|
||||
Reference in New Issue
Block a user