diff --git a/src/client/EditorHost.tsx b/src/client/EditorHost.tsx new file mode 100644 index 0000000..6e1c202 --- /dev/null +++ b/src/client/EditorHost.tsx @@ -0,0 +1,101 @@ +/** + * The editor tab host: resolves a file's previewer through the sidebar + * registry (`matchFileViewer`), fetches bytes per the matched viewer's + * fetch strategy, and renders its component — or the shared download pane + * when nothing can render the file. The header shows the file title; the + * editable code/markdown viewers render their own toolbar below it. + * + * The strategy dispatch is pure (planFirstMatch / planFsReadOutcome in + * editor-load.ts); this component only wires it to the host APIs. + */ +import { useEffect, useState } from 'react' +import { createElement } from 'react' +import type { Context } from '../context-types.ts' +import { api, mediaUrl, type SessionScope } from './api.ts' +import { BinaryDownload } from './binary-download.tsx' +import { planFirstMatch, planFsReadOutcome, type EditorLoadAction } from './editor-load.ts' +import { t } from './locales.ts' +import type { FileViewerDescriptor } from './service.ts' +import type { SidebarStore } from './state.ts' +import css from './sidebar.module.css' + +type EditorLoad = + | { status: 'loading' } + | { status: 'error'; message: string } + | { status: 'ready'; viewer: FileViewerDescriptor; content?: string; truncated?: boolean; mediaUrl?: string; customData?: unknown } + | { status: 'binary' } + +export function EditorHost(props: { ctx: Context; store: SidebarStore; scope: SessionScope; path: string; title: string }) { + const { ctx, store, scope, path, title } = props + const [load, setLoad] = useState({ status: 'loading' }) + + useEffect(() => { + let cancelled = false + setLoad({ status: 'loading' }) + const mediaUrlOf = (): string => mediaUrl(scope, path) + const apply = (action: EditorLoadAction): void => { + if (cancelled) return + switch (action.kind) { + case 'binary': + setLoad({ status: 'binary' }) + return + case 'render': + setLoad({ + status: 'ready', + viewer: action.viewer, + content: action.content, + truncated: action.truncated, + mediaUrl: action.mediaUrl, + customData: action.customData, + }) + return + case 'customLoad': + void action.viewer.load?.(path, scope).then((data) => { + if (cancelled) return + setLoad({ status: 'ready', viewer: action.viewer, customData: data }) + }).catch((error: unknown) => { + if (cancelled) return + setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) }) + }) + return + case 'fetchFsRead': + api.fsRead(scope, path).then((result) => { + if (cancelled) return + // Binary reads carry the head bytes for the detect re-match. + const outcome = planFsReadOutcome(action.viewer, { + binary: result.kind === 'binary', + content: result.kind === 'text' ? result.content : '', + truncated: result.truncated, + head: result.kind === 'binary' ? result.head : undefined, + }, (head) => ctx.betterSidebar?.matchFileViewer(path, head), mediaUrlOf) + apply(outcome) + }).catch((error: unknown) => { + if (cancelled) return + setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) }) + }) + return + } + } + apply(planFirstMatch(ctx.betterSidebar?.matchFileViewer(path), mediaUrlOf)) + return () => { cancelled = true } + }, [scope.sessionId, scope.cwd, path, ctx]) + + return ( +
+
+ {title} +
+ {load.status === 'loading' &&
{t('loading')}
} + {load.status === 'error' &&
{load.message}
} + {load.status === 'binary' && } + {load.status === 'ready' && createElement(load.viewer.component, { + ctx, store, scope, path, title, + viewerId: load.viewer.id, + content: load.content, + truncated: load.truncated, + mediaUrl: load.mediaUrl, + customData: load.customData, + })} +
+ ) +} diff --git a/src/client/EditorView.tsx b/src/client/EditorView.tsx deleted file mode 100644 index 0cf29cd..0000000 --- a/src/client/EditorView.tsx +++ /dev/null @@ -1,285 +0,0 @@ -/** - * The file editor tab: a CodeMirror 6 editor with line wrapping and syntax - * highlighting (extension-keyed language), a dirty dot and Ctrl/Cmd+S save, - * image viewing through the media route, and a plain notice for binary - * files. Markdown files open in rendered preview with a preview/edit toggle: - * edit mode shows the source in the editor, preview renders the live draft, - * and unsaved edits survive the toggle. Reads cap at the host's 512KB bound - * (a banner marks truncation). - */ -import { createElement, useEffect, useRef, useState } from 'react' -import clsx from 'clsx' -import { EditorState } from '@codemirror/state' -import { EditorView as CodeMirrorView, keymap, lineNumbers } from '@codemirror/view' -import { defaultKeymap, history, historyKeymap } from '@codemirror/commands' -import { IconCheckOutline16, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' -import type { Context } from '../context-types.ts' -import { api, downloadUrl, mediaUrl, type SessionScope } from './api.ts' -import { languageForPath } from './lang.ts' -import { cmSurfaceTheme, CmThemeCompartment } from './cm-themes.ts' -import { isDarkScheme, subscribeColorScheme } from './theme.ts' -import { t } from './locales.ts' -import type { FileViewerDescriptor } from './service.ts' -import type { SidebarStore } from './state.ts' -import css from './sidebar.module.css' - -const MD_EXT = ['.md', '.markdown'] - -type EditorLoad = - | { status: 'loading' } - | { status: 'error'; message: string } - | { status: 'ready'; viewer?: FileViewerDescriptor; kind?: 'text' | 'md'; content?: string; truncated?: boolean; mediaUrl?: string; customData?: unknown } - | { status: 'binary' } // unknown binary / OLE legacy formats → download button - -/** Previewable files (rendered output vs source editing). */ -type ViewMode = 'preview' | 'edit' - -export function EditorView(props: { ctx: Context; store: SidebarStore; scope: SessionScope; path: string; title: string }) { - const { ctx, store, scope, path, title } = props - const [load, setLoad] = useState({ status: 'loading' }) - const [mode, setMode] = useState('preview') - /** The editor's current text (null while clean); preview renders this. */ - const [draft, setDraft] = useState(null) - const [dirty, setDirty] = useState(false) - const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved' | 'failed'>('idle') - const hostRef = useRef(null) - const viewRef = useRef(null) - const savingRef = useRef(false) - /** The theme compartment of the current view (reconfigured on scheme flip). */ - const themeCompRef = useRef(null) - /** The app's resolved color scheme; the editor re-themes in place on flips. */ - const [dark, setDark] = useState(() => isDarkScheme()) - - useEffect(() => subscribeColorScheme(() => { setDark(isDarkScheme()) }), []) - - useEffect(() => { - let cancelled = false - setLoad({ status: 'loading' }) - setMode('preview') - setDraft(null) - setDirty(false) - setSaveState('idle') - const ext = extOfPath(path) - // Registry-driven viewer match: image/pdf/office/binary-download etc. - // are registered through ctx.betterSidebar; code/markdown have no - // registered viewer and fall through to the fsRead + CodeMirror path. - const viewer = ctx.betterSidebar?.matchFileViewer(path) - if (viewer !== undefined) { - switch (viewer.fetchStrategy) { - case 'mediaUrl': - case 'none': - setLoad({ status: 'ready', viewer, mediaUrl: mediaUrl(scope, path) }) - return - case 'binary-download': - setLoad({ status: 'binary' }) - return - case 'custom': - void viewer.load?.(path, scope).then((data) => { - if (cancelled) return - setLoad({ status: 'ready', viewer, customData: data }) - }).catch((error: unknown) => { - if (cancelled) return - setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) }) - }) - return - case 'fsRead': - // An fsRead viewer (e.g. external CSV viewer) gets its content - // through the host's fs.read and renders through viewer.component. - api.fsRead(scope, path).then((result) => { - if (cancelled) return - if (result.kind === 'binary') { setLoad({ status: 'binary' }); return } - setLoad({ status: 'ready', viewer, content: result.content, truncated: result.truncated }) - }).catch((error: unknown) => { - if (cancelled) return - setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) }) - }) - return - } - } - // Fallback: code/markdown (no registered viewer) — fsRead + CodeMirror. - api.fsRead(scope, path).then((result) => { - if (cancelled) return - if (result.kind === 'binary') { - setLoad({ status: 'binary' }) - return - } - setLoad({ - status: 'ready', - kind: MD_EXT.includes(ext) ? 'md' : 'text', - content: result.content, - truncated: result.truncated, - }) - }).catch((error: unknown) => { - if (cancelled) return - setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) }) - }) - return () => { cancelled = true } - }, [scope.sessionId, scope.cwd, path, ctx]) - - // Create the CodeMirror editor once the file is loaded. The view owns the - // document; React only tracks dirty/draft state through the update - // listener. For markdown the view stays mounted while previewing (hidden), - // so unsaved edits survive the preview/edit toggle. The theme + syntax - // colors live in a compartment so a scheme flip reconfigures only that - // part — the document, undo history and scroll position survive. - useEffect(() => { - if (load.status !== 'ready' || load.viewer !== undefined || (load.kind !== 'text' && load.kind !== 'md')) return - const host = hostRef.current - if (host === null) return - const language = languageForPath(path) - const themeComp = new CmThemeCompartment() - themeCompRef.current = themeComp - const state = EditorState.create({ - doc: load.content, - extensions: [ - CodeMirrorView.lineWrapping, - lineNumbers(), - history(), - EditorState.tabSize.of(2), - CodeMirrorView.contentAttributes.of({ spellcheck: 'false' }), - cmSurfaceTheme, - themeComp.of(dark), - ...(language !== null ? [language] : []), - CodeMirrorView.updateListener.of((update) => { - if (update.docChanged) { - setDraft(update.state.doc.toString()) - setDirty(true) - } - }), - keymap.of([ - { - key: 'Mod-s', - preventDefault: true, - run: () => { save(); return true }, - }, - ...defaultKeymap, - ...historyKeymap, - ]), - ], - }) - const view = new CodeMirrorView({ state, parent: host }) - viewRef.current = view - return () => { - view.destroy() - viewRef.current = null - themeCompRef.current = null - } - // The keymap's save() reads live refs; scope/path are stable for a - // tab's lifetime, and the dark flip is handled by the reconfigure - // effect below (recreating the view here would drop the draft). - }, [load]) - - // Scheme flip: re-theme in place (the compartment holds only the - // scheme-dependent extensions; everything else is untouched). - useEffect(() => { - const view = viewRef.current - const themeComp = themeCompRef.current - if (view === null || themeComp === null) return - view.dispatch({ effects: themeComp.reconfigure(dark) }) - }, [dark]) - - // The editor may have been display:none while previewing; re-measure when - // it becomes visible again (CodeMirror sizes itself on reveal). - useEffect(() => { - if (mode === 'edit') viewRef.current?.requestMeasure() - }, [mode]) - - const save = (): void => { - const view = viewRef.current - if (view === null || savingRef.current) return - savingRef.current = true - setSaveState('saving') - api.fsWrite(scope, path, view.state.doc.toString()).then(() => { - savingRef.current = false - setDraft(null) - setDirty(false) - setSaveState('saved') - }).catch(() => { - savingRef.current = false - setSaveState('failed') - }) - } - - const previewable = load.status === 'ready' && load.kind === 'md' - const editable = load.status === 'ready' && (load.kind === 'text' || load.kind === 'md') - const saveLabel = saveState === 'saving' ? t('loading') : saveState === 'saved' ? t('saved') : saveState === 'failed' ? t('saveFailed') : '' - - return ( -
-
- {title} - {previewable && ( -
- - -
- )} - {dirty && } - {editable && ( - - )} - {saveLabel !== '' && {saveLabel}} -
- {load.status === 'loading' &&
{t('loading')}
} - {load.status === 'error' &&
{load.message}
} - {load.status === 'binary' && ( -
- {t('binaryNoPreview')} - - {t('downloadToView')} - -
- )} - {editable && ( - <> - {load.truncated && mode === 'edit' &&
{t('truncation')}
} -
- - )} - {load.status === 'ready' && load.viewer !== undefined && createElement(load.viewer.component, { - ctx, store, scope, path, title, - viewerId: load.viewer.id, - content: load.content, - truncated: load.truncated, - mediaUrl: load.mediaUrl, - customData: load.customData, - })} - {previewable && mode === 'preview' && ( -
- -
- )} -
- ) -} - -/** The lowercased file extension of a path ('' when none). */ -function extOfPath(path: string): string { - const at = path.lastIndexOf('.') - if (at === -1) return '' - const base = path.slice(at).toLowerCase() - return base.includes('/') || base.includes('\\') ? '' : base -} diff --git a/src/client/TextEditor.tsx b/src/client/TextEditor.tsx new file mode 100644 index 0000000..ca72853 --- /dev/null +++ b/src/client/TextEditor.tsx @@ -0,0 +1,193 @@ +/** + * The code/markdown file viewer: a CodeMirror 6 editor with line wrapping, + * syntax highlighting (extension-keyed language), a dirty dot and Ctrl/Cmd+S + * save, and a preview/edit toggle for markdown files. Registered as the + * `code` (catch-all) and `markdown` built-in viewers; the editor tab host + * fetches the content through the fsRead strategy and passes it in props, + * so this component never fetches or dispatches — it only edits. + * + * The toolbar (mode toggle / dirty dot / save / status) renders as its own + * row below the host's title bar, VSCode-style. + */ +import { useEffect, useRef, useState } from 'react' +import clsx from 'clsx' +import { EditorState } from '@codemirror/state' +import { EditorView as CodeMirrorView, keymap, lineNumbers } from '@codemirror/view' +import { defaultKeymap, history, historyKeymap } from '@codemirror/commands' +import { IconCheckOutline16, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' +import { api } from './api.ts' +import { languageForPath } from './lang.ts' +import { cmSurfaceTheme, CmThemeCompartment } from './cm-themes.ts' +import { isDarkScheme, subscribeColorScheme } from './theme.ts' +import { t } from './locales.ts' +import type { FileViewerProps } from './service.ts' +import css from './sidebar.module.css' + +/** Previewable files (rendered output vs source editing). */ +type ViewMode = 'preview' | 'edit' + +export function TextEditor(props: FileViewerProps) { + const { scope, path, viewerId, content, truncated } = props + const [mode, setMode] = useState('preview') + /** The editor's current text (null while clean); preview renders this. */ + const [draft, setDraft] = useState(null) + const [dirty, setDirty] = useState(false) + const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved' | 'failed'>('idle') + const hostRef = useRef(null) + const viewRef = useRef(null) + const savingRef = useRef(false) + /** The theme compartment of the current view (reconfigured on scheme flip). */ + const themeCompRef = useRef(null) + /** The app's resolved color scheme; the editor re-themes in place on flips. */ + const [dark, setDark] = useState(() => isDarkScheme()) + + useEffect(() => subscribeColorScheme(() => { setDark(isDarkScheme()) }), []) + + // A new file (tab switch) starts clean: fresh preview mode, no draft. + useEffect(() => { + setMode('preview') + setDraft(null) + setDirty(false) + setSaveState('idle') + }, [content]) + + // Create the CodeMirror editor once the content is loaded. The view owns + // the document; React only tracks dirty/draft state through the update + // listener. For markdown the view stays mounted while previewing (hidden), + // so unsaved edits survive the preview/edit toggle. The theme + syntax + // colors live in a compartment so a scheme flip reconfigures only that + // part — the document, undo history and scroll position survive. + useEffect(() => { + if (content === undefined) return + const host = hostRef.current + if (host === null) return + const language = languageForPath(path) + const themeComp = new CmThemeCompartment() + themeCompRef.current = themeComp + const state = EditorState.create({ + doc: content, + extensions: [ + CodeMirrorView.lineWrapping, + lineNumbers(), + history(), + EditorState.tabSize.of(2), + CodeMirrorView.contentAttributes.of({ spellcheck: 'false' }), + cmSurfaceTheme, + themeComp.of(dark), + ...(language !== null ? [language] : []), + CodeMirrorView.updateListener.of((update) => { + if (update.docChanged) { + setDraft(update.state.doc.toString()) + setDirty(true) + } + }), + keymap.of([ + { + key: 'Mod-s', + preventDefault: true, + run: () => { save(); return true }, + }, + ...defaultKeymap, + ...historyKeymap, + ]), + ], + }) + const view = new CodeMirrorView({ state, parent: host }) + viewRef.current = view + return () => { + view.destroy() + viewRef.current = null + themeCompRef.current = null + } + // The keymap's save() reads live refs; scope/path are stable for a + // tab's lifetime, and the dark flip is handled by the reconfigure + // effect below (recreating the view here would drop the draft). + }, [content, path]) + + // Scheme flip: re-theme in place (the compartment holds only the + // scheme-dependent extensions; everything else is untouched). + useEffect(() => { + const view = viewRef.current + const themeComp = themeCompRef.current + if (view === null || themeComp === null) return + view.dispatch({ effects: themeComp.reconfigure(dark) }) + }, [dark]) + + // The editor may have been display:none while previewing; re-measure when + // it becomes visible again (CodeMirror sizes itself on reveal). + useEffect(() => { + if (mode === 'edit') viewRef.current?.requestMeasure() + }, [mode]) + + const save = (): void => { + const view = viewRef.current + if (view === null || savingRef.current) return + savingRef.current = true + setSaveState('saving') + api.fsWrite(scope, path, view.state.doc.toString()).then(() => { + savingRef.current = false + setDraft(null) + setDirty(false) + setSaveState('saved') + }).catch(() => { + savingRef.current = false + setSaveState('failed') + }) + } + + const markdown = viewerId === 'markdown' + const editable = content !== undefined + const saveLabel = saveState === 'saving' ? t('loading') : saveState === 'saved' ? t('saved') : saveState === 'failed' ? t('saveFailed') : '' + + return ( + <> +
+ {markdown && ( +
+ + +
+ )} + {dirty && } + {editable && ( + + )} + {saveLabel !== '' && {saveLabel}} +
+ {editable && ( + <> + {truncated === true && mode === 'edit' &&
{t('truncation')}
} +
+ + )} + {markdown && mode === 'preview' && ( +
+ +
+ )} + + ) +} diff --git a/src/client/api.ts b/src/client/api.ts index 5583565..8fd943c 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -54,8 +54,9 @@ export interface GitLogEntry { /** Text read result. */ export interface FsTextResult { kind: 'text'; content: string; truncated: boolean } -/** Binary read result (no content; images load through the media route). */ -export interface FsBinaryResult { kind: 'binary'; size: number; truncated: boolean } +/** Binary read result (no content; images load through the media route). + * `head` carries the first bytes (base64) for viewer detect sniffing. */ +export interface FsBinaryResult { kind: 'binary'; size: number; truncated: boolean; head: string } async function call(method: string, payload: Record, signal?: AbortSignal): Promise { let response: Response diff --git a/src/client/binary-download.tsx b/src/client/binary-download.tsx new file mode 100644 index 0000000..71ea3ef --- /dev/null +++ b/src/client/binary-download.tsx @@ -0,0 +1,23 @@ +/** + * The "no preview — download instead" pane: shown by the editor host when no + * viewer can render a file (binary without a registered renderer, or the + * `binary-download` strategy) and registered as the `binary-download` viewer + * component so the declarative route and the host fallback share one UI. + */ +import type { ReactNode } from 'react' +import type { SessionScope } from './api.ts' +import { downloadUrl } from './api.ts' +import { t } from './locales.ts' +import css from './sidebar.module.css' + +export function BinaryDownload(props: { scope: SessionScope; path: string }): ReactNode { + const { scope, path } = props + return ( +
+ {t('binaryNoPreview')} + + {t('downloadToView')} + +
+ ) +} diff --git a/src/client/builtins.tsx b/src/client/builtins.tsx index cc1b044..229bb68 100644 --- a/src/client/builtins.tsx +++ b/src/client/builtins.tsx @@ -1,22 +1,24 @@ /** * Built-in tab types and file viewers: the plugin registers its own * built-in pages (explorer / git / terminal / subagent / editor / diff) - * and file previewers (image / pdf / docx / xlsx / pptx / binary-download) - * through the same {@link BetterSidebarService} external plugins use — - * eating its own dogfood. Code/Markdown editing stays inlined in - * {@link EditorView} as the catch-all fallback (no matching viewer → - * fsRead + CodeMirror/MarkdownText). + * and file previewers (image / pdf / docx / xlsx / pptx / markdown / code / + * binary-download) through the same {@link BetterSidebarService} external + * plugins use — eating its own dogfood. Code/markdown are registered + * viewers like any other (fsRead strategy, {@link TextEditor}); the + * `binary-download` viewer sniffs NUL bytes via `detect` for unknown + * binaries and serves doc/xls/ppt by extension. */ import { IconBranchOutline16, IconCodeOutline16, IconFolderOpen16, IconThinkOutline16, } from '@deepseek-ai/dsh-client-ui-primitives' import type { Context } from '../context-types.ts' import { allLeaves, isAgentTabId, type SidebarState } from './state.ts' -import { downloadUrl, mediaUrl } from './api.ts' +import { mediaUrl } from './api.ts' import { t } from './locales.ts' import { openSidebarFile } from './intercept.tsx' import { ExplorerView } from './ExplorerView.tsx' -import { EditorView } from './EditorView.tsx' +import { EditorHost } from './EditorHost.tsx' +import { TextEditor } from './TextEditor.tsx' import { TerminalView } from './TerminalView.tsx' import { GitView } from './GitView.tsx' import { DiffTab } from './DiffTab.tsx' @@ -24,6 +26,7 @@ import { SubagentView } from './SubagentView.tsx' import { DocxView, XlsxView } from './office-view.tsx' import { PdfView } from './PdfView.tsx' import { PptxView } from './PptxView.tsx' +import { BinaryDownload } from './binary-download.tsx' import { IconTerminalOutline16, IconDiffOutline16 } from './icons.tsx' import type { BetterSidebarService, FileViewerDescriptor, TabDescriptor } from './service.ts' import css from './sidebar.module.css' @@ -49,7 +52,7 @@ function builtinTabs(ctx: Context): readonly TabDescriptor[] { hidden: true, dedupeKey: (tab) => tab.path, component: ({ ctx, store, scope, tab }) => ( - + ), }, { @@ -135,7 +138,7 @@ function builtinTabs(ctx: Context): readonly TabDescriptor[] { ] } -/** The 6 built-in file viewer descriptors (code/markdown stay in EditorView). */ +/** The 8 built-in file viewer descriptors — every preview surface is a registered viewer. */ const builtinViewers: readonly FileViewerDescriptor[] = [ { id: 'image', @@ -179,19 +182,28 @@ const builtinViewers: readonly FileViewerDescriptor[] = [ ), }, + { + id: 'markdown', + exts: ['md', 'markdown'], + fetchStrategy: 'fsRead', + component: (props) => , + }, + { + id: 'code', + exts: [], + priority: -100, + fetchStrategy: 'fsRead', + component: (props) => , + }, { id: 'binary-download', exts: ['doc', 'xls', 'ppt'], priority: -50, fetchStrategy: 'binary-download', - component: ({ scope, path }) => ( -
- {t('binaryNoPreview')} - - {t('downloadToView')} - -
- ), + // NUL probe: a file whose head bytes contain a NUL is binary — claimed + // before the catch-all code viewer on the head re-match. + detect: (_path, head) => head.includes(0), + component: ({ scope, path }) => , }, ] @@ -199,7 +211,7 @@ const builtinViewers: readonly FileViewerDescriptor[] = [ * Register all built-in tabs and viewers with the service. Returns a * disposer that unregisters everything (cordis auto-invokes it on fiber * disposal). The `ctx` is threaded into tab descriptors that need it - * (EditorView reads `ctx.betterSidebar` for file-viewer matching). + * (EditorHost reads `ctx.betterSidebar` for file-viewer matching). */ export function registerBuiltins(ctx: Context, service: BetterSidebarService): () => void { const disposers: (() => void)[] = [] diff --git a/src/client/cm-themes.ts b/src/client/cm-themes.ts index f739179..673c464 100644 --- a/src/client/cm-themes.ts +++ b/src/client/cm-themes.ts @@ -5,7 +5,7 @@ * values, and those come from the same designed palettes the app's code * surfaces use — the one-dark family for dark, the one-light family for * light. The scheme flip reconfigures these via a compartment (see - * EditorView), so the document, undo history and scroll survive re-theming. + * TextEditor), so the document, undo history and scroll survive re-theming. */ import { Compartment } from '@codemirror/state' import { HighlightStyle, syntaxHighlighting } from '@codemirror/language' diff --git a/src/client/editor-load.ts b/src/client/editor-load.ts new file mode 100644 index 0000000..96249ca --- /dev/null +++ b/src/client/editor-load.ts @@ -0,0 +1,92 @@ +/** + * 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' } +} diff --git a/src/client/office-types.ts b/src/client/office-types.ts index 6440cc3..93a35df 100644 --- a/src/client/office-types.ts +++ b/src/client/office-types.ts @@ -1,6 +1,6 @@ /** * Office file type dispatch — pure functions mapping file extensions to - * preview kinds. Kept separate from {@link EditorView} so the mapping is + * preview kinds. Kept separate from the editor host so the mapping is * unit-testable without mounting React or pulling in docx-preview / Univer. * * The host side never inspects Office extensions: the binary NUL probe in @@ -36,7 +36,7 @@ export type OfficeKind = 'docx' | 'xlsx' | 'pptx' | 'download-only' | null /** * Map a lowercased extension (with leading dot) to its preview kind. * - * @param ext - the path extension from {@link EditorView.extOfPath} (`.docx`, + * @param ext - the path extension (with leading dot) (`.docx`, * `.xlsx`, `.pptx`, `.doc`, `.xls`, `.ppt`, …); `''` when the path has none. */ export function officeKindForExt(ext: string): OfficeKind { diff --git a/src/client/office-view.tsx b/src/client/office-view.tsx index 5948cfe..3766aa0 100644 --- a/src/client/office-view.tsx +++ b/src/client/office-view.tsx @@ -234,7 +234,7 @@ export function XlsxView(props: OfficeViewProps): JSX.Element { /** * The shared error / fallback affordance: the failure reason plus a download * link, so the user always has a path to the file. Used by both DocxView and - * XlsxView, and matches the binary placeholder's download link in EditorView. + * XlsxView, and matches the binary placeholder download link. */ function BinaryFallback(props: { scope: SessionScope; path: string; message: string }): JSX.Element { const { scope, path, message } = props diff --git a/src/index.ts b/src/index.ts index 274720c..747455c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -123,8 +123,19 @@ async function resolveGitPath(cwd: string, raw: string): Promise { return requireAbsolute(join(root, raw)) } -/** Text read of a file with the size cap; binary detection via NUL probe. */ -async function readText(path: string, readLimit: number): Promise<{ content: string; truncated: boolean; binary: boolean; size: number }> { +/** How many leading bytes a binary read returns for client-side detect sniffing. */ +const READ_HEAD_LIMIT = 4096 + +/** Text read of a file with the size cap; binary detection via NUL probe. + * Binary reads also return the first {@link READ_HEAD_LIMIT} bytes (base64) + * so the client can re-match viewers by content (`detect`). */ +async function readText(path: string, readLimit: number): Promise<{ + content: string + truncated: boolean + binary: boolean + size: number + head?: string +}> { const info = await stat(path).catch((error: unknown) => { throw new SidebarError('fs-error', `cannot read "${path}": ${error instanceof Error ? error.message : String(error)}`, 400) }) @@ -141,7 +152,10 @@ async function readText(path: string, readLimit: number): Promise<{ content: str const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0) const slice = buffer.subarray(0, bytesRead) const binary = slice.includes(0) - return { content: binary ? '' : slice.toString('utf8'), truncated, binary, size } + const head = binary + ? slice.subarray(0, Math.min(slice.length, READ_HEAD_LIMIT)).toString('base64') + : undefined + return { content: binary ? '' : slice.toString('utf8'), truncated, binary, size, head } } finally { await handle.close() } @@ -194,8 +208,8 @@ function buildApi( // Relative paths are git-derived (status/diff report repo-root-relative // names; the untracked diff view reads the file through this route). const path = await resolveGitPath(cwd, requireString(payload, 'path')) - const { content, truncated, binary, size } = await readText(path, resolved.readLimit) - if (binary) return { kind: 'binary', size, truncated } + const { content, truncated, binary, size, head } = await readText(path, resolved.readLimit) + if (binary) return { kind: 'binary', size, truncated, head } return { kind: 'text', content, truncated } }, 'fs.write': async (payload) => { diff --git a/tests/editor-load.spec.ts b/tests/editor-load.spec.ts new file mode 100644 index 0000000..c4afb2e --- /dev/null +++ b/tests/editor-load.spec.ts @@ -0,0 +1,100 @@ +/** + * Tests for the editor-load planning: the pure strategy dispatch the + * editor host runs (planFirstMatch / planFsReadOutcome / decodeHead). + */ +import { describe, expect, it } from 'vitest' +import { decodeHead, planFirstMatch, planFsReadOutcome } from '../src/client/editor-load.ts' +import type { FileViewerDescriptor } from '../src/client/service.ts' + +const viewer = (over: Partial): FileViewerDescriptor => ({ + id: 'v', + exts: [], + fetchStrategy: 'fsRead', + component: () => null, + ...over, +}) + +describe('planFirstMatch', () => { + it('no viewer → binary (download UI)', () => { + expect(planFirstMatch(undefined, () => '/m')).toEqual({ kind: 'binary' }) + }) + + it('binary-download strategy → binary', () => { + const v = viewer({ id: 'dl', fetchStrategy: 'binary-download' }) + expect(planFirstMatch(v, () => '/m')).toEqual({ kind: 'binary' }) + }) + + it('mediaUrl / none → render with the media URL', () => { + const v = viewer({ id: 'img', fetchStrategy: 'mediaUrl' }) + expect(planFirstMatch(v, () => '/media')).toEqual({ kind: 'render', viewer: v, mediaUrl: '/media' }) + expect(planFirstMatch(viewer({ id: 'none', fetchStrategy: 'none' }), () => '/m').kind).toBe('render') + }) + + it('custom → customLoad (the viewer loads its own bytes)', () => { + const v = viewer({ id: 'csv', fetchStrategy: 'custom', load: async () => [] }) + expect(planFirstMatch(v, () => '/m')).toEqual({ kind: 'customLoad', viewer: v }) + }) + + it('fsRead → fetchFsRead', () => { + const v = viewer({ id: 'code', fetchStrategy: 'fsRead' }) + expect(planFirstMatch(v, () => '/m')).toEqual({ kind: 'fetchFsRead', viewer: v }) + }) +}) + +describe('planFsReadOutcome', () => { + const rematch = (head: Uint8Array): FileViewerDescriptor | undefined => { + // The builtin binary-download NUL probe shape. + if (head.includes(0)) return viewer({ id: 'binary-download', fetchStrategy: 'binary-download' }) + return undefined + } + + it('text result renders with content, same viewer', () => { + const v = viewer({ id: 'code' }) + const action = planFsReadOutcome(v, { binary: false, content: 'hello', truncated: true }, rematch, () => '/m') + expect(action).toEqual({ kind: 'render', viewer: v, content: 'hello', truncated: true }) + }) + + it('binary without head bytes → binary', () => { + expect(planFsReadOutcome(viewer({}), { binary: true, content: '', truncated: false }, rematch, () => '/m')).toEqual({ kind: 'binary' }) + }) + + it('binary whose NUL head re-matches nothing renderable → binary', () => { + const action = planFsReadOutcome( + viewer({}), + { binary: true, content: '', truncated: false, head: 'AAAAAA==' }, // 0x00 bytes + rematch, + () => '/m', + ) + // binary-download claims it — still the download UI. + expect(action).toEqual({ kind: 'binary' }) + }) + + it('binary re-matched to a custom viewer → customLoad (external sniffer)', () => { + const sniffer = viewer({ id: 'my:raw', fetchStrategy: 'custom', load: async () => ({}) }) + const action = planFsReadOutcome( + viewer({}), + { binary: true, content: '', truncated: false, head: 'AAAAAA==' }, + () => sniffer, + () => '/m', + ) + expect(action).toEqual({ kind: 'customLoad', viewer: sniffer }) + }) + + it('binary re-matched to a mediaUrl viewer → render with the media URL', () => { + const media = viewer({ id: 'my:binimg', fetchStrategy: 'mediaUrl' }) + const action = planFsReadOutcome( + viewer({}), + { binary: true, content: '', truncated: false, head: 'AAAAAA==' }, + () => media, + () => '/m?x=1', + ) + expect(action).toEqual({ kind: 'render', viewer: media, mediaUrl: '/m?x=1' }) + }) +}) + +describe('decodeHead', () => { + it('decodes base64 to the exact bytes', () => { + const head = decodeHead('AP//AA==') + expect(Array.from(head)).toEqual([0x00, 0xff, 0xff, 0x00]) + }) +})