/** * 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 — unless the host passes * `toolbar: 'host'` (the merged editor-explorer mode), in which case this * component skips the row and reports state + registers commands through * the FileViewerProps toolbar callbacks so the host's path-input header * renders the controls instead. */ import { useEffect, useMemo, useRef, useState } from 'react' import type { ComponentType } from 'react' import { createPortal } from 'react-dom' 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, htmlUrl } from './api.ts' import { languageForPath } from './lang.ts' import { cmSurfaceTheme, CmThemeCompartment } from './cm-themes.ts' import { isDarkScheme, subscribeColorScheme } from './theme.ts' import { SandboxStatusBar } from './SandboxStatusBar.tsx' import { appendToDraft } from './conversation-draft.ts' import { buildSelectionInsert, linesOfSelection } from './selection-payload.ts' import { lazyChunkComponent } from './lazy-chunk.tsx' import { splitMermaidBlocks, type MermaidMarkdownProps } from './mermaid-blocks.ts' import { t } from './locales.ts' import type { EditorToolbarState, FileViewerProps } from './service.ts' import css from './sidebar.module.css' /** Previewable files (rendered output vs source editing). */ type ViewMode = 'preview' | 'edit' /** The floating "add to conversation" action: payload + viewport anchor. */ interface SelectionPopup { insert: string left: number top: number } /** * The chunk-resident markdown preview renderer (mermaid lazy chunk): one * MarkdownText pass over the whole source, with rendered mermaid fences * swapped for diagrams. Module-level `pick` keeps the load effect stable. */ const LazyMermaidMarkdown = lazyChunkComponent( 'mermaid', (mod) => mod.MermaidMarkdown as ComponentType | undefined, ) /** * The sandbox tokens of the HTML preview iframe. NO allow-same-origin (the * preview must stay in an opaque origin — with the route's own origin it * could read session data) and NO allow-top-navigation (a previewed page * must not hijack the GUI). The user can disable the sandbox per-feature * in the side card settings (warned); the toggle below reflects it. */ export const HTML_IFRAME_SANDBOX = 'allow-scripts allow-popups allow-downloads allow-modals' export function TextEditor(props: FileViewerProps) { const { ctx, 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()) /** The floating "add to conversation" popup (viewport-anchored; null = hidden). */ const [popup, setPopup] = useState(null) /** Live mirror of the popup state for click-time reads (no re-render race). */ const popupRef = useRef(null) /** The markdown preview container (selection-containment + line lookup). */ const mdRef = useRef(null) const hidePopup = (): void => { popupRef.current = null setPopup(null) } /** Anchor the popup above the selection center; clamp inside the viewport. */ const showPopup = (insert: string, left: number, top: number): void => { const next: SelectionPopup = { insert, left: Math.min(Math.max(left, 80), window.innerWidth - 80), top, } popupRef.current = next setPopup(next) } /** The popup button's click: insert the stored payload into the draft. */ const commitPopup = (): void => { const current = popupRef.current if (current === null) return appendToDraft(ctx, scope.sessionId, current.insert) hidePopup() } 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') hidePopup() }, [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, ]), // Selection popup (the code and markdown editors): a non-empty // selection anchors the floating "add to conversation" button above // its head. Scrolling (geometry/viewport change) or losing focus // hides it; typing collapses the selection and hides it too. ...(viewerId === 'code' || viewerId === 'markdown' ? [ CodeMirrorView.updateListener.of((update) => { if (update.geometryChanged || update.viewportChanged) { hidePopup() return } if (!update.view.hasFocus) { hidePopup() return } if (!(update.selectionSet || update.docChanged || update.focusChanged)) return const sel = update.state.selection.main if (sel.empty) { hidePopup() return } const text = update.state.sliceDoc(sel.from, sel.to) if (text.trim() === '') { hidePopup() return } // Page coordinates (the document root may scroll); the popup is // position:fixed, so convert to viewport coordinates. const rect = update.view.coordsAtPos(sel.head) if (rect === null) { hidePopup() return } const doc = update.state.doc showPopup( buildSelectionInsert(path, scope.cwd, { start: doc.lineAt(sel.from).number, end: doc.lineAt(sel.to).number, }, text), rect.left - window.scrollX + (rect.right - rect.left) / 2, rect.top - window.scrollY, ) }), ] : []), ], }) 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). A mode // flip also invalidates any anchored selection popup. useEffect(() => { hidePopup() 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 html = viewerId === 'html' /** The markdown source the preview renders (draft wins over saved content). */ const mdText = draft ?? content ?? '' /** md/mermaid block split for the preview (mermaid fences lift out). Split * only in preview mode: edit-mode keystrokes must not re-scan the source. */ const mdBlocks = useMemo( () => (markdown && mode === 'preview' ? splitMermaidBlocks(mdText) : []), [markdown, mode, mdText], ) const hasMermaid = useMemo( () => mdBlocks.some(block => block.kind === 'mermaid'), [mdBlocks], ) const codeLabels = { copyLabel: t('copy'), copiedLabel: t('copied') } /** * Selection popup for the markdown preview: a mouse-up inside the preview * container anchors the floating "add to conversation" button above the * selection. Line numbers come from a best-effort reverse-search of the * selected text in the source ({@link linesOfSelection} — an ambiguous or * missing hit omits them). The button's own mousedown preventDefaults so * the selection survives until the click commits. */ const handlePreviewMouseUp = (): void => { const sel = window.getSelection() if (sel === null || sel.isCollapsed || sel.anchorNode === null || sel.focusNode === null) { hidePopup() return } const host = mdRef.current if (host === null || !host.contains(sel.anchorNode) || !host.contains(sel.focusNode)) { hidePopup() return } const text = sel.toString() if (text.trim() === '') { hidePopup() return } const rect = sel.getRangeAt(0).getBoundingClientRect() const lines = linesOfSelection(mdText, text) showPopup( buildSelectionInsert(path, scope.cwd, lines ?? undefined, text), rect.left + rect.width / 2, rect.top, ) } const editable = content !== undefined const saveLabel = saveState === 'saving' ? t('loading') : saveState === 'saved' ? t('saved') : saveState === 'failed' ? t('saveFailed') : '' // Per-feature sandbox escape hatch: the global side card setting (warned) // plus a per-surface temporary unlock. The unlock state starts at the // "default unsafe" pref so a preview can open straight into the red // unsandboxed state (still restorable from the status row). With the // sandbox OFF the preview iframe drops its sandbox attribute entirely — // the previewed page then runs on the GUI's own origin with full session // access. const [localUnlock, setLocalUnlock] = useState(() => props.store?.getPrefs().htmlViewerDefaultUnsafe === true) const htmlNoSandbox = props.store?.getPrefs().htmlViewerNoSandbox === true || localUnlock // Host-toolbar mode (the merged editor header renders the controls): skip // the own toolbar row, report the state after every relevant render (the // JSON key guards redundant calls), and register the commands on mount. const hostToolbar = props.toolbar === 'host' const lastToolbarRef = useRef('') useEffect(() => { if (!hostToolbar) return const state: EditorToolbarState = { modes: markdown || html, mode, dirty, editable, saveState } const key = JSON.stringify(state) if (lastToolbarRef.current === key) return lastToolbarRef.current = key props.onToolbarState?.(state) }) useEffect(() => { if (!hostToolbar) return // `save` reads live refs only, and `setMode` is the stable state setter — // registering this render's closures is safe for the mount's lifetime. props.onToolbarControls?.({ setMode, save }) return () => { props.onToolbarControls?.(null) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [hostToolbar]) return ( <> {!hostToolbar && (
{(markdown || html) && (
)} {dirty && } {editable && ( )} {saveLabel !== '' && {saveLabel}}
)} {editable && ( <> {truncated === true && mode === 'edit' &&
{t('truncation')}
}
)} {markdown && mode === 'preview' && (
{/* The fence copy-button labels must come from this plugin's own dictionary: the DSH MarkdownText/CodeBlock are cordis-free and fall back to hardcoded Chinese otherwise (same pattern as the chat's AssistantMarkdown). Render-time t() keeps them following the active locale on live switches. Mermaid fences hand the whole document to the mermaid lazy chunk (single markdown parse; cross-fence references/footnotes stay intact); files without one render exactly as before. */} {hasMermaid ? : }
)} {html && mode === 'preview' && ( <> { setLocalUnlock(true) }} onRestore={() => { setLocalUnlock(false) }} /> {/* Route-src (never srcdoc — a srcdoc frame inherits the parent origin when unsandboxed; the route URL keeps the frame cross-origin by construction). The preview shows the SAVED file; the draft is only visible in edit mode. */}