ScratchEditor
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
- tcmts.react.navigate
- tcmts.share.at
- tcmts.share.codeHref
- tcmts.share.toasts
- tcmts.studio.PullIn
- tcmts.studio.ScratchCheck
- tcmts.studio.ScratchProblem
- tcmts.studio.ScratchValue
- tcmts.studio.completeInScratch
- tcmts.studio.definitionInScratch
- tcmts.studio.discardScratch
- tcmts.studio.evaluateInScratch
- tcmts.studio.extractInScratch
- tcmts.studio.formatScratch
- tcmts.studio.hoverInScratch
- tcmts.studio.previewInScratch
- tcmts.studio.pullIntoScratch
- tcmts.studio.referencesInScratch
- tcmts.studio.renameInScratch
- tcmts.studio.runScratch
- tcmts.studio.runTestInScratch
- tcmts.studio.saveScratch
- tcmts.studio.signatureInScratch
- tcmts.studio.updateScratch