From 3c0734564694676051e4d96a4bb06cc6ca923a00 Mon Sep 17 00:00:00 2001 From: Evan Klem Date: Mon, 29 Jun 2026 00:41:42 -0400 Subject: [PATCH] Add Monaco syntax highlighting to the diff panel Reuse the editor's Monaco tokenizer and theme to syntax-highlight diff lines instead of rendering flat text. Factor the shared theme, language detection, and colorize helpers into plugins/shared/monaco-highlight.ts and rewire the editor to use them. Diff-status tints stay dominant as the row background; token colors render over them. Co-Authored-By: Claude Opus 4.8 --- .../diff-history/collect-side-lines.test.ts | 34 +++++ plugins/diff-history/collect-side-lines.ts | 30 ++++ plugins/diff-history/component.tsx | 77 ++++++++-- plugins/diff-history/diff-history.css | 6 +- plugins/editor/component.tsx | 102 +------------ plugins/shared/monaco-highlight.test.ts | 25 ++++ plugins/shared/monaco-highlight.ts | 136 ++++++++++++++++++ 7 files changed, 301 insertions(+), 109 deletions(-) create mode 100644 plugins/diff-history/collect-side-lines.test.ts create mode 100644 plugins/diff-history/collect-side-lines.ts create mode 100644 plugins/shared/monaco-highlight.test.ts create mode 100644 plugins/shared/monaco-highlight.ts diff --git a/plugins/diff-history/collect-side-lines.test.ts b/plugins/diff-history/collect-side-lines.test.ts new file mode 100644 index 0000000..169ef56 --- /dev/null +++ b/plugins/diff-history/collect-side-lines.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from 'vitest'; +import { collectSideLines, type SideRow } from './collect-side-lines'; + +const rows: SideRow[] = [ + { kind: 'header', text: '@@ -1,3 +1,3 @@' }, + { kind: 'context', baseLn: 1, targetLn: 1, text: 'const a = 1;' }, + { kind: 'delete', baseLn: 2, text: 'const old = 2;' }, + { kind: 'add', targetLn: 2, text: 'const fresh = 2;' }, + { kind: 'change', baseLn: 3, baseText: 'return old;', targetLn: 3, targetText: 'return fresh;' }, +]; + +describe('collectSideLines', () => { + test('base side takes context, delete, and change-base text in row order', () => { + expect(collectSideLines(rows, 'base')).toEqual([ + { index: 1, text: 'const a = 1;' }, + { index: 2, text: 'const old = 2;' }, + { index: 4, text: 'return old;' }, + ]); + }); + + test('target side takes context, add, and change-target text in row order', () => { + expect(collectSideLines(rows, 'target')).toEqual([ + { index: 1, text: 'const a = 1;' }, + { index: 3, text: 'const fresh = 2;' }, + { index: 4, text: 'return fresh;' }, + ]); + }); + + test('headers and the opposite side’s rows are excluded', () => { + expect(collectSideLines(rows, 'base').some((l) => l.index === 0)).toBe(false); + expect(collectSideLines(rows, 'base').some((l) => l.index === 3)).toBe(false); + expect(collectSideLines(rows, 'target').some((l) => l.index === 2)).toBe(false); + }); +}); diff --git a/plugins/diff-history/collect-side-lines.ts b/plugins/diff-history/collect-side-lines.ts new file mode 100644 index 0000000..b678bec --- /dev/null +++ b/plugins/diff-history/collect-side-lines.ts @@ -0,0 +1,30 @@ +export type SideRow = + | { kind: 'header'; text: string } + | { kind: 'context'; baseLn: number; targetLn: number; text: string } + | { kind: 'delete'; baseLn: number; text: string } + | { kind: 'add'; targetLn: number; text: string } + | { kind: 'change'; baseLn: number; baseText: string; targetLn: number; targetText: string }; + +export type DiffSide = 'base' | 'target'; + +export type SideLine = { index: number; text: string }; + +/* pull out the code-bearing lines for one column, paired with their index in + the original row list. The index is what lets a colorized line be threaded + back to its row during render. Headers carry no code; each diff side only + shows its own half of a change. */ +export function collectSideLines(rows: SideRow[], side: DiffSide): SideLine[] { + const lines: SideLine[] = []; + rows.forEach((row, index) => { + if (row.kind === 'context') { + lines.push({ index, text: row.text }); + } else if (row.kind === 'delete' && side === 'base') { + lines.push({ index, text: row.text }); + } else if (row.kind === 'add' && side === 'target') { + lines.push({ index, text: row.text }); + } else if (row.kind === 'change') { + lines.push({ index, text: side === 'base' ? row.baseText : row.targetText }); + } + }); + return lines; +} diff --git a/plugins/diff-history/component.tsx b/plugins/diff-history/component.tsx index a21ab73..ca12d73 100644 --- a/plugins/diff-history/component.tsx +++ b/plugins/diff-history/component.tsx @@ -2,6 +2,8 @@ import React, { useEffect, useRef, useState } from 'react'; import type { GitDiffResult } from '../../packages/sdk/src/host'; import type { BuiltinPluginProps } from '../shared'; import { PanelHeader, ResizeHandle, scheduleAfterPaint, useResizableSplit } from '../shared'; +import { applyGlassTheme, colorizeLines, loadMonaco, monacoLanguageForPath } from '../shared/monaco-highlight'; +import { collectSideLines, type SideRow } from './collect-side-lines'; type CompareMode = 'working' | 'branch'; @@ -14,13 +16,6 @@ const COMPARE_OPTIONS: Array<{ { mode: 'branch', label: 'upstream vs current branch', detail: 'commits on this branch' }, ]; -type SideRow = - | { kind: 'header'; text: string } - | { kind: 'context'; baseLn: number; targetLn: number; text: string } - | { kind: 'delete'; baseLn: number; text: string } - | { kind: 'add'; targetLn: number; text: string } - | { kind: 'change'; baseLn: number; baseText: string; targetLn: number; targetText: string }; - function parseUnifiedDiff(unified: string): SideRow[] { if (!unified.trim()) return []; const rows: SideRow[] = []; @@ -93,6 +88,14 @@ export function DiffHistoryPanel({ header, host }: BuiltinPluginProps) { const [diffParsing, setDiffParsing] = useState(false); const [sideRows, setSideRows] = useState([]); const [visibleRowCount, setVisibleRowCount] = useState(0); + /* row index -> Monaco-colorized HTML for that line, one map per column. + missing entries (plaintext, unloaded monaco) fall back to plain text. */ + const [baseHtml, setBaseHtml] = useState>({}); + const [targetHtml, setTargetHtml] = useState>({}); + /* apply the Monaco theme once — it's global + accent-independent for token + colors, so re-applying on every file switch would needlessly recolor any + live editor. */ + const themeAppliedRef = useRef(false); const [selectedFile, setSelectedFile] = useState(''); const [compareMode, setCompareMode] = useState('working'); @@ -230,6 +233,54 @@ export function DiffHistoryPanel({ header, host }: BuiltinPluginProps) { return () => window.cancelAnimationFrame(frame); }, [sideRows.length, visibleRowCount]); + /* syntax-highlight each column with the same Monaco tokenizer/theme the + editor uses. Colorize the contiguous per-side text once, then thread each + resulting line back to its row index. Diff-status tints stay dominant — + these are foreground token colors over the row background. Falls back to + plain text for plaintext/unknown languages or if Monaco fails to load. */ + useEffect(() => { + let cancelled = false; + setBaseHtml({}); + setTargetHtml({}); + if (sideRows.length === 0 || !activeFile) return () => { cancelled = true; }; + loadMonaco() + .then((monaco) => { + if (cancelled) return; + if (!themeAppliedRef.current) { + applyGlassTheme(monaco); + themeAppliedRef.current = true; + } + const language = monacoLanguageForPath(monaco, activeFile); + if (language === 'plaintext') return; + const baseLines = collectSideLines(sideRows, 'base'); + const targetLines = collectSideLines(sideRows, 'target'); + return Promise.all([ + colorizeLines(monaco, baseLines.map((line) => line.text).join('\n'), language), + colorizeLines(monaco, targetLines.map((line) => line.text).join('\n'), language), + ]).then(([baseColored, targetColored]) => { + if (cancelled) return; + const baseMap: Record = {}; + baseLines.forEach((line, i) => { + if (baseColored[i] != null) baseMap[line.index] = baseColored[i]; + }); + const targetMap: Record = {}; + targetLines.forEach((line, i) => { + if (targetColored[i] != null) targetMap[line.index] = targetColored[i]; + }); + setBaseHtml(baseMap); + setTargetHtml(targetMap); + }); + }) + .catch(() => { /* leave the plain-text fallback in place */ }); + return () => { cancelled = true; }; + }, [sideRows, activeFile]); + + const renderCode = (html: Record, index: number, fallback: string) => { + const colored = html[index]; + if (colored != null) return ; + return {fallback || ' '}; + }; + return (
@@ -371,7 +422,7 @@ export function DiffHistoryPanel({ header, host }: BuiltinPluginProps) { return (
{row.baseLn} - {row.text || ' '} + {renderCode(baseHtml, index, row.text)}
); } @@ -379,7 +430,7 @@ export function DiffHistoryPanel({ header, host }: BuiltinPluginProps) { return (
{row.baseLn} - {row.text || ' '} + {renderCode(baseHtml, index, row.text)}
); } @@ -387,7 +438,7 @@ export function DiffHistoryPanel({ header, host }: BuiltinPluginProps) { return (
{row.baseLn} - {row.baseText || ' '} + {renderCode(baseHtml, index, row.baseText)}
); } @@ -421,7 +472,7 @@ export function DiffHistoryPanel({ header, host }: BuiltinPluginProps) { return (
{row.targetLn} - {row.text || ' '} + {renderCode(targetHtml, index, row.text)}
); } @@ -429,7 +480,7 @@ export function DiffHistoryPanel({ header, host }: BuiltinPluginProps) { return (
{row.targetLn} - {row.text || ' '} + {renderCode(targetHtml, index, row.text)}
); } @@ -437,7 +488,7 @@ export function DiffHistoryPanel({ header, host }: BuiltinPluginProps) { return (
{row.targetLn} - {row.targetText || ' '} + {renderCode(targetHtml, index, row.targetText)}
); } diff --git a/plugins/diff-history/diff-history.css b/plugins/diff-history/diff-history.css index 18ed6d1..6f36782 100644 --- a/plugins/diff-history/diff-history.css +++ b/plugins/diff-history/diff-history.css @@ -685,7 +685,11 @@ } -.diff-line span { +/* direct child only — this is the line-number gutter. Monaco's colorize() + output is also (token spans) nested inside ; a bare + `.diff-line span` would out-specify the .mtkN token-color classes and flatten + syntax highlighting to the gutter color. */ +.diff-line > span { height: 100%; display: grid; place-items: center end; diff --git a/plugins/editor/component.tsx b/plugins/editor/component.tsx index fccf2a1..23c07c7 100644 --- a/plugins/editor/component.tsx +++ b/plugins/editor/component.tsx @@ -21,7 +21,7 @@ import { normalizeTypeScriptProjectConfig, } from './ambient-types'; import type { DebugState } from '../../packages/sdk/src/host'; -import { loadInterfaceSettings } from '../../src/settings/settingsStorage'; +import { applyGlassTheme, loadMonaco, monacoLanguageForPath } from '../shared/monaco-highlight'; import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'; import CssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker'; import HtmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker'; @@ -33,49 +33,6 @@ type MonacoEditorModel = ReturnType; type MonacoEditorInstance = ReturnType; type ExtraLibDisposable = { dispose: () => void }; -/* Build Monaco theme color map from the current accent hex. All accent-derived - slots use 8-digit hex (RRGGBBAA) so they update together when the accent - changes; fixed warm-dark surface colors stay hardcoded. */ -function buildMonacoThemeColors(accentHex: string): Record { - let r = 240, g = 179, b = 90; // honey fallback - const clean = accentHex.replace('#', ''); - if (/^[0-9a-fA-F]{6}$/.test(clean)) { - r = parseInt(clean.slice(0, 2), 16); - g = parseInt(clean.slice(2, 4), 16); - b = parseInt(clean.slice(4, 6), 16); - } - const hex2 = (n: number) => Math.round(Math.max(0, Math.min(255, n))).toString(16).padStart(2, '0'); - const a = (alpha: number) => `#${hex2(r)}${hex2(g)}${hex2(b)}${hex2(alpha * 255)}`; - const full = `#${hex2(r)}${hex2(g)}${hex2(b)}`; - return { - 'editor.background': '#0d0a0700', - 'editor.foreground': '#ffffff', - 'editorLineNumber.foreground': '#5c4a32', - 'editorLineNumber.activeForeground': full, - 'editor.selectionBackground': a(0.40), - 'editor.lineHighlightBackground': '#1a120c80', - 'editorCursor.foreground': full, - 'editorIndentGuide.background': '#2a1c1240', - 'editorIndentGuide.activeBackground': a(0.19), - 'editorBracketMatch.background': a(0.19), - 'editorBracketMatch.border': full, - 'editorStickyScroll.background': '#120c08f5', - 'editorStickyScrollHover.background': '#1d1410f8', - 'editor.findMatchBackground': a(0.31), - 'editor.findMatchHighlightBackground': a(0.13), - 'editor.findMatchBorder': a(0.60), - 'editor.findRangeHighlightBackground': a(0.06), - 'editorWidget.background': '#1a110a', - 'editorWidget.border': '#3d2a1a', - 'editorWidget.foreground': '#ffffff', - 'input.background': '#2a1c10', - 'input.border': '#3d2a1a', - 'input.foreground': '#ffffff', - 'inputOption.activeBorder': full, - 'inputOption.activeBackground': a(0.13), - }; -} - const TYPE_SCRIPT_FILE_EXTENSIONS = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs']; const WORKSPACE_IMPORT_LIMIT = 80; const PACKAGE_LIB_LIMIT = 180; @@ -528,22 +485,12 @@ export function EditorPanel({ header, host }: BuiltinPluginProps) { loads the editor with zero language contributions, which is why opening a .tsx file rendered as flat plaintext. */ perfPoint('editor:monaco-import-start'); - import('monaco-editor/esm/vs/editor/editor.main').then((monaco) => { + loadMonaco().then((monaco) => { perfPoint('editor:monaco-import-resolved'); if (disposed || !monacoHostRef.current) return; - /* glass editor theme — warm-dark surface with transparent background so - the panel's frosted layer shows through. Accent colors are derived from - the user's current settings so they update when the theme changes. */ - const applyTheme = () => { - const accent = loadInterfaceSettings().accent; - monaco.editor.defineTheme('polypore-warm', { - base: 'vs-dark', - inherit: true, - rules: [], - colors: buildMonacoThemeColors(accent), - }); - monaco.editor.setTheme('polypore-warm'); - }; + /* glass editor theme — derived from the user's accent, shared with the + diff panel via applyGlassTheme so both stay in sync. */ + const applyTheme = () => applyGlassTheme(monaco); applyTheme(); /* watch :root inline style for changes — applyInterfaceSettings writes CSS vars there, so this fires whenever the user changes their accent. */ @@ -1977,43 +1924,8 @@ function declarationReferenceTypes(text: string) { .flatMap((match) => match[1] ? [match[1]] : []); } -/* look up monaco's registered language id for a file path by querying the - editor's own language registry. `monaco.languages.getLanguages()` returns - every contribution registered by `editor.main` (the basic-languages - bundle), each with the file extensions, recognized filenames (Dockerfile, - Makefile, …), and aliases it claims. matching against that registry means - we automatically pick up any language monaco ships with — no whitelist to - keep in sync. falls back to plaintext if nothing claims the file. */ -function monacoLanguageForPath(monaco: MonacoModule, path: string): string { - if (!path) return 'plaintext'; - const filename = (path.split('/').pop() ?? '').toLowerCase(); - const dot = filename.lastIndexOf('.'); - const ext = dot >= 0 ? filename.slice(dot) : ''; - const langs = (monaco as unknown as { - languages: { - getLanguages: () => Array<{ - id: string; - extensions?: string[]; - filenames?: string[]; - aliases?: string[]; - }>; - }; - }).languages.getLanguages(); - for (const lang of langs) { - if (ext && lang.extensions?.some((e) => e.toLowerCase() === ext)) return lang.id; - if (lang.filenames?.some((f) => f.toLowerCase() === filename)) return lang.id; - } - /* aliases/ids without dotted extensions (e.g. "Dockerfile.dev") — last - ditch: check if the bare filename or trailing token matches a language - id or alias. */ - const tail = dot >= 0 ? filename.slice(dot + 1) : filename; - for (const lang of langs) { - if (lang.id.toLowerCase() === tail) return lang.id; - if (lang.aliases?.some((a) => a.toLowerCase() === tail)) return lang.id; - } - return 'plaintext'; -} - +/* project language-server config wins; otherwise fall back to monaco's own + language registry (monacoLanguageForPath, in shared/monaco-highlight). */ function editorLanguageForPath(monaco: MonacoModule, path: string, projectConfig: ProjectLanguageConfig): string { return projectLanguageForPath(projectConfig, path) ?? monacoLanguageForPath(monaco, path); } diff --git a/plugins/shared/monaco-highlight.test.ts b/plugins/shared/monaco-highlight.test.ts new file mode 100644 index 0000000..a450323 --- /dev/null +++ b/plugins/shared/monaco-highlight.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from 'vitest'; +import { splitColorizedLines } from './monaco-highlight'; + +describe('splitColorizedLines', () => { + test('splits monaco colorize output on
and drops the trailing empty entry', () => { + const html = 'const
x
'; + expect(splitColorizedLines(html)).toEqual([ + 'const', + 'x', + ]); + }); + + test('keeps interior blank lines so row alignment is preserved', () => { + const html = 'a

b
'; + expect(splitColorizedLines(html)).toEqual(['a', '', 'b']); + }); + + test('returns an empty array for empty input', () => { + expect(splitColorizedLines('')).toEqual([]); + }); + + test('a lone
is a single blank line', () => { + expect(splitColorizedLines('
')).toEqual(['']); + }); +}); diff --git a/plugins/shared/monaco-highlight.ts b/plugins/shared/monaco-highlight.ts new file mode 100644 index 0000000..5772d01 --- /dev/null +++ b/plugins/shared/monaco-highlight.ts @@ -0,0 +1,136 @@ +import type * as MonacoApi from 'monaco-editor/esm/vs/editor/editor.api'; +import { loadInterfaceSettings } from '../../src/settings/settingsStorage'; + +export type MonacoModule = typeof MonacoApi; + +/* one shared promise so the editor panel and the diff panel pull the same + Monaco chunk. editor.main (not editor.api) is the "fat" entry that + auto-registers every basic-language monarch tokenizer — without it both + panels would render flat plaintext. */ +let monacoPromise: Promise | null = null; + +export function loadMonaco(): Promise { + if (!monacoPromise) { + monacoPromise = import('monaco-editor/esm/vs/editor/editor.main') as unknown as Promise; + } + return monacoPromise; +} + +/* glass editor theme — warm-dark surface with a transparent background so the + panel's frosted layer shows through. Accent colors are derived from the + user's current settings so chrome updates when the theme changes; syntax + token colors stay inherited from vs-dark (rules: []). */ +export function buildGlassThemeColors(accentHex: string): Record { + let r = 240, g = 179, b = 90; // honey fallback + const clean = accentHex.replace('#', ''); + if (/^[0-9a-fA-F]{6}$/.test(clean)) { + r = parseInt(clean.slice(0, 2), 16); + g = parseInt(clean.slice(2, 4), 16); + b = parseInt(clean.slice(4, 6), 16); + } + const hex2 = (n: number) => Math.round(Math.max(0, Math.min(255, n))).toString(16).padStart(2, '0'); + const a = (alpha: number) => `#${hex2(r)}${hex2(g)}${hex2(b)}${hex2(alpha * 255)}`; + const full = `#${hex2(r)}${hex2(g)}${hex2(b)}`; + return { + 'editor.background': '#0d0a0700', + 'editor.foreground': '#ffffff', + 'editorLineNumber.foreground': '#5c4a32', + 'editorLineNumber.activeForeground': full, + 'editor.selectionBackground': a(0.40), + 'editor.lineHighlightBackground': '#1a120c80', + 'editorCursor.foreground': full, + 'editorIndentGuide.background': '#2a1c1240', + 'editorIndentGuide.activeBackground': a(0.19), + 'editorBracketMatch.background': a(0.19), + 'editorBracketMatch.border': full, + 'editorStickyScroll.background': '#120c08f5', + 'editorStickyScrollHover.background': '#1d1410f8', + 'editor.findMatchBackground': a(0.31), + 'editor.findMatchHighlightBackground': a(0.13), + 'editor.findMatchBorder': a(0.60), + 'editor.findRangeHighlightBackground': a(0.06), + 'editorWidget.background': '#1a110a', + 'editorWidget.border': '#3d2a1a', + 'editorWidget.foreground': '#ffffff', + 'input.background': '#2a1c10', + 'input.border': '#3d2a1a', + 'input.foreground': '#ffffff', + 'inputOption.activeBorder': full, + 'inputOption.activeBackground': a(0.13), + }; +} + +/* define + activate the glass theme. idempotent — defineTheme/setTheme just + overwrite, so callers (editor on accent change, diff before colorizing) can + invoke it freely. setTheme also injects the global .mtkN color classes that + colorize()'s output relies on. */ +export function applyGlassTheme(monaco: MonacoModule): void { + const accent = loadInterfaceSettings().accent; + monaco.editor.defineTheme('polypore-warm', { + base: 'vs-dark', + inherit: true, + rules: [], + colors: buildGlassThemeColors(accent), + }); + monaco.editor.setTheme('polypore-warm'); +} + +/* look up monaco's registered language id for a file path by querying the + editor's own language registry. `monaco.languages.getLanguages()` returns + every contribution registered by `editor.main` (the basic-languages bundle), + each with the file extensions, recognized filenames (Dockerfile, Makefile, …), + and aliases it claims. matching against that registry means we automatically + pick up any language monaco ships with — no whitelist to keep in sync. falls + back to plaintext if nothing claims the file. */ +export function monacoLanguageForPath(monaco: MonacoModule, path: string): string { + if (!path) return 'plaintext'; + const filename = (path.split('/').pop() ?? '').toLowerCase(); + const dot = filename.lastIndexOf('.'); + const ext = dot >= 0 ? filename.slice(dot) : ''; + const langs = (monaco as unknown as { + languages: { + getLanguages: () => Array<{ + id: string; + extensions?: string[]; + filenames?: string[]; + aliases?: string[]; + }>; + }; + }).languages.getLanguages(); + for (const lang of langs) { + if (ext && lang.extensions?.some((e) => e.toLowerCase() === ext)) return lang.id; + if (lang.filenames?.some((f) => f.toLowerCase() === filename)) return lang.id; + } + /* aliases/ids without dotted extensions (e.g. "Dockerfile.dev") — last + ditch: check if the bare filename or trailing token matches a language + id or alias. */ + const tail = dot >= 0 ? filename.slice(dot + 1) : filename; + for (const lang of langs) { + if (lang.id.toLowerCase() === tail) return lang.id; + if (lang.aliases?.some((a) => a.toLowerCase() === tail)) return lang.id; + } + return 'plaintext'; +} + +/* monaco.editor.colorize joins per-line HTML with
and appends a trailing +
. Split it back into one entry per source line, dropping only that + trailing terminator so interior blank lines stay (row alignment depends on + it). */ +export function splitColorizedLines(html: string): string[] { + if (html === '') return []; + const parts = html.split('
'); + if (parts.length > 0 && parts[parts.length - 1] === '') parts.pop(); + return parts; +} + +/* colorize a contiguous block of source text into per-line HTML using the + active theme's token colors. plaintext is returned as escaped-but-uncolored + lines, so callers can skip it. */ +export async function colorizeLines( + monaco: MonacoModule, + text: string, + languageId: string, +): Promise { + const html = await monaco.editor.colorize(text, languageId, { tabSize: 2 }); + return splitColorizedLines(html); +}