diff --git a/app/src/index.css b/app/src/index.css index ea2b657..9c300f6 100644 --- a/app/src/index.css +++ b/app/src/index.css @@ -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; } diff --git a/app/src/viewer/CodeEditor.tsx b/app/src/viewer/CodeEditor.tsx new file mode 100644 index 0000000..fae857b --- /dev/null +++ b/app/src/viewer/CodeEditor.tsx @@ -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(); + +function readOnlyExt(readOnly: boolean): Extension[] { + return [EditorState.readOnly.of(readOnly), EditorView.editable.of(!readOnly)]; +} + +export const CodeEditor = forwardRef(function CodeEditor(props, ref) { + const host = useRef(null); + const view = useRef(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
; +}); + +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" })], + }); +} diff --git a/app/src/viewer/highlightLine.test.ts b/app/src/viewer/highlightLine.test.ts new file mode 100644 index 0000000..04e91d8 --- /dev/null +++ b/app/src/viewer/highlightLine.test.ts @@ -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); + }); +}); diff --git a/app/src/viewer/highlightLine.ts b/app/src/viewer/highlightLine.ts new file mode 100644 index 0000000..937d94f --- /dev/null +++ b/app/src/viewer/highlightLine.ts @@ -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({ + 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]; +} diff --git a/app/src/viewer/languages.test.ts b/app/src/viewer/languages.test.ts new file mode 100644 index 0000000..9f558e7 --- /dev/null +++ b/app/src/viewer/languages.test.ts @@ -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); + }); +}); diff --git a/app/src/viewer/languages.ts b/app/src/viewer/languages.ts new file mode 100644 index 0000000..4ee1a22 --- /dev/null +++ b/app/src/viewer/languages.ts @@ -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; + +const BY_EXTENSION: Record = { + 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 = { + dockerfile: () => stream("shell"), + makefile: () => stream("shell"), +}; + +async function stream(mode: "toml" | "shell"): Promise { + 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 { + 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)); +} diff --git a/app/src/viewer/viewerTheme.ts b/app/src/viewer/viewerTheme.ts new file mode 100644 index 0000000..8fb88c2 --- /dev/null +++ b/app/src/viewer/viewerTheme.ts @@ -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" }, + ]), + ), +];