File Tree
Expandable file and folder tree with selection.
import { FileTree, FileTreeFile, FileTreeFolder,} from "@/components/ai/file-tree";import { useState } from "react";
export default function Demo() { const [selected, setSelected] = useState("src/lib/keeper.ts");
return ( <FileTree className="w-72" defaultExpanded={new Set(["src", "src/lib"])} onSelect={setSelected} selectedPath={selected} > <FileTreeFolder name="src" path="src"> <FileTreeFolder name="lib" path="src/lib"> <FileTreeFile name="keeper.ts" path="src/lib/keeper.ts" /> <FileTreeFile name="drift.ts" path="src/lib/drift.ts" /> </FileTreeFolder> <FileTreeFolder name="components" path="src/components"> <FileTreeFile name="ticker.tsx" path="src/components/ticker.tsx" /> </FileTreeFolder> <FileTreeFile name="app.tsx" path="src/app.tsx" /> </FileTreeFolder> <FileTreeFolder name="tests" path="tests"> <FileTreeFile name="keeper.test.ts" path="tests/keeper.test.ts" /> </FileTreeFolder> <FileTreeFile name="package.json" path="package.json" /> </FileTree> );}<script setup lang="ts">import { ref } from "vue";import { FileTree, FileTreeFile, FileTreeFolder } from "@/components/ai/file-tree";
const selected = ref("src/lib/keeper.ts");const expanded = ref(new Set(["src", "src/lib"]));</script>
<template> <FileTree v-model:expanded="expanded" class="w-72" :selected-path="selected" @select="selected = $event" > <FileTreeFolder name="src" path="src"> <FileTreeFolder name="lib" path="src/lib"> <FileTreeFile name="keeper.ts" path="src/lib/keeper.ts" /> <FileTreeFile name="drift.ts" path="src/lib/drift.ts" /> </FileTreeFolder> <FileTreeFolder name="components" path="src/components"> <FileTreeFile name="ticker.tsx" path="src/components/ticker.tsx" /> </FileTreeFolder> <FileTreeFile name="app.tsx" path="src/app.tsx" /> </FileTreeFolder> <FileTreeFolder name="tests" path="tests"> <FileTreeFile name="keeper.test.ts" path="tests/keeper.test.ts" /> </FileTreeFolder> <FileTreeFile name="package.json" path="package.json" /> </FileTree></template><script lang="ts"> import { FileTree, FileTreeFile, FileTreeFolder } from "$lib/components/ai/file-tree";
let selected = $state("src/lib/keeper.ts"); let expanded = $state(new Set(["src", "src/lib"]));</script>
<FileTree class="w-72" bind:expanded selectedPath={selected} onSelect={(p) => (selected = p)}> <FileTreeFolder name="src" path="src"> <FileTreeFolder name="lib" path="src/lib"> <FileTreeFile name="keeper.ts" path="src/lib/keeper.ts" /> <FileTreeFile name="drift.ts" path="src/lib/drift.ts" /> </FileTreeFolder> <FileTreeFolder name="components" path="src/components"> <FileTreeFile name="ticker.tsx" path="src/components/ticker.tsx" /> </FileTreeFolder> <FileTreeFile name="app.tsx" path="src/app.tsx" /> </FileTreeFolder> <FileTreeFolder name="tests" path="tests"> <FileTreeFile name="keeper.test.ts" path="tests/keeper.test.ts" /> </FileTreeFolder> <FileTreeFile name="package.json" path="package.json" /></FileTree>Built on collapsible.
Installation
Section titled “Installation”npx shadcn@latest add @edmi-ui/ai-file-treepnpm dlx shadcn@latest add @edmi-ui/ai-file-treeyarn dlx shadcn@latest add @edmi-ui/ai-file-treebunx shadcn@latest add @edmi-ui/ai-file-treePick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Add the Edmi components this one builds on:
collapsible.Copy the code from Component source into your project and update the import paths to match your aliases.
npx shadcn-vue@latest add @edmi-ui/ai-file-treepnpm dlx shadcn-vue@latest add @edmi-ui/ai-file-treeyarn dlx shadcn-vue@latest add @edmi-ui/ai-file-treebunx shadcn-vue@latest add @edmi-ui/ai-file-treePick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Install the dependencies:
Terminal window npm install @lucide/vue @vueuse/coreTerminal window pnpm add @lucide/vue @vueuse/coreTerminal window yarn add @lucide/vue @vueuse/coreTerminal window bun add @lucide/vue @vueuse/coreAdd the Edmi components this one builds on:
collapsible.Copy the code from Component source into your project and update the import paths to match your aliases.
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-file-tree.jsonpnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-file-tree.jsonyarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-file-tree.jsonbunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-file-tree.jsonPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Add the Edmi components this one builds on:
collapsible.Copy the code from Component source into your project and update the import paths to match your aliases.
import { FileTree, FileTreeFile, FileTreeFolder,} from "@/components/ai/file-tree";
<FileTree className="w-72" defaultExpanded={new Set(["src", "src/lib"])}> <FileTreeFolder name="src" path="src"> <FileTreeFolder name="lib" path="src/lib"> <FileTreeFile name="keeper.ts" path="src/lib/keeper.ts" /> <FileTreeFile name="drift.ts" path="src/lib/drift.ts" /> </FileTreeFolder> <FileTreeFolder name="components" path="src/components"> <FileTreeFile name="ticker.tsx" path="src/components/ticker.tsx" /> </FileTreeFolder> <FileTreeFile name="app.tsx" path="src/app.tsx" /> </FileTreeFolder> <FileTreeFolder name="tests" path="tests"> <FileTreeFile name="keeper.test.ts" path="tests/keeper.test.ts" /> </FileTreeFolder> <FileTreeFile name="package.json" path="package.json" /></FileTree><script setup lang="ts">import { FileTree, FileTreeFile, FileTreeFolder,} from "@/components/ai/file-tree";</script>
<template> <FileTree class="w-72" > <FileTreeFolder name="src" path="src"> <FileTreeFolder name="lib" path="src/lib"> <FileTreeFile name="keeper.ts" path="src/lib/keeper.ts" /> <FileTreeFile name="drift.ts" path="src/lib/drift.ts" /> </FileTreeFolder> <FileTreeFolder name="components" path="src/components"> <FileTreeFile name="ticker.tsx" path="src/components/ticker.tsx" /> </FileTreeFolder> <FileTreeFile name="app.tsx" path="src/app.tsx" /> </FileTreeFolder> <FileTreeFolder name="tests" path="tests"> <FileTreeFile name="keeper.test.ts" path="tests/keeper.test.ts" /> </FileTreeFolder> <FileTreeFile name="package.json" path="package.json" /> </FileTree></template><script lang="ts"> import { FileTree, FileTreeFile, FileTreeFolder, } from "$lib/components/ai/file-tree";</script>
<FileTree class="w-72" bind:expanded> <FileTreeFolder name="src" path="src"> <FileTreeFolder name="lib" path="src/lib"> <FileTreeFile name="keeper.ts" path="src/lib/keeper.ts" /> <FileTreeFile name="drift.ts" path="src/lib/drift.ts" /> </FileTreeFolder> <FileTreeFolder name="components" path="src/components"> <FileTreeFile name="ticker.tsx" path="src/components/ticker.tsx" /> </FileTreeFolder> <FileTreeFile name="app.tsx" path="src/app.tsx" /> </FileTreeFolder> <FileTreeFolder name="tests" path="tests"> <FileTreeFile name="keeper.test.ts" path="tests/keeper.test.ts" /> </FileTreeFolder> <FileTreeFile name="package.json" path="package.json" /></FileTree>Component source
Section titled “Component source”"use client";
import { cn } from "cn";import type { HTMLAttributes, ReactNode } from "react";import { createContext, useCallback, useContext, useMemo, useState,} from "react";import { CaretRightIcon, FolderOpenIcon, FolderIcon, FileCodeIcon } from "@phosphor-icons/react";import { Collapsible, CollapsibleContent, CollapsibleTrigger,} from "@/registry/edmi/ui/collapsible";
interface FileTreeContextType { expandedPaths: Set<string>; togglePath: (path: string) => void; selectedPath?: string; onSelect?: (path: string) => void;}
const noop = () => {};
const FileTreeContext = createContext<FileTreeContextType>({ expandedPaths: new Set(), togglePath: noop,});
// One row: 28px, 13px sans, selected = --accent fill + weight 500 (board AI 05).const ROW = "flex h-7 w-full items-center gap-[7px] rounded-md px-2 text-left text-[13px] whitespace-nowrap transition-colors hover:bg-muted data-[selected]:bg-accent data-[selected]:font-medium";
export type FileTreeProps = Omit<HTMLAttributes<HTMLDivElement>, "onSelect"> & { expanded?: Set<string>; defaultExpanded?: Set<string>; selectedPath?: string; onSelect?: (path: string) => void; onExpandedChange?: (expanded: Set<string>) => void;};
export const FileTree = ({ expanded: controlledExpanded, defaultExpanded = new Set(), selectedPath, onSelect, onExpandedChange, className, children, ...props}: FileTreeProps) => { const [internalExpanded, setInternalExpanded] = useState(defaultExpanded); const expandedPaths = controlledExpanded ?? internalExpanded;
const togglePath = useCallback( (path: string) => { const newExpanded = new Set(expandedPaths); if (newExpanded.has(path)) { newExpanded.delete(path); } else { newExpanded.add(path); } setInternalExpanded(newExpanded); onExpandedChange?.(newExpanded); }, [expandedPaths, onExpandedChange], );
const contextValue = useMemo( () => ({ expandedPaths, onSelect, selectedPath, togglePath }), [expandedPaths, onSelect, selectedPath, togglePath], );
return ( <FileTreeContext.Provider value={contextValue}> <div data-slot="ai-file-tree" className={cn( "rounded-xl border border-border bg-card p-2 text-[13px] text-card-foreground", className, )} role="tree" {...props} > {children} </div> </FileTreeContext.Provider> );};
export type FileTreeIconProps = HTMLAttributes<HTMLSpanElement>;
export const FileTreeIcon = ({ className, children, ...props}: FileTreeIconProps) => ( <span className={cn("inline-flex shrink-0 text-muted-foreground", className)} {...props} > {children} </span>);
export type FileTreeNameProps = HTMLAttributes<HTMLSpanElement>;
export const FileTreeName = ({ className, children, ...props}: FileTreeNameProps) => ( <span className={cn("truncate", className)} {...props}> {children} </span>);
interface FileTreeFolderContextType { path: string; name: string; isExpanded: boolean;}
const FileTreeFolderContext = createContext<FileTreeFolderContextType>({ isExpanded: false, name: "", path: "",});
export type FileTreeFolderProps = HTMLAttributes<HTMLDivElement> & { path: string; name: string;};
export const FileTreeFolder = ({ path, name, className, children, ...props}: FileTreeFolderProps) => { const { expandedPaths, togglePath, selectedPath, onSelect } = useContext(FileTreeContext); const isExpanded = expandedPaths.has(path); const isSelected = selectedPath === path;
const handleOpenChange = useCallback(() => { togglePath(path); }, [togglePath, path]);
// Clicking the label selects the folder and toggles it, like the chevron. const handleSelect = useCallback(() => { onSelect?.(path); togglePath(path); }, [onSelect, togglePath, path]);
const folderContextValue = useMemo( () => ({ isExpanded, name, path }), [isExpanded, name, path], );
return ( <FileTreeFolderContext.Provider value={folderContextValue}> <Collapsible onOpenChange={handleOpenChange} open={isExpanded}> <div aria-expanded={isExpanded} className={className} role="treeitem" tabIndex={-1} {...props} > <div className={ROW} data-selected={isSelected ? "" : undefined}> <CollapsibleTrigger aria-label={isExpanded ? `Collapse ${name}` : `Expand ${name}`} className="flex shrink-0 cursor-pointer items-center border-none bg-transparent p-0 text-muted-foreground" > <CaretRightIcon className={cn( "size-3.5 transition-transform", isExpanded && "rotate-90", )} /> </CollapsibleTrigger> <button className="flex min-w-0 flex-1 cursor-pointer items-center gap-[7px] border-none bg-transparent p-0 text-left" onClick={handleSelect} type="button" > <FileTreeIcon className="text-chart-3"> {isExpanded ? ( <FolderOpenIcon className="size-4" /> ) : ( <FolderIcon className="size-4" /> )} </FileTreeIcon> <FileTreeName>{name}</FileTreeName> </button> </div> <CollapsibleContent> <div className="pl-4">{children}</div> </CollapsibleContent> </div> </Collapsible> </FileTreeFolderContext.Provider> );};
interface FileTreeFileContextType { path: string; name: string;}
const FileTreeFileContext = createContext<FileTreeFileContextType>({ name: "", path: "",});
export type FileTreeFileProps = HTMLAttributes<HTMLDivElement> & { path: string; name: string; icon?: ReactNode;};
export const FileTreeFile = ({ path, name, icon, className, children, ...props}: FileTreeFileProps) => { const { selectedPath, onSelect } = useContext(FileTreeContext); const isSelected = selectedPath === path;
const handleClick = useCallback(() => { onSelect?.(path); }, [onSelect, path]);
const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "Enter" || e.key === " ") { onSelect?.(path); } }, [onSelect, path], );
const fileContextValue = useMemo(() => ({ name, path }), [name, path]);
return ( <FileTreeFileContext.Provider value={fileContextValue}> <div aria-selected={isSelected} className={cn(ROW, "cursor-pointer", className)} data-selected={isSelected ? "" : undefined} onClick={handleClick} onKeyDown={handleKeyDown} role="treeitem" tabIndex={0} {...props} > {children ?? ( <> {/* Spacer so files line up with folder names */} <span className="w-3.5 shrink-0" /> <FileTreeIcon> {icon ?? ( <FileCodeIcon className="size-4" /> )} </FileTreeIcon> <FileTreeName>{name}</FileTreeName> </> )} </div> </FileTreeFileContext.Provider> );};
export type FileTreeActionsProps = HTMLAttributes<HTMLDivElement>;
const stopPropagation = (e: React.SyntheticEvent) => e.stopPropagation();
export const FileTreeActions = ({ className, children, ...props}: FileTreeActionsProps) => ( // biome-ignore lint/a11y/useSemanticElements: a plain group that only stops propagation <div className={cn("ml-auto flex items-center gap-1", className)} onClick={stopPropagation} onKeyDown={stopPropagation} role="group" {...props} > {children} </div>);<script setup lang="ts">import type { HTMLAttributes, Ref } from "vue"import { useVModel } from "@vueuse/core"import { provide, toRef } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { FileTreeKey } from "./context"
const props = withDefaults(defineProps<{ expanded?: Set<string> defaultExpanded?: Set<string> selectedPath?: string class?: HTMLAttributes["class"]}>(), { expanded: undefined, defaultExpanded: () => new Set<string>(), selectedPath: undefined,})
const emit = defineEmits<{ (e: "select", path: string): void (e: "update:expanded", expanded: Set<string>): void}>()
const expandedPaths = useVModel(props, "expanded", emit, { passive: true, defaultValue: props.defaultExpanded,})
provide(FileTreeKey, { expandedPaths: expandedPaths as Ref<Set<string>>, togglePath: (path: string) => { const next = new Set(expandedPaths.value) if (next.has(path)) { next.delete(path) } else { next.add(path) } expandedPaths.value = next }, selectedPath: toRef(props, "selectedPath"), select: (path: string) => emit("select", path),})</script>
<template> <div data-slot="ai-file-tree" role="tree" :class="cn('rounded-xl border border-border bg-card p-2 text-[13px] text-card-foreground', props.class)" > <slot /> </div></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { cn } from "@/registry/edmi/lib/utils"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()</script>
<template> <div role="group" :class="cn('ml-auto flex items-center gap-1', props.class)" @click.stop @keydown.stop > <slot /> </div></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { FileCodeIcon } from "@lucide/vue"import { computed } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { FILE_TREE_ROW, useFileTreeContext } from "./context"import FileTreeIcon from "./FileTreeIcon.vue"import FileTreeName from "./FileTreeName.vue"
// Default content is a spacer, the `icon` slot (or a file icon) and the name; pass a default slot to replace all of it.const props = defineProps<{ path: string name: string class?: HTMLAttributes["class"]}>()
const { selectedPath, select } = useFileTreeContext()const isSelected = computed(() => selectedPath.value === props.path)</script>
<template> <div role="treeitem" tabindex="0" :aria-selected="isSelected" :data-selected="isSelected ? '' : undefined" :class="cn(FILE_TREE_ROW, 'cursor-pointer', props.class)" @click="select(props.path)" @keydown.enter="select(props.path)" @keydown.space.prevent="select(props.path)" > <slot> <!-- Spacer so files line up with folder names --> <span class="w-3.5 shrink-0" /> <FileTreeIcon> <slot name="icon"> <FileCodeIcon class="size-4" /> </slot> </FileTreeIcon> <FileTreeName>{{ props.name }}</FileTreeName> </slot> </div></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { ChevronRightIcon, FolderIcon, FolderOpenIcon } from "@lucide/vue"import { computed } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/registry/edmi/ui/collapsible"import { FILE_TREE_ROW, useFileTreeContext } from "./context"import FileTreeIcon from "./FileTreeIcon.vue"import FileTreeName from "./FileTreeName.vue"
const props = defineProps<{ path: string name: string class?: HTMLAttributes["class"]}>()
const { expandedPaths, togglePath, selectedPath, select } = useFileTreeContext()
const isExpanded = computed(() => expandedPaths.value.has(props.path))const isSelected = computed(() => selectedPath.value === props.path)
// Clicking the label selects the folder and toggles it, like the chevron.function onLabelClick() { select(props.path) togglePath(props.path)}</script>
<template> <Collapsible :open="isExpanded" @update:open="togglePath(props.path)"> <div role="treeitem" :aria-expanded="isExpanded" tabindex="-1" :class="props.class"> <div :class="FILE_TREE_ROW" :data-selected="isSelected ? '' : undefined"> <CollapsibleTrigger :aria-label="isExpanded ? `Collapse ${props.name}` : `Expand ${props.name}`" class="flex shrink-0 cursor-pointer items-center border-none bg-transparent p-0 text-muted-foreground" > <ChevronRightIcon :class="cn('size-3.5 transition-transform', isExpanded && 'rotate-90')" /> </CollapsibleTrigger> <button class="flex min-w-0 flex-1 cursor-pointer items-center gap-[7px] border-none bg-transparent p-0 text-left" type="button" @click="onLabelClick" > <FileTreeIcon class="text-chart-3"> <FolderOpenIcon v-if="isExpanded" class="size-4" /> <FolderIcon v-else class="size-4" /> </FileTreeIcon> <FileTreeName>{{ props.name }}</FileTreeName> </button> </div> <CollapsibleContent> <div class="pl-4"> <slot /> </div> </CollapsibleContent> </div> </Collapsible></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { cn } from "@/registry/edmi/lib/utils"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()</script>
<template> <span :class="cn('inline-flex shrink-0 text-muted-foreground', props.class)"> <slot /> </span></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { cn } from "@/registry/edmi/lib/utils"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()</script>
<template> <span :class="cn('truncate', props.class)"> <slot /> </span></template>import type { InjectionKey, Ref } from "vue";import { inject } from "vue";
export interface FileTreeContext { expandedPaths: Ref<Set<string>>; togglePath: (path: string) => void; selectedPath: Ref<string | undefined>; select: (path: string) => void;}
export const FileTreeKey: InjectionKey<FileTreeContext> = Symbol("FileTree");
export function useFileTreeContext() { const ctx = inject(FileTreeKey); if (!ctx) throw new Error("FileTree parts must be used within <FileTree />"); return ctx;}
// One row: 28px, 13px sans, selected = --accent fill + weight 500 (board AI 05).export const FILE_TREE_ROW = "flex h-7 w-full items-center gap-[7px] rounded-md px-2 text-left text-[13px] whitespace-nowrap transition-colors hover:bg-muted data-[selected]:bg-accent data-[selected]:font-medium";export * from "./context";export { default as FileTree } from "./FileTree.vue";export { default as FileTreeActions } from "./FileTreeActions.vue";export { default as FileTreeFile } from "./FileTreeFile.vue";export { default as FileTreeFolder } from "./FileTreeFolder.vue";export { default as FileTreeIcon } from "./FileTreeIcon.vue";export { default as FileTreeName } from "./FileTreeName.vue";<script lang="ts"> import { cn } from "$lib/utils.js"; import type { Snippet } from "svelte"; import type { HTMLAttributes } from "svelte/elements";
let { class: className, children, ...restProps }: HTMLAttributes<HTMLDivElement> & { children?: Snippet } = $props();</script>
<div role="group" class={cn("ml-auto flex items-center gap-1", className)} onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} {...restProps}> {@render children?.()}</div><script lang="ts"> import FileCodeIcon from "phosphor-svelte/lib/FileCode"; import { cn } from "$lib/utils.js"; import type { Snippet } from "svelte"; import type { HTMLAttributes } from "svelte/elements"; import FileTreeIcon from "./file-tree-icon.svelte"; import FileTreeName from "./file-tree-name.svelte"; import { FILE_TREE_ROW, useFileTreeContext } from "./use-file-tree.svelte.js";
// Default content is a spacer, the `icon` snippet (or a file icon) and the name; pass `children` to replace all of it. let { path, name, icon, class: className, children, ...restProps }: HTMLAttributes<HTMLDivElement> & { path: string; name: string; icon?: Snippet; children?: Snippet; } = $props();
const tree = useFileTreeContext(); const isSelected = $derived(tree.selectedPath === path);
function onkeydown(event: KeyboardEvent) { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); tree.select(path); } }</script>
<div role="treeitem" tabindex="0" aria-selected={isSelected} data-selected={isSelected ? "" : undefined} class={cn(FILE_TREE_ROW, "cursor-pointer", className)} onclick={() => tree.select(path)} {onkeydown} {...restProps}> {#if children} {@render children()} {:else} <!-- Spacer so files line up with folder names --> <span class="w-3.5 shrink-0"></span> <FileTreeIcon> {#if icon} {@render icon()} {:else} <FileCodeIcon class="size-4" /> {/if} </FileTreeIcon> <FileTreeName>{name}</FileTreeName> {/if}</div><script lang="ts"> import CaretRightIcon from "phosphor-svelte/lib/CaretRight"; import FolderOpenIcon from "phosphor-svelte/lib/FolderOpen"; import FolderIcon from "phosphor-svelte/lib/Folder"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "$lib/registry/ui/collapsible/index.js"; import { cn } from "$lib/utils.js"; import type { Snippet } from "svelte"; import type { HTMLAttributes } from "svelte/elements"; import FileTreeIcon from "./file-tree-icon.svelte"; import FileTreeName from "./file-tree-name.svelte"; import { FILE_TREE_ROW, useFileTreeContext } from "./use-file-tree.svelte.js";
let { path, name, class: className, children, ...restProps }: HTMLAttributes<HTMLDivElement> & { path: string; name: string; children?: Snippet } = $props();
const tree = useFileTreeContext(); const isExpanded = $derived(tree.expandedPaths.has(path)); const isSelected = $derived(tree.selectedPath === path);</script>
<Collapsible open={isExpanded} onOpenChange={() => tree.togglePath(path)}> <div role="treeitem" aria-selected={isSelected} aria-expanded={isExpanded} tabindex="-1" class={className} {...restProps}> <div class={FILE_TREE_ROW} data-selected={isSelected ? "" : undefined}> <CollapsibleTrigger aria-label={isExpanded ? `Collapse ${name}` : `Expand ${name}`} class="flex shrink-0 cursor-pointer items-center border-none bg-transparent p-0 text-muted-foreground" > <CaretRightIcon class={cn("size-3.5 transition-transform", isExpanded && "rotate-90")} /> </CollapsibleTrigger> <button class="flex min-w-0 flex-1 cursor-pointer items-center gap-[7px] border-none bg-transparent p-0 text-left" type="button" onclick={() => { tree.select(path); tree.togglePath(path); }} > <FileTreeIcon class="text-chart-3"> {#if isExpanded} <FolderOpenIcon class="size-4" /> {:else} <FolderIcon class="size-4" /> {/if} </FileTreeIcon> <FileTreeName>{name}</FileTreeName> </button> </div> <CollapsibleContent> <div class="pl-4"> {@render children?.()} </div> </CollapsibleContent> </div></Collapsible><script lang="ts"> import { cn } from "$lib/utils.js"; import type { Snippet } from "svelte"; import type { HTMLAttributes } from "svelte/elements";
let { class: className, children, ...restProps }: HTMLAttributes<HTMLSpanElement> & { children?: Snippet } = $props();</script>
<span class={cn("inline-flex shrink-0 text-muted-foreground", className)} {...restProps}> {@render children?.()}</span><script lang="ts"> import { cn } from "$lib/utils.js"; import type { Snippet } from "svelte"; import type { HTMLAttributes } from "svelte/elements";
let { class: className, children, ...restProps }: HTMLAttributes<HTMLSpanElement> & { children?: Snippet } = $props();</script>
<span class={cn("truncate", className)} {...restProps}> {@render children?.()}</span><script lang="ts"> import { cn } from "$lib/utils.js"; import type { Snippet } from "svelte"; import type { HTMLAttributes } from "svelte/elements"; import { setFileTreeContext } from "./use-file-tree.svelte.js";
let { expanded = $bindable(new Set<string>()), selectedPath, onSelect, class: className, children, ...restProps }: Omit<HTMLAttributes<HTMLDivElement>, "onselect"> & { expanded?: Set<string>; selectedPath?: string; onSelect?: (path: string) => void; children?: Snippet; } = $props();
setFileTreeContext({ get expandedPaths() { return expanded; }, get selectedPath() { return selectedPath; }, togglePath(path: string) { const next = new Set(expanded); if (next.has(path)) next.delete(path); else next.add(path); expanded = next; }, select(path: string) { onSelect?.(path); }, });</script>
<div data-slot="ai-file-tree" role="tree" class={cn("rounded-xl border border-border bg-card p-2 text-[13px] text-card-foreground", className)} {...restProps}> {@render children?.()}</div>import { getContext, setContext } from "svelte";
/** Getter object keeps the context reactive. */export interface FileTreeContextValue { readonly expandedPaths: Set<string>; readonly selectedPath: string | undefined; togglePath: (path: string) => void; select: (path: string) => void;}
const KEY = Symbol("ai-file-tree");
export function setFileTreeContext(value: FileTreeContextValue) { return setContext(KEY, value);}
export function useFileTreeContext(): FileTreeContextValue { const ctx = getContext<FileTreeContextValue | undefined>(KEY); if (!ctx) { throw new Error("FileTree parts must be used within <FileTree>"); } return ctx;}
// One row: 28px, 13px sans, selected = --accent fill + weight 500 (board AI 05).export const FILE_TREE_ROW = "flex h-7 w-full items-center gap-[7px] rounded-md px-2 text-left text-[13px] whitespace-nowrap transition-colors hover:bg-muted data-[selected]:bg-accent data-[selected]:font-medium";import Root from "./file-tree.svelte";import Actions from "./file-tree-actions.svelte";import File from "./file-tree-file.svelte";import Folder from "./file-tree-folder.svelte";import Icon from "./file-tree-icon.svelte";import Name from "./file-tree-name.svelte";
export { useFileTreeContext } from "./use-file-tree.svelte.js";
export { Actions as FileTreeActions, File as FileTreeFile, Folder as FileTreeFolder, Icon as FileTreeIcon, Name as FileTreeName, Root as FileTree,};| Part | Prop | Type | Default |
|---|---|---|---|
| FileTree | expanded, defaultExpanded, onExpandedChange |
Set<string> |
|
| FileTree | selectedPath, onSelect |
string, (path) => void |
|
| FileTreeFolder | path, name |
string |
|
| FileTreeFile | path, name, icon |
string, ReactNode |
|
| FileTreeActions | stops click propagation |
✦ Edmi additions
Section titled “✦ Edmi additions”- Rows are 28px with an
accentfill for the selected path; folder icons usechart-3; no connector lines. - Vue:
v-model:expandedand theselectevent; Svelte:bind:expandedandonSelect.FileTreeFiletakes aniconslot (Vue) oriconsnippet (Svelte).