ScratchEditor

tcmts.studio.ScratchEditor · #blntjswf · term · runs in the browser

uses document

A scratch file to edit in the browser, as a notebook: its code, in an editor coloured as the forge colours code, kept and checked as you type (saveScratch, after a pause, or at once with ⌘S), its problems marked where they are and listed below it; with none, what updating the codebase would do to each definition it holds, and what each of its statements comes to, under it (runScratch: a React element rendered; one reaching outside, the clock say, when ⌘↩ or Run asks). Code has hovers (hoverInScratch) with what's to do with a name: Go to it (definitionInScratch; ⌘-click too), its References (referencesInScratch: here, and what in the codebase uses it) and Rename it (renameInScratch: a codebase name in the codebase too, as tcm mv); and completions with the codebase's names and their imports (completeInScratch). A file kept as written (Markdown, JSON, CSS) is edited as what it is. Update codebase saves it (updateScratch, as tcm update) and shows the history entry that made; Discard throws it away.

import { navigate } from "#codebase/tcmts/react";
import { at, codeHref, toasts } from "#codebase/tcmts/share";
import {
  completeInScratch, definitionInScratch, discardScratch, evaluateInScratch, extractInScratch, formatScratch,
  hoverInScratch, previewInScratch, PullIn, pullIntoScratch, referencesInScratch, renameInScratch, runScratch,
  runTestInScratch, saveScratch, signatureInScratch, updateScratch,
} from "#codebase/tcmts/studio";
import type { ScratchCheck, ScratchProblem, ScratchValue } from "#codebase/tcmts/studio";
import { autocompletion, closeBrackets, closeBracketsKeymap, completionKeymap } from "@codemirror/autocomplete";
import type { CompletionContext } from "@codemirror/autocomplete";
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
import { css } from "@codemirror/lang-css";
import { javascript } from "@codemirror/lang-javascript";
import { json } from "@codemirror/lang-json";
import { markdown } from "@codemirror/lang-markdown";
import {
  bracketMatching, foldGutter, foldKeymap, HighlightStyle, indentOnInput, syntaxHighlighting, syntaxTree,
} from "@codemirror/language";
import { lintGutter, lintKeymap, setDiagnostics } from "@codemirror/lint";
import { highlightSelectionMatches, searchKeymap } from "@codemirror/search";
import { EditorState, Prec, StateEffect, StateField } from "@codemirror/state";
import type { Extension } from "@codemirror/state";
import {
  Decoration, drawSelection, EditorView, highlightActiveLine, highlightActiveLineGutter, highlightSpecialChars,
  hoverTooltip, keymap, lineNumbers, placeholder, showTooltip, WidgetType,
} from "@codemirror/view";
import type { DecorationSet, Tooltip } from "@codemirror/view";
import { tags } from "@lezer/highlight";
import { useEffect, useRef, useState } from "react";
import { Button, Dialog, DialogTrigger, Heading, Input, Popover, TextField } from "react-aria-components";

