Skip to content
edmi-ui

File Tree

Expandable file and folder tree with selection.

StackBlitz ↗StackBlitz ↗StackBlitz ↗
keeper.ts
drift.ts
app.tsx
package.json
keeper.ts
drift.ts
app.tsx
package.json
keeper.ts
drift.ts
app.tsx
package.json

Built on collapsible.

Terminal window
npx shadcn@latest add @edmi-ui/ai-file-tree
Terminal window
pnpm dlx shadcn@latest add @edmi-ui/ai-file-tree
Terminal window
yarn dlx shadcn@latest add @edmi-ui/ai-file-tree
Terminal window
bunx shadcn@latest add @edmi-ui/ai-file-tree

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-vue@latest add @edmi-ui/ai-file-tree
Terminal window
pnpm dlx shadcn-vue@latest add @edmi-ui/ai-file-tree
Terminal window
yarn dlx shadcn-vue@latest add @edmi-ui/ai-file-tree
Terminal window
bunx shadcn-vue@latest add @edmi-ui/ai-file-tree

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-file-tree.json
Terminal window
pnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-file-tree.json
Terminal window
yarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-file-tree.json
Terminal window
bunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-file-tree.json

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

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>
registry/components/ai/file-tree.tsx
"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>
);
registry/components/ai/file-tree/FileTree.vue
<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>
registry/components/ai/file-tree/FileTreeActions.vue
<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>
registry/components/ai/file-tree/FileTreeFile.vue
<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>
registry/components/ai/file-tree/FileTreeFolder.vue
<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>
registry/components/ai/file-tree/FileTreeIcon.vue
<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>
registry/components/ai/file-tree/FileTreeName.vue
<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>
registry/components/ai/file-tree/context.ts
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";
registry/components/ai/file-tree/index.ts
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";
src/lib/registry/ai/file-tree/file-tree-actions.svelte
<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>
src/lib/registry/ai/file-tree/file-tree-file.svelte
<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>
src/lib/registry/ai/file-tree/file-tree-folder.svelte
<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>
src/lib/registry/ai/file-tree/file-tree-icon.svelte
<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>
src/lib/registry/ai/file-tree/file-tree-name.svelte
<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>
src/lib/registry/ai/file-tree/file-tree.svelte
<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>
src/lib/registry/ai/file-tree/use-file-tree.svelte.ts
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";
src/lib/registry/ai/file-tree/index.ts
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
  • Rows are 28px with an accent fill for the selected path; folder icons use chart-3; no connector lines.
  • Vue: v-model:expanded and the select event; Svelte: bind:expanded and onSelect. FileTreeFile takes an icon slot (Vue) or icon snippet (Svelte).