Skip to content

Commit c05ae67

Browse files
committed
feat: add code browsing/display
1 parent 7c1bc9d commit c05ae67

15 files changed

Lines changed: 1858 additions & 45 deletions

File tree

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
<script setup lang="ts">
2+
import type { PackageFileTree } from '#shared/types'
3+
import { getFileIcon } from '~/utils/file-icons'
4+
5+
const props = defineProps<{
6+
tree: PackageFileTree[]
7+
currentPath: string
8+
baseUrl: string
9+
}>()
10+
11+
// Get the current directory's contents
12+
const currentContents = computed(() => {
13+
if (!props.currentPath) {
14+
return props.tree
15+
}
16+
17+
const parts = props.currentPath.split('/')
18+
let current: PackageFileTree[] | undefined = props.tree
19+
20+
for (const part of parts) {
21+
const found: PackageFileTree | undefined = current?.find(n => n.name === part)
22+
if (!found || found.type === 'file') {
23+
return []
24+
}
25+
current = found.children
26+
}
27+
28+
return current ?? []
29+
})
30+
31+
// Get parent directory path
32+
const parentPath = computed(() => {
33+
if (!props.currentPath) return null
34+
const parts = props.currentPath.split('/')
35+
if (parts.length <= 1) return ''
36+
return parts.slice(0, -1).join('/')
37+
})
38+
39+
// Format file size
40+
function formatBytes(bytes: number): string {
41+
if (bytes < 1024) return `${bytes} B`
42+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} kB`
43+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
44+
}
45+
</script>
46+
47+
<template>
48+
<div class="directory-listing">
49+
<!-- Empty state -->
50+
<div
51+
v-if="currentContents.length === 0"
52+
class="py-20 text-center text-fg-muted"
53+
>
54+
<p>No files in this directory</p>
55+
</div>
56+
57+
<!-- File list -->
58+
<table
59+
v-else
60+
class="w-full"
61+
>
62+
<thead class="sr-only">
63+
<tr>
64+
<th>Name</th>
65+
<th>Size</th>
66+
</tr>
67+
</thead>
68+
<tbody>
69+
<!-- Parent directory link -->
70+
<tr
71+
v-if="parentPath !== null"
72+
class="border-b border-border hover:bg-bg-subtle transition-colors"
73+
>
74+
<td class="py-2 px-4">
75+
<NuxtLink
76+
:to="parentPath ? `${baseUrl}/${parentPath}` : baseUrl"
77+
class="flex items-center gap-2 font-mono text-sm text-fg-muted hover:text-fg transition-colors"
78+
>
79+
<span class="i-carbon-folder w-4 h-4 text-yellow-600" />
80+
<span>..</span>
81+
</NuxtLink>
82+
</td>
83+
<td />
84+
</tr>
85+
86+
<!-- Directory/file rows -->
87+
<tr
88+
v-for="node in currentContents"
89+
:key="node.path"
90+
class="border-b border-border hover:bg-bg-subtle transition-colors"
91+
>
92+
<td class="py-2 px-4">
93+
<NuxtLink
94+
:to="`${baseUrl}/${node.path}`"
95+
class="flex items-center gap-2 font-mono text-sm hover:text-fg transition-colors"
96+
:class="node.type === 'directory' ? 'text-fg' : 'text-fg-muted'"
97+
>
98+
<span
99+
v-if="node.type === 'directory'"
100+
class="i-carbon-folder w-4 h-4 text-yellow-600"
101+
/>
102+
<span
103+
v-else
104+
class="w-4 h-4"
105+
:class="getFileIcon(node.name)"
106+
/>
107+
<span>{{ node.name }}</span>
108+
</NuxtLink>
109+
</td>
110+
<td class="py-2 px-4 text-right font-mono text-xs text-fg-subtle">
111+
<span v-if="node.type === 'file' && node.size">
112+
{{ formatBytes(node.size) }}
113+
</span>
114+
</td>
115+
</tr>
116+
</tbody>
117+
</table>
118+
</div>
119+
</template>

app/components/CodeFileTree.vue

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
<script setup lang="ts">
2+
import type { PackageFileTree } from '#shared/types'
3+
import { getFileIcon } from '~/utils/file-icons'
4+
5+
const props = defineProps<{
6+
tree: PackageFileTree[]
7+
currentPath: string
8+
baseUrl: string
9+
depth?: number
10+
}>()
11+
12+
const depth = computed(() => props.depth ?? 0)
13+
14+
// Check if a node or any of its children is currently selected
15+
function isNodeActive(node: PackageFileTree): boolean {
16+
if (props.currentPath === node.path) return true
17+
if (props.currentPath.startsWith(node.path + '/')) return true
18+
return false
19+
}
20+
21+
// State for expanded directories
22+
const expandedDirs = ref<Set<string>>(new Set())
23+
24+
// Auto-expand directories in the current path
25+
watch(() => props.currentPath, (path) => {
26+
if (!path) return
27+
const parts = path.split('/')
28+
for (let i = 1; i <= parts.length; i++) {
29+
expandedDirs.value.add(parts.slice(0, i).join('/'))
30+
}
31+
}, { immediate: true })
32+
33+
function toggleDir(path: string) {
34+
if (expandedDirs.value.has(path)) {
35+
expandedDirs.value.delete(path)
36+
}
37+
else {
38+
expandedDirs.value.add(path)
39+
}
40+
}
41+
42+
function isExpanded(path: string): boolean {
43+
return expandedDirs.value.has(path)
44+
}
45+
</script>
46+
47+
<template>
48+
<ul
49+
class="list-none m-0 p-0"
50+
:class="depth === 0 ? 'py-2' : ''"
51+
>
52+
<li
53+
v-for="node in tree"
54+
:key="node.path"
55+
>
56+
<!-- Directory -->
57+
<template v-if="node.type === 'directory'">
58+
<button
59+
class="w-full flex items-center gap-1.5 py-1.5 px-3 text-left font-mono text-sm transition-colors hover:bg-bg-muted"
60+
:class="isNodeActive(node) ? 'text-fg' : 'text-fg-muted'"
61+
:style="{ paddingLeft: `${depth * 12 + 12}px` }"
62+
@click="toggleDir(node.path)"
63+
>
64+
<span
65+
class="w-4 h-4 shrink-0 transition-transform"
66+
:class="[
67+
isExpanded(node.path) ? 'i-carbon-chevron-down' : 'i-carbon-chevron-right',
68+
]"
69+
/>
70+
<span
71+
class="w-4 h-4 shrink-0"
72+
:class="isExpanded(node.path) ? 'i-carbon-folder-open text-yellow-500' : 'i-carbon-folder text-yellow-600'"
73+
/>
74+
<span class="truncate">{{ node.name }}</span>
75+
</button>
76+
<CodeFileTree
77+
v-if="isExpanded(node.path) && node.children"
78+
:tree="node.children"
79+
:current-path="currentPath"
80+
:base-url="baseUrl"
81+
:depth="depth + 1"
82+
/>
83+
</template>
84+
85+
<!-- File -->
86+
<template v-else>
87+
<NuxtLink
88+
:to="`${baseUrl}/${node.path}`"
89+
class="flex items-center gap-1.5 py-1.5 px-3 font-mono text-sm transition-colors hover:bg-bg-muted"
90+
:class="currentPath === node.path ? 'bg-bg-muted text-fg' : 'text-fg-muted'"
91+
:style="{ paddingLeft: `${depth * 12 + 32}px` }"
92+
>
93+
<span
94+
class="w-4 h-4 shrink-0"
95+
:class="getFileIcon(node.name)"
96+
/>
97+
<span class="truncate">{{ node.name }}</span>
98+
</NuxtLink>
99+
</template>
100+
</li>
101+
</ul>
102+
</template>
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
<script setup lang="ts">
2+
import type { PackageFileTree } from '#shared/types'
3+
4+
defineProps<{
5+
tree: PackageFileTree[]
6+
currentPath: string
7+
baseUrl: string
8+
}>()
9+
10+
const isOpen = ref(false)
11+
12+
// Close drawer on navigation
13+
const route = useRoute()
14+
watch(() => route.fullPath, () => {
15+
isOpen.value = false
16+
})
17+
18+
// Prevent body scroll when drawer is open
19+
watch(isOpen, (open) => {
20+
if (open) {
21+
document.body.style.overflow = 'hidden'
22+
}
23+
else {
24+
document.body.style.overflow = ''
25+
}
26+
})
27+
28+
// Cleanup on unmount
29+
onUnmounted(() => {
30+
document.body.style.overflow = ''
31+
})
32+
</script>
33+
34+
<template>
35+
<!-- Toggle button (mobile only) -->
36+
<button
37+
class="md:hidden fixed bottom-4 right-4 z-40 w-12 h-12 bg-bg-elevated border border-border rounded-full shadow-lg flex items-center justify-center text-fg-muted hover:text-fg transition-colors"
38+
aria-label="Toggle file tree"
39+
@click="isOpen = !isOpen"
40+
>
41+
<span
42+
class="w-5 h-5"
43+
:class="isOpen ? 'i-carbon-close' : 'i-carbon-folder'"
44+
/>
45+
</button>
46+
47+
<!-- Backdrop -->
48+
<Transition
49+
enter-active-class="transition-opacity duration-200"
50+
enter-from-class="opacity-0"
51+
enter-to-class="opacity-100"
52+
leave-active-class="transition-opacity duration-200"
53+
leave-from-class="opacity-100"
54+
leave-to-class="opacity-0"
55+
>
56+
<div
57+
v-if="isOpen"
58+
class="md:hidden fixed inset-0 z-40 bg-black/50"
59+
@click="isOpen = false"
60+
/>
61+
</Transition>
62+
63+
<!-- Drawer -->
64+
<Transition
65+
enter-active-class="transition-transform duration-200"
66+
enter-from-class="-translate-x-full"
67+
enter-to-class="translate-x-0"
68+
leave-active-class="transition-transform duration-200"
69+
leave-from-class="translate-x-0"
70+
leave-to-class="-translate-x-full"
71+
>
72+
<aside
73+
v-if="isOpen"
74+
class="md:hidden fixed inset-y-0 left-0 z-50 w-72 bg-bg-subtle border-r border-border overflow-y-auto"
75+
>
76+
<div class="sticky top-0 bg-bg-subtle border-b border-border px-4 py-3 flex items-center justify-between">
77+
<span class="font-mono text-sm text-fg-muted">Files</span>
78+
<button
79+
class="text-fg-muted hover:text-fg transition-colors"
80+
aria-label="Close file tree"
81+
@click="isOpen = false"
82+
>
83+
<span class="i-carbon-close w-5 h-5" />
84+
</button>
85+
</div>
86+
<CodeFileTree
87+
:tree="tree"
88+
:current-path="currentPath"
89+
:base-url="baseUrl"
90+
/>
91+
</aside>
92+
</Transition>
93+
</template>

0 commit comments

Comments
 (0)