|
| 1 | +<script lang="ts"> |
| 2 | + import { ChevronDown, File, Folder, Icon, Search, FileImage, FileText, Check } from "lucide-svelte"; |
| 3 | + import { Input } from "../ui/input"; |
| 4 | + import { Separator } from "../ui/separator"; |
| 5 | + import { SvelteSet } from "svelte/reactivity"; |
| 6 | + import { cn } from "$lib/utils"; |
| 7 | + import type { VFSFile } from "./types"; |
| 8 | + import Button from "../ui/button/button.svelte"; |
| 9 | +
|
| 10 | + interface Props { |
| 11 | + files?: VFSFile[]; |
| 12 | + onFileSelect?: (file: VFSFile) => void | Promise<void>; |
| 13 | + } |
| 14 | +
|
| 15 | + interface VFSNode { |
| 16 | + name: string; |
| 17 | + type: "file" | "directory"; |
| 18 | + content?: string | URL; |
| 19 | + children: VFSNode[]; |
| 20 | + } |
| 21 | +
|
| 22 | + let selected = $state<VFSFile | null>(null); |
| 23 | + const { files = [], onFileSelect }: Props = $props(); |
| 24 | +
|
| 25 | + // ====== // |
| 26 | +
|
| 27 | + function getIcon(file: VFSNode) { |
| 28 | + const parts = file.name.split("."); |
| 29 | + return iconMap[parts.pop() || ""] || File; |
| 30 | + } |
| 31 | +
|
| 32 | + let search = $state(""); |
| 33 | + const visibleFiles = $derived.by(() => { |
| 34 | + if (!search.trim()) return files; |
| 35 | + return files.filter((file) => file.path.toLowerCase().includes(search.toLowerCase())); |
| 36 | + }); |
| 37 | +
|
| 38 | + function build(files: VFSFile[]): VFSNode[] { |
| 39 | + const nodeMap: Record<string, VFSNode> = { |
| 40 | + "/": { name: "/", type: "directory", children: [] }, |
| 41 | + }; |
| 42 | +
|
| 43 | + for (const file of files) { |
| 44 | + const path = file.path.startsWith("/") ? file.path : `/${file.path}`; |
| 45 | + if (path === "/") continue; |
| 46 | +
|
| 47 | + const segments = path.split("/").filter(Boolean); |
| 48 | + const fileName = segments.pop() || ""; |
| 49 | +
|
| 50 | + let currentPath = ""; |
| 51 | + let parent = nodeMap["/"]; |
| 52 | +
|
| 53 | + for (const segment of segments) { |
| 54 | + currentPath += `/${segment}`; |
| 55 | + if (!nodeMap[currentPath]) { |
| 56 | + nodeMap[currentPath] = { name: segment, type: "directory", children: [] }; |
| 57 | + parent.children.push(nodeMap[currentPath]); |
| 58 | + } |
| 59 | + parent = nodeMap[currentPath]; |
| 60 | + } |
| 61 | +
|
| 62 | + parent.children.push({ |
| 63 | + name: fileName, |
| 64 | + type: "file", |
| 65 | + content: file.content, |
| 66 | + children: [], |
| 67 | + }); |
| 68 | + } |
| 69 | +
|
| 70 | + Object.values(nodeMap).forEach((dir) => { |
| 71 | + dir.children.sort((a, b) => |
| 72 | + a.type !== b.type |
| 73 | + ? a.type === "directory" |
| 74 | + ? -1 |
| 75 | + : 1 |
| 76 | + : a.name.localeCompare(b.name), |
| 77 | + ); |
| 78 | + }); |
| 79 | +
|
| 80 | + return [nodeMap["/"]]; |
| 81 | + } |
| 82 | +
|
| 83 | + const vfs = $derived(build(visibleFiles)); |
| 84 | +
|
| 85 | + // ====== // |
| 86 | +
|
| 87 | + const iconMap: Record<string, typeof Icon> = { |
| 88 | + "pdf": FileText, |
| 89 | + "doc": FileText, |
| 90 | + "docx": FileText, |
| 91 | + "png": FileImage |
| 92 | + }; |
| 93 | +</script> |
| 94 | + |
| 95 | +<div id="vfs" class="file-tree"> |
| 96 | + <Input |
| 97 | + type="text" |
| 98 | + icon={Search} |
| 99 | + placeholder="Search files..." |
| 100 | + bind:value={search} |
| 101 | + class="bg-background focus:ring-primary w-full rounded border py-1 pl-8 pr-2 text-sm focus:outline-none focus:ring-1" |
| 102 | + /> |
| 103 | + |
| 104 | + <Separator class="my-2" /> |
| 105 | + |
| 106 | + <ul> |
| 107 | + {#if vfs.length > 0 && vfs[0].children.length > 0} |
| 108 | + {#each vfs[0].children as child, i} |
| 109 | + {@render renderNode(child, i)} |
| 110 | + {/each} |
| 111 | + {:else} |
| 112 | + <li class="text-muted-foreground p-4 text-center"> |
| 113 | + {search ? "No matching files found" : "No files available"} |
| 114 | + </li> |
| 115 | + {/if} |
| 116 | + </ul> |
| 117 | +</div> |
| 118 | +{#snippet renderNode(node: VFSNode, index: number)} |
| 119 | + {#if node.type === "directory"} |
| 120 | + <li class="directory-item"> |
| 121 | + <details open={search.length > 0}> |
| 122 | + <summary |
| 123 | + class="hover:bg-muted flex cursor-pointer select-none items-center rounded px-2 py-1 transition-colors" |
| 124 | + > |
| 125 | + <ChevronDown class="h-4 min-w-4 transition-transform" /> |
| 126 | + <Folder class="h-4 min-w-4" /> |
| 127 | + <span class="truncate font-medium">{node.name}</span> |
| 128 | + </summary> |
| 129 | + |
| 130 | + {#if node.children && node.children.length > 0} |
| 131 | + <ul class="border-muted ml-5 mt-1 border-l pl-2"> |
| 132 | + {#each node.children as child, i} |
| 133 | + {@render renderNode(child, index + i)} |
| 134 | + {/each} |
| 135 | + </ul> |
| 136 | + {/if} |
| 137 | + </details> |
| 138 | + </li> |
| 139 | + {:else} |
| 140 | + {@const IconComponent = getIcon(node)} |
| 141 | + {@const isSelected = selected?.path.endsWith(node.name)} |
| 142 | + <li class="file-item group"> |
| 143 | + <Button |
| 144 | + variant="ghost" |
| 145 | + class={cn( |
| 146 | + "hover:bg-muted w-full select-none justify-start shadow-none relative", |
| 147 | + isSelected && "bg-muted/70", |
| 148 | + )} |
| 149 | + onclick={() => { |
| 150 | + selected = files.find((f) => f.path.endsWith(node.name)) ?? null; |
| 151 | + onFileSelect?.(selected!); |
| 152 | + }} |
| 153 | + > |
| 154 | + <IconComponent class="h-4 min-w-4" /> |
| 155 | + <span class={cn("truncate", isSelected && "line-through")}>{node.name}</span> |
| 156 | + <Check class={cn("h-4 w-4 ml-auto", !isSelected && "opacity-0 group-hover:opacity-100")} /> |
| 157 | + </Button> |
| 158 | + </li> |
| 159 | + {/if} |
| 160 | +{/snippet} |
| 161 | + |
| 162 | +<style> |
| 163 | + :global(.file-tree details > summary::-webkit-details-marker) { |
| 164 | + display: none; |
| 165 | + } |
| 166 | +
|
| 167 | + :global(.file-tree details[open] > summary .lucide-chevron-down) { |
| 168 | + transform: rotate(0deg); |
| 169 | + } |
| 170 | +
|
| 171 | + :global(.file-tree details:not([open]) > summary .lucide-chevron-down) { |
| 172 | + transform: rotate(-90deg); |
| 173 | + } |
| 174 | +
|
| 175 | + :global(.file-tree) { |
| 176 | + font-family: 'Geist Mono', 'JetBrains Mono', 'Menlo', 'Consolas', 'Liberation Mono', monospace; |
| 177 | + } |
| 178 | +</style> |
0 commit comments