From 69a13cef6d3429c36d3fa5c6f7fcfdd559aed2f4 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Tue, 8 Sep 2026 21:58:35 +0800 Subject: [PATCH] feat(primitives): add file-type icon set and shared link icon --- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 3 +- packages/client/ui-primitives/README.zh.md | 3 +- .../client/ui-primitives/src/FileTypeIcon.tsx | 167 ++++++++++++++++++ .../client/ui-primitives/src/LinkIcon.tsx | 34 ++-- .../ui-primitives/src/file-extensions.ts | 59 +++++++ .../client/ui-primitives/src/icons/index.tsx | 4 +- packages/client/ui-primitives/src/index.ts | 2 + .../tests/file-type-icon.client.spec.tsx | 61 +++++++ 9 files changed, 310 insertions(+), 27 deletions(-) create mode 100644 packages/client/ui-primitives/src/FileTypeIcon.tsx create mode 100644 packages/client/ui-primitives/src/file-extensions.ts create mode 100644 packages/client/ui-primitives/tests/file-type-icon.client.spec.tsx diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 4f9173f729..138d94440f 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: 7b670394f6ca70ea369fc79766b26119f670f753 -README.zh.md: 8ddefde928a723992945f801fb7407a2d22f5f27 +README.md: 8fd59c5ecc98a609f1383e428701cf590da95e73 +README.zh.md: 2478330825681f65aa83415c698cf0edc54fdad6 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 7b670394f6..8fd59c5ecc 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -55,6 +55,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `MarkdownText`, `CodeBlock` | Untrusted GFM with TeX math, and highlighted code. `CodeBlock` accepts opt-in `lineNumbers`; copied source excludes the gutter. | | `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, `WebBlock` | The agent-output card matching each tool-result intent. | | `icons/*`, `FishLogo`, `BrandWordmark`, `ReferenceIcon`, `LinkIcon`, `DocumentFileIcon` | Glyphs and brand marks, all riding `currentColor`. | +| `FileTypeIcon` | The coloured file-type sheet (code, html, image, markdown, pdf, sheet, slides, document, other); `classifyFileType` picks the kind from a path's extension. | Three pairs are easy to confuse: @@ -66,7 +67,7 @@ Writing your own component in your own package is fine when the need is genuinel ### Controls and icons -The catalog above lists what each export is for; this section covers the behavior that props alone do not show. The `ic_ds_*` icon set and `FishLogo`/`BrandWordmark` marks fill brand and inline-icon slots. `LinkIcon` draws the leading category glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, all riding `currentColor` — and `classifyLinkPath` derives a file path's category from its extension. `ConnectionIndicator` renders a warning-colored disconnected action, a connecting label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Hover or keyboard focus shows only the reconnect action label, including while the connecting dots animate. Every state reserves the widest supplied label and uses fixed icon and text columns, so copy changes do not move or resize the control. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. +The catalog above lists what each export is for; this section covers the behavior that props alone do not show. The `ic_ds_*` icon set and `FishLogo`/`BrandWordmark` marks fill brand and inline-icon slots. `LinkIcon` draws the leading category glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, all riding `currentColor` — and `classifyLinkPath` derives a file path's category from its extension. `FileTypeIcon` is the one glyph that does not ride `currentColor`: its sheet colour is the type's identity, so a consumer that wants it muted (an empty state) applies `filter: grayscale(1)` itself. `classifyFileType` and `classifyLinkPath` read one extension vocabulary (`file-extensions.ts`), so a path classifies consistently on a link and on a sheet; the sheet is the finer of the two. `ConnectionIndicator` renders a warning-colored disconnected action, a connecting label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Hover or keyboard focus shows only the reconnect action label, including while the connecting dots animate. Every state reserves the widest supplied label and uses fixed icon and text columns, so copy changes do not move or resize the control. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. ### Rendering agent output diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 8ddefde928..2478330825 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -55,6 +55,7 @@ kind: "package-library" | `MarkdownText`、`CodeBlock` | 不可信 GFM 与 TeX 数学,以及高亮代码。`CodeBlock` 可通过 `lineNumbers` 开启行号;复制的源码不含行号栏。 | | `TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock`、`WebBlock` | 与各类工具结果意图对应的 agent 输出卡片。 | | `icons/*`、`FishLogo`、`BrandWordmark`、`ReferenceIcon`、`LinkIcon`、`DocumentFileIcon` | 字形与品牌标识,全部随 `currentColor`。 | +| `FileTypeIcon` | 彩色文件类型纸片(code、html、image、markdown、pdf、sheet、slides、document、other);`classifyFileType` 按路径扩展名选出类型。 | 有三组容易混淆: @@ -66,7 +67,7 @@ kind: "package-library" ### 控件与图标 -上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。`ic_ds_*` 图标集与 `FishLogo`/`BrandWordmark` 标记填充品牌与行内图标 slot。`LinkIcon` 为可点击产物链接绘制前置分类图形——地球、文件夹、代码、图片、文档或纸张,全部随 `currentColor`——`classifyLinkPath` 按扩展名推导文件路径的类别。`ConnectionIndicator` 可渲染警告色的断联操作、以独立于 retry 时序的 500ms 节奏推进一至三个点的连接中状态,或成功色的恢复状态。悬停或键盘聚焦时只显示重连操作文案,连接中的圆点动画也保持隐藏。所有状态都为最长的输入 label 预留空间,并使用固定的图标列和文字列,因此文案变化不会移动控件或改变其宽度。它的 owner 提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。 `Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。 `rankByName` 是 `/` 菜单命令源与 skill 源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 +上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。`ic_ds_*` 图标集与 `FishLogo`/`BrandWordmark` 标记填充品牌与行内图标 slot。`LinkIcon` 为可点击产物链接绘制前置分类图形——地球、文件夹、代码、图片、文档或纸张,全部随 `currentColor`——`classifyLinkPath` 按扩展名推导文件路径的类别。`FileTypeIcon` 是唯一不随 `currentColor` 的字形:纸片颜色就是类型的身份,需要压灰的使用方(空状态)自行加 `filter: grayscale(1)`。`classifyFileType` 与 `classifyLinkPath` 读同一份扩展名词表(`file-extensions.ts`),一条路径在链接上与纸片上的归类一致;纸片分得更细。`ConnectionIndicator` 可渲染警告色的断联操作、以独立于 retry 时序的 500ms 节奏推进一至三个点的连接中状态,或成功色的恢复状态。悬停或键盘聚焦时只显示重连操作文案,连接中的圆点动画也保持隐藏。所有状态都为最长的输入 label 预留空间,并使用固定的图标列和文字列,因此文案变化不会移动控件或改变其宽度。它的 owner 提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。 `Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。 `rankByName` 是 `/` 菜单命令源与 skill 源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 ### 渲染 agent 输出 diff --git a/packages/client/ui-primitives/src/FileTypeIcon.tsx b/packages/client/ui-primitives/src/FileTypeIcon.tsx new file mode 100644 index 0000000000..f03c01d4a7 --- /dev/null +++ b/packages/client/ui-primitives/src/FileTypeIcon.tsx @@ -0,0 +1,167 @@ +/** + * The coloured file-type sheet: a 28×28 paper with a folded corner, filled in + * the type's colour, carrying a white mark for the type. Unlike the `ic_ds_*` + * glyphs it does not ride `currentColor` — the colour is the type's identity — + * so a consumer that wants it muted applies a CSS filter. Design sources: + * DSHFiles iconCode, iconHTML, iconImage, iconMD, iconPDF, iconExcel, + * iconPPT, iconWord, iconOthers. + * + * TODO: interim. The sheets and `classifyFileType` are drawn for the Sidebar + * only; the product-wide file-type standard that will replace them is described + * in `file-extensions.ts`. + */ +import type { ReactNode } from 'react' +import { + CODE_EXTENSIONS, HTML_EXTENSIONS, IMAGE_EXTENSIONS, MARKDOWN_EXTENSIONS, PDF_EXTENSIONS, SHEET_EXTENSIONS, + SLIDES_EXTENSIONS, WORD_EXTENSIONS, fileExtension, +} from './file-extensions.ts' +import type { IconProps } from './icons/props.ts' + +/** File types with their own sheet colour and mark; `other` is the grey sheet with text lines. */ +export type FileTypeKind = 'code' | 'html' | 'image' | 'markdown' | 'pdf' | 'sheet' | 'slides' | 'document' | 'other' + +/** Props for {@link FileTypeIcon}: the type plus the shared icon sizing seat. */ +export interface FileTypeIconProps extends IconProps { + kind: FileTypeKind +} + +/** Extension sets in precedence order: a specific type wins over the code set it may also sit in. */ +const KIND_SETS: readonly (readonly [FileTypeKind, ReadonlySet])[] = [ + ['markdown', MARKDOWN_EXTENSIONS], + ['html', HTML_EXTENSIONS], + ['pdf', PDF_EXTENSIONS], + ['sheet', SHEET_EXTENSIONS], + ['slides', SLIDES_EXTENSIONS], + ['document', WORD_EXTENSIONS], + ['image', IMAGE_EXTENSIONS], + ['code', CODE_EXTENSIONS], +] + +/** + * Derive a file path's type glyph from its extension. Unknown and missing + * extensions fall to `other`. + * @param path - File path or bare name, with either separator. + * @returns The file's type. + */ +export function classifyFileType(path: string): FileTypeKind { + const extension = fileExtension(path) + return KIND_SETS.find(([, set]) => set.has(extension))?.[0] ?? 'other' +} + +const SHEET_PATH = 'M8.48949 28H19.511C21.6482 28 22.7167 28 23.5596 27.6509C24.6835 27.1853 25.5764 26.2924 26.042 25.1685C26.3911 24.3256 26.3911 23.257 26.3911 21.1199V8.79443C26.3911 8.32877 26.3911 8.09593 26.3473 7.87507C26.2889 7.58058 26.1733 7.30042 26.007 7.05048C25.8822 6.86303 25.718 6.69799 25.3895 6.36792L20.0613 1.01354C19.7307 0.681235 19.5653 0.515081 19.3771 0.38885C19.1263 0.220541 18.8446 0.103463 18.5483 0.0443412C18.3261 0 18.0917 0 17.6229 0H8.48949C6.35233 0 5.28376 0 4.44085 0.349145C3.31697 0.814671 2.42405 1.70759 1.95852 2.83147C1.60938 3.67438 1.60938 4.74296 1.60938 6.88011V21.1199C1.60938 23.257 1.60938 24.3256 1.95852 25.1685C2.42405 26.2924 3.31697 27.1853 4.44085 27.6509C5.28376 28 6.35233 28 8.48949 28Z' + +const CORNER_PATH = 'M26.3911 7.37445L19.0527 0V3.77445C19.0527 4.89271 19.0527 5.45184 19.2354 5.89289C19.479 6.48096 19.9462 6.94818 20.5343 7.19176C20.9753 7.37445 21.5345 7.37445 22.6527 7.37445H26.3911Z' + +/** One type's sheet colour and its white mark. */ +interface Face { + readonly fill: string + readonly mark: ReactNode +} + +const FACES: Readonly> = { + code: { + fill: '#4176E6', + mark: ( + <> + + + + ), + }, + html: { + fill: '#4176E6', + mark: ( + + ), + }, + image: { + fill: '#8B76F6', + mark: ( + <> + + + + + ), + }, + markdown: { + fill: '#4176E6', + mark: ( + + ), + }, + pdf: { + fill: '#EC1313', + mark: ( + + ), + }, + sheet: { + fill: '#22C55E', + mark: ( + + ), + }, + slides: { + fill: '#F59E0B', + mark: ( + + ), + }, + document: { + fill: '#5686FE', + mark: ( + + ), + }, + other: { + fill: '#CFD3D6', + mark: ( + <> + + + + + ), + }, +} + +/** + * Render one file type's coloured sheet. + * @param props - The type, optional size (default 28px, the drawn size), and + * optional CSS class. + * @returns The type's SVG, `aria-hidden`; the adjacent name carries the meaning. + */ +export function FileTypeIcon({ kind, size = 28, className }: FileTypeIconProps): ReactNode { + const face = FACES[kind] + return ( + + + {face.mark} + {/* The grey sheet folds a darker corner; every coloured sheet folds a translucent white one. */} + {kind === 'other' + ? + : } + + ) +} diff --git a/packages/client/ui-primitives/src/LinkIcon.tsx b/packages/client/ui-primitives/src/LinkIcon.tsx index 093e8e4cad..37dade0529 100644 --- a/packages/client/ui-primitives/src/LinkIcon.tsx +++ b/packages/client/ui-primitives/src/LinkIcon.tsx @@ -9,6 +9,10 @@ * ic_photo_outline_20, ic_paper_doc_outline_20, ic_paper_outline_20. */ import type { ReactNode } from 'react' +import { + CODE_EXTENSIONS, IMAGE_EXTENSIONS, PDF_EXTENSIONS, SHEET_EXTENSIONS, SLIDES_EXTENSIONS, WORD_EXTENSIONS, + fileExtension, +} from './file-extensions.ts' import type { IconProps } from './icons/props.ts' /** @@ -24,36 +28,22 @@ export interface LinkIconProps extends IconProps { kind: LinkIconKind } -/** Code, web, and data extensions: all three categories share the code glyph. */ -const CODE_EXTENSIONS = new Set([ - 'ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'cts', 'mts', 'css', 'scss', 'sass', 'less', - 'html', 'htm', 'vue', 'svelte', 'astro', 'json', 'jsonc', 'json5', 'yaml', 'yml', - 'toml', 'xml', 'ini', 'env', 'sh', 'bash', 'zsh', 'fish', 'ps1', 'bat', 'cmd', - 'py', 'pyi', 'rb', 'rs', 'go', 'java', 'kt', 'kts', 'c', 'cc', 'cpp', 'cxx', - 'h', 'hh', 'hpp', 'cs', 'php', 'swift', 'sql', 'csv', 'tsv', 'proto', 'graphql', - 'gql', 'lua', 'r', 'pl', 'scala', 'clj', 'cljs', 'ex', 'exs', 'erl', 'hs', 'dart', -]) - -const IMAGE_EXTENSIONS = new Set([ - 'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'avif', 'bmp', 'ico', 'tif', 'tiff', 'heic', 'heif', -]) - -const DOCUMENT_EXTENSIONS = new Set(['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx']) +/** Office-style documents share one paper-doc glyph on a link. */ +const DOCUMENT_SETS = [PDF_EXTENSIONS, SHEET_EXTENSIONS, SLIDES_EXTENSIONS, WORD_EXTENSIONS] /** - * Derive a file path's link-icon category from its extension. Unknown and - * missing extensions fall to `other` (the plain-paper glyph). + * Derive a file path's link-icon category from its extension. Code, web, and + * data files share the code glyph; unknown and missing extensions fall to + * `other` (the plain-paper glyph). * @param path - File path as the producing tool spelled it (either separator). * @returns The file's glyph category; never `url` or `folder`. */ export function classifyLinkPath(path: string): LinkIconKind { - const name = path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1) - const dot = name.lastIndexOf('.') - if (dot < 0) return 'other' - const extension = name.slice(dot + 1).toLowerCase() + const extension = fileExtension(path) + if (extension === '') return 'other' if (CODE_EXTENSIONS.has(extension)) return 'code' if (IMAGE_EXTENSIONS.has(extension)) return 'image' - return DOCUMENT_EXTENSIONS.has(extension) ? 'document' : 'other' + return DOCUMENT_SETS.some(set => set.has(extension)) ? 'document' : 'other' } const GlobeGlyph = ({ size, className }: IconProps) => ( diff --git a/packages/client/ui-primitives/src/file-extensions.ts b/packages/client/ui-primitives/src/file-extensions.ts new file mode 100644 index 0000000000..8adbf67af2 --- /dev/null +++ b/packages/client/ui-primitives/src/file-extensions.ts @@ -0,0 +1,59 @@ +/** + * File-extension vocabularies shared by the glyph pickers (`classifyLinkPath`, + * `classifyFileType`), so a path classifies the same way on a link and on a + * file-type icon. + * + * TODO: this is an interim rule. The product will settle one extension + * standard for every place a file is drawn — an upload in the input bar, a + * sent attachment, an artifact, the Sidebar's tree rows, the Sidebar's tab + * chips and pane headers — and these sets, the glyph pickers over them, and + * the sheets in `FileTypeIcon` are to be replaced by it. Until then the + * surfaces differ: the Sidebar classifies by these sets, the conversation's + * links fold the four office types into `document` (`LinkIcon`), and the + * attachment cards (`ui-attachment` `FileCard`) draw one generic glyph with no + * classification at all. + */ + +/** Code, web, and data extensions. */ +export const CODE_EXTENSIONS: ReadonlySet = new Set([ + 'ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'cts', 'mts', 'css', 'scss', 'sass', 'less', + 'html', 'htm', 'vue', 'svelte', 'astro', 'json', 'jsonc', 'json5', 'yaml', 'yml', + 'toml', 'xml', 'ini', 'env', 'sh', 'bash', 'zsh', 'fish', 'ps1', 'bat', 'cmd', + 'py', 'pyi', 'rb', 'rs', 'go', 'java', 'kt', 'kts', 'c', 'cc', 'cpp', 'cxx', + 'h', 'hh', 'hpp', 'cs', 'php', 'swift', 'sql', 'csv', 'tsv', 'proto', 'graphql', + 'gql', 'lua', 'r', 'pl', 'scala', 'clj', 'cljs', 'ex', 'exs', 'erl', 'hs', 'dart', +]) + +/** Raster and vector image extensions. */ +export const IMAGE_EXTENSIONS: ReadonlySet = new Set([ + 'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'avif', 'bmp', 'ico', 'tif', 'tiff', 'heic', 'heif', +]) + +/** Web page extensions; a subset of {@link CODE_EXTENSIONS} with its own file-type glyph. */ +export const HTML_EXTENSIONS: ReadonlySet = new Set(['html', 'htm']) + +/** Markdown extensions. */ +export const MARKDOWN_EXTENSIONS: ReadonlySet = new Set(['md', 'mdx', 'markdown']) + +/** PDF. */ +export const PDF_EXTENSIONS: ReadonlySet = new Set(['pdf']) + +/** Spreadsheet extensions; `csv`/`tsv` also sit in {@link CODE_EXTENSIONS}. */ +export const SHEET_EXTENSIONS: ReadonlySet = new Set(['xls', 'xlsx', 'xlsm', 'csv', 'tsv', 'numbers']) + +/** Slide-deck extensions. */ +export const SLIDES_EXTENSIONS: ReadonlySet = new Set(['ppt', 'pptx', 'key']) + +/** Word-processor document extensions. */ +export const WORD_EXTENSIONS: ReadonlySet = new Set(['doc', 'docx', 'rtf', 'odt', 'pages']) + +/** + * A path's lowercase extension without the dot, taken from its last segment. + * @param path - File path with either separator. + * @returns The extension, or `''` for a name without a dot. + */ +export function fileExtension(path: string): string { + const name = path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1) + const dot = name.lastIndexOf('.') + return dot < 0 ? '' : name.slice(dot + 1).toLowerCase() +} diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index 4afc31089f..23ad8cebb9 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -241,10 +241,12 @@ export const IconCopyOutline16 = ({ size = 16, className }: IconProps) => ( ) -/** ic_ds_refresh_outline_16 */ +/** ic_ds_refresh_outline_16, inset 10%: the exported glyph fills its box edge + * to edge, one visual size above the neighbouring 16px glyphs. */ export const IconRefreshOutline16 = ({ size = 16, className }: IconProps) => ( diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index 737e8fc879..d9b12eda0e 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -33,6 +33,8 @@ export { ReferenceIcon } from './ReferenceIcon.tsx' export type { ReferenceIconKind, ReferenceIconProps } from './ReferenceIcon.tsx' export { LinkIcon, classifyLinkPath } from './LinkIcon.tsx' export type { LinkIconKind, LinkIconProps } from './LinkIcon.tsx' +export { FileTypeIcon, classifyFileType } from './FileTypeIcon.tsx' +export type { FileTypeKind, FileTypeIconProps } from './FileTypeIcon.tsx' export { projectUserText } from './user-text.tsx' export { Tooltip } from './Tooltip.tsx' export type { TooltipSide } from './Tooltip.tsx' diff --git a/packages/client/ui-primitives/tests/file-type-icon.client.spec.tsx b/packages/client/ui-primitives/tests/file-type-icon.client.spec.tsx new file mode 100644 index 0000000000..306a9e7941 --- /dev/null +++ b/packages/client/ui-primitives/tests/file-type-icon.client.spec.tsx @@ -0,0 +1,61 @@ +// @vitest-environment jsdom +import { cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { FileTypeIcon, classifyFileType, type FileTypeKind } from '@deepseek-ai/dsh-client-ui-primitives' + +afterEach(cleanup) + +const KINDS: FileTypeKind[] = ['code', 'html', 'image', 'markdown', 'pdf', 'sheet', 'slides', 'document', 'other'] + +describe('classifyFileType', () => { + it.each([ + ['src/render.tsx', 'code'], + ['site/index.HTML', 'html'], + ['README.md', 'markdown'], + ['data/export.csv', 'sheet'], + ['book.xlsx', 'sheet'], + ['shots/hero.png', 'image'], + ['report.pdf', 'pdf'], + ['deck.pptx', 'slides'], + ['C:\\work\\summary.docx', 'document'], + ['notes.unknownext', 'other'], + ['Makefile', 'other'], + ['archive.tar/.hidden', 'other'], + ] as [string, FileTypeKind][])('%s → %s', (path, kind) => { + expect(classifyFileType(path)).toBe(kind) + }) +}) + +describe('FileTypeIcon', () => { + it.each(KINDS)('%s draws the shared sheet in its own colour, aria-hidden', (kind) => { + const { container } = render() + const svg = container.querySelector('svg')! + expect(svg.getAttribute('aria-hidden')).toBe('true') + expect(svg.getAttribute('width')).toBe('28') + expect(svg.querySelector('path')!.getAttribute('fill')).toMatch(/^#[0-9A-F]{6}$/) + }) + + it('every kind carries a distinct mark', () => { + const marks = KINDS.map((kind) => { + const { container } = render() + const [, mark] = Array.from(container.querySelectorAll('path, rect')) + return `${mark!.getAttribute('d') ?? ''}${mark!.getAttribute('y') ?? ''}` + }) + expect(new Set(marks).size).toBe(KINDS.length) + }) + + it('folds a darker corner on the grey sheet and a translucent one elsewhere', () => { + const grey = render().container.querySelectorAll('path') + expect(grey[grey.length - 1]!.getAttribute('fill')).toBe('#A2A4A6') + const blue = render().container.querySelectorAll('path') + expect(blue[blue.length - 1]!.getAttribute('fill-opacity')).toBe('0.7') + }) + + it('size and className land on the svg', () => { + const { container } = render() + const svg = container.querySelector('svg')! + expect(svg.getAttribute('width')).toBe('16') + expect(svg.getAttribute('height')).toBe('16') + expect(svg.classList.contains('x')).toBe(true) + }) +})