theme: terminal and editor follow the app's color scheme, re-theming in place

The app resolves light/dark from prefers-color-scheme and the user's
choice (body[data-ds-dark-theme]), but xterm and CodeMirror were pinned to
hardcoded dark themes — a light-mode user got a dark card inside a light
panel. New theme.ts reads the resolved scheme + token values and notifies
on flips (the presenter toggles the body attribute); the terminal now takes
its surface colors from the tokens and a curated one-dark/one-light ANSI
palette, and the editor gets a token-driven surface with the same curated
syntax palettes. Both re-theme in place on a scheme flip: the terminal
re-applies its theme, the editor reconfigures a Compartment holding only
the scheme-dependent extensions, so the document, undo history and scroll
survive. one-dark is dropped (superseded by the curated highlight style).
This commit is contained in:
Menghuan1918
2026-08-08 17:29:13 +08:00
parent d1cdca3324
commit 7aa24badf0
6 changed files with 255 additions and 24 deletions
+28 -8
View File
@@ -12,10 +12,11 @@ import clsx from 'clsx'
import { EditorState } from '@codemirror/state'
import { EditorView as CodeMirrorView, keymap } from '@codemirror/view'
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
import { oneDark } from '@codemirror/theme-one-dark'
import { IconCheckOutline16, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import { api, 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 css from './sidebar.module.css'
@@ -42,6 +43,12 @@ export function EditorView(props: { scope: SessionScope; path: string; title: st
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
@@ -77,12 +84,16 @@ export function EditorView(props: { scope: SessionScope; path: string; title: st
// 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.
// 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.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: [
@@ -90,13 +101,9 @@ export function EditorView(props: { scope: SessionScope; path: string; title: st
history(),
EditorState.tabSize.of(2),
CodeMirrorView.contentAttributes.of({ spellcheck: 'false' }),
CodeMirrorView.theme({
'&': { height: '100%', fontSize: '13px', backgroundColor: 'transparent' },
'.cm-scroller': { overflow: 'auto', fontFamily: '"SF Mono", Menlo, Consolas, "Liberation Mono", monospace' },
'.cm-content': { caretColor: 'var(--dsw-alias-label-primary)' },
}),
cmSurfaceTheme,
themeComp.of(dark),
...(language !== null ? [language] : []),
oneDark,
CodeMirrorView.updateListener.of((update) => {
if (update.docChanged) {
setDraft(update.state.doc.toString())
@@ -119,9 +126,22 @@ export function EditorView(props: { scope: SessionScope; path: string; title: st
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(() => {