# Developing a dsh-file-explorer extension [中文](developing-extensions.zh.md) | English This guide covers everything you need to build a preview plugin (or "extension") for [dsh-file-explorer](https://github.com/wolfsonliu/dsh-file-explorer). Three reference implementations are available: | Extension | What it previews | Key patterns | |-----------|-----------------|--------------| | [dsh-file-explorer-preview-code](https://github.com/wolfsonliu/dsh-file-explorer-preview-code) | Code files with syntax highlighting + editing | `writeFile`, text-only | | [dsh-file-explorer-preview-molstar](https://github.com/wolfsonliu/dsh-file-explorer-preview-molstar) | Protein/small-molecule structures (`.cif`/`.pdb`/…) | `readRawFile`, large + binary files | | [dsh-file-explorer-preview-sequence](https://github.com/wolfsonliu/dsh-file-explorer-preview-sequence) | DNA/RNA sequences (`.gb`/`.fasta`/…) | `readRawFile`, large + binary files | ## Architecture ``` dsh-file-explorer (core) └─ client apply: ctx.reflect.provide('fileExplorer', { registerPreview, registerViewer, registerFileAction, writeFile, readRawFile }) dsh-file-explorer-preview- (your extension) └─ inject: ['fileExplorer', 'locale'] └─ apply: ctx.fileExplorer.registerViewer({ id, label, exts: [...], component: MyPreview, priority: 10 }) ``` The core registers built-in previewers at priority `0` (text, markdown, image, CSV, binary). Your extension registers at priority `10` to override them. Higher priority wins; equal priority: later registration wins. ## The contract Types come from the core package's `./client` export: ```typescript import type { FileExplorerService, PreviewProps, ViewerRegistration, FileAction, FileActionHelpers, Translate, } from '@dsh-external/dsh-file-explorer/client' ``` ### `FileExplorerService` ```typescript interface FileExplorerService { /** Register a preview component for a file extension (lowercase, no dot). */ registerPreview(ext: string, component: ComponentType, priority?: number): () => void /** * Register one viewer across multiple extensions under a single identity * (one "Open with…" list entry per id). Reserved ids: 'auto' | 'text' | 'binary'. */ registerViewer(viewer: { id: string; label: string; exts: string[]; component: ComponentType; priority?: number }): () => void /** Register a file-row action (appears in the row "···" menu). */ registerFileAction(action: FileAction): () => void /** Write UTF-8 text to a workspace file. */ writeFile(path: string, content: string): Promise /** * Read raw bytes from a workspace file, with optional byte range. * @param path Workspace-relative file path. * @param offset Byte offset (default 0). * @param limit Max bytes to read (capped server-side by maxRawBytes, default 100 MiB). * @param signal Optional AbortSignal to cancel the read. */ readRawFile(path: string, offset?: number, limit?: number, signal?: AbortSignal): Promise } ``` ### `PreviewProps` and `FilePreview` ```typescript interface PreviewProps { preview: FilePreview filePath: string // workspace-relative path t: Translate // (key, params?) => string (bound to the file-explorer namespace) 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 } ``` ### How routing works `resolvePreviewFor(preview, ext, readRawFile?, viewerId?)` decides which component renders a file. When the user has forced a viewer (Open with… or the panel switcher), `viewerId` selects that viewer directly — it wins over the priority routing below for any non-`empty` preview; `empty` always stays on the status page, and unknown ids fall through to the default routing: ``` preview.kind === 'image' → your registered component, or ImagePreview (fallback) preview.kind === 'empty' → BinaryPreview (status page) — never overridden preview.kind === 'text' → your registered component, or TextPreview (fallback) preview.kind === 'binary' → your registered component, or BinaryPreview (fallback) preview.kind === 'too-large' → your registered component, or BinaryPreview (fallback) preview.kind === 'text-large' → your registered component, or the built-in paged text preview ``` The key change (dsh-file-explorer v0.1.0+): `too-large` and `binary` previews are now **forwarded to registered extension components** instead of being hard-routed to the status page. This means your extension can handle large files and binary formats by calling `readRawFile`. `image` previews are likewise forwarded to your registered component when one is registered for the file's extension; otherwise they fall back to the built-in `ImagePreview`. - If your extension is registered for extension `cif`, a `too-large` `.cif` file is routed to your component — you call `readRawFile` to get the bytes. - If no extension is registered for `dat`, a `too-large` `.dat` file still falls back to the built-in status page ("File too large to preview"). ### Registering your viewer — `registerViewer` (recommended) For a new plugin, prefer `registerViewer`: one call registers a single identity across every extension, and its `label` is the name shown in the "Open with…" list and the panel switcher: ```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() }) ``` Users pick your viewer per file via the row "···" menu → **Open with…**, or the viewer switcher in the preview-panel title bar. The choice is one-shot: plain `open` still resolves by priority. ### Anonymous viewers — `registerPreview` `registerPreview(ext, component, priority?)` registers an *anonymous* viewer for a single extension: it works, but the "Open with…" list cannot name it (it shows "Extension viewer"), and each extension becomes its own list entry. Use it only for a quick single-extension override without a label, or as a fallback for older cores. `registerViewer` shipped in v0.9.0 — probe and degrade on older cores: ```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() } } ``` ## Minimal skeleton (read-only, text only) ```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 is the file text — parse and render it. return renderStructure(props.preview.content) } ``` Key points: - **Service name** is `'fileExplorer'`. Inject it with `inject: ['fileExplorer']`. - **Priority** — higher wins; built-ins use `0`, use `10` to override. Equal priority: later registration wins. - **`registerViewer`** registers one named viewer across all your `exts` in one call and returns a single disposer — call it in `ctx.effect` cleanup so HMR/unload removes the registration. Give it a stable, unique `id`; `auto`/`text`/`binary` are reserved. - **`registerPreview`** remains available for a quick anonymous single-extension override, or as the fallback for cores older than v0.9.0 (see the probe-and-degrade snippet above). ## Handling large and binary files with `readRawFile` For extensions that need to preview files larger than the core's 2 MiB text cap (`maxTextBytes`), or binary formats that the core returns as `{ kind: 'binary' }`, use `readRawFile`. ### Detecting `readRawFile` availability `readRawFile` was added in dsh-file-explorer v0.1.0. Older versions of the core don't have it, so your extension should probe and degrade gracefully: ```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() }) } ``` ### In the preview component ```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 // Small text files: use preview.content directly if (preview.kind === 'text') { parseAndRender(preview.content) return } // Large or binary files: fetch raw bytes if (preview.kind === 'too-large' || preview.kind === 'binary') { if (!readRaw) { showError('File too large — upgrade dsh-file-explorer to preview this file') return } readRaw(filePath).then(setData).catch(handleError) return } }, [preview, filePath]) } ``` The molstar plugin's `MolstarPreview.tsx` is the reference implementation of this pattern: it checks `preview.kind`, uses `content` for text, and calls `readRaw(filePath)` for `too-large`/`binary`. ### Using byte ranges For very large files you can read only the header/metadata first: ```typescript // Read the first 4 KiB to inspect a file header const header = await readRaw(filePath, 0, 4096) // Read bytes 1 MiB to 2 MiB const chunk = await readRaw(filePath, 1048576, 1048576) ``` The `limit` parameter is capped server-side by `maxRawBytes` (default 100 MiB). ## Editing with `writeFile` Pass `writeFile` into your component via a factory closure: ```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() }) } ``` Inside the component, call `writeFile(filePath, content)` to save. The code plugin's `CodePreview.tsx` is the reference: autosave 500ms after the last keystroke, plus `Ctrl/Cmd+S` immediate save. ## Internationalization Inject `locale` alongside `fileExplorer`, register your own `zh`/`en` dictionaries, and bind a translator: ```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() } }) } ``` Note: `PreviewProps.t` is bound to the *file-explorer* namespace (`emptyFile`/`tooLarge`/ `hexTruncated`/…). Bind your own namespace for your own copy. ## CSS injection External plugins can't import CSS modules. Inject styles via a `