Skip to content

Commit 66d2633

Browse files
committed
use custom diff file tree
1 parent f12bbf3 commit 66d2633

2 files changed

Lines changed: 189 additions & 24 deletions

File tree

app/components/DiffFileTree.vue

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
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>

app/pages/diff/[...path].vue

Lines changed: 7 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -394,35 +394,18 @@ useSeoMeta({
394394
</span>
395395
</summary>
396396

397-
<!-- File list (scrollable) -->
397+
<!-- File tree (scrollable) -->
398398
<div class="flex-1 overflow-y-auto min-h-0">
399399
<div v-if="filteredChanges.length === 0" class="p-8 text-center text-xs text-fg-muted">
400400
No {{ fileFilter === 'all' ? '' : fileFilter }} files
401401
</div>
402402

403-
<nav v-else class="divide-y divide-border">
404-
<button
405-
v-for="file in filteredChanges"
406-
:key="file.path"
407-
type="button"
408-
class="w-full px-3 py-2 flex items-center gap-2 text-sm text-left hover:bg-bg-muted transition-colors group"
409-
:class="{
410-
'bg-bg-muted border-l-3 border-l-blue-500': selectedFile?.path === file.path,
411-
}"
412-
@click="selectedFile = file"
413-
>
414-
<!-- File icon -->
415-
<span :class="[getFileIcon(file.path), 'w-3.5 h-3.5 shrink-0']" />
416-
417-
<!-- Change type indicator -->
418-
<span :class="[getChangeIcon(file.type), 'w-3 h-3 shrink-0']" />
419-
420-
<!-- File path -->
421-
<span class="font-mono text-[10px] truncate min-w-0 group-hover:text-fg">
422-
{{ file.path }}
423-
</span>
424-
</button>
425-
</nav>
403+
<DiffFileTree
404+
v-else
405+
:files="filteredChanges"
406+
:selected-path="selectedFile?.path ?? null"
407+
@select="selectedFile = $event"
408+
/>
426409
</div>
427410
</details>
428411
</aside>

0 commit comments

Comments
 (0)