feat(viewer): CodeMirror editor, theme and target-line highlight

Implements Task 10 of the terminal file viewer plan: highlightLine.ts
(line-range StateField + setHighlight effect), viewerTheme.ts (EditorView
theme + syntax HighlightStyle), and CodeEditor.tsx (the React wrapper with
getDoc/setDoc/goTo/focus).

Also implements languages.ts + languages.test.ts, moved here from Task 7
per preflight ruling P1 (they need @codemirror/* packages, which only
Task 6 installs).

Preflight rulings applied:
- P1: languages.ts/.test.ts live here, not in Task 7.
- P12: syntax colours are `--syntax-*` custom properties in index.css,
  not hard-coded hex.
- P13: languageFor tests assert `.not.toBeNull()` for mapped extensions
  and move README to the "returns null" case, instead of the vacuous
  `.resolves.toBeDefined()`.
- P14(c): readOnly extension array factored into readOnlyExt(readOnly)
  instead of being duplicated in CodeEditor.
- P16: no custom Mod-g binding; searchKeymap's Mod-Alt-g covers "go to
  line" already.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-22 21:12:05 -07:00
co-authored by Claude Opus 5.5
parent bbcaee7797
commit 5b66e861df
7 changed files with 334 additions and 0 deletions
+9
View File
@@ -33,6 +33,15 @@
/* Two radii only: controls and panels. */ /* Two radii only: controls and panels. */
--radius-control: 6px; --radius-control: 6px;
--radius-panel: 8px; --radius-panel: 8px;
/* Syntax colours for the file viewer's editor (viewer/viewerTheme.ts). Same
GitHub-dark palette TerminalView.tsx already uses for ANSI, expressed as
tokens rather than hard-coded hex per the styling convention above. */
--syntax-keyword: #ff7b72;
--syntax-string: #a5d6ff;
--syntax-number: #79c0ff;
--syntax-function: #d2a8ff;
--syntax-type: #ffa657;
--syntax-property: #7ee787;
color-scheme: dark; color-scheme: dark;
} }
+125
View File
@@ -0,0 +1,125 @@
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
import { Annotation, EditorState, Compartment, EditorSelection, type Extension } from "@codemirror/state";
import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, drawSelection, highlightSpecialChars } from "@codemirror/view";
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
import { search, searchKeymap } from "@codemirror/search";
import { bracketMatching, indentOnInput } from "@codemirror/language";
import type { ViewerLocation } from "../lib/types";
import { viewerTheme } from "./viewerTheme";
import { highlightExtension, setHighlight } from "./highlightLine";
export interface CodeEditorHandle {
getDoc(): string;
/** Replace the whole document, keeping scroll and a clamped cursor. Does not mark dirty. */
setDoc(text: string): void;
goTo(loc: ViewerLocation): void;
focus(): void;
}
export interface CodeEditorProps {
initialDoc: string;
readOnly: boolean;
language: Extension | null;
lineWrapping: boolean;
initialLocation: ViewerLocation;
onDocChanged(): void;
onSave(): void;
}
/** A `dispatch` from `setDoc` is a reload, not a user edit; the listener must not mark it dirty. */
const reloadTag = Annotation.define<boolean>();
function readOnlyExt(readOnly: boolean): Extension[] {
return [EditorState.readOnly.of(readOnly), EditorView.editable.of(!readOnly)];
}
export const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(function CodeEditor(props, ref) {
const host = useRef<HTMLDivElement>(null);
const view = useRef<EditorView | null>(null);
const readOnlyCompartment = useRef(new Compartment());
const languageCompartment = useRef(new Compartment());
const wrapCompartment = useRef(new Compartment());
const callbacks = useRef(props);
callbacks.current = props;
useEffect(() => {
if (!host.current) return;
const v = new EditorView({
parent: host.current,
state: EditorState.create({
doc: props.initialDoc,
extensions: [
lineNumbers(),
highlightActiveLine(),
highlightActiveLineGutter(),
highlightSpecialChars(),
drawSelection(),
history(),
bracketMatching(),
indentOnInput(),
search({ top: true }),
highlightExtension(),
viewerTheme,
keymap.of([
{ key: "Mod-s", run: () => { callbacks.current.onSave(); return true; } },
...defaultKeymap, ...historyKeymap, ...searchKeymap, indentWithTab,
]),
readOnlyCompartment.current.of(readOnlyExt(props.readOnly)),
languageCompartment.current.of(props.language ?? []),
wrapCompartment.current.of(props.lineWrapping ? EditorView.lineWrapping : []),
EditorView.updateListener.of((u) => {
if (u.docChanged && !u.transactions.some((tr) => tr.annotation(reloadTag))) callbacks.current.onDocChanged();
}),
],
}),
});
view.current = v;
goTo(v, props.initialLocation);
return () => { v.destroy(); view.current = null; };
// The editor is created once per mount; later prop changes go through compartments below.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
view.current?.dispatch({ effects: readOnlyCompartment.current.reconfigure(readOnlyExt(props.readOnly)) });
}, [props.readOnly]);
useEffect(() => {
view.current?.dispatch({ effects: languageCompartment.current.reconfigure(props.language ?? []) });
}, [props.language]);
useEffect(() => {
view.current?.dispatch({ effects: wrapCompartment.current.reconfigure(props.lineWrapping ? EditorView.lineWrapping : []) });
}, [props.lineWrapping]);
useImperativeHandle(ref, () => ({
getDoc: () => view.current?.state.doc.toString() ?? "",
setDoc: (text) => {
const v = view.current;
if (!v) return;
const scrollTop = v.scrollDOM.scrollTop;
const head = Math.min(v.state.selection.main.head, text.length);
v.dispatch({
changes: { from: 0, to: v.state.doc.length, insert: text },
selection: EditorSelection.single(head),
annotations: reloadTag.of(true),
});
v.scrollDOM.scrollTop = scrollTop;
},
goTo: (loc) => { if (view.current) goTo(view.current, loc); },
focus: () => view.current?.focus(),
}));
return <div ref={host} className="h-full min-h-0" data-testid="code-editor" />;
});
function goTo(v: EditorView, loc: ViewerLocation): void {
if (loc.line === null) return;
const from = loc.line;
const to = loc.end_line ?? loc.line;
const lineNo = Math.min(Math.max(1, from), v.state.doc.lines);
const line = v.state.doc.line(lineNo);
const pos = Math.min(line.from + Math.max(0, (loc.col ?? 1) - 1), line.to);
v.dispatch({
selection: EditorSelection.cursor(pos),
effects: [setHighlight.of({ from, to }), EditorView.scrollIntoView(pos, { y: "center" })],
});
}
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { EditorState, Text } from "@codemirror/state";
import { highlightExtension, highlightLineField, lineRangeToPositions, setHighlight } from "./highlightLine";
describe("lineRangeToPositions", () => {
const doc = Text.of(["one", "two", "three"]);
it("maps 1-based inclusive lines to document offsets", () => {
expect(lineRangeToPositions(doc, 2, 2)).toEqual({ from: 4, to: 4 });
expect(lineRangeToPositions(doc, 1, 3)).toEqual({ from: 0, to: 8 });
});
it("clamps past the end and refuses nonsense", () => {
expect(lineRangeToPositions(doc, 2, 99)).toEqual({ from: 4, to: 8 });
expect(lineRangeToPositions(doc, 99, 100)).toEqual({ from: 8, to: 8 });
expect(lineRangeToPositions(doc, 0, 1)).toEqual({ from: 0, to: 0 });
expect(lineRangeToPositions(doc, 3, 1)).toEqual({ from: 8, to: 8 });
});
});
describe("highlightLineField", () => {
it("decorates every line in the range and clears on null", () => {
let state = EditorState.create({ doc: "a\nb\nc\nd", extensions: [highlightExtension()] });
state = state.update({ effects: setHighlight.of({ from: 2, to: 3 }) }).state;
let count = 0;
state.field(highlightLineField).between(0, state.doc.length, () => { count++; });
expect(count).toBe(2);
state = state.update({ effects: setHighlight.of(null) }).state;
count = 0;
state.field(highlightLineField).between(0, state.doc.length, () => { count++; });
expect(count).toBe(0);
});
});
+40
View File
@@ -0,0 +1,40 @@
import { StateEffect, StateField, type Extension, type Text } from "@codemirror/state";
import { Decoration, EditorView, type DecorationSet } from "@codemirror/view";
export const setHighlight = StateEffect.define<{ from: number; to: number } | null>();
const lineMark = Decoration.line({ class: "cm-triple-c-target" });
export function lineRangeToPositions(doc: Text, from: number, to: number): { from: number; to: number } | null {
const clamp = (n: number) => Math.min(Math.max(1, Math.floor(n)), doc.lines);
const a = clamp(from);
const b = Math.max(a, clamp(to));
return { from: doc.line(a).from, to: doc.line(b).from };
}
export const highlightLineField = StateField.define<DecorationSet>({
create: () => Decoration.none,
update(value, tr) {
let next = value.map(tr.changes);
for (const e of tr.effects) {
if (!e.is(setHighlight)) continue;
if (e.value === null) { next = Decoration.none; continue; }
const range = lineRangeToPositions(tr.state.doc, e.value.from, e.value.to);
if (!range) { next = Decoration.none; continue; }
const marks = [];
for (let pos = range.from; pos <= range.to; ) {
const line = tr.state.doc.lineAt(pos);
marks.push(lineMark.range(line.from));
if (line.to + 1 > tr.state.doc.length) break;
pos = line.to + 1;
}
next = Decoration.set(marks, true);
}
return next;
},
provide: (f) => EditorView.decorations.from(f),
});
export function highlightExtension(): Extension {
return [highlightLineField];
}
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { languageFor, wrapsLines } from "./languages";
describe("languageFor", () => {
it.each(["a.ts", "a.tsx", "a.js", "a.jsx", "a.mjs", "a.rs", "a.py", "a.json", "a.yaml", "a.yml", "a.toml", "a.sh", "a.bash", "a.css", "a.html", "a.md", "Dockerfile", "Cargo.lock", "README"])(
"resolves %s without throwing", async (name) => {
const result = await languageFor(`/workspace/${name}`);
if (name === "README") {
expect(result).toBeNull();
} else {
expect(result).not.toBeNull();
}
});
it("returns null for an unknown extension", async () => {
await expect(languageFor("/workspace/x.xyz")).resolves.toBeNull();
});
it("returns an extension for markdown", async () => {
await expect(languageFor("/workspace/x.md")).resolves.not.toBeNull();
});
});
describe("wrapsLines", () => {
it("wraps prose, not code", () => {
expect(wrapsLines("x.md")).toBe(true);
expect(wrapsLines("x.txt")).toBe(true);
expect(wrapsLines("x.rs")).toBe(false);
});
});
+59
View File
@@ -0,0 +1,59 @@
/**
* Extension → CodeMirror language, loaded on demand so a window only pays for
* the grammar it shows. Dynamic `import()` becomes a same-origin chunk, fine
* under `script-src 'self'`.
*/
import type { Extension } from "@codemirror/state";
import { extensionOf } from "../components/projects/home/filePreview";
type Loader = () => Promise<Extension>;
const BY_EXTENSION: Record<string, Loader> = {
md: () => import("@codemirror/lang-markdown").then((m) => m.markdown()),
markdown: () => import("@codemirror/lang-markdown").then((m) => m.markdown()),
js: () => import("@codemirror/lang-javascript").then((m) => m.javascript()),
mjs: () => import("@codemirror/lang-javascript").then((m) => m.javascript()),
cjs: () => import("@codemirror/lang-javascript").then((m) => m.javascript()),
jsx: () => import("@codemirror/lang-javascript").then((m) => m.javascript({ jsx: true })),
ts: () => import("@codemirror/lang-javascript").then((m) => m.javascript({ typescript: true })),
tsx: () => import("@codemirror/lang-javascript").then((m) => m.javascript({ jsx: true, typescript: true })),
rs: () => import("@codemirror/lang-rust").then((m) => m.rust()),
py: () => import("@codemirror/lang-python").then((m) => m.python()),
json: () => import("@codemirror/lang-json").then((m) => m.json()),
jsonc: () => import("@codemirror/lang-json").then((m) => m.json()),
yaml: () => import("@codemirror/lang-yaml").then((m) => m.yaml()),
yml: () => import("@codemirror/lang-yaml").then((m) => m.yaml()),
css: () => import("@codemirror/lang-css").then((m) => m.css()),
html: () => import("@codemirror/lang-html").then((m) => m.html()),
htm: () => import("@codemirror/lang-html").then((m) => m.html()),
toml: () => stream("toml"),
lock: () => stream("toml"),
sh: () => stream("shell"),
bash: () => stream("shell"),
zsh: () => stream("shell"),
};
const BY_BASENAME: Record<string, Loader> = {
dockerfile: () => stream("shell"),
makefile: () => stream("shell"),
};
async function stream(mode: "toml" | "shell"): Promise<Extension> {
const { StreamLanguage } = await import("@codemirror/language");
const parser = mode === "toml"
? (await import("@codemirror/legacy-modes/mode/toml")).toml
: (await import("@codemirror/legacy-modes/mode/shell")).shell;
return StreamLanguage.define(parser);
}
export function languageFor(path: string): Promise<Extension | null> {
const ext = extensionOf(path);
const base = path.slice(path.lastIndexOf("/") + 1).toLowerCase();
const loader = BY_EXTENSION[ext] ?? BY_BASENAME[base];
return loader ? loader() : Promise.resolve(null);
}
const PROSE = new Set(["md", "markdown", "txt", "rst", "log", ""]);
export function wrapsLines(path: string): boolean {
return PROSE.has(extensionOf(path));
}
+42
View File
@@ -0,0 +1,42 @@
import { EditorView } from "@codemirror/view";
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
import { tags as t } from "@lezer/highlight";
import type { Extension } from "@codemirror/state";
// Syntax colours come from the `--syntax-*` tokens in index.css (P12), not
// hard-coded hex, even though the values match the GitHub-dark ANSI palette
// TerminalView.tsx already uses.
export const viewerTheme: Extension = [
EditorView.theme(
{
"&": { backgroundColor: "var(--bg-primary)", color: "var(--text-primary)", height: "100%", fontSize: "13px" },
".cm-content": { fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, monospace", caretColor: "var(--accent)" },
".cm-scroller": { overflow: "auto" },
".cm-gutters": { backgroundColor: "var(--bg-secondary)", color: "var(--text-secondary)", borderRight: "1px solid var(--border-color)" },
".cm-activeLine": { backgroundColor: "var(--accent-muted)" },
".cm-activeLineGutter": { backgroundColor: "var(--accent-muted)" },
".cm-triple-c-target": { backgroundColor: "var(--warning-muted)", outline: "1px solid var(--warning)" },
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground": { backgroundColor: "var(--accent-muted)" },
".cm-panels": { backgroundColor: "var(--bg-secondary)", color: "var(--text-primary)", borderBottom: "1px solid var(--border-color)" },
".cm-searchMatch": { backgroundColor: "var(--warning-muted)", outline: "1px solid var(--warning)" },
".cm-searchMatch.cm-searchMatch-selected": { backgroundColor: "var(--success-muted)" },
},
{ dark: true },
),
syntaxHighlighting(
HighlightStyle.define([
{ tag: [t.keyword, t.modifier, t.operatorKeyword], color: "var(--syntax-keyword)" },
{ tag: [t.string, t.special(t.string)], color: "var(--syntax-string)" },
{ tag: [t.comment, t.lineComment, t.blockComment], color: "var(--text-secondary)", fontStyle: "italic" },
{ tag: [t.number, t.bool, t.null, t.atom], color: "var(--syntax-number)" },
{ tag: [t.function(t.variableName), t.function(t.propertyName)], color: "var(--syntax-function)" },
{ tag: [t.typeName, t.className, t.namespace], color: "var(--syntax-type)" },
{ tag: [t.propertyName, t.attributeName], color: "var(--syntax-property)" },
{ tag: t.heading, fontWeight: "bold", color: "var(--accent)" },
{ tag: t.emphasis, fontStyle: "italic" },
{ tag: t.strong, fontWeight: "bold" },
{ tag: t.link, color: "var(--accent)", textDecoration: "underline" },
{ tag: t.invalid, color: "var(--syntax-keyword)", textDecoration: "underline wavy" },
]),
),
];