Something went wrong. Try again.
Monorepo for the nibling.run platform
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221import { adopt, define, sheet } from "@nibling/web/internals";import type * as CodeModule from "../code/element.ts";import type { Files } from "../files.ts";import { languageOf, type Language } from "./keys.ts";import type { Unsubscribe } from "../shell/workbench.ts";import type { CaretMove, EditingTarget } from "./editing.ts";
/** * Structural CSS only; theme values come from @nibling/web's semantic tokens. `--nib-code-padding` * cascades into `<nib-code>`'s shadow root (custom properties inherit through shadow boundaries), * so the `<pre>` shown before the chunk loads and the editor shown after it share one padding rule * and there is no layout shift between them. */const editorSheet = sheet(` :host { display: flex; container: nib-editor / size; --nib-code-padding: var(--nib-space-sm) 0; } nib-code { flex: 1; min-width: 0; min-height: 0; } pre { flex: 1; min-width: 0; min-height: 0; margin: 0; overflow: auto; padding: var(--nib-code-padding); background: var(--nib-color-canvas); color: var(--nib-color-text); font: var(--nib-font-code); white-space: pre; } @container nib-editor (max-width: 220px) { :host { --nib-code-padding: var(--nib-space-xs) 0; } }`);
type DocType = import("../collab/doc.ts").Doc;type ConnectFn = typeof import("../collab/sync.ts").connect;
type CodeChunk = { NibCodeElement: typeof CodeModule.NibCodeElement; fileDocs: typeof import("../collab/file-docs.ts").fileDocs; Doc: typeof import("../collab/doc.ts").Doc; connect: ConnectFn;};
/** Loaded once, shared by every `<nib-editor>` that mounts after the first. */let loading: Promise<CodeChunk> | undefined;
function loadCode(): Promise<CodeChunk> { return (loading ??= Promise.all([ import("../code/element.ts"), import("../collab/file-docs.ts"), import("../collab/doc.ts"), import("../collab/sync.ts"), ]).then(([code, docs, doc, sync]) => ({ NibCodeElement: code.NibCodeElement, fileDocs: docs.fileDocs, Doc: doc.Doc, connect: sync.connect })));}
/** * Every live editor replica for a path, so a newly opened panel can mesh into the ones already * there. `connect`'s `onUpdate` only forwards a doc's own local edits (D16), not updates it merely * relayed in from elsewhere; a hub that just forwarded everyone's edits through itself would never * pass one spoke's edit on to another. Connecting every replica directly to every other one (and to * the path's shared hub Doc, which only Files reads and writes) sidesteps that: each edit is local * to the replica that made it, so ordinary two-party `connect` forwarding reaches every peer. */const liveReplicas = new WeakMap<Files, Map<string, Set<DocType>>>();
/** Connects `replica` to `hub` and to every other replica already open on `path`, then joins the set. */function mesh(files: Files, path: string, hub: DocType, replica: DocType, connect: ConnectFn): Unsubscribe { let byPath = liveReplicas.get(files); if (!byPath) liveReplicas.set(files, (byPath = new Map())); let peers = byPath.get(path); if (!peers) byPath.set(path, (peers = new Set()));
const stops: Unsubscribe[] = []; for (const other of [hub, ...peers]) { const channel = new MessageChannel(); stops.push(connect(other, channel.port1), connect(replica, channel.port2), () => { channel.port1.close(); channel.port2.close(); }); } peers.add(replica);
return () => { peers!.delete(replica); for (const stop of stops) stop(); };}
/** * `<nib-editor>`: a lazy host over `<nib-code>` (CodeMirror + Loro), so CodeMirror never enters the * initial bundle. Until the chunk loads, a non-interactive `<pre>` mirrors the file so there's no * blank flash; it tracks `files` directly, since the shared `Doc` (D16) doesn't exist yet. */export class EditorElement extends HTMLElement implements EditingTarget { readonly #root: ShadowRoot; #pre: HTMLPreElement | undefined; #code: CodeModule.NibCodeElement | undefined; #commands: typeof import("@codemirror/commands") | undefined; #undo: typeof import("loro-codemirror") | undefined; #language: Language = "text"; #disposed = false; #disposeSync: Unsubscribe | undefined;
constructor() { super(); this.#root = this.attachShadow({ mode: "open", delegatesFocus: true }); adopt(this.#root, editorSheet); }
override focus(): void { this.#code?.view?.focus(); }
override blur(): void { this.#code?.view?.contentDOM.blur(); }
get language(): Language { return this.#language; }
insert(text: string, caret = text.length): void { const view = this.#code?.view; if (!view) return; const { from, to } = view.state.selection.main; view.dispatch({ changes: { from, to, insert: text }, selection: { anchor: from + caret }, scrollIntoView: true }); view.focus(); }
move(direction: CaretMove): void { const view = this.#code?.view; const commands = this.#commands; if (!view || !commands) return; const run = { left: commands.cursorCharLeft, right: commands.cursorCharRight, up: commands.cursorLineUp, down: commands.cursorLineDown, }[direction]; run(view); }
indent(): void { const view = this.#code?.view; if (view && this.#commands) this.#commands.indentMore(view); }
undo(): void { const view = this.#code?.view; if (view && this.#undo) this.#undo.undo(view); }
redo(): void { const view = this.#code?.view; if (view && this.#undo) this.#undo.redo(view); }
/** Binds to `path` in `files`. The accessible name is the path, set by `<nib-code>` once it mounts. */ connect(files: Files, path: string): Unsubscribe { this.#language = languageOf(path);
const pre = document.createElement("pre"); pre.textContent = files.read(path) ?? ""; pre.setAttribute("aria-hidden", "true"); this.#pre = pre; this.#root.append(pre); const unwatchPre = files.watch((changed) => { if (changed === path) pre.textContent = files.read(path) ?? ""; });
loadCode().then(({ NibCodeElement, fileDocs, Doc, connect }) => { if (this.#disposed) return; pre.remove(); unwatchPre(); this.#pre = undefined;
// CodeMirror's Loro binding tells its own edits apart from a peer's by Loro's local/remote // event flag, which is meaningless if two editor instances share one LoroDoc directly (every // edit looks "local" to both, so neither ever sees the other's). Each panel instead gets its // own replica, seeded from the shared per-path doc (D16) and meshed with the hub and every // other open replica on this path, the same connect() a human, an agent, or the kernel syncs // through (collab/sync.ts). const hub = fileDocs(files).open(path); const replica = Doc.load(hub.export()); this.#disposeSync = mesh(files, path, hub, replica, connect);
const code = new NibCodeElement(); code.filename = path; this.#code = code; this.#root.append(code); code.doc = replica;
Promise.all([import("@codemirror/commands"), import("loro-codemirror")]).then(([commands, loroCodemirror]) => { if (this.#disposed) return; this.#commands = commands; this.#undo = loroCodemirror; }); });
return () => { this.#disposed = true; this.#pre?.remove(); unwatchPre(); this.#code?.remove(); this.#disposeSync?.(); }; }}
declare global { interface HTMLElementTagNameMap { "nib-editor": EditorElement; }}
define("nib-editor", EditorElement);