/** * 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, 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 { 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' /** * 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 { 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 html = viewerId === 'html' 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 return ( <>
{(markdown || html) && (
)} {dirty && } {editable && ( )} {saveLabel !== '' && {saveLabel}}
{editable && ( <> {truncated === true && mode === 'edit' &&
{t('truncation')}
}
)} {markdown && mode === 'preview' && (
)} {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. */}