# 开发 dsh-file-explorer 扩展 [English](developing-extensions.md) | 中文 本文档涵盖为 [dsh-file-explorer](https://github.com/wolfsonliu/dsh-file-explorer) 开发预览插件(即"扩展")所需的一切。可参考以下三个实现: | 扩展 | 预览内容 | 关键模式 | |------|---------|----------| | [dsh-file-explorer-preview-code](https://github.com/wolfsonliu/dsh-file-explorer-preview-code) | 代码文件(语法高亮 + 编辑) | `writeFile`,纯文本 | | [dsh-file-explorer-preview-molstar](https://github.com/wolfsonliu/dsh-file-explorer-preview-molstar) | 蛋白质/小分子结构(`.cif`/`.pdb`/…) | `readRawFile`,大文件 + 二进制 | | [dsh-file-explorer-preview-sequence](https://github.com/wolfsonliu/dsh-file-explorer-preview-sequence) | DNA/RNA 序列(`.gb`/`.fasta`/…) | `readRawFile`,大文件 + 二进制 | ## 架构 ``` dsh-file-explorer(核心) └─ 客户端 apply:ctx.reflect.provide('fileExplorer', { registerPreview, registerViewer, registerFileAction, writeFile, readRawFile }) dsh-file-explorer-preview-(你的扩展) └─ inject: ['fileExplorer', 'locale'] └─ apply:ctx.fileExplorer.registerViewer({ id, label, exts: [...], component: MyPreview, priority: 10 }) ``` 核心以优先级 `0` 注册内置预览器(文本、Markdown、图片、CSV、二进制)。你的扩展以优先级 `10` 注册来覆盖它们。优先级数值越大越优先;同优先级时后注册者胜。 ## 契约 类型来自核心包的 `./client` 导出: ```typescript import type { FileExplorerService, PreviewProps, ViewerRegistration, FileAction, FileActionHelpers, Translate, } from '@dsh-external/dsh-file-explorer/client' ``` ### `FileExplorerService` ```typescript interface FileExplorerService { /** 为文件扩展名注册预览组件(小写,无前导点)。 */ registerPreview(ext: string, component: ComponentType, priority?: number): () => void /** * 以单个身份跨多个扩展名注册一个查看器(每个 id 对应一个「打开方式…」 * 列表项)。保留 id:'auto' | 'text' | 'binary'。 */ registerViewer(viewer: { id: string; label: string; exts: string[]; component: ComponentType; priority?: number }): () => void /** 注册文件行操作(出现在行末尾「···」菜单中)。 */ registerFileAction(action: FileAction): () => void /** 将 UTF-8 文本写入工作区文件。 */ writeFile(path: string, content: string): Promise /** * 从工作区文件读取原始字节,可选字节范围。 * @param path 工作区相对路径。 * @param offset 起始字节偏移(默认 0)。 * @param limit 最大读取字节数(服务端由 maxRawBytes 限制,默认 100 MiB)。 * @param signal 可选的 AbortSignal,用于取消读取。 */ readRawFile(path: string, offset?: number, limit?: number, signal?: AbortSignal): Promise } ``` ### `PreviewProps` 和 `FilePreview` ```typescript interface PreviewProps { preview: FilePreview filePath: string // 工作区相对路径 t: Translate // (key, params?) => string(绑定在 file-explorer 命名空间) activeView: 'preview' | 'source' onViewSource?: () => void } type FilePreview = | { kind: 'text'; name: string; extension: string; content: string; size: number } | { kind: 'image'; name: string; mime: string; dataUrl: string; size: number } | { kind: 'empty'; name: string; size: 0 } | { kind: 'binary'; name: string; size: number; bytes: string; truncated: boolean } | { kind: 'text-large'; name: string; extension: string; size: number } | { kind: 'too-large'; name: string; size: number } ``` ### 路由规则 `resolvePreviewFor(preview, ext, readRawFile?, viewerId?)` 决定哪个组件渲染文件。 当用户强制指定了查看器(「打开方式…」或面板切换器)时,`viewerId` 直接选中该查看器 ——对任何非 `empty` 预览它都优先于下面的优先级路由;`empty` 始终停留在状态页, 未知 id 则回落到默认路由: ``` preview.kind === 'image' → 你注册的组件,或 ImagePreview(回退) preview.kind === 'empty' → BinaryPreview(状态页)——永不覆盖 preview.kind === 'text' → 你注册的组件,或 TextPreview(回退) preview.kind === 'binary' → 你注册的组件,或 BinaryPreview(回退) preview.kind === 'too-large' → 你注册的组件,或 BinaryPreview(回退) preview.kind === 'text-large' → 你注册的组件,或内置的分页文本预览 ``` 关键变化(dsh-file-explorer v0.1.0+):`too-large` 和 `binary` 类型的预览现在会 **转发到已注册的扩展组件**,而非硬路由到状态页。这意味着你的扩展可以通过调用 `readRawFile` 来处理大文件和二进制格式。`image` 类型的预览同样会转发到为该扩展名 注册的组件;未注册时回退到内置 `ImagePreview`。 - 如果你为扩展名 `cif` 注册了预览组件,一个 `too-large` 的 `.cif` 文件会被路由到 你的组件——你调用 `readRawFile` 获取字节。 - 如果没有为 `dat` 注册任何扩展,一个 `too-large` 的 `.dat` 文件仍会回退到内置 状态页("文件过大,无法预览")。 ### 注册你的查看器 — `registerViewer`(推荐) 对于新插件,请优先使用 `registerViewer`:一次调用即可跨所有扩展名注册一个身份,其 `label` 即「打开方式…」列表和面板切换器中显示的名称: ```typescript ctx.effect(() => { const dispose = ctx.fileExplorer.registerViewer({ id: 'molstar', // unique; 'auto' | 'text' | 'binary' are reserved label: 'Mol* Structure', // shown in Open with… and the panel switcher exts: ['cif', 'pdb', 'mmcif'], component: MolstarPreview, priority: 10, }) return () => dispose() }) ``` 用户可通过行「···」菜单 → **打开方式…**,或预览面板标题栏的查看器切换器,按文件选择 你的查看器。该选择仅本次生效:普通「打开」仍按优先级解析。 ### 匿名查看器 — `registerPreview` `registerPreview(ext, component, priority?)` 为*单个*扩展名注册一个匿名查看器:它能正常 工作,但「打开方式…」列表无法为它命名(显示为「扩展查看器」),每个扩展名都会成为 单独的列表项。仅在需要为单个扩展名做无名称的快速覆盖、或作为旧核心的降级方案时使用。 `registerViewer` 自 v0.9.0 起可用——请在旧核心上探测并降级: ```typescript const register = typeof ctx.fileExplorer.registerViewer === 'function' ? (exts: string[], comp: ComponentType) => ctx.fileExplorer.registerViewer!({ id: 'molstar', label: 'Mol* Structure', exts, component: comp, priority: 10 }) : (exts: string[], comp: ComponentType) => { const disposers = exts.map((ext) => ctx.fileExplorer.registerPreview(ext, comp, 10)) return () => { for (const d of disposers) d() } } ``` ## 最小骨架(只读,纯文本) ```typescript // src/client/index.ts import type { FileExplorerService, PreviewProps } from '@dsh-external/dsh-file-explorer/client' export const inject = ['fileExplorer'] export function apply(ctx: { fileExplorer: FileExplorerService effect(cb: () => (() => void), label?: string): void }): void { ctx.effect(() => { const dispose = ctx.fileExplorer.registerViewer({ id: 'cif-viewer', // unique; 'auto' | 'text' | 'binary' are reserved label: 'My CIF Preview', exts: ['cif'], component: CifPreview, priority: 10, }) return () => dispose() }, 'my-preview: client') } function CifPreview(props: PreviewProps) { if (props.preview.kind !== 'text') return null // props.preview.content 即文件文本——解析并渲染。 return renderStructure(props.preview.content) } ``` 要点: - **服务名**为 `'fileExplorer'`,用 `inject: ['fileExplorer']` 注入。 - **优先级**:数值越大越优先;内置为 `0`,用 `10` 覆盖。同优先级后注册者胜。 - **`registerViewer`** 一次调用即可跨所有 `exts` 注册一个具名查看器,并返回单个 disposer——在 `ctx.effect` 清理中调用,以便 HMR/卸载时移除注册。请使用稳定且唯一的 `id`;`auto`/`text`/`binary` 为保留 id。 - **`registerPreview`** 仍可用于单个扩展名的快速匿名覆盖,或作为 v0.9.0 之前核心的 降级方案(见上文的探测降级片段)。 ## 处理大文件和二进制文件(`readRawFile`) 对于需要预览超过核心 2 MiB 文本上限(`maxTextBytes`)的文件,或核心返回 `{ kind: 'binary' }` 的二进制格式,请使用 `readRawFile`。 ### 检测 `readRawFile` 是否可用 `readRawFile` 在 dsh-file-explorer v0.1.0 中加入。旧版本核心没有此方法,因此你的 扩展应探测并优雅降级: ```typescript import type { FileExplorerService } from '@dsh-external/dsh-file-explorer/client' type MyFileExplorer = FileExplorerService & { readRawFile?: (path: string, offset?: number, limit?: number, signal?: AbortSignal) => Promise } export function apply(ctx: { fileExplorer: MyFileExplorer; ... }): void { ctx.effect(() => { const readRaw = typeof ctx.fileExplorer.readRawFile === 'function' ? ctx.fileExplorer.readRawFile : undefined const component = makeMyPreview(readRaw, t) const dispose = ctx.fileExplorer.registerViewer({ id: 'molstar', label: 'Mol* Structure', exts: EXTS, component, priority: 10, }) return () => dispose() }) } ``` ### 在预览组件中使用 ```typescript type ReadRaw = (path: string, offset?: number, limit?: number, signal?: AbortSignal) => Promise function MyPreview({ preview, filePath, readRaw }: PreviewProps & { readRaw?: ReadRaw }) { const [data, setData] = useState(null) useEffect(() => { if (preview.kind === 'empty') return // 小文本文件:直接使用 preview.content if (preview.kind === 'text') { parseAndRender(preview.content) return } // 大文件或二进制文件:获取原始字节 if (preview.kind === 'too-large' || preview.kind === 'binary') { if (!readRaw) { showError('文件过大——请升级 dsh-file-explorer 以预览此文件') return } readRaw(filePath).then(setData).catch(handleError) return } }, [preview, filePath]) } ``` molstar 插件的 `MolstarPreview.tsx` 是此模式的参考实现:它检查 `preview.kind`,对 文本使用 `content`,对 `too-large`/`binary` 调用 `readRaw(filePath)`。 ### 使用字节范围 对于非常大的文件,可以先只读取头部/元数据: ```typescript // 读取前 4 KiB 检查文件头 const header = await readRaw(filePath, 0, 4096) // 读取第 1 MiB 到第 2 MiB const chunk = await readRaw(filePath, 1048576, 1048576) ``` `limit` 参数在服务端受 `maxRawBytes` 限制(默认 100 MiB)。 ## 带保存的编辑(`writeFile`) 通过工厂闭包将 `writeFile` 传入组件: ```typescript export function apply(ctx: { fileExplorer: FileExplorerService; ... }): void { ctx.effect(() => { const component = makeMyPreview(ctx.fileExplorer.writeFile, t) const dispose = ctx.fileExplorer.registerViewer({ id: 'code', label: 'Code', exts: EXTS, component, priority: 10, }) return () => dispose() }) } ``` 在组件中调用 `writeFile(filePath, content)` 进行保存。code 插件的 `CodePreview.tsx` 是参考实现:末次按键 500ms 后自动保存,加 `Ctrl/Cmd+S` 立即保存。 ## 国际化 在 `fileExplorer` 之外再注入 `locale`,用自己的命名空间注册 `zh`/`en` 字典,并绑定 翻译器: ```typescript export const inject = ['fileExplorer', 'locale'] export function apply(ctx: { fileExplorer: FileExplorerService locale: { register(ns: string, locale: string, dict: Record): () => void bind(ns: string): Translate } effect(cb: () => (() => void), label?: string): void }): void { ctx.effect(() => { const d1 = ctx.locale.register('my-preview', 'zh', { hello: '你好' }) const d2 = ctx.locale.register('my-preview', 'en', { hello: 'Hello' }) const t = ctx.locale.bind('my-preview') const component = makeMyPreview(t) const dispose = ctx.fileExplorer.registerViewer({ id: 'my-preview', label: 'My Preview', exts: EXTS, component, priority: 10, }) return () => { dispose() d1(); d2() } }) } ``` 注意:`PreviewProps.t` 绑定在 *file-explorer* 命名空间(`emptyFile`/`tooLarge`/ `hexTruncated`/…)。请为自己的文案绑定自己的命名空间。 ## CSS 注入 外部插件无法导入 CSS 模块。通过 `