- 新 src/client/EditorHost.tsx:'editor' tab 组件。matchFileViewer → 按 fetchStrategy 分发(纯逻辑在 editor-load.ts)→ 渲染 viewer.component; binary 无匹配时渲染共享 BinaryDownload。标题栏保留在 host - 新 src/client/TextEditor.tsx:code/markdown 的 viewer 组件(CodeMirror + 保存/脏点/截断横幅 + md 预览/编辑切换),内容由 host 经 fsRead 传入, 自身不再 fetch;按 viewerId === 'markdown' 分支(不再解析扩展名) - 新 src/client/editor-load.ts:planFirstMatch / planFsReadOutcome / decodeHead 纯函数,策略分发可单测 - 新 src/client/binary-download.tsx:共享下载兜底组件(viewer 注册与 host fallback 同一 UI) - host src/index.ts:fs.read 二进制响应增加 head 字段(base64,前 4KB, 唯一 host 触碰,设计文档 detect 机制所需);客户端 FsBinaryResult 同步 - builtins:注册 8 个 viewer(新增 markdown exts md/markdown、code catch-all -100);binary-download 增加 NUL detect + 复用 BinaryDownload - 删除 src/client/EditorView.tsx(职责一分为二);陈旧注释同步 - 新增 tests/editor-load.spec.ts(12 用例)
93 lines
3.9 KiB
TypeScript
93 lines
3.9 KiB
TypeScript
/**
|
|
* Pure editor-load planning: the decision logic the editor host needs to
|
|
* turn a matched file viewer + a host fs.read result into a render action.
|
|
* Kept dependency-free (no React, no fetch) so the strategy dispatch is
|
|
* unit-testable and the wire contract (head bytes, binary flag) is pinned.
|
|
*
|
|
* The host flow this module drives:
|
|
* 1. `matchFileViewer(path)` picks a viewer by extension/priority.
|
|
* 2. `planFirstMatch` dispatches its fetchStrategy.
|
|
* 3. An fsRead viewer fetches through the host; `planFsReadOutcome`
|
|
* decides what to do with the result — including the head-based
|
|
* re-match that lets a `detect` viewer claim a binary the extension
|
|
* match could not see (the builtin NUL probe on `binary-download`).
|
|
*/
|
|
import type { FileViewerDescriptor } from './service.ts'
|
|
|
|
/** One host fs.read result (mirror of the wire; `head` present when binary). */
|
|
export interface FsReadOutcome {
|
|
binary: boolean
|
|
content: string
|
|
truncated: boolean
|
|
/** base64 of the first bytes (present on binary reads; sniffing material). */
|
|
head?: string
|
|
}
|
|
|
|
/** What the editor host should do next. */
|
|
export type EditorLoadAction =
|
|
/** No renderer: show the download UI. */
|
|
| { kind: 'binary' }
|
|
/** Render `viewer`'s component with the carried payload. */
|
|
| { kind: 'render'; viewer: FileViewerDescriptor; content?: string; truncated?: boolean; mediaUrl?: string; customData?: unknown }
|
|
/** Fetch the file through the host (fsRead strategy). */
|
|
| { kind: 'fetchFsRead'; viewer: FileViewerDescriptor }
|
|
/** Call the viewer's load() and render with its return value. */
|
|
| { kind: 'customLoad'; viewer: FileViewerDescriptor }
|
|
|
|
/** Decode the host's base64 head bytes into the sniffing buffer. */
|
|
export function decodeHead(headBase64: string): Uint8Array {
|
|
const binary = atob(headBase64)
|
|
const bytes = new Uint8Array(binary.length)
|
|
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i)
|
|
return bytes
|
|
}
|
|
|
|
/**
|
|
* Dispatch one matched viewer's fetchStrategy. A missing viewer or a
|
|
* `binary-download` strategy both mean "no client-side renderer" → the
|
|
* download UI. `mediaUrlOf` builds the media URL for `mediaUrl`/`none`
|
|
* strategies (pure, but scope-bound — injected by the host).
|
|
*/
|
|
export function planFirstMatch(
|
|
viewer: FileViewerDescriptor | undefined,
|
|
mediaUrlOf: () => string,
|
|
): EditorLoadAction {
|
|
if (viewer === undefined || viewer.fetchStrategy === 'binary-download') return { kind: 'binary' }
|
|
switch (viewer.fetchStrategy) {
|
|
case 'mediaUrl':
|
|
case 'none':
|
|
return { kind: 'render', viewer, mediaUrl: mediaUrlOf() }
|
|
case 'custom':
|
|
return { kind: 'customLoad', viewer }
|
|
case 'fsRead':
|
|
return { kind: 'fetchFsRead', viewer }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Decide what an fsRead result means for the editor.
|
|
* - Text: the first match stands (content is valid for any fsRead viewer).
|
|
* - Binary: the host head bytes enable a re-match — a `detect` viewer (e.g.
|
|
* a plugin sniffing a binary format) may claim the file. `custom` viewers
|
|
* load their own bytes; `mediaUrl`/`none` viewers render the media route;
|
|
* an fsRead viewer or nothing cannot render binary → download UI.
|
|
*/
|
|
export function planFsReadOutcome(
|
|
viewer: FileViewerDescriptor,
|
|
result: FsReadOutcome,
|
|
rematch: (head: Uint8Array) => FileViewerDescriptor | undefined,
|
|
mediaUrlOf: () => string,
|
|
): EditorLoadAction {
|
|
if (!result.binary) {
|
|
return { kind: 'render', viewer, content: result.content, truncated: result.truncated }
|
|
}
|
|
const claimed = result.head === undefined ? undefined : rematch(decodeHead(result.head))
|
|
if (claimed !== undefined && claimed.fetchStrategy === 'custom') {
|
|
return { kind: 'customLoad', viewer: claimed }
|
|
}
|
|
if (claimed !== undefined && (claimed.fetchStrategy === 'mediaUrl' || claimed.fetchStrategy === 'none')) {
|
|
return { kind: 'render', viewer: claimed, mediaUrl: mediaUrlOf() }
|
|
}
|
|
return { kind: 'binary' }
|
|
}
|