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