('app-components-file-tree')
+
+ return (
+
+
+
+
+
+ )
+}
+
+function IconGallery() {
+ const iconTypes = [
+ 'folder',
+ 'file',
+ 'markdown',
+ 'json',
+ 'image',
+ 'code',
+ 'database',
+ 'text',
+ 'pdf',
+ 'table',
+ 'archive',
+ ] as const
+
+ return (
+
+
+ {iconTypes.map(type => (
+ type === 'folder'
+ ? (
+
+
+
+ {type}
+
+
+
+ )
+ : (
+
+
+ {type}
+
+ )
+ ))}
+
+
+ )
+}
+
+function StateFrame({
+ label,
+ children,
+}: {
+ label: string
+ children: ReactNode
+}) {
+ return (
+
+
{label}
+
+
+ {children}
+
+
+
+ )
+}
+
+function VisualStates() {
+ return (
+
+
+
+
+ default.txt
+
+
+
+
+
+ active.md
+
+
+
+
+
+ disabled.json
+
+
+
+
+
+
+ disabled-folder
+
+
+
+
+ nested.ts
+
+
+
+
+
+
+
+
+ closed-folder
+
+
+
+
+ nested.ts
+
+
+
+
+
+
+
+
+ open-folder
+
+
+
+
+ nested.ts
+
+
+
+
+
+
+
+ very-long-file-name-that-should-truncate-without-shifting-layout.txt
+ preview
+
+
+
+ )
+}
+
+export const Default: Story = {
+ render: () => ,
+}
+
+export const DataDriven: Story = {
+ render: () => ,
+}
+
+export const Icons: Story = {
+ render: () => ,
+}
+
+export const States: Story = {
+ render: () => ,
+}
diff --git a/packages/dify-ui/src/file-tree/index.tsx b/packages/dify-ui/src/file-tree/index.tsx
new file mode 100644
index 0000000000..1b56ae8fac
--- /dev/null
+++ b/packages/dify-ui/src/file-tree/index.tsx
@@ -0,0 +1,378 @@
+'use client'
+
+import type { ReactNode } from 'react'
+import { Collapsible as BaseCollapsible } from '@base-ui/react/collapsible'
+import { mergeProps } from '@base-ui/react/merge-props'
+import { useRender } from '@base-ui/react/use-render'
+import {
+ createContext,
+ useContext,
+} from 'react'
+import { cn } from '../cn'
+
+const FileTreeLevelContext = createContext(1)
+
+function useFileTreeLevel() {
+ return useContext(FileTreeLevelContext)
+}
+
+function getLabelText(children: ReactNode) {
+ return typeof children === 'string' || typeof children === 'number'
+ ? String(children)
+ : undefined
+}
+
+function renderGuides(level: number) {
+ return Array.from({ length: Math.max(level - 1, 0) }, (_, index) => (
+
+ ))
+}
+
+type FileTreeRowState = {
+ selected: boolean
+ disabled: boolean
+ level: number
+}
+
+function fileTreeRowClassName({
+ className,
+}: {
+ className?: string
+}) {
+ return cn(
+ 'group/file-tree-row relative flex h-6 w-full min-w-0 cursor-pointer items-center rounded-md pl-2 pr-1.5 text-left outline-hidden select-none',
+ 'hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-state-accent-solid',
+ 'data-[selected]:bg-state-base-active',
+ 'data-disabled:cursor-not-allowed data-disabled:opacity-50 data-disabled:hover:bg-transparent',
+ 'aria-disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:hover:bg-transparent',
+ className,
+ )
+}
+
+export type FileTreeRootProps = useRender.ComponentProps<'section'>
+
+export function FileTreeRoot({
+ render,
+ className,
+ children,
+ ...props
+}: FileTreeRootProps) {
+ const defaultProps: useRender.ElementProps<'section'> = {
+ className: cn('flex min-w-0 flex-col gap-px p-1', className),
+ children: (
+
+ {children}
+
+ ),
+ }
+
+ return useRender({
+ defaultTagName: 'section',
+ render,
+ props: mergeProps<'section'>(defaultProps, props),
+ })
+}
+
+export type FileTreeListProps = useRender.ComponentProps<'ul'>
+
+export function FileTreeList({
+ render,
+ className,
+ ...props
+}: FileTreeListProps) {
+ const defaultProps: useRender.ElementProps<'ul'> = {
+ className: cn('m-0 flex min-w-0 list-none flex-col gap-px p-0', className),
+ }
+
+ return useRender({
+ defaultTagName: 'ul',
+ render,
+ props: mergeProps<'ul'>(defaultProps, props),
+ })
+}
+
+export type FileTreeFolderProps
+ = Omit
+ & {
+ render?: BaseCollapsible.Root.Props['render']
+ }
+
+export function FileTreeFolder({
+ render = ,
+ className,
+ ...props
+}: FileTreeFolderProps) {
+ return (
+
+ )
+}
+
+export type FileTreeFolderTriggerProps
+ = Omit
+ & {
+ className?: string
+ level?: number
+ }
+
+export function FileTreeFolderTrigger({
+ className,
+ children,
+ disabled,
+ level: levelProp,
+ ...props
+}: FileTreeFolderTriggerProps) {
+ const contextLevel = useFileTreeLevel()
+ const level = levelProp ?? contextLevel
+
+ return (
+
+ {renderGuides(level)}
+
+ {children}
+
+
+ )
+}
+
+export type FileTreeFolderPanelProps
+ = Omit
+ & {
+ render?: BaseCollapsible.Panel.Props['render']
+ }
+
+export function FileTreeFolderPanel({
+ render = ,
+ className,
+ children,
+ ...props
+}: FileTreeFolderPanelProps) {
+ const level = useFileTreeLevel()
+
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+export type FileTreeFileProps
+ = Omit, 'type'>
+ & {
+ level?: number
+ selected?: boolean
+ }
+
+export function FileTreeFile({
+ render,
+ className,
+ children,
+ disabled = false,
+ level: levelProp,
+ selected = false,
+ ...props
+}: FileTreeFileProps) {
+ const contextLevel = useFileTreeLevel()
+ const level = levelProp ?? contextLevel
+ const state: FileTreeRowState = {
+ selected,
+ disabled,
+ level,
+ }
+ const defaultProps = {
+ 'type': 'button',
+ 'disabled': disabled,
+ 'data-selected': selected || undefined,
+ 'data-disabled': disabled || undefined,
+ 'aria-current': selected ? 'true' : undefined,
+ 'className': fileTreeRowClassName({ className }),
+ 'children': (
+ <>
+ {renderGuides(level)}
+
+ {children}
+
+ >
+ ),
+ } as useRender.ElementProps<'button'>
+
+ const file = useRender({
+ defaultTagName: 'button',
+ render,
+ state,
+ props: mergeProps<'button'>(defaultProps, props),
+ })
+
+ return {file}
+}
+
+export type FileTreeGuideProps = useRender.ComponentProps<'span'>
+
+export function FileTreeGuide({
+ render,
+ className,
+ ...props
+}: FileTreeGuideProps) {
+ const defaultProps: useRender.ElementProps<'span'> = {
+ 'aria-hidden': true,
+ 'className': cn(
+ 'relative h-6 w-5 shrink-0 before:absolute before:bottom-[-1px] before:left-1/2 before:top-0 before:w-px before:-translate-x-1/2 before:bg-divider-subtle',
+ className,
+ ),
+ }
+
+ return useRender({
+ defaultTagName: 'span',
+ render,
+ props: mergeProps<'span'>(defaultProps, props),
+ })
+}
+
+export type FileTreeIconType
+ = 'folder'
+ | 'file'
+ | 'markdown'
+ | 'json'
+ | 'image'
+ | 'code'
+ | 'database'
+ | 'text'
+ | 'pdf'
+ | 'table'
+ | 'archive'
+
+const fileTreeIconClassNames: Record, string> = {
+ file: 'i-ri-file-3-fill text-[#A4AABF]',
+ markdown: 'i-ri-markdown-fill text-[#309BEC]',
+ json: 'i-ri-braces-fill text-[#A4AABF]',
+ image: 'i-ri-file-image-fill text-[#00B2EA]',
+ code: 'i-ri-file-code-fill text-[#A4AABF]',
+ database: 'i-ri-database-2-fill text-[#A4AABF]',
+ text: 'i-ri-file-text-fill text-[#6F8BB5]',
+ pdf: 'i-ri-file-pdf-2-fill text-[#EA3434]',
+ table: 'i-ri-file-excel-fill text-[#01AC49]',
+ archive: 'i-ri-file-zip-fill text-[#A4AABF]',
+}
+
+export type FileTreeIconProps
+ = Omit, 'children'>
+ & {
+ type?: FileTreeIconType
+ children?: ReactNode
+ }
+
+export function FileTreeIcon({
+ type = 'file',
+ render,
+ className,
+ children,
+ ...props
+}: FileTreeIconProps) {
+ const defaultProps: useRender.ElementProps<'span'> = {
+ 'aria-hidden': true,
+ 'className': cn('relative flex size-5 shrink-0 items-center justify-center text-text-secondary', className),
+ 'children': (
+ <>
+ {children ?? (
+ type === 'folder'
+ ? (
+ <>
+
+
+ >
+ )
+ :
+ )}
+ >
+ ),
+ }
+
+ return useRender({
+ defaultTagName: 'span',
+ render,
+ props: mergeProps<'span'>(defaultProps, props),
+ })
+}
+
+export type FileTreeLabelProps = useRender.ComponentProps<'span'>
+type FileTreeLabelElementProps = useRender.ElementProps<'span'> & {
+ 'data-label'?: string
+}
+
+export function FileTreeLabel({
+ render,
+ className,
+ children,
+ ...props
+}: FileTreeLabelProps) {
+ const labelText = getLabelText(children)
+ const defaultProps = {
+ 'data-label': labelText,
+ 'className': cn(
+ 'min-w-0 truncate rounded-[5px] px-1 py-0.5',
+ labelText && 'after:invisible after:block after:h-0 after:overflow-hidden after:system-sm-medium after:content-[attr(data-label)]',
+ 'system-sm-regular text-text-secondary group-data-[selected]/file-tree-row:system-sm-medium group-data-[selected]/file-tree-row:text-text-primary',
+ className,
+ ),
+ children,
+ } satisfies FileTreeLabelElementProps
+
+ return useRender({
+ defaultTagName: 'span',
+ render,
+ props: mergeProps<'span'>(defaultProps, props),
+ })
+}
+
+export type FileTreeMetaProps = useRender.ComponentProps<'span'>
+
+export function FileTreeMeta({
+ render,
+ className,
+ ...props
+}: FileTreeMetaProps) {
+ const defaultProps: useRender.ElementProps<'span'> = {
+ className: cn('min-w-0 shrink truncate system-xs-regular text-text-tertiary', className),
+ }
+
+ return useRender({
+ defaultTagName: 'span',
+ render,
+ props: mergeProps<'span'>(defaultProps, props),
+ })
+}
+
+export type FileTreeBadgeProps = useRender.ComponentProps<'span'>
+
+export function FileTreeBadge({
+ render,
+ className,
+ ...props
+}: FileTreeBadgeProps) {
+ const defaultProps: useRender.ElementProps<'span'> = {
+ className: cn(
+ 'ml-1 inline-flex min-w-4 shrink-0 items-center justify-center rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1 py-0.5 system-2xs-medium-uppercase text-text-tertiary',
+ className,
+ ),
+ }
+
+ return useRender({
+ defaultTagName: 'span',
+ render,
+ props: mergeProps<'span'>(defaultProps, props),
+ })
+}