From 7aa24badf023889e9ff0ad35359e64d80a87fadc Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Sat, 8 Aug 2026 17:29:13 +0800 Subject: [PATCH] theme: terminal and editor follow the app's color scheme, re-theming in place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- package.json | 2 +- pnpm-lock.yaml | 16 +---- src/client/EditorView.tsx | 36 +++++++--- src/client/TerminalView.tsx | 52 ++++++++++++++- src/client/cm-themes.ts | 129 ++++++++++++++++++++++++++++++++++++ src/client/theme.ts | 44 ++++++++++++ 6 files changed, 255 insertions(+), 24 deletions(-) create mode 100644 src/client/cm-themes.ts create mode 100644 src/client/theme.ts diff --git a/package.json b/package.json index 07f3ea7..6e56f9d 100644 --- a/package.json +++ b/package.json @@ -48,8 +48,8 @@ "@codemirror/legacy-modes": "^6.5.3", "@codemirror/search": "^6.7.1", "@codemirror/state": "^6.7.1", - "@codemirror/theme-one-dark": "^6.1.3", "@codemirror/view": "^6.43.8", + "@lezer/highlight": "^1.2.3", "@xterm/addon-fit": "^0.10.0", "clsx": "^2.1.1", "node-pty": "^1.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32cd44d..ee34277 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,12 +65,12 @@ importers: '@codemirror/state': specifier: ^6.7.1 version: 6.7.1 - '@codemirror/theme-one-dark': - specifier: ^6.1.3 - version: 6.1.3 '@codemirror/view': specifier: ^6.43.8 version: 6.43.8 + '@lezer/highlight': + specifier: ^1.2.3 + version: 1.2.3 '@xterm/addon-fit': specifier: ^0.10.0 version: 0.10.0(@xterm/xterm@5.4.0) @@ -204,9 +204,6 @@ packages: '@codemirror/state@6.7.1': resolution: {integrity: sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==} - '@codemirror/theme-one-dark@6.1.3': - resolution: {integrity: sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==} - '@codemirror/view@6.43.8': resolution: {integrity: sha512-qtItTDssZ/5GFfi94hrILu9j/VUeFPDPkhovEfmWFj2ipTxnzPB8DdHgfbb8HYTzLTYhrndKmyQxXUz/PDLenw==} @@ -1175,13 +1172,6 @@ snapshots: dependencies: '@marijn/find-cluster-break': 1.0.3 - '@codemirror/theme-one-dark@6.1.3': - dependencies: - '@codemirror/language': 6.12.4 - '@codemirror/state': 6.7.1 - '@codemirror/view': 6.43.8 - '@lezer/highlight': 1.2.3 - '@codemirror/view@6.43.8': dependencies: '@codemirror/state': 6.7.1 diff --git a/src/client/EditorView.tsx b/src/client/EditorView.tsx index dbd5d2e..b446920 100644 --- a/src/client/EditorView.tsx +++ b/src/client/EditorView.tsx @@ -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(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()) }), []) 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(() => { diff --git a/src/client/TerminalView.tsx b/src/client/TerminalView.tsx index f572c63..e0192fa 100644 --- a/src/client/TerminalView.tsx +++ b/src/client/TerminalView.tsx @@ -9,17 +9,57 @@ * attempts, so the banner never spins forever. */ import { useEffect, useRef, useState } from 'react' -import { Terminal } from 'xterm' +import { Terminal, type ITheme } from 'xterm' import { FitAddon } from '@xterm/addon-fit' import 'xterm/css/xterm.css' import { t } from './locales.ts' import type { SessionScope } from './api.ts' import { sidebarStore } from './state.ts' +import { isDarkScheme, subscribeColorScheme, tokenValue } from './theme.ts' import css from './sidebar.module.css' /** How many consecutive unreasoned failures before showing the error banner. */ const FAILURE_LIMIT = 3 +/** + * Curated ANSI palettes for the terminal. The surface colors (background, + * foreground, cursor, selection) ride the theme tokens so the terminal + * blends with the panel in both schemes; the 16 ANSI colors are the same + * designed palettes the app's code surfaces use (one-dark family for dark, + * one-light family for light), read live so a scheme flip re-themes in + * place. + */ +const ANSI_DARK: Record = { + black: '#282c34', red: '#e06c75', green: '#98c379', yellow: '#e5c07b', + blue: '#61afef', magenta: '#c678dd', cyan: '#56b6c2', white: '#abb2bf', + brightBlack: '#5c6370', brightRed: '#e06c75', brightGreen: '#98c379', + brightYellow: '#e5c07b', brightBlue: '#61afef', brightMagenta: '#c678dd', + brightCyan: '#56b6c2', brightWhite: '#ffffff', +} + +const ANSI_LIGHT: Record = { + black: '#383a42', red: '#e45649', green: '#50a14f', yellow: '#c18401', + blue: '#0184bc', magenta: '#a626a4', cyan: '#0997b3', white: '#a0a1a7', + brightBlack: '#4f525e', brightRed: '#e45649', brightGreen: '#50a14f', + brightYellow: '#c18401', brightBlue: '#0184bc', brightMagenta: '#a626a4', + brightCyan: '#0997b3', brightWhite: '#fafafa', +} + +/** The xterm theme for the current scheme (surface from tokens, ANSI curated). */ +function xtermTheme(): ITheme { + const dark = isDarkScheme() + const background = tokenValue('--dsw-alias-bg-base') || (dark ? '#111114' : '#ffffff') + const foreground = tokenValue('--dsw-alias-label-primary') || (dark ? '#e6e6e6' : '#1a1a1a') + return { + background, + foreground, + cursor: foreground, + cursorAccent: background, + selectionBackground: dark ? 'rgba(255,255,255,0.22)' : 'rgba(0,0,0,0.12)', + ...(dark ? ANSI_DARK : ANSI_LIGHT), + } +} + export function TerminalView(props: { scope: SessionScope; tabId: string }) { const { scope, tabId } = props const hostRef = useRef(null) @@ -34,15 +74,22 @@ export function TerminalView(props: { scope: SessionScope; tabId: string }) { const term = new Terminal({ cursorBlink: true, fontSize: 13, - fontFamily: '"SF Mono", Menlo, Consolas, "Liberation Mono", monospace', + fontFamily: tokenValue('--ds-font-family-code') || '"SF Mono", Menlo, Consolas, "Liberation Mono", monospace', allowTransparency: true, convertEol: false, scrollback: 4000, + theme: xtermTheme(), }) const fit = new FitAddon() term.loadAddon(fit) term.open(host) fit.fit() + // Re-theme in place when the app's scheme flips (tokens + palette). + const applyTheme = (): void => { + term.options.theme = xtermTheme() + term.refresh(0, term.rows - 1) + } + const schemeSub = subscribeColorScheme(applyTheme) let socket: WebSocket | null = null let closed = false @@ -125,6 +172,7 @@ export function TerminalView(props: { scope: SessionScope; tabId: string }) { closed = true window.clearTimeout(retry) observer.disconnect() + schemeSub() inputSub.dispose() // The close frame tells the host the owning tab is GONE (immediate // quota release). A bare unmount — conversation switch, re-render, diff --git a/src/client/cm-themes.ts b/src/client/cm-themes.ts new file mode 100644 index 0000000..f739179 --- /dev/null +++ b/src/client/cm-themes.ts @@ -0,0 +1,129 @@ +/** + * CodeMirror 6 theme pieces for the sidebar editor. The editor surface + * (background, caret, gutter) rides the DSH theme tokens so it blends with + * the panel in both schemes; only the syntax token colors need concrete + * 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. + */ +import { Compartment } from '@codemirror/state' +import { HighlightStyle, syntaxHighlighting } from '@codemirror/language' +import { tags, type Tag } from '@lezer/highlight' +import { EditorView } from '@codemirror/view' + +/** Token-driven surface shared by both schemes (pure CSS values). */ +export const cmSurfaceTheme = EditorView.theme({ + '&': { + height: '100%', + fontSize: '13px', + backgroundColor: 'transparent', + color: 'var(--dsw-alias-label-primary)', + }, + '.cm-scroller': { + overflow: 'auto', + fontFamily: 'var(--ds-font-family-code)', + }, + '.cm-content': { + caretColor: 'var(--dsw-alias-label-primary)', + }, + '.cm-gutters': { + backgroundColor: 'transparent', + color: 'var(--dsw-alias-label-tertiary)', + border: 'none', + }, +}) + +/** Scheme-specific surface tints (selection, active line). */ +function cmSurfaceTint(dark: boolean): ReturnType { + return EditorView.theme({ + '.cm-selectionBackground, .cm-focused .cm-selectionBackground, ::selection': { + backgroundColor: dark ? 'rgba(255,255,255,0.22)' : 'rgba(0,0,0,0.12)', + }, + '.cm-activeLine, .cm-activeLineGutter': { + backgroundColor: dark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.03)', + }, + }) +} + +/** One syntax rule: a tag (or tag set) mapped to a concrete color/style. */ +interface HighlightRule { + tag: Tag | readonly Tag[] + color?: string + fontStyle?: string +} + +/** one-dark syntax palette (mirrors @codemirror/theme-one-dark). */ +const HIGHLIGHTS_DARK: HighlightRule[] = [ + { tag: tags.comment, color: '#5c6370', fontStyle: 'italic' }, + { tag: tags.keyword, color: '#c678dd' }, + { tag: tags.string, color: '#98c379' }, + { tag: tags.number, color: '#d19a66' }, + { tag: tags.bool, color: '#d19a66' }, + { tag: tags.atom, color: '#d19a66' }, + { tag: tags.typeName, color: '#e5c07b' }, + { tag: tags.className, color: '#e5c07b' }, + { tag: tags.propertyName, color: '#e06c75' }, + { tag: tags.function(tags.variableName), color: '#61afef' }, + { tag: tags.variableName, color: '#e06c75' }, + { tag: tags.operator, color: '#56b6c2' }, + { tag: tags.tagName, color: '#e06c75' }, + { tag: tags.attributeName, color: '#d19a66' }, + { tag: tags.heading, color: '#e06c75', fontStyle: 'bold' }, + { tag: tags.emphasis, fontStyle: 'italic' }, + { tag: tags.strong, fontStyle: 'bold' }, + { tag: tags.link, color: '#61afef', fontStyle: 'underline' }, + { tag: tags.meta, color: '#e5c07b' }, + { tag: tags.invalid, color: '#ffffff', fontStyle: 'bold' }, +] + +/** one-light syntax palette (the light counterpart of one-dark). */ +const HIGHLIGHTS_LIGHT: HighlightRule[] = [ + { tag: tags.comment, color: '#a0a1a7', fontStyle: 'italic' }, + { tag: tags.keyword, color: '#a626a4' }, + { tag: tags.string, color: '#50a14f' }, + { tag: tags.number, color: '#986801' }, + { tag: tags.bool, color: '#0184bc' }, + { tag: tags.atom, color: '#0184bc' }, + { tag: tags.typeName, color: '#c18401' }, + { tag: tags.className, color: '#c18401' }, + { tag: tags.propertyName, color: '#e45649' }, + { tag: tags.function(tags.variableName), color: '#c18401' }, + { tag: tags.variableName, color: '#e45649' }, + { tag: tags.operator, color: '#383a42' }, + { tag: tags.tagName, color: '#e45649' }, + { tag: tags.attributeName, color: '#986801' }, + { tag: tags.heading, color: '#e45649', fontStyle: 'bold' }, + { tag: tags.emphasis, fontStyle: 'italic' }, + { tag: tags.strong, fontStyle: 'bold' }, + { tag: tags.link, color: '#4078f2', fontStyle: 'underline' }, + { tag: tags.meta, color: '#c18401' }, + { tag: tags.invalid, color: '#ffffff', fontStyle: 'bold' }, +] + +/** The scheme-dependent extension pair (surface tint + syntax highlight). */ +function cmThemeExtensions(dark: boolean): Array> { + return [ + cmSurfaceTint(dark), + syntaxHighlighting(HighlightStyle.define(dark ? HIGHLIGHTS_DARK : HIGHLIGHTS_LIGHT)), + ] +} + +/** + * A Compartment holding the two scheme-dependent extensions. Created once + * per editor view; a scheme flip dispatches `reconfigure(dark)` on it, so + * the document, undo history, scroll and keymaps survive re-theming. + */ +export class CmThemeCompartment { + private readonly compartment = new Compartment() + + /** `of(...)` payload for EditorState.create. */ + of(dark: boolean): ReturnType { + return this.compartment.of(cmThemeExtensions(dark)) + } + + /** Reconfigure for a new scheme. */ + reconfigure(dark: boolean): ReturnType { + return this.compartment.reconfigure(cmThemeExtensions(dark)) + } +} diff --git a/src/client/theme.ts b/src/client/theme.ts new file mode 100644 index 0000000..b77af10 --- /dev/null +++ b/src/client/theme.ts @@ -0,0 +1,44 @@ +/** + * Live theme access for surfaces that cannot consume the token colors + * directly — xterm's palette and CodeMirror's theme extensions need concrete + * values, but the app's scheme flips at runtime (ui-layout's ThemePresenter + * projects prefers-color-scheme and the user's choice onto + * body[data-ds-dark-theme] and html { color-scheme }). This module reads the + * resolved scheme and token values, and notifies subscribers on flips, so + * the terminal and the editor re-theme in place instead of freezing in the + * scheme they happened to be created under. + */ + +/** Whether the app shell resolved to the dark scheme. + * + * The presenter sets `html { color-scheme }` together with the body palette + * attribute, so a set color-scheme means the decision is authoritative (an + * absent attribute is then LIGHT even when the OS prefers dark — the user + * chose light). Before the presenter has run, fall back to the OS media + * query as the best guess. + */ +export function isDarkScheme(): boolean { + if (typeof document === 'undefined') return true + const decided = document.documentElement.style.colorScheme !== '' + if (decided) return document.body.hasAttribute('data-ds-dark-theme') + return typeof matchMedia !== 'undefined' && matchMedia('(prefers-color-scheme: dark)').matches +} + +/** One token's computed value on ('' while the theme has not applied). */ +export function tokenValue(name: string): string { + if (typeof document === 'undefined') return '' + return getComputedStyle(document.body).getPropertyValue(name).trim() +} + +/** + * Subscribe to color-scheme flips (the presenter toggles the body + * attribute). The callback fires after the attribute changed; re-read the + * scheme inside it. + * @returns the disposer. + */ +export function subscribeColorScheme(callback: () => void): () => void { + if (typeof document === 'undefined') return () => {} + const observer = new MutationObserver(() => { callback() }) + observer.observe(document.body, { attributes: true, attributeFilter: ['data-ds-dark-theme'] }) + return () => { observer.disconnect() } +}