feat(file-icons): 目录保留扩展名注册 + 全局默认兜底 + 编辑器文件 tab 对应

- registerFileIcon 保留 'folder'/'folder-open' 特殊扩展名:目录行(含根行)
  图标可注册,匹配保留值不认领真实 .folder 文件
- 新增权威解析器 fileIcon(path,size)(具体注册 → 内置 glyph → catch-all
  全局默认 → VscFile,逐工厂崩溃隔离)与 folderIcon(path,open,size);
  matchFileIcon 收窄为仅具体扩展名匹配
- 编辑器文件 tab(非 meta.dir)与文件树行显示同一 fileIcon 图标
- 同步 external-plugin-guide §7(保留值/回退链/三例注册)与 README
  (功能一览、v0.19.0-alpha.0 发布注记、服务化扩展章节)
- tests/file-icons.spec.tsx 重写为 17 用例(链序/保留值/目录/崩溃回退)
This commit is contained in:
Menghuan1918
2026-09-08 21:11:32 +08:00
parent 05773e0f2a
commit 8025b83a45
8 changed files with 427 additions and 172 deletions
+16 -19
View File
@@ -31,7 +31,7 @@ import { SiCursor, SiZedindustries } from 'react-icons/si'
import { VscFolder, VscFolderOpened, VscLinkExternal, VscPin, VscPinned } from 'react-icons/vsc'
import { api, downloadUrl, isOutsideWorkspaceMessage, type FsEntry } from './api.ts'
import { FenceErrorNotice } from './FenceErrorNotice.tsx'
import { builtinFileIcon } from './file-icons.tsx'
import { builtinFileIcon, builtinFolderIcon } from './file-icons.tsx'
import { IconUploadOutline16, IconVscode16 } from './icons.tsx'
import { isImeComposition } from './ime-guard.ts'
import { useSubmenuFlip } from './menu-flip.ts'
@@ -168,23 +168,20 @@ export function FileTree(props: {
[service],
)
/**
* One file row's leading glyph: an externally registered icon
* (`registerFileIcon`, priority desc) wins; otherwise the built-in
* per-extension glyph map (monochrome VSCodicons), whose own fallback
* is the generic VscFile. A throwing registered factory is logged and
* skipped — a broken plugin icon must never blank the tree.
* 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 => {
const registered = service?.matchFileIcon(path)
if (registered !== undefined) {
try {
return registered.icon(path, 14)
} catch (error) {
console.error('[dsh-better-sidebar] file icon factory error:', error)
}
}
return builtinFileIcon(path, 14)
}
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)
/** The row whose path was just copied ("copied" label replaces its button). */
const [copiedPath, setCopiedPath] = useState<string | null>(null)
@@ -662,7 +659,7 @@ export function FileTree(props: {
onDrop={(event) => { handleDirDrop(event, entry.path) }}
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>
{entry.isSymlink && <IconLinkOutline16 size={12} className={css.explorerSymlink} />}
{rowActions(entry)}
@@ -740,7 +737,7 @@ export function FileTree(props: {
onDrop={(event) => { handleDirDrop(event, root) }}
onContextMenu={(event) => { openRowMenu(event, root, true) }}
>
<VscFolderOpened size={14} />
{dirRowIcon(root, true)}
<span className={css.explorerName}>{baseName(root)}</span>
{copiedPath === root
? <span className={css.explorerCopied}>{t('copied')}</span>