feat(primitives): add file-type icon set and shared link icon

This commit is contained in:
Yif
2026-09-09 12:14:57 +08:00
committed by imccyu
parent 08f646da36
commit 69a13cef6d
9 changed files with 310 additions and 27 deletions
@@ -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
+2 -1
View File
@@ -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
+2 -1
View File
@@ -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 输出
@@ -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<string>])[] = [
['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<Record<FileTypeKind, Face>> = {
code: {
fill: '#4176E6',
mark: (
<>
<path d="M8.61 16.3601L11.76 18.3901V20.1401L7 17.0601V15.6601L11.76 12.5801V14.3301L8.61 16.3601Z" fill="white" />
<path d="M16.1918 14.3301V12.5801L20.9518 15.6601V17.0601L16.1918 20.1401V18.3901L19.3418 16.3601L16.1918 14.3301Z" fill="white" />
</>
),
},
html: {
fill: '#4176E6',
mark: (
<path
fillRule="evenodd"
clipRule="evenodd"
d="M13.9983 9.68311C17.2109 9.68311 19.8156 12.2873 19.8157 15.4998C19.8157 18.7124 17.211 21.3172 13.9983 21.3172C10.7858 21.317 8.18164 18.7123 8.18164 15.4998C8.1818 12.2874 10.7859 9.68327 13.9983 9.68311ZM9.26104 16.0248C9.46915 17.9243 10.7925 19.4877 12.5628 20.0464C12.4189 19.7978 12.2941 19.5153 12.1868 19.2117C11.8839 18.3543 11.6919 17.2436 11.6413 16.0248H9.26104ZM16.3554 16.0248C16.3048 17.2435 16.1134 18.3543 15.8105 19.2117C15.7033 19.5153 15.5777 19.7972 15.4339 20.0457C17.2043 19.4871 18.5282 17.9243 18.7363 16.0248H16.3554ZM12.6927 16.0248C12.7428 17.146 12.9201 18.1335 13.1773 18.8617C13.3309 19.2963 13.5019 19.6102 13.6675 19.8051C13.8329 19.9998 13.9462 20.0257 13.9983 20.0259C14.0503 20.0259 14.164 20.0003 14.3299 19.8051C14.4955 19.6101 14.6665 19.2963 14.82 18.8617C15.0773 18.1335 15.2546 17.146 15.3047 16.0248H12.6927ZM13.9983 10.7331C13.9462 10.7332 13.8329 10.7599 13.6675 10.9546C13.5019 11.1495 13.3308 11.4634 13.1773 11.8979C12.9019 12.6778 12.7177 13.7546 12.6838 14.9748H15.3136C15.2797 13.7546 15.0955 12.6778 14.82 11.8979C14.6665 11.4634 14.4954 11.1495 14.3299 10.9546C14.164 10.7594 14.0503 10.7331 13.9983 10.7331ZM15.5877 11.0052C15.669 11.1758 15.7433 11.3577 15.8105 11.5479C16.1332 12.4615 16.3297 13.6621 16.3636 14.9748H18.7363C18.5341 13.1308 17.2806 11.6037 15.5877 11.0052ZM12.409 11.0052C10.7163 11.6038 9.46318 13.1309 9.26104 14.9748H11.6338C11.6677 13.6621 11.8641 12.4615 12.1868 11.5479C12.254 11.3578 12.3277 11.1757 12.409 11.0052Z"
fill="white"
/>
),
},
image: {
fill: '#8B76F6',
mark: (
<>
<path d="M10.4211 15.9204C10.5755 15.6558 10.9578 15.6558 11.1121 15.9204L13.6491 20.2696C13.8047 20.5362 13.6123 20.8711 13.3036 20.8711H8.22962C7.9209 20.8711 7.72855 20.5362 7.88411 20.2696L10.4211 15.9204Z" fill="white" />
<path d="M15.498 13.186C15.6504 12.9117 16.0449 12.9117 16.1973 13.186L20.1367 20.2769C20.2848 20.5435 20.092 20.8711 19.787 20.8711H11.9083C11.6033 20.8711 11.4105 20.5435 11.5587 20.2769L15.498 13.186Z" fill="white" />
<path d="M11.8599 11.3997C11.8599 12.286 11.1415 13.0045 10.2552 13.0045C9.36887 13.0045 8.65039 12.286 8.65039 11.3997C8.65039 10.5134 9.36887 9.79492 10.2552 9.79492C11.1415 9.79492 11.8599 10.5134 11.8599 11.3997Z" fill="white" />
</>
),
},
markdown: {
fill: '#4176E6',
mark: (
<path
d="M8.75904 19.5V14.6H9.90004L11.93 17.932H11.328L13.302 14.6H14.443L14.457 19.5H13.183L13.169 16.539H13.386L11.909 19.017H11.293L9.77404 16.539H10.04V19.5H8.75904ZM15.4378 19.5V14.6H17.7548C18.2961 14.6 18.7721 14.7003 19.1828 14.901C19.5934 15.1017 19.9131 15.384 20.1418 15.748C20.3751 16.112 20.4918 16.546 20.4918 17.05C20.4918 17.5493 20.3751 17.9833 20.1418 18.352C19.9131 18.716 19.5934 18.9983 19.1828 19.199C18.7721 19.3997 18.2961 19.5 17.7548 19.5H15.4378ZM16.8238 18.394H17.6988C17.9788 18.394 18.2214 18.3427 18.4268 18.24C18.6368 18.1327 18.8001 17.9787 18.9168 17.778C19.0334 17.5727 19.0918 17.33 19.0918 17.05C19.0918 16.7653 19.0334 16.5227 18.9168 16.322C18.8001 16.1213 18.6368 15.9697 18.4268 15.867C18.2214 15.7597 17.9788 15.706 17.6988 15.706H16.8238V18.394Z"
fill="white"
/>
),
},
pdf: {
fill: '#EC1313',
mark: (
<path
d="M6.80641 19.5V14.6H9.04641C9.49441 14.6 9.87941 14.6723 10.2014 14.817C10.5281 14.9617 10.7801 15.1717 10.9574 15.447C11.1347 15.7177 11.2234 16.0397 11.2234 16.413C11.2234 16.7817 11.1347 17.1013 10.9574 17.372C10.7801 17.6427 10.5281 17.8527 10.2014 18.002C9.87941 18.1467 9.49441 18.219 9.04641 18.219H7.57641L8.19241 17.617V19.5H6.80641ZM8.19241 17.764L7.57641 17.127H8.96241C9.25174 17.127 9.46641 17.064 9.60641 16.938C9.75107 16.812 9.82341 16.637 9.82341 16.413C9.82341 16.1843 9.75107 16.007 9.60641 15.881C9.46641 15.755 9.25174 15.692 8.96241 15.692H7.57641L8.19241 15.055V17.764ZM11.8992 19.5V14.6H14.2162C14.7575 14.6 15.2335 14.7003 15.6442 14.901C16.0548 15.1017 16.3745 15.384 16.6032 15.748C16.8365 16.112 16.9532 16.546 16.9532 17.05C16.9532 17.5493 16.8365 17.9833 16.6032 18.352C16.3745 18.716 16.0548 18.9983 15.6442 19.199C15.2335 19.3997 14.7575 19.5 14.2162 19.5H11.8992ZM13.2852 18.394H14.1602C14.4402 18.394 14.6828 18.3427 14.8882 18.24C15.0982 18.1327 15.2615 17.9787 15.3782 17.778C15.4948 17.5727 15.5532 17.33 15.5532 17.05C15.5532 16.7653 15.4948 16.5227 15.3782 16.322C15.2615 16.1213 15.0982 15.9697 14.8882 15.867C14.6828 15.7597 14.4402 15.706 14.1602 15.706H13.2852V18.394ZM17.6824 19.5V14.6H21.5254V15.671H19.0684V19.5H17.6824ZM18.9704 17.82V16.749H21.2314V17.82H18.9704Z"
fill="white"
/>
),
},
sheet: {
fill: '#22C55E',
mark: (
<path
d="M10.2935 20.5L13.3535 16.25L13.3435 17.66L10.4035 13.5H12.6335L14.5135 16.21L13.5635 16.22L15.4135 13.5H17.5535L14.6135 17.58V16.18L17.7135 20.5H15.4335L13.5235 17.65H14.4335L12.5535 20.5H10.2935Z"
fill="white"
/>
),
},
slides: {
fill: '#F59E0B',
mark: (
<path
d="M11.0135 20.5V13.5H14.2135C14.8535 13.5 15.4035 13.6033 15.8635 13.81C16.3301 14.0167 16.6901 14.3167 16.9435 14.71C17.1968 15.0967 17.3235 15.5567 17.3235 16.09C17.3235 16.6167 17.1968 17.0733 16.9435 17.46C16.6901 17.8467 16.3301 18.1467 15.8635 18.36C15.4035 18.5667 14.8535 18.67 14.2135 18.67H12.1135L12.9935 17.81V20.5H11.0135ZM12.9935 18.02L12.1135 17.11H14.0935C14.5068 17.11 14.8135 17.02 15.0135 16.84C15.2201 16.66 15.3235 16.41 15.3235 16.09C15.3235 15.7633 15.2201 15.51 15.0135 15.33C14.8135 15.15 14.5068 15.06 14.0935 15.06H12.1135L12.9935 14.15V18.02Z"
fill="white"
/>
),
},
document: {
fill: '#5686FE',
mark: (
<path
d="M10.512 20.5L8.24203 13.5H10.282L12.192 19.56H11.162L13.172 13.5H14.992L16.892 19.56H15.902L17.872 13.5H19.762L17.492 20.5H15.372L13.752 15.35H14.322L12.632 20.5H10.512Z"
fill="white"
/>
),
},
other: {
fill: '#CFD3D6',
mark: (
<>
<rect x="7.98242" y="10.792" width="12.0368" height="1.46414" fill="#545557" />
<rect x="7.98242" y="14.9221" width="12.0368" height="1.46414" fill="#545557" />
<rect x="7.98242" y="19.0522" width="7.57257" height="1.46414" fill="#545557" />
</>
),
},
}
/**
* 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 (
<svg width={size} height={size} className={className} viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden>
<path d={SHEET_PATH} fill={face.fill} />
{face.mark}
{/* The grey sheet folds a darker corner; every coloured sheet folds a translucent white one. */}
{kind === 'other'
? <path d={CORNER_PATH} fill="#A2A4A6" />
: <path d={CORNER_PATH} fill="white" fillOpacity="0.7" />}
</svg>
)
}
+12 -22
View File
@@ -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) => (
@@ -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<string> = 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<string> = 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<string> = new Set(['html', 'htm'])
/** Markdown extensions. */
export const MARKDOWN_EXTENSIONS: ReadonlySet<string> = new Set(['md', 'mdx', 'markdown'])
/** PDF. */
export const PDF_EXTENSIONS: ReadonlySet<string> = new Set(['pdf'])
/** Spreadsheet extensions; `csv`/`tsv` also sit in {@link CODE_EXTENSIONS}. */
export const SHEET_EXTENSIONS: ReadonlySet<string> = new Set(['xls', 'xlsx', 'xlsm', 'csv', 'tsv', 'numbers'])
/** Slide-deck extensions. */
export const SLIDES_EXTENSIONS: ReadonlySet<string> = new Set(['ppt', 'pptx', 'key'])
/** Word-processor document extensions. */
export const WORD_EXTENSIONS: ReadonlySet<string> = 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()
}
@@ -241,10 +241,12 @@ export const IconCopyOutline16 = ({ size = 16, className }: IconProps) => (
</svg>
)
/** 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) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
transform="translate(0.8 0.8) scale(0.9)"
d="M7.92136 0.349152C10.3744 0.349234 12.5564 1.5052 13.9557 3.29894L15.1281 2.12759C15.3303 1.92546 15.6767 2.06943 15.6767 2.35538V5.53923C15.6766 5.71626 15.5329 5.85976 15.3559 5.86002H12.171C11.8854 5.8597 11.7426 5.51465 11.9443 5.31249L12.9641 4.29056C11.8237 2.74305 9.98908 1.74106 7.92136 1.74097C4.46436 1.74097 1.66233 4.543 1.66233 8C1.66233 11.457 4.46436 14.259 7.92136 14.259C11.3782 14.2589 14.1804 11.4569 14.1804 8H15.5722C15.5722 12.2251 12.1465 15.6507 7.92136 15.6508C3.69614 15.6508 0.270508 12.2252 0.270508 8C0.270508 3.77478 3.69614 0.349152 7.92136 0.349152Z"
fill="currentColor"
/>
@@ -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'
@@ -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(<FileTypeIcon kind={kind} />)
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(<FileTypeIcon kind={kind} />)
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(<FileTypeIcon kind="other" />).container.querySelectorAll('path')
expect(grey[grey.length - 1]!.getAttribute('fill')).toBe('#A2A4A6')
const blue = render(<FileTypeIcon kind="code" />).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(<FileTypeIcon kind="pdf" size={16} className="x" />)
const svg = container.querySelector('svg')!
expect(svg.getAttribute('width')).toBe('16')
expect(svg.getAttribute('height')).toBe('16')
expect(svg.classList.contains('x')).toBe(true)
})
})