Merge the file-icon API from #531 (feat/file-icons-api) into the rc.2 line
Brings in the plugin-side file-icon feature (issue #611): the public `registerFileIcon` API plus the resolver chain the tree, the editor tab and the external-plugin surface all read. Conflicts (three, all comment/list unions — resolved by union, verified green): - src/client/service.ts: main's "v0.19.0 REMOVED 'floatWindows'" note stays (the capability is gone and must not come back); the feature list gains 'fileIcons'. - src/client/Sidebar.tsx: one comment above `tabIconOf`; both sides' wording is kept (the editor-with-path special case and the descriptor fallback). - docs/external-plugin-guide.md: the `features` list comment keeps main's "additive only, the one exception being floatWindows" wording plus the new 'fileIcons' entry. Notes on what this merge does NOT bring in: #594 (feat/file-icon-theme) is stacked on this branch and lands next, but its colored-theme implementation (563-entry react-icons dataset in a 255 kB lazy chunk plus a `fileIconTheme` switch) is being replaced — DSH 0.1.5-rc.2 exports the official `FileTypeIcon`/`CodeFileIcon` artwork from ui-primitives, so the plugin does not need to ship its own color dataset. Gates on the merge result: pnpm typecheck clean, pnpm test 127 files / 1331 passed / 9 skipped.
This commit is contained in:
@@ -43,7 +43,7 @@
|
|||||||
|
|
||||||
## ✨ 功能一览
|
## ✨ 功能一览
|
||||||
|
|
||||||
- **🗂️ 文件工作台**:资源管理器(懒加载目录树;软链接按目标类型展示——目录软链接可展开、失效链接标红)+ CodeMirror 编辑器;图片 / Markdown(含 Mermaid 图表,strict 安全渲染 + 点击放大;README 级内嵌 HTML——徽章墙 / `<details>` 折叠 / 表格内联标签经 DOMPurify 消毒真实渲染;浮动目录大纲一键跳转)/ HTML / PDF
|
- **🗂️ 文件工作台**:资源管理器(懒加载目录树;软链接按目标类型展示——目录软链接可展开、失效链接标红;文件树与文件 tab 按扩展名显示图标——markdown / 图片 / PDF / 代码 / 配置 / 压缩包等各有 glyph,插件可经 `registerFileIcon` 注册自定义图标与目录图标)+ CodeMirror 编辑器;图片 / Markdown(含 Mermaid 图表,strict 安全渲染 + 点击放大;README 级内嵌 HTML——徽章墙 / `<details>` 折叠 / 表格内联标签经 DOMPurify 消毒真实渲染;浮动目录大纲一键跳转)/ HTML / PDF
|
||||||
- **🌐 内嵌浏览器**:多开网页 tab,后退 / 前进 / 刷新;内容运行在沙箱 iframe;外链默认按协议分流——HTTP 在侧边栏打开、HTTPS 走系统浏览器(设置页可分别调整)
|
- **🌐 内嵌浏览器**:多开网页 tab,后退 / 前进 / 刷新;内容运行在沙箱 iframe;外链默认按协议分流——HTTP 在侧边栏打开、HTTPS 走系统浏览器(设置页可分别调整)
|
||||||
- **💻 真实终端**:xterm.js + node-pty 真实 shell,断线重连回放;可选为模型注入 `terminal_*` 工具
|
- **💻 真实终端**:xterm.js + node-pty 真实 shell,断线重连回放;可选为模型注入 `terminal_*` 工具
|
||||||
- **📂 模型侧边栏打开(可选)**:全局设置开启后注入 `sidebar_open` 工具——模型可主动在侧边栏打开文件 / 文件夹(树以该目录为根)/ HTTP(S) 网页
|
- **📂 模型侧边栏打开(可选)**:全局设置开启后注入 `sidebar_open` 工具——模型可主动在侧边栏打开文件 / 文件夹(树以该目录为根)/ HTTP(S) 网页
|
||||||
@@ -328,6 +328,10 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar
|
|||||||
|
|
||||||
- 真机挂载冒烟门禁钉 0.1.5-alpha.1,e2e 增加「展开原生栏 → 经引导页逐个打开插件 tab 类型」的巡检;typecheck / lint / 单测 / 挂载车道全绿。
|
- 真机挂载冒烟门禁钉 0.1.5-alpha.1,e2e 增加「展开原生栏 → 经引导页逐个打开插件 tab 类型」的巡检;typecheck / lint / 单测 / 挂载车道全绿。
|
||||||
|
|
||||||
|
### v0.19.0(未发布)
|
||||||
|
|
||||||
|
- 🎨 **文件图标多样化 + 对外图标注册 API([file-icons.tsx](./src/client/file-icons.tsx))**:文件树与编辑器文件 tab 不再是单一 `VscFile`——markdown / 图片媒体 / PDF / JSON / 40+ 代码扩展 / 配置 / 数据库 / lock / 压缩包各有专属 glyph(VSCodicons 单色 `currentColor`,遵循皮肤契约),未知扩展回退通用图标。`ctx.betterSidebar` 新增 `registerFileIcon`(`features` 含 `'fileIcons'`):按扩展名注册自定义图标(彩色 ReactNode 亦可,颜色责任在注册方),`exts: []` 为全局默认(只兜内置 glyph 没认领的扩展,不吞掉内置多样性),保留扩展名 `'folder'` / `'folder-open'` 可换目录行图标;`fileIcon` / `folderIcon` 为权威解析器(完整回退链 + 逐工厂崩溃隔离),注册/注销即时生效。接入示例见[外部插件指南 §7](./docs/external-plugin-guide.md)。
|
||||||
|
|
||||||
### v0.18.1
|
### v0.18.1
|
||||||
|
|
||||||
> 📌 **正式版**(npm `latest`):DSH 基线不变(**0.1.2-rc.1+**,peer 下限 `^0.1.2-rc.1`)——本版是 v0.18.0 之后的增量发布:变更面板预览能力增强、文件树可写,以及五项修复。
|
> 📌 **正式版**(npm `latest`):DSH 基线不变(**0.1.2-rc.1+**,peer 下限 `^0.1.2-rc.1`)——本版是 v0.18.0 之后的增量发布:变更面板预览能力增强、文件树可写,以及五项修复。
|
||||||
@@ -589,7 +593,7 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar
|
|||||||
|
|
||||||
## 🔌 服务化扩展
|
## 🔌 服务化扩展
|
||||||
|
|
||||||
从 v0.4.0 起暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏页面与文件预览器(内置 8 tab + 6 viewer 亦通过同一服务注册)。v0.12.1 补齐基座能力(完整类型导出、能力探测、状态订阅、tab 角标、生命周期回调、定向打开、插件自有设置等)。
|
从 v0.4.0 起暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏页面与文件预览器(内置 8 tab + 6 viewer 亦通过同一服务注册)。v0.12.1 补齐基座能力(完整类型导出、能力探测、状态订阅、tab 角标、生命周期回调、定向打开、插件自有设置等)。v0.19.0 起新增文件图标注册:`registerFileIcon` 按扩展名(或保留的 `'folder'` / `'folder-open'` 目录扩展名、`exts: []` 全局默认)替换文件树与文件 tab 的图标,彩色 ReactNode 亦可——内置消费、注册即生效,无需自己接线。
|
||||||
|
|
||||||
完整接入文档(全字段、匹配算法、HMR 陷阱、声明式设置、版本探测、原生栏承载面与皮肤契约):**[`docs/external-plugin-guide.md`](./docs/external-plugin-guide.md)**;仓库开发规则(硬约束 / CI / 发版)见 [`AGENTS.md`](./AGENTS.md)。
|
完整接入文档(全字段、匹配算法、HMR 陷阱、声明式设置、版本探测、原生栏承载面与皮肤契约):**[`docs/external-plugin-guide.md`](./docs/external-plugin-guide.md)**;仓库开发规则(硬约束 / CI / 发版)见 [`AGENTS.md`](./AGENTS.md)。
|
||||||
|
|
||||||
|
|||||||
@@ -583,10 +583,28 @@ interface BetterSidebarService {
|
|||||||
registerTab(descriptor: TabDescriptor): () => void
|
registerTab(descriptor: TabDescriptor): () => void
|
||||||
/** 注册文件预览器;返回 disposer */
|
/** 注册文件预览器;返回 disposer */
|
||||||
registerFileViewer(descriptor: FileViewerDescriptor): () => void
|
registerFileViewer(descriptor: FileViewerDescriptor): () => void
|
||||||
|
/** 注册自定义文件树/文件 tab 图标(v0.19.0+,features 含 'fileIcons');返回 disposer */
|
||||||
|
registerFileIcon(descriptor: FileIconDescriptor): () => void
|
||||||
/** 当前已注册的 tab 描述符快照(同步,供 useSyncExternalStore 用;含被设置页禁用的类型) */
|
/** 当前已注册的 tab 描述符快照(同步,供 useSyncExternalStore 用;含被设置页禁用的类型) */
|
||||||
getTabs(): readonly TabDescriptor[]
|
getTabs(): readonly TabDescriptor[]
|
||||||
/** 当前已注册的 file viewer 描述符快照(含被设置页禁用的 viewer) */
|
/** 当前已注册的 file viewer 描述符快照(含被设置页禁用的 viewer) */
|
||||||
getFileViewers(): readonly FileViewerDescriptor[]
|
getFileViewers(): readonly FileViewerDescriptor[]
|
||||||
|
/** 当前已注册的文件图标描述符快照(v0.19.0+) */
|
||||||
|
getFileIcons(): readonly FileIconDescriptor[]
|
||||||
|
/** 按 path 匹配**具体扩展名**注册(priority 降序、注册序;不查 catch-all
|
||||||
|
* 与 folder 保留值)。消费方一般直接用 fileIcon/folderIcon 全链解析器。 */
|
||||||
|
matchFileIcon(path: string): FileIconDescriptor | undefined
|
||||||
|
/** 匹配目录行注册('folder'/'folder-open' 保留扩展名;priority 降序、注册序;
|
||||||
|
* 返回 undefined = 回退内置 VscFolder/VscFolderOpened) */
|
||||||
|
matchFolderIcon(open: boolean): FileIconDescriptor | undefined
|
||||||
|
/** 文件图标权威解析器(v0.19.0+),完整回退链:
|
||||||
|
* ① 具体扩展名注册 → ② 内置 glyph(md/媒体/pdf/json/代码/配置/数据库/lock/压缩包)
|
||||||
|
* → ③ 最优 catch-all 注册(exts: [],即全局默认)→ ④ 通用 VscFile。
|
||||||
|
* 任一注册工厂抛错都会被吞(console.error 后跳下一级),永远返回有效 ReactNode。 */
|
||||||
|
fileIcon(path: string, size: number): ReactNode
|
||||||
|
/** 目录图标解析器:注册的 'folder'(闭合)/'folder-open'(展开)图标 →
|
||||||
|
* 内置 VscFolder/VscFolderOpened;path 为目录自身路径(主题可按目录变化)。 */
|
||||||
|
folderIcon(path: string, open: boolean, size: number): ReactNode
|
||||||
/** 按 id 查 tab 描述符 */
|
/** 按 id 查 tab 描述符 */
|
||||||
getTab(id: string): TabDescriptor | undefined
|
getTab(id: string): TabDescriptor | undefined
|
||||||
/** 某个 tab 类型是否在 Side card 设置中启用(v0.4.1+;缺省 = 启用) */
|
/** 某个 tab 类型是否在 Side card 设置中启用(v0.4.1+;缺省 = 启用) */
|
||||||
@@ -621,7 +639,8 @@ interface BetterSidebarService {
|
|||||||
/** 能力清单(只增不删,唯一例外:v0.19.0 删除了 'floatWindows'):
|
/** 能力清单(只增不删,唯一例外:v0.19.0 删除了 'floatWindows'):
|
||||||
* 'badge' | 'tabLifecycle' | 'updateTab' | 'openFile' | 'targetedOpen' |
|
* 'badge' | 'tabLifecycle' | 'updateTab' | 'openFile' | 'targetedOpen' |
|
||||||
* 'stateSubscription' | 'tabMeta' | 'pluginSettings' | 'urlTarget' |
|
* 'stateSubscription' | 'tabMeta' | 'pluginSettings' | 'urlTarget' |
|
||||||
* 'settingSelect' ——用 `features.includes('xxx')` 按能力 gate。 */
|
* 'settingSelect' | 'fileIcons'
|
||||||
|
* ——用 `features.includes('xxx')` 按能力 gate。 */
|
||||||
readonly features: readonly string[]
|
readonly features: readonly string[]
|
||||||
/** 当前快照:激活 sessionId + 其状态(面板几何/打开的 tabs/展开集)+ prefs。
|
/** 当前快照:激活 sessionId + 其状态(面板几何/打开的 tabs/展开集)+ prefs。
|
||||||
* session 未激活时 state/sessionId 为 undefined。 */
|
* session 未激活时 state/sessionId 为 undefined。 */
|
||||||
@@ -652,8 +671,65 @@ interface OpenTabSeed {
|
|||||||
* undefined = 不改,null = 显式清除 */
|
* undefined = 不改,null = 显式清除 */
|
||||||
meta?: unknown
|
meta?: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 文件图标注册描述符(v0.19.0+,features 含 'fileIcons')。 */
|
||||||
|
interface FileIconDescriptor {
|
||||||
|
/** 唯一 id(如 'my-plugin:icons') */
|
||||||
|
id: string
|
||||||
|
/** 小写扩展名、不带前导点(如 ['csv','tsv'])。两个**保留值**认领目录行
|
||||||
|
* 而非文件扩展名:'folder'(闭合目录)、'folder-open'(展开目录)——
|
||||||
|
* 它们不会匹配真实文件(名为 x.folder 的文件不受影响)。
|
||||||
|
* [] = catch-all 全局默认:只兜内置 glyph 没认领的扩展名(注册的具体
|
||||||
|
* 扩展名与内置 glyph 永远优先于它)。 */
|
||||||
|
exts: readonly string[]
|
||||||
|
/** priority 高者胜,缺省 0(同级按注册先后) */
|
||||||
|
priority?: number
|
||||||
|
/** 尺寸感知的图标工厂(文件树/文件 tab 当前以 size=14 渲染)。
|
||||||
|
* 与内置图标(currentColor 单色,遵循皮肤契约)不同,注册图标可以是
|
||||||
|
* 任意 ReactNode——包括彩色图标;颜色在皮肤间的表现由注册方自行负责。 */
|
||||||
|
icon: (path: string, size: number) => ReactNode
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**图标注册示例**(v0.19.0+;一次注册可同时覆盖具体扩展名、目录与全局默认):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
if (ctx.betterSidebar.features.includes('fileIcons')) {
|
||||||
|
ctx.effect(() =>
|
||||||
|
ctx.betterSidebar.registerFileIcon({
|
||||||
|
id: 'my-plugin:icons',
|
||||||
|
exts: ['csv', 'tsv'],
|
||||||
|
icon: (path, size) => <MyCsvIcon size={size} />, // 彩色也可以
|
||||||
|
})
|
||||||
|
)
|
||||||
|
ctx.effect(() =>
|
||||||
|
ctx.betterSidebar.registerFileIcon({
|
||||||
|
id: 'my-plugin:folders',
|
||||||
|
exts: ['folder'], // 闭合目录行;['folder-open'] 认领展开行——
|
||||||
|
icon: (path, size) => <MyFolderIcon size={size} />, // 开/合图标不同就注册两条
|
||||||
|
})
|
||||||
|
)
|
||||||
|
ctx.effect(() =>
|
||||||
|
ctx.betterSidebar.registerFileIcon({
|
||||||
|
id: 'my-plugin:default', // 全局默认:只兜内置 glyph 没认领的文件
|
||||||
|
exts: [],
|
||||||
|
icon: (path, size) => <MyGenericFileIcon size={size} />,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**消费表面与回退链**(由本插件内置消费,插件无需自己接线):
|
||||||
|
|
||||||
|
- 文件树文件行 / 编辑器文件 tab(每个文件独立窗口):`fileIcon(path, size)`
|
||||||
|
——具体扩展名注册 → 内置 glyph(markdown/媒体/pdf/json/代码/配置/数据库/lock/压缩包)
|
||||||
|
→ catch-all 全局默认 → 通用 `VscFile`。
|
||||||
|
- 文件树目录行(含根行):`folderIcon(path, open, size)`——`'folder'`/`'folder-open'`
|
||||||
|
注册 → 内置 `VscFolder`/`VscFolderOpened`。
|
||||||
|
|
||||||
|
注册/注销即时生效(文件树与 tab 栏订阅注册表变化自动重渲染);图标工厂抛错会被吞掉
|
||||||
|
(console.error 后跳到回退链下一级),不会空白行。
|
||||||
|
|
||||||
**版本与能力探测**(v0.12.0+):消费插件先查能力再使用新 API,老版本(或旧 DSH)下优雅降级:
|
**版本与能力探测**(v0.12.0+):消费插件先查能力再使用新 API,老版本(或旧 DSH)下优雅降级:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
|
|||||||
@@ -401,6 +401,7 @@ export function EditorHost(props: {
|
|||||||
onReferenceFile={onReferenceFile}
|
onReferenceFile={onReferenceFile}
|
||||||
onPathRenamed={onPathRenamed}
|
onPathRenamed={onPathRenamed}
|
||||||
onPathDeleted={onPathDeleted}
|
onPathDeleted={onPathDeleted}
|
||||||
|
service={ctx.get('betterSidebar')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -525,6 +526,7 @@ export function EditorHost(props: {
|
|||||||
onReferenceFile={onReferenceFile}
|
onReferenceFile={onReferenceFile}
|
||||||
onPathRenamed={onPathRenamed}
|
onPathRenamed={onPathRenamed}
|
||||||
onPathDeleted={onPathDeleted}
|
onPathDeleted={onPathDeleted}
|
||||||
|
service={ctx.get('betterSidebar')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+41
-7
@@ -28,15 +28,17 @@ import {
|
|||||||
IconEditOutline16, IconLinkOutline16, IconTrashOutline16, Menu, Modal, type MenuEntry, type MenuItem, writeClipboard,
|
IconEditOutline16, IconLinkOutline16, IconTrashOutline16, Menu, Modal, type MenuEntry, type MenuItem, writeClipboard,
|
||||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||||
import { SiCursor, SiZedindustries } from 'react-icons/si'
|
import { SiCursor, SiZedindustries } from 'react-icons/si'
|
||||||
import { VscFile, VscFolder, VscFolderOpened, VscLinkExternal, VscPin, VscPinned } from 'react-icons/vsc'
|
import { VscFolderOpened, VscLinkExternal, VscPin, VscPinned } from 'react-icons/vsc'
|
||||||
import { api, downloadUrl, isOutsideWorkspaceMessage, type FsEntry } from './api.ts'
|
import { api, downloadUrl, isOutsideWorkspaceMessage, type FsEntry } from './api.ts'
|
||||||
import { FenceErrorNotice } from './FenceErrorNotice.tsx'
|
import { FenceErrorNotice } from './FenceErrorNotice.tsx'
|
||||||
|
import { builtinFileIcon, builtinFolderIcon } from './file-icons.tsx'
|
||||||
import { IconUploadOutline16, IconVscode16 } from './icons.tsx'
|
import { IconUploadOutline16, IconVscode16 } from './icons.tsx'
|
||||||
import { isImeComposition } from './ime-guard.ts'
|
import { isImeComposition } from './ime-guard.ts'
|
||||||
import { useSubmenuFlip } from './menu-flip.ts'
|
import { useSubmenuFlip } from './menu-flip.ts'
|
||||||
import type { OpenWithTarget } from './open-with.ts'
|
import type { OpenWithTarget } from './open-with.ts'
|
||||||
import { relativeTo } from './paths.ts'
|
import { relativeTo } from './paths.ts'
|
||||||
import { t } from './locales.ts'
|
import { t } from './locales.ts'
|
||||||
|
import type { BetterSidebarService } from './service.ts'
|
||||||
import type { SidebarStore } from './state.ts'
|
import type { SidebarStore } from './state.ts'
|
||||||
import { uploadItemsFromDrop, uploadItemsFromFiles, type UploadItem } from './upload.ts'
|
import { uploadItemsFromDrop, uploadItemsFromFiles, type UploadItem } from './upload.ts'
|
||||||
import css from './sidebar.module.css'
|
import css from './sidebar.module.css'
|
||||||
@@ -145,9 +147,41 @@ export function FileTree(props: {
|
|||||||
onUploadRequest: (dir: string, items: UploadItem[]) => void
|
onUploadRequest: (dir: string, items: UploadItem[]) => void
|
||||||
/** True while an upload is in flight (drops are ignored). */
|
/** True while an upload is in flight (drops are ignored). */
|
||||||
busy: boolean
|
busy: boolean
|
||||||
|
/**
|
||||||
|
* The sidebar registry service: when present, externally registered file
|
||||||
|
* icons (`registerFileIcon`) outrank the built-in glyph map on file rows.
|
||||||
|
* Absent → the built-ins alone (the host always passes it today).
|
||||||
|
*/
|
||||||
|
service?: BetterSidebarService
|
||||||
}) {
|
}) {
|
||||||
const { sessionId, cwd, store, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, onPathRenamed, onPathDeleted, refreshTick, onUploadRequest, busy } = props
|
const { sessionId, cwd, store, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, onPathRenamed, onPathDeleted, refreshTick, onUploadRequest, busy, service } = props
|
||||||
const [data, setData] = useState<Record<string, LevelData>>({})
|
const [data, setData] = useState<Record<string, LevelData>>({})
|
||||||
|
/**
|
||||||
|
* Registry revision for the file-icon feature: bumps on ANY registry
|
||||||
|
* change (register/dispose of tabs, viewers, or icons — one listener
|
||||||
|
* set) so rows re-resolve their icons. The value itself is unread; the
|
||||||
|
* state bump IS the re-render trigger.
|
||||||
|
*/
|
||||||
|
const [, setIconsVersion] = useState(0)
|
||||||
|
useEffect(
|
||||||
|
() => service?.subscribe(() => { setIconsVersion(version => version + 1) }),
|
||||||
|
[service],
|
||||||
|
)
|
||||||
|
/**
|
||||||
|
* One file row's leading glyph. The service resolver owns the whole
|
||||||
|
* chain (registered specific ext → built-in glyph map → registered
|
||||||
|
* global default → stock VscFile, with per-factory crash isolation);
|
||||||
|
* without a service the built-in map alone applies.
|
||||||
|
*/
|
||||||
|
const fileRowIcon = (path: string): ReactNode =>
|
||||||
|
service !== undefined ? service.fileIcon(path, 14) : builtinFileIcon(path, 14)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One directory row's leading glyph: the registered `'folder'` /
|
||||||
|
* `'folder-open'` icon when present, else the built-in VSCodicons glyphs.
|
||||||
|
*/
|
||||||
|
const dirRowIcon = (path: string, open: boolean): ReactNode =>
|
||||||
|
service !== undefined ? service.folderIcon(path, open, 14) : builtinFolderIcon(open, 14)
|
||||||
const dataRef = useRef(data)
|
const dataRef = useRef(data)
|
||||||
/** The row whose path was just copied ("copied" label replaces its button). */
|
/** The row whose path was just copied ("copied" label replaces its button). */
|
||||||
const [copiedPath, setCopiedPath] = useState<string | null>(null)
|
const [copiedPath, setCopiedPath] = useState<string | null>(null)
|
||||||
@@ -546,8 +580,8 @@ export function FileTree(props: {
|
|||||||
style={{ paddingLeft: depth * 22 + 6 }}
|
style={{ paddingLeft: depth * 22 + 6 }}
|
||||||
>
|
>
|
||||||
{entry.isDir
|
{entry.isDir
|
||||||
? (expandedSet.has(entry.path) ? <VscFolderOpened size={14} /> : <VscFolder size={14} />)
|
? dirRowIcon(entry.path, expandedSet.has(entry.path))
|
||||||
: <VscFile size={14} />}
|
: fileRowIcon(entry.path)}
|
||||||
<input
|
<input
|
||||||
ref={renameInputRef}
|
ref={renameInputRef}
|
||||||
className={css.explorerRenameInput}
|
className={css.explorerRenameInput}
|
||||||
@@ -625,7 +659,7 @@ export function FileTree(props: {
|
|||||||
onDrop={(event) => { handleDirDrop(event, entry.path) }}
|
onDrop={(event) => { handleDirDrop(event, entry.path) }}
|
||||||
onContextMenu={(event) => { openRowMenu(event, entry.path, true) }}
|
onContextMenu={(event) => { openRowMenu(event, entry.path, true) }}
|
||||||
>
|
>
|
||||||
{isOpen ? <VscFolderOpened size={14} /> : <VscFolder size={14} />}
|
{dirRowIcon(entry.path, isOpen)}
|
||||||
<span className={css.explorerName}>{entry.name}</span>
|
<span className={css.explorerName}>{entry.name}</span>
|
||||||
{entry.isSymlink && <IconLinkOutline16 size={12} className={css.explorerSymlink} />}
|
{entry.isSymlink && <IconLinkOutline16 size={12} className={css.explorerSymlink} />}
|
||||||
{rowActions(entry)}
|
{rowActions(entry)}
|
||||||
@@ -658,7 +692,7 @@ export function FileTree(props: {
|
|||||||
onDrop={(event) => { handleFileDrop(event, entry.path) }}
|
onDrop={(event) => { handleFileDrop(event, entry.path) }}
|
||||||
onContextMenu={(event) => { openRowMenu(event, entry.path, false) }}
|
onContextMenu={(event) => { openRowMenu(event, entry.path, false) }}
|
||||||
>
|
>
|
||||||
<VscFile size={14} />
|
{fileRowIcon(entry.path)}
|
||||||
<span className={css.explorerName}>{entry.name}</span>
|
<span className={css.explorerName}>{entry.name}</span>
|
||||||
{entry.isSymlink && <IconLinkOutline16 size={12} className={css.explorerSymlink} />}
|
{entry.isSymlink && <IconLinkOutline16 size={12} className={css.explorerSymlink} />}
|
||||||
{rowActions(entry)}
|
{rowActions(entry)}
|
||||||
@@ -703,7 +737,7 @@ export function FileTree(props: {
|
|||||||
onDrop={(event) => { handleDirDrop(event, root) }}
|
onDrop={(event) => { handleDirDrop(event, root) }}
|
||||||
onContextMenu={(event) => { openRowMenu(event, root, true) }}
|
onContextMenu={(event) => { openRowMenu(event, root, true) }}
|
||||||
>
|
>
|
||||||
<VscFolderOpened size={14} />
|
{dirRowIcon(root, true)}
|
||||||
<span className={css.explorerName}>{baseName(root)}</span>
|
<span className={css.explorerName}>{baseName(root)}</span>
|
||||||
{copiedPath === root
|
{copiedPath === root
|
||||||
? <span className={css.explorerCopied}>{t('copied')}</span>
|
? <span className={css.explorerCopied}>{t('copied')}</span>
|
||||||
|
|||||||
+10
-1
@@ -628,8 +628,17 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
|||||||
service.openTab({ type: optionId, title, target: 'bottom' }, { sessionId, cwd })
|
service.openTab({ type: optionId, title, target: 'bottom' }, { sessionId, cwd })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The tab icon from the tab-type registry. */
|
/**
|
||||||
|
* The tab icon from the tab-type registry. An editor tab WITH a file path
|
||||||
|
* (the per-path windows of split mode — `meta.dir` marks folder windows,
|
||||||
|
* which keep the folder glyph) shows the same file icon the tree row shows
|
||||||
|
* (`fileIcon`, feature `fileIcons`); every other tab uses its tab-type
|
||||||
|
* descriptor icon.
|
||||||
|
*/
|
||||||
const tabIconOf = (tab: SidebarTab): ReactNode => {
|
const tabIconOf = (tab: SidebarTab): ReactNode => {
|
||||||
|
if (tab.type === 'editor' && tab.path !== undefined && (tab.meta as { dir?: boolean } | undefined)?.dir !== true) {
|
||||||
|
return ctx.get('betterSidebar')?.fileIcon(tab.path, 14) ?? null
|
||||||
|
}
|
||||||
const descriptor = ctx.get('betterSidebar')?.getTab(tab.type)
|
const descriptor = ctx.get('betterSidebar')?.getTab(tab.type)
|
||||||
if (descriptor === undefined) return null
|
if (descriptor === undefined) return null
|
||||||
return typeof descriptor.icon === 'function' ? descriptor.icon(14) : descriptor.icon
|
return typeof descriptor.icon === 'function' ? descriptor.icon(14) : descriptor.icon
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { useEffect, useRef, useState, type InputHTMLAttributes } from 'react'
|
|||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { IconFolderOpen16, IconRefreshOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
import { IconFolderOpen16, IconRefreshOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||||
import { api } from './api.ts'
|
import { api } from './api.ts'
|
||||||
|
import type { BetterSidebarService } from './service.ts'
|
||||||
import type { SidebarStore } from './state.ts'
|
import type { SidebarStore } from './state.ts'
|
||||||
import { FileTree } from './FileTree.tsx'
|
import { FileTree } from './FileTree.tsx'
|
||||||
import { IconUploadOutline16 } from './icons.tsx'
|
import { IconUploadOutline16 } from './icons.tsx'
|
||||||
@@ -70,8 +71,10 @@ export function TreePanel(props: {
|
|||||||
/** Full-window presentation: the panel fills its host instead of docking
|
/** Full-window presentation: the panel fills its host instead of docking
|
||||||
* at a fixed width. */
|
* at a fixed width. */
|
||||||
full?: boolean
|
full?: boolean
|
||||||
|
/** The sidebar registry service (file-icon registrations; passed through to FileTree). */
|
||||||
|
service?: BetterSidebarService
|
||||||
}) {
|
}) {
|
||||||
const { sessionId, cwd, store, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, onPathRenamed, onPathDeleted, full } = props
|
const { sessionId, cwd, store, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, onPathRenamed, onPathDeleted, full, service } = props
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const [results, setResults] = useState<{ matches: string[]; truncated: boolean } | null>(null)
|
const [results, setResults] = useState<{ matches: string[]; truncated: boolean } | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@@ -261,6 +264,7 @@ export function TreePanel(props: {
|
|||||||
refreshTick={refreshTick}
|
refreshTick={refreshTick}
|
||||||
onUploadRequest={startUpload}
|
onUploadRequest={startUpload}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
|
service={service}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className={css.explorerBody}>
|
<div className={css.explorerBody}>
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* Built-in per-extension file glyphs for the file tree (feature: file icons).
|
||||||
|
*
|
||||||
|
* All glyphs are VSCodicons consumed as `currentColor` monochrome — the same
|
||||||
|
* skin contract as every other icon this plugin draws (visual values ride the
|
||||||
|
* `--dsw-alias-*` tokens, never hardcoded colors). External plugins that want
|
||||||
|
* colorful icons register their own `FileIconDescriptor`s through
|
||||||
|
* `ctx.betterSidebar.registerFileIcon` (they own their colors); this map is
|
||||||
|
* only the fallback nobody claimed.
|
||||||
|
*/
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import {
|
||||||
|
VscDatabase, VscFile, VscFileCode, VscFileMedia, VscFilePdf, VscFileZip,
|
||||||
|
VscFolder, VscFolderOpened, VscJson, VscLock, VscMarkdown, VscSettings,
|
||||||
|
} from 'react-icons/vsc'
|
||||||
|
import { extOf } from './paths.ts'
|
||||||
|
|
||||||
|
/** One glyph shared by a group of extensions. */
|
||||||
|
type GlyphFactory = (size: number) => ReactNode
|
||||||
|
|
||||||
|
/** The generic file glyph — the fallback for every unclaimed extension. */
|
||||||
|
export function fallbackFileIcon(size: number): ReactNode {
|
||||||
|
return <VscFile size={size} />
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The built-in directory glyphs (what a folder row shows unregistered). */
|
||||||
|
export function builtinFolderIcon(open: boolean, size: number): ReactNode {
|
||||||
|
return open ? <VscFolderOpened size={size} /> : <VscFolder size={size} />
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One glyph shared by a group of extensions. */
|
||||||
|
type GlyphGroup = [readonly string[], GlyphFactory]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extension → glyph groups. Extensions are lowercase without the leading dot,
|
||||||
|
* matched case-insensitively by the caller's normalization. Group order is
|
||||||
|
* documentation only: the flattened lookup below is a Map, so every extension
|
||||||
|
* key must stay unique (a duplicate would be last-write-wins). The reserved
|
||||||
|
* folder values (`'folder'`/`'folder-open'`) never appear here — directories
|
||||||
|
* resolve through `builtinFolderIcon`.
|
||||||
|
*/
|
||||||
|
const GROUPS: readonly GlyphGroup[] = [
|
||||||
|
[['md', 'markdown', 'mdx'], (size) => <VscMarkdown size={size} />],
|
||||||
|
[['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif', 'tiff'], (size) => <VscFileMedia size={size} />],
|
||||||
|
[['pdf'], (size) => <VscFilePdf size={size} />],
|
||||||
|
[['json', 'jsonc', 'json5'], (size) => <VscJson size={size} />],
|
||||||
|
[['html', 'htm', 'xhtml', 'xml', 'jsx', 'tsx'], (size) => <VscFileCode size={size} />],
|
||||||
|
[['css', 'scss', 'sass', 'less', 'styl'], (size) => <VscFileCode size={size} />],
|
||||||
|
[['js', 'mjs', 'cjs', 'ts', 'mts', 'cts', 'py', 'rs', 'go', 'java', 'kt', 'kts', 'swift', 'rb', 'php', 'c', 'h', 'cpp', 'hpp', 'cs', 'm', 'mm', 'scala', 'sh', 'bash', 'zsh', 'fish', 'lua', 'pl', 'r', 'jl', 'dart', 'ex', 'exs', 'erl', 'hs', 'clj', 'cljs', 'vb', 'fs'], (size) => <VscFileCode size={size} />],
|
||||||
|
[['yml', 'yaml', 'toml', 'ini', 'cfg', 'conf', 'properties', 'editorconfig'], (size) => <VscSettings size={size} />],
|
||||||
|
[['sql', 'db', 'sqlite', 'sqlite3'], (size) => <VscDatabase size={size} />],
|
||||||
|
[['lock'], (size) => <VscLock size={size} />],
|
||||||
|
[['zip', 'tar', 'gz', 'tgz', 'bz2', 'xz', '7z', 'rar'], (size) => <VscFileZip size={size} />],
|
||||||
|
]
|
||||||
|
|
||||||
|
const LOOKUP: ReadonlyMap<string, GlyphFactory> = new Map(
|
||||||
|
GROUPS.flatMap(([exts, glyph]) => exts.map((ext) => [ext, glyph] as const)),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The built-in glyph FACTORY claiming a path's extension, or undefined when
|
||||||
|
* the extension is unclaimed (the caller decides the fallback — the generic
|
||||||
|
* `VscFile` or a registered catch-all). Exposed as a separate function so
|
||||||
|
* the service's resolver can distinguish "builtin claims it" from "builtin
|
||||||
|
* is just its own fallback" without comparing ReactNodes by reference.
|
||||||
|
*/
|
||||||
|
export function builtinFileIconOf(path: string): GlyphFactory | undefined {
|
||||||
|
return LOOKUP.get(extOf(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The built-in glyph for a path: its extension's group glyph, or the generic
|
||||||
|
* `VscFile` when nothing claims it. Pure and synchronous — callers run this
|
||||||
|
* on every render, so it stays a Map lookup. The no-service fallback path
|
||||||
|
* for surfaces without the registry (and the last-but-one link of the
|
||||||
|
* service's `fileIcon` chain).
|
||||||
|
*/
|
||||||
|
export function builtinFileIcon(path: string, size: number): ReactNode {
|
||||||
|
const glyph = LOOKUP.get(extOf(path))
|
||||||
|
return glyph === undefined ? fallbackFileIcon(size) : glyph(size)
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
type SidebarSnapshot, type SidebarState, type SidebarStore, type SidebarTab, type TabType,
|
type SidebarSnapshot, type SidebarState, type SidebarStore, type SidebarTab, type TabType,
|
||||||
} from './state.ts'
|
} from './state.ts'
|
||||||
import { extOf } from './paths.ts'
|
import { extOf } from './paths.ts'
|
||||||
|
import { builtinFileIconOf, builtinFolderIcon, fallbackFileIcon } from './file-icons.tsx'
|
||||||
import type { SessionScope } from './api.ts'
|
import type { SessionScope } from './api.ts'
|
||||||
import type { SidebarPrefs } from '../prefs-shared.ts'
|
import type { SidebarPrefs } from '../prefs-shared.ts'
|
||||||
|
|
||||||
@@ -334,6 +335,41 @@ export interface FileViewerDescriptor {
|
|||||||
component: (props: FileViewerProps) => ReactNode
|
component: (props: FileViewerProps) => ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes one external file-icon registration (feature `fileIcons`).
|
||||||
|
* Registrations override the built-in per-extension glyph map for their
|
||||||
|
* extensions; unlike the built-ins (monochrome `currentColor` per the skin
|
||||||
|
* contract), a registration's icon may be ANY ReactNode — colored included —
|
||||||
|
* and the registering plugin owns how its colors behave across skins.
|
||||||
|
*/
|
||||||
|
export interface FileIconDescriptor {
|
||||||
|
/** Unique id (`'my-plugin:icons'`). */
|
||||||
|
id: string
|
||||||
|
/**
|
||||||
|
* Lowercase extensions without leading dot (`['csv','tsv']`). `[]` = the
|
||||||
|
* global default (catch-all): it only claims files the built-in glyph map
|
||||||
|
* does not cover — registered specifics and built-in glyphs always outrank
|
||||||
|
* it. Two values are RESERVED for directory rows (never matched against
|
||||||
|
* real file extensions): `'folder'` (a closed directory) and
|
||||||
|
* `'folder-open'` (an expanded directory) — see `FOLDER_EXT`.
|
||||||
|
*/
|
||||||
|
exts: readonly string[]
|
||||||
|
/** Higher wins; default 0. Registered icons always outrank the built-in map. */
|
||||||
|
priority?: number
|
||||||
|
/** Size-aware icon factory (the tree and file tabs render at 14 today). */
|
||||||
|
icon: (path: string, size: number) => ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reserved `exts` values that claim DIRECTORY rows instead of file
|
||||||
|
* extensions: `'folder'` matches a closed directory, `'folder-open'` an
|
||||||
|
* expanded one (`folderIcon(path, open)` resolves them). They are filtered out of
|
||||||
|
* real-extension matching, so a file literally named `x.folder` is NOT
|
||||||
|
* claimed by a folder registration.
|
||||||
|
*/
|
||||||
|
export const FOLDER_EXT = 'folder' as const
|
||||||
|
export const FOLDER_OPEN_EXT = 'folder-open' as const
|
||||||
|
|
||||||
/** One `openTab` request. */
|
/** One `openTab` request. */
|
||||||
export interface OpenTabSeed {
|
export interface OpenTabSeed {
|
||||||
type: string
|
type: string
|
||||||
@@ -408,8 +444,43 @@ export interface SidebarSurface {
|
|||||||
export interface BetterSidebarService {
|
export interface BetterSidebarService {
|
||||||
registerTab(descriptor: TabDescriptor): () => void
|
registerTab(descriptor: TabDescriptor): () => void
|
||||||
registerFileViewer(descriptor: FileViewerDescriptor): () => void
|
registerFileViewer(descriptor: FileViewerDescriptor): () => void
|
||||||
|
registerFileIcon(descriptor: FileIconDescriptor): () => void
|
||||||
getTabs(): readonly TabDescriptor[]
|
getTabs(): readonly TabDescriptor[]
|
||||||
getFileViewers(): readonly FileViewerDescriptor[]
|
getFileViewers(): readonly FileViewerDescriptor[]
|
||||||
|
getFileIcons(): readonly FileIconDescriptor[]
|
||||||
|
/**
|
||||||
|
* Find a SPECIFIC registered file icon for a path (priority desc, then
|
||||||
|
* registration order). Catch-alls (`exts: []`) and folder registrations
|
||||||
|
* (`'folder'`/`'folder-open'`) are not consulted — this answers "did a
|
||||||
|
* registration claim this exact extension". Consumers should prefer
|
||||||
|
* `fileIcon`/`folderIcon`, which run the whole fallback chain.
|
||||||
|
*/
|
||||||
|
matchFileIcon(path: string): FileIconDescriptor | undefined
|
||||||
|
/**
|
||||||
|
* Find the registered icon for DIRECTORY rows (priority desc, then
|
||||||
|
* registration order): `open` picks between the `'folder'` and
|
||||||
|
* `'folder-open'` reserved exts. Undefined = fall back to the built-in
|
||||||
|
* VSCodicons folder glyphs.
|
||||||
|
*/
|
||||||
|
matchFolderIcon(open: boolean): FileIconDescriptor | undefined
|
||||||
|
/**
|
||||||
|
* The authoritative FILE icon for a path (feature `fileIcons`), running
|
||||||
|
* the whole chain with per-factory crash isolation:
|
||||||
|
* 1. a specific registered extension (priority desc, registration order),
|
||||||
|
* 2. the built-in monochrome glyph map (md/media/pdf/json/code/...),
|
||||||
|
* 3. the best registered global default (`exts: []`, priority desc),
|
||||||
|
* 4. the generic `VscFile`.
|
||||||
|
* A throwing factory is logged (console.error) and skipped — the caller
|
||||||
|
* always gets a valid ReactNode.
|
||||||
|
*/
|
||||||
|
fileIcon(path: string, size: number): ReactNode
|
||||||
|
/**
|
||||||
|
* The authoritative DIRECTORY icon for a tree row: the registered
|
||||||
|
* `'folder'`/`'folder-open'` icon (priority desc), else the built-in
|
||||||
|
* `VscFolder`/`VscFolderOpened`. `path` is the directory's own path (a
|
||||||
|
* theme may vary icons per directory). Same crash isolation as `fileIcon`.
|
||||||
|
*/
|
||||||
|
folderIcon(path: string, open: boolean, size: number): ReactNode
|
||||||
/** Find a tab descriptor by id (undefined if not registered). */
|
/** Find a tab descriptor by id (undefined if not registered). */
|
||||||
getTab(id: string): TabDescriptor | undefined
|
getTab(id: string): TabDescriptor | undefined
|
||||||
/**
|
/**
|
||||||
@@ -544,6 +615,8 @@ export const SIDEBAR_SERVICE_VERSION = '0.19.1'
|
|||||||
* - 'pluginSettings': SidebarSettingsDeclaration.pluginToggles/render
|
* - 'pluginSettings': SidebarSettingsDeclaration.pluginToggles/render
|
||||||
* - 'urlTarget' (v0.13.0): TabDescriptor.urlTarget (external-link claims)
|
* - 'urlTarget' (v0.13.0): TabDescriptor.urlTarget (external-link claims)
|
||||||
* - 'settingSelect': SidebarSettingToggle type 'select' (options/multi)
|
* - 'settingSelect': SidebarSettingToggle type 'select' (options/multi)
|
||||||
|
* - 'fileIcons' (v0.19.0): registerFileIcon/getFileIcons/matchFileIcon —
|
||||||
|
* external per-extension file-tree icons overriding the built-in glyphs.
|
||||||
*
|
*
|
||||||
* v0.19.0 REMOVED 'floatWindows': the free-window feature is gone (DSH 0.1.5
|
* v0.19.0 REMOVED 'floatWindows': the free-window feature is gone (DSH 0.1.5
|
||||||
* owns the right column, so the plugin keeps only its bottom workbench).
|
* owns the right column, so the plugin keeps only its bottom workbench).
|
||||||
@@ -560,6 +633,7 @@ export const SIDEBAR_FEATURES = [
|
|||||||
'pluginSettings',
|
'pluginSettings',
|
||||||
'urlTarget',
|
'urlTarget',
|
||||||
'settingSelect',
|
'settingSelect',
|
||||||
|
'fileIcons',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
/** Run one plugin callback; a throw is logged and never breaks the caller. */
|
/** Run one plugin callback; a throw is logged and never breaks the caller. */
|
||||||
@@ -579,6 +653,7 @@ function safeCall(fn: () => void): void {
|
|||||||
export function createBetterSidebarService(store: SidebarStore): BetterSidebarService {
|
export function createBetterSidebarService(store: SidebarStore): BetterSidebarService {
|
||||||
const tabs = new Map<string, TabDescriptor>()
|
const tabs = new Map<string, TabDescriptor>()
|
||||||
const viewers = new Map<string, FileViewerDescriptor>()
|
const viewers = new Map<string, FileViewerDescriptor>()
|
||||||
|
const fileIcons = new Map<string, FileIconDescriptor>()
|
||||||
const listeners = new Set<() => void>()
|
const listeners = new Set<() => void>()
|
||||||
/** The native right-Sidebar write face, installed by the client half. */
|
/** The native right-Sidebar write face, installed by the client half. */
|
||||||
let surface: SidebarSurface | undefined
|
let surface: SidebarSurface | undefined
|
||||||
@@ -622,8 +697,98 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
|||||||
|
|
||||||
const getTabs = (): readonly TabDescriptor[] => Array.from(tabs.values())
|
const getTabs = (): readonly TabDescriptor[] => Array.from(tabs.values())
|
||||||
const getFileViewers = (): readonly FileViewerDescriptor[] => Array.from(viewers.values())
|
const getFileViewers = (): readonly FileViewerDescriptor[] => Array.from(viewers.values())
|
||||||
|
const getFileIcons = (): readonly FileIconDescriptor[] => Array.from(fileIcons.values())
|
||||||
const getTab = (id: string): TabDescriptor | undefined => tabs.get(id)
|
const getTab = (id: string): TabDescriptor | undefined => tabs.get(id)
|
||||||
|
|
||||||
|
const registerFileIcon = (descriptor: FileIconDescriptor): (() => void) => {
|
||||||
|
if (fileIcons.has(descriptor.id)) {
|
||||||
|
throw new Error(`[dsh-better-sidebar] file icons "${descriptor.id}" already registered`)
|
||||||
|
}
|
||||||
|
fileIcons.set(descriptor.id, descriptor)
|
||||||
|
notify()
|
||||||
|
return () => {
|
||||||
|
if (fileIcons.get(descriptor.id) === descriptor) {
|
||||||
|
fileIcons.delete(descriptor.id)
|
||||||
|
notify()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registrations in ranking order: priority desc, stable for equal
|
||||||
|
// priorities (insertion order) — the same ranking `matchFileViewer` uses.
|
||||||
|
const rankedFileIcons = (): FileIconDescriptor[] =>
|
||||||
|
Array.from(fileIcons.values()).sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
|
||||||
|
|
||||||
|
// Specific registrations only: catch-alls (`exts: []`) and folder
|
||||||
|
// registrations (`'folder'`/`'folder-open'`) are skipped, and the reserved
|
||||||
|
// folder values never match a real file's extension. The built-in glyph
|
||||||
|
// map is not consulted here — an undefined result IS the "fall through"
|
||||||
|
// signal the `fileIcon` resolver acts on.
|
||||||
|
const matchFileIcon = (path: string): FileIconDescriptor | undefined => {
|
||||||
|
const ext = extOf(path)
|
||||||
|
// Reserved folder values never claim a real file: `x.folder` falls
|
||||||
|
// through to the built-in/catch-all chain like any unknown extension.
|
||||||
|
if (ext === FOLDER_EXT || ext === FOLDER_OPEN_EXT) return undefined
|
||||||
|
for (const d of rankedFileIcons()) {
|
||||||
|
if (d.exts.includes(ext)) return d
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// Directory rows have no extension: the reserved `'folder'`/`'folder-open'`
|
||||||
|
// exts are the only claim surface (a catch-all never claims a directory).
|
||||||
|
const matchFolderIcon = (open: boolean): FileIconDescriptor | undefined => {
|
||||||
|
const want = open ? FOLDER_OPEN_EXT : FOLDER_EXT
|
||||||
|
for (const d of rankedFileIcons()) {
|
||||||
|
if (d.exts.includes(want)) return d
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run one registered factory; a throw is logged and returns undefined. */
|
||||||
|
const safeIcon = (d: FileIconDescriptor, path: string, size: number): ReactNode => {
|
||||||
|
try {
|
||||||
|
return d.icon(path, size)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[dsh-better-sidebar] file icon factory "${d.id}" error:`, error)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The authoritative file-icon chain (see the interface doc): specific
|
||||||
|
// registration → built-in glyph → best catch-all → stock VscFile. The
|
||||||
|
// catch-all ranks by priority desc then registration order (first wins);
|
||||||
|
// an unclaimed extension reaching it is exactly "nothing specified falls
|
||||||
|
// back to the (registered or stock) default".
|
||||||
|
const fileIcon = (path: string, size: number): ReactNode => {
|
||||||
|
const specific = matchFileIcon(path)
|
||||||
|
if (specific !== undefined) {
|
||||||
|
const icon = safeIcon(specific, path, size)
|
||||||
|
if (icon !== undefined) return icon
|
||||||
|
}
|
||||||
|
const builtin = builtinFileIconOf(path)
|
||||||
|
if (builtin !== undefined) return builtin(size)
|
||||||
|
for (const d of rankedFileIcons()) {
|
||||||
|
if (d.exts.length === 0) {
|
||||||
|
const icon = safeIcon(d, path, size)
|
||||||
|
if (icon !== undefined) return icon
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallbackFileIcon(size)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Directory rows: registered folder/folder-open icon, else the built-in
|
||||||
|
// VSCodicons glyphs. The row's path feeds the factory (a theme may vary
|
||||||
|
// icons per directory).
|
||||||
|
const folderIcon = (path: string, open: boolean, size: number): ReactNode => {
|
||||||
|
const registered = matchFolderIcon(open)
|
||||||
|
if (registered !== undefined) {
|
||||||
|
const icon = safeIcon(registered, path, size)
|
||||||
|
if (icon !== undefined) return icon
|
||||||
|
}
|
||||||
|
return builtinFolderIcon(open, size)
|
||||||
|
}
|
||||||
|
|
||||||
// The enable switches come from the user's side card prefs (the shared
|
// The enable switches come from the user's side card prefs (the shared
|
||||||
// store the service is bound to): an absent key means enabled.
|
// store the service is bound to): an absent key means enabled.
|
||||||
const isTabEnabled = (id: string): boolean => store.getPrefs().tabsEnabled[id] !== false
|
const isTabEnabled = (id: string): boolean => store.getPrefs().tabsEnabled[id] !== false
|
||||||
@@ -907,8 +1072,14 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
|||||||
return {
|
return {
|
||||||
registerTab,
|
registerTab,
|
||||||
registerFileViewer,
|
registerFileViewer,
|
||||||
|
registerFileIcon,
|
||||||
getTabs,
|
getTabs,
|
||||||
getFileViewers,
|
getFileViewers,
|
||||||
|
getFileIcons,
|
||||||
|
matchFileIcon,
|
||||||
|
matchFolderIcon,
|
||||||
|
fileIcon,
|
||||||
|
folderIcon,
|
||||||
getTab,
|
getTab,
|
||||||
isTabEnabled,
|
isTabEnabled,
|
||||||
isViewerEnabled,
|
isViewerEnabled,
|
||||||
|
|||||||
@@ -12,12 +12,15 @@
|
|||||||
*/
|
*/
|
||||||
import type {} from '../src/client/service.ts'
|
import type {} from '../src/client/service.ts'
|
||||||
import {
|
import {
|
||||||
|
FOLDER_EXT,
|
||||||
|
FOLDER_OPEN_EXT,
|
||||||
SIDEBAR_FEATURES,
|
SIDEBAR_FEATURES,
|
||||||
SIDEBAR_SERVICE_VERSION,
|
SIDEBAR_SERVICE_VERSION,
|
||||||
} from '../src/client/service.ts'
|
} from '../src/client/service.ts'
|
||||||
import type {
|
import type {
|
||||||
BetterSidebarService,
|
BetterSidebarService,
|
||||||
FileFetchStrategy,
|
FileFetchStrategy,
|
||||||
|
FileIconDescriptor,
|
||||||
FileViewerDescriptor,
|
FileViewerDescriptor,
|
||||||
FileViewerProps,
|
FileViewerProps,
|
||||||
OpenTabSeed,
|
OpenTabSeed,
|
||||||
@@ -125,6 +128,23 @@ service.updateTab('tab:1', { title: 'T', path: '/p', meta: 1 })
|
|||||||
service.activateTab('tab:1')
|
service.activateTab('tab:1')
|
||||||
service.openFile({ sessionId: 's1', cwd: '/p' }, '/p/a.csv', 'Data')
|
service.openFile({ sessionId: 's1', cwd: '/p' }, '/p/a.csv', 'Data')
|
||||||
|
|
||||||
|
/** File-icon registration surface (feature `fileIcons`). */
|
||||||
|
const icon: FileIconDescriptor = {
|
||||||
|
id: 'my-plugin:icons',
|
||||||
|
exts: ['csv', FOLDER_EXT, FOLDER_OPEN_EXT],
|
||||||
|
priority: 5,
|
||||||
|
icon: (path: string, size: number) => {
|
||||||
|
void path; void size
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
}
|
||||||
|
service.registerFileIcon(icon)
|
||||||
|
service.getFileIcons()
|
||||||
|
service.matchFileIcon('/p/a.csv')
|
||||||
|
service.matchFolderIcon(true)
|
||||||
|
service.fileIcon('/p/a.csv', 14)
|
||||||
|
service.folderIcon('/p', true, 14)
|
||||||
|
|
||||||
/** Named state vocabulary stays importable (the pre-0.12 gap). */
|
/** Named state vocabulary stays importable (the pre-0.12 gap). */
|
||||||
const diff: SidebarDiffRef = { kind: 'worktree', path: '/p/a.ts', staged: false }
|
const diff: SidebarDiffRef = { kind: 'worktree', path: '/p/a.ts', staged: false }
|
||||||
const prefs: SidebarPrefs = { ...({} as SidebarPrefs) }
|
const prefs: SidebarPrefs = { ...({} as SidebarPrefs) }
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
/**
|
||||||
|
* Tests for the file-icon feature: the external registration API
|
||||||
|
* (registerFileIcon — registry lifecycle, reserved folder exts, and the
|
||||||
|
* authoritative fileIcon/folderIcon resolver chains) and the built-in
|
||||||
|
* per-extension glyph map (extension normalization, group hits, and the
|
||||||
|
* generic VscFile fallback).
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import type { ReactElement, ReactNode } from 'react'
|
||||||
|
import { VscFile, VscFileCode, VscFileMedia, VscFolder, VscFolderOpened, VscJson, VscMarkdown } from 'react-icons/vsc'
|
||||||
|
|
||||||
|
// Mock browser globals (SidebarStore.reduce → schedulePersist uses window.setTimeout)
|
||||||
|
const g = globalThis as Record<string, unknown>
|
||||||
|
if (g.window === undefined) {
|
||||||
|
g.window = {
|
||||||
|
clearTimeout: () => {},
|
||||||
|
setTimeout: (_fn: () => void) => 0,
|
||||||
|
innerWidth: 1024,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (g.localStorage === undefined) {
|
||||||
|
g.localStorage = {
|
||||||
|
getItem: () => null,
|
||||||
|
setItem: () => {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
import { createBetterSidebarService, SIDEBAR_FEATURES } from '../src/client/service.ts'
|
||||||
|
import { createSidebarStore } from '../src/client/state.ts'
|
||||||
|
import { builtinFileIcon, builtinFolderIcon, fallbackFileIcon } from '../src/client/file-icons.tsx'
|
||||||
|
|
||||||
|
/** The component type of a rendered icon element (identity of the glyph). */
|
||||||
|
const glyphOf = (node: ReactNode): unknown => (node as ReactElement).type
|
||||||
|
|
||||||
|
/** A marker component standing in for a plugin's registered icon. */
|
||||||
|
const marker = (): ReactNode => <span data-marker />
|
||||||
|
|
||||||
|
/** A second marker with a DISTINCT element type, so `glyphOf` can tell the
|
||||||
|
* two registrations apart in priority/ordering assertions. */
|
||||||
|
const markerB = (): ReactNode => <b data-marker-b />
|
||||||
|
|
||||||
|
describe('file icon registration API', () => {
|
||||||
|
it('registerFileIcon adds to the registry and dispose removes it', () => {
|
||||||
|
const service = createBetterSidebarService(createSidebarStore())
|
||||||
|
expect(service.getFileIcons()).toHaveLength(0)
|
||||||
|
const dispose = service.registerFileIcon({ id: 'test:icons', exts: ['csv'], icon: marker })
|
||||||
|
expect(service.getFileIcons()).toHaveLength(1)
|
||||||
|
expect(service.matchFileIcon('/w/a.csv')?.id).toBe('test:icons')
|
||||||
|
dispose()
|
||||||
|
expect(service.getFileIcons()).toHaveLength(0)
|
||||||
|
expect(service.matchFileIcon('/w/a.csv')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('registerFileIcon throws on duplicate id', () => {
|
||||||
|
const service = createBetterSidebarService(createSidebarStore())
|
||||||
|
service.registerFileIcon({ id: 'dup', exts: [], icon: marker })
|
||||||
|
expect(() => service.registerFileIcon({ id: 'dup', exts: [], icon: marker })).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('matchFileIcon matches by extension (case-insensitive), specifics only', () => {
|
||||||
|
const service = createBetterSidebarService(createSidebarStore())
|
||||||
|
service.registerFileIcon({ id: 'csv', exts: ['csv'], icon: marker })
|
||||||
|
service.registerFileIcon({ id: 'all', exts: [], icon: marker })
|
||||||
|
expect(service.matchFileIcon('/w/data.csv')?.id).toBe('csv')
|
||||||
|
expect(service.matchFileIcon('/w/DATA.CSV')?.id).toBe('csv')
|
||||||
|
// The catch-all never answers matchFileIcon (it lives in fileIcon's chain).
|
||||||
|
expect(service.matchFileIcon('/w/a.tsv')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('higher priority wins on extension conflict; ties keep registration order', () => {
|
||||||
|
const service = createBetterSidebarService(createSidebarStore())
|
||||||
|
service.registerFileIcon({ id: 'low', exts: ['csv'], icon: marker })
|
||||||
|
service.registerFileIcon({ id: 'high', exts: ['csv'], priority: 10, icon: marker })
|
||||||
|
service.registerFileIcon({ id: 'tie', exts: ['csv'], icon: marker })
|
||||||
|
expect(service.matchFileIcon('/w/a.csv')?.id).toBe('high')
|
||||||
|
const fresh = createBetterSidebarService(createSidebarStore())
|
||||||
|
fresh.registerFileIcon({ id: 'first', exts: ['csv'], icon: marker })
|
||||||
|
fresh.registerFileIcon({ id: 'second', exts: ['csv'], icon: marker })
|
||||||
|
expect(fresh.matchFileIcon('/w/a.csv')?.id).toBe('first')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('the feature is advertised in SIDEBAR_FEATURES', () => {
|
||||||
|
expect(SIDEBAR_FEATURES.includes('fileIcons')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('register and dispose notify subscribers (mounted rows re-resolve, no reload)', () => {
|
||||||
|
const service = createBetterSidebarService(createSidebarStore())
|
||||||
|
let notified = 0
|
||||||
|
const unsubscribe = service.subscribe(() => { notified += 1 })
|
||||||
|
const dispose = service.registerFileIcon({ id: 'test:icons', exts: ['csv'], icon: marker })
|
||||||
|
expect(notified).toBe(1)
|
||||||
|
dispose()
|
||||||
|
expect(notified).toBe(2)
|
||||||
|
// A second dispose is a no-op — no spurious notification.
|
||||||
|
dispose()
|
||||||
|
expect(notified).toBe(2)
|
||||||
|
unsubscribe()
|
||||||
|
service.registerFileIcon({ id: 'test:other', exts: ['tsv'], icon: marker })
|
||||||
|
expect(notified).toBe(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('fileIcon resolver chain (specific → builtin → catch-all → VscFile)', () => {
|
||||||
|
it('a specific registration beats the builtin glyph', () => {
|
||||||
|
const service = createBetterSidebarService(createSidebarStore())
|
||||||
|
service.registerFileIcon({ id: 'md', exts: ['md'], icon: marker })
|
||||||
|
expect(glyphOf(service.fileIcon('/w/README.md', 14))).toBe('span')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('the builtin glyph beats a registered catch-all (the default only claims unclaimed extensions)', () => {
|
||||||
|
const service = createBetterSidebarService(createSidebarStore())
|
||||||
|
service.registerFileIcon({ id: 'all', exts: [], icon: marker })
|
||||||
|
expect(glyphOf(service.fileIcon('/w/README.md', 14))).toBe(VscMarkdown)
|
||||||
|
expect(glyphOf(service.fileIcon('/w/logo.png', 14))).toBe(VscFileMedia)
|
||||||
|
// An extension the builtin map does not cover reaches the catch-all.
|
||||||
|
expect(glyphOf(service.fileIcon('/w/Makefile', 14))).toBe('span')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('the best (priority desc) catch-all wins among several', () => {
|
||||||
|
const service = createBetterSidebarService(createSidebarStore())
|
||||||
|
service.registerFileIcon({ id: 'all-a', exts: [], icon: marker })
|
||||||
|
service.registerFileIcon({ id: 'all-b', exts: [], priority: 5, icon: markerB })
|
||||||
|
// markerB ('b') is the higher-priority catch-all — not merely "some span".
|
||||||
|
expect(glyphOf(service.fileIcon('/w/Makefile', 14))).toBe('b')
|
||||||
|
const tie = createBetterSidebarService(createSidebarStore())
|
||||||
|
tie.registerFileIcon({ id: 'first', exts: [], icon: marker })
|
||||||
|
tie.registerFileIcon({ id: 'second', exts: [], icon: markerB })
|
||||||
|
// Equal priority keeps registration order (the first-registered wins).
|
||||||
|
expect(glyphOf(tie.fileIcon('/w/Makefile', 14))).toBe('span')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a factory returning undefined declines that link and the chain continues', () => {
|
||||||
|
const service = createBetterSidebarService(createSidebarStore())
|
||||||
|
service.registerFileIcon({ id: 'md-decline', exts: ['md'], icon: () => undefined })
|
||||||
|
// The specific registration declined → the built-in glyph claims it.
|
||||||
|
expect(glyphOf(service.fileIcon('/w/README.md', 14))).toBe(VscMarkdown)
|
||||||
|
service.registerFileIcon({ id: 'all-decline', exts: [], priority: 10, icon: () => undefined })
|
||||||
|
service.registerFileIcon({ id: 'all-take', exts: [], icon: marker })
|
||||||
|
// The declining catch-all is skipped, the next one takes the row.
|
||||||
|
expect(glyphOf(service.fileIcon('/w/Makefile', 14))).toBe('span')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('with no registration at all the chain is the built-in one', () => {
|
||||||
|
const service = createBetterSidebarService(createSidebarStore())
|
||||||
|
expect(glyphOf(service.fileIcon('/w/pkg.json', 14))).toBe(VscJson)
|
||||||
|
expect(glyphOf(service.fileIcon('/w/main.ts', 14))).toBe(VscFileCode)
|
||||||
|
expect(glyphOf(service.fileIcon('/w/Makefile', 14))).toBe(VscFile)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a throwing factory is skipped at every level (console.error, next link wins)', () => {
|
||||||
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
try {
|
||||||
|
const service = createBetterSidebarService(createSidebarStore())
|
||||||
|
service.registerFileIcon({ id: 'boom-md', exts: ['md'], icon: () => { throw new Error('boom') } })
|
||||||
|
// Specific throws → falls to the builtin glyph.
|
||||||
|
expect(glyphOf(service.fileIcon('/w/README.md', 14))).toBe(VscMarkdown)
|
||||||
|
service.registerFileIcon({ id: 'boom-all', exts: [], priority: 10, icon: () => { throw new Error('boom') } })
|
||||||
|
// Catch-all throws → falls to the stock VscFile.
|
||||||
|
expect(glyphOf(service.fileIcon('/w/Makefile', 14))).toBe(VscFile)
|
||||||
|
expect(errorSpy).toHaveBeenCalled()
|
||||||
|
} finally {
|
||||||
|
errorSpy.mockRestore()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reserved folder exts never claim a real file named x.folder', () => {
|
||||||
|
const service = createBetterSidebarService(createSidebarStore())
|
||||||
|
service.registerFileIcon({ id: 'folders', exts: ['folder', 'folder-open'], icon: marker })
|
||||||
|
expect(service.matchFileIcon('/w/x.folder')).toBeUndefined()
|
||||||
|
// The real file falls through to the stock default, not the folder icon.
|
||||||
|
expect(glyphOf(service.fileIcon('/w/x.folder', 14))).toBe(VscFile)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('folderIcon resolver (registered folder/folder-open → builtin glyphs)', () => {
|
||||||
|
it('unregistered directories show the builtin glyphs', () => {
|
||||||
|
const service = createBetterSidebarService(createSidebarStore())
|
||||||
|
expect(glyphOf(service.folderIcon('/w/src', false, 14))).toBe(VscFolder)
|
||||||
|
expect(glyphOf(service.folderIcon('/w/src', true, 14))).toBe(VscFolderOpened)
|
||||||
|
expect(glyphOf(builtinFolderIcon(false, 14))).toBe(VscFolder)
|
||||||
|
expect(glyphOf(builtinFolderIcon(true, 14))).toBe(VscFolderOpened)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('folder / folder-open registrations replace the dir glyphs (priority desc)', () => {
|
||||||
|
const service = createBetterSidebarService(createSidebarStore())
|
||||||
|
service.registerFileIcon({ id: 'closed', exts: ['folder'], icon: marker })
|
||||||
|
expect(glyphOf(service.folderIcon('/w/src', false, 14))).toBe('span')
|
||||||
|
// The OPEN row is a separate reserved ext — still the builtin here.
|
||||||
|
expect(glyphOf(service.folderIcon('/w/src', true, 14))).toBe(VscFolderOpened)
|
||||||
|
service.registerFileIcon({ id: 'open', exts: ['folder-open'], icon: markerB })
|
||||||
|
expect(glyphOf(service.folderIcon('/w/src', true, 14))).toBe('b')
|
||||||
|
service.registerFileIcon({ id: 'closed-hi', exts: ['folder'], priority: 3, icon: markerB })
|
||||||
|
// The higher-priority registration ('b') takes the closed row.
|
||||||
|
expect(glyphOf(service.folderIcon('/w/src', false, 14))).toBe('b')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a catch-all registration never claims a directory', () => {
|
||||||
|
const service = createBetterSidebarService(createSidebarStore())
|
||||||
|
service.registerFileIcon({ id: 'all', exts: [], icon: marker })
|
||||||
|
expect(glyphOf(service.folderIcon('/w/src', false, 14))).toBe(VscFolder)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a throwing folder factory falls back to the builtin glyph', () => {
|
||||||
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
try {
|
||||||
|
const service = createBetterSidebarService(createSidebarStore())
|
||||||
|
service.registerFileIcon({ id: 'boom', exts: ['folder'], icon: () => { throw new Error('boom') } })
|
||||||
|
expect(glyphOf(service.folderIcon('/w/src', false, 14))).toBe(VscFolder)
|
||||||
|
expect(errorSpy).toHaveBeenCalled()
|
||||||
|
} finally {
|
||||||
|
errorSpy.mockRestore()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('built-in glyph map (builtinFileIcon)', () => {
|
||||||
|
it('maps representative extensions to their group glyph', () => {
|
||||||
|
expect(glyphOf(builtinFileIcon('/w/README.md', 14))).toBe(VscMarkdown)
|
||||||
|
expect(glyphOf(builtinFileIcon('/w/logo.png', 14))).toBe(VscFileMedia)
|
||||||
|
expect(glyphOf(builtinFileIcon('/w/logo.SVG', 14))).toBe(VscFileMedia)
|
||||||
|
expect(glyphOf(builtinFileIcon('/w/pkg.json', 14))).toBe(VscJson)
|
||||||
|
expect(glyphOf(builtinFileIcon('/w/main.ts', 14))).toBe(VscFileCode)
|
||||||
|
expect(glyphOf(builtinFileIcon('/w/main.py', 14))).toBe(VscFileCode)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the generic VscFile for unknown extensions and paths without one', () => {
|
||||||
|
expect(glyphOf(builtinFileIcon('/w/data.xyzunknown', 14))).toBe(VscFile)
|
||||||
|
expect(glyphOf(builtinFileIcon('/w/Makefile', 14))).toBe(VscFile)
|
||||||
|
expect(glyphOf(fallbackFileIcon(14))).toBe(VscFile)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user