|
| 1 | +<script setup lang="ts"> |
| 2 | +import type { FileChange } from '#shared/types' |
| 3 | +import { getFileIcon } from '~/utils/file-icons' |
| 4 | +
|
| 5 | +interface DiffTreeNode { |
| 6 | + name: string |
| 7 | + path: string |
| 8 | + type: 'file' | 'directory' |
| 9 | + changeType?: 'added' | 'removed' | 'modified' |
| 10 | + children?: DiffTreeNode[] |
| 11 | +} |
| 12 | +
|
| 13 | +const props = defineProps<{ |
| 14 | + files: FileChange[] |
| 15 | + selectedPath: string | null |
| 16 | + // Internal props for recursion |
| 17 | + treeNodes?: DiffTreeNode[] |
| 18 | + depth?: number |
| 19 | +}>() |
| 20 | +
|
| 21 | +const emit = defineEmits<{ |
| 22 | + select: [file: FileChange] |
| 23 | +}>() |
| 24 | +
|
| 25 | +const depth = computed(() => props.depth ?? 0) |
| 26 | +
|
| 27 | +// Build tree structure from flat file list (only at root level) |
| 28 | +function buildTree(files: FileChange[]): DiffTreeNode[] { |
| 29 | + const root: DiffTreeNode[] = [] |
| 30 | +
|
| 31 | + for (const file of files) { |
| 32 | + const parts = file.path.split('/') |
| 33 | + let current = root |
| 34 | +
|
| 35 | + for (let i = 0; i < parts.length; i++) { |
| 36 | + const part = parts[i]! |
| 37 | + const isFile = i === parts.length - 1 |
| 38 | + const path = parts.slice(0, i + 1).join('/') |
| 39 | +
|
| 40 | + let node = current.find(n => n.name === part) |
| 41 | + if (!node) { |
| 42 | + node = { |
| 43 | + name: part, |
| 44 | + path, |
| 45 | + type: isFile ? 'file' : 'directory', |
| 46 | + changeType: isFile ? file.type : undefined, |
| 47 | + children: isFile ? undefined : [], |
| 48 | + } |
| 49 | + current.push(node) |
| 50 | + } |
| 51 | +
|
| 52 | + if (!isFile) { |
| 53 | + current = node.children! |
| 54 | + } |
| 55 | + } |
| 56 | + } |
| 57 | +
|
| 58 | + // Sort: directories first, then alphabetically |
| 59 | + function sortTree(nodes: DiffTreeNode[]): DiffTreeNode[] { |
| 60 | + return nodes |
| 61 | + .map(n => ({ |
| 62 | + ...n, |
| 63 | + children: n.children ? sortTree(n.children) : undefined, |
| 64 | + })) |
| 65 | + .sort((a, b) => { |
| 66 | + if (a.type !== b.type) return a.type === 'directory' ? -1 : 1 |
| 67 | + return a.name.localeCompare(b.name) |
| 68 | + }) |
| 69 | + } |
| 70 | +
|
| 71 | + return sortTree(root) |
| 72 | +} |
| 73 | +
|
| 74 | +// Use provided tree nodes or build from files |
| 75 | +const tree = computed(() => props.treeNodes ?? buildTree(props.files)) |
| 76 | +
|
| 77 | +// Check if a node or any of its children is currently selected |
| 78 | +function isNodeActive(node: DiffTreeNode): boolean { |
| 79 | + if (props.selectedPath === node.path) return true |
| 80 | + if (props.selectedPath?.startsWith(node.path + '/')) return true |
| 81 | + return false |
| 82 | +} |
| 83 | +
|
| 84 | +const expandedDirs = ref<Set<string>>(new Set()) |
| 85 | +
|
| 86 | +// Auto-expand all directories on mount (only at root level) |
| 87 | +onMounted(() => { |
| 88 | + if (props.depth === undefined || props.depth === 0) { |
| 89 | + function collectDirs(nodes: DiffTreeNode[]) { |
| 90 | + for (const node of nodes) { |
| 91 | + if (node.type === 'directory') { |
| 92 | + expandedDirs.value.add(node.path) |
| 93 | + if (node.children) collectDirs(node.children) |
| 94 | + } |
| 95 | + } |
| 96 | + } |
| 97 | + collectDirs(tree.value) |
| 98 | + } |
| 99 | +}) |
| 100 | +
|
| 101 | +function toggleDir(path: string) { |
| 102 | + if (expandedDirs.value.has(path)) { |
| 103 | + expandedDirs.value.delete(path) |
| 104 | + } else { |
| 105 | + expandedDirs.value.add(path) |
| 106 | + } |
| 107 | +} |
| 108 | +
|
| 109 | +function isExpanded(path: string): boolean { |
| 110 | + return expandedDirs.value.has(path) |
| 111 | +} |
| 112 | +
|
| 113 | +function getChangeIcon(type: 'added' | 'removed' | 'modified') { |
| 114 | + switch (type) { |
| 115 | + case 'added': |
| 116 | + return 'i-carbon-add-alt text-green-500' |
| 117 | + case 'removed': |
| 118 | + return 'i-carbon-subtract-alt text-red-500' |
| 119 | + case 'modified': |
| 120 | + return 'i-carbon-edit text-yellow-500' |
| 121 | + } |
| 122 | +} |
| 123 | +
|
| 124 | +function handleFileClick(node: DiffTreeNode) { |
| 125 | + const file = props.files.find(f => f.path === node.path) |
| 126 | + if (file) emit('select', file) |
| 127 | +} |
| 128 | +</script> |
| 129 | + |
| 130 | +<template> |
| 131 | + <ul class="list-none m-0 p-0" :class="depth === 0 ? 'py-2' : ''"> |
| 132 | + <li v-for="node in tree" :key="node.path"> |
| 133 | + <!-- Directory --> |
| 134 | + <template v-if="node.type === 'directory'"> |
| 135 | + <button |
| 136 | + type="button" |
| 137 | + class="w-full flex items-center gap-1.5 py-1.5 px-3 text-start font-mono text-sm transition-colors hover:bg-bg-muted" |
| 138 | + :class="isNodeActive(node) ? 'text-fg' : 'text-fg-muted'" |
| 139 | + :style="{ paddingLeft: `${depth * 12 + 12}px` }" |
| 140 | + @click="toggleDir(node.path)" |
| 141 | + > |
| 142 | + <span |
| 143 | + class="w-4 h-4 shrink-0 transition-transform" |
| 144 | + :class="[isExpanded(node.path) ? 'i-carbon:chevron-down' : 'i-carbon:chevron-right']" |
| 145 | + /> |
| 146 | + <span |
| 147 | + class="w-4 h-4 shrink-0" |
| 148 | + :class=" |
| 149 | + isExpanded(node.path) |
| 150 | + ? 'i-carbon:folder-open text-yellow-500' |
| 151 | + : 'i-carbon:folder text-yellow-600' |
| 152 | + " |
| 153 | + /> |
| 154 | + <span class="truncate">{{ node.name }}</span> |
| 155 | + </button> |
| 156 | + <DiffFileTree |
| 157 | + v-if="isExpanded(node.path) && node.children" |
| 158 | + :files="files" |
| 159 | + :tree-nodes="node.children" |
| 160 | + :selected-path="selectedPath" |
| 161 | + :depth="depth + 1" |
| 162 | + @select="emit('select', $event)" |
| 163 | + /> |
| 164 | + </template> |
| 165 | + |
| 166 | + <!-- File --> |
| 167 | + <template v-else> |
| 168 | + <button |
| 169 | + type="button" |
| 170 | + class="w-full flex items-center gap-1.5 py-1.5 px-3 font-mono text-sm transition-colors hover:bg-bg-muted text-start" |
| 171 | + :class="selectedPath === node.path ? 'bg-bg-muted text-fg' : 'text-fg-muted'" |
| 172 | + :style="{ paddingLeft: `${depth * 12 + 32}px` }" |
| 173 | + @click="handleFileClick(node)" |
| 174 | + > |
| 175 | + <span class="w-4 h-4 shrink-0" :class="getFileIcon(node.name)" /> |
| 176 | + <span class="w-3 h-3 shrink-0" :class="getChangeIcon(node.changeType!)" /> |
| 177 | + <span class="truncate">{{ node.name }}</span> |
| 178 | + </button> |
| 179 | + </template> |
| 180 | + </li> |
| 181 | + </ul> |
| 182 | +</template> |
0 commit comments