Merge branch 'worktree/pr-3858-release-sync' into worktree/pr-3819-release-sync

# Conflicts:
#	packages/client/ui-primitives/README.i18n.yaml
#	packages/client/ui-primitives/README.md
#	packages/client/ui-primitives/README.zh.md
This commit is contained in:
creatixchu
2026-09-09 17:20:37 +08:00
30 changed files with 1573 additions and 299 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 .agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md
2026-09-04-web-clickable-link-styles.md: 8781990a40de30e26d0c59c0ecc1a08ba41643ac
2026-09-04-web-clickable-link-styles.zh.md: 7b5fde4c87319f6ae34b76d1c8e712e6f9f18392
2026-09-04-web-clickable-link-styles.md: 6fa61945143853a2812468638dafd23ebcd456dc
2026-09-04-web-clickable-link-styles.zh.md: 0b15a6c72df9151ffb79f7fb41d582e191e79be6
@@ -13,7 +13,7 @@ Clickable artifact links in the chat transcript wore four different costumes: ma
One link language across the transcript's clickable-link surfaces — markdown anchors (including reference links, mailto, and URL-promoted inline code), prose file mentions, web search source links and the fetch URL, produced-file chips, and workflow member links:
- Color comes through a dedicated `--dsw-alias-link` alias in `design-platform.css` (light `deepseek-500`, dark `deepseek-400`), decoupled from `state-business-primary`; links render at `font-weight: 500` with no underline at rest and `underline dotted` at 3px offset on hover/focus.
- A leading category glyph — the new `LinkIcon` in ui-primitives with kinds `url` (globe), `folder`, `code`, `image`, `document`, and `other` (paper) — renders `currentColor` only; `classifyLinkPath` derives the file kinds from the extension, and code, web, and data extensions share the code glyph by design. Two anchor shapes carry no glyph: workflow member links (an in-app member view fits no file or URL category) and anchors wrapping only images (a badge or thumbnail — a dangling globe beside the picture leads no text). Inline glyphs sit at 1.1em with a 0.25em baseline offset; the flex-centered produced-file glyphs instead nudge 1.2px down because the 22px text box carries its glyphs below box center.
- A leading category glyph — the new `LinkIcon` in ui-primitives with kinds `url` (globe), `folder`, `code`, `image`, `document`, and `other` (paper) — renders `currentColor` only; `classifyLinkPath` folds the [shared detailed file-type classification](2026-09-08-shared-file-type-icons.md) into those six link categories, and code, web, and data extensions share the code glyph by design. Two anchor shapes carry no glyph: workflow member links (an in-app member view fits no file or URL category) and anchors wrapping only images (a badge or thumbnail — a dangling globe beside the picture leads no text). Inline glyphs sit at 1.1em with a 0.25em baseline offset; the flex-centered produced-file glyphs instead nudge 1.2px down because the 22px text box carries its glyphs below box center.
- Produced-file chips drop the grey pill and the 96px cap: plain link-blue text at natural width that shrinks with ellipsis only when the row overflows; the container-query bands still budget 96px per chip when choosing how many chips to show.
- Deliberately untouched: ToolRow's grey dotted file links, and the grey "Show in folder" action (it gains the folder glyph but keeps its grey style).
- In the same pass, the inline-code chip tint moved from `neutral-bluish-100` to `neutral-50` (dark: `neutral-800`) and gained a 0.5px l1 border.
@@ -22,8 +22,8 @@ Coverage: a LinkIcon unit spec (one distinct glyph per kind, classification tabl
## Alternatives considered
- **Colored Word/Excel/PPT/PDF brand glyphs.** Implemented, then removed: fixed brand fills break the icon set's currentColor-only rule, so those extensions fold into the single outline `document` glyph.
- **Per-extension icons.** Collapsed to six categories: more glyphs than the eye can parse at 14px adds noise, and per-site favicons remain possible later behind the same `url` category.
- **Colored Word/Excel/PPT/PDF link glyphs.** Rejected: fixed brand fills break the icon set's currentColor-only rule, so those extensions fold into the single outline `document` glyph. The larger file-card primitive uses distinct current-color silhouettes instead.
- **Per-extension link icons.** Collapsed to six categories: more glyphs than the eye can parse at 14px adds noise. The 28px `FileTypeIcon` owns the more detailed file identities, and per-site favicons remain possible later behind the same `url` category.
- **Keeping links on `state-business-primary`.** Darker link blues (blue-600/650/700 were auditioned and reverted) would have dragged focus rings and state dots along; the dedicated alias localizes any future tuning to one line.
- **Glyphs on ToolRow path links.** Rejected: tool rows keep their quieter grey dotted affordance, and leading glyphs there would stack icons in already dense rows.
@@ -13,7 +13,7 @@ Status: implemented
会话记录的可点击链接表面——Markdown 锚点(含引用式链接、mailto、被提升为链接的 inline code)、正文文件引用、网页搜索来源链接与抓取 URL、产物 chips、workflow 成员链接——统一为一套链接语言:
- 颜色经由 `design-platform.css` 中专用的 `--dsw-alias-link` 别名(亮色 `deepseek-500`,暗色 `deepseek-400`),与 `state-business-primary` 解耦;链接以 `font-weight: 500` 呈现,默认无下划线,hover/focus 时为 3px offset 的 `underline dotted`
- 前置分类图标——ui-primitives 新增的 `LinkIcon`kind 为 `url`(地球)、`folder``code``image``document``other`(纸张)——只渲染 `currentColor``classifyLinkPath` 按扩展名推导文件类别,代码、网页、数据扩展名按设计共用 code 图形。两类锚点不带图标:workflow 成员链接(应用内成员视图不属于任何文件或 URL 类别)和只包图片的锚点(徽章或缩略图——图片旁悬着的地球没有可引导的文字)。行内图标为 1.1em、基线偏移 −0.25em;flex 居中的产物图标则下移 1.2px,因为 22px 文字盒的字形低于盒中心。
- 前置分类图标——ui-primitives 新增的 `LinkIcon`kind 为 `url`(地球)、`folder``code``image``document``other`(纸张)——只渲染 `currentColor``classifyLinkPath` 把[共享精细文件类型分类](2026-09-08-shared-file-type-icons.zh.md)折叠进这六种链接类别,代码、网页、数据扩展名按设计共用 code 图形。两类锚点不带图标:workflow 成员链接(应用内成员视图不属于任何文件或 URL 类别)和只包图片的锚点(徽章或缩略图——图片旁悬着的地球没有可引导的文字)。行内图标为 1.1em、基线偏移 −0.25em;flex 居中的产物图标则下移 1.2px,因为 22px 文字盒的字形低于盒中心。
- 产物 chips 去掉灰色药丸和 96px 上限:纯链接蓝文字按自然宽度展示,仅当整行溢出时才收缩出省略号;容器查询档位在决定展示几个 chip 时仍按每个 96px 预算。
- 刻意不动:ToolRow 的灰色点线文件链接,以及灰色的「在文件夹中显示」操作(它获得文件夹图标但保持灰色样式)。
- 同一批次中,inline code 底色从 `neutral-bluish-100` 换到 `neutral-50`(暗色:`neutral-800`),并新增 0.5px l1 描边。
@@ -22,8 +22,8 @@ Status: implemented
## 备选方案
- **彩色 Word/Excel/PPT/PDF 品牌图形。** 实现后又移除:固定品牌填充违反图标集 currentColor-only 规则,这些扩展名并入单一的 outline `document` 图形。
- **每个扩展名一个图标。** 收敛为六个类别:14px 下超出肉眼可分辨数量的图形只会增加噪音,按站点的 favicon 以后仍可在同一 `url` 类别之下引入。
- **彩色 Word/Excel/PPT/PDF 链接图形。** 否决:固定品牌填充违反图标集 currentColor-only 规则,因此这些扩展名并入单一的 outline `document` 图形。较大的文件卡片 primitive 改用各自不同的 current-color 轮廓。
- **每个扩展名一个链接图标。** 收敛为六个类别:14px 下超出肉眼可分辨数量的图形只会增加噪音。28px 的 `FileTypeIcon` 拥有更精细的文件身份,按站点的 favicon 以后仍可在同一 `url` 类别之下引入。
- **链接继续用 `state-business-primary`。** 更深的链接蓝(试过 blue-600/650/700 又回退)会连带焦点环和状态点;专用别名把未来的调色收敛到一行。
- **给 ToolRow 路径链接加图形。** 否决:工具行保持更安静的灰色点线示能,在已经很密的行里加前置图形会造成图标堆叠。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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 .agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md
2026-09-08-shared-file-type-icons.md: 36b4ff8f57dd810da3d2af94aac15ec5afab799e
2026-09-08-shared-file-type-icons.zh.md: 4b33a8ffe5934953b12e82750ce57febc6f054f4
@@ -0,0 +1,43 @@
# Agent Note: Shared file-type icons
Status: implemented
English | [中文](2026-09-08-shared-file-type-icons.zh.md)
## Problem
Client feature plugins can share React components only through `@deepseek-ai/dsh-client-ui-primitives`, but file cards had no shared file-type presentation. `LinkIcon` owned the only extension table and deliberately collapsed paths into six link categories, while attachment cards, sent-message attachments, queued files, and workspace file rows used one generic document glyph. Two of those consumers also carried separate extension-display helpers. Adding a precise file icon anywhere else would have required another extension table or a runtime import between feature plugins.
## Decision
`ui-primitives` owns one Cordis-free file classification and rendering API. `fileExtension(path)` applies the shared basename and final-dot semantics to either path separator. `classifyFileType(path)` matches without case sensitivity and returns the closed `FileType` union: the traditional `code`, `excel`, `folder`, `html`, `image`, `markdown`, `other`, `pdf`, `ppt`, `video`, and `word` categories plus the detailed `CodeFileType` set imported from the supplied `square-code-icons` map. Exact filenames, filename prefixes, filename suffixes, optional project context, and extensions run in that order. The path classifier returns every member except `folder`; callers use the explicit `FileTypeIcon` `type` override when they know an entry is a directory. Unknown extensions, missing extensions, and trailing dots resolve to `other`, except the shared table recognizes named files such as `Dockerfile`, `Makefile`, `package.json`, `.gitignore`, `README`, and `CHANGELOG`.
`FileTypeIcon` accepts a path, shared `IconProps`, the optional explicit type, and an optional project-file snapshot. Traditional file types render the supplied 28px document and folder contours as inline SVG. Excel, Markdown, PDF, PPT, and Word foreground marks scale to 122% around their visual center; the remaining marked traditional glyphs use 112%, while the file body and folded corner retain their source geometry and the generic file has no invented center mark. The sheet is a solid category color, the foreground mark and ordinary folded corner are white, and the generic file has a darker grey corner. CSS assigns the supplied category palette through static design tokens: DeepSeek blue for code/HTML/Markdown, the lighter DeepSeek blue for Word, green for Excel, two amber steps for folder/PPT, red for PDF, and neutral grey for unknown files. Image and video share the supplied violet through a component-local variable because the design platform has no matching violet token. A caller may override a traditional sheet through `--dsh-file-type-icon-color`.
Recognized code and configuration files render the corresponding 20px square artwork scaled to the requested icon size. These technology marks retain their embedded multicolor fills and are the explicit exception to the ordinary current-color icon rule. The map selects React before TypeScript/JavaScript, Angular filename suffixes before their base extension, Docker/Node/Git/Make/CMake by filename rules, and Flutter only when the optional project snapshot contains a `pubspec.yaml` whose text includes `flutter:`. Markdown and SVG remain owned by the traditional Markdown and image categories. Every traditional and technology SVG is `aria-hidden`, and the card, row, or button that owns the file identity supplies the accessible name.
`LinkIcon` delegates extension classification to `classifyFileType` and folds the detailed result into its existing link vocabulary: code and HTML use `code`, images use `image`, PDF/Word/Excel/PPT use `document`, and Markdown/video/unknown files use `other`. Extensionless names remain `other` in link contexts, so the 14px clickable-link appearance defined by the [clickable-link decision](2026-09-04-web-clickable-link-styles.md) does not change.
Attachment upload cards, sent-message file cards, queued-file rows, and workspace file rows render `FileTypeIcon`. The two metadata rows use `fileExtension` rather than local parsers; a leading-dot basename such as `.env` therefore displays `ENV`, while an absent or trailing suffix displays no extension label. Image content continues to render as a preview rather than a file-type glyph, and produced-file links and Markdown file mentions continue to use `LinkIcon` because they are link surfaces.
## Alternatives considered
**Use `LinkIcon` for every file surface.** Rejected. Its six categories and 14px outline drawings communicate link destinations at text size; a 28px file card has room for the supplied HTML, Markdown, PDF, Word, Excel, PPT, and video identities.
**Keep a second extension table beside `LinkIcon`.** Rejected. The same path could drift to different categories as either list grows. One detailed table plus an explicit detailed-to-link adapter preserves both consumers' semantics.
**Put the supplied traditional-file colors directly in each SVG path.** Rejected. Traditional SVG geometry stays reusable and follows the icon set's `currentColor` rule; the component stylesheet owns the default category palette, and render sites retain one CSS-variable override instead of rewriting path fills. The technology artwork is exempt because its embedded multicolor marks identify the language or tool rather than decorating a generic file silhouette.
**Add archive, audio, and data categories for symmetry.** Rejected. The supplied artwork and current consumers require no such glyphs. New `FileType` members require a shipped render site and artwork that remains legible at the 28px seat.
## Testing
The `ui-primitives` specs cover case-insensitive suffixes, both path separators, leading-dot files, missing and trailing suffixes, common named files, unknown fallback, all detailed code mappings, rule priority, Flutter context, every supplied technology SVG, instance-safe gradient ids, `aria-hidden`, sizing/class forwarding, distinct artwork, the 112% and 122% foreground-mark transforms, and the traditional solid-sheet/contrast-mark layers without literal SVG colors. A stylesheet spec pins every traditional category-to-color mapping, the caller override, and the local violet value. The existing `LinkIcon` classification table pins its coarse output, including `Makefile` remaining `other`. Attachment, chat, queue, and sidebar component suites exercise the migrated render paths; their accessibility output does not change because the glyphs remain decorative.
## Consequences
- Client packages use one filename parser and one detailed file-type table instead of importing or recreating feature-local logic.
- A new suffix joins the detailed table only when an existing glyph truthfully represents it. If its link category differs from the current adapter, the change must also decide whether the 14px link appearance changes.
- Code and configuration artwork preserves its embedded palette and does not accept the traditional `--dsh-file-type-icon-color` override.
- The primitive owns no copy but does own the default file-type palette. Consumers continue to own accessible labels and surrounding text, and may replace the category color through `--dsh-file-type-icon-color`.
- The detailed category names describe presentation, not MIME validation. A suffix is a display hint and does not establish file contents or trust.
@@ -0,0 +1,43 @@
# Agent Note: 共享文件类型图标
Status: implemented
[English](2026-09-08-shared-file-type-icons.md) | 中文
## 问题
客户端功能插件只能通过 `@deepseek-ai/dsh-client-ui-primitives` 共享 React 组件,但文件卡片没有共享的文件类型呈现。`LinkIcon` 拥有唯一一份扩展名表,并有意把路径折叠为六种链接类别;附件卡片、已发送消息附件、排队文件和工作区文件行则统一使用一个通用文档图形。其中两个消费方还各自带着一份扩展名展示辅助函数。在其他位置增加精细文件图标,需要再复制一份扩展名表或让功能插件之间产生运行时 import。
## 决策
`ui-primitives` 统一拥有一套 Cordis-free 的文件分类与渲染 API。`fileExtension(path)` 对两种路径分隔符采用共享的 basename 与最终点号语义。`classifyFileType(path)` 不区分大小写,并返回闭合的 `FileType` 联合:传统的 `code``excel``folder``html``image``markdown``other``pdf``ppt``video``word` 类别,以及从所提供 `square-code-icons` 映射接入的细分 `CodeFileType` 集合。解析按完整文件名、文件名前缀、文件名后缀、可选项目上下文、扩展名的顺序执行。路径分类器返回 `folder` 之外的全部成员;调用方确认条目是目录时,通过 `FileTypeIcon` 的显式 `type` 覆盖指定目录。未知扩展名、无扩展名和末尾点号回退到 `other`,但共享表识别 `Dockerfile``Makefile``package.json``.gitignore``README``CHANGELOG` 等具名文件。
`FileTypeIcon` 接受路径、共享 `IconProps`、可选的显式类型和可选的项目文件快照。传统文件类型把所提供的 28px 文档与文件夹轮廓渲染为 inline SVG。Excel、Markdown、PDF、PPT、Word 的前景标记围绕自身视觉中心缩放至 122%,其余带标记的传统图形使用 112%;文件底板与折角保持源图几何,通用文件不凭空增加中心标记。底板使用实色分类颜色,前景标记与普通折角使用白色,通用文件使用较深的灰色折角。CSS 通过静态设计 token 分配所提供的分类调色板:code/HTML/Markdown 使用 DeepSeek 蓝,Word 使用较浅的 DeepSeek 蓝,Excel 使用绿色,folder/PPT 使用两档琥珀色,PDF 使用红色,未知文件使用中性灰。image 与 video 通过组件本地变量共用所提供的紫色,因为设计平台没有匹配的紫色 token。调用方可通过 `--dsh-file-type-icon-color` 覆盖传统底板颜色。
已识别的代码与配置文件把对应的 20px 方形图稿缩放到请求的图标尺寸。这些技术标记保留自身内嵌的多色填充,是普通 current-color 图标规则的明确例外。映射让 React 优先于 TypeScript/JavaScript、Angular 文件名后缀优先于基础扩展名,并按文件名识别 Docker/Node/Git/Make/CMake;只有可选项目快照包含内容带 `flutter:``pubspec.yaml` 时才选择 Flutter。Markdown 与 SVG 仍由传统 Markdown 和图片类别拥有。所有传统与技术 SVG 都是 `aria-hidden` 的,拥有文件身份的卡片、行或按钮提供无障碍名称。
`LinkIcon` 委托 `classifyFileType` 做扩展名分类,再把精细结果折叠进原有链接词汇:code 与 HTML 使用 `code`,图片使用 `image`PDF/Word/Excel/PPT 使用 `document`Markdown、video 与未知文件使用 `other`。无扩展名文件在链接语境中仍是 `other`,因此[可点击链接决策](2026-09-04-web-clickable-link-styles.zh.md)定义的 14px 外观不变。
附件上传卡片、已发送消息文件卡片、排队文件行和工作区文件行渲染 `FileTypeIcon`。两处元数据行使用 `fileExtension`,不再保留本地解析器;`.env` 这样的前导点 basename 会显示 `ENV`,无后缀或末尾点号则不显示扩展名 label。图片内容继续渲染为预览而不是文件类型图形,产物文件链接与 Markdown 文件提及继续使用 `LinkIcon`,因为它们属于链接表面。
## 备选方案
**所有文件表面都使用 `LinkIcon`。** 否决。它的六种类别和 14px outline 图形用于在文字尺寸下表达链接目标;28px 文件卡片有空间表达所提供的 HTML、Markdown、PDF、Word、Excel、PPT 与 video 身份。
**在 `LinkIcon` 旁保留第二份扩展名表。** 否决。任一列表增长后,同一路径可能漂移到不同类别。一份精细表加一个显式的精细到链接适配,能同时保住两类消费方的语义。
**把所提供的传统文件颜色直接写入每一条 SVG path。** 否决。传统 SVG 几何保持可复用,并遵守图标集的 `currentColor` 规则;组件样式表拥有默认分类调色板,渲染点只需一个 CSS 变量即可覆盖颜色,不需要重写 path 填充。技术图稿是例外,因为其内嵌的多色标记用于识别语言或工具,而不是装饰通用文件轮廓。
**为了对称增加 archive、audio 与 data 类别。** 否决。提供的图稿与当前消费方都不需要这些图形。新增 `FileType` 成员必须有已发布的渲染点,以及在 28px slot 中仍可辨认的图稿。
## 测试
`ui-primitives` 测试覆盖不区分大小写的后缀、两种路径分隔符、前导点文件、无后缀与末尾点号、常见具名文件、未知回退、全部细分代码映射、规则优先级、Flutter 上下文、每一份技术 SVG、实例安全的渐变 id、`aria-hidden`、尺寸/class 转发、不同图稿、112% 与 122% 前景标记变换,以及不含 SVG 字面颜色的传统实色底板/对比标记层。样式表测试钉住每一项传统类别到颜色的映射、调用方覆盖变量与本地紫色值。既有 `LinkIcon` 分类表钉住它的粗粒度输出,包括 `Makefile` 仍为 `other`。附件、聊天、队列和侧边栏组件测试覆盖迁移后的渲染路径;由于图形仍是装饰性的,其无障碍输出不变。
## 后果
- 客户端包使用一个文件名解析器与一份精细文件类型表,不再 import 或重新实现功能包本地逻辑。
- 新后缀只在已有图形能准确表达它时加入精细表。若它的链接类别与当前适配不同,这次改动还必须决定是否改变 14px 链接外观。
- 代码与配置图稿保留内嵌调色板,不接受传统图形的 `--dsh-file-type-icon-color` 覆盖。
- primitive 不拥有文案,但拥有默认文件类型调色板。消费方继续拥有无障碍 label 与周围文字,并可通过 `--dsh-file-type-icon-color` 替换分类颜色。
- 精细类别名称描述展示意图,不是 MIME 校验。后缀只是展示提示,不能证明文件内容或可信度。
+4 -10
View File
@@ -1,4 +1,4 @@
import { DocumentFileIcon, fileSizeText, IconCloseFill14 } from '@deepseek-ai/dsh-client-ui-primitives'
import { fileExtension, FileTypeIcon, fileSizeText, IconCloseFill14 } from '@deepseek-ai/dsh-client-ui-primitives'
import css from './FileCard.module.css'
/** Localized strings consumed by one pending-file card. */
@@ -18,13 +18,7 @@ export interface FileCardLabels {
/** Upload display state resolved by the owner. */
export type FileCardState = 'uploading' | 'ready' | 'error'
function extensionOf(name: string): string {
const dot = name.lastIndexOf('.')
if (dot <= 0 || dot === name.length - 1) return ''
return name.slice(dot + 1).toUpperCase().slice(0, 8)
}
/** One pending generic-file card: name, size or upload status, remove, retry. */
/** One pending file card: type glyph, name, size or upload status, remove, retry. */
export function FileCard({
name, bytes, state, progress, labels, onRemove, onRetry,
}: {
@@ -36,7 +30,7 @@ export function FileCard({
onRemove: () => void
onRetry: () => void
}) {
const extension = extensionOf(name)
const extension = fileExtension(name).toUpperCase().slice(0, 8)
const meta = state === 'uploading'
? labels.uploading
: state === 'error'
@@ -51,7 +45,7 @@ export function FileCard({
<span className={css.icon} aria-hidden>
{state === 'uploading'
? <span className={css.spinner} />
: <DocumentFileIcon />}
: <FileTypeIcon path={name} />}
</span>
{retryable
? (
@@ -263,4 +263,17 @@ describe('ComposerAttachments file drafts', () => {
expect(group.textContent).toContain('文件')
expect(group.textContent).toContain('3B')
})
it('uses the shared leading-dot suffix in ready-file metadata', () => {
const view = render(<ComposerAttachments {...props({
attachments: [fileDraft('env', '.env')],
uploads: {
env: {
status: 'ready', receiptId: 'receipt-env' as never,
file: { attachmentId: 'file-env' as never, name: '.env', bytes: 3 },
},
},
})} />)
expect(view.getByTitle('.env').textContent).toContain('ENV 3B')
})
})
@@ -317,7 +317,7 @@
.fileIcon {
flex: none;
width: 24px;
width: 28px;
height: 28px;
}
@@ -2,7 +2,7 @@ import { Fragment, memo, useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import type { PendingSubmission } from '@deepseek-ai/dsh-api-session-controller/client'
import type { MessageImageSource } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { DocumentFileIcon, fileSizeText, JsonBlock, projectUserText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import { fileExtension, FileTypeIcon, fileSizeText, JsonBlock, projectUserText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatNodeOwnerProps, ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts'
import type { ModelRetryNode, TurnErrorNode, UserMessageNode } from '../contract/snapshot.ts'
import { CompactionItem } from './CompactionItem.tsx'
@@ -16,12 +16,6 @@ type PresentedAttachment =
| { readonly type: 'image'; readonly image: MessageImageSource }
| { readonly type: 'file'; readonly file: UserFile['attachment'] }
function extensionOf(name: string): string {
const dot = name.lastIndexOf('.')
if (dot <= 0 || dot === name.length - 1) return ''
return name.slice(dot + 1).toUpperCase().slice(0, 8)
}
function contentParts(content: readonly unknown[]): {
text: string
attachments: PresentedAttachment[]
@@ -206,11 +200,11 @@ function UserStyleBubble({
)
: (
<span key={`file:${index}`} className={css.fileCard} title={attachment.file.name}>
<DocumentFileIcon className={css.fileIcon} />
<FileTypeIcon path={attachment.file.name} className={css.fileIcon} />
<span className={css.fileContent}>
<span className={css.fileName}>{attachment.file.name}</span>
<span className={css.fileMeta}>
{[extensionOf(attachment.file.name), fileSizeText(attachment.file.bytes)]
{[fileExtension(attachment.file.name).toUpperCase().slice(0, 8), fileSizeText(attachment.file.bytes)]
.filter(Boolean).join(' ')}
</span>
</span>
@@ -1085,6 +1085,10 @@ describe('user file attachments', () => {
expect(view.getByTitle('notes.pdf').textContent).toContain('3.2MB')
expect(view.getByTitle('tiny.txt').textContent).toContain('12B')
expect(view.getByTitle('mid.csv').textContent).toContain('500KB')
const icons = ['notes.pdf', 'tiny.txt', 'mid.csv'].map(name =>
view.getByTitle(name).querySelector('svg')?.innerHTML,
)
expect(new Set(icons).size).toBe(icons.length)
expect(view.getByText('summarize these')).toBeTruthy()
})
})
@@ -5,7 +5,7 @@ import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import {
IconCheckOutline16, IconChevronDownOutline14, IconChevronUpOutline14, IconCloseOutline16,
DocumentFileIcon, fileSizeText, IconEditOutline16, IconQueueOutline14, IconSendOutline14,
FileTypeIcon, fileSizeText, IconEditOutline16, IconQueueOutline14, IconSendOutline14,
IconTrashOutline16, projectUserText, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { QueueAction, QueueItemId, QueueRow } from '../contract/queue.ts'
@@ -52,7 +52,7 @@ function queueAttachments(content: QueueRow['content']): Array<
function QueueFile({ attachment, label }: { attachment: FileAttachmentRef; label: string }) {
return (
<span className={css.file} aria-label={label} title={attachment.name}>
<span className={css.fileIcon} aria-hidden><DocumentFileIcon /></span>
<span className={css.fileIcon} aria-hidden><FileTypeIcon path={attachment.name} size={16} /></span>
<span className={css.fileName}>{attachment.name}</span>
<span className={css.fileSize}>{fileSizeText(attachment.bytes)}</span>
</span>
@@ -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: 8e1ee78d02ce0802d1a8353050de69e6a7ab7c65
README.zh.md: 4c50570d917a081a886525cf6f9be0de2ccc7065
README.md: d6e6a101c9c4397532ac84bc27c519f9d02dcdaf
README.zh.md: 1c41e5185d8f0701bf4d5bd377f1a77b83401bfa
+4 -4
View File
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
Use `dsh-client-ui-primitives` to build web-client controls and render agent output with shared React UI. It includes standard controls, icons, anchored overlays, and renderers for Markdown with TeX, terminal output, file reads, diffs, search, web retrieval, and JSON. The renderers handle untrusted model output by dropping raw HTML, restricting links, and parsing ANSI escape sequences. Callers must supply localized labels, and the components rely only on React and `--dsw-*` design tokens.
Use `dsh-client-ui-primitives` to build web-client controls and render agent output with shared React UI. It includes standard controls, icons, anchored overlays, and renderers for Markdown with TeX, terminal output, file reads, diffs, search, web retrieval, and JSON. The renderers handle untrusted model output by dropping raw HTML, restricting links, and parsing ANSI escape sequences. The components import no Cordis runtime; callers supply localized labels, and theme-facing colors use `--dsw-*` design tokens.
## Table of Contents
@@ -54,8 +54,8 @@ Check this table before writing a control in a feature package. A plugin cannot
| `JsonTree`, `JsonBlock` | Read-only JSON inspection. |
| `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. |
| `icons/*`, `FishLogo`, `BrandWordmark`, `ReferenceIcon`, `LinkIcon`, `DocumentFileIcon` | Glyphs and brand marks. Use `LinkIcon` only for 14px clickable-link categories and `DocumentFileIcon` only when no file path exists to classify. |
| `FileTypeIcon`, `classifyFileType`, `fileExtension` | A category-colored 28px file or folder glyph and the shared case-insensitive filename mapping behind it. Code and configuration files use detailed full-color technology glyphs; use `LinkIcon` for link-leading glyphs and image previews for image content. |
Three pairs are easy to confuse:
@@ -67,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. `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. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus.
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. `FileTypeIcon` renders the traditional 28px Excel, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the full-color square technology artwork for recognized code and configuration files. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIcon` remains the smaller leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `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. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus.
### Rendering agent output
+4 -4
View File
@@ -9,7 +9,7 @@ kind: "package-library"
## 概述
使用 `dsh-client-ui-primitives`,通过共享 React UI 构建 Web 客户端控件并渲染 agent 输出。它提供标准控件、图标、锚定浮层,以及用于带 TeX 公式的 Markdown、终端输出、文件读取、差异、搜索、网页检索和 JSON 的渲染器。这些渲染器会丢弃原始 HTML、限制链接并解析 ANSI 转义序列,以处理不受信任的模型输出。调用方必须提供本地化 label;这些组件仅依赖 React 和 `--dsw-*` 设计 token。
使用 `dsh-client-ui-primitives`,通过共享 React UI 构建 Web 客户端控件并渲染 agent 输出。它提供标准控件、图标、锚定浮层,以及用于带 TeX 公式的 Markdown、终端输出、文件读取、差异、搜索、网页检索和 JSON 的渲染器。这些渲染器会丢弃原始 HTML、限制链接并解析 ANSI 转义序列,以处理不受信任的模型输出。组件不 import Cordis 运行时;调用方提供本地化 label,主题相关颜色使用 `--dsw-*` 设计 token。
## 目录
@@ -54,8 +54,8 @@ kind: "package-library"
| `JsonTree``JsonBlock` | 只读 JSON 查看。 |
| `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` 按路径扩展名选出类型。 |
| `icons/*``FishLogo``BrandWordmark``ReferenceIcon``LinkIcon``DocumentFileIcon` | 字形与品牌标识`LinkIcon` 只用于 14px 的可点击链接分类,`DocumentFileIcon` 只用于没有文件路径可供分类的场景。 |
| `FileTypeIcon``classifyFileType``fileExtension` | 按类别着色的 28px 文件或文件夹图形,以及它背后共享的不区分大小写文件名映射。代码与配置文件使用细分的全彩技术图形;链接前置图形使用 `LinkIcon`,图片内容使用图片预览。 |
有三组容易混淆:
@@ -67,7 +67,7 @@ kind: "package-library"
### 控件与图标
上面的目录说明每个导出的用途;本节讲 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 源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 `Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。
上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。`ic_ds_*` 图标集与 `FishLogo`/`BrandWordmark` 标记填充品牌与行内图标 slot。`FileTypeIcon` 渲染传统的 28px Excel、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为已识别的代码和配置文件使用全彩方形技术图形。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScriptAngular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:``pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIcon` 仍是可点击产物链接较小的前置分类图形——地球、文件夹、代码、图片、文档或纸张——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇`ConnectionIndicator` 可渲染警告色的断联操作、以独立于 retry 时序的 500ms 节奏推进一至三个点的连接中状态,或成功色的恢复状态。悬停或键盘聚焦时只显示重连操作文案,连接中的圆点动画也保持隐藏。所有状态都为最长的输入 label 预留空间,并使用固定的图标列和文字列,因此文案变化不会移动控件或改变其宽度。它的 owner 提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition``useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。 `Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。 `rankByName``/` 菜单命令源与 skill 源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 `Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。
### 渲染 agent 输出
@@ -0,0 +1,491 @@
import { useId } from 'react'
import type { ReactNode } from 'react'
import type { IconProps } from './icons/props.ts'
import type { CodeFileType } from './code-file-types.ts'
/** Non-localizable letterforms that are part of the source-format artwork. */
const CODE_MARKS = {
c: 'C',
cpp: 'C',
csharp: 'C',
css: 'CSS',
env: 'ENV',
erlang: 'E',
ini: 'INI',
javascript: 'JS',
objectiveC: 'OC',
perl: 'PL',
php: 'PHP',
r: 'R',
toml: 'TOML',
typescript: 'TS',
yaml: 'YAML',
zig: 'ZIG',
} as const
/**
* Render one full-color square code-file glyph from the embedded icon set.
* @param props - Detailed code type, optional size, and optional CSS class.
* @returns The selected decorative SVG with its identifying palette intact.
*/
export function CodeFileIcon({ type, size = 20, className }: IconProps & { readonly type: CodeFileType }): ReactNode {
const gradientId = useId()
return (
<svg width={size} height={size} className={className} viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg" aria-hidden>
{codeFileArtwork(type, gradientId)}
</svg>
)
}
function codeFileArtwork(type: CodeFileType, gradientId: string): ReactNode {
switch (type) {
case 'angular':
return (
<>
<rect x="1" y="1" width="18" height="18" rx="4" fill="#DD0031"/>
<path fill="none" stroke="#fff" strokeWidth="1.4" strokeLinejoin="round" d="m10 3.4 6 2-1 8-5 3-5-3-1-8z"/>
<path fill="#fff" d="m10 5.3-3.7 8.2h1.9l.7-1.8h2.2l.7 1.8h1.9zm0 3.4.5 1.4h-1z"/>
</>
)
case 'c':
return (
<>
<rect x="1" y="1" width="18" height="18" rx="4" fill="#659AD2"/>
<path fill="none" stroke="#fff" strokeWidth="1.3" strokeLinejoin="round" d="m10 3.4 6 3.3v6.6l-6 3.3-6-3.3V6.7z"/>
<text x="10" y="13.5" textAnchor="middle" fontFamily="Arial,sans-serif" fontSize="9.5" fontWeight="800" fill="#fff">
{CODE_MARKS.c}
</text>
</>
)
case 'clojure':
return (
<>
<rect x="1" y="1" width="18" height="18" rx="4" fill="#344D70"/>
<circle cx="10" cy="10" r="6.6" fill="#fff"/>
<path fill="#63B132" d="M9.5 3.5a6.5 6.5 0 0 0-4.7 10.8c1-2.6 2.5-4.3 4.3-5.3-1-1.5-.8-3.4.4-5.5z"/>
<path fill="#5881D8" d="M10.7 3.5c1.8 1.6 2.5 3.3 2 5.1 1.5 1.1 2.4 2.8 2.7 5.2a6.5 6.5 0 0 0-4.7-10.3z"/>
<path fill="#90B4FE" d="M12 9.8c-1.6.3-2.8 2.1-3.8 5.4a6.4 6.4 0 0 0 6.3-.7c-.2-2.1-1-3.7-2.5-4.7z"/>
</>
)
case 'cmake':
return (
<>
<rect x="1" y="1" width="18" height="18" rx="4" fill="#263A55"/>
<path fill="#4D9DCC" d="M10 3 4 16h4.6z"/>
<path fill="#43A047" d="M10 3 8.6 16H16z"/>
<path fill="#E53935" d="m10 3 6 13-6-5z"/>
<path fill="#fff" d="m10 6 2.3 5.1-2.3-1.8-.8 2.9z"/>
</>
)
case 'cpp':
return (
<>
<rect x="1" y="1" width="18" height="18" rx="4" fill="#00599C"/>
<path fill="none" stroke="#fff" strokeWidth="1.15" strokeLinejoin="round" d="m10 3.4 6 3.3v6.6l-6 3.3-6-3.3V6.7z"/>
<text x="7.8" y="13" textAnchor="middle" fontFamily="Arial,sans-serif" fontSize="8.2" fontWeight="800" fill="#fff">
{CODE_MARKS.cpp}
</text>
<path stroke="#fff" strokeWidth="1.1" d="M12 8.8v3.8m-1.9-1.9h3.8m2-1.9v3.8M14 10.7h3.8"/>
</>
)
case 'csharp':
return (
<>
<rect x="1" y="1" width="18" height="18" rx="4" fill="#68217A"/>
<path fill="none" stroke="#fff" strokeWidth="1.15" strokeLinejoin="round" d="m10 3.4 6 3.3v6.6l-6 3.3-6-3.3V6.7z"/>
<text x="7.6" y="13" textAnchor="middle" fontFamily="Arial,sans-serif" fontSize="8.2" fontWeight="800" fill="#fff">
{CODE_MARKS.csharp}
</text>
<path stroke="#fff" strokeWidth=".95" d="M11.6 8.6h5m-5 3h5m-3.3-5v7m1.8-7v7"/>
</>
)
case 'css':
return (
<>
<rect x="1" y="1" width="18" height="18" rx="4" fill="#1572B6"/>
<text x="10" y="12.8" textAnchor="middle" fontFamily="Arial,sans-serif" fontSize="6.2" fontWeight="900" fill="#fff">
{CODE_MARKS.css}
</text>
</>
)
case 'dart':
return (
<>
<rect x="1" y="1" width="18" height="18" rx="4" fill="#0175C2"/>
<path fill="#fff" d="M5 4h6.5l4.5 4.5-3.2 7.5H8.5L4 11.5z"/>
<path fill="#13B9FD" d="m5 4 4 5.5h7L11.5 4zm4 5.5 3.8 6.5H8.5z"/>
</>
)
case 'docker':
return (
<>
<rect x="1" y="1" width="18" height="18" rx="4" fill="#2496ED"/>
<path fill="#fff" d="M3 10h11.7c.2-1.2.9-2 2-2.5.5 1 .4 2-.3 2.7.5.1 1 .1 1.5-.1-.7 3.9-3.6 5.9-8.2 5.9C6 16 3.4 14 3 10z"/>
<g fill="#2496ED">
<path d="M4.5 7h2v2h-2zm2.6 0h2v2h-2zm2.6 0h2v2h-2zM7.1 4.5h2v2h-2zm2.6 0h2v2h-2zm2.6 2.5h2v2h-2z"/>
</g>
</>
)
case 'elixir':
return (
<>
<defs>
<linearGradient id={gradientId} x1="1" y1="19" x2="19" y2="1">
<stop stopColor="#4B275F"/>
<stop offset="1" stopColor="#8E6AAF"/>
</linearGradient>
</defs>
<rect x="1" y="1" width="18" height="18" rx="4" fill={`url(#${gradientId})`}/>
<path fill="#fff" d="M10.7 3c.2 3.5-4.3 5.8-4.3 9.4 0 2.9 1.8 4.9 4.4 4.9 2.8 0 4.7-1.9 4.7-4.6 0-4.3-3.4-7-4.8-9.7z"/>
<path fill="#8E6AAF" d="M10.7 4.8c-.4 3.1-3 5.4-3 7.8 0 1.5.7 2.6 1.9 3-1-3.6 2.8-5.8 1.1-10.8z"/>
</>
)
case 'env':
return (
<>
<rect x="1" y="1" width="18" height="18" rx="4" fill="#ECD53F"/>
<text x="10" y="13.2" textAnchor="middle" fontFamily="Arial,sans-serif" fontSize="6.8" fontWeight="900" fill="#24292F">
{CODE_MARKS.env}
</text>
</>
)
case 'erlang':
return (
<>
<rect x="1" y="1" width="18" height="18" rx="4" fill="#A90533"/>
<circle cx="10" cy="10" r="6.4" fill="none" stroke="#fff" strokeWidth="1"/>
<text x="10" y="13.8" textAnchor="middle" fontFamily="Georgia,serif" fontSize="10.5" fontWeight="700" fill="#fff">
{CODE_MARKS.erlang}
</text>
</>
)
case 'flutter':
return (
<>
<rect x="1" y="1" width="18" height="18" rx="4" fill="#027DFD"/>
<path fill="#fff" d="m4 10 6-6h4L8 10zm4 0 2.5-2.5H14L11.5 10l2.5 2.5h-3.5z"/>
<path fill="#B9E6FF" d="m8 10 4.5 4.5-2 2L4 10z"/>
</>
)
case 'git':
return (
<>
<rect x="1" y="1" width="18" height="18" rx="4" fill="#F05032"/>
<rect x="5" y="5" width="10" height="10" rx="1.4" transform="rotate(45 10 10)" fill="#fff"/>
<path d="m7.7 7.4 4.9 4.9M9.9 9.6v3.6" fill="none" stroke="#F05032" strokeWidth="1.15"/>
<circle cx="7.7" cy="7.4" r="1.1" fill="#F05032"/>
<circle cx="12.6" cy="12.3" r="1.1" fill="#F05032"/>
<circle cx="9.9" cy="13.2" r="1.1" fill="#F05032"/>
</>
)
case 'go':
return (
<>
<rect x="1" y="1" width="18" height="18" rx="4" fill="#00ADD8"/>
<path fill="#fff" d="M3.5 8h6v1.6H5.2a2 2 0 0 0 0 .8h2.4v-.5H6.8V8.7h3.7c.3 3-1 4.4-3.6 4.4S3 11.7 3 9.7c0-.6.2-1.2.5-1.7Zm9.3 0h1.7c1.6 0 2.5 1 2.5 2.5s-.9 2.6-2.5 2.6h-1.7c-1.6 0-2.5-1-2.5-2.6S11.2 8 12.8 8Zm.3 1.6c-.7 0-1 .3-1 .9s.3 1 1 1h1.1c.7 0 1-.4 1-1s-.3-.9-1-.9z"/>
</>
)
case 'graphql':
return (
<>
<rect x="1" y="1" width="18" height="18" rx="4" fill="#E10098"/>
<path d="m10 4 5.2 3v6L10 16l-5.2-3V7zm-5 3.2h10M5 12.8h10M10 4l5 8.8M10 4l-5 8.8M10 16 5 7.2m5 8.8 5-8.8" stroke="#fff" strokeWidth=".8"/>
<g fill="#fff">
<circle cx="10" cy="4" r="1"/>
<circle cx="15.2" cy="7" r="1"/>
<circle cx="15.2" cy="13" r="1"/>
<circle cx="10" cy="16" r="1"/>
<circle cx="4.8" cy="13" r="1"/>
<circle cx="4.8" cy="7" r="1"/>
</g>
</>
)
case 'haskell':
return (
<>
<rect x="1" y="1" width="18" height="18" rx="4" fill="#5D4F85"/>
<path fill="#fff" d="m3.5 4.5 3.5 5.5-3.5 5.5h2.7L9.8 10 6.2 4.5zm4 0L11 10l-3.5 5.5h2.7l3.5-5.5-3.5-5.5zm5 3.1 1 1.6H17V7.6zm1.4 3 1 1.7H17v-1.7z"/>
</>
)
case 'ini':
return (
<>
<rect x="1" y="1" width="18" height="18" rx="4" fill="#6E7781"/>
<text x="10" y="13.2" textAnchor="middle" fontFamily="Arial,sans-serif" fontSize="7.2" fontWeight="800" fill="#fff">
{CODE_MARKS.ini}
</text>
</>
)
case 'java':
return (
<>
<rect x="1" y="1" width="18" height="18" rx="4" fill="#427A9B"/>
<path d="M9 3.1c2.3 1.7-2.4 2.5.5 4.5m2-4.9c2.3 1.9-3.2 2.9-.2 5" stroke="#FF6B57" strokeWidth="1.15" strokeLinecap="round"/>
<path d="M5.4 8.6h8c-.2 3.7-1.3 4.8-4 4.8-2.6 0-3.8-1.2-4-4.8Zm7.8 1h1c1.6 0 1.6 2.5-.5 2.5M4.7 14.8c2.5.8 7.5.8 10.1 0M5.7 16.5c2 .5 6.3.6 8.3 0" stroke="#fff" strokeWidth="1.05" strokeLinecap="round"/>
</>
)
case 'javascript':
return (
<>
<rect x="1" y="1" width="18" height="18" rx="4" fill="#F7DF1E"/>
<text x="10" y="14" textAnchor="middle" fontFamily="Arial,sans-serif" fontSize="8.4" fontWeight="800" fill="#191919">
{CODE_MARKS.javascript}
</text>
</>
)
case 'json':
return (
<>
<rect x="1" y="1" width="18" height="18" rx="4" fill="#4A4A4A"/>
<text x="10" y="13.6" textAnchor="middle" fontFamily="Arial,sans-serif" fontSize="9.4" fontWeight="700" fill="#F7DF1E">
{'{ }'}
</text>
</>
)
case 'kotlin':
return (
<>
<defs>
<linearGradient id={gradientId} x1="1" y1="19" x2="19" y2="1">
<stop stopColor="#0095D5"/>
<stop offset=".5" stopColor="#7F52FF"/>
<stop offset="1" stopColor="#F88909"/>
</linearGradient>
</defs>
<rect x="1" y="1" width="18" height="18" rx="4" fill={`url(#${gradientId})`}/>
<path fill="#fff" d="M5 5h2.2v4.1L11.1 5h3l-4.6 4.6 5 5.4h-3l-3.6-4-.7.7V15H5z"/>
</>
)
case 'lua':
return (
<>
<rect x="1" y="1" width="18" height="18" rx="4" fill="#000080"/>
<circle cx="9" cy="11" r="5.7" fill="none" stroke="#fff" strokeWidth="1"/>
<circle cx="13.1" cy="6.6" r="2.3" fill="#000080" stroke="#fff" strokeWidth="1"/>
<circle cx="13.8" cy="5.9" r=".65" fill="#fff"/>
<circle cx="10.7" cy="9" r=".9" fill="#fff"/>
</>
)
case 'makefile':
return (
<>
<rect x="1" y="1" width="18" height="18" rx="4" fill="#427819"/>
<path fill="#fff" d="m5 4.5 2.3 2.3 2.3-2.3L11 5.9 8.7 8.2l2.4 2.4L9.7 12l-2.4-2.4L5 11.9 3.6 10.5l2.3-2.3-2.3-2.3zm7 6.5h4v1.5h-4zm0 2.8h4v1.5h-4z"/>
</>
)
case 'node':
return (
<>
<rect x="1" y="1" width="18" height="18" rx="4" fill="#539E43"/>
<path fill="none" stroke="#fff" strokeWidth="1.35" strokeLinejoin="round" d="m10 3.5 6 3.3v6.4l-6 3.3-6-3.3V6.8z"/>
<path fill="#fff" d="M6.8 7h1.8l2.8 4.2V7h1.8v6h-1.8L8.6 8.8V13H6.8z"/>
</>
)
case 'objective-c':
return (
<>
<rect x="1" y="1" width="18" height="18" rx="4" fill="#438EFF"/>
<text x="10" y="13.2" textAnchor="middle" fontFamily="Arial,sans-serif" fontSize="7.2" fontWeight="800" fill="#fff">
{CODE_MARKS.objectiveC}
</text>
</>
)
case 'perl':
return (
<>
<rect x="1" y="1" width="18" height="18" rx="4" fill="#39457E"/>
<text x="10" y="13.3" textAnchor="middle" fontFamily="Arial,sans-serif" fontSize="7.8" fontWeight="800" fill="#fff">
{CODE_MARKS.perl}
</text>
</>
)
case 'php':
return (
<>
<rect x="1" y="1" width="18" height="18" rx="4" fill="#777BB4"/>
<ellipse cx="10" cy="10" rx="7" ry="4.5" fill="none" stroke="#fff" strokeWidth="1"/>
<text x="10" y="12.1" textAnchor="middle" fontFamily="Arial,sans-serif" fontSize="5.8" fontStyle="italic" fontWeight="800" fill="#fff">
{CODE_MARKS.php}
</text>
</>
)
case 'powershell':
return (
<>
<rect x="1" y="1" width="18" height="18" rx="4" fill="#2671BE"/>
<path fill="#fff" fillOpacity=".16" d="M5.5 4h11l-3.5 12H2z"/>
<path d="m6.5 6.5 3 3-4.2 3.2m4 1h4" fill="none" stroke="#fff" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"/>
</>
)
case 'protobuf':
return (
<>
<rect x="1" y="1" width="18" height="18" rx="4" fill="#4285F4"/>
<path fill="none" stroke="#fff" strokeWidth="1.2" strokeLinejoin="round" d="m10 3.5 6 3.3v6.4l-6 3.3-6-3.3V6.8z"/>
<path fill="#fff" d="M6.5 6.5h4.4c2.3 0 3.6 1.2 3.6 3.1s-1.3 3.1-3.6 3.1H9v2H6.5zM9 8.4v2.4h1.6c.8 0 1.2-.4 1.2-1.2s-.4-1.2-1.2-1.2z"/>
</>
)
case 'python':
return (
<>
<rect x="1" y="1" width="18" height="18" rx="4" fill="#306998"/>
<path fill="#fff" d="M9.8 3.2c-3.5 0-3.6 1.5-3.6 1.5v2.5H10V8H5s-2.2-.2-2.2 3.3 2 3.4 2 3.4h1.8v-2.6c0-2 1.8-2.4 3.2-2.4H13c1.8 0 2.4-1.4 2.4-3.2S13.8 3.2 12 3.2z"/>
<circle cx="8.1" cy="5.1" r=".65" fill="#306998"/>
<path fill="#FFD43B" d="M10.2 16.8c3.5 0 3.6-1.5 3.6-1.5v-2.5H10V12h5s2.2.2 2.2-3.3-2-3.4-2-3.4h-1.8v2.6c0 2-1.8 2.4-3.2 2.4H7c-1.8 0-2.4 1.4-2.4 3.2s1.6 3.3 3.4 3.3z"/>
<circle cx="11.9" cy="14.9" r=".65" fill="#306998"/>
</>
)
case 'r':
return (
<>
<rect x="1" y="1" width="18" height="18" rx="4" fill="#276DC3"/>
<ellipse cx="9" cy="9.4" rx="6.4" ry="4.5" fill="#fff" fillOpacity=".9"/>
<ellipse cx="8.3" cy="8.9" rx="4.5" ry="2.8" fill="#276DC3"/>
<path fill="#fff" d="M8 7h4c2.2 0 3.4.9 3.4 2.5 0 1-.6 1.7-1.7 2.1l2.5 4.1h-2.7l-2-3.7h-.9v3.7H8zm2.6 1.9v1.4h1c.7 0 1.1-.2 1.1-.7s-.4-.7-1.1-.7z"/>
</>
)
case 'react':
return (
<>
<rect x="1" y="1" width="18" height="18" rx="4" fill="#20232A"/>
<circle cx="10" cy="10" r="1.4" fill="#61DAFB"/>
<g stroke="#61DAFB" strokeWidth="1">
<ellipse cx="10" cy="10" rx="6.8" ry="2.7"/>
<ellipse cx="10" cy="10" rx="6.8" ry="2.7" transform="rotate(60 10 10)"/>
<ellipse cx="10" cy="10" rx="6.8" ry="2.7" transform="rotate(120 10 10)"/>
</g>
</>
)
case 'ruby':
return (
<>
<rect x="1" y="1" width="18" height="18" rx="4" fill="#B51F27"/>
<path fill="#fff" d="m4 7 3-3h6l3 3-6 9-6-6z"/>
<path fill="#F15A4A" d="m7 4 1.8 3H4zm1.8 3L13 4l3 3zm0 0L10 16 4 10zm0 0H16l-6 9z"/>
</>
)
case 'rust':
return (
<>
<rect x="1" y="1" width="18" height="18" rx="4" fill="#2B2B2B"/>
<path fill="#DEA584" d="m10 2.8.7 1.3 1.4-.4.1 1.5 1.5.2-.6 1.4 1.3.8-.1.1 1.4.7-.9 1.2 1 1.1-1.2.8.6 1.3-1.4.4.3 1.5-1.5.1-.4 1.4-1.3-.5-.9 1.2-.9-1.2-1.3.5-.4-1.4-1.5-.1.3-1.5-1.4-.4.6-1.3-1.2-.8 1-1.1-.9-1.2 1.4-.7-.1-.1 1.3-.8-.6-1.4 1.5-.2.1-1.5 1.4.4z"/>
<circle cx="10" cy="10" r="4.6" fill="#2B2B2B"/>
<text x="10" y="13.1" textAnchor="middle" fontFamily="Georgia,serif" fontSize="8.2" fontWeight="700" fill="#DEA584">
{CODE_MARKS.r}
</text>
</>
)
case 'scala':
return (
<>
<rect x="1" y="1" width="18" height="18" rx="4" fill="#DE3423"/>
<path fill="#fff" d="M5 4.2c3.3 1.1 6.7-.3 10-.9v3.3c-3.3.5-6.7 2-10 .8zm0 4.7c3.3 1.2 6.7-.3 10-.8v3.3c-3.3.5-6.7 1.9-10 .8zm0 4.7c3.3 1.2 6.7-.3 10-.8v3.3c-3.3.5-6.7 1.9-10 .8z"/>
</>
)
case 'shell':
return (
<>
<rect x="1" y="1" width="18" height="18" rx="4" fill="#303642"/>
<path d="m5 6.5 3.5 3.5L5 13.5m5 0h5" fill="none" stroke="#7EE787" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</>
)
case 'solidity':
return (
<>
<rect x="1" y="1" width="18" height="18" rx="4" fill="#555"/>
<path fill="#fff" d="M7.2 3.2h5.6L10 8zm-.5.8 2.8 4.8H4zm6.6 0L16 8.8h-5.5zM4 10.3h5.5l-2.8 4.8zm6.5 0H16l-2.7 4.8zM10 11.2l2.8 4.8H7.2z"/>
</>
)
case 'sql':
return (
<>
<rect x="1" y="1" width="18" height="18" rx="4" fill="#336791"/>
<ellipse cx="10" cy="5.5" rx="5.8" ry="2.1" fill="#fff"/>
<path fill="#fff" fillOpacity=".88" d="M4.2 5.5v8.9c0 1.2 2.6 2.1 5.8 2.1s5.8-.9 5.8-2.1V5.5c0 1.2-2.6 2.1-5.8 2.1s-5.8-.9-5.8-2.1z"/>
<path d="M4.2 9.2c0 1.2 2.6 2.1 5.8 2.1s5.8-.9 5.8-2.1M4.2 12.9c0 1.2 2.6 2.1 5.8 2.1s5.8-.9 5.8-2.1" fill="none" stroke="#336791" strokeOpacity=".7" strokeWidth=".7"/>
</>
)
case 'svelte':
return (
<>
<rect x="1" y="1" width="18" height="18" rx="4" fill="#FF3E00"/>
<path d="M13.9 6.1c-.9-1.1-2.6-1.4-3.8-.7L7 7.4c-1.2.8-1.5 2.4-.7 3.6.7 1 2 1.4 3.1 1l3-1.2c.5-.2 1 .1 1.2.5.2.4 0 .9-.4 1.2l-3.1 2c-.5.3-1.1.2-1.5-.2" stroke="#fff" strokeWidth="1.6" strokeLinecap="round"/>
<path d="M6.1 13.9c.9 1.1 2.6 1.4 3.8.7l3.1-2c1.2-.8 1.5-2.4.7-3.6-.7-1-2-1.4-3.1-1l-3 1.2c-.5.2-1-.1-1.2-.5-.2-.4 0-.9.4-1.2l3.1-2c.5-.3 1.1-.2 1.5.2" stroke="#fff" strokeWidth="1.6" strokeLinecap="round"/>
</>
)
case 'swift':
return (
<>
<rect x="1" y="1" width="18" height="18" rx="4" fill="#F05138"/>
<path fill="#fff" d="M15.9 14.9c.3-3.6-1.2-6.2-3.8-8.4 1.1 1.5 1.7 2.9 1.9 4.1C10.9 8.4 8 6 5.4 3.7c1.6 2.3 3.4 4.4 5.4 6.2-1.7-1-3.5-2.2-5.6-3.7 2.3 3 4.7 5.1 7.3 6.1-2 .9-4.5.7-7.2-.8 2.2 3.2 5.6 4.1 8.3 2.4.8.4 1.6.8 2.3 1z"/>
</>
)
case 'toml':
return (
<>
<rect x="1" y="1" width="18" height="18" rx="4" fill="#9C4121"/>
<text x="10" y="12.9" textAnchor="middle" fontFamily="Arial,sans-serif" fontSize="5.6" fontWeight="800" fill="#fff">
{CODE_MARKS.toml}
</text>
</>
)
case 'typescript':
return (
<>
<rect x="1" y="1" width="18" height="18" rx="4" fill="#3178C6"/>
<text x="10" y="14" textAnchor="middle" fontFamily="Arial,sans-serif" fontSize="8.4" fontWeight="800" fill="#fff">
{CODE_MARKS.typescript}
</text>
</>
)
case 'vue':
return (
<>
<rect x="1" y="1" width="18" height="18" rx="4" fill="#41B883"/>
<path fill="#fff" d="M3.5 5h3.2l3.3 5.7L13.3 5h3.2L10 16z"/>
<path fill="#35495E" d="M6.7 5H9l1 1.8L11 5h2.3L10 10.7z"/>
</>
)
case 'wasm':
return (
<>
<rect x="1" y="1" width="18" height="18" rx="4" fill="#654FF0"/>
<path fill="#fff" d="M4 6h2l.8 5.1L8 7h2l1.2 4.1L12 6h2l-1.7 8h-2l-1.3-4-1.3 4h-2zm10.5 0H17v8h-2V8h-.5z"/>
</>
)
case 'xml':
return (
<>
<rect x="1" y="1" width="18" height="18" rx="4" fill="#F28C28"/>
<path d="m7.5 6-4 4 4 4m5-8 4 4-4 4M11 5 9 15" fill="none" stroke="#fff" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</>
)
case 'yaml':
return (
<>
<rect x="1" y="1" width="18" height="18" rx="4" fill="#CB171E"/>
<text x="10" y="12.9" textAnchor="middle" fontFamily="Arial,sans-serif" fontSize="5.6" fontWeight="800" fill="#fff">
{CODE_MARKS.yaml}
</text>
</>
)
case 'zig':
return (
<>
<rect x="1" y="1" width="18" height="18" rx="4" fill="#F7A41D"/>
<path fill="#fff" d="M3.5 6h4L6 7.5h5L7.5 12h5L11 14h5.5l-.8-3 1-5h-4.4L14 7.5h-3.7L13 4.2H6L7.3 6z"/>
<text x="10" y="12.2" textAnchor="middle" fontFamily="Arial,sans-serif" fontSize="4.6" fontWeight="900" fill="#F7A41D">
{CODE_MARKS.zig}
</text>
</>
)
/* v8 ignore next -- closed-union backstop; only reached if a type is forged */
default: return assertNever(type)
}
}
/** Closed-union exhaustiveness guard for the embedded artwork table. */
/* v8 ignore next 3 -- only reachable when an untyped caller forges a code file type */
function assertNever(value: never): never {
throw new Error(`unreachable code file type: ${String(value)}`)
}
@@ -1,7 +1,7 @@
import { useId } from 'react'
/**
* Render the DeepSeek Web document glyph used by generic-file cards.
* Render a generic document glyph when the caller intentionally has no file path to classify.
* @param props - optional CSS class for sizing and placement.
* @returns a decorative document SVG with an instance-safe gradient id.
*/
@@ -0,0 +1,50 @@
.icon {
/* The design platform has no violet token matching the supplied image/video artwork. */
--dsh-file-type-violet: rgb(139, 118, 246);
color: var(--dsh-file-type-icon-color, var(--dsh-file-type-default-color));
}
.code {
--dsh-file-type-default-color: var(--dsw-static-deepseek-500);
}
.excel {
--dsh-file-type-default-color: var(--dsw-static-green-500);
}
.folder {
--dsh-file-type-default-color: var(--dsw-static-amber-400);
}
.html {
--dsh-file-type-default-color: var(--dsw-static-deepseek-500);
}
.image {
--dsh-file-type-default-color: var(--dsh-file-type-violet);
}
.markdown {
--dsh-file-type-default-color: var(--dsw-static-deepseek-500);
}
.other {
--dsh-file-type-default-color: var(--dsw-static-neutral-bluish-300);
}
.pdf {
--dsh-file-type-default-color: var(--dsw-static-red-600);
}
.ppt {
--dsh-file-type-default-color: var(--dsw-static-amber-500);
}
.video {
--dsh-file-type-default-color: var(--dsh-file-type-violet);
}
.word {
--dsh-file-type-default-color: var(--dsw-static-deepseek-450);
}
+245 -149
View File
@@ -1,167 +1,263 @@
/**
* 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 clsx from 'clsx'
import { CodeFileIcon } from './CodeFileIcon.tsx'
import {
CODE_EXTENSIONS, HTML_EXTENSIONS, IMAGE_EXTENSIONS, MARKDOWN_EXTENSIONS, PDF_EXTENSIONS, SHEET_EXTENSIONS,
SLIDES_EXTENSIONS, WORD_EXTENSIONS, fileExtension,
} from './file-extensions.ts'
classifyCodeFileType, isCodeFileType, type CodeFileType, type FileTypeProjectContext,
} from './code-file-types.ts'
import type { IconProps } from './icons/props.ts'
import css from './FileTypeIcon.module.css'
/** 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'
/** File categories with distinct 28px glyphs. */
export type FileType =
| CodeFileType
| 'code'
| 'excel'
| 'folder'
| 'html'
| 'image'
| 'markdown'
| 'other'
| 'pdf'
| 'ppt'
| 'video'
| 'word'
/** Props for {@link FileTypeIcon}: the type plus the shared icon sizing seat. */
export interface FileTypeIconProps extends IconProps {
kind: FileTypeKind
/** Compatibility name for consumers that pass an already resolved category. */
export type FileTypeKind = FileType
type ClassifiedFileType = Exclude<FileType, 'folder'>
type TraditionalFileType = Exclude<FileType, CodeFileType>
export type { CodeFileType, FileTypeProjectContext } from './code-file-types.ts'
/** Props for {@link FileTypeIcon}: either a path to classify or an already resolved kind. */
export type FileTypeIconProps = IconProps & (
| {
/** File path or name to classify. */
readonly path: string
/** Optional project-file snapshot for context-sensitive code icons such as Flutter. */
readonly context?: FileTypeProjectContext | undefined
}
| {
/** Explicit category for callers that already resolved the file type. */
readonly kind: FileTypeKind
}
)
const EXTENSION_TYPES: Readonly<Record<string, ClassifiedFileType>> = {
scss: 'code',
sass: 'code',
less: 'code',
vue: 'code',
svelte: 'code',
astro: 'code',
env: 'code',
bat: 'code',
cmd: 'code',
csv: 'code',
tsv: 'code',
html: 'html',
htm: 'html',
png: 'image',
jpg: 'image',
jpeg: 'image',
gif: 'image',
svg: 'image',
webp: 'image',
avif: 'image',
bmp: 'image',
ico: 'image',
tif: 'image',
tiff: 'image',
heic: 'image',
heif: 'image',
md: 'markdown',
mdx: 'markdown',
markdown: 'markdown',
pdf: 'pdf',
ppt: 'ppt',
pptx: 'ppt',
mp4: 'video',
mov: 'video',
m4v: 'video',
webm: 'video',
mkv: 'video',
avi: 'video',
mpg: 'video',
mpeg: 'video',
doc: 'word',
docx: 'word',
xls: 'excel',
xlsx: 'excel',
}
/** 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 NAME_TYPES: Readonly<Record<string, ClassifiedFileType>> = {
changelog: 'markdown',
contributing: 'markdown',
readme: 'markdown',
}
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" />
</>
),
},
function basename(path: string): string {
return path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1)
}
/**
* 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.
* Extract the final suffix from a file path without changing its case.
* A leading dot starts a suffix, while a missing or trailing dot returns an empty string.
* @param path - File path or basename using either path separator.
* @returns The characters after the basename's final dot.
*/
export function FileTypeIcon({ kind, size = 28, className }: FileTypeIconProps): ReactNode {
const face = FACES[kind]
export function fileExtension(path: string): string {
const name = basename(path)
const dot = name.lastIndexOf('.')
return dot < 0 ? '' : name.slice(dot + 1)
}
/**
* Classify a file path or name for file-card presentation.
* Matching is case-insensitive and applies code filename rules before extension rules;
* unknown names fall back to `other`.
* @param path - File path or basename using either path separator.
* @param context - Optional project files used by context-sensitive code mappings.
* @returns The file's closed presentation category.
*/
export function classifyFileType(
path: string,
context?: FileTypeProjectContext,
): ClassifiedFileType {
const name = basename(path).toLowerCase()
const extension = fileExtension(name).toLowerCase()
return classifyCodeFileType(name, extension, context)
?? NAME_TYPES[name]
?? EXTENSION_TYPES[extension]
?? 'other'
}
const FILE_BODY = 'M8.48924 28H19.5108C21.6479 28 22.7165 28 23.5594 27.6509C24.6833 27.1853 25.5762 26.2924 26.0417 25.1685C26.3909 24.3256 26.3909 23.257 26.3909 21.1199V8.79443C26.3909 8.32877 26.3909 8.09593 26.3471 7.87507C26.2887 7.58058 26.173 7.30042 26.0067 7.05048C25.882 6.86303 25.7177 6.69799 25.3893 6.36792L20.0611 1.01354C19.7304 0.681235 19.5651 0.515081 19.3769 0.38885C19.126 0.220541 18.8443 0.103463 18.5481 0.0443412C18.3259 0 18.0915 0 17.6226 0H8.48924C6.35209 0 5.28351 0 4.4406 0.349145C3.31672 0.814671 2.4238 1.70759 1.95828 2.83147C1.60913 3.67438 1.60913 4.74296 1.60913 6.88011V21.1199C1.60913 23.257 1.60913 24.3256 1.95828 25.1685C2.4238 26.2924 3.31672 27.1853 4.4406 27.6509C5.28351 28 6.35209 28 8.48924 28Z'
const FILE_FOLD = 'M26.3909 7.37445L19.0525 0V3.77445C19.0525 4.89271 19.0525 5.45184 19.2352 5.89289C19.4788 6.48096 19.946 6.94818 20.5341 7.19176C20.9751 7.37445 21.5342 7.37445 22.6525 7.37445H26.3909Z'
const FILE_MARK_TRANSFORM = 'translate(14 16) scale(1.12) translate(-14 -16)'
const LARGE_FILE_MARK_TRANSFORM = 'translate(14 16) scale(1.22) translate(-14 -16)'
const FOLDER_MARK_TRANSFORM = 'translate(14 13.0693) scale(1.12) translate(-14 -13.0693)'
function FileGlyph({
size, className, children, markTransform = FILE_MARK_TRANSFORM, muted = false,
}: IconProps & { children?: ReactNode; markTransform?: string; muted?: boolean }): ReactNode {
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" />}
<path d={FILE_BODY} fill="currentColor" />
{muted
? <path d={FILE_FOLD} fill="var(--dsw-static-neutral-400)" />
: <path d={FILE_FOLD} fill="var(--dsw-static-neutral-00)" fillOpacity=".7" />}
{children !== undefined && (
<g color="var(--dsw-static-neutral-00)" data-file-type-mark transform={markTransform}>{children}</g>
)}
</svg>
)
}
function FolderGlyph({ size, className }: IconProps): ReactNode {
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="M2.80078 10.2112C2.80078 9.52802 2.80078 9.18642 2.85314 8.866C2.98648 8.05 3.36942 7.29538 3.94925 6.70595C4.17694 6.4745 4.45264 6.2728 5.00404 5.86939C5.29197 5.65874 5.43594 5.55342 5.58711 5.46632C5.97089 5.24521 6.39638 5.10618 6.83667 5.05803C7.01011 5.03906 7.18849 5.03906 7.54524 5.03906H11.6543C12.4669 5.03906 12.8732 5.03906 13.2599 5.13697C13.3882 5.16945 13.5143 5.20986 13.6375 5.25795C14.0091 5.40294 14.3398 5.63902 15.0012 6.1112L16.2632 7.01224C16.5526 7.21881 16.6972 7.3221 16.8598 7.38553C16.9137 7.40657 16.9689 7.42425 17.025 7.43846C17.1942 7.4813 17.372 7.4813 17.7275 7.4813H19.8008C22.0506 7.4813 23.1755 7.4813 23.9641 8.05425C24.2188 8.23928 24.4428 8.46326 24.6278 8.71794C25.2008 9.50654 25.2008 10.6315 25.2008 12.8813V18.7283C25.2008 20.9781 25.2008 22.1031 24.6278 22.8917C24.4428 23.1463 24.2188 23.3703 23.9641 23.5554C23.1755 24.1283 22.0506 24.1283 19.8008 24.1283H8.20077C5.95094 24.1283 4.82602 24.1283 4.03743 23.5554C3.78274 23.3703 3.55877 23.1463 3.37373 22.8917C2.80078 22.1031 2.80078 20.9781 2.80078 18.7283V10.2112Z"
fill="currentColor"
/>
<g color="var(--dsw-static-neutral-00)" data-file-type-mark transform={FOLDER_MARK_TRANSFORM}>
<path d="M6.31445 13.0693H21.6893" stroke="currentColor" strokeWidth="2.38" />
</g>
</svg>
)
}
function glyph(type: TraditionalFileType, size: number, className: string | undefined): ReactNode {
switch (type) {
case 'code':
return (
<FileGlyph size={size} className={className}>
<path d="M8.61 16.3601L11.76 18.3901V20.1401L7 17.0601V15.6601L11.76 12.5801V14.3301L8.61 16.3601Z" fill="currentColor" />
<path d="M16.1918 14.3301V12.5801L20.9518 15.6601V17.0601L16.1918 20.1401V18.3901L19.3418 16.3601L16.1918 14.3301Z" fill="currentColor" />
</FileGlyph>
)
case 'excel':
return (
<FileGlyph size={size} className={className} markTransform={LARGE_FILE_MARK_TRANSFORM}>
<path d="M10.2932 20.5L13.3532 16.25L13.3432 17.66L10.4032 13.5H12.6332L14.5132 16.21L13.5632 16.22L15.4132 13.5H17.5532L14.6132 17.58V16.18L17.7132 20.5H15.4332L13.5232 17.65H14.4332L12.5532 20.5H10.2932Z" fill="currentColor" />
</FileGlyph>
)
case 'folder': return <FolderGlyph size={size} className={className} />
case 'html':
return (
<FileGlyph size={size} className={className}>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M13.9994 9.68298C17.212 9.68298 19.8167 12.2872 19.8168 15.4997C19.8168 18.7123 17.2121 21.3171 13.9994 21.3171C10.7869 21.3169 8.18274 18.7122 8.18274 15.4997C8.1829 12.2873 10.787 9.68315 13.9994 9.68298ZM9.26213 16.0247C9.47025 17.9241 10.7936 19.4876 12.5639 20.0463C12.42 19.7977 12.2952 19.5152 12.1879 19.2116C11.885 18.3541 11.693 17.2434 11.6424 16.0247H9.26213ZM16.3565 16.0247C16.3059 17.2434 16.1145 18.3542 15.8116 19.2116C15.7044 19.5151 15.5788 19.7971 15.435 20.0456C17.2054 19.487 18.5293 17.9242 18.7374 16.0247H16.3565ZM12.6938 16.0247C12.7439 17.1459 12.9212 18.1334 13.1784 18.8616C13.332 19.2962 13.503 19.61 13.6686 19.805C13.834 19.9996 13.9473 20.0256 13.9994 20.0258C14.0514 20.0258 14.1651 20.0002 14.331 19.805C14.4966 19.61 14.6676 19.2962 14.8211 18.8616C15.0784 18.1334 15.2557 17.1459 15.3058 16.0247H12.6938ZM13.9994 10.733C13.9473 10.7331 13.834 10.7598 13.6686 10.9545C13.503 11.1494 13.3319 11.4633 13.1784 11.8978C12.903 12.6777 12.7188 13.7545 12.6849 14.9747H15.3147C15.2808 13.7545 15.0966 12.6777 14.8211 11.8978C14.6676 11.4633 14.4965 11.1494 14.331 10.9545C14.1651 10.7593 14.0514 10.733 13.9994 10.733ZM15.5888 11.0051C15.6701 11.1756 15.7444 11.3576 15.8116 11.5478C16.1343 12.4613 16.3308 13.6619 16.3647 14.9747H18.7374C18.5352 13.1307 17.2817 11.6036 15.5888 11.0051ZM12.4101 11.0051C10.7174 11.6037 9.46428 13.1308 9.26213 14.9747H11.6349C11.6688 13.6619 11.8652 12.4613 12.1879 11.5478C12.2551 11.3577 12.3288 11.1756 12.4101 11.0051Z"
fill="currentColor"
/>
</FileGlyph>
)
case 'image':
return (
<FileGlyph size={size} className={className}>
<path d="M10.4212 15.9204C10.5756 15.6558 10.9579 15.6558 11.1123 15.9204L13.6493 20.2696C13.8048 20.5362 13.6125 20.8711 13.3037 20.8711H8.22974C7.92102 20.8711 7.72868 20.5362 7.88423 20.2696L10.4212 15.9204Z" fill="currentColor" />
<path d="M15.4981 13.186C15.6505 12.9117 16.0451 12.9117 16.1975 13.186L20.1368 20.2769C20.2849 20.5435 20.0922 20.8711 19.7872 20.8711H11.9084C11.6034 20.8711 11.4107 20.5435 11.5588 20.2769L15.4981 13.186Z" fill="currentColor" />
<path d="M11.8603 11.3997C11.8603 12.286 11.1418 13.0045 10.2555 13.0045C9.36924 13.0045 8.65076 12.286 8.65076 11.3997C8.65076 10.5134 9.36924 9.79492 10.2555 9.79492C11.1418 9.79492 11.8603 10.5134 11.8603 11.3997Z" fill="currentColor" />
</FileGlyph>
)
case 'markdown':
return (
<FileGlyph size={size} className={className} markTransform={LARGE_FILE_MARK_TRANSFORM}>
<path d="M8.7588 19.5V14.6H9.8998L11.9298 17.932H11.3278L13.3018 14.6H14.4428L14.4568 19.5H13.1828L13.1688 16.539H13.3858L11.9088 19.017H11.2928L9.7738 16.539H10.0398V19.5H8.7588ZM15.4375 19.5V14.6H17.7545C18.2958 14.6 18.7718 14.7003 19.1825 14.901C19.5932 15.1017 19.9128 15.384 20.1415 15.748C20.3748 16.112 20.4915 16.546 20.4915 17.05C20.4915 17.5493 20.3748 17.9833 20.1415 18.352C19.9128 18.716 19.5932 18.9983 19.1825 19.199C18.7718 19.3997 18.2958 19.5 17.7545 19.5H15.4375ZM16.8235 18.394H17.6985C17.9785 18.394 18.2212 18.3427 18.4265 18.24C18.6365 18.1327 18.7998 17.9787 18.9165 17.778C19.0332 17.5727 19.0915 17.33 19.0915 17.05C19.0915 16.7653 19.0332 16.5227 18.9165 16.322C18.7998 16.1213 18.6365 15.9697 18.4265 15.867C18.2212 15.7597 17.9785 15.706 17.6985 15.706H16.8235V18.394Z" fill="currentColor" />
</FileGlyph>
)
case 'other': return <FileGlyph size={size} className={className} muted />
case 'pdf':
return (
<FileGlyph size={size} className={className} markTransform={LARGE_FILE_MARK_TRANSFORM}>
<path d="M6.80616 19.5V14.6H9.04616C9.49416 14.6 9.87916 14.6723 10.2012 14.817C10.5278 14.9617 10.7798 15.1717 10.9572 15.447C11.1345 15.7177 11.2232 16.0397 11.2232 16.413C11.2232 16.7817 11.1345 17.1013 10.9572 17.372C10.7798 17.6427 10.5278 17.8527 10.2012 18.002C9.87916 18.1467 9.49416 18.219 9.04616 18.219H7.57616L8.19216 17.617V19.5H6.80616ZM8.19216 17.764L7.57616 17.127H8.96216C9.2515 17.127 9.46616 17.064 9.60616 16.938C9.75083 16.812 9.82316 16.637 9.82316 16.413C9.82316 16.1843 9.75083 16.007 9.60616 15.881C9.46616 15.755 9.2515 15.692 8.96216 15.692H7.57616L8.19216 15.055V17.764ZM11.8989 19.5V14.6H14.2159C14.7573 14.6 15.2333 14.7003 15.6439 14.901C16.0546 15.1017 16.3743 15.384 16.6029 15.748C16.8363 16.112 16.9529 16.546 16.9529 17.05C16.9529 17.5493 16.8363 17.9833 16.6029 18.352C16.3743 18.716 16.0546 18.9983 15.6439 19.199C15.2333 19.3997 14.7573 19.5 14.2159 19.5H11.8989ZM13.2849 18.394H14.1599C14.4399 18.394 14.6826 18.3427 14.8879 18.24C15.0979 18.1327 15.2613 17.9787 15.3779 17.778C15.4946 17.5727 15.5529 17.33 15.5529 17.05C15.5529 16.7653 15.4946 16.5227 15.3779 16.322C15.2613 16.1213 15.0979 15.9697 14.8879 15.867C14.6826 15.7597 14.4399 15.706 14.1599 15.706H13.2849V18.394ZM17.6821 19.5V14.6H21.5251V15.671H19.0681V19.5H17.6821ZM18.9701 17.82V16.749H21.2311V17.82H18.9701Z" fill="currentColor" />
</FileGlyph>
)
case 'ppt':
return (
<FileGlyph size={size} className={className} markTransform={LARGE_FILE_MARK_TRANSFORM}>
<path d="M11.0132 20.5V13.5H14.2132C14.8532 13.5 15.4032 13.6033 15.8632 13.81C16.3299 14.0167 16.6899 14.3167 16.9432 14.71C17.1966 15.0967 17.3232 15.5567 17.3232 16.09C17.3232 16.6167 17.1966 17.0733 16.9432 17.46C16.6899 17.8467 16.3299 18.1467 15.8632 18.36C15.4032 18.5667 14.8532 18.67 14.2132 18.67H12.1132L12.9932 17.81V20.5H11.0132ZM12.9932 18.02L12.1132 17.11H14.0932C14.5066 17.11 14.8132 17.02 15.0132 16.84C15.2199 16.66 15.3232 16.41 15.3232 16.09C15.3232 15.7633 15.2199 15.51 15.0132 15.33C14.8132 15.15 14.5066 15.06 14.0932 15.06H12.1132L12.9932 14.15V18.02Z" fill="currentColor" />
</FileGlyph>
)
case 'video':
return (
<FileGlyph size={size} className={className}>
<path d="M17.5 14.634C18.1667 15.0189 18.1667 15.9811 17.5 16.366L11.5 19.8301C10.8333 20.215 10 19.7339 10 18.9641L10 12.0359C10 11.2661 10.8333 10.785 11.5 11.1699L17.5 14.634Z" fill="currentColor" />
</FileGlyph>
)
case 'word':
return (
<FileGlyph size={size} className={className} markTransform={LARGE_FILE_MARK_TRANSFORM}>
<path d="M10.5118 20.5L8.24179 13.5H10.2818L12.1918 19.56H11.1618L13.1718 13.5H14.9918L16.8918 19.56H15.9018L17.8718 13.5H19.7618L17.4918 20.5H15.3718L13.7518 15.35H14.3218L12.6318 20.5H10.5118Z" fill="currentColor" />
</FileGlyph>
)
/* v8 ignore next -- closed-union backstop; only reached if a type is forged */
default: return assertNever(type)
}
}
/** Closed-union exhaustiveness guard for traditional file artwork. */
/* v8 ignore next 3 -- only reachable when an untyped caller forges a traditional file type */
function assertNever(value: never): never {
throw new Error(`unreachable traditional file type: ${String(value)}`)
}
/**
* Render a decorative file-type glyph for a path or an explicitly resolved kind.
* @param props - Path or kind selection, optional project context, size, and CSS class.
* @returns The category-colored SVG; the caller owns the accessible name and may override
* the color through `--dsh-file-type-icon-color`.
*/
export function FileTypeIcon(props: FileTypeIconProps): ReactNode {
const { size = 28, className } = props
const resolvedType = 'path' in props
? classifyFileType(props.path, props.context)
: props.kind
return isCodeFileType(resolvedType)
? <CodeFileIcon type={resolvedType} size={size} className={className} />
: glyph(resolvedType, size, clsx(css.icon, css[resolvedType], className))
}
+18 -13
View File
@@ -9,10 +9,8 @@
* 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 { classifyFileType, fileExtension } from './FileTypeIcon.tsx'
import { isCodeFileType, isLinkCodeExtension } from './code-file-types.ts'
import type { IconProps } from './icons/props.ts'
/**
@@ -28,22 +26,29 @@ export interface LinkIconProps extends IconProps {
kind: LinkIconKind
}
/** 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. Code, web, and
* data files share the code glyph; unknown and missing extensions fall to
* `other` (the plain-paper glyph).
* Derive a file path's link-icon category from its extension. 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 type = classifyFileType(path)
const extension = fileExtension(path)
if (isCodeFileType(type)) return isLinkCodeExtension(extension) ? 'code' : 'other'
if (extension === '') return 'other'
if (CODE_EXTENSIONS.has(extension)) return 'code'
if (IMAGE_EXTENSIONS.has(extension)) return 'image'
return DOCUMENT_SETS.some(set => set.has(extension)) ? 'document' : 'other'
switch (type) {
case 'code':
case 'html': return 'code'
case 'image': return 'image'
case 'excel':
case 'pdf':
case 'ppt':
case 'word': return 'document'
case 'markdown':
case 'other':
case 'video': return 'other'
}
}
const GlobeGlyph = ({ size, className }: IconProps) => (
@@ -0,0 +1,339 @@
/** Full-color code and configuration glyphs supplied by square-code-icons v1. */
export const CODE_FILE_TYPES = [
'angular',
'c',
'clojure',
'cmake',
'cpp',
'csharp',
'css',
'dart',
'docker',
'elixir',
'env',
'erlang',
'flutter',
'git',
'go',
'graphql',
'haskell',
'ini',
'java',
'javascript',
'json',
'kotlin',
'lua',
'makefile',
'node',
'objective-c',
'perl',
'php',
'powershell',
'protobuf',
'python',
'r',
'react',
'ruby',
'rust',
'scala',
'shell',
'solidity',
'sql',
'svelte',
'swift',
'toml',
'typescript',
'vue',
'wasm',
'xml',
'yaml',
'zig',
] as const
/** A code or configuration file category with its own full-color square glyph. */
export type CodeFileType = typeof CODE_FILE_TYPES[number]
/** Optional project files used by context-sensitive code-icon rules. */
export interface FileTypeProjectContext {
/** Text content keyed by project-relative or basename path. */
readonly files: Readonly<Record<string, string | undefined>>
}
const CODE_FILE_TYPE_SET = new Set<string>(CODE_FILE_TYPES)
const FILE_NAME_TYPES: Readonly<Record<string, CodeFileType>> = {
'.bash_profile': 'shell',
'.bashrc': 'shell',
'.env': 'env',
'.gitattributes': 'git',
'.gitconfig': 'git',
'.gitignore': 'git',
'.gitmodules': 'git',
'.mailmap': 'git',
'.profile': 'shell',
'.zprofile': 'shell',
'.zshrc': 'shell',
'bsdmakefile': 'makefile',
'cmakelists.txt': 'cmake',
'commit_editmsg': 'git',
'compose.yaml': 'docker',
'compose.yml': 'docker',
'docker-compose.yaml': 'docker',
'docker-compose.yml': 'docker',
'dockerfile': 'docker',
'gemfile': 'ruby',
'gnumakefile': 'makefile',
'guardfile': 'ruby',
'makefile': 'makefile',
'npm-shrinkwrap.json': 'node',
'package-lock.json': 'node',
'package.json': 'node',
'podfile': 'ruby',
'rakefile': 'ruby',
}
const FILE_NAME_PREFIX_TYPES: readonly (readonly [string, CodeFileType])[] = [
['dockerfile.', 'docker'],
['.env.', 'env'],
]
const FILE_NAME_SUFFIX_TYPES: readonly (readonly [string, CodeFileType])[] = [
['.component.ts', 'angular'],
['.component.html', 'angular'],
['.directive.ts', 'angular'],
['.service.ts', 'angular'],
['.module.ts', 'angular'],
['.pipe.ts', 'angular'],
['.guard.ts', 'angular'],
['.interceptor.ts', 'angular'],
['.dockerfile', 'docker'],
]
const EXTENSION_TYPES: Readonly<Record<string, CodeFileType>> = {
'bash': 'shell',
'c': 'c',
'c++': 'cpp',
'cc': 'cpp',
'cfg': 'ini',
'cjs': 'javascript',
'clj': 'clojure',
'cljc': 'clojure',
'cljs': 'clojure',
'cmake': 'cmake',
'cpp': 'cpp',
'cs': 'csharp',
'csh': 'shell',
'css': 'css',
'csx': 'csharp',
'cts': 'typescript',
'cxx': 'cpp',
'dart': 'dart',
'dtd': 'xml',
'edn': 'clojure',
'erl': 'erlang',
'es6': 'javascript',
'escript': 'erlang',
'ex': 'elixir',
'exs': 'elixir',
'fish': 'shell',
'gemspec': 'ruby',
'go': 'go',
'gql': 'graphql',
'graphql': 'graphql',
'h': 'c',
'h++': 'cpp',
'hh': 'cpp',
'hpp': 'cpp',
'hrl': 'erlang',
'hs': 'haskell',
'hxx': 'cpp',
'ini': 'ini',
'ipp': 'cpp',
'java': 'java',
'js': 'javascript',
'json': 'json',
'json5': 'json',
'jsonc': 'json',
'jsx': 'react',
'ksh': 'shell',
'kt': 'kotlin',
'kts': 'kotlin',
'lhs': 'haskell',
'lua': 'lua',
'm': 'objective-c',
'mak': 'makefile',
'mjs': 'javascript',
'mk': 'makefile',
'mm': 'objective-c',
'mts': 'typescript',
'node': 'node',
'pch': 'objective-c',
'php': 'php',
'php3': 'php',
'php4': 'php',
'php5': 'php',
'phps': 'php',
'phtml': 'php',
'pl': 'perl',
'plist': 'xml',
'pm': 'perl',
'pod': 'perl',
'proto': 'protobuf',
'ps1': 'powershell',
'psd1': 'powershell',
'psm1': 'powershell',
'py': 'python',
'pyi': 'python',
'pyw': 'python',
'pyx': 'python',
'r': 'r',
'rake': 'ruby',
'rb': 'ruby',
'rmd': 'r',
'rs': 'rust',
'sc': 'scala',
'scala': 'scala',
'sh': 'shell',
'sol': 'solidity',
'sql': 'sql',
'svelte': 'svelte',
'swift': 'swift',
't': 'perl',
'tcsh': 'shell',
'toml': 'toml',
'tpp': 'cpp',
'ts': 'typescript',
'tsx': 'react',
'vue': 'vue',
'wasm': 'wasm',
'wast': 'wasm',
'wat': 'wasm',
'xml': 'xml',
'xsd': 'xml',
'xsl': 'xml',
'xslt': 'xml',
'yaml': 'yaml',
'yml': 'yaml',
'zig': 'zig',
'zsh': 'shell',
}
const LINK_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',
])
function basename(path: string): string {
return path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1)
}
/**
* Test whether a resolved file type uses the full-color code-icon set.
* @param type - Resolved file-type string.
* @returns Whether the value is a detailed code-file type.
*/
export function isCodeFileType(type: string): type is CodeFileType {
return CODE_FILE_TYPE_SET.has(type)
}
/**
* Test whether an extension belonged to the established coarse LinkIcon code category.
* @param extension - Extension without a leading dot.
* @returns Whether clickable links keep the code glyph for this extension.
*/
export function isLinkCodeExtension(extension: string): boolean {
return LINK_CODE_EXTENSIONS.has(extension.toLowerCase())
}
/**
* Resolve the most specific code/configuration icon according to the supplied map priority.
* @param name - Lowercase basename.
* @param extension - Lowercase extension without the leading dot.
* @param context - Optional project-file snapshot for context-sensitive matches.
* @returns The detailed code type, or null when the traditional file classifier owns the path.
*/
export function classifyCodeFileType(
name: string,
extension: string,
context?: FileTypeProjectContext,
): CodeFileType | null {
const exact = FILE_NAME_TYPES[name]
if (exact !== undefined) return exact
for (const [prefix, type] of FILE_NAME_PREFIX_TYPES) {
if (name.startsWith(prefix)) return type
}
for (const [suffix, type] of FILE_NAME_SUFFIX_TYPES) {
if (name.endsWith(suffix)) return type
}
if (extension === 'dart' && context !== undefined) {
const pubspec = Object.entries(context.files).find(([path]) => basename(path).toLowerCase() === 'pubspec.yaml')?.[1]
if (pubspec?.includes('flutter:') === true) return 'flutter'
}
return EXTENSION_TYPES[extension] ?? null
}
@@ -1,59 +0,0 @@
/**
* 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()
}
+4 -2
View File
@@ -33,8 +33,10 @@ 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 { FileTypeIcon, classifyFileType, fileExtension } from './FileTypeIcon.tsx'
export type {
CodeFileType, FileType, FileTypeIconProps, FileTypeKind, FileTypeProjectContext,
} from './FileTypeIcon.tsx'
export { projectUserText } from './user-text.tsx'
export { Tooltip } from './Tooltip.tsx'
export type { TooltipSide } from './Tooltip.tsx'
@@ -0,0 +1,123 @@
// @vitest-environment jsdom
import { cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import {
classifyFileType, FileTypeIcon, type CodeFileType, type FileTypeProjectContext,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { CODE_FILE_TYPES } from '../src/code-file-types.ts'
afterEach(cleanup)
const flutter: FileTypeProjectContext = {
files: { 'config/pubspec.yaml': 'name: demo\ndependencies:\n flutter: sdk' },
}
describe('code-file classification', () => {
it.each([
['app.component.ts', 'angular'],
['file.c', 'c'],
['file.clj', 'clojure'],
['CMakeLists.txt', 'cmake'],
['file.cc', 'cpp'],
['file.cs', 'csharp'],
['file.css', 'css'],
['file.dart', 'dart'],
['Dockerfile', 'docker'],
['file.ex', 'elixir'],
['.env', 'env'],
['file.erl', 'erlang'],
['.gitignore', 'git'],
['file.go', 'go'],
['file.graphql', 'graphql'],
['file.hs', 'haskell'],
['file.ini', 'ini'],
['file.java', 'java'],
['file.js', 'javascript'],
['file.json', 'json'],
['file.kt', 'kotlin'],
['file.lua', 'lua'],
['Makefile', 'makefile'],
['package.json', 'node'],
['file.m', 'objective-c'],
['file.pl', 'perl'],
['file.php', 'php'],
['file.ps1', 'powershell'],
['file.proto', 'protobuf'],
['file.py', 'python'],
['file.r', 'r'],
['file.jsx', 'react'],
['Gemfile', 'ruby'],
['file.rs', 'rust'],
['file.scala', 'scala'],
['.bashrc', 'shell'],
['file.sol', 'solidity'],
['file.sql', 'sql'],
['file.svelte', 'svelte'],
['file.swift', 'swift'],
['file.toml', 'toml'],
['file.ts', 'typescript'],
['file.vue', 'vue'],
['file.wasm', 'wasm'],
['file.xml', 'xml'],
['file.yaml', 'yaml'],
['file.zig', 'zig'],
] as [string, CodeFileType][])('%s → %s', (path, type) => {
expect(classifyFileType(path)).toBe(type)
})
it('applies filename priority before generic extensions', () => {
expect(classifyFileType('package.json')).toBe('node')
expect(classifyFileType('docker-compose.yaml')).toBe('docker')
expect(classifyFileType('feature.component.ts')).toBe('angular')
expect(classifyFileType('Dockerfile.local')).toBe('docker')
expect(classifyFileType('.env.production')).toBe('env')
})
it('selects Flutter only when the supplied project snapshot identifies it', () => {
expect(classifyFileType('lib/main.dart')).toBe('dart')
expect(classifyFileType('lib/main.dart', { files: { 'pubspec.yaml': 'name: demo' } })).toBe('dart')
expect(classifyFileType('lib/main.dart', flutter)).toBe('flutter')
const view = render(<FileTypeIcon path="lib/main.dart" context={flutter} />)
const explicit = render(<FileTypeIcon kind="flutter" />)
expect(view.container.innerHTML).toBe(explicit.container.innerHTML)
})
it('keeps Markdown and SVG on the traditional file artwork', () => {
expect(classifyFileType('README.md')).toBe('markdown')
expect(classifyFileType('logo.svg')).toBe('image')
})
})
describe('full-color code-file artwork', () => {
it.each(CODE_FILE_TYPES)('%s renders its supplied square SVG', (type) => {
const { container } = render(<FileTypeIcon kind={type} />)
const svg = container.querySelector('svg')!
expect(svg.getAttribute('viewBox')).toBe('0 0 20 20')
expect(svg.getAttribute('width')).toBe('28')
expect(svg.getAttribute('aria-hidden')).toBe('true')
expect(container.innerHTML).toMatch(/#[0-9a-fA-F]{3,8}/)
expect(container.innerHTML).not.toContain('currentColor')
})
it('keeps every code category visually distinct', () => {
const artwork = CODE_FILE_TYPES.map((type) => {
const { container } = render(<FileTypeIcon kind={type} />)
return container.querySelector('svg')!.innerHTML
})
expect(new Set(artwork).size).toBe(CODE_FILE_TYPES.length)
})
it('uses instance-safe gradient ids and forwards sizing classes', () => {
const { container } = render(<><FileTypeIcon kind="elixir" /><FileTypeIcon kind="elixir" /></>)
const ids = [...container.querySelectorAll('linearGradient')].map(gradient => gradient.id)
expect(new Set(ids).size).toBe(2)
expect([...container.querySelectorAll('rect')].map(rect => rect.getAttribute('fill'))).toEqual(ids.map(id => `url(#${id})`))
const sized = render(<FileTypeIcon path="file.ts" size={16} className="x" />)
const svg = sized.container.querySelector('svg')!
expect(svg.getAttribute('width')).toBe('16')
expect(svg.getAttribute('height')).toBe('16')
expect(svg.classList.contains('x')).toBe(true)
})
})
@@ -0,0 +1,38 @@
/** FileTypeIcon's default per-category palette as CSS text. */
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import type { CodeFileType, FileType } from '@deepseek-ai/dsh-client-ui-primitives'
const css = readFileSync(fileURLToPath(new URL('../src/FileTypeIcon.module.css', import.meta.url)), 'utf8')
type TraditionalFileType = Exclude<FileType, CodeFileType>
const TYPE_COLORS: Readonly<Record<TraditionalFileType, string>> = {
code: 'var(--dsw-static-deepseek-500)',
excel: 'var(--dsw-static-green-500)',
folder: 'var(--dsw-static-amber-400)',
html: 'var(--dsw-static-deepseek-500)',
image: 'var(--dsh-file-type-violet)',
markdown: 'var(--dsw-static-deepseek-500)',
other: 'var(--dsw-static-neutral-bluish-300)',
pdf: 'var(--dsw-static-red-600)',
ppt: 'var(--dsw-static-amber-500)',
video: 'var(--dsh-file-type-violet)',
word: 'var(--dsw-static-deepseek-450)',
}
describe('FileTypeIcon.module.css', () => {
it.each(Object.entries(TYPE_COLORS) as [TraditionalFileType, string][])(
'%s has its own default color',
(type, color) => {
const rule = css.match(new RegExp(`\\.${type}\\s*\\{([^}]*)\\}`))?.[1]
expect(rule).toContain(`--dsh-file-type-default-color: ${color}`)
},
)
it('keeps one caller override and the supplied violet in named variables', () => {
expect(css).toContain('color: var(--dsh-file-type-icon-color, var(--dsh-file-type-default-color))')
expect(css).toContain('--dsh-file-type-violet: rgb(139, 118, 246)')
})
})
@@ -1,61 +1,133 @@
// @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'
import {
classifyFileType, fileExtension, FileTypeIcon, type FileTypeKind,
} from '@deepseek-ai/dsh-client-ui-primitives'
import css from '../src/FileTypeIcon.module.css'
afterEach(cleanup)
const KINDS: FileTypeKind[] = ['code', 'html', 'image', 'markdown', 'pdf', 'sheet', 'slides', 'document', 'other']
describe('fileExtension', () => {
it.each([
['src/README.md', 'md'],
['C:\\work\\REPORT.PDF', 'PDF'],
['.env', 'env'],
['archive.tar.gz', 'gz'],
['Makefile', ''],
['trailing.', ''],
])('%s → %s', (path, extension) => {
expect(fileExtension(path)).toBe(extension)
})
})
describe('classifyFileType', () => {
it.each([
['src/render.tsx', 'code'],
['site/index.HTML', 'html'],
['README.md', 'markdown'],
['data/export.csv', 'sheet'],
['book.xlsx', 'sheet'],
['src/index.TSX', 'react'],
['site/index.html', 'html'],
['data/export.CSV', 'code'],
['shots/hero.png', 'image'],
['notes/README.MDX', 'markdown'],
['report.pdf', 'pdf'],
['deck.pptx', 'slides'],
['C:\\work\\summary.docx', 'document'],
['deck.pptx', 'ppt'],
['clip.webm', 'video'],
['letter.docx', 'word'],
['budget.xlsx', 'excel'],
['Makefile', 'makefile'],
['README', 'markdown'],
['.env', 'env'],
['.hidden', 'other'],
['notes.unknownext', 'other'],
['Makefile', 'other'],
['extensionless', 'other'],
['archive.tar/.hidden', 'other'],
] as [string, FileTypeKind][])('%s → %s', (path, kind) => {
expect(classifyFileType(path)).toBe(kind)
] as [string, Exclude<FileTypeKind, 'folder'>][])('%s → %s', (path, type) => {
expect(classifyFileType(path)).toBe(type)
})
})
describe('FileTypeIcon', () => {
it.each(KINDS)('%s draws the shared sheet in its own colour, aria-hidden', (kind) => {
const { container } = render(<FileTypeIcon kind={kind} />)
const types: FileTypeKind[] = [
'code', 'excel', 'folder', 'html', 'image', 'markdown', 'other', 'pdf', 'ppt', 'video', 'word',
]
it.each(types)('%s renders a distinct aria-hidden svg without literal colors', (type) => {
const { container } = render(<FileTypeIcon kind={type} />)
const svg = container.querySelector('svg')!
expect(svg).not.toBeNull()
expect(svg.getAttribute('aria-hidden')).toBe('true')
expect(svg.getAttribute('width')).toBe('28')
expect(svg.querySelector('path')!.getAttribute('fill')).toMatch(/^#[0-9A-F]{6}$/)
expect(svg.classList.contains(css.icon!)).toBe(true)
expect(svg.classList.contains(css[type]!)).toBe(true)
const markup = container.innerHTML
expect(markup).not.toMatch(/#[0-9a-fA-F]{3,8}/)
const paints = [...container.querySelectorAll('[fill], [stroke]')].flatMap(node =>
['fill', 'stroke'].map(attribute => node.getAttribute(attribute)).filter(value => value !== null),
)
expect(paints).toContain('currentColor')
expect(paints.every(value => [
'currentColor', 'none', 'var(--dsw-static-neutral-00)', 'var(--dsw-static-neutral-400)',
].includes(value))).toBe(true)
})
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') ?? ''}`
it.each(types)('%s uses a solid sheet fill', (type) => {
const { container } = render(<FileTypeIcon kind={type} />)
const sheet = container.querySelector('svg > path')!
expect(sheet.getAttribute('fill')).toBe('currentColor')
expect(sheet.getAttribute('fill-opacity')).toBeNull()
})
it('uses white marks and fold for coloured sheets, with a darker grey fold for other', () => {
const coloured = render(<FileTypeIcon kind="pdf" />).container
expect(coloured.querySelector('[data-file-type-mark]')?.getAttribute('color'))
.toBe('var(--dsw-static-neutral-00)')
expect(coloured.querySelector('svg > path:nth-of-type(2)')?.getAttribute('fill'))
.toBe('var(--dsw-static-neutral-00)')
const other = render(<FileTypeIcon kind="other" />).container
expect(other.querySelector('svg > path:nth-of-type(2)')?.getAttribute('fill'))
.toBe('var(--dsw-static-neutral-400)')
})
it('draws one distinct glyph for every category', () => {
const paths = types.map((type) => {
const { container } = render(<FileTypeIcon kind={type} />)
return [...container.querySelectorAll('path')].map(path => path.getAttribute('d')).join('|')
})
expect(new Set(marks).size).toBe(KINDS.length)
expect(new Set(paths).size).toBe(types.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.each(['code', 'folder', 'html', 'image', 'video'] as const)(
'enlarges the %s center mark without scaling the file shell',
(type) => {
const { container } = render(<FileTypeIcon kind={type} />)
expect(container.querySelector('[data-file-type-mark]')?.getAttribute('transform')).toContain('scale(1.12)')
expect(container.querySelector('svg > path')?.getAttribute('transform')).toBeNull()
},
)
it.each(['excel', 'markdown', 'pdf', 'ppt', 'word'] as const)(
'gives the %s center mark the larger emphasis scale',
(type) => {
const { container } = render(<FileTypeIcon kind={type} />)
expect(container.querySelector('[data-file-type-mark]')?.getAttribute('transform')).toContain('scale(1.22)')
expect(container.querySelector('svg > path')?.getAttribute('transform')).toBeNull()
},
)
it('keeps the generic file glyph without an invented center mark', () => {
const { container } = render(<FileTypeIcon kind="other" />)
expect(container.querySelector('[data-file-type-mark]')).toBeNull()
})
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')
it('classifies the path by default and forwards size and className', () => {
const inferred = render(<FileTypeIcon path="report.pdf" />)
const explicit = render(<FileTypeIcon kind="pdf" />)
expect(inferred.container.innerHTML).toBe(explicit.container.innerHTML)
expect(inferred.container.querySelector('svg')!.getAttribute('width')).toBe('28')
const sized = render(<FileTypeIcon path="photo.png" size={20} className="x" />)
const svg = sized.container.querySelector('svg')!
expect(svg.getAttribute('width')).toBe('20')
expect(svg.getAttribute('height')).toBe('20')
expect(svg.classList.contains('x')).toBe(true)
})
})
@@ -3,7 +3,7 @@ import { cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import * as primitives from '@deepseek-ai/dsh-client-ui-primitives'
import {
IconAlarmClockOutline16, IconApiOutline14, IconArchiveOutline20, IconFolderClose16,
DocumentFileIcon, IconAlarmClockOutline16, IconApiOutline14, IconArchiveOutline20, IconFolderClose16,
IconGoalOutline16, IconSendOutline16,
} from '@deepseek-ai/dsh-client-ui-primitives'
@@ -70,6 +70,19 @@ describe('FishLogo', () => {
})
})
describe('DocumentFileIcon', () => {
it('renders the generic document artwork with instance-safe gradients and its class', () => {
const { container } = render(<><DocumentFileIcon className="document"/><DocumentFileIcon /></>)
const svgs = [...container.querySelectorAll('svg')]
const ids = [...container.querySelectorAll('linearGradient')].map(gradient => gradient.id)
expect(svgs).toHaveLength(2)
expect(svgs[0]?.classList.contains('document')).toBe(true)
expect(new Set(ids).size).toBe(2)
expect([...container.querySelectorAll('svg > path:first-child')].map(path => path.getAttribute('fill')))
.toEqual(ids.map(id => `url(#${id})`))
})
})
describe('BrandWordmark', () => {
it('can render the name artwork with or without its leading mark', () => {
const view = render(<primitives.BrandWordmark />)
@@ -12,9 +12,13 @@ describe('classifyLinkPath', () => {
['data/export.CSV', 'code'],
['shots/hero.png', 'image'],
['report.pdf', 'document'],
['budget.xlsx', 'document'],
['deck.pptx', 'document'],
['notes.md', 'other'],
['clip.mp4', 'other'],
['notes.unknownext', 'other'],
['Makefile', 'other'],
['README', 'other'],
['C:\\work\\summary.docx', 'document'],
['archive.tar/.hidden', 'other'],
] as [string, LinkIconKind][])('%s → %s', (path, kind) => {
@@ -21,8 +21,8 @@ describe('TextTitle', () => {
})
it('picks the sheet from the title\'s extension', () => {
const markdown = render(<TextTitle {...props('notes.md')} />).container.querySelector('path')?.getAttribute('fill')
const pdf = render(<TextTitle {...props('paper.pdf')} />).container.querySelector('path')?.getAttribute('fill')
const markdown = render(<TextTitle {...props('notes.md')} />).container.querySelector('svg')?.innerHTML
const pdf = render(<TextTitle {...props('paper.pdf')} />).container.querySelector('svg')?.innerHTML
expect(markdown).not.toBe(pdf)
})
})
@@ -53,6 +53,9 @@ describe('FilesBody', () => {
expect(path?.getAttribute('title')).toBe(ROOT)
expect([...path?.querySelectorAll('span > span') ?? []].map(span => span.textContent)).toEqual(['/work/', 'app'])
expect(names(view.container)).toEqual([`${ROOT}/src`, `${ROOT}/.env`, `${ROOT}/pipe`, `${ROOT}/README.md`])
const envIcon = view.container.querySelector(`[data-files-path="${ROOT}/.env"] svg`)?.innerHTML
const readmeIcon = view.container.querySelector(`[data-files-path="${ROOT}/README.md"] svg`)?.innerHTML
expect(envIcon).not.toBe(readmeIcon)
})
it('heads a separator-only root by the root itself, since it has no final segment', async () => {