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:
+16
-19
@@ -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>
|
||||
|
||||
@@ -813,8 +813,16 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
* ctx and the conversation input service at click time; a missing service
|
||||
* or scope degrades to a logged no-op, never a crash.
|
||||
*/
|
||||
/** The tab icon from the tab-type registry (shared by every workbench). */
|
||||
/**
|
||||
* The tab icon: 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 => {
|
||||
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)
|
||||
if (descriptor === undefined) return null
|
||||
return typeof descriptor.icon === 'function' ? descriptor.icon(14) : descriptor.icon
|
||||
|
||||
+31
-17
@@ -11,22 +11,31 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
VscDatabase, VscFile, VscFileCode, VscFileMedia, VscFilePdf, VscFileZip,
|
||||
VscJson, VscLock, VscMarkdown, VscSettings,
|
||||
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[], (size: number) => ReactNode]
|
||||
type GlyphGroup = [readonly string[], GlyphFactory]
|
||||
|
||||
/**
|
||||
* Extension → glyph groups, first match wins. Extensions are lowercase
|
||||
* without the leading dot, matched case-insensitively by the caller's
|
||||
* normalization. Groups are ordered specific-first; the trailing catch-all
|
||||
* lives in `builtinFileIcon`, not here.
|
||||
* normalization. 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} />],
|
||||
@@ -42,24 +51,29 @@ const GROUPS: readonly GlyphGroup[] = [
|
||||
[['zip', 'tar', 'gz', 'tgz', 'bz2', 'xz', '7z', 'rar'], (size) => <VscFileZip size={size} />],
|
||||
]
|
||||
|
||||
const LOOKUP: ReadonlyMap<string, (size: number) => ReactNode> = new Map(
|
||||
const LOOKUP: ReadonlyMap<string, GlyphFactory> = new Map(
|
||||
GROUPS.flatMap(([exts, glyph]) => exts.map((ext) => [ext, glyph] as const)),
|
||||
)
|
||||
|
||||
/**
|
||||
* The built-in glyph for a path: its extension's group glyph, or the generic
|
||||
* `VscFile` when nothing claims it. Pure and synchronous — the file tree row
|
||||
* calls this on every render, so it stays a Map lookup.
|
||||
* 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 builtinFileIcon(path: string, size: number): ReactNode {
|
||||
const glyph = LOOKUP.get(extOfPath(path))
|
||||
return glyph === undefined ? fallbackFileIcon(size) : glyph(size)
|
||||
export function builtinFileIconOf(path: string): GlyphFactory | undefined {
|
||||
return LOOKUP.get(extOf(path))
|
||||
}
|
||||
|
||||
/** The lowercase extension without leading dot ('' when none). */
|
||||
function extOfPath(path: string): string {
|
||||
const at = path.lastIndexOf('.')
|
||||
if (at === -1) return ''
|
||||
const base = path.slice(at + 1).toLowerCase()
|
||||
return base.includes('/') || base.includes('\\') ? '' : base
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
|
||||
+123
-21
@@ -28,6 +28,7 @@ import {
|
||||
} from './state.ts'
|
||||
import { isNarrowWidth } from './breakpoints.ts'
|
||||
import { extOf } from './paths.ts'
|
||||
import { builtinFileIconOf, builtinFolderIcon, fallbackFileIcon } from './file-icons.tsx'
|
||||
import type { SessionScope } from './api.ts'
|
||||
import type { SidebarPrefs } from '../prefs-shared.ts'
|
||||
|
||||
@@ -334,14 +335,31 @@ export interface FileViewerDescriptor {
|
||||
export interface FileIconDescriptor {
|
||||
/** Unique id (`'my-plugin:icons'`). */
|
||||
id: string
|
||||
/** Lowercase extensions without leading dot (`['csv','tsv']`). `[]` = match any (catch-all). */
|
||||
/**
|
||||
* 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 renders at 14 today). */
|
||||
/** 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. */
|
||||
export interface OpenTabSeed {
|
||||
type: string
|
||||
@@ -370,12 +388,38 @@ export interface BetterSidebarService {
|
||||
getFileViewers(): readonly FileViewerDescriptor[]
|
||||
getFileIcons(): readonly FileIconDescriptor[]
|
||||
/**
|
||||
* Find a registered file icon for a path (priority desc, then registration
|
||||
* order; `exts: []` is a catch-all). Undefined means "no registration
|
||||
* claims it" — the caller falls back to the built-in glyph map, then the
|
||||
* generic `VscFile`.
|
||||
* 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). */
|
||||
getTab(id: string): TabDescriptor | undefined
|
||||
/**
|
||||
@@ -604,26 +648,81 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
}
|
||||
}
|
||||
|
||||
// Priority desc, stable for equal priorities (insertion order) — the same
|
||||
// ranking `matchFileViewer` uses, plus one tie-break: at EQUAL priority a
|
||||
// catch-all (`exts: []`) sorts behind specific descriptors, so a
|
||||
// same-priority `['md']` registration is never shadowed by an earlier
|
||||
// registered catch-all. This never consults the built-in glyph map: an
|
||||
// undefined result IS the "fall through to the built-ins" signal.
|
||||
// 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 = extOfPath(path)
|
||||
for (const d of Array.from(fileIcons.values()).sort((a, b) => {
|
||||
const byPriority = (b.priority ?? 0) - (a.priority ?? 0)
|
||||
if (byPriority !== 0) return byPriority
|
||||
const aCatch = a.exts.length === 0 ? 1 : 0
|
||||
const bCatch = b.exts.length === 0 ? 1 : 0
|
||||
return aCatch - bCatch
|
||||
})) {
|
||||
if (d.exts.length === 0 || d.exts.includes(ext)) return d
|
||||
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
|
||||
// store the service is bound to): an absent key means enabled.
|
||||
const isTabEnabled = (id: string): boolean => store.getPrefs().tabsEnabled[id] !== false
|
||||
@@ -884,6 +983,9 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
getFileViewers,
|
||||
getFileIcons,
|
||||
matchFileIcon,
|
||||
matchFolderIcon,
|
||||
fileIcon,
|
||||
folderIcon,
|
||||
getTab,
|
||||
isTabEnabled,
|
||||
isViewerEnabled,
|
||||
|
||||
Reference in New Issue
Block a user