feat(editor): 拆分 EditorHost/TextEditor — code/markdown 成为注册 viewer,fs.read 增加 head 字节
- 新 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 用例)
This commit is contained in:
@@ -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<EditorLoad>({ 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 (
|
||||
<div className={css.editor}>
|
||||
<div className={css.editorHeader}>
|
||||
<span className={css.editorTitle} title={path}>{title}</span>
|
||||
</div>
|
||||
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
|
||||
{load.status === 'error' && <div className={css.editorError}>{load.message}</div>}
|
||||
{load.status === 'binary' && <BinaryDownload scope={scope} path={path} />}
|
||||
{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,
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<EditorLoad>({ status: 'loading' })
|
||||
const [mode, setMode] = useState<ViewMode>('preview')
|
||||
/** The editor's current text (null while clean); preview renders this. */
|
||||
const [draft, setDraft] = useState<string | null>(null)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved' | 'failed'>('idle')
|
||||
const hostRef = useRef<HTMLDivElement>(null)
|
||||
const viewRef = useRef<CodeMirrorView | null>(null)
|
||||
const savingRef = useRef(false)
|
||||
/** The theme compartment of the current view (reconfigured on scheme flip). */
|
||||
const themeCompRef = useRef<CmThemeCompartment | null>(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 (
|
||||
<div className={css.editor}>
|
||||
<div className={css.editorHeader}>
|
||||
<span className={css.editorTitle} title={path}>{title}</span>
|
||||
{previewable && (
|
||||
<div className={css.editorModeToggle}>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.editorModeButton, mode === 'preview' && css.editorModeActive)}
|
||||
onClick={() => { setMode('preview') }}
|
||||
>
|
||||
{t('preview')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.editorModeButton, mode === 'edit' && css.editorModeActive)}
|
||||
onClick={() => { setMode('edit') }}
|
||||
>
|
||||
{t('edit')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{dirty && <span className={css.dirtyDot} title={t('unsaved')} />}
|
||||
{editable && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={t('save')}
|
||||
title={`${t('save')} (Ctrl/Cmd+S)`}
|
||||
onClick={save}
|
||||
>
|
||||
<IconCheckOutline16 />
|
||||
</button>
|
||||
)}
|
||||
{saveLabel !== '' && <span className={clsx(css.editorStatus, saveState === 'failed' && css.editorStatusError)}>{saveLabel}</span>}
|
||||
</div>
|
||||
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
|
||||
{load.status === 'error' && <div className={css.editorError}>{load.message}</div>}
|
||||
{load.status === 'binary' && (
|
||||
<div className={css.editorBinary}>
|
||||
<span className={css.editorBinaryNotice}>{t('binaryNoPreview')}</span>
|
||||
<a className={css.editorDownloadLink} href={downloadUrl(scope, path)} download>
|
||||
{t('downloadToView')}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{editable && (
|
||||
<>
|
||||
{load.truncated && mode === 'edit' && <div className={css.editorBanner}>{t('truncation')}</div>}
|
||||
<div
|
||||
className={clsx(css.editorCm, previewable && mode === 'preview' && css.editorCmHidden)}
|
||||
ref={hostRef}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{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' && (
|
||||
<div className={css.editorMd}>
|
||||
<MarkdownText text={draft ?? load.content ?? ''} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
@@ -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<ViewMode>('preview')
|
||||
/** The editor's current text (null while clean); preview renders this. */
|
||||
const [draft, setDraft] = useState<string | null>(null)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved' | 'failed'>('idle')
|
||||
const hostRef = useRef<HTMLDivElement>(null)
|
||||
const viewRef = useRef<CodeMirrorView | null>(null)
|
||||
const savingRef = useRef(false)
|
||||
/** The theme compartment of the current view (reconfigured on scheme flip). */
|
||||
const themeCompRef = useRef<CmThemeCompartment | null>(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 (
|
||||
<>
|
||||
<div className={css.editorHeader}>
|
||||
{markdown && (
|
||||
<div className={css.editorModeToggle}>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.editorModeButton, mode === 'preview' && css.editorModeActive)}
|
||||
onClick={() => { setMode('preview') }}
|
||||
>
|
||||
{t('preview')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.editorModeButton, mode === 'edit' && css.editorModeActive)}
|
||||
onClick={() => { setMode('edit') }}
|
||||
>
|
||||
{t('edit')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{dirty && <span className={css.dirtyDot} title={t('unsaved')} />}
|
||||
{editable && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={t('save')}
|
||||
title={`${t('save')} (Ctrl/Cmd+S)`}
|
||||
onClick={save}
|
||||
>
|
||||
<IconCheckOutline16 />
|
||||
</button>
|
||||
)}
|
||||
{saveLabel !== '' && <span className={clsx(css.editorStatus, saveState === 'failed' && css.editorStatusError)}>{saveLabel}</span>}
|
||||
</div>
|
||||
{editable && (
|
||||
<>
|
||||
{truncated === true && mode === 'edit' && <div className={css.editorBanner}>{t('truncation')}</div>}
|
||||
<div
|
||||
className={clsx(css.editorCm, markdown && mode === 'preview' && css.editorCmHidden)}
|
||||
ref={hostRef}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{markdown && mode === 'preview' && (
|
||||
<div className={css.editorMd}>
|
||||
<MarkdownText text={draft ?? content ?? ''} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
+3
-2
@@ -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<T>(method: string, payload: Record<string, unknown>, signal?: AbortSignal): Promise<T> {
|
||||
let response: Response
|
||||
|
||||
@@ -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 (
|
||||
<div className={css.editorBinary}>
|
||||
<span className={css.editorBinaryNotice}>{t('binaryNoPreview')}</span>
|
||||
<a className={css.editorDownloadLink} href={downloadUrl(scope, path)} download>
|
||||
{t('downloadToView')}
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+30
-18
@@ -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 }) => (
|
||||
<EditorView ctx={ctx} store={store} scope={scope} path={tab.path ?? ''} title={tab.title} />
|
||||
<EditorHost ctx={ctx} store={store} scope={scope} path={tab.path ?? ''} title={tab.title} />
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -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[] = [
|
||||
<PptxView scope={scope} path={path} title={title} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'markdown',
|
||||
exts: ['md', 'markdown'],
|
||||
fetchStrategy: 'fsRead',
|
||||
component: (props) => <TextEditor {...props} />,
|
||||
},
|
||||
{
|
||||
id: 'code',
|
||||
exts: [],
|
||||
priority: -100,
|
||||
fetchStrategy: 'fsRead',
|
||||
component: (props) => <TextEditor {...props} />,
|
||||
},
|
||||
{
|
||||
id: 'binary-download',
|
||||
exts: ['doc', 'xls', 'ppt'],
|
||||
priority: -50,
|
||||
fetchStrategy: 'binary-download',
|
||||
component: ({ scope, path }) => (
|
||||
<div className={css.editorBinary}>
|
||||
<span className={css.editorBinaryNotice}>{t('binaryNoPreview')}</span>
|
||||
<a className={css.editorDownloadLink} href={downloadUrl(scope, path)} download>
|
||||
{t('downloadToView')}
|
||||
</a>
|
||||
</div>
|
||||
),
|
||||
// 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 }) => <BinaryDownload scope={scope} path={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)[] = []
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
+19
-5
@@ -123,8 +123,19 @@ async function resolveGitPath(cwd: string, raw: string): Promise<string> {
|
||||
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) => {
|
||||
|
||||
@@ -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>): 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])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user