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:
@@ -33,6 +33,15 @@
|
||||
/* Two radii only: controls and panels. */
|
||||
--radius-control: 6px;
|
||||
--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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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" })],
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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" },
|
||||
]),
|
||||
),
|
||||
];
|
||||
Reference in New Issue
Block a user