/** * 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' && (
)} ) }