export function ScratchEditor({
  file,
  initial,
  readOnly,
  show,
}: {
  file: string;
  initial: string;
  readOnly: boolean;
  /** a definition to show as it opens (one just pulled in): its name */
  show?: string;
}) {
  const host = useRef<HTMLDivElement>(null);
  const view = useRef<EditorView | undefined>(undefined);
  // (each check's number: only the latest one's answer is shown; Infinity once the file's being saved or thrown away)
  const seq = useRef(0);
  const runAll = useRef<(v: EditorView) => void>(() => {});
  const [check, setCheck] = useState<ScratchCheck | undefined>(undefined);
  const [pending, setPending] = useState(!readOnly);
  const [running, setRunning] = useState(false);
  const [watches, setWatches] = useState(0);
  const [busy, setBusy] = useState(false);
  const [refs, setRefs] = useState<Awaited<ReturnType<typeof referencesInScratch>>>(null);
  const [renaming, setRenaming] = useState<{
    pos: number;
    name: string;
    codebase?: string;
    uses: number;
  } | null>(null);
  const [renameTo, setRenameTo] = useState("");
  const [extracting, setExtracting] = useState<{ from: number; to: number } | null>(null);
  const [extractName, setExtractName] = useState("");
  const code = /\.tsx?$/.test(file);
  const range = (s: EditorState, p: ScratchProblem) => {
    const l = s.doc.line(Math.min(Math.max(1, p.line), s.doc.lines));
    const from = Math.min(l.from + p.col - 1, l.to);
    if (p.length) return { from, to: Math.min(from + p.length, s.doc.length) };
    const word = /^[\w$]+/.exec(s.doc.sliceString(from, l.to));
    return { from, to: word ? from + word[0].length : Math.min(from + 1, l.to) };
  };
  // (the file's text made `now`: the part that changed, changed, so what's around it stays where it is)
  const replaceText = (v: EditorView, now: string) => {
    const was = v.state.doc.toString();
    if (now === was) return;
    let a = 0;
    while (a < was.length && a < now.length && was[a] === now[a]) a++;
    let b = 0;
    while (
      b < was.length - a &&
      b < now.length - a &&
      was[was.length - 1 - b] === now[now.length - 1 - b]
    )
      b++;
    v.dispatch({ changes: { from: a, to: was.length - b, insert: now.slice(a, now.length - b) } });
  };
  // a definition the file holds, in view: its name chosen, at the top of the editor
  const reveal = (v: EditorView, name: string) => {
    const leaf = name.slice(name.lastIndexOf(".") + 1).replace(/[$]/g, "\\$");
    const now = v.state.doc.toString();
    const marked = now.indexOf(`// tcm: path ${name}\n`);
    const decl = new RegExp(
      `^(?:export\\s+)?(?:declare\\s+)?(?:async\\s+)?(?:abstract\\s+)?(?:const|let|var|function\\*?|class|type|interface|enum)\\s+(${leaf})\\b`,
      "m",
    );
    const m = decl.exec(marked >= 0 ? now.slice(marked) : now);
    if (!m) return;
    const from = (marked >= 0 ? marked : 0) + m.index + m[0].length - m[1]!.length;
    v.dispatch({
      selection: { anchor: from, head: from + m[1]!.length },
      effects: EditorView.scrollIntoView(from, { y: "start", yMargin: 48 }),
    });
  };
  // pull definitions into the file (`pullIntoScratch`, as `tcm edit` adds them)
  const pull = async (names: string[]) => {
    const v = view.current;
    if (!v || !names.length) return;
    const was = v.state.doc.toString();
    try {
      const r = await pullIntoScratch(file, was, names);
      // (JSX: the file's a .tsx one now, opened as that)
      if (r.file) {
        seq.current = Infinity;
        if (r.said)
          toasts.add({
            title: r.said.split("\n")[0]!,
            description: `${file} is ${r.file} now: JSX goes in a .tsx file`,
            tone: "ok",
          });
        return navigate(
          at(`/scratch/${encodeURIComponent(r.file)}?show=${encodeURIComponent(names[0]!)}`),
        );
      }
      if (v.state.doc.toString() !== was)
        return toasts.add({ title: "The file changed meanwhile: pull it in again" });
      replaceText(v, r.text);
      reveal(v, names[0]!);
      if (r.said) toasts.add({ title: r.said.split("\n")[0]!, tone: "ok" }, { timeout: 4000 });
      v.focus();
    } catch (e) {
      toasts.add(
        {
          title: `Couldn't pull ${names.join(", ")} in`,
          description: (e as Error).message,
          tone: "bad",
        },
        { timeout: 8000 },
      );
    }
  };
  // (the text the last check was of: a fix's changes are to it)
  const checked = useRef("");
  const fix = (changes: { from: number; to: number; insert: string }[]) => {
    const v = view.current;
    if (!v) return;
    if (v.state.doc.toString() !== checked.current)
      return toasts.add({
        title: "The file's changed since it was checked: fix it once it's checked again",
      });
    v.dispatch({ changes });
    v.focus();
  };
  useEffect(() => {
    let timer: ReturnType<typeof setTimeout> | undefined;
    let dirty = false;
    // ---- the notebook: each statement's value, under it
    const setValues = StateEffect.define<ScratchValue[]>();
    // (the values shown now: a test run again takes its place among them)
    let shown: ScratchValue[] = [];
    class ValueWidget extends WidgetType {
      readonly v: ScratchValue;
      constructor(v: ScratchValue) {
        super();
        this.v = v;
      }
      override eq(o: ValueWidget) {
        return (
          o.v.value === this.v.value &&
          o.v.html === this.v.html &&
          o.v.ok === this.v.ok &&
          o.v.untested === this.v.untested &&
          o.v.output === this.v.output &&
          String(o.v.waits) === String(this.v.waits)
        );
      }
      toDOM(view: EditorView) {
        const d = document.createElement("div");
        d.className = `cmvalue${this.v.ok ? "" : " bad"}${this.v.waits ? " waits" : ""}`;
        // what it wrote to the console, before what it came to
        if (this.v.output) {
          const said = document.createElement("pre");
          said.className = "cmout";
          said.textContent = this.v.output;
          d.append(said);
        }
        if (this.v.html !== undefined) {
          // (the page's stylesheets, as React put them in it: the forge's and the studio's, and those the codebase's
          // components render; an element is written against its app's, which aren't its own)
          const styles = [
            ...document.querySelectorAll(
              'style[data-precedence], link[rel="stylesheet"][data-precedence]',
            ),
          ]
            .map((n) =>
              n instanceof HTMLLinkElement
                ? `<link rel="stylesheet" href="${n.href}">`
                : `<style>${(n.textContent ?? "").replace(/<\/style/gi, "<\\/style")}</style>`,
            )
            .join("");
          const head = `<meta charset="utf-8">${styles}<style>body{margin:12px;background:transparent}</style>`;
          const f = document.createElement("iframe");
          f.className = "cmrender";
          // (its markup alone: no script runs in it)
          f.setAttribute("sandbox", "allow-same-origin");
          f.title = this.v.value;
          f.srcdoc = `<!doctype html><html><head>${head}</head><body>${this.v.html}</body></html>`;
          f.onload = () => {
            // (as tall as what it shows: the body, and its margins)
            const body = f.contentDocument?.body;
            const h = body && Math.ceil(body.getBoundingClientRect().height) + 24;
            if (h) f.style.height = `${Math.min(h, 640)}px`;
            // (its height, now it's laid out: the lines after it move)
            view.requestMeasure();
          };
          d.append(f);
          // and run, at once: built for the browser (`previewInScratch`, kept by hash), in a frame of its own (scripts,
          // but nothing of the studio's) that takes the server's render's place once it's drawn, saying its height as it
          // changes; code that only runs on the server stays as the server rendered it, saying why
          void previewInScratch(file, view.state.doc.toString(), this.v.line).then(
            (code) => {
              const live = document.createElement("iframe");
              live.className = "cmrender";
              live.setAttribute("sandbox", "allow-scripts");
              live.title = this.v.value;
              live.style.cssText = `position:absolute;visibility:hidden;width:${f.offsetWidth || 600}px`;
              const id = Math.random().toString(36).slice(2);
              live.srcdoc = `<!doctype html><html><head>${head}</head><body><div id="root"></div><script>addEventListener("error",function(e){var p=document.createElement("pre");p.style.cssText="color:#cf222e;white-space:pre-wrap";p.textContent=String(e.message);document.body.append(p)});new ResizeObserver(function(){parent.postMessage({tcmPreview:"${id}",h:Math.ceil(document.body.getBoundingClientRect().height)+24},"*")}).observe(document.body)</script><script type="module">${code.replace(/<\/script/gi, "<\\/script")}</script></body></html>`;
              const heard = (e: MessageEvent) => {
                if (e.source !== live.contentWindow || e.data?.tcmPreview !== id) return;
                // (drawn: in the server's render's place)
                if (f.isConnected) {
                  f.remove();
                  live.style.cssText = "";
                }
                live.style.height = `${Math.min(Number(e.data.h) || 48, 640)}px`;
                view.requestMeasure();
              };
              addEventListener("message", heard);
              (d as HTMLElement & { off?: () => void }).off = () =>
                removeEventListener("message", heard);
              d.append(live);
            },
            (err) => {
              const why = document.createElement("div");
              why.className = "cmvalbar";
              why.textContent = `as the server renders it: it doesn't run in the browser (${(err as Error).message.replace(/^It couldn't be built to run in the browser: /, "")})`;
              d.append(why);
              view.requestMeasure();
            },
          );
        } else if (this.v.waits) {
          const said = document.createElement("div");
          said.textContent = `reaches ${this.v.waits.join(", ")}: ⌘↩ runs it`;
          d.append(said);
        } else if (this.v.test) {
          // a test's outcome, under it
          d.className += this.v.untested ? " test untested" : " test";
          const said = document.createElement("pre");
          said.textContent = this.v.untested
            ? "a test, not run yet"
            : `${this.v.ok ? "✓" : "✗"} ${this.v.value}${this.v.cached && this.v.ok ? " (kept)" : ""}`;
          d.append(said);
          // run it, this one, again
          const go = document.createElement("button");
          go.type = "button";
          go.className = "linkbtn testrun";
          go.textContent = this.v.untested ? "Run it" : "Run it again";
          const test = this.v.test;
          go.onclick = async () => {
            go.disabled = true;
            go.textContent = "Running…";
            try {
              const r = await runTestInScratch(file, view.state.doc.toString(), test);
              shown = shown.map((x) => (x.test === test ? { ...r, line: x.line } : x));
              view.dispatch({ effects: setValues.of(shown) });
            } catch (e) {
              go.disabled = false;
              go.textContent = "Run it";
              toasts.add({
                title: `Couldn't run ${test}`,
                description: (e as Error).message,
                tone: "bad",
              });
            }
          };
          d.append(go);
        } else if (!(this.v.output && this.v.value === "undefined")) {
          // (what it came to: nothing said, for a statement that only wrote out)
          const pre = document.createElement("pre");
          pre.textContent = this.v.value;
          d.append(pre);
        }
        return d;
      }
      override ignoreEvent() {
        return true;
      }
      override destroy(dom: HTMLElement) {
        (dom as HTMLElement & { off?: () => void }).off?.();
      }
    }
    // (under the statement that begins on its line: a statement over several lines, after its last)
    const statementEnd = (s: EditorState, line: number) => {
      const l = s.doc.line(Math.min(Math.max(1, line), s.doc.lines));
      const start = l.from + (/^\s*/.exec(l.text)?.[0].length ?? 0);
      let n = syntaxTree(s).resolveInner(start, 1);
      while (n.parent && n.parent.parent) n = n.parent;
      return s.doc.lineAt(Math.max(l.from, Math.min(n.to, s.doc.length))).to;
    };
    const values = StateField.define<DecorationSet>({
      create: () => Decoration.none,
      update(deco, tr) {
        let next = deco.map(tr.changes);
        for (const e of tr.effects)
          if (e.is(setValues))
            next = Decoration.set(
              e.value.map((v) =>
                Decoration.widget({ widget: new ValueWidget(v), block: true, side: 1 }).range(
                  statementEnd(tr.state, v.line),
                ),
              ),
              true,
            );
        return next;
      },
      provide: (f) => EditorView.decorations.from(f),
    });
    const evaluate = async (v: EditorView, n: number, all: boolean) => {
      setRunning(true);
      try {
        const vals = await runScratch(file, v.state.doc.toString(), all);
        if (n !== seq.current) return;
        setWatches(vals.length);
        shown = vals;
        v.dispatch({ effects: setValues.of(vals) });
      } catch (e) {
        if (n === seq.current)
          toasts.add(
            { title: `Couldn't run ${file}`, description: (e as Error).message, tone: "bad" },
            { timeout: 8000 },
          );
      }
      if (n === seq.current) setRunning(false);
    };
    const run = async (v: EditorView, all = false) => {
      const n = ++seq.current;
      if (!Number.isFinite(n)) return;
      dirty = false;
      setPending(true);
      try {
        const text = v.state.doc.toString();
        const r = await saveScratch(file, text);
        if (n !== seq.current) return;
        checked.current = text;
        setCheck(r);
        setPending(false);
        v.dispatch(
          setDiagnostics(
            v.state,
            r.problems.map((p) => ({
              ...range(v.state, p),
              severity: p.severity,
              message: p.message,
              ...(p.source ? { source: p.source } : {}),
              ...(p.fixes
                ? {
                    actions: p.fixes.map((f) => ({
                      name: f.label,
                      apply: (_v: EditorView) => fix(f.changes),
                    })),
                  }
                : {}),
            })),
          ),
        );
        if (code && !r.problems.some((p) => p.severity === "error")) await evaluate(v, n, all);
        else if (code) v.dispatch({ effects: setValues.of([]) });
      } catch (e) {
        if (n !== seq.current) return;
        setPending(false);
        toasts.add(
          { title: `Couldn't keep ${file}`, description: (e as Error).message, tone: "bad" },
          { timeout: 8000 },
        );
      }
    };
    runAll.current = (v) => {
      clearTimeout(timer);
      void run(v, true);
    };
    // ---- what's chosen: evaluated, or made a definition of its own
    const chosen = StateField.define<Tooltip | null>({
      create: () => null,
      update(t, tr) {
        if (!tr.selection && !tr.docChanged) return t;
        const sel = tr.state.selection.main;
        const text = tr.state.sliceDoc(sel.from, sel.to);
        if (sel.empty || !text.trim() || text.length > 4000) return null;
        const { from, to } = sel;
        return {
          pos: to,
          above: false,
          create: (view) => {
            const dom = document.createElement("div");
            dom.className = "cmchosen";
            const acts = document.createElement("div");
            acts.className = "hovkeys";
            const said = document.createElement("div");
            const evaluate = document.createElement("button");
            evaluate.type = "button";
            evaluate.className = "linkbtn";
            evaluate.textContent = "Evaluate";
            evaluate.onclick = async () => {
              evaluate.disabled = true;
              said.className = "muted";
              said.textContent = "Evaluating…";
              try {
                const r = await evaluateInScratch(file, view.state.doc.toString(), text);
                said.replaceChildren();
                said.className = `cmvalue${r.ok ? "" : " bad"}`;
                if (r.output) {
                  const out = document.createElement("pre");
                  out.className = "cmout";
                  out.textContent = r.output;
                  said.append(out);
                }
                const pre = document.createElement("pre");
                pre.textContent = r.value;
                said.append(pre);
              } catch (e) {
                said.className = "bad";
                said.textContent = (e as Error).message;
              }
              evaluate.disabled = false;
            };
            const extract = document.createElement("button");
            extract.type = "button";
            extract.className = "linkbtn";
            extract.textContent = "Extract to a definition";
            extract.onclick = () => {
              setExtractName("");
              setExtracting({ from, to });
            };
            acts.append(evaluate, extract);
            dom.append(acts, said);
            return { dom };
          },
        };
      },
      provide: (f) => showTooltip.from(f),
    });
    // ---- the editor's questions
    let signTimer: ReturnType<typeof setTimeout> | undefined;
    type Signed = {
      pos: number;
      label: string;
      params: { from: number; to: number }[];
      active: number;
    };
    const setSignature = StateEffect.define<Signed | null>();
    const signature = StateField.define<Tooltip | null>({
      create: () => null,
      update(t, tr) {
        for (const e of tr.effects)
          if (e.is(setSignature)) {
            const s = e.value;
            return s
              ? {
                  pos: s.pos,
                  above: true,
                  strictSide: true,
                  create: () => {
                    const dom = document.createElement("div");
                    dom.className = "cmsig";
                    const p = s.params[s.active];
                    if (p) {
                      const b = document.createElement("b");
                      b.textContent = s.label.slice(p.from, p.to);
                      dom.append(s.label.slice(0, p.from), b, s.label.slice(p.to));
                    } else dom.textContent = s.label;
                    return { dom };
                  },
                }
              : null;
          }
        return t && tr.docChanged ? { ...t, pos: tr.changes.mapPos(t.pos) } : t;
      },
      provide: (f) => showTooltip.from(f),
    });
    const goTo = async (v: EditorView, pos: number) => {
      const r = await definitionInScratch(file, v.state.doc.toString(), pos).catch(() => null);
      if (r?.href) navigate(r.href);
      else if (r?.at !== undefined) {
        v.dispatch({ selection: { anchor: r.at }, scrollIntoView: true });
        v.focus();
      }
    };
    const references = async (v: EditorView, pos: number) => {
      try {
        setRefs(await referencesInScratch(file, v.state.doc.toString(), pos));
      } catch (e) {
        toasts.add({
          title: "Couldn't find its references",
          description: (e as Error).message,
          tone: "bad",
        });
      }
    };
    const rename = async (v: EditorView, pos: number) => {
      try {
        const r = await referencesInScratch(file, v.state.doc.toString(), pos);
        if (!r) return;
        setRenameTo(r.codebase?.name ?? r.name);
        setRenaming({
          pos,
          name: r.name,
          uses: r.here.length,
          ...(r.codebase ? { codebase: r.codebase.name } : {}),
        });
      } catch (e) {
        toasts.add({ title: "Couldn't rename it", description: (e as Error).message, tone: "bad" });
      }
    };
    const complete = async (ctx: CompletionContext) => {
      const word = ctx.matchBefore(/[\w$]*/);
      const start = word?.from ?? ctx.pos;
      const dot = ctx.state.sliceDoc(Math.max(0, start - 1), start) === ".";
      if (!ctx.explicit && !dot && start === ctx.pos) return null;
      const r = await completeInScratch(
        file,
        ctx.state.doc.toString(),
        ctx.pos,
        dot ? "." : undefined,
      ).catch(() => null);
      if (!r || ctx.aborted) return null;
      return {
        from: r.from,
        validFor: /^[\w$]*$/,
        options: r.options.map((o) => ({
          label: o.label,
          ...(o.type ? { type: o.type } : {}),
          ...(o.detail ? { detail: o.detail } : {}),
          ...(o.boost !== undefined ? { boost: o.boost } : {}),
          ...(o.imports
            ? {
                apply: (v: EditorView, _c: unknown, from: number, to: number) =>
                  v.dispatch({ changes: [o.imports!, { from, to, insert: o.apply ?? o.label }] }),
              }
            : o.apply
              ? { apply: o.apply }
              : {}),
        })),
      };
    };
    const hover = hoverTooltip(
      async (v, pos) => {
        const r = await hoverInScratch(file, v.state.doc.toString(), pos).catch(() => null);
        if (!r) return null;
        return {
          pos: r.from,
          end: r.to,
          above: true,
          create: () => {
            const dom = document.createElement("div");
            dom.className = "cmhover";
            dom.innerHTML = r.html;
            // what's to do with it
            const acts = document.createElement("div");
            acts.className = "hovkeys";
            if (r.name) {
              const name = r.name;
              const b = document.createElement("button");
              b.type = "button";
              b.className = "linkbtn";
              b.textContent = "Pull in";
              b.title = `Write ${name} into this file, to edit it with the rest`;
              b.onclick = () => void pull([name]);
              acts.append(b);
            }
            for (const [label, act] of [
              ["Go to it", goTo],
              ["References", references],
              ["Rename", rename],
            ] as const) {
              const b = document.createElement("button");
              b.type = "button";
              b.className = "linkbtn";
              b.textContent = label;
              b.onclick = () => void act(v, r.from);
              acts.append(b);
            }
            dom.append(acts);
            return { dom };
          },
        };
      },
      { hoverTime: 350 },
    );
    const language: Extension = code
      ? [
          javascript({ typescript: true, jsx: file.endsWith(".tsx") }),
          values,
          ...(readOnly
            ? []
            : [
                hover,
                autocompletion({ override: [complete] }),
                EditorView.domEventHandlers({
                  mousedown: (e, v) => {
                    if (!(e.metaKey || e.ctrlKey) || e.button !== 0) return false;
                    const pos = v.posAtCoords(e);
                    if (pos === null) return false;
                    e.preventDefault();
                    void goTo(v, pos);
                    return true;
                  },
                }),
                Prec.high(
                  keymap.of([
                    { key: "F12", run: (v) => (void goTo(v, v.state.selection.main.head), true) },
                    {
                      key: "Shift-F12",
                      run: (v) => (void references(v, v.state.selection.main.head), true),
                    },
                    { key: "F2", run: (v) => (void rename(v, v.state.selection.main.head), true) },
                    { key: "Mod-Enter", run: (v) => (runAll.current(v), true) },
                  ]),
                ),
              ]),
        ]
      : file.endsWith(".md")
        ? markdown()
        : file.endsWith(".json")
          ? json()
          : file.endsWith(".css")
            ? css()
            : [];
    const style = HighlightStyle.define([
      {
        tag: [tags.keyword, tags.operatorKeyword, tags.modifier, tags.controlKeyword],
        color: "var(--k)",
      },
      { tag: [tags.string, tags.special(tags.string), tags.regexp], color: "var(--s)" },
      { tag: [tags.number, tags.bool, tags.null, tags.atom], color: "var(--n)" },
      { tag: tags.comment, color: "var(--c)", fontStyle: "italic" },
      {
        tag: [tags.function(tags.variableName), tags.function(tags.propertyName)],
        color: "var(--f)",
      },
      { tag: [tags.typeName, tags.className, tags.namespace], color: "var(--t)" },
      { tag: tags.heading, color: "var(--fg)", fontWeight: "600" },
      { tag: [tags.link, tags.url], color: "var(--acc)" },
      { tag: tags.emphasis, fontStyle: "italic" },
      { tag: tags.strong, fontWeight: "600" },
      { tag: tags.monospace, color: "var(--s)" },
      { tag: tags.propertyName, color: "var(--n)" },
    ]);
    const v = new EditorView({
      parent: host.current!,
      state: EditorState.create({
        doc: initial,
        extensions: [
          lineNumbers(),
          foldGutter({ openText: "▾", closedText: "▸" }),
          highlightActiveLineGutter(),
          highlightSpecialChars(),
          history(),
          drawSelection(),
          indentOnInput(),
          bracketMatching(),
          closeBrackets(),
          highlightActiveLine(),
          highlightSelectionMatches(),
          language,
          syntaxHighlighting(style),
          lintGutter(),
          ...(code
            ? [placeholder("export const …")]
            : file.endsWith(".md")
              ? [EditorView.lineWrapping]
              : []),
          EditorState.readOnly.of(readOnly),
          EditorView.editable.of(!readOnly),
          Prec.high(
            keymap.of([
              {
                key: "Mod-s",
                run: (v) => {
                  clearTimeout(timer);
                  void (async () => {
                    // formatted as it's kept, as tcm formats what it saves (the part that changed, changed)
                    const was = v.state.doc.toString();
                    const now = code ? await formatScratch(file, was).catch(() => was) : was;
                    if (v.state.doc.toString() === was) replaceText(v, now);
                    clearTimeout(timer);
                    await run(v);
                  })();
                  return true;
                },
              },
              {
                key: "Escape",
                run: (v) => {
                  if (!v.state.field(signature, false)) return false;
                  v.dispatch({ effects: setSignature.of(null) });
                  return true;
                },
              },
            ]),
          ),
          keymap.of([
            ...closeBracketsKeymap,
            ...defaultKeymap,
            ...searchKeymap,
            ...historyKeymap,
            ...completionKeymap,
            ...foldKeymap,
            ...lintKeymap,
            indentWithTab,
          ]),
          ...(code && !readOnly ? [signature, chosen] : []),
          EditorView.updateListener.of((u) => {
            // a call's signature, as its arguments are written: asked as one opens or moves on, and while it's shown
            if (code && !readOnly && (u.docChanged || u.selectionSet)) {
              const head = u.state.selection.main.head;
              const typed =
                u.docChanged && /[(,]\s*$/.test(u.state.sliceDoc(Math.max(0, head - 40), head));
              if (typed || u.state.field(signature, false)) {
                clearTimeout(signTimer);
                signTimer = setTimeout(async () => {
                  // (where the cursor is once typing pauses)
                  const pos = u.view.state.selection.main.head;
                  const r = await signatureInScratch(file, u.view.state.doc.toString(), pos).catch(
                    () => null,
                  );
                  if (u.view.state.selection.main.head !== pos) return;
                  u.view.dispatch({ effects: setSignature.of(r ? { ...r, pos } : null) });
                }, 200);
              }
            }
            if (!u.docChanged || readOnly) return;
            clearTimeout(timer);
            dirty = true;
            setPending(true);
            timer = setTimeout(() => void run(u.view), 600);
          }),
        ],
      }),
    });
    view.current = v;
    if (show) {
      reveal(v, show);
      v.focus();
    }
    if (!readOnly) void run(v);
    return () => {
      clearTimeout(timer);
      // (leaving the page with a change not kept yet keeps it)
      if (dirty && Number.isFinite(seq.current))
        void saveScratch(file, v.state.doc.toString()).catch(() => {});
      v.destroy();
    };
  }, [file]);
  const jump = (from: number) => {
    const v = view.current;
    if (!v) return;
    v.dispatch({ selection: { anchor: from }, scrollIntoView: true });
    v.focus();
  };
  // make what's chosen a definition of its own (`extractInScratch`), and show it
  const doExtract = async () => {
    const v = view.current;
    if (!v || !extracting) return;
    try {
      const r = await extractInScratch(
        file,
        v.state.doc.toString(),
        extracting.from,
        extracting.to,
        extractName.trim(),
      );
      setExtracting(null);
      replaceText(v, r.text);
      v.dispatch({
        selection: { anchor: r.at, head: r.at + extractName.trim().length },
        effects: EditorView.scrollIntoView(r.at, { y: "center" }),
      });
      v.focus();
    } catch (e) {
      toasts.add(
        { title: "Couldn't make it a definition", description: (e as Error).message, tone: "bad" },
        { timeout: 8000 },
      );
    }
  };
  const doRename = async () => {
    const v = view.current;
    if (!v || !renaming) return;
    try {
      const r = await renameInScratch(file, v.state.doc.toString(), renaming.pos, renameTo);
      setRenaming(null);
      if (r.changes.length) v.dispatch({ changes: r.changes });
      if (r.said) toasts.add({ title: r.said, tone: "ok" }, { timeout: 6000 });
      setRefs(null);
      v.focus();
    } catch (e) {
      toasts.add(
        {
          title: `Couldn't rename ${renaming.name}`,
          description: (e as Error).message,
          tone: "bad",
        },
        { timeout: 10000 },
      );
    }
  };
  const update = async () => {
    const v = view.current;
    if (!v) return;
    seq.current = Infinity;
    setBusy(true);
    try {
      const r = await updateScratch(file, v.state.doc.toString());
      toasts.add(
        { title: `Saved ${file} to the codebase`, description: r.said.split("\n")[0], tone: "ok" },
        { timeout: 6000 },
      );
      navigate(r.entry ? at(`/history/${r.entry}`) : at("/scratch"));
    } catch (e) {
      seq.current = 0;
      setBusy(false);
      toasts.add(
        { title: "Couldn't update the codebase", description: (e as Error).message, tone: "bad" },
        { timeout: 12000 },
      );
    }
  };
  const discard = async () => {
    seq.current = Infinity;
    try {
      await discardScratch(file);
      navigate(at("/scratch"));
    } catch (e) {
      seq.current = 0;
      toasts.add(
        { title: `Couldn't discard ${file}`, description: (e as Error).message, tone: "bad" },
        { timeout: 8000 },
      );
    }
  };
  const errors = check?.problems.filter((p) => p.severity === "error") ?? [];
  const changes = check?.rows.filter((r) => r.status !== "identical") ?? [];
  const badge: Record<string, [string, string]> = {
    new: ["b-new", "new"],
    update: ["b-edit", "update"],
    alias: ["b-edit", "another name"],
    conflict: ["b-rm", "conflict"],
    doc: ["b-edit", "doc"],
    text: ["b-edit", "text"],
  };
  return (
    <div className="scratch">
      <div className="pagehead">
        <h2 className="title mono">{file}</h2>
        <span className="muted">
          {readOnly
            ? "read only"
            : busy
              ? "Updating the codebase…"
              : pending
                ? "Checking…"
                : running
                  ? "Running…"
                  : "Kept"}
        </span>
        {!readOnly && (
          <div className="right actions">
            {code && <PullIn onPick={(name) => void pull([name])} />}
            {code && watches > 0 && (
              <Button
                className="btn"
                isDisabled={busy || pending || running}
                onPress={() => view.current && runAll.current(view.current)}
              >
                Run
              </Button>
            )}
            <DialogTrigger>
              <Button className="btn" isDisabled={busy}>
                Discard
              </Button>
              <Popover className="menu-pop getit" placement="bottom end" offset={6}>
                <Dialog className="getit-body" aria-label={`Discard ${file}`}>
                  {({ close }) => (
                    <>
                      <Heading slot="title" className="getit-title">
                        Throw {file} away?
                      </Heading>
                      <p>What it holds that isn't saved to the codebase is lost.</p>
                      <div className="actions">
                        <Button className="btn" onPress={close}>
                          Keep it
                        </Button>
                        <Button className="btn danger" onPress={() => (close(), void discard())}>
                          Discard
                        </Button>
                      </div>
                    </>
                  )}
                </Dialog>
              </Popover>
            </DialogTrigger>
            <Button
              className="btn primary"
              isPending={busy}
              isDisabled={pending || !!errors.length || !changes.length}
              onPress={update}
            >
              {busy ? "Updating…" : "Update codebase"}
            </Button>
          </div>
        )}
      </div>
      {extracting && (
        <form
          className="box renamebar"
          onSubmit={(e) => {
            e.preventDefault();
            void doExtract();
          }}
        >
          <TextField
            aria-label="The new definition's name"
            value={extractName}
            onChange={setExtractName}
            autoFocus
            onKeyDown={(e) => {
              if (e.key === "Escape") {
                setExtracting(null);
                view.current?.focus();
              }
            }}
          >
            <span>Make it a definition named</span>
            <Input className="field mono" placeholder="a name" />
          </TextField>
          <span className="muted">
            above the one it's in; what it uses that's local there, its parameters
          </span>
          <div className="actions right">
            <Button
              className="btn"
              onPress={() => {
                setExtracting(null);
                view.current?.focus();
              }}
            >
              Cancel
            </Button>
            <Button
              className="btn primary"
              type="submit"
              isDisabled={!/^[A-Za-z_$][\w$]*$/.test(extractName.trim())}
            >
              Extract
            </Button>
          </div>
        </form>
      )}
      {renaming && (
        <form
          className="box renamebar"
          onSubmit={(e) => {
            e.preventDefault();
            void doRename();
          }}
        >
          <TextField
            aria-label={`Rename ${renaming.name} to`}
            value={renameTo}
            onChange={setRenameTo}
            autoFocus
            onKeyDown={(e) => {
              if (e.key === "Escape") {
                setRenaming(null);
                view.current?.focus();
              }
            }}
          >
            <span>
              Rename <code>{renaming.codebase ?? renaming.name}</code> to
            </span>
            <Input className="field mono" />
          </TextField>
          <span className="muted">
            {renaming.codebase
              ? "in the codebase too, as tcm mv does: its hash stays, and what uses it follows"
              : `in this file (${renaming.uses} place${renaming.uses === 1 ? "" : "s"})`}
          </span>
          <div className="actions right">
            <Button
              className="btn"
              onPress={() => {
                setRenaming(null);
                view.current?.focus();
              }}
            >
              Cancel
            </Button>
            <Button className="btn primary" type="submit">
              Rename
            </Button>
          </div>
        </form>
      )}
      <div className="box cmbox">
        <div ref={host} />
      </div>
      {refs && (
        <div className="box">
          <div className="boxhead">
            <b>
              References to <code>{refs.codebase?.name ?? refs.name}</code>
            </b>
            <Button className="right linkbtn" onPress={() => setRefs(null)}>
              Close
            </Button>
          </div>
          {refs.here.map((r) => (
            <Button className="lrow probrow" key={r.from} onPress={() => jump(r.from)}>
              <span className="mono muted">{`line ${r.line}`}</span>
              <span className="mono">{r.text}</span>
            </Button>
          ))}
          {refs.codebase && (
            <>
              <div className="relhead refshead">
                {refs.codebase.usedBy.length
                  ? `Used by ${refs.codebase.usedBy.length} in the codebase`
                  : "Nothing in the codebase uses it"}
                {!readOnly &&
                  refs.codebase.usedBy.length > 1 &&
                  refs.codebase.usedBy.length <= 30 && (
                    <Button className="linkbtn" onPress={() => void pull(refs.codebase!.usedBy)}>
                      Pull them all in
                    </Button>
                  )}
              </div>
              {refs.codebase.usedBy.map((u) => (
                <div className="lrow" key={u}>
                  <a className="mono" href={codeHref(u)}>
                    {u}
                  </a>
                  {!readOnly && (
                    <Button className="btn small" onPress={() => void pull([u])}>
                      Pull in
                    </Button>
                  )}
                </div>
              ))}
            </>
          )}
        </div>
      )}
      {!!check?.problems.length && (
        <div className="box">
          <div className="boxhead">
            <b>Problems</b>
            <span className="count">{check.problems.length}</span>
          </div>
          {check.problems.map((p, i) => (
            <div className="probline" key={i}>
              <Button
                className="lrow probrow"
                onPress={() => view.current && jump(range(view.current.state, p).from)}
              >
                <span className="mono muted">{`${p.line}:${p.col}`}</span>
                <span className={p.severity === "error" ? "bad" : "warn"}>
                  {p.message}
                  {p.source && <span className="muted">{` ${p.source}`}</span>}
                </span>
              </Button>
              {p.fixes && (
                <div className="probfixes">
                  {p.fixes.map((f) => (
                    <Button key={f.label} className="btn small" onPress={() => fix(f.changes)}>
                      {f.label}
                    </Button>
                  ))}
                </div>
              )}
            </div>
          ))}
        </div>
      )}
      {check && !errors.length && (
        <div className="box">
          <div className="boxhead">
            <b>What updating does</b>
            {check.namespace && <span className="muted">{`in ${check.namespace}`}</span>}
          </div>
          {check.rows.length ? (
            check.rows.map((r) => (
              <div className="lrow" key={r.path}>
                <div>
                  {badge[r.status] && (
                    <span className={`badge ${badge[r.status]![0]}`}>{badge[r.status]![1]}</span>
                  )}{" "}
                  {/* (the name goes to it in the file; its page, for one the codebase has) */}
                  <Button
                    className="linkbtn mono"
                    onPress={() => {
                      if (!view.current) return;
                      reveal(view.current, r.path);
                      view.current.focus();
                    }}
                  >
                    {r.path}
                  </Button>{" "}
                  {r.status === "identical" && <span className="muted">as it is</span>}
                  {r.note && <span className="muted">{r.note}</span>}
                  {r.status === "update" && r.retyped && (
                    <span className="warn">
                      {` its type changes: ${r.usedBy ? `the ${r.usedBy.length} definition${r.usedBy.length === 1 ? "" : "s"} using it ${r.usedBy.length === 1 ? "is" : "are"}` : "what uses it is"} checked again`}
                    </span>
                  )}
                  {r.status === "update" && r.retyped && !!r.usedBy?.length && !readOnly && (
                    <>
                      {" "}
                      <Button className="linkbtn" onPress={() => void pull(r.usedBy!)}>
                        {r.usedBy.length === 1 ? "Pull it in" : "Pull them in"}
                      </Button>
                    </>
                  )}
                </div>
                {r.status !== "new" && (
                  <a className="muted" href={codeHref(r.path)}>
                    its page
                  </a>
                )}
              </div>
            ))
          ) : (
            <div className="empty">
              {code ? (
                <>
                  Nothing to save yet: what it declares (<code>export const …</code>) goes into the
                  codebase.
                </>
              ) : (
                "Nothing to save: where it goes isn't known (its first line says, for Markdown)."
              )}
            </div>
          )}
        </div>
      )}
    </div>
  );
}

uses

used by