From 210b84b7913fa1fe41df05f7186fc6d28dc27792 Mon Sep 17 00:00:00 2001 From: carl-chen Date: Thu, 14 May 2026 18:47:30 +0800 Subject: [PATCH 1/3] feat: add folder component --- .../src/components/component-overview/data.ts | 22 + .../pages/components/folder/demo/basic.vue | 55 ++ .../components/folder/demo/custom-icons.vue | 39 ++ .../folder/demo/file-controlled.vue | 48 ++ .../pages/components/folder/demo/slots.vue | 54 ++ .../pages/components/folder/index.en-US.md | 90 ++++ .../pages/components/folder/index.zh-CN.md | 91 ++++ .../components/x-provider/index.en-US.md | 32 +- .../components/x-provider/index.zh-CN.md | 32 +- .../x/components/folder/DirectoryTree.tsx | 196 +++++++ packages/x/components/folder/FilePreview.tsx | 264 ++++++++++ packages/x/components/folder/Folder.tsx | 479 ++++++++++++++++++ .../folder/__tests__/index.test.tsx | 371 ++++++++++++++ packages/x/components/folder/index.ts | 19 + packages/x/components/folder/interface.ts | 119 +++++ packages/x/components/folder/style/folder.ts | 116 +++++ packages/x/components/folder/style/index.ts | 28 + packages/x/components/index.ts | 14 + .../components/theme/interface/components.ts | 5 +- packages/x/components/x-provider/context.ts | 2 + 20 files changed, 2041 insertions(+), 35 deletions(-) create mode 100644 packages/docs/src/pages/components/folder/demo/basic.vue create mode 100644 packages/docs/src/pages/components/folder/demo/custom-icons.vue create mode 100644 packages/docs/src/pages/components/folder/demo/file-controlled.vue create mode 100644 packages/docs/src/pages/components/folder/demo/slots.vue create mode 100644 packages/docs/src/pages/components/folder/index.en-US.md create mode 100644 packages/docs/src/pages/components/folder/index.zh-CN.md create mode 100644 packages/x/components/folder/DirectoryTree.tsx create mode 100644 packages/x/components/folder/FilePreview.tsx create mode 100644 packages/x/components/folder/Folder.tsx create mode 100644 packages/x/components/folder/__tests__/index.test.tsx create mode 100644 packages/x/components/folder/index.ts create mode 100644 packages/x/components/folder/interface.ts create mode 100644 packages/x/components/folder/style/folder.ts create mode 100644 packages/x/components/folder/style/index.ts diff --git a/packages/docs/src/components/component-overview/data.ts b/packages/docs/src/components/component-overview/data.ts index c1b2f80..10047ff 100644 --- a/packages/docs/src/components/component-overview/data.ts +++ b/packages/docs/src/components/component-overview/data.ts @@ -205,6 +205,28 @@ export const componentOverviewItems: ComponentOverviewItem[] = [ coverDark: "https://mdn.alipayobjects.com/huamei_iwk9zp/afts/img/A*6ySvTqb7XhkAAAAAAAAAAAAADgCCAQ/original", }, + { + slug: "folder", + path: "/components/folder", + title: "Folder", + subtitle: { + "zh-CN": "文件树", + }, + description: { + "zh-CN": "用于展示层级文件结构的文件树组件。", + "en-US": + "File tree component for displaying hierarchical file structure.", + }, + group: { + "zh-CN": "反馈", + "en-US": "Feedback", + }, + groupOrder: 4, + cover: + "https://mdn.alipayobjects.com/huamei_lkxviz/afts/img/uWJQS7CnYE0AAAAAQCAAAAgADtFMAQFr/original", + coverDark: + "https://mdn.alipayobjects.com/huamei_lkxviz/afts/img/iUnnR43iHu8AAAAAQCAAAAgADtFMAQFr/original", + }, { slug: "sender", path: "/components/sender", diff --git a/packages/docs/src/pages/components/folder/demo/basic.vue b/packages/docs/src/pages/components/folder/demo/basic.vue new file mode 100644 index 0000000..6b84c4d --- /dev/null +++ b/packages/docs/src/pages/components/folder/demo/basic.vue @@ -0,0 +1,55 @@ + + + + + +基础用法,展示文件树及选中文件的内容预览。 + + + +Basic usage. Render a file tree and preview the selected file's content. + diff --git a/packages/docs/src/pages/components/folder/demo/custom-icons.vue b/packages/docs/src/pages/components/folder/demo/custom-icons.vue new file mode 100644 index 0000000..8abd107 --- /dev/null +++ b/packages/docs/src/pages/components/folder/demo/custom-icons.vue @@ -0,0 +1,39 @@ + + + + + +通过 `directoryIcons` 自定义目录与按扩展名匹配的文件图标。 + + + +Customize directory icon and per-extension file icons via `directoryIcons`. + diff --git a/packages/docs/src/pages/components/folder/demo/file-controlled.vue b/packages/docs/src/pages/components/folder/demo/file-controlled.vue new file mode 100644 index 0000000..b272722 --- /dev/null +++ b/packages/docs/src/pages/components/folder/demo/file-controlled.vue @@ -0,0 +1,48 @@ + + + + + +通过 `selectedFile` + `@selected-file-change` 实现受控选中。 + + + +Controlled file selection via `selectedFile` + `@selected-file-change`. + diff --git a/packages/docs/src/pages/components/folder/demo/slots.vue b/packages/docs/src/pages/components/folder/demo/slots.vue new file mode 100644 index 0000000..d6a2a95 --- /dev/null +++ b/packages/docs/src/pages/components/folder/demo/slots.vue @@ -0,0 +1,54 @@ + + + + + +通过 `directoryTitle` / `previewTitle` / `previewRender` / `emptyRender` 插槽自定义渲染。 +插槽优先级高于同名 prop。 + + + +Customize render via `directoryTitle` / `previewTitle` / `previewRender` / `emptyRender` slots. +Slots take precedence over the same-named props. + diff --git a/packages/docs/src/pages/components/folder/index.en-US.md b/packages/docs/src/pages/components/folder/index.en-US.md new file mode 100644 index 0000000..46efcb2 --- /dev/null +++ b/packages/docs/src/pages/components/folder/index.en-US.md @@ -0,0 +1,90 @@ +--- +title: Folder +description: File tree component for displaying hierarchical file structure. +--- + +## When To Use + +- Display hierarchical file/folder structures. +- Combine file selection with content preview. + +## Examples + +Basic +Custom Icons +Controlled Selection +Slots + +## API + +### FolderProps + +| Property | Description | Type | Default | +| ------------------------- | ------------------------------------------------- | -------------------------------------------------------------------------- | ------- | +| `treeData` | File tree data | [`FolderTreeData[]`](#foldertreedata) | `[]` | +| `selectable` | Whether the tree is selectable | `boolean` | `true` | +| `selectedFile` (v-model) | Selected file path (controlled) | `string[]` | - | +| `defaultSelectedFile` | Initial selected file path | `string[]` | - | +| `directoryTreeWith` | Width of the directory tree panel | `number \| string` | `278` | +| `emptyRender` | Empty state node, `false` to hide | `false \| VNodeChild \| (() => VNodeChild)` | - | +| `previewRender` | Custom preview content | `VNodeChild \| ((file, info) => VNodeChild)` | - | +| `expandedPaths` (v-model) | Expanded node paths (controlled) | `string[]` | - | +| `defaultExpandedPaths` | Initial expanded paths | `string[]` | - | +| `defaultExpandAll` | Whether to expand all nodes by default | `boolean` | `true` | +| `fileContentService` | File content loader | [`FileContentService`](#filecontentservice) | - | +| `directoryTitle` | Directory tree title, `false` to hide | `false \| VNodeChild \| (() => VNodeChild)` | - | +| `previewTitle` | File preview title, `false` to hide | `false \| VNodeChild \| ((info: { title; path; content }) => VNodeChild)` | - | +| `directoryIcons` | Icon map keyed by `"directory"` or file extension | `false \| Record<'directory' \| string, VNodeChild \| (() => VNodeChild)>` | - | +| `classes` | Semantic class names | `Partial>` | - | +| `styles` | Semantic styles | `Partial>` | - | + +### Folder Events + +| Event | Description | Signature | +| ---------------------- | --------------------------------- | -------------------------------------------------------------- | +| `selectedFileChange` | Selected file changed | `(file: { path: string[]; title?; content?: string }) => void` | +| `update:selectedFile` | v-model update for `selectedFile` | `(paths: string[]) => void` | +| `fileClick` | A file was clicked | `(filePath: string, content?: string) => void` | +| `folderClick` | A folder was clicked | `(folderPath: string) => void` | +| `expandedPathsChange` | Expanded paths changed | `(paths: string[]) => void` | +| `update:expandedPaths` | v-model update for expanded paths | `(paths: string[]) => void` | + +### Folder Slots + +Slots take precedence over the same-named props. + +| Slot | Description | Type | +| ---------------- | ---------------------- | ------------------------------------------------------------------------------ | +| `directoryTitle` | Directory tree title | `() => VNodeChild` | +| `previewTitle` | File preview title | `(info: { title: VNodeChild; path: string[]; content: string }) => VNodeChild` | +| `previewRender` | Custom preview content | `(info: { file: PreviewFileInfo; originNode: VNodeChild }) => VNodeChild` | +| `emptyRender` | Empty state node | `() => VNodeChild` | + +### FolderTreeData + +| Property | Description | Type | Default | +| ---------- | ------------------------------- | ------------------ | ------- | +| `title` | Display title | `VNodeChild` | - | +| `path` | Node path segment | `string` | - | +| `content` | File content (file nodes only) | `string` | - | +| `children` | Child nodes (folder nodes only) | `FolderTreeData[]` | - | + +### FileContentService + +```ts +interface FileContentService { + loadFileContent: (filePath: string) => Promise; +} +``` + +### FolderSemanticType + +```ts +type FolderSemanticType = + | "root" + | "directoryTree" + | "directoryTitle" + | "filePreview" + | "previewTitle" + | "previewRender"; +``` diff --git a/packages/docs/src/pages/components/folder/index.zh-CN.md b/packages/docs/src/pages/components/folder/index.zh-CN.md new file mode 100644 index 0000000..4984908 --- /dev/null +++ b/packages/docs/src/pages/components/folder/index.zh-CN.md @@ -0,0 +1,91 @@ +--- +title: Folder +subtitle: 文件树 +description: 用于展示层级文件结构的文件树组件。 +--- + +## 何时使用 + +- 展示层级文件/文件夹结构。 +- 选中文件并预览内容。 + +## 代码演示 + +基础用法 +自定义图标 +受控选中 +插槽 + +## API + +### FolderProps + +| 属性 | 说明 | 类型 | 默认值 | +| ------------------------- | ------------------------------------------ | -------------------------------------------------------------------------- | ------ | +| `treeData` | 文件树数据 | [`FolderTreeData[]`](#foldertreedata) | `[]` | +| `selectable` | 是否可选择 | `boolean` | `true` | +| `selectedFile` (v-model) | 受控选中文件路径 | `string[]` | - | +| `defaultSelectedFile` | 默认选中的文件路径 | `string[]` | - | +| `directoryTreeWith` | 目录树面板宽度 | `number \| string` | `278` | +| `emptyRender` | 空状态节点,`false` 不显示 | `false \| VNodeChild \| (() => VNodeChild)` | - | +| `previewRender` | 自定义预览内容 | `VNodeChild \| ((file, info) => VNodeChild)` | - | +| `expandedPaths` (v-model) | 受控展开节点路径 | `string[]` | - | +| `defaultExpandedPaths` | 默认展开节点路径 | `string[]` | - | +| `defaultExpandAll` | 是否默认展开所有节点 | `boolean` | `true` | +| `fileContentService` | 文件内容加载服务 | [`FileContentService`](#filecontentservice) | - | +| `directoryTitle` | 目录树标题,`false` 不显示 | `false \| VNodeChild \| (() => VNodeChild)` | - | +| `previewTitle` | 预览区标题,`false` 不显示 | `false \| VNodeChild \| ((info: { title; path; content }) => VNodeChild)` | - | +| `directoryIcons` | 以 `"directory"` 或扩展名为 key 的图标映射 | `false \| Record<'directory' \| string, VNodeChild \| (() => VNodeChild)>` | - | +| `classes` | 语义化 class | `Partial>` | - | +| `styles` | 语义化 style | `Partial>` | - | + +### Folder 事件 + +| 事件 | 说明 | 签名 | +| ---------------------- | ------------------------ | -------------------------------------------------------------- | +| `selectedFileChange` | 选中文件变化 | `(file: { path: string[]; title?; content?: string }) => void` | +| `update:selectedFile` | `selectedFile` 双向绑定 | `(paths: string[]) => void` | +| `fileClick` | 文件被点击 | `(filePath: string, content?: string) => void` | +| `folderClick` | 文件夹被点击 | `(folderPath: string) => void` | +| `expandedPathsChange` | 展开节点变化 | `(paths: string[]) => void` | +| `update:expandedPaths` | `expandedPaths` 双向绑定 | `(paths: string[]) => void` | + +### Folder 插槽 + +同名插槽优先级高于 prop。 + +| 插槽 | 说明 | 类型 | +| ---------------- | -------------- | ------------------------------------------------------------------------------ | +| `directoryTitle` | 目录树标题 | `() => VNodeChild` | +| `previewTitle` | 预览区标题 | `(info: { title: VNodeChild; path: string[]; content: string }) => VNodeChild` | +| `previewRender` | 自定义预览内容 | `(info: { file: PreviewFileInfo; originNode: VNodeChild }) => VNodeChild` | +| `emptyRender` | 空状态节点 | `() => VNodeChild` | + +### FolderTreeData + +| 属性 | 说明 | 类型 | 默认值 | +| ---------- | -------------------------- | ------------------ | ------ | +| `title` | 节点展示标题 | `VNodeChild` | - | +| `path` | 节点路径片段 | `string` | - | +| `content` | 文件内容(仅文件节点有效) | `string` | - | +| `children` | 子节点(仅目录节点有效) | `FolderTreeData[]` | - | + +### FileContentService + +```ts +interface FileContentService { + loadFileContent: (filePath: string) => Promise; +} +``` + +### FolderSemanticType + +```ts +type FolderSemanticType = + | "root" + | "directoryTree" + | "directoryTitle" + | "filePreview" + | "previewTitle" + | "previewRender"; +``` diff --git a/packages/docs/src/pages/components/x-provider/index.en-US.md b/packages/docs/src/pages/components/x-provider/index.en-US.md index af83c97..f48ec9f 100644 --- a/packages/docs/src/pages/components/x-provider/index.en-US.md +++ b/packages/docs/src/pages/components/x-provider/index.en-US.md @@ -48,22 +48,22 @@ import zhCN from 'antdv-next/dist/locale/zh_CN' ### Component Config -| Property | Description | Type | Default | -| ----------------- | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | -| `actions` | Global configuration for Actions | `{ style?: StyleValue; styles?: Record; classes?: Record }` | - | -| `attachments` | Global configuration for Attachments | `{ style?: StyleValue; styles?: Record; classes?: Record }` | - | -| `bubble` | Global configuration for Bubble | `{ style?: StyleValue; styles?: Record; classes?: Record }` | - | -| `codeHighlighter` | Global configuration for CodeHighlighter | `{ style?: StyleValue; styles?: Record; classes?: Record }` | - | -| `conversations` | Global configuration for Conversations | `{ style?: StyleValue; styles?: Record; classes?: Record; shortcutKeys?: { creation?: ShortcutKeys; items?: ShortcutKeys<'number'> \| ShortcutKeys[] } }` | - | -| `fileCard` | Global configuration for FileCard | `{ style?: StyleValue; styles?: Record; classes?: Record }` | - | -| `mermaid` | Global configuration for Mermaid | `{ style?: StyleValue; styles?: Record; classes?: Record }` | - | -| `prompts` | Global configuration for Prompts | `{ style?: StyleValue; styles?: Record; classes?: Record }` | - | -| `sender` | Global configuration for Sender | `{ style?: StyleValue; styles?: Record; classes?: Record }` | - | -| `sources` | Global configuration for Sources | `{ style?: StyleValue; styles?: Record; classes?: Record }` | - | -| `suggestion` | Global configuration for Suggestion | `{ style?: StyleValue; styles?: Record; classes?: Record }` | - | -| `think` | Global configuration for Think | `{ style?: StyleValue; styles?: Record; classes?: Record }` | - | -| `thoughtChain` | Global configuration for ThoughtChain | `{ style?: StyleValue; styles?: Record; classes?: Record }` | - | -| `welcome` | Global configuration for Welcome | `{ style?: StyleValue; styles?: Record; classes?: Record }` | - | +| Property | Description | Type | Default | +| ----------------- | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | +| `actions` | Global configuration for Actions | `{ style?: StyleValue; styles?: Record; classes?: Record }` | - | +| `attachments` | Global configuration for Attachments | `{ style?: StyleValue; styles?: Record; classes?: Record }` | - | +| `bubble` | Global configuration for Bubble | `{ style?: StyleValue; styles?: Record; classes?: Record }` | - | +| `codeHighlighter` | Global configuration for CodeHighlighter | `{ style?: StyleValue; styles?: Record; classes?: Record }` | - | +| `conversations` | Global configuration for Conversations | `{ style?: StyleValue; styles?: Record; classes?: Record; shortcutKeys?: { creation?: ShortcutKeys; items?: ShortcutKeys<'number'> \| ShortcutKeys[] } }` | - | +| `fileCard` | Global configuration for FileCard | `{ style?: StyleValue; styles?: Record; classes?: Record }` | - | +| `mermaid` | Global configuration for Mermaid | `{ style?: StyleValue; styles?: Record; classes?: Record }` | - | +| `prompts` | Global configuration for Prompts | `{ style?: StyleValue; styles?: Record; classes?: Record }` | - | +| `sender` | Global configuration for Sender | `{ style?: StyleValue; styles?: Record; classes?: Record }` | - | +| `sources` | Global configuration for Sources | `{ style?: StyleValue; styles?: Record; classes?: Record }` | - | +| `suggestion` | Global configuration for Suggestion | `{ style?: StyleValue; styles?: Record; classes?: Record }` | - | +| `think` | Global configuration for Think | `{ style?: StyleValue; styles?: Record; classes?: Record }` | - | +| `thoughtChain` | Global configuration for ThoughtChain | `{ style?: StyleValue; styles?: Record; classes?: Record }` | - | +| `welcome` | Global configuration for Welcome | `{ style?: StyleValue; styles?: Record; classes?: Record }` | - | #### ShortcutKeys diff --git a/packages/docs/src/pages/components/x-provider/index.zh-CN.md b/packages/docs/src/pages/components/x-provider/index.zh-CN.md index 36d2acb..b746565 100644 --- a/packages/docs/src/pages/components/x-provider/index.zh-CN.md +++ b/packages/docs/src/pages/components/x-provider/index.zh-CN.md @@ -49,22 +49,22 @@ import zhCN from 'antdv-next/dist/locale/zh_CN' ### 组件配置 -| 属性 | 说明 | 类型 | 默认值 | -| ----------------- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -| `actions` | 操作列表组件的全局配置 | `{ style?: StyleValue; styles?: Record; classes?: Record }` | - | -| `attachments` | 附件组件的全局配置 | `{ style?: StyleValue; styles?: Record; classes?: Record }` | - | -| `bubble` | 气泡组件的全局配置 | `{ style?: StyleValue; styles?: Record; classes?: Record }` | - | -| `codeHighlighter` | 代码高亮组件的全局配置 | `{ style?: StyleValue; styles?: Record; classes?: Record }` | - | -| `conversations` | 会话组件的全局配置 | `{ style?: StyleValue; styles?: Record; classes?: Record; shortcutKeys?: { creation?: ShortcutKeys; items?: ShortcutKeys<'number'> \| ShortcutKeys[] } }` | - | -| `fileCard` | 文件卡片组件的全局配置 | `{ style?: StyleValue; styles?: Record; classes?: Record }` | - | -| `mermaid` | Mermaid 组件的全局配置 | `{ style?: StyleValue; styles?: Record; classes?: Record }` | - | -| `prompts` | 提示集组件的全局配置 | `{ style?: StyleValue; styles?: Record; classes?: Record }` | - | -| `sender` | 输入框组件的全局配置 | `{ style?: StyleValue; styles?: Record; classes?: Record }` | - | -| `sources` | 来源组件的全局配置 | `{ style?: StyleValue; styles?: Record; classes?: Record }` | - | -| `suggestion` | 快捷指令组件的全局配置 | `{ style?: StyleValue; styles?: Record; classes?: Record }` | - | -| `think` | 思考组件的全局配置 | `{ style?: StyleValue; styles?: Record; classes?: Record }` | - | -| `thoughtChain` | 思维链组件的全局配置 | `{ style?: StyleValue; styles?: Record; classes?: Record }` | - | -| `welcome` | 欢迎组件的全局配置 | `{ style?: StyleValue; styles?: Record; classes?: Record }` | - | +| 属性 | 说明 | 类型 | 默认值 | +| ----------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | +| `actions` | 操作列表组件的全局配置 | `{ style?: StyleValue; styles?: Record; classes?: Record }` | - | +| `attachments` | 附件组件的全局配置 | `{ style?: StyleValue; styles?: Record; classes?: Record }` | - | +| `bubble` | 气泡组件的全局配置 | `{ style?: StyleValue; styles?: Record; classes?: Record }` | - | +| `codeHighlighter` | 代码高亮组件的全局配置 | `{ style?: StyleValue; styles?: Record; classes?: Record }` | - | +| `conversations` | 会话组件的全局配置 | `{ style?: StyleValue; styles?: Record; classes?: Record; shortcutKeys?: { creation?: ShortcutKeys; items?: ShortcutKeys<'number'> \| ShortcutKeys[] } }` | - | +| `fileCard` | 文件卡片组件的全局配置 | `{ style?: StyleValue; styles?: Record; classes?: Record }` | - | +| `mermaid` | Mermaid 组件的全局配置 | `{ style?: StyleValue; styles?: Record; classes?: Record }` | - | +| `prompts` | 提示集组件的全局配置 | `{ style?: StyleValue; styles?: Record; classes?: Record }` | - | +| `sender` | 输入框组件的全局配置 | `{ style?: StyleValue; styles?: Record; classes?: Record }` | - | +| `sources` | 来源组件的全局配置 | `{ style?: StyleValue; styles?: Record; classes?: Record }` | - | +| `suggestion` | 快捷指令组件的全局配置 | `{ style?: StyleValue; styles?: Record; classes?: Record }` | - | +| `think` | 思考组件的全局配置 | `{ style?: StyleValue; styles?: Record; classes?: Record }` | - | +| `thoughtChain` | 思维链组件的全局配置 | `{ style?: StyleValue; styles?: Record; classes?: Record }` | - | +| `welcome` | 欢迎组件的全局配置 | `{ style?: StyleValue; styles?: Record; classes?: Record }` | - | #### ShortcutKeys diff --git a/packages/x/components/folder/DirectoryTree.tsx b/packages/x/components/folder/DirectoryTree.tsx new file mode 100644 index 0000000..4ea0522 --- /dev/null +++ b/packages/x/components/folder/DirectoryTree.tsx @@ -0,0 +1,196 @@ +import type { PropType, VNodeChild } from "vue"; + +import { FileOutlined, FolderOutlined } from "@antdv-next/icons"; +import { DirectoryTree as AntDirectoryTree } from "antdv-next"; +import { computed, defineComponent } from "vue"; + +import type { + DirectoryIconsMap, + DirectoryTitleRender, + FolderProps, + FolderTreeData, +} from "./interface"; + +export interface DirectoryTreeNode { + key: string; + path: string; + pathSegments: string[]; + title: VNodeChild; + icon?: VNodeChild; + isLeaf?: boolean; + content?: string; + children?: DirectoryTreeNode[]; +} + +export interface DirectoryTreeProps { + prefixCls: string; + treeData: FolderTreeData[]; + directoryIcons?: DirectoryIconsMap; + selectedKeys?: string[]; + expandedKeys?: string[]; + defaultExpandAll?: boolean; + showLine?: boolean; + classes?: FolderProps["classes"]; + styles?: FolderProps["styles"]; + directoryTitle?: DirectoryTitleRender; +} + +function isFolder(node: FolderTreeData): boolean { + return !!node.children && node.children.length > 0; +} + +function buildPathSegments( + node: FolderTreeData, + parentSegments: string[], +): string[] { + if (node.path === "/" && parentSegments.length === 0) { + return ["/"]; + } + return [...parentSegments, node.path].filter(seg => seg !== ""); +} + +function getIcon( + node: FolderTreeData, + directoryIcons: DirectoryIconsMap | undefined, +): VNodeChild { + if (directoryIcons === false || directoryIcons === null) { + return null; + } + if (isFolder(node)) { + const icon = directoryIcons?.directory; + if (typeof icon === "function") return (icon as () => VNodeChild)(); + return icon ?? ; + } + const filePath = node.path.toLowerCase(); + const ext = filePath.split(".").pop(); + if (ext) { + const icon = directoryIcons?.[ext]; + if (icon) { + return typeof icon === "function" ? (icon as () => VNodeChild)() : icon; + } + } + return ; +} + +function convertToTreeData( + nodes: FolderTreeData[], + directoryIcons: DirectoryIconsMap | undefined, + parentSegments: string[] = [], +): DirectoryTreeNode[] { + return nodes.map(node => { + const pathSegments = buildPathSegments(node, parentSegments); + const fullPath = pathSegments.join("/").replace(/^\/+/, ""); + return { + key: fullPath, + path: fullPath, + pathSegments, + title: node.title, + icon: getIcon(node, directoryIcons), + isLeaf: !isFolder(node), + content: node.content, + children: node.children + ? convertToTreeData(node.children, directoryIcons, pathSegments) + : undefined, + }; + }); +} + +const FolderDirectoryTree = defineComponent({ + name: "AxFolderDirectoryTree", + props: { + prefixCls: { type: String, required: true }, + treeData: { + type: Array as PropType, + default: () => [], + }, + directoryIcons: { + type: [Boolean, Object] as PropType, + default: undefined, + }, + selectedKeys: { + type: Array as PropType, + default: () => [], + }, + expandedKeys: { + type: Array as PropType, + default: undefined, + }, + defaultExpandAll: { type: Boolean, default: true }, + showLine: { type: Boolean, default: false }, + classes: { + type: Object as PropType, + default: () => ({}), + }, + styles: { + type: Object as PropType, + default: () => ({}), + }, + directoryTitle: { + type: [ + Boolean, + String, + Number, + Object, + Array, + Function, + ] as PropType, + default: undefined, + }, + }, + emits: { + select: (_keys: string[], _info: any) => true, + expand: (_keys: string[], _info: any) => true, + }, + setup(props, { emit }) { + const treeDataConverted = computed(() => + convertToTreeData(props.treeData, props.directoryIcons), + ); + + const titleNode = computed(() => { + const t = props.directoryTitle; + if (t === false || t === null || t === undefined) return null; + if (typeof t === "function") return (t as () => VNodeChild)(); + return t as VNodeChild; + }); + + return () => { + const { prefixCls } = props; + return ( + <> + {titleNode.value ? ( +
+ {titleNode.value} +
+ ) : null} + + emit("select", keys as string[], info), + onExpand: (keys: any, info: any) => + emit("expand", keys as string[], info), + } as any)} + /> + + ); + }; + }, +}); + +export default FolderDirectoryTree; diff --git a/packages/x/components/folder/FilePreview.tsx b/packages/x/components/folder/FilePreview.tsx new file mode 100644 index 0000000..b37f0da --- /dev/null +++ b/packages/x/components/folder/FilePreview.tsx @@ -0,0 +1,264 @@ +import type { CSSProperties, PropType, VNodeChild } from "vue"; + +import { Empty, Spin } from "antdv-next"; +import { computed, defineComponent } from "vue"; + +import type { + EmptyRender, + FolderProps, + FolderTreeData, + PreviewRender, + PreviewTitleRender, +} from "./interface"; + +import { XActionsCopy } from "../actions/ActionsCopy"; +import { useLocale } from "../locale"; +import enUS from "../locale/en_US"; + +export interface FilePreviewProps { + prefixCls: string; + classes?: FolderProps["classes"]; + styles?: FolderProps["styles"]; + selectedFile?: string[] | null; + fileContent?: string; + loading?: boolean; + previewTitle?: PreviewTitleRender; + previewRender?: PreviewRender; + emptyRender?: EmptyRender; + getFileNode?: ( + path: string[], + ) => + | { title: FolderTreeData["title"]; path: string; content?: string } + | undefined; +} + +function getFileExtension(path = "") { + const parts = path.split("."); + return parts[parts.length - 1] || ""; +} + +function getLanguageFromExtension(ext: string) { + return ext.toLowerCase() || "txt"; +} + +const FilePreview = defineComponent({ + name: "AxFolderFilePreview", + props: { + prefixCls: { type: String, required: true }, + classes: { + type: Object as PropType, + default: () => ({}), + }, + styles: { + type: Object as PropType, + default: () => ({}), + }, + style: { + type: Object as PropType, + default: undefined, + }, + selectedFile: { + type: Array as PropType, + default: () => [], + }, + fileContent: { type: String, default: "" }, + loading: { type: Boolean, default: false }, + previewTitle: { + type: [ + Boolean, + String, + Number, + Object, + Array, + Function, + ] as PropType, + default: undefined, + }, + previewRender: { + type: [ + String, + Number, + Object, + Array, + Function, + ] as PropType, + default: undefined, + }, + emptyRender: { + type: [ + Boolean, + String, + Number, + Object, + Array, + Function, + ] as PropType, + default: undefined, + }, + getFileNode: { + type: Function as PropType, + default: undefined, + }, + }, + setup(props) { + const [locale] = useLocale("Folder", enUS.Folder); + + const previewCls = computed(() => `${props.prefixCls}-preview`); + + const renderOriginContent = (fileContent: string, language: string) => ( +
+        
+          {fileContent.replace(/\n$/, "")}
+        
+      
+ ); + + const renderEmpty = () => { + if (props.emptyRender === false || props.emptyRender === null) { + return null; + } + const node = + typeof props.emptyRender === "function" + ? (props.emptyRender as () => VNodeChild)() + : ((props.emptyRender as VNodeChild) ?? ( + + )); + return ( +
+ {node} +
+ ); + }; + + const renderContent = () => { + if (props.loading) { + return ( +
+ +
+ ); + } + + if (!props.selectedFile || props.selectedFile.length === 0) { + return renderEmpty(); + } + + const selected = props.selectedFile; + const fileNode = props.getFileNode?.(selected); + const title = fileNode?.title ?? selected[selected.length - 1]; + const fileName = selected[selected.length - 1]; + const extension = getFileExtension(fileName); + const language = getLanguageFromExtension(extension); + + let headerNode: VNodeChild; + if (props.previewTitle === false || props.previewTitle === null) { + headerNode = null; + } else if (props.previewTitle) { + headerNode = + typeof props.previewTitle === "function" + ? ( + props.previewTitle as (info: { + title: VNodeChild; + path: string[]; + content: string; + }) => VNodeChild + )({ + title, + path: selected, + content: props.fileContent ?? "", + }) + : (props.previewTitle as VNodeChild); + } else { + headerNode = ( +
+ {title} + +
+ ); + } + + const originNode = renderOriginContent(props.fileContent ?? "", language); + + let contentNode: VNodeChild; + if (props.previewRender !== undefined && props.previewRender !== null) { + if (typeof props.previewRender === "function") { + contentNode = ( + props.previewRender as ( + file: any, + info: { originNode: VNodeChild }, + ) => VNodeChild + )( + { + content: props.fileContent, + path: selected, + title: fileNode?.title, + language, + }, + { originNode }, + ); + } else { + contentNode = props.previewRender as VNodeChild; + } + } else { + contentNode = originNode; + } + + return ( + <> + {headerNode && ( +
+ {headerNode} +
+ )} +
+ {contentNode} +
+ + ); + }; + + return () => ( +
+ {renderContent()} +
+ ); + }, +}); + +export default FilePreview; diff --git a/packages/x/components/folder/Folder.tsx b/packages/x/components/folder/Folder.tsx new file mode 100644 index 0000000..c9f3f83 --- /dev/null +++ b/packages/x/components/folder/Folder.tsx @@ -0,0 +1,479 @@ +import type { + CSSProperties, + PropType, + SlotsType, + StyleValue, + VNodeChild, +} from "vue"; + +import { Flex, Splitter, SplitterPanel } from "antdv-next"; +import { useConfig } from "antdv-next/config-provider/context"; +import { computed, defineComponent, ref, useAttrs, watch } from "vue"; + +import type { + DirectoryIconsMap, + DirectoryTitleRender, + EmptyRender, + FileContentService, + FolderProps, + FolderRef, + FolderSlots, + FolderTreeData, + PreviewRender, + PreviewTitleRender, + SemanticType, +} from "./interface"; + +import useXComponentConfig from "../_utils/hooks/use-x-component-config"; +import { useLocale } from "../locale"; +import enUS from "../locale/en_US"; +import DirectoryTree from "./DirectoryTree"; +import FilePreview from "./FilePreview"; +import useStyle from "./style"; + +function findNode( + nodes: FolderTreeData[], + segments: string[], + index = 0, +): FolderTreeData | undefined { + if (index >= segments.length) return undefined; + const current = segments[index]; + for (const node of nodes) { + if (node.path === current) { + if (index === segments.length - 1) return node; + return node.children + ? findNode(node.children, segments, index + 1) + : undefined; + } + } + return undefined; +} + +function findNodeAndValidate( + treeData: FolderTreeData[], + path: string | string[] | undefined, + validateAsFile = false, +): { node: FolderTreeData | undefined; isValid: boolean } { + if (!path) return { node: undefined, isValid: false }; + const segments = Array.isArray(path) + ? path.filter(Boolean) + : path.split("/").filter(Boolean); + if (segments.length === 0) return { node: undefined, isValid: false }; + const node = findNode(treeData, segments); + const isValid = validateAsFile + ? !!node && (!node?.children || node.children.length === 0) + : !!node; + return { node, isValid }; +} + +const XFolder = defineComponent({ + name: "AxFolder", + inheritAttrs: false, + props: { + prefixCls: { + type: String, + default: "antd-folder", + }, + rootClass: { + type: String, + default: "", + }, + class: { + type: [String, Array, Object] as PropType, + default: undefined, + }, + style: { + type: [String, Object, Array] as PropType, + default: undefined, + }, + classes: { + type: Object as PropType>>, + default: () => ({}), + }, + styles: { + type: Object as PropType>>, + default: () => ({}), + }, + treeData: { + type: Array as PropType, + default: () => [], + }, + directoryIcons: { + type: [Boolean, Object] as PropType, + default: undefined, + }, + selectable: { type: Boolean, default: true }, + selectedFile: { + type: Array as PropType, + default: undefined, + }, + defaultSelectedFile: { + type: Array as PropType, + default: undefined, + }, + directoryTreeWith: { + type: [Number, String] as PropType, + default: 278, + }, + emptyRender: { + type: [ + Boolean, + String, + Number, + Object, + Array, + Function, + ] as PropType, + default: undefined, + }, + previewRender: { + type: [ + String, + Number, + Object, + Array, + Function, + ] as PropType, + default: undefined, + }, + defaultExpandedPaths: { + type: Array as PropType, + default: undefined, + }, + expandedPaths: { + type: Array as PropType, + default: undefined, + }, + defaultExpandAll: { type: Boolean, default: true }, + fileContentService: { + type: Object as PropType, + default: undefined, + }, + directoryTitle: { + type: [ + Boolean, + String, + Number, + Object, + Array, + Function, + ] as PropType, + default: undefined, + }, + previewTitle: { + type: [ + Boolean, + String, + Number, + Object, + Array, + Function, + ] as PropType, + default: undefined, + }, + }, + emits: { + selectedFileChange: (_file: { + path: string[]; + title?: VNodeChild; + content?: string; + }) => true, + fileClick: (_path: string, _content?: string) => true, + folderClick: (_path: string) => true, + expandedPathsChange: (_paths: string[]) => true, + "update:selectedFile": (_paths: string[]) => true, + "update:expandedPaths": (_paths: string[]) => true, + }, + slots: Object as SlotsType, + setup(props, { emit, expose, slots }) { + const attrs = useAttrs(); + const configCtx = useConfig(); + const contextConfig = useXComponentConfig("folder"); + const [hashId, cssVarCls] = useStyle(computed(() => props.prefixCls)); + const [locale] = useLocale("Folder", enUS.Folder); + + const rootRef = ref(); + + const isValidSelectedFile = (filePath?: string[]): boolean => + !!( + filePath && + filePath.length > 0 && + findNodeAndValidate(props.treeData, filePath, true).isValid + ); + + // controlled/uncontrolled selectedFile + const innerSelectedFile = ref( + isValidSelectedFile(props.defaultSelectedFile) + ? (props.defaultSelectedFile ?? []) + : [], + ); + + const mergedSelectedFile = computed(() => + props.selectedFile !== undefined + ? props.selectedFile + : innerSelectedFile.value, + ); + + const validSelectedFile = ref( + isValidSelectedFile(props.selectedFile ?? props.defaultSelectedFile), + ); + + watch( + () => + [ + props.selectedFile, + props.defaultSelectedFile, + props.treeData, + ] as const, + () => { + validSelectedFile.value = isValidSelectedFile( + props.selectedFile ?? props.defaultSelectedFile ?? [], + ); + }, + { deep: true }, + ); + + // controlled/uncontrolled expandedPaths + const innerExpandedPaths = ref( + props.defaultExpandedPaths, + ); + const mergedExpandedPaths = computed(() => + props.expandedPaths !== undefined + ? props.expandedPaths + : innerExpandedPaths.value, + ); + + // file content state + const fileContent = ref(""); + const loadingContent = ref(false); + + const loadFileContent = async () => { + if (!validSelectedFile.value || mergedSelectedFile.value.length === 0) { + fileContent.value = ""; + loadingContent.value = false; + return; + } + + const filePath = mergedSelectedFile.value.join("/"); + const segments = filePath.split("/").filter(s => s !== ""); + const { node } = findNodeAndValidate(props.treeData, segments); + + if (props.fileContentService) { + loadingContent.value = true; + try { + const content = + await props.fileContentService.loadFileContent(filePath); + fileContent.value = content; + } catch (error) { + fileContent.value = `// ${locale.value.loadError}: ${ + error instanceof Error ? error.message : "Unknown error" + }`; + } finally { + loadingContent.value = false; + } + return; + } + + if (node?.content) { + fileContent.value = node.content; + loadingContent.value = false; + return; + } + + fileContent.value = `// ${locale.value.noService}`; + loadingContent.value = false; + }; + + watch( + [ + validSelectedFile, + mergedSelectedFile, + () => props.treeData, + () => props.fileContentService, + ], + () => { + void loadFileContent(); + }, + { immediate: true, deep: true }, + ); + + // handlers + const handleSelect = (keys: string[], info: any) => { + const nodes = Array.isArray(info?.selectedNodes) + ? info.selectedNodes + : info?.selectedNodes + ? [info.selectedNodes] + : []; + + const isFolderClick = nodes.some((node: any) => !node?.isLeaf); + + if (isFolderClick) { + if (nodes.length === 1) { + const node = nodes[0]; + const folderPath = node?.path ?? ""; + emit("folderClick", folderPath); + } + return; + } + + const pathArray = keys[0]?.split("/").filter(Boolean) ?? []; + if (pathArray.length === 0) return; + + const selectedNode = nodes[0]; + const fileName = selectedNode?.title; + const fileContentValue = selectedNode?.content as string | undefined; + + emit("selectedFileChange", { + path: pathArray, + title: fileName, + content: fileContentValue, + }); + + const isControlled = props.selectedFile !== undefined; + if (!isControlled) { + validSelectedFile.value = true; + innerSelectedFile.value = pathArray; + emit("update:selectedFile", pathArray); + } + + if (nodes.length === 1) { + const node = nodes[0]; + emit("fileClick", node?.path ?? "", node?.content); + } + }; + + const handleExpand = (keys: string[]) => { + const newPaths = keys ?? []; + if (props.expandedPaths === undefined) { + innerExpandedPaths.value = newPaths; + } + emit("update:expandedPaths", newPaths); + emit("expandedPathsChange", newPaths); + }; + + const domAttrs = computed(() => { + const { class: _c, style: _s, ...rest } = attrs; + return rest; + }); + + expose({ + get nativeElement() { + return rootRef.value as HTMLDivElement; + }, + }); + + // slot > prop resolution + const mergedDirectoryTitle = computed( + () => slots.directoryTitle ?? props.directoryTitle, + ); + const mergedPreviewTitle = computed( + () => slots.previewTitle ?? props.previewTitle, + ); + const mergedPreviewRender = computed(() => { + if (slots.previewRender) { + return (file: any, info: { originNode: VNodeChild }) => + slots.previewRender!({ file, originNode: info.originNode }); + } + return props.previewRender; + }); + const mergedEmptyRender = computed( + () => slots.emptyRender ?? props.emptyRender, + ); + + return () => { + const prefixCls = props.prefixCls; + return ( +
+ + + +
+ +
+
+ + { + if (!path || path.length === 0) return undefined; + const { node } = findNodeAndValidate(props.treeData, path); + return node + ? { + title: node.title, + path: node.path, + content: node.content, + } + : undefined; + }} + /> + +
+
+
+ ); + }; + }, +}); + +export default XFolder; diff --git a/packages/x/components/folder/__tests__/index.test.tsx b/packages/x/components/folder/__tests__/index.test.tsx new file mode 100644 index 0000000..ee379e3 --- /dev/null +++ b/packages/x/components/folder/__tests__/index.test.tsx @@ -0,0 +1,371 @@ +import { mount } from "@vue/test-utils"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { h, nextTick } from "vue"; + +import type { FolderTreeData } from "../interface"; + +import Folder from "../Folder"; + +const mockTreeData: FolderTreeData[] = [ + { + title: "src", + path: "src", + children: [ + { + title: "components", + path: "components", + children: [ + { + title: "Button.tsx", + path: "Button.tsx", + content: "export const Button = () => ;", + }, + ], + }, + ], + }, + { + title: "package.json", + path: "package.json", + content: '{ "name": "test-app" }', + }, +]; + +const mockFlatNoContent: FolderTreeData[] = [ + { + title: "Button.tsx", + path: "Button.tsx", + }, +]; + +const flush = () => new Promise(resolve => setTimeout(resolve, 0)); + +describe("Folder", () => { + it("renders basic structure", () => { + const wrapper = mount(Folder, { + props: { + treeData: mockTreeData, + directoryTitle: "Project Files", + previewTitle: "Custom Preview", + defaultExpandAll: false, + }, + }); + expect(wrapper.find(".antd-folder").exists()).toBe(true); + }); + + it("renders empty state when no treeData", () => { + const wrapper = mount(Folder, { props: { treeData: [] } }); + expect(wrapper.find(".antd-folder").exists()).toBe(true); + }); + + it("respects selectable prop", () => { + const wrapper = mount(Folder, { + props: { treeData: mockTreeData, selectable: true }, + }); + expect(wrapper.find(".antd-folder-selectable").exists()).toBe(true); + }); + + it("accepts custom directory width", () => { + const wrapper = mount(Folder, { + props: { treeData: mockTreeData, directoryTreeWith: 300 }, + }); + expect(wrapper.find(".antd-folder").exists()).toBe(true); + }); + + it("accepts custom empty render", () => { + const wrapper = mount(Folder, { + props: { + treeData: mockTreeData, + emptyRender: () => h("div", "Custom Empty"), + }, + }); + expect(wrapper.find(".antd-folder").exists()).toBe(true); + }); + + it("accepts custom directory icons", () => { + const wrapper = mount(Folder, { + props: { + treeData: mockTreeData, + directoryIcons: { + directory: h("span", "Dir"), + tsx: () => h("span", "TSX"), + }, + }, + }); + expect(wrapper.find(".antd-folder").exists()).toBe(true); + }); + + it("hides icons when directoryIcons=false", () => { + const wrapper = mount(Folder, { + props: { treeData: mockTreeData, directoryIcons: false }, + }); + expect(wrapper.find(".antd-folder").exists()).toBe(true); + }); + + it("exposes ref nativeElement", () => { + const wrapper = mount(Folder, { props: { treeData: mockTreeData } }); + const vm = wrapper.vm as any; + expect(vm.nativeElement).toBeTruthy(); + }); + + it("accepts selectedFile prop (controlled)", () => { + const wrapper = mount(Folder, { + props: { + treeData: mockTreeData, + selectedFile: ["package.json"], + }, + }); + expect(wrapper.find(".antd-folder").exists()).toBe(true); + }); + + it("ignores invalid selectedFile path", () => { + const wrapper = mount(Folder, { + props: { treeData: mockTreeData, selectedFile: ["unknown.json"] }, + }); + expect(wrapper.find(".antd-folder").exists()).toBe(true); + }); + + it("accepts defaultSelectedFile", () => { + const wrapper = mount(Folder, { + props: { + treeData: mockTreeData, + defaultSelectedFile: ["package.json"], + }, + }); + expect(wrapper.find(".antd-folder").exists()).toBe(true); + }); + + it("accepts expandedPaths and defaultExpandedPaths", () => { + const a = mount(Folder, { + props: { treeData: mockTreeData, expandedPaths: ["src"] }, + }); + const b = mount(Folder, { + props: { treeData: mockTreeData, defaultExpandedPaths: ["src"] }, + }); + expect(a.find(".antd-folder").exists()).toBe(true); + expect(b.find(".antd-folder").exists()).toBe(true); + }); + + it("renders directoryTitle as function", () => { + const wrapper = mount(Folder, { + props: { + treeData: mockTreeData, + directoryTitle: () => h("span", "Title"), + }, + }); + expect(wrapper.text()).toContain("Title"); + }); + + it("hides directoryTitle when false", () => { + const wrapper = mount(Folder, { + props: { treeData: mockTreeData, directoryTitle: false }, + }); + expect(wrapper.find(".antd-folder-directory-tree-title").exists()).toBe( + false, + ); + }); + + it("hides empty render when emptyRender=false", () => { + const wrapper = mount(Folder, { + props: { treeData: mockTreeData, emptyRender: false }, + }); + expect(wrapper.find(".antd-folder-preview-empty-container").exists()).toBe( + false, + ); + }); + + it("loads content via fileContentService", async () => { + const fileContentService = { + loadFileContent: vi.fn().mockResolvedValue("loaded content"), + }; + const wrapper = mount(Folder, { + props: { + treeData: mockFlatNoContent, + defaultSelectedFile: ["Button.tsx"], + fileContentService, + }, + }); + await nextTick(); + await flush(); + expect(fileContentService.loadFileContent).toHaveBeenCalled(); + wrapper.unmount(); + }); + + it("falls back to noService message when no content/service", async () => { + mount(Folder, { + props: { + treeData: mockFlatNoContent, + defaultSelectedFile: ["Button.tsx"], + }, + }); + await nextTick(); + await flush(); + }); + + it("renders previewTitle as function", () => { + const previewTitle = vi.fn(({ title }: any) => + h("span", `Title: ${title as string}`), + ); + mount(Folder, { + props: { + treeData: mockTreeData, + defaultSelectedFile: ["package.json"], + previewTitle: previewTitle as any, + }, + }); + }); + + it("renders previewRender as function", async () => { + const wrapper = mount(Folder, { + props: { + treeData: mockTreeData, + defaultSelectedFile: ["package.json"], + previewRender: ({ content }: any) => + h("div", { class: "custom-preview" }, `Custom: ${content as string}`), + }, + }); + await nextTick(); + expect(wrapper.find(".custom-preview").exists()).toBe(true); + }); + + it("renders static previewRender", async () => { + const wrapper = mount(Folder, { + props: { + treeData: mockTreeData, + defaultSelectedFile: ["package.json"], + previewRender: h("div", { class: "static-preview" }, "Static"), + }, + }); + await nextTick(); + expect(wrapper.find(".static-preview").exists()).toBe(true); + }); + + it("applies custom classes and styles", () => { + const wrapper = mount(Folder, { + props: { + treeData: mockTreeData, + classes: { + root: "custom-root", + directoryTree: "custom-tree", + filePreview: "custom-preview", + }, + styles: { + root: { padding: "10px" }, + directoryTree: { border: "1px solid red" }, + }, + }, + }); + expect(wrapper.find(".custom-root").exists()).toBe(true); + expect(wrapper.find(".custom-tree").exists()).toBe(true); + }); + + it("handles error in fileContentService", async () => { + const fileContentService = { + loadFileContent: vi.fn().mockRejectedValue(new Error("Network error")), + }; + mount(Folder, { + props: { + treeData: mockFlatNoContent, + defaultSelectedFile: ["Button.tsx"], + fileContentService, + }, + }); + await nextTick(); + await flush(); + expect(fileContentService.loadFileContent).toHaveBeenCalled(); + }); + + it("supports root path '/'", () => { + const treeDataWithRoot: FolderTreeData[] = [ + { + title: "/", + path: "/", + children: [ + { + title: "index.ts", + path: "index.ts", + content: "export default {};", + }, + ], + }, + ]; + const wrapper = mount(Folder, { props: { treeData: treeDataWithRoot } }); + expect(wrapper.find(".antd-folder").exists()).toBe(true); + }); + + it("handles node with empty children (treated as file)", () => { + const wrapper = mount(Folder, { + props: { + treeData: [{ title: "emptyDir", path: "emptyDir", children: [] }], + }, + }); + expect(wrapper.find(".antd-folder").exists()).toBe(true); + }); + + it("supports directoryTitle slot (overrides prop)", () => { + const wrapper = mount(Folder, { + props: { + treeData: mockTreeData, + directoryTitle: "Prop Title", + }, + slots: { + directoryTitle: () => h("span", { class: "slot-dir-title" }, "Slot"), + }, + }); + expect(wrapper.find(".slot-dir-title").exists()).toBe(true); + expect(wrapper.text()).not.toContain("Prop Title"); + }); + + it("supports previewTitle slot", () => { + const wrapper = mount(Folder, { + props: { + treeData: mockTreeData, + defaultSelectedFile: ["package.json"], + }, + slots: { + previewTitle: ({ title }: any) => + h("span", { class: "slot-prev-title" }, `T: ${title as string}`), + }, + }); + expect(wrapper.find(".slot-prev-title").exists()).toBe(true); + }); + + it("supports previewRender slot", async () => { + const wrapper = mount(Folder, { + props: { + treeData: mockTreeData, + defaultSelectedFile: ["package.json"], + }, + slots: { + previewRender: ({ file }: any) => + h( + "div", + { class: "slot-prev-render" }, + `lang=${file.language as string}`, + ), + }, + }); + await nextTick(); + expect(wrapper.find(".slot-prev-render").exists()).toBe(true); + }); + + it("supports emptyRender slot", () => { + const wrapper = mount(Folder, { + props: { treeData: mockTreeData }, + slots: { emptyRender: () => h("div", { class: "slot-empty" }, "Empty") }, + }); + expect(wrapper.find(".slot-empty").exists()).toBe(true); + }); + + it("handles file with no extension", () => { + const wrapper = mount(Folder, { + props: { + treeData: [ + { title: "Makefile", path: "Makefile", content: "all: build" }, + ], + defaultSelectedFile: ["Makefile"], + }, + }); + expect(wrapper.find(".antd-folder").exists()).toBe(true); + }); +}); diff --git a/packages/x/components/folder/index.ts b/packages/x/components/folder/index.ts new file mode 100644 index 0000000..6ce7ca2 --- /dev/null +++ b/packages/x/components/folder/index.ts @@ -0,0 +1,19 @@ +import Folder from "./Folder"; + +export type { + DirectoryIconsMap, + DirectoryTitleRender, + EmptyRender, + FileContentService, + FolderProps, + FolderRef, + FolderSlots, + FolderTreeData, + PreviewFileInfo, + PreviewRender, + PreviewTitleRender, + SelectedFileInfo, + SemanticType as FolderSemanticType, +} from "./interface"; + +export default Folder; diff --git a/packages/x/components/folder/interface.ts b/packages/x/components/folder/interface.ts new file mode 100644 index 0000000..ac5a599 --- /dev/null +++ b/packages/x/components/folder/interface.ts @@ -0,0 +1,119 @@ +import type { + CSSProperties, + HTMLAttributes, + StyleValue, + VNodeChild, +} from "vue"; + +export type SemanticType = + | "root" + | "directoryTree" + | "directoryTitle" + | "filePreview" + | "previewTitle" + | "previewRender"; + +export interface FolderTreeData { + title: VNodeChild; + path: string; + content?: string; + children?: FolderTreeData[]; +} + +export interface FileContentService { + loadFileContent: (filePath: string) => Promise; +} + +export type DirectoryIconRenderer = VNodeChild | (() => VNodeChild); +export type DirectoryIconsMap = + | false + | (Record & { + directory?: DirectoryIconRenderer; + }); + +export interface PreviewFileInfo { + content?: string; + path: string[]; + title?: FolderTreeData["title"]; + language: string; +} + +export interface SelectedFileInfo { + path: string[]; + title?: FolderTreeData["title"]; + content?: string; +} + +export type EmptyRender = false | null | VNodeChild | (() => VNodeChild); + +export type PreviewTitleRender = + | false + | null + | VNodeChild + | ((info: { + title: VNodeChild; + path: string[]; + content: string; + }) => VNodeChild); + +export type PreviewRender = + | VNodeChild + | ((file: PreviewFileInfo, info: { originNode: VNodeChild }) => VNodeChild); + +export type DirectoryTitleRender = + | false + | null + | VNodeChild + | (() => VNodeChild); + +export interface FolderProps extends Omit { + prefixCls?: string; + rootClass?: string; + class?: any; + style?: StyleValue; + classes?: Partial>; + styles?: Partial>; + + treeData: FolderTreeData[]; + directoryIcons?: DirectoryIconsMap; + + selectable?: boolean; + selectedFile?: string[]; + defaultSelectedFile?: string[]; + onSelectedFileChange?: (file: SelectedFileInfo) => void; + + directoryTreeWith?: number | string; + emptyRender?: EmptyRender; + previewRender?: PreviewRender; + + defaultExpandedPaths?: string[]; + expandedPaths?: string[]; + defaultExpandAll?: boolean; + onExpandedPathsChange?: (paths: string[]) => void; + + fileContentService?: FileContentService; + + onFileClick?: (filePath: string, content?: string) => void; + onFolderClick?: (folderPath: string) => void; + + directoryTitle?: DirectoryTitleRender; + previewTitle?: PreviewTitleRender; +} + +export interface FolderRef { + nativeElement: HTMLDivElement; +} + +export interface FolderSlots { + directoryTitle?: () => VNodeChild; + previewTitle?: (info: { + title: VNodeChild; + path: string[]; + content: string; + }) => VNodeChild; + previewRender?: (info: { + file: PreviewFileInfo; + originNode: VNodeChild; + }) => VNodeChild; + emptyRender?: () => VNodeChild; +} diff --git a/packages/x/components/folder/style/folder.ts b/packages/x/components/folder/style/folder.ts new file mode 100644 index 0000000..6eabe14 --- /dev/null +++ b/packages/x/components/folder/style/folder.ts @@ -0,0 +1,116 @@ +import type { FullToken, GenerateStyle } from "../../theme/interface"; + +export interface FolderToken extends FullToken<"Folder"> { + colorBgDirectory: string; +} + +const genFolderStyle: GenerateStyle = token => { + const { componentCls, antCls } = token; + + return { + [componentCls]: { + height: "100%", + width: "100%", + background: token.colorBgDirectory, + [`${antCls}-tree-node-content-wrapper`]: { + display: "flex", + }, + [`${antCls}-tree-node-content-wrapper-open,${antCls}-tree-node-content-wrapper-close`]: + { + display: "flex", + }, + [`${antCls}-tree-node-content-wrapper-normal`]: { + display: "flex", + }, + [`${antCls}-tree-list`]: { + paddingInline: token.padding, + paddingBlock: token.paddingXS, + }, + [`${antCls}-tree-switcher`]: { + width: "10px", + "&:before": { + width: "10px", + height: "10px", + }, + }, + [`${antCls}-tree-node-content-wrapper`]: { + paddingInline: 0, + }, + [`${componentCls}-container`]: { + height: "100%", + }, + [`${componentCls}-directory-tree`]: { + height: "100%", + display: "flex", + flexDirection: "column", + }, + [`${componentCls}-directory-tree-title`]: { + width: "100%", + display: "flex", + alignItems: "center", + }, + [`${componentCls}-directory-tree-content`]: { + width: "100%", + background: "transparent", + height: "100%", + borderRadius: "unset", + display: "flex", + overflow: "auto", + }, + [`&${componentCls}-rtl`]: { + direction: "rtl", + }, + }, + }; +}; + +export const genFilePreviewStyle: GenerateStyle = token => { + const { componentCls } = token; + + return { + [componentCls]: { + [`${componentCls}-preview`]: { + width: "100%", + background: token.colorBgContainer, + flex: 1, + height: "100%", + display: "flex", + flexDirection: "column", + }, + [`${componentCls}-preview-title-wrapper`]: { + background: token.colorBgContainer, + paddingInline: token.padding, + paddingBlock: token.paddingXS, + borderBottom: `1px solid ${token.colorBorderSecondary}`, + }, + [`${componentCls}-preview-title`]: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + }, + [`${componentCls}-preview-content`]: { + overflow: "auto", + flex: 1, + background: token.colorBgContainer, + paddingInline: token.padding, + paddingBlock: token.paddingXS, + }, + [`${componentCls}-preview-loading-container`]: { + display: "flex", + justifyContent: "center", + alignItems: "center", + marginBlockStart: token.calc(token.marginLG).mul(3).equal(), + }, + [`${componentCls}-preview-empty-container`]: { + marginBlockStart: token.calc(token.marginLG).mul(3).equal(), + }, + [`${componentCls}-directory-tree-item-title`]: { + display: "flex", + whiteSpace: "nowrap", + paddingInlineEnd: token.padding, + }, + }, + }; +}; + +export default genFolderStyle; diff --git a/packages/x/components/folder/style/index.ts b/packages/x/components/folder/style/index.ts new file mode 100644 index 0000000..300f332 --- /dev/null +++ b/packages/x/components/folder/style/index.ts @@ -0,0 +1,28 @@ +import { mergeToken } from "@antdv-next/cssinjs/cssinjs-utils"; + +import type { GetDefaultToken } from "../../theme/interface"; +import type { FolderToken } from "./folder"; + +import { genStyleHooks } from "../../theme/genStyleUtils"; +import genFolderStyle, { genFilePreviewStyle } from "./folder"; + +export interface ComponentToken { + /** + * @desc Background color of directory + * @descZH 目录背景色 + */ + colorBgDirectory: string; +} + +export const prepareComponentToken: GetDefaultToken<"Folder"> = token => ({ + colorBgDirectory: token.colorFillTertiary, +}); + +export default genStyleHooks<"Folder">( + "Folder", + token => { + const folderToken = mergeToken(token, {}); + return [genFolderStyle(folderToken), genFilePreviewStyle(folderToken)]; + }, + prepareComponentToken, +); diff --git a/packages/x/components/index.ts b/packages/x/components/index.ts index 6a9fa18..d5737a5 100644 --- a/packages/x/components/index.ts +++ b/packages/x/components/index.ts @@ -12,6 +12,7 @@ import Bubble, { BubbleDivider, BubbleList, BubbleSystem } from "./bubble"; import CodeHighlighter from "./code-highlighter"; import Conversations, { ConversationsCreation } from "./conversations"; import FileCard, { FileCardList } from "./file-card"; +import Folder from "./folder"; import Mermaid from "./mermaid"; import Notification, { XNotification } from "./notification"; import Prompts from "./prompts"; @@ -42,6 +43,7 @@ const components = [ ActionsItem, FileCard, FileCardList, + Folder, Mermaid, Attachments, Sender, @@ -80,6 +82,7 @@ export { ConversationsCreation, FileCard, FileCardList, + Folder, Mermaid, Notification, XNotification, @@ -117,6 +120,17 @@ export type { } from "./bubble"; export type { FileCardListProps, FileCardProps } from "./file-card"; +export type { + DirectoryIconsMap, + FileContentService, + FolderProps, + FolderRef, + FolderSemanticType, + FolderSlots, + FolderTreeData, + PreviewFileInfo, + SelectedFileInfo, +} from "./folder"; export type { MermaidActions, MermaidProps, diff --git a/packages/x/components/theme/interface/components.ts b/packages/x/components/theme/interface/components.ts index a9171de..aee381d 100644 --- a/packages/x/components/theme/interface/components.ts +++ b/packages/x/components/theme/interface/components.ts @@ -4,6 +4,7 @@ import type { ComponentToken as BubbleComponentToken } from "../../bubble/style" import type { ComponentToken as CodeHighlighterToken } from "../../code-highlighter/style"; import type { ComponentToken as ConversationsComponentToken } from "../../conversations/style"; import type { ComponentToken as FileCardComponentToken } from "../../file-card/style"; +import type { ComponentToken as FolderComponentToken } from "../../folder/style"; import type { ComponentToken as MermaidComponentToken } from "../../mermaid/style"; import type { ComponentToken as PromptsComponentToken } from "../../prompts/style"; import type { ComponentToken as SenderComponentToken } from "../../sender/style"; @@ -13,8 +14,6 @@ import type { ComponentToken as ThinkComponentToken } from "../../think/style"; import type { ComponentToken as ThoughtChainComponentToken } from "../../thought-chain/style"; import type { ComponentToken as WelcomeComponentToken } from "../../welcome/style"; -type EmptyComponentToken = Record; - export interface ComponentTokenMap { Attachments?: AttachmentsComponentToken; Bubble?: BubbleComponentToken; @@ -27,7 +26,7 @@ export interface ComponentTokenMap { Welcome?: WelcomeComponentToken; Actions?: ActionsToken; FileCard?: FileCardComponentToken; - Folder?: EmptyComponentToken; + Folder?: FolderComponentToken; Sources?: SourcesComponentToken; CodeHighlighter?: CodeHighlighterToken; Mermaid?: MermaidComponentToken; diff --git a/packages/x/components/x-provider/context.ts b/packages/x/components/x-provider/context.ts index e17b661..6f0e3f3 100644 --- a/packages/x/components/x-provider/context.ts +++ b/packages/x/components/x-provider/context.ts @@ -9,6 +9,7 @@ import type { BubbleProps } from "../bubble"; import type { CodeHighlighterProps } from "../code-highlighter"; import type { ConversationsProps } from "../conversations"; import type { FileCardProps } from "../file-card"; +import type { FolderProps } from "../folder"; import type { MermaidProps } from "../mermaid"; import type { PromptsProps } from "../prompts"; import type { SenderProps } from "../sender"; @@ -40,6 +41,7 @@ export interface XComponentsConfig { sources?: Pick; fileCard?: Pick; codeHighlighter?: Pick; + folder?: Pick; mermaid?: Pick; prompts?: Pick; sender?: Pick & { From e36f6cdc773338315fb8d34707b4f07bba23529f Mon Sep 17 00:00:00 2001 From: Carl Chen Date: Thu, 14 May 2026 21:38:25 +0800 Subject: [PATCH 2/3] feat: optimize demo & style --- .../pages/components/folder/demo/basic.vue | 68 ++++-- .../components/folder/demo/custom-icons.vue | 152 ++++++++++++- .../components/folder/demo/custom-service.vue | 85 ++++++++ .../folder/demo/file-controlled.vue | 87 ++++++-- .../folder/demo/fully-controlled.vue | 71 ++++++ .../components/folder/demo/preview-render.vue | 69 ++++++ .../components/folder/demo/searchable.vue | 206 ++++++++++++++++++ .../pages/components/folder/index.en-US.md | 10 +- .../pages/components/folder/index.zh-CN.md | 8 +- .../x/components/folder/DirectoryTree.tsx | 8 +- packages/x/components/folder/FilePreview.tsx | 39 +++- 11 files changed, 737 insertions(+), 66 deletions(-) create mode 100644 packages/docs/src/pages/components/folder/demo/custom-service.vue create mode 100644 packages/docs/src/pages/components/folder/demo/fully-controlled.vue create mode 100644 packages/docs/src/pages/components/folder/demo/preview-render.vue create mode 100644 packages/docs/src/pages/components/folder/demo/searchable.vue diff --git a/packages/docs/src/pages/components/folder/demo/basic.vue b/packages/docs/src/pages/components/folder/demo/basic.vue index 6b84c4d..1b5cc21 100644 --- a/packages/docs/src/pages/components/folder/demo/basic.vue +++ b/packages/docs/src/pages/components/folder/demo/basic.vue @@ -3,53 +3,73 @@ import type { FolderTreeData } from "@antdv-next/x"; const treeData: FolderTreeData[] = [ { - title: "src", - path: "src", + title: "use-x-chat", + path: "use-x-chat", children: [ { - title: "components", - path: "components", + title: "SKILL.md", + path: "SKILL.md", + content: `--- +name: use-x-chat +version: 2.3.0 +description: Focus on explaining how to use the useXChat Hook, including custom Provider integration, message management, error handling, etc. +--- + +# Quick Start + +Use the useXChat Hook to build professional AI conversation applications. + +## Requirements + +- @ant-design/x-sdk: 2.2.2+ +- @ant-design/x: latest +`, + }, + { + title: "reference", + path: "reference", children: [ { - title: "Button.tsx", - path: "Button.tsx", - content: "export const Button = () => ;", + title: "API.md", + path: "API.md", + content: `### useXChat + +\`\`\`tsx +const { messages, onRequest, isRequesting } = useXChat({ provider }); +\`\`\` + +Message list and request state are exposed from the hook.`, }, { - title: "Input.tsx", - path: "Input.tsx", - content: "export const Input = () => ;", + title: "CORE.md", + path: "CORE.md", + content: `### Message Management + +- Read current messages +- Append new messages +- Abort active request +- Reload an existing message`, }, ], }, - { - title: "index.ts", - path: "index.ts", - content: "export * from './components';", - }, ], }, - { - title: "package.json", - path: "package.json", - content: '{\n "name": "demo-app"\n}', - }, ]; -基础用法,展示文件树及选中文件的内容预览。 +基础用法。 -Basic usage. Render a file tree and preview the selected file's content. +Basic usage. diff --git a/packages/docs/src/pages/components/folder/demo/custom-icons.vue b/packages/docs/src/pages/components/folder/demo/custom-icons.vue index 8abd107..58688fd 100644 --- a/packages/docs/src/pages/components/folder/demo/custom-icons.vue +++ b/packages/docs/src/pages/components/folder/demo/custom-icons.vue @@ -1,39 +1,167 @@ -通过 `directoryIcons` 自定义目录与按扩展名匹配的文件图标。 +自定义文件图标。 -Customize directory icon and per-extension file icons via `directoryIcons`. +Custom file icons. diff --git a/packages/docs/src/pages/components/folder/demo/custom-service.vue b/packages/docs/src/pages/components/folder/demo/custom-service.vue new file mode 100644 index 0000000..b3563fa --- /dev/null +++ b/packages/docs/src/pages/components/folder/demo/custom-service.vue @@ -0,0 +1,85 @@ + + + + + +自定义文件内容服务。 + + + +Custom file content service. + diff --git a/packages/docs/src/pages/components/folder/demo/file-controlled.vue b/packages/docs/src/pages/components/folder/demo/file-controlled.vue index b272722..e8ceabb 100644 --- a/packages/docs/src/pages/components/folder/demo/file-controlled.vue +++ b/packages/docs/src/pages/components/folder/demo/file-controlled.vue @@ -5,44 +5,105 @@ import { ref } from "vue"; const treeData: FolderTreeData[] = [ { - title: "src", - path: "src", + title: "x-chat-provider", + path: "x-chat-provider", children: [ { - title: "App.vue", - path: "App.vue", - content: "", + title: "SKILL.md", + path: "SKILL.md", + content: `--- +name: x-chat-provider +version: 2.3.0 +description: Focus on implementing custom Chat Provider, helping to adapt any streaming interface to Ant Design X standard format +--- + +# Skill Positioning + +How to quickly adapt your streaming interface to Ant Design X's Chat Provider.`, }, { - title: "main.ts", - path: "main.ts", - content: "import App from './App.vue';", + title: "reference", + path: "reference", + children: [ + { + title: "EXAMPLES.md", + path: "EXAMPLES.md", + content: `## Scenario 1 + +Use built-in provider for OpenAI-compatible services. + +## Scenario 2 + +Use a custom provider when the response protocol is not standard.`, + }, + ], }, ], }, ]; -const selectedFile = ref(["src", "App.vue"]); +const selectedFile = ref(); function onChange(info: SelectedFileInfo) { selectedFile.value = info.path; } + +function selectSkill() { + selectedFile.value = ["x-chat-provider", "SKILL.md"]; +} + +function selectExamples() { + selectedFile.value = ["x-chat-provider", "reference", "EXAMPLES.md"]; +} + +function clearSelection() { + selectedFile.value = []; +} -通过 `selectedFile` + `@selected-file-change` 实现受控选中。 +受控文件选择。 -Controlled file selection via `selectedFile` + `@selected-file-change`. +Controlled file selection. diff --git a/packages/docs/src/pages/components/folder/demo/fully-controlled.vue b/packages/docs/src/pages/components/folder/demo/fully-controlled.vue new file mode 100644 index 0000000..79a5891 --- /dev/null +++ b/packages/docs/src/pages/components/folder/demo/fully-controlled.vue @@ -0,0 +1,71 @@ + + + + + +完全受控模式。 + + + +Fully controlled mode. + diff --git a/packages/docs/src/pages/components/folder/demo/preview-render.vue b/packages/docs/src/pages/components/folder/demo/preview-render.vue new file mode 100644 index 0000000..1177f29 --- /dev/null +++ b/packages/docs/src/pages/components/folder/demo/preview-render.vue @@ -0,0 +1,69 @@ + + + + + +使用 `previewRender` 属性自定义文件预览内容。 + +`previewRender` 支持两种形式: + +- ReactNode:直接渲染自定义内容 +- 函数形式:`(file, info) => ReactNode`,其中: + - `file`: 包含文件信息的对象 `{ content, path, title, language }` + - `info`: 包含原始预览节点的对象 `{ originNode }` + + + +Customize file preview content using the `previewRender` prop. + +`previewRender` supports two forms: + +- ReactNode: Directly render custom content +- Function form: `(file, info) => ReactNode`, where: + - `file`: Object containing file info `{ content, path, title, language }` + - `info`: Object containing original preview node `{ originNode }` + diff --git a/packages/docs/src/pages/components/folder/demo/searchable.vue b/packages/docs/src/pages/components/folder/demo/searchable.vue new file mode 100644 index 0000000..b09e9dd --- /dev/null +++ b/packages/docs/src/pages/components/folder/demo/searchable.vue @@ -0,0 +1,206 @@ + + + + + +可搜索的文件浏览器。 + + + +Searchable file browser. + diff --git a/packages/docs/src/pages/components/folder/index.en-US.md b/packages/docs/src/pages/components/folder/index.en-US.md index 46efcb2..df4ad5c 100644 --- a/packages/docs/src/pages/components/folder/index.en-US.md +++ b/packages/docs/src/pages/components/folder/index.en-US.md @@ -10,10 +10,14 @@ description: File tree component for displaying hierarchical file structure. ## Examples -Basic +Basic Usage +Custom File Service +Controlled File Selection +Fully Controlled Mode +Searchable File Tree Custom Icons -Controlled Selection -Slots +Custom Preview Content +Custom Slots ## API diff --git a/packages/docs/src/pages/components/folder/index.zh-CN.md b/packages/docs/src/pages/components/folder/index.zh-CN.md index 4984908..1fe30c1 100644 --- a/packages/docs/src/pages/components/folder/index.zh-CN.md +++ b/packages/docs/src/pages/components/folder/index.zh-CN.md @@ -12,9 +12,13 @@ description: 用于展示层级文件结构的文件树组件。 ## 代码演示 基础用法 +自定义文件服务 +受控文件选择 +完全受控模式 +可搜索的文件树 自定义图标 -受控选中 -插槽 +自定义预览内容 +自定义插槽 ## API diff --git a/packages/x/components/folder/DirectoryTree.tsx b/packages/x/components/folder/DirectoryTree.tsx index 4ea0522..6407366 100644 --- a/packages/x/components/folder/DirectoryTree.tsx +++ b/packages/x/components/folder/DirectoryTree.tsx @@ -177,8 +177,12 @@ const FolderDirectoryTree = defineComponent({ blockNode: true, showLine: props.showLine, defaultExpandAll: props.defaultExpandAll, - class: `${prefixCls}-directory-tree-content`, - classNames: { + class: [ + `${prefixCls}-directory-tree-content`, + props.classes?.directoryTree, + ], + style: props.styles?.directoryTree, + classes: { itemTitle: `${prefixCls}-directory-tree-item-title`, }, onSelect: (keys: any, info: any) => diff --git a/packages/x/components/folder/FilePreview.tsx b/packages/x/components/folder/FilePreview.tsx index b37f0da..8073471 100644 --- a/packages/x/components/folder/FilePreview.tsx +++ b/packages/x/components/folder/FilePreview.tsx @@ -11,9 +11,12 @@ import type { PreviewTitleRender, } from "./interface"; +import useXComponentConfig from "../_utils/hooks/use-x-component-config"; import { XActionsCopy } from "../actions/ActionsCopy"; +import CodeHighlighter from "../code-highlighter"; import { useLocale } from "../locale"; import enUS from "../locale/en_US"; +import useStyle from "./style"; export interface FilePreviewProps { prefixCls: string; @@ -102,18 +105,25 @@ const FilePreview = defineComponent({ }, setup(props) { const [locale] = useLocale("Folder", enUS.Folder); + const contextConfig = useXComponentConfig("folder"); + const [hashId, cssVarCls] = useStyle(computed(() => props.prefixCls)); const previewCls = computed(() => `${props.prefixCls}-preview`); const renderOriginContent = (fileContent: string, language: string) => ( -
-        
-          {fileContent.replace(/\n$/, "")}
-        
-      
+ ); const renderEmpty = () => { @@ -252,8 +262,17 @@ const FilePreview = defineComponent({ return () => (
{renderContent()}
From ada8eebf412d12e9f0ae25876886c9d455b4b756 Mon Sep 17 00:00:00 2001 From: Carl Chen Date: Thu, 14 May 2026 22:51:33 +0800 Subject: [PATCH 3/3] feat: add. semantic demo --- .../pages/components/folder/demo/semantic.vue | 158 ++++++++++++++++++ .../pages/components/folder/index.en-US.md | 4 + .../pages/components/folder/index.zh-CN.md | 4 + 3 files changed, 166 insertions(+) create mode 100644 packages/docs/src/pages/components/folder/demo/semantic.vue diff --git a/packages/docs/src/pages/components/folder/demo/semantic.vue b/packages/docs/src/pages/components/folder/demo/semantic.vue new file mode 100644 index 0000000..ebc8ced --- /dev/null +++ b/packages/docs/src/pages/components/folder/demo/semantic.vue @@ -0,0 +1,158 @@ + + + + + +Folder 的语义化 DOM 结构预览。 + + + +Semantic DOM preview for Folder. + diff --git a/packages/docs/src/pages/components/folder/index.en-US.md b/packages/docs/src/pages/components/folder/index.en-US.md index df4ad5c..19ac508 100644 --- a/packages/docs/src/pages/components/folder/index.en-US.md +++ b/packages/docs/src/pages/components/folder/index.en-US.md @@ -19,6 +19,10 @@ description: File tree component for displaying hierarchical file structure. Custom Preview Content Custom Slots +## Semantic DOM + +Folder Semantic DOM + ## API ### FolderProps diff --git a/packages/docs/src/pages/components/folder/index.zh-CN.md b/packages/docs/src/pages/components/folder/index.zh-CN.md index 1fe30c1..0082e1f 100644 --- a/packages/docs/src/pages/components/folder/index.zh-CN.md +++ b/packages/docs/src/pages/components/folder/index.zh-CN.md @@ -20,6 +20,10 @@ description: 用于展示层级文件结构的文件树组件。 自定义预览内容 自定义插槽 +## 语义化 DOM + +Folder 语义结构 + ## API ### FolderProps