Drop the remaining data file and/or interpreter(s) here
Structural changes re-pack the layout; the rest just re-render.
Current values shown below are applied immediately. Load settings replaces these values; reset restores the built-in defaults.
A dict value or table cell wrapped between this prefix and suffix, matched verbatim (delimiters included), becomes a clickable link that jumps to whichever dict/table elsewhere in this document has that exact same name. Blank prefix = off. Saved automatically, and round-trips through Export/Load settings.
Override how specific values are displayed — cosmetic only, doesn't change the underlying data. Blank = no override. Saved automatically, and round-trips through Export/Load settings.
Detached-view-only column width overrides (the ⧀ button in a table's toolbar). A rule forces every column whose header name matches its regex to a fixed width, in characters — first matching rule wins. Saved automatically, and round-trips through Export/Load settings.
A column with no rule match whose distinct-value count is under this fraction of its row count (e.g. a status/category/flag column) gets the width above instead of its natural full width. 0 disables.
Original
Editing
Drop to load
block once it's serialized back to text via outerHTML.
snapScript.textContent = "window.__STRUCTILE_SNAPSHOT__ = " + JSON.stringify(snapshot).replace(/\n" + clone.outerHTML;
}
// "Save viewer with this interpreter…" (see buildSaveMenu / exportBakedViewerHtml):
// the client-side, air-gapped counterpart to ?interpreter= — emits a
// clean copy of THIS page with the currently-active interpreter baked into
// window.__STRUCTILE_BAKED_INTERPRETERS__ (structile.html's build-time
// interpreter registry — see BAKED_INTERPRETERS) and NO data. Opening the
// result and dropping only a data file of the baked extension resolves it
// with no companion .js (tryBakedInterpreter). Same DOM-node injection as
// buildStandaloneSnapshotHtml above — inserted BEFORE the main engine
// script so the global is already set when BAKED_INTERPRETERS reads it at
// module eval — and the same "\n" + clone.outerHTML;
}
// Opens a blank tab, writes the captured data-free viewer into it, and hands
// over the just-saved state through a nonce-scoped BroadcastChannel. window.name
// carries the nonce because the new document deliberately has no source URL of
// its own. The receiver announces itself ("hello") once init() is listening,
// avoiding a fixed-delay race against parsing the freshly-written document.
function forkToNewTab(text){
if (typeof BroadcastChannel === "undefined"){
note("Saved " + currentFileName + " — this browser can't auto-open a clean copy in a new tab; reload manually to start one.");
return;
}
const nonce = makeNonce();
const win = window.open("", "_blank");
if (!win){
note("Saved " + currentFileName + " — allow popups to open the saved copy in a new tab.");
return;
}
const payload = buildHandoffPayload(text, currentFileName);
const ch = new BroadcastChannel("structile-handoff-" + nonce);
const onMsg = (e)=>{
if (!e.data || e.data.type !== "structile-handoff-hello") return;
ch.postMessage({ type:"structile-handoff-payload", payload });
ch.removeEventListener("message", onMsg);
setTimeout(()=> ch.close(), 1000);
};
ch.addEventListener("message", onMsg);
try {
win.name = HANDOFF_WINDOW_NAME_PREFIX + nonce;
win.document.open();
win.document.write(cleanViewerDocumentHtml || buildCleanViewerDocumentHtml());
win.document.close();
} catch(e){
ch.removeEventListener("message", onMsg);
ch.close();
try { win.close(); } catch(ignore){}
note("Saved " + currentFileName + " — could not open the clean viewer tab: " + (e && e.message || e));
return;
}
setTimeout(()=>{ ch.removeEventListener("message", onMsg); ch.close(); }, 15000);
}
// Receiving side, run by the clean tab written by forkToNewTab above — see
// its call in init(). Reparses the handed-off text through the
// SAME format (rebuilding the XML interpreter from source when needed, since
// functions themselves can't cross postMessage/BroadcastChannel), so the new
// tab's baseline is exactly "what was just saved", not a structured clone of
// the old tab's in-memory value.
function handoffNonceFromPage(){
try {
const fromUrl = new URL(location.href).searchParams.get("handoff");
if (fromUrl) return fromUrl; // compatibility with pre-3.7 same-page handoffs
} catch(e){}
try {
return window.name && window.name.startsWith(HANDOFF_WINDOW_NAME_PREFIX)
? window.name.slice(HANDOFF_WINDOW_NAME_PREFIX.length) : null;
} catch(e){ return null; }
}
// ?url= — see loadSnapshotOrDefault in init() and fetchAndLoadUrl.
function urlParamValue(){
try { return new URL(location.href).searchParams.get("url"); }
catch(e){ return null; }
}
// ?interpreter= — the hosted-bundle counterpart to dropping/pasting a
// companion .js: fetch an interpreter script from a URL on boot and hand
// it to the same pairing flow (handleInterpreterText), so it resolves
// whatever XML/other structured data arrives by any other means. See
// loadSnapshotOrDefault in init() and fetchAndLoadInterpreterUrl. A
// cross-origin bundle host must send Access-Control-Allow-Origin for this
// page's origin, same CORS constraint as ?url=.
function interpreterParamValue(){
try { return new URL(location.href).searchParams.get("interpreter"); }
catch(e){ return null; }
}
function clearHandoffMarker(){
try {
const u = new URL(location.href);
u.searchParams.delete("handoff");
history.replaceState(null, "", u.href);
} catch(e){}
try { if (window.name.startsWith(HANDOFF_WINDOW_NAME_PREFIX)) window.name = ""; } catch(e){}
}
function applyHandoffPayload(payload){
try {
let format;
if (payload.kind === "python") format = PYTHON_FORMAT;
else if (payload.kind === "csv") format = csvFormat(payload.delimiter || detectCsvDelimiter(payload.text || ""));
else if (payload.kind === "xml"){
const fns = buildInterpreterFns(payload.interpreterSource || "");
const needed = payload.xmlMode ? fns.interpretFn : fns.interpretTextFn;
if (typeof needed !== "function")
throw new Error("handed-off interpreter script is missing " + (payload.xmlMode ? "interpretXML" : "interpretText"));
format = structuredFormat(fns, payload.xmlMode || null, payload.format || payload.xmlExt);
format.interpreterSource = payload.interpreterSource || "";
} else format = JSON_FORMAT;
const value = format.parse(payload.text);
loadValue(value, payload.fileName || "data", format, { rawText: payload.text });
note("Opened the saved copy of " + (payload.fileName || "data"));
} catch(e){
note("Could not open the saved copy: " + (e && e.message || e));
loadDefaultEmpty();
}
}
function tryReceiveHandoff(onDone){
const nonce = handoffNonceFromPage();
if (!nonce || typeof BroadcastChannel === "undefined"){ onDone(false); return; }
clearHandoffMarker();
let settled = false;
const ch = new BroadcastChannel("structile-handoff-" + nonce);
const finish = (got)=>{ if (settled) return; settled = true; onDone(got); };
ch.addEventListener("message", (e)=>{
if (e.data && e.data.type === "structile-handoff-payload"){
applyHandoffPayload(e.data.payload);
finish(true);
setTimeout(()=> ch.close(), 500);
}
});
ch.postMessage({ type:"structile-handoff-hello" });
setTimeout(()=>{ if (!settled){ ch.close(); finish(false); } }, 4000);
}
// =====================================================================
// EMBEDDED MODE (?embed=1, or a host postMessage even without the flag)
// =====================================================================
// Lets a host page run this viewer inside an iframe (or a popup it opened)
// as an editable surface it controls: the host supplies the data/config/
// interpreter instead of the user picking a file, so every "which data,
// whose settings" control — load/paste, view save/load,
// about — is hidden (see the [data-embed-hide]
// CSS rule); only the actual editing surface survives (canvas, inline
// edit, undo/redo, search, save). The settings panel is hidden
// the same way UNLESS the host opts in via enableSettingsPanel (see below).
// Save posts the serialized
// content to the host instead of downloading/opening a tab (see
// performSave), and dirty state is pushed to the host every time it changes
// (see emitDirtyStateChanged/emitSelfEditDirtyStateChanged).
//
// Message protocol (all messages are plain objects with a `type`):
// host -> viewer "structile-embed-init" { value? | text?+format?, name?, sourceFileName?, interpreterSource?, interpreterCandidateNames?, config?, hostManagesSave?, disableSourceView?, disableSaveAs?, enableSettingsPanel? }
// sourceFileName: the real on-disk name (with extension, no directory)
// when the data was loaded from an actual file — becomes
// currentSourceFileName (the chip's fold + tooltip). `name`
// is only the display stem; absent means no file behind it.
// config: { settings?: {gap, n, m, N, M, nameMax, valMax, headerLabelMax,
// detCols, detRows, dwellMs, kvRows, kvCols, maxWidthFrac},
// theme?: "light"|"dark", renderMap?, links?: {open?, close?} } —
// same shape as a .settings.json file (see buildSettingsFile),
// plus theme. `links` is the cross-reference-value marker (see
// linkSettings) — a dict value/table cell wrapped between
// open/close that names another dict/table in the document
// becomes a clickable jump-to-it link.
// hostManagesSave: true hides Save (see [data-host-save-hide])
// and stops Ctrl/Cmd+S from being handled here at all, letting
// it fall through to the host's own save keybinding — for a
// host (e.g. the VS Code extension) that renders this page as
// the WHOLE webview document (no nested iframe of its own),
// where this page's own keydown handler would otherwise be
// first in line for every keystroke, including the host's.
// The host is expected to pull current content on demand via
// structile-embed-request-save instead of waiting for a push.
// disableSourceView: true hides the source-pane toggle/pane entirely
// (see [data-source-view-hide]) — for a host that will get its
// own, separately-designed source view later (the
// vscode-extension/ custom editor; see
// source_code_view_claude.md Phase 6) and wants this feature
// kept out of its way meanwhile. Absent (the structile
// widget's own embed-init never sets it) means "feature
// present," matching standalone-page behavior.
// disableSaveAs: true disables the Save As… row in the Save split-
// button's menu (see buildSaveMenu) instead of leaving it to
// silently no-op — for a host that has no location to write a
// new file to in the first place (the structile widget sets
// this whenever neither source_path nor an explicit save_path
// was known at construction time — see widget.py's
// has_save_location — e.g. structile.open(some_in_memory_value)
// with no path= at all). Absent/false means "feature present,"
// same convention as disableSourceView above.
// enableSettingsPanel: true shows the settings panel/button (see
// [data-settings-panel-hide]) instead of the default
// fully-hidden embed behavior — both the structile widget
// and the VS Code extension set this. Its "Set as default"
// button (data-embed-only, next to Export/Load/Reset
// settings) posts structile-settings-changed (see below) instead of
// applying only to this one view, letting each host persist
// the panel's current state as its own new baseline.
// host -> viewer "structile-embed-text-changed" { text } — see applyHostTextChanged: a disableSourceView
// host with no in-page pane of its own (the VS Code extension) pushes its REAL text
// editor's own current content here whenever it changes (debounced host-side), so a
// source edit made directly in that real editor lands in the graph too, not just a
// selection/fold nudge. Reuses the exact same SourceEdit command
// commitSourceRenderIfValid pushes for the in-page pane's own typed edits — same
// "always rebuilds via buildTree, exactly like a fresh load" trade-off (including
// undoStack/redoStack restarting, per applySourceEditCommand's own banner comment) —
// and, unlike a graph-driven value/rename edit, commits DIRECTLY rather than through
// Edit-review (see applyHostTextChanged's own sourceViewDisabled branch): this host's
// REAL editor already IS the review surface (native undo/redo, native syntax
// highlighting), and entering diffMode here would break both real-editor -> graph
// selection sync (selectSourceRangeFromHost's own `if (diffMode) return`) and a later
// graph-side undo/redo mirroring back into the real editor (selfEditMode's undo/redo
// branch never calls notifyHostGraphTextChanged). Direct commit means sourceSession
// DOES need to stay live for this host after all (syncSourceAfterGraphMutation's own
// sourceViewDisabled branch — updateSourceSessionBookkeeping) — isSourceTextDirty()
// and selectSourceRangeFromHost both read it — even though sourceTextForSave() still
// falls back to serializeCurrentValue() here regardless (no in-page pane's formatting
// to preserve for this host). A no-op while `text` doesn't parse (mid-keystroke
// transient invalid JSON/Python-repr — VS Code's own editor already shows that as a
// syntax error natively) or parses to something with no actual semantic difference from
// the graph's current value (whitespace/key-order/a save echoing back after the real
// editor's own external-change auto-reload). Single-value mode, same position-map
// restriction as structile-embed-select-range. If a graph-driven Edit-review session is
// ALREADY open (double-click a value/rename in progress) when this arrives, it still
// lands inside that review as a second undo-able commit rather than being dropped or
// force-committed underneath it — see applyHostTextChanged's own diffMode&&selfEditMode
// branch, unaffected by any of the above. Every commit this push produces immediately
// re-baselines (syncSourceAfterGraphMutation's own SourceEdit branch sets
// sourceSession.baselineText = sourceSession.text before the caller's own
// emitDirtyStateChanged reads it) — it's an echo of content the real editor already owns
// saving, never a graph-originated change the custom document itself needs a Save
// button/dirty dot for.
// host -> viewer "structile-embed-source-saved" { text? } — see applyHostSourceSaved: for a
// disableSourceView host (the VS Code extension) to say "the REAL text editor for this
// file was just saved to disk" — something this page has no way to detect on its own,
// since a plain text-content push (structile-embed-text-changed above) fires on every
// keystroke, save or not. `text` (when given) is applied first, exactly like a plain
// structile-embed-text-changed, to catch up to whatever was actually written; either way, the
// graph's CURRENT value then becomes the new clean baseline (snapshotBaseline() — the
// same "Save clears the dots" contract performSave/requestSaveForHost give every other
// save path here), clearing dirty dots and the Save button. This is what lets a save
// made from the SOURCE side finalize the graph too, instead of only a save pulled FROM
// the graph (structile-embed-request-save) ever resetting that state.
// host -> viewer "structile-embed-note" { text } — shows `text` as a warning in the status
// note (see note()), for a problem only the host can see: the structile widget
// sends it when a Save's edited Python object (see PyExpr) doesn't rebuild on the
// Python side (widget.py's _notify_viewer), since the viewer can't check that itself.
// host -> viewer "structile-embed-diff-init" { left:{text,format,name?}, right:{text,format,name?},
// interpreterSource?, config?, keyColumns?, diff?:{view}, hostManagesSave?, disableSourceView?, disableSaveAs? } — see
// "DIFF MODE" below (diff_plan.md Phase 2). Both sides must share one format in this
// version; markup (xml/html) shares one interpreterSource across both sides too. Puts
// the viewer into the same diff state a window.__STRUCTILE_SNAPSHOT__ with mode:"diff" does
// (see loadDiffPayload) — no graph editing/undo-redo/second-diff (the graph stays
// read-only, same as a normal two-document diff always has been), but each side's own
// source pane (Phase 5c) IS independently editable/saveable via its own Save button
// (performDiffSideSave, posting structile-save-requested with `side`) whenever the source
// view itself isn't disabled — this is NOT a fully read-only mode. Diff mode's own
// graph is permanently read-only, so it never sets/uses enableSettingsPanel.
// interpreterCandidateNames: string[] — one label per interpreterSource candidate (only
// meaningful when interpreterSource is an array, or when a caller wants a real name
// for a lone candidate instead of a generic placeholder) — purely for reporting which
// one wins/loses a trial back to the host (see structile-embed-interpreter-resolved below);
// never affects which candidate actually gets tried or in what order. The VS Code
// extension sends this (structileEditorProvider.ts's buildInit); other embeddings may
// omit it, in which case reported names fall back to "candidate N".
// host -> viewer "structile-embed-interpreter" { source, name? } — for an XML/HTML doc sent without one yet
// host -> viewer "structile-embed-request-save" { forBackup?: boolean } — see requestSaveForHost.
// forBackup:true (the VS Code extension's own backupCustomDocument, a periodic
// hot-exit snapshot VS Code triggers on its own initiative while the document is
// dirty — NOT a real save, and NOT written to the actual file) still gets the
// current content back, but SKIPS snapshotBaseline()/resetting sourceSession's own
// baseline text — otherwise a backup silently happening to fire between an edit and
// the user's own explicit save would wrongly clear the graph's own dirty state
// (the Save button, and an embedded host's own dirty indicator) for content that was
// never actually persisted anywhere the user asked for. Omitted/false (a real save, either
// this page's own Ctrl+S/Save button in non-embedded use, or a host's
// saveCustomDocument-driven pull) keeps the original "posting IS saving, reset the
// baseline right after" behavior.
// host -> viewer "structile-embed-select-range" { from, to } | { from:null, to:null } — see
// selectSourceRangeFromHost: highlights (expanding collapsed ancestors as needed)
// the graph node whose source range contains character offset `from`, into the
// CURRENT sourceSession text (LF-normalized — see normalizeSourceLineEndings);
// null clears the selection instead. For a host with disableSourceView set (no
// in-page source pane of its own) to drive graph highlighting FROM its own separate
// view of the source — the VS Code extension sends this from its real text editor's
// own cursor/selection, when one happens to be open for the same file. Single-value
// mode only — needs a position map (JSON/Python-repr always have one; interpreted
// formats only when their interpreter opted in — see buildSourceLocations/
// buildInterpreterLocations),
// a no-op otherwise.
// host -> viewer "structile-embed-fold-range" { from, to, collapsed } — see applyHostFoldRange:
// folds/unfolds (STATE.collapsed for a dict/set, STATE.minimized for a table) the
// graph node whose source range exactly matches [from, to) (same LF-normalized
// offsets as structile-embed-select-range), the reverse direction of
// structile-graph-fold-changed below — for a disableSourceView host (the VS Code
// extension) to mirror a fold/unfold the user just performed in its OWN real text
// editor. A no-op if the graph's own fold state already matches `collapsed` (see
// applyingHostFold), which is what keeps this from ping-ponging back out as another
// structile-graph-fold-changed. Single-value mode only, same position-map
// restriction as structile-embed-select-range.
// viewer -> host "structile-dirty-state-changed" { dirty, totalCount, containers:[{path,label,count}] }
// viewer -> host "structile-save-requested" { format, ext, fileName, isSaveAs, content } | { error }
// isSaveAs:false (plain Save/Ctrl+S) — fileName is just an echo
// of the file already being edited. isSaveAs:true (the
// embed-only Save As… button — see performSaveAs and
// [data-embed-only]) — fileName names a NEW file to create;
// widget.py's _resolve_save_path writes it alongside the
// original (never touching that original) and retargets all
// later saves at it. Either flag can carry a fileName that
// differs from the current file — hosts should key off the
// name actually differing, not off isSaveAs alone, since
// isSaveAs is informational (some future host mechanism could
// reasonably rename without this specific button).
// viewer -> host "structile-settings-changed" { settings, renderMap, links, theme } — fired only by the
// "Set as default" button (enableSettingsPanel must be set for it to even be
// reachable); settings/renderMap/links are buildSettingsFile()'s own output (the FULL
// current panel state, not a diff — same as Export settings produces), plus theme
// added alongside it. One-off action, not continuously-observed state, so — like
// structile-save-requested and unlike structile-dirty-state-changed — hosts should treat this as
// an event to react to once, not a value to read back later.
// viewer -> host "structile-graph-selection-changed" { from, to } | { from:null, to:null } — the reverse
// direction of structile-embed-select-range above: posted whenever a graph<->source click
// (or Escape/click-away deselect) changes the current selection, but ONLY while
// disableSourceView is set (a host WITH its own in-page pane already gets full local
// sync and has no separate view to keep in step) — see notifyHostGraphSelectionChanged.
// Same LF-normalized offsets as structile-embed-select-range, so a host can highlight/reveal
// the equivalent range in its own separate view of the source (the VS Code extension's
// real text editor, if one happens to be open for the same file).
// viewer -> host "structile-graph-fold-changed" { from, to, collapsed } — the reverse direction of
// structile-embed-fold-range above: posted whenever toggleCollapse/headerMinBtn changes a
// dict/set/table's fold state (see notifyHostGraphFoldChanged), but ONLY while
// disableSourceView is set — same gate as structile-graph-selection-changed, and for the
// same reason (a host with its own in-page pane already gets this for free, since
// STATE.collapsed/STATE.minimized already ARE that pane's own fold state — see the
// "SOURCE-VIEW CODE FOLDING" banner comment above). Suppressed while applying an
// incoming structile-embed-fold-range (applyingHostFold), so the two messages don't loop.
// viewer -> host "structile-graph-text-changed" { text } — see notifyHostGraphTextChanged: posted
// whenever a graph-side edit commits/undoes/redoes, ONLY while disableSourceView is set
// (a host with its own in-page pane already keeps that pane's own text in sync via
// syncSourceAfterGraphMutation instead) — so a host with no pane of its own (the VS Code
// extension) can mirror the edit into a REAL text editor for the same file, when one
// happens to be open beside this one (e.g. via "Open Preview to the Side"), instead of
// only finding out once an explicit Save pulls content. Never posted for a SourceEdit's
// own initial commit (see commitCommand's own cmd.type!=="SourceEdit" guard) — that
// command's text just arrived FROM the host (or the in-page pane) a moment ago, so
// echoing it straight back would be a pointless same-tick round trip; undo/redo DOES
// always post this, even when reverting through a SourceEdit, since undo/redo itself is
// always a graph-side action the host doesn't otherwise learn about. Also never posted
// while selfEditMode is active (commitCommand/undoN/redoN each return from their own
// selfEditMode branch before reaching this) — double-clicking a VALUE now auto-enters
// that Edit-review sub-mode (see enterSelfEditMode), and its edits land in
// rightSource.value, not currentValue, until the session is actually resolved; an
// uncommitted, still-cancellable edit has no business reaching a real editor's buffer
// yet. Only a rename (startKeyEdit/startNameEdit/startChainNameEdit/renameTableColumn —
// deliberately left out of the Edit-review flow, see enterSelfEditMode's own comment)
// commits immediately through the plain path and posts this right away. A value edit
// instead reaches the real editor (and disk) only once an actual host-driven pull-save
// resolves the session (see structile-embed-request-save/requestSaveForHost's own
// diffMode&&selfEditMode branch, which calls commitSelfEditAndSave) — writeFromWebview
// on the host side mirrors that FINAL pulled content the same way either way, so this
// is a timing difference (live vs. only-at-save), not a coverage gap.
// viewer -> host "structile-graph-save-requested" {} — see requestGraphSaveFromHost: #hostSaveBtn's own
// click handler, for a hostManagesSave host that hides this page's own #saveBtn split-btn
// entirely (data-host-save-hide) and instead expects Ctrl+S/File>Save to
// reach the HOST's own save command. Carries no content — unlike structile-save-requested,
// there's nothing for this page to pull/serialize on its own initiative here; the host
// already has everything it needs to drive an actual save itself, exactly as if the
// user had pressed Ctrl+S/File>Save with this editor focused.
// viewer -> host "structile-embed-interpreter-resolved" { candidates:[{name, outcome, error?}] } — see
// reportInterpreterResolution: posted whenever runInterpreter actually runs an
// interpreter trial (tryInterpreterCandidates), single-candidate or array, win or
// total failure alike — the ONE most useful case for a debugging tool being exactly
// the one where every candidate raised. `outcome` is "used" (this is the candidate
// that won — the trial stops at the first success, so at most one), "raised" (tried
// and threw — `error` holds its message), or "not_tried" (never reached, because an
// earlier candidate already won). Same length and order as the `interpreterSource`
// array that was sent in (or a single-element array for a lone `interpreterSource`
// string) — `name` comes from the matching `interpreterCandidateNames` entry, or
// "candidate N" (1-based) when that wasn't supplied. Deliberately host-agnostic (no
// VS Code concepts in the payload) — the VS Code extension's `Structile: Show
// Interpreter Resolution`/status bar are its first consumer (structileEditorProvider.ts),
// but any embedding could use it the same way; no other consumer is built yet.
// Receiving EITHER host->viewer message activates embed mode by itself,
// independent of the URL flag — a host that can't easily control the
// iframe's src query string can still opt in purely via postMessage.
let embedMode = false;
let hostManagesSave = false; // see hostManagesSave in the structile-embed-init doc above
let sourceViewDisabled = false; // see disableSourceView in the structile-embed-init doc above
let saveAsDisabled = false; // see disableSaveAs in the structile-embed-init doc above
function applyDisableSourceView(msg){
if (msg.disableSourceView){
sourceViewDisabled = true;
document.body.classList.add("structile-source-view-disabled");
}
}
function enterEmbedMode(){
if (embedMode) return;
embedMode = true;
document.body.classList.add("embedded");
// #fileNameLabel's click-to-load affordance is already suppressed
// visually (see body.embedded #fileNameLabel above) — disabling it too
// means a real disabled state instead of a live, focusable button whose
// click silently no-ops.
document.getElementById("fileNameLabel").disabled = true;
// #sourcePanelInput's HTML default is `readonly` — matches embedMode's
// own contract now too (see refreshSourcePanel: typing directly into this
// pane is retired for every host and format), so nothing to override here.
// #saveBtn's own label: the standalone-page default ("Save As…") is
// truthful there (performSave always prompts for a destination — see its
// own banner comment), but embedded, Save posts back to a host-owned
// target and genuinely overwrites in place, so "Save As…" would be a lie
// — #hostSaveBtn (the OTHER of this pair, see data-host-save-show) never
// needs this: it's only ever real Save.
document.getElementById("saveBtn").textContent = "Save";
}
function postToHost(msg){
const target = (window.parent && window.parent !== window) ? window.parent : window.opener;
if (target) target.postMessage(msg, "*");
}
// Applies a host-supplied config exactly like a loaded settings file (see
// applySettingsFile) — same shape (flat or {settings,renderMap,theme}), same
// merge/validation rules — since it's standing in for the panel (and the
// theme button's remembered choice) this mode hides. The data itself is
// either an already-parsed `value` (saved/edited
// back through JSON by default — there's no source format to infer one
// from), or raw `text` read through one of the built-in parsers named by
// `format`; an XML/HTML text without `interpreterSource` yet just waits
// (via the existing handleStructuredText pairing) for a later
// structile-embed-interpreter message, same as a paste/drop pairing would.
//
// Factored out of applyEmbedInit so a self-contained standalone snapshot
// (see window.__STRUCTILE_SNAPSHOT__ in init()) can drive the exact same data/
// config loading without also calling enterEmbedMode() — a snapshot file
// keeps the settings bar, Phase A editing, undo/redo, and client-side Save
// As, all of which enterEmbedMode() would hide.
function applyInitialPayload(msg){
if (msg.config) applySettingsFile(msg.config, "host config");
// No msg.name means the host handed over an in-memory value with no path
// and no explicit name (e.g. structile.open(some_dict) in Jupyter — see
// structile/__init__.py's inferred_name) — hasRealName carries that
// through to updateFileNameLabel so it doesn't fabricate a "data.json"
// that reads as an actual file when nothing of the sort exists. Only
// meaningful for the two plain-value branches below (value/json text) —
// structile.open() never sends format="python" or a markup format for a
// nameless in-memory value, so those branches don't need it threaded
// through.
const hasRealName = typeof msg.name === "string" && !!msg.name;
const name = hasRealName ? msg.name : "data";
// The real on-disk name (extension and all) when the host loaded this
// from an actual file — structile.open("x.xml") sends the path's own
// name — so the chip gets its fold and the tooltip names the file
// instead of "no file on disk" (see currentSourceFileName). Absent for
// an in-memory value/literal string, and for a "Save as HTML" copy
// (exportStandaloneHtml never writes it), both of which correctly stay
// square-cornered.
const sourceFileName = typeof msg.sourceFileName === "string" && msg.sourceFileName ? msg.sourceFileName : null;
if (msg.value !== undefined){
loadValue(msg.value, name, JSON_FORMAT, { hasRealName, sourceFileName });
return;
}
// A binary session's "Save as HTML…" (see exportStandaloneHtml): bytes,
// not text — checked before the text guard below, since it carries none.
if (msg.format === "bin" && typeof msg.bytesBase64 === "string"){ restoreBinarySnapshot(msg, name); return; }
if (typeof msg.text !== "string") return;
// The interpreter branches below reach loadValue only after an
// interpreter run (or a pairing wait), so they get the name the same way
// a dropped file's deferred load does — staged on the raw text (see
// stageOriginForText) rather than threaded through every one of those
// paths. json/python have loadValue right here and pass it directly.
const stageSourceName = () => { if (sourceFileName) stagedSourceName = { name: sourceFileName, rawText: msg.text }; };
// Format genuinely unknown (no msg.format at all) but a per-format
// interpreter candidate map was given instead — see
// runInterpreterAnyFormat/tryInterpreterCandidatesAnyFormat. Checked
// before the "no format -> json" default below, since THIS is what
// signals "this is markup, format still TBD" rather than "this is plain
// JSON with format= simply omitted" (the long-standing default).
if (!msg.format && msg.interpreterCandidatesByFormat && Object.keys(msg.interpreterCandidatesByFormat).length){
stageSourceName();
runInterpreterAnyFormat(msg.text, msg.interpreterCandidatesByFormat, name);
return;
}
const fmt = msg.format || "json";
if (fmt === "json"){
try { loadValue(jsonParsePreserveBigInt(msg.text), name, JSON_FORMAT, { hasRealName, rawText: msg.text, sourceFileName }); }
catch(e){ note("Could not parse embedded JSON: " + e.message); }
} else if (fmt === "python"){
try { loadValue(parsePythonRepr(msg.text), name, PYTHON_FORMAT, { rawText: msg.text, sourceFileName }); }
catch(e){ note("Could not parse embedded Python data: " + e.message); }
} else {
// Any other format runs through an interpreter — xml/html via the DOM
// contract (mode set), anything else (ini, ...) via the raw-text
// contract (mode null — see domModeForFormat/tryInterpreterCandidates)
// — EXCEPT a natively-parsed one (csv/tsv), which handleStructuredText
// below resolves on its own (tryNativeTextFormat) with nothing to wait
// for. An interpreterSource in the payload still wins, so a snapshot
// saved back when csv needed generic_csv.js keeps rendering through it.
const mode = domModeForFormat(fmt);
const hasInterpreter = typeof msg.interpreterSource === "string" ? !!msg.interpreterSource
: Array.isArray(msg.interpreterSource) && msg.interpreterSource.length > 0;
stageSourceName();
if (hasInterpreter) runInterpreter(msg.text, msg.interpreterSource, mode, name, fmt, msg.interpreterCandidateNames);
else handleStructuredText(msg.text, mode, name, fmt); // waits for a later structile-embed-interpreter message
}
}
// The binary counterpart of applyInitialPayload's interpreter branch:
// decodes the bundled bytes and re-runs the bundled interpreter on them
// (runBinaryInterpreter — byte-exact, same as the original drop), or, for a
// snapshot saved from the hex view (no interpreter), shows the same hex
// view again. The reopened copy keeps Save disabled, same as the live
// session — the point is that the DATA survives, not that it becomes
// editable. `fromSnapshot` keeps it from claiming a file on disk that a
// downloaded standalone copy doesn't have (same square-cornered chip a
// restored text snapshot gets); `displayName` keeps the name exactly as
// saved rather than re-deriving it from name + sourceExt.
function restoreBinarySnapshot(msg, name){
let bytes;
try { bytes = base64ToBytes(msg.bytesBase64); }
catch(e){ note("Could not decode the embedded binary data: " + (e && e.message || e)); return; }
const ext = typeof msg.sourceExt === "string" ? msg.sourceExt : "";
const info = { name: name + ext, displayName: name, bytes, fromSnapshot: true,
label: BINARY_EXT_LABEL[ext] || "binary data", reason: "bundled into this standalone copy" };
const sources = Array.isArray(msg.interpreterSource) ? msg.interpreterSource
: (typeof msg.interpreterSource === "string" && msg.interpreterSource ? [msg.interpreterSource] : []);
if (!sources.length){ showBinaryAsHex(info); return; }
// One or several candidates, tried in order on the bytes — the same
// "first non-throwing wins" trial runInterpreter runs for text, and the
// same per-candidate report to an embedding host (the VS Code
// extension's own resolution debug view reads it — see
// reportInterpreterResolution), sent BEFORE the winner is loaded.
const names = Array.isArray(msg.interpreterCandidateNames) ? msg.interpreterCandidateNames : null;
const attempts = [];
for (let i = 0; i < sources.length; i++){
const result = tryBinaryInterpreter(bytes, sources[i], names && names[i], info.name);
if (result.ok){
attempts.push({ ok:true, error:null });
reportInterpreterResolution(sources, { attempts }, names);
loadInterpretedBinary(info, sources[i], msg.interpreterName || (names && names[i]) || null, result);
return;
}
attempts.push({ ok:false, error: result.error });
}
reportInterpreterResolution(sources, { attempts }, names);
const last = attempts[attempts.length - 1];
note((sources.length > 1 ? "None of " + sources.length + " interpreters worked — " : "") + last.error);
// Nothing reads its own bytes (it shouldn't happen for a snapshot — the
// same source produced it) — fall back to the same hex/pairing prompt a
// fresh drop gets rather than showing nothing.
beginBinaryFile(info);
}
// Shared by both structile-embed-init and structile-embed-diff-init below — the four
// host flags (embed mode itself, hostManagesSave, disableSourceView, disableSaveAs) apply
// identically regardless of which init message carries them; only the data
// payload that follows (applyInitialPayload vs. loadDiffPayload) differs.
function applyEmbedHostFlags(msg){
enterEmbedMode();
if (msg.hostManagesSave){ hostManagesSave = true; document.body.classList.add("host-manages-save"); }
applyDisableSourceView(msg);
if (msg.disableSaveAs){ saveAsDisabled = true; }
if (msg.enableSettingsPanel){ document.body.classList.add("structile-settings-panel-enabled"); }
}
function applyEmbedInit(msg){
applyEmbedHostFlags(msg);
applyInitialPayload(msg);
}
function handleHostMessage(e){
const msg = e.data;
if (!msg || typeof msg !== "object") return;
if (msg.type === "structile-embed-init") applyEmbedInit(msg);
else if (msg.type === "structile-embed-diff-init"){
applyEmbedHostFlags(msg);
loadDiffPayload(msg);
}
else if (msg.type === "structile-embed-interpreter" && typeof msg.source === "string"){
enterEmbedMode();
handleInterpreterText(msg.source, typeof msg.name === "string" ? msg.name : undefined);
}
else if (msg.type === "structile-embed-request-save") requestSaveForHost(!!msg.forBackup);
else if (msg.type === "structile-embed-select-range") selectSourceRangeFromHost(msg);
else if (msg.type === "structile-embed-fold-range") applyHostFoldRange(msg);
else if (msg.type === "structile-embed-text-changed") applyHostTextChanged(msg.text);
else if (msg.type === "structile-embed-source-saved") applyHostSourceSaved(msg.text);
else if (msg.type === "structile-embed-note" && typeof msg.text === "string") note(msg.text, { warning: true });
}
// Always listening (harmless in standalone use — nothing ever posts these
// message types unprompted), so a host that embeds this page without the
// URL flag can still switch it into embed mode purely via postMessage.
//
// srcdoc detection matters because of a race: both known srcdoc hosts
// (structile's own static/widget.js, and the VS Code extension's
// webviewContent.ts) set `iframe.srcdoc` and then wait for the iframe's
// "load" event before postMessage-ing structile-embed-init — but that "load"
// event fires only AFTER this document's own DOMContentLoaded (and thus
// after init()/loadSnapshotOrDefault() has already run). A srcdoc document
// always has location.href === "about:srcdoc" (never a real URL a host
// would deliberately navigate the top-level page to), so without this
// check, every srcdoc-embedded load would flash the "No data loaded" empty
// default before the real postMessage-delivered data ever arrives — see
// loadDefaultEmpty's `!embedMode` guard in loadSnapshotOrDefault.
function initEmbedMessaging(){
try {
const isEmbedUrl = new URL(location.href).searchParams.get("embed") === "1";
if (isEmbedUrl || location.href === "about:srcdoc") enterEmbedMode();
} catch(e){}
window.addEventListener("message", handleHostMessage);
}
// Notifies an embedded host of the plain (non-selfEditMode) graph's dirty
// state — just isSourceTextDirty() now (the ONE remaining plain-mode command,
// a first-load SourceEdit, has nothing more granular to report — see
// commitSourceRenderIfValid's own !hasLoadedRealData guard). `containers`
// (a per-field breakdown this used to also send) was never actually
// consumed by either host — structile/static/widget.js and
// vscode-extension/src/structileEditorProvider.ts's isDirtyStateChanged both only
// ever read `dirty`/`totalCount` — so dropping it is a protocol
// simplification, not a capability loss.
function emitDirtyStateChanged(){
if (!embedMode) return;
const dirty = isSourceTextDirty();
postToHost({ type: "structile-dirty-state-changed", dirty, totalCount: dirty ? 1 : 0 });
}
// selfEditMode's own counterpart — a value/cell/rename/source edit made
// through the Edit-review flow lives in a completely separate diff-engine
// tree, so without this, an embedded host's own dirty/modified indicator
// (structile's StructileWidget.dirty/dirty_count, or VS Code's tab dot)
// would never activate for any edit that goes through this flow — see
// finishRightSourceUpdate/commitSelfEditParsedText, where both a graph-side
// and a typed-text self-edit commit land.
function emitSelfEditDirtyStateChanged(){
if (!embedMode || !diffRoot) return;
const counts = diffCounts(diffRoot);
const total = Object.values(counts).reduce((a, b) => a + b, 0);
postToHost({ type: "structile-dirty-state-changed", dirty: total > 0, totalCount: total });
}
// =====================================================================
// DIFF MODE — state and both init paths. Unified-view rendering is the
// "DIFF RENDERER" section further down (see renderDiffUnified); Split
// view has its own state/rendering in the "SPLIT VIEW" section below.
// =====================================================================
// Two entry points drive the exact same state through the exact same
// loadDiffPayload, mirroring how applyInitialPayload already serves both
// window.__STRUCTILE_SNAPSHOT__ and structile-embed-init for the single-value case:
// - a standalone snapshot: window.__STRUCTILE_SNAPSHOT__ = {mode:"diff", left,
// right, interpreterSource?, config?, keyColumns?, diff?:{view}} (see
// init() below) — settings bar/paste panel stay visible
// and usable, same as a normal standalone snapshot; only Save/
// editing/undo-redo/another-diff are unavailable (body.structile-diff-mode).
// - an embedded host: postMessage({type:"structile-embed-diff-init", ...}) —
// see handleHostMessage — additionally calls enterEmbedMode(), which
// hides the "which data" controls the same way a normal embedded
// single-value session already does (body.embedded).
let diffMode = false;
// Self-edit sub-mode: reuses the whole diffMode machinery above (diffRoot,
// unified/split rendering, scope pruning, coloring) to compare the document's
// saved baseline (left, read-only) against a live editable copy (right) —
// see enterSelfEditMode/cancelSelfEditMode/commitSelfEditAndSave. Whenever
// this is true, diffMode is also true; the reverse is not required. Reset
// only in exitDiffMode, so it can never drift true while diffMode is false.
let selfEditMode = false;
let diffView = "unified"; // "unified" | "split" — see renderDiffSplitEngine/paintDiffSplit (Phase 4)
let leftSource = null; // normalizeDiffSideSession's full typed session shape (name/value plus
let rightSource = null; // buildSourceSessionCore's fields plus serialize) — see sourceSession's own comment
// Per-side, not one shared value (diff_plan.md's "Different Input Formats"
// Later Extension, pulled forward here — see loadDiffPayload/enterDiffMode):
// left and right can be different formats/schemas now (e.g. two XML
// documents in genuinely different schemas each needing their OWN
// interpreter), so there is no single "the" interpreter for a diff anymore.
let diffLeftInterpreterSource = "";
let diffRightInterpreterSource = "";
let diffKeyColumns = null; // retained (not just a one-shot enterDiffMode param) so a re-entry — e.g. the
// Left/Right swap button — can reuse the same explicit row-identity override.
let diffRoot = null; // diffValues(leftSource.value, rightSource.value, {keyColumns}) — see Phase 1
// Phase 5b: forward indexes (rendered-path -> diffRoot node), one per view —
// see buildDiffSupersetValue/buildDiffSideValue's own banner comments and
// renderDiffViaEngine/renderDiffSplitEngine, which rebuild these fresh on
// every repaint. Phase 5b's own selection-sync state (which pane/side is
// currently highlighted) lives further down, next to single-value mode's
// currentGraphSelId/currentSourceSelection.
let diffNodeAtSuperset = new Map(), diffNodeAtLeft = new Map(), diffNodeAtRight = new Map();
// A normal two-document diff's own graph editing (DIFF MODE GRAPH EDITING,
// below commitSelfEditAndSave) keeps ONE undo/redo stack per side — not the
// shared undoStack/redoStack single-value mode and selfEditMode use — since
// left and right are independent documents with independent histories (same
// reasoning as their independent Save buttons). Reset on every enterDiffMode
// (a fresh diff, or a swap, is a new edit session — see its own banner).
let diffUndo = { left: [], right: [] };
let diffRedo = { left: [], right: [] };
function exitDiffMode(){
if (!diffMode) return;
closeAllSourceFindBars();
diffMode = false;
selfEditMode = false;
leftSource = null; rightSource = null; diffLeftInterpreterSource = ""; diffRightInterpreterSource = ""; diffKeyColumns = null; diffRoot = null;
currentDiffColorPlan = []; currentDiffColorPlanLeft = []; currentDiffColorPlanRight = [];
splitLeftValue = null; splitRightValue = null; splitLeftRegs = null; splitRightRegs = null;
diffNodeAtSuperset = new Map(); diffNodeAtLeft = new Map(); diffNodeAtRight = new Map();
diffOriginalIndexLeft = new Map(); diffOriginalIndexRight = new Map();
clearDiffSelectionState();
ACTIVE_CANVAS_ID = "canvas"; ACTIVE_WRAP_ID = "canvasWrap";
document.body.classList.remove("structile-diff-mode");
document.body.classList.remove("structile-diff-split");
document.body.classList.remove("structile-self-edit-mode");
renderDiffHeader();
}
// #diffExitBtn's handler (renderDiffHeader) — "go back to single data view"
// from diff mode, with no side effect beyond that (contrast startDiffReplaceSide,
// which re-enters a NEW diff; this always leaves diff mode behind). Plain
// exitDiffMode() alone is NOT enough here: diff mode's own repaint
// (loadDiffTree, called from renderDiffViaEngine) runs buildTree() against
// the diff SUPERSET value, not currentValue — see loadDiffTree's own banner
// comment on why it deliberately leaves currentValue itself untouched — so
// ROOT/NODES are left holding the diff tree's shape, and exitDiffMode() by
// itself doesn't rebuild them. loadValue()'s own diffMode/exitDiffMode guard
// gets away without this because it always calls buildTree() again right
// after with the newly loaded value anyway; this button isn't loading
// anything new, so it has to do that rebuild itself, or the canvas would
// keep showing the stale diff superset (e.g. "1 → 99" instead of "1") until
// something unrelated happened to trigger a repaint.
function exitDiff(){
exitDiffMode();
// An active search's layout snapshot (SNAP) holds the diff tree's own
// per-node visKids — item objects carrying "1 → 99" — keyed by pkey, so
// restoring it onto the single-value tree built below (which shares most
// of those pkeys) would paint the diff's values back in the moment the
// search is cleared. Ended here, while NODES is still the tree SNAP was
// taken from — the same order loadValue's own diff-exit uses.
if (searchActive) clearSearch();
if (hasLoadedRealData){
STATE.collapsed = new Set(); STATE.minimized = new Set();
STATE.large = new Set(); STATE.detached = new Set(); STATE.linkPinned = new Set();
STATE.kidsShown = new Map(); STATE.kidsRevealed = new Set();
// Per-link inclusions are path-keyed like the sets above; Expand links
// itself (STATE.linkExpand) is a mode of the view, and stays.
STATE.linkIncluded = new Set(); STATE.linkExcluded = new Set();
autoRevealedLargePkeys.clear();
buildTree(currentValue);
fullLayout();
render();
document.getElementById("canvasWrap").scrollTo(0, 0);
} else {
loadDefaultEmpty();
}
}
// Cancel button for selfEditMode (renderDiffHeader's #diffExitBtn while this
// sub-mode is active). currentValue/sourceSession were never touched while
// selfEditMode was open (only rightSource was), so exitDiff()'s existing
// "leave diff mode, rebuild the graph from currentValue" behavior already IS
// "discard the right side's edits" — no separate discard logic needed.
// Deliberately unconditional, with no confirm() gate: this used to ask
// "discard your edits?" first, but window.confirm() is a native, blocking
// dialog — several embedded hosts this same Edit flow runs in (a Jupyter
// output cell/anywidget iframe chief among them) sandbox or silently no-op
// it, which made Cancel do nothing at all from the user's side, with no
// visible error. A broken/un-rendered source-pane edit (hasPendingSelfEdit)
// must be just as discardable as a clean one — Cancel means "throw this
// away," pending or not, valid JSON or not.
function cancelSelfEditMode(){
if (!selfEditMode) return true;
exitDiff();
// Resyncs the local Save button and an embedded host's dirty indicator
// back to whatever they were BEFORE this self-edit session started
// (nothing about entering/cancelling a self-edit session ever touches
// sourceSession.baselineText, so isSourceTextDirty() genuinely returns to
// that prior state) — without this, a host that saw
// emitSelfEditDirtyStateChanged()'s last "dirty: true" (from the now-
// discarded edit) would keep showing it as unsaved/modified forever, since
// a plain render() (what exitDiff() triggers) never recomputes dirty state
// on its own — only an actual commit/undo/redo/snapshot does.
updateSaveButtons();
emitDirtyStateChanged();
return true;
}
// Parses one side of a diff pair the same way the single-value path parses
// its own text (jsonParsePreserveBigInt / parsePythonRepr / interpretFn
// over a DOMParser doc — see JSON_FORMAT/PYTHON_FORMAT/xmlFormat and
// applyInitialPayload), just without ever calling loadValue — a diff has
// no single activeFormat/currentValue to hand off to the normal render
// path. Errors are tagged with which side failed, matching diff_plan.md's
// "parser/interpreter errors [are reported] as left-side or right-side
// errors" — each side gets its own interpreterSource (single or array),
// never a shared one, so two markup sides in different schemas can each
// resolve via their own interpreter.
// The actual text->value parsing, factored out of parseDiffSideValue so
// Phase 5c's own live reparse-on-edit (reparseDiffSideAndRecompute) can
// reuse it directly without the "Left side:"/"Right side:" error prefixing
// below, which only makes sense for the initial load path. Thin wrapper
// around parseDiffSideTextWithSerializer (below) for the two call sites
// (checkSelfEditSyntax/commitSelfEditRenderIfValid) that only ever need the
// value — selfEditMode already has its own serializer via activeFormat, so
// there's nothing for them to do with the extra return field.
function parseDiffSideText(fmt, text, interpreterSource){
return parseDiffSideTextWithSerializer(fmt, text, interpreterSource).value;
}
// Same parse as above, but also returns whichever serializer the resolved
// format/candidate exposes: JSON_FORMAT.serialize/PYTHON_FORMAT.serialize
// directly for those two, or — for an interpreter-backed format (xml/html/…)
// — that candidate's own serializeXML/serializeText, when the interpreter
// script defines one (buildInterpreterFns's own comment: it's optional, so
// this can come back null). Needed so DIFF MODE GRAPH EDITING (below), a
// normal two-document diff's own graph editing, can turn an edited VALUE
// back into TEXT for a side backed by an interpreter (json/python already
// have JSON_FORMAT.serialize/PYTHON_FORMAT.serialize directly and never
// reach the interpreter branch at all).
function parseDiffSideTextWithSerializer(fmt, text, interpreterSource){
if (fmt === "json") return { value: jsonParsePreserveBigInt(text), serialize: JSON_FORMAT.serialize };
if (fmt === "python") return { value: parsePythonRepr(text), serialize: PYTHON_FORMAT.serialize };
const hasInterpreter = Array.isArray(interpreterSource) ? interpreterSource.length > 0 : !!interpreterSource;
// csv/tsv: native, so this side needs no interpreterSource at all — but one
// that WAS supplied still wins (same precedence handleStructuredText
// applies), which keeps an old snapshot's baked generic_csv.js in charge of
// its own side. The serializer is bound to the delimiter THIS side's text
// was read with, so each side round-trips in its own dialect.
if (fmt === CSV_FORMAT_LABEL && !hasInterpreter){
const fmtObj = csvFormatForText(text);
return { value: fmtObj.parse(text), serialize: fmtObj.serialize };
}
if (!hasInterpreter) throw new Error(fmt + " diff needs an interpreterSource");
const mode = domModeForFormat(fmt);
const result = tryInterpreterCandidates(text, mode, interpreterSource);
if (!result.ok){
const last = result.attempts[result.attempts.length - 1];
throw new Error(result.attempts.length > 1
? "none of " + result.attempts.length + " interpreters worked — " + (last ? last.error : "")
: (last ? last.error : "interpreter failed"));
}
return { value: result.value, serialize: (mode ? result.serializeFn : result.serializeTextFn) || null };
}
function parseDiffSideValue(side, interpreterSource, whichSide){
const fmt = side.format || "json";
const name = (typeof side.name === "string" && side.name) ? side.name : whichSide;
try {
const { value, serialize } = parseDiffSideTextWithSerializer(fmt, side.text, interpreterSource);
return { name, format: fmt, text: side.text, value, serialize, sourceFileName: side.sourceFileName || null };
} catch(e){
throw new Error((whichSide === "left" ? "Left" : "Right") + " side: " + (e && e.message || e));
}
}
// Grows a plain { name, format, text, value } side (or an already-normalized
// session from a previous enterDiffMode call, e.g. swapDiffSides passing
// leftSource/rightSource back in) into the full typed session shape (source_
// code_view_claude.md Shared Foundations) diff mode's own source panes need
// for Phase 5b/5c: baselineText for Save-dirty checking, valid/diagnostic
// for a mid-edit invalid buffer, locations for selection-sync (Phase 5b) and
// splice-free reparsing (Phase 5c), revision to guard a stale debounced
// reparse. Re-normalizing an already-normalized session (the swap case) is
// harmless — a swap is a fresh "load" of that side same as a first entry.
function normalizeDiffSideSession(side){
const fmt = side.format || "json";
return {
name: side.name, value: side.value,
// renderedText/baselineText/valid/diagnostic/locations/revision: see
// buildSourceSessionCore's own doc comment (near setSourceSession) for
// what each means — selfEditMode's own deferred-render tracking
// (hasPendingSelfEdit/commitSelfEditRenderIfValid) and a normal
// two-document diff's own graph editing (DIFF MODE GRAPH EDITING, below,
// via hasPendingDiffSideEdit) both rely on renderedText being kept in
// sync by reparseDiffSideAndRecompute/finishDiffSideSourceUpdate the same
// way sourceSession.renderedText already is for single-value mode.
...buildSourceSessionCore(side.text, fmt, side.value),
// Where this side can be re-read from, if anywhere (see FILE ORIGIN
// TRACKING) — carried through as-is on a re-normalize, which is what
// keeps each side's own refresh button pointing at its own file across
// a swap (swapDiffSides hands these very sessions straight back in).
origin: side.origin || null,
// The side's real on-disk filename (see currentSourceFileName) — kept
// across a re-normalize for the same reason origin is: swapDiffSides
// hands these very sessions straight back in, and each side's chip
// tooltip has to keep naming its own file.
sourceFileName: side.sourceFileName || null,
// Whichever serializer parseDiffSideValue resolved for this side (json/
// python's own direct serialize, or an interpreter's serializeXML/
// serializeText when one exists) — carried over as-is on a re-normalize
// (the swap case: same already-resolved value/text pair, just relabeled
// as the other side, so nothing needs re-resolving) — see
// fullReserializeSideText.
serialize: side.serialize || null,
};
}
// leftInterpreterSource/rightInterpreterSource are independent — a side
// that isn't markup simply has none; two markup sides in different
// schemas can each carry their own (see diffLeftInterpreterSource above).
// `preserve` (see loadDiffTree's own banner comment) is only safe for the
// ONE caller that passes it — enterSelfEditMode, whose left/right are
// identical copies of the SAME already-displayed document at the instant
// of entry, so whatever's currently in STATE.collapsed/minimized still
// addresses the exact same paths in the new diff tree. A genuine two-
// document diff (startDiffFlow/loadDiffPayload) or a left/right swap
// (swapDiffSides) both potentially land on a DIFFERENT tree shape than
// whatever STATE was collected against, so they keep the default (reset)
// behavior — see also the PRESERVING ARRANGEMENT banner above fullLayout().
function enterDiffMode(left, right, leftInterpreterSource, rightInterpreterSource, view, keyColumns, preserve){
if (searchActive) clearSearch();
closeAllSourceFindBars();
diffMode = true;
leftSource = normalizeDiffSideSession(left);
rightSource = normalizeDiffSideSession(right);
diffLeftInterpreterSource = leftInterpreterSource || "";
diffRightInterpreterSource = rightInterpreterSource || "";
diffKeyColumns = (keyColumns && keyColumns.length) ? keyColumns : null;
diffView = view === "split" ? "split" : "unified";
diffUndo = { left: [], right: [] };
diffRedo = { left: [], right: [] };
// A new comparison warns afresh (see checkRenderComplexity); a preserve
// re-entry (swap, Edit) is the same two documents an instant later.
if (!preserve) renderComplexityOver = false;
recomputeDiffRoot();
diffScope = "full";
document.body.classList.add("structile-diff-mode");
document.body.classList.toggle("structile-diff-split", diffView === "split");
clearDiffSelectionState();
renderDiffViaEngine(preserve);
updateUndoRedoButtons();
updateSaveButtons();
updateDiffSaveButtons();
}
// The Left/Right swap button (#diffSwapBtn, renderDiffHeader): re-enters
// diff mode with sides (and their OWN interpreters) reversed. enterDiffMode
// always resets diffScope to "full" (a brand-new comparison should start
// there), but a swap is a perspective flip on an ALREADY-open diff, not a
// new one — capture and restore the current scope/view rather than
// dropping them. preserve:true (same flag enterSelfEditMode passes, for
// the same reason — literally the same two documents an instant apart,
// just relabeled) keeps STATE.minimized/large/detached/collapsed intact
// across the swap too; without it, swapping silently un-minimized/
// un-expanded/re-attached every table you'd adjusted.
function swapDiffSides(){
if (selfEditMode) return; // no "replace a side" concept once there's only one real document
const view = diffView, scope = diffScope;
enterDiffMode(rightSource, leftSource, diffRightInterpreterSource, diffLeftInterpreterSource, view, diffKeyColumns, true);
if (scope !== diffScope){ diffScope = scope; renderDiffViaEngine(); }
}
// Entry point for the new "Edit" button in single-value mode's source pane
// title bar (#sourcePanel — the button is a new .source-pane-title first
// child, see the HTML). Builds a left/right pair from the SAME currently-
// saved document — left is the read-only original (baseline text/value,
// never touched again), right is a freshly, independently parsed copy that
// becomes the live editable side — then hands off to the exact same
// enterDiffMode() a normal two-document diff uses, so Split/Unified,
// Full/Compact/Ultracompact, and every coloring/added/removed rule are
// reused as-is. selfEditMode is set BEFORE calling enterDiffMode so the very
// first paint (via renderDiffViaEngine -> renderDiffHeader) already reflects
// the self-edit chrome instead of normal two-document diff chrome.
// Deliberately does NOT require sourcePanelOn — this is also the auto-entry
// path for a graph double-click made with the source pane closed (see
// startValueEdit's own autoEnterPath branch): the diffHeader (Split/Unified,
// scope, Save/Cancel) is a separate bar that shows regardless of
// sourcePanelOn, so a "no source pane needed" edit already has everything
// it needs the moment this returns true. Returns true on success (false on
// any guard failure) so a caller can tell whether it's now safe to act
// against rightSource.
//
// embedMode is explicitly ALLOWED here (unlike the diffMode guard) — both
// the structile widget and the VS Code extension now get the same
// Edit-review flow standalone does for every kind of graph-driven edit
// (value/cell, key/name/chain rename, table column rename — see each
// start*Edit function's own !diffMode branch and handleEditDblClick's
// shared dispatch), rather than the old per-field dirty-dot indicators.
// Save for a self-edit session
// entered this way still goes through the normal embedded-host plumbing —
// performDiffSideSave's embedMode branch, and sourceTextForSave/
// requestSaveForHost are self-edit-aware (see their own comments) so a
// host-driven pull-save (hostManagesSave, e.g. VS Code) picks up the
// in-progress edit rather than stale pre-edit content.
// `seedText` (optional): the text both left AND right start IDENTICAL
// from — defaults to sourceSession.text, correct for every caller that
// hasn't touched sourceSession yet at call time (a graph-driven value/rename
// edit — a bare host-pushed text change no longer freshly enters this at
// all, see applyHostTextChanged's own sourceViewDisabled branch; it only
// ever reaches here already-open, via that function's diffMode&&selfEditMode
// branch, which calls commitSelfEditParsedText directly instead).
// commitSourceRenderIfValid's own auto-entry is the one exception: its
// trigger already wrote the NEW,
// not-yet-applied text into sourceSession.text eagerly (see
// handleSourcePaneInput), so passing sourceSession.baselineText (the TRUE
// last-saved text) explicitly there is what keeps the "Original" side
// showing the real baseline instead of the edit already applied to it.
function enterSelfEditMode(seedText){
if (diffMode || !sourceSession || !hasLoadedRealData) return false;
if (!activeFormat || typeof activeFormat.parse !== "function") return false;
const text = typeof seedText === "string" ? seedText : sourceSession.text;
let rightValue;
try { rightValue = activeFormat.parse(text); }
catch(e){ note("Can't edit — the current source doesn't parse: " + (e && e.message || e)); return false; }
const left = { name: currentFileName, format: sourceSession.format, text, value: currentValue, sourceFileName: currentSourceFileName };
const right = { name: currentFileName, format: sourceSession.format, text, value: rightValue, sourceFileName: currentSourceFileName };
const interp = activeFormat.interpreterSource || "";
selfEditMode = true;
document.body.classList.add("structile-self-edit-mode");
// A fresh session starts with a clean slate — explicit, rather than
// relying on buildTree's own wipe (which enterDiffMode -> renderDiffViaEngine
// -> loadDiffTree triggers anyway): preserveSelfEditHistory only preserves
// whatever undoStack/redoStack already held the MOMENT selfEditMode became
// true, and without this, that would be whatever single-value-mode
// SetValue/Rename/ChainRename commands (referencing NODE_BY_ID ids from
// the tree that's about to be replaced) happened to be sitting there.
undoStack = []; redoStack = [];
// preserve:true — left/right are identical copies of the SAME document
// this instant, so whatever's currently collapsed/minimized still
// addresses the same paths in the new diff tree; see enterDiffMode's
// own banner comment on why this is the one caller that can pass it.
enterDiffMode(left, right, interp, interp, "unified", null, true);
return true;
}
// Shared by both entry points (see the banner comment above). Left and
// right can be different formats now (diff_plan.md's "Different Input
// Formats" Later Extension, pulled forward) — diffValues/topKind already
// report a root-level format mismatch as an ordinary typeChanged node
// (see diff_plan.md's own note on this), so there is no gate to enforce
// here anymore; each side is parsed with its OWN interpreterSource(s),
// never a shared one.
function loadDiffPayload(msg){
if (msg.config) applySettingsFile(msg.config, "host config");
if (!msg.left || !msg.right || typeof msg.left.text !== "string" || typeof msg.right.text !== "string"){
note("Diff init is missing left/right data");
return false;
}
const leftInterp = msg.leftInterpreterSource || msg.interpreterSource;
const rightInterp = msg.rightInterpreterSource || msg.interpreterSource;
let left, right;
try {
left = parseDiffSideValue(msg.left, leftInterp, "left");
right = parseDiffSideValue(msg.right, rightInterp, "right");
} catch(e){
note("Could not load diff: " + (e && e.message || e));
return false;
}
enterDiffMode(left, right, leftInterp || "", rightInterp || "", (msg.diff && msg.diff.view) || "unified", msg.keyColumns || null);
return true;
}
// Merges a per-status count object `from` into `into`, summing shared keys.
function mergeStatusCounts(into, from){
for (const k in from) into[k] = (into[k] || 0) + from[k];
return into;
}
// Rolls up how many diff-tree nodes represent a genuinely NEW difference —
// see diff_count_semantics_plan.md for the full derivation. Never counts a
// container's own status when its children already account for it (an
// object/array/table is only ever "changed"/"orderChanged" BECAUSE some
// descendant differs — diffObjects/diffArrays/diffTables/
// diffContainerStatus never invent that status independently), so one
// leaf-level change no longer inflates every ancestor's count too. The one
// deliberate exception is "mixed" (see mergeStatusCounts below the mixed
// branch) and the one deliberate fallback is an "orphaned" difference with
// no dedicated child node at all (a table's column order or a column
// rename with nothing else different) — mirrors diffLeafCount's own
// `sum || 1` fallback, generalized to keep which status bucket, not just
// an anonymous unit.
function diffCounts(node){
if (!node || node.status === "equal") return {};
if (node.status === "mixed"){
// No finer-grained equivalent exists ANYWHERE in the tree — "mixed" is
// only ever assigned to a container (diffContainerStatus) compositing
// an order change AND a value change at once; no leaf/item/row is ever
// individually "mixed". It's a co-occurrence flag, not a rollup total,
// so it's always counted here IN ADDITION TO recursing — the specific
// added/removed/changed/orderChanged facts underneath still get their
// own counts too (that's what makes "1 mixed, 1 added, 1 orderChanged"
// meaningfully different from "1 added" and "1 orderChanged" showing
// up from two unrelated, non-co-located changes elsewhere in the tree).
const merged = { mixed: 1 };
for (const c of (node.children || [])) mergeStatusCounts(merged, diffCounts(c));
return merged;
}
if (node.status === "added" || node.status === "removed"){
// buildOneSidedNode stamps EVERY descendant with this exact same
// status by construction — counting them too would be counting the
// same addition/removal once per node inside it, not once per actual
// difference. Stop here; do not recurse.
const o = {}; o[node.status] = 1; return o;
}
if (!node.children || !node.children.length){
// A true leaf: a changed/typeChanged primitive (diffScalarNode), or a
// type-mismatched node (kind:"mixed" from diffValues' lk!==rk branch —
// NOTE: that's a `kind`, unrelated to the `status:"mixed"` case above;
// this codebase reuses the word "mixed" for two different things, see
// diff_count_semantics_plan.md's "Terminology gotcha"). Neither ever
// has `children`.
const o = {}; o[node.status] = 1; return o;
}
const merged = {};
for (const c of node.children) mergeStatusCounts(merged, diffCounts(c));
const childTotal = Object.values(merged).reduce((s, n) => s + n, 0);
if (childTotal === 0){
// Orphaned fact: this container's own status is not "equal", yet NONE
// of its children account for it — the only way that happens today is
// a table whose sole difference is column order (diffTables'
// `columnsOrderChanged`, which has no per-column child node at all) or
// a column rename (`renamedColumns.length > 0`, same — a renamed
// column has no dedicated child either, exactly like a renamed OBJECT
// KEY doesn't get a separate status, just `renamedFrom` on the
// existing child). Count the container itself so the difference isn't
// silently dropped.
merged[node.status] = 1;
}
return merged;
}
// Per-status glossary shown as a native tooltip on each count in the
// header summary (renderDiffHeader) — hovering "3 changed" explains what
// "changed" means without needing the separate "i" popover, which covers
// Split/Unified/scope instead. Wording mirrors diffContainerStatus/
// compareScalars/buildOneSidedNode's actual rules, not a simplification of
// them.
const DIFF_STATUS_INFO = {
added: "Added: present only on the right side.",
removed: "Removed: present only on the left side.",
changed: "Changed: same location and type, different value.",
typeChanged: "Type changed: same location, but a different JSON type entirely (e.g. number vs string, or an object vs an array) — never treated as a value change.",
orderChanged: "Order changed: the same values on both sides, just in a different position (arrays/tables only — object keys and set members have no order to change).",
mixed: "Mixed: both reordered AND value-changed within the same container."
};
// Shown on the summary itself (not on any one count) — explains the
// one-difference-per-count semantics implemented by diffCounts above.
const DIFF_SUMMARY_NOTE = "Counts each distinct difference once — a changed value, a whole " +
"added/removed subtree, or a moved item/row — never the container that holds it. A " +
"container (object/array/table) only adds to a count directly when it holds a difference " +
"no single child can represent on its own (for example: a table whose only difference is " +
"column order or a renamed column, with every cell and row otherwise identical).";
// =====================================================================
// DIFF RENDERER — reuses the single-data engine (Phase 3, rewritten)
// =====================================================================
// Not a separate renderer: diff mode builds an ordinary JS value (the
// "superset" of left and right) and hands it to the SAME buildTree/
// fullLayout/render() pipeline every normal load already uses — so diff
// mode looks and behaves like the tool itself (box-packed dicts, real
// tables, collapse/expand, peek, search), not a different UI bolted on
// next to it. Diff-awareness lives entirely in two places: (1)
// buildDiffSupersetValue, which decides what VALUE to show at each spot
// (equal: the value; added/removed: the one-sided value; changed: a
// synthesized "old → new" string, since a single rendered cell can only
// ever hold one string — that's a real limitation of reusing this
// renderer, not an oversight), and (2) applyDiffColorPlan, a POST-render
// pass that tints the resulting DOM red/green/amber/blue by looking up
// the exact same element registries (NODES/ITEM_BY_ID/CELL_BY_ID/
// COLUMN_BY_ID) editing and the undo/redo history hover-highlight already
// use — buildTree/layoutNode/renderNode/buildGrid themselves are never
// touched or branched on diffMode.
//
// Color contract: red = left-only/old, green = right-only/new, amber =
// changed-in-place, accent/blue = order-only (neither add nor remove).
function diffValDisplay(v){ return v === undefined ? "" : cellStr(v); }
// Fallback stringifier for a root type mismatch (kind:"mixed" — e.g. left
// is an object, right is an array), which carries raw left/right with no
// child diff nodes to recurse into (see diffValues). Handles Set/BigInt
// the same way canonicalKey/serializePythonRepr do elsewhere in this file.
function diffSummarizeValue(v){
if (v === undefined) return "";
if (v instanceof Set) return "{" + [...v].map(diffSummarizeValue).join(", ") + "}";
if (Array.isArray(v)) return "[" + v.map(diffSummarizeValue).join(", ") + "]";
if (isPlainObject(v)) return "{" + Object.keys(v).map(k => JSON.stringify(k) + ": " + diffSummarizeValue(v[k])).join(", ") + "}";
return cellStr(v);
}
function diffLeafClass(status){
if (status === "added") return "diff-added";
if (status === "removed") return "diff-removed";
if (status === "changed" || status === "typeChanged") return "diff-changed";
return null;
}
// Builds the plain value the normal renderer will display, and pushes a
// coloring instruction into `plan` for every spot that isn't simply
// "equal, unmoved" — applyDiffColorPlan resolves each instruction to a
// real DOM element AFTER buildTree()/render() has run and assigned ids.
// `path` mirrors the SUPERSET value's own structure (plain string/number
// segments, not diffRoot's typed {t,k} path segments — see pkey), since
// that's what NODES/ITEM_BY_ID key off of once buildTree walks this value.
// `suppress`, once true, stops pushing any FURTHER coloring instructions
// for this subtree — set the moment a container's own status is added/
// removed, since that container already gets exactly one whole-box tint
// (see the "object"/"set"/"table" branches below); every descendant is
// only added/removed too (see buildOneSidedNode) and would otherwise get
// individually re-tinted on top of it, exactly the "don't need to
// highlight every internal value, the container tint already says it
// all" over-marking this parameter exists to prevent. The VALUE itself is
// still built normally either way — only the color PLAN is suppressed.
// `prune`, Ultracompact scope only, drops equal children/rows/cells from
// the built VALUE (not just from styling) — see collectCompactUnits/
// renderDiffViaEngine: Ultracompact reuses the exact same innermost-unit
// selection Compact does, then renders each unit with prune=true so only
// its actual differences show, instead of Compact's full local context.
//
// =====================================================================
// Phase 5b (source_code_view_claude.md) — diffRoot <-> real source path
// =====================================================================
// Every diffRoot node already carries enough to reconstruct the REAL
// left/right path (the plain path leftSource.value/rightSource.value's own
// buildSourceLocations indexes by) without re-deriving any of the engine's
// own matching decisions:
// - object children: c.status ("added"/"removed" mean missing on one
// side) plus c.renamedFrom (see diffObjects/findKeyRenames).
// - array items (non-table): the LAST path segment is always
// {t:"item", i, ri} — i is the real left index, ri the real right
// index, either possibly absent (see diffArrays/buildOneSidedNode).
// - table rows: a two-sided row always carries order.fromIndex/toIndex
// (the real left/right row indices — see diffTables); a one-sided row's
// last path segment is {t:"row", k:{index}}, that index being whichever
// side it actually came from.
// - table cells: the canonical column name is the RIGHT column's name
// (or the left name, for a left-only/removed column) — match.
// renamedColumns (left<->right name pairs) recovers the real left name
// for a renamed column; cell.status again flags a one-sided cell
// (a column absent on that side even though the row exists on both).
// - set members: matched purely by canonicalKey (see diffSets), which has
// no direct index — recovered here by re-scanning the parent Set's own
// iteration order, mirroring how buildSourceLocations assigns a Set
// member's path (Set displays as "a dict keyed by iteration position").
// Stashes `node.leftPath`/`node.rightPath` (null when this node doesn't
// exist on that side) directly onto diffRoot's own tree — cheap, and reused
// by every click-resolution path below rather than recomputed per click.
function diffTableColumnRenameToLeft(node){
return new Map(((node.match && node.match.renamedColumns) || []).map(r => [r.right, r.left]));
}
function annotateDiffOriginalPaths(node, leftPath, rightPath){
if (!node) return;
node.leftPath = leftPath;
node.rightPath = rightPath;
if (!node.children || !node.children.length) return;
const kind = node.kind;
if (kind === "object"){
for (const c of node.children){
const k = c.path[c.path.length - 1].k;
const lk = c.status === "added" ? null : (c.renamedFrom != null ? c.renamedFrom : k);
const rk = c.status === "removed" ? null : k;
annotateDiffOriginalPaths(c,
(lk == null || leftPath == null) ? null : leftPath.concat([lk]),
(rk == null || rightPath == null) ? null : rightPath.concat([rk]));
}
} else if (kind === "set"){
for (const c of node.children){
const k = c.path[c.path.length - 1].k; // canonicalKey(v)
let lk = null, rk = null;
if (c.status !== "added" && leftPath != null && node.left){
const idx = [...node.left].findIndex(v => canonicalKey(v) === k);
if (idx !== -1) lk = idx;
}
if (c.status !== "removed" && rightPath != null && node.right){
const idx = [...node.right].findIndex(v => canonicalKey(v) === k);
if (idx !== -1) rk = idx;
}
annotateDiffOriginalPaths(c,
lk == null ? null : leftPath.concat([lk]),
rk == null ? null : rightPath.concat([rk]));
}
} else if (kind === "array"){
for (const c of node.children){
const seg = c.path[c.path.length - 1]; // {t:"item", i, ri}
const lk = seg.i, rk = seg.ri;
annotateDiffOriginalPaths(c,
(lk == null || leftPath == null) ? null : leftPath.concat([lk]),
(rk == null || rightPath == null) ? null : rightPath.concat([rk]));
}
} else if (kind === "table"){
const renameToLeft = diffTableColumnRenameToLeft(node);
// diffTables' own addressing (a row index into t.rows, a column NAME
// from t.colKeys) matches the REAL underlying JSON path only for
// "records" mode, where a row genuinely IS {"colName": value, ...}.
// "grid" mode (array-of-arrays — e.g. a correlation matrix) is
// positionally addressed instead: a header row consumes one real array
// slot that t.rows' own indexing already strips out (t.dataStart), and
// a "column" is just a plain array position, never a dict key. Rebuild
// both sides' real table shape here (the SAME buildTable() the graph
// itself renders from) to translate diffTables' row-index/column-name
// addressing back into the real JSON path for whichever mode this
// table actually is — node.left/right are only ever undefined for a
// wholesale one-sided add/remove, which never reaches diffTables (see
// diffValues' own dispatch), so both are real arrays here.
const lt = buildTable(node.left), rt = buildTable(node.right);
const leftRowOffset = lt.mode === "grid" ? (lt.dataStart || 0) : 0;
const rightRowOffset = rt.mode === "grid" ? (rt.dataStart || 0) : 0;
const leftColIndex = (name) => lt.mode === "grid" ? lt.colKeys.indexOf(name) : name;
const rightColIndex = (name) => rt.mode === "grid" ? rt.colKeys.indexOf(name) : name;
for (const rowNode of node.children){
const seg = rowNode.path[rowNode.path.length - 1]; // {t:"row", k:...}
let li = null, ri = null;
if (rowNode.order && rowNode.order.fromIndex != null && rowNode.order.toIndex != null){
li = rowNode.order.fromIndex; ri = rowNode.order.toIndex;
} else if (seg.k && typeof seg.k.index === "number"){
if (rowNode.status === "removed") li = seg.k.index; else ri = seg.k.index;
}
const rowLeftPath = (li == null || leftPath == null) ? null : leftPath.concat([li + leftRowOffset]);
const rowRightPath = (ri == null || rightPath == null) ? null : rightPath.concat([ri + rightRowOffset]);
rowNode.leftPath = rowLeftPath; rowNode.rightPath = rowRightPath;
for (const cell of rowNode.children){
const c = cell.path[cell.path.length - 1].k; // canonical (~right) column name
const leftCol = renameToLeft.has(c) ? renameToLeft.get(c) : c;
const leftColKey = leftColIndex(leftCol), rightColKey = rightColIndex(c);
const cLeft = (cell.status === "added" || rowLeftPath == null || leftColKey === -1) ? null : rowLeftPath.concat([leftColKey]);
const cRight = (cell.status === "removed" || rowRightPath == null || rightColKey === -1) ? null : rowRightPath.concat([rightColKey]);
annotateDiffOriginalPaths(cell, cLeft, cRight);
}
}
}
}
// Reverse index (original left/right path -> diffRoot node), built once per
// recomputeDiffRoot() call from the leftPath/rightPath annotateDiffOriginalPaths
// just stamped on every node — the source->graph click direction resolves a
// source-pane offset to an ORIGINAL path via leftSource.locations/
// rightSource.locations (exactly like Phase 2's single-value flow), then
// needs to find which diffRoot node that original path belongs to; this map
// is that lookup, keyed by pkey(path) same as every other id scheme here.
let diffOriginalIndexLeft = new Map(), diffOriginalIndexRight = new Map();
function indexDiffOriginalPaths(node, leftOut, rightOut){
if (!node) return;
if (node.leftPath != null) leftOut.set(pkey(node.leftPath), node);
if (node.rightPath != null) rightOut.set(pkey(node.rightPath), node);
if (node.children) for (const c of node.children) indexDiffOriginalPaths(c, leftOut, rightOut);
}
// Recomputes diffRoot from the current leftSource/rightSource values (initial
// entry, swap, or — Phase 5c — a live source edit on either side) and
// immediately re-annotates it — the two must never drift apart, so every
// diffRoot assignment goes through this one function rather than calling
// diffValues() directly.
function recomputeDiffRoot(){
diffRoot = diffValues(leftSource.value, rightSource.value,
diffKeyColumns && diffKeyColumns.length ? { keyColumns: diffKeyColumns } : {});
annotateDiffOriginalPaths(diffRoot, [], []);
diffOriginalIndexLeft = new Map(); diffOriginalIndexRight = new Map();
indexDiffOriginalPaths(diffRoot, diffOriginalIndexLeft, diffOriginalIndexRight);
}
// `nodeAt` (Phase 5b, optional): when supplied, records nodeAt.set(pkey(path),
// node) for every node this walk visits — a forward index from the rendered
// SUPERSET value's own path space back to the diffRoot node it came from,
// used by graph->source click resolution (see graphClickToDiffLocations).
// Also stashes node.supersetPath = path directly on the node, the reverse
// direction (diffRoot node -> current superset render path) needed once a
// source-pane click resolves to a node via leftPath/rightPath and has to
// find that node's CURRENT on-screen position to highlight.
function buildDiffSupersetValue(node, path, plan, suppress, prune, nodeAt){
if (!node) return null;
if (nodeAt) nodeAt.set(pkey(path), node);
node.supersetPath = path;
const kind = node.kind;
if (kind === "primitive"){
if (!suppress && node.status !== "equal") plan.push({ t:"item", path, cls: diffLeafClass(node.status) });
if (node.status === "removed") return node.left;
if (node.status === "added") return node.right;
if (node.status === "equal") return node.left;
return diffValDisplay(node.left) + " → " + diffValDisplay(node.right);
}
if (kind === "mixed"){
if (!suppress) plan.push({ t:"item", path, cls:"diff-changed" });
return diffSummarizeValue(node.left) + " → " + diffSummarizeValue(node.right);
}
if (kind === "object"){
let childSuppress = suppress;
if (!suppress && (node.status === "added" || node.status === "removed")){
plan.push({ t:"container", path, cls: diffLeafClass(node.status) });
childSuppress = true;
}
const obj = {};
for (const c of node.children){
if (prune && c.status === "equal") continue;
const k = c.path[c.path.length - 1].k;
// A renamed key gets a synthesized "oldKey → newKey" compound key in
// the superset object — same trick as a changed SCALAR's synthesized
// "old → new" string, just one level up, since the underlying
// renderer only ever shows whatever key name the object literally
// has (see findKeyRenames/diffObjects).
const displayKey = c.renamedFrom ? (c.renamedFrom + " → " + k) : k;
const childPath = path.concat([displayKey]);
// A rename onto a CONTAINER value (object/array/set/table) that's
// otherwise fully equal never trips any of the added/removed/leaf
// coloring below — nothing inside actually differs, only the key
// did — so without this the rename would render with no highlight
// anywhere, unlike a renamed SCALAR (caught by the primitive
// branch's own status!==equal check below) or a renamed table
// COLUMN (always tinted regardless of cell content — see
// renamedColumnByRight above). Tint just the child's own NAME label
// (nameOnly — see applyDiffColorPlan), not its whole box: nothing
// inside a rename-only container actually changed, so painting the
// whole box would misleadingly imply its contents did too.
if (c.renamedFrom && !childSuppress && c.kind !== "primitive" && c.kind !== "mixed"){
plan.push({ t:"container", path: childPath, cls:"diff-changed", nameOnly:true });
}
setOwnKey(obj, displayKey, buildDiffSupersetValue(c, childPath, plan, childSuppress, prune, nodeAt));
}
return obj;
}
if (kind === "set"){
// Sets have no engine-level display order (diffSets matches by
// canonical key but doesn't sort its output — see Phase 1); sort a
// COPY here purely for display, per diff_plan.md's "display the union
// of elements in canonical order" — the diff tree itself is untouched.
let childSuppress = suppress;
if (!suppress && (node.status === "added" || node.status === "removed")){
plan.push({ t:"container", path, cls: diffLeafClass(node.status) });
childSuppress = true;
}
const sorted = node.children.slice().sort((a, b) => {
const ka = a.path[a.path.length - 1].k, kb = b.path[b.path.length - 1].k;
return ka < kb ? -1 : ka > kb ? 1 : 0;
});
// Filter BEFORE assigning index `i` — `i` becomes this member's actual
// position once inserted into `s` below, which is also the key the
// renderer later addresses it by (a Set displays as a dict keyed by
// iteration position — see buildNode's `isSet` handling). Skipping
// pruned members mid-forEach while still using the ORIGINAL sorted
// index would desync `i` from `s`'s real insertion order the moment
// even one earlier member got dropped, the exact same class of bug
// the table branch's `cols`/`keptRowNodes` comment above avoids.
const kept = prune ? sorted.filter(c => c.status !== "equal") : sorted;
const s = new Set();
kept.forEach((c, i) => {
if (!childSuppress && c.status !== "equal") plan.push({ t:"item", path: path.concat([i]), cls: diffLeafClass(c.status) });
s.add(buildDiffSupersetValue(c, path.concat([i]), plan, childSuppress, prune, nodeAt));
});
return s;
}
if (kind === "array"){
// Rendered as a normal single-column list-table by the engine
// (classify() treats every array as "table" — see buildTable's "list"
// mode) — coloring reuses the exact same row/cell mechanism tables do,
// just always at column 0. Order is now a PER-ITEM flag (see
// diffArrays' relative-rank computation), so a moved item gets the
// same row-gutter marker a moved table row gets — no whole-box order
// outline, which was noisy and, worse, imprecise (see D06 vs. the
// insertion-in-the-middle case this session's feedback called out).
let childSuppress = suppress;
if (!suppress && (node.status === "added" || node.status === "removed")){
plan.push({ t:"container", path, cls: diffLeafClass(node.status) });
childSuppress = true;
}
const kept = prune ? node.children.filter(c => c.status !== "equal") : node.children;
const arr = [];
kept.forEach((c, i) => {
if (!childSuppress){
if (c.status === "orderChanged") plan.push({ t:"rowOrder", tablePath: path, row: i });
else if (c.status !== "equal") plan.push({ t:"cell", tablePath: path, row: i, col: 0, cls: diffLeafClass(c.status) || "diff-changed" });
}
arr.push(buildDiffSupersetValue(c, path.concat([i]), plan, childSuppress, prune, nodeAt));
});
return arr;
}
if (kind === "table"){
let childSuppress = suppress;
if (!suppress && (node.status === "added" || node.status === "removed")){
plan.push({ t:"container", path, cls: diffLeafClass(node.status) });
childSuppress = true;
}
// node.order/node.match only exist for a TWO-sided table diff (see
// diffTables) — a wholesale added/removed table (buildOneSidedNode)
// has neither, so fall back to the one side's own column list rather
// than assuming they're always present.
const allCols = node.order ? node.order.canonicalColumns
: (node.children[0] ? node.children[0].children.map(cell => cell.path[cell.path.length - 1].k) : []);
// Ultracompact: drop rows with nothing different, and drop columns
// that never differ anywhere in the table AND aren't the row-identity
// key — but keep those SAME dropped-or-kept columns fixed across every
// remaining row (never a per-row subset). A per-row subset would make
// each row object's own key set different, and buildTable() derives
// colKeys from the UNION of keys in first-seen order across rows (see
// buildTable's "allObj" branch) — that union order would then depend
// on which row happens to introduce which key first, silently
// decoupling the rendered table's actual column positions from the
// `ci` indices this function pushes into the color plan. Keeping every
// kept row's key set identical (= `cols` below) sidesteps that
// entirely: colKeys is always exactly `cols`, in `cols` order.
const keptRowNodes = prune ? node.children.filter(r => r.status !== "equal") : node.children;
const keyCols = new Set((node.match && node.match.keyColumns) || []);
const renamedColumnByRight = new Map(((node.match && node.match.renamedColumns) || []).map(r => [r.right, r.left]));
let cols = prune ? allCols.filter(c => keyCols.has(c) ||
(node.match && node.match.addedColumns.includes(c)) || (node.match && node.match.removedColumns.includes(c)) ||
renamedColumnByRight.has(c) ||
(node.order && node.order.movedColumns && node.order.movedColumns.has(c)) ||
node.children.some(r => { const cell = r.children.find(cc => cc.path[cc.path.length-1].k === c); return cell && cell.status !== "equal"; })
) : allCols;
// No column carries any diff-bearing or identity signal (e.g. an
// unkeyed pure row reorder with no auto-detected key) — fall back to
// every column rather than rendering identity-less blank rows.
if (prune && !cols.length) cols = allCols;
if (!childSuppress){
cols.forEach((c, ci) => {
if (renamedColumnByRight.has(c)) plan.push({ t:"colHeader", tablePath: path, col: ci, cls:"diff-changed" });
else if (node.match.addedColumns.includes(c)) plan.push({ t:"colHeader", tablePath: path, col: ci, cls:"diff-added" });
else if (node.match.removedColumns.includes(c)) plan.push({ t:"colHeader", tablePath: path, col: ci, cls:"diff-removed" });
else if (node.order.movedColumns && node.order.movedColumns.has(c)) plan.push({ t:"colHeader", tablePath: path, col: ci, cls:"diff-order" });
});
}
return keptRowNodes.map((rowNode, ri) => {
if (!childSuppress && rowNode.order && rowNode.order.changed) plan.push({ t:"rowOrder", tablePath: path, row: ri });
const cellsByCol = new Map(rowNode.children.map(cell => [cell.path[cell.path.length - 1].k, cell]));
const obj = {};
if (nodeAt) nodeAt.set(pkey(path.concat([ri])), rowNode);
rowNode.supersetPath = path.concat([ri]);
cols.forEach((c, ci) => {
const displayCol = renamedColumnByRight.has(c) ? (renamedColumnByRight.get(c) + " → " + c) : c;
const cell = cellsByCol.get(c);
if (!cell){ setOwnKey(obj, displayCol, null); return; }
if (!childSuppress && cell.status !== "equal") plan.push({ t:"cell", tablePath: path, row: ri, col: ci, cls: diffLeafClass(cell.status) });
// A column kept only because it's the row-identity key (not
// because THIS cell differs) still needs its real value shown, or
// the row becomes unidentifiable — only blank a cell that's both
// equal AND not an identity column.
if (prune && cell.status === "equal" && !keyCols.has(c)){ setOwnKey(obj, displayCol, null); return; }
setOwnKey(obj, displayCol, buildDiffSupersetValue(cell, path.concat([ri, displayCol]), plan, childSuppress, prune, nodeAt));
});
return obj;
});
}
return null;
}
// =====================================================================
// SPLIT VIEW — buildDiffSideValue
// =====================================================================
// Split's per-pane counterpart to buildDiffSupersetValue above: instead of
// ONE combined "old -> new" tree, it builds two independent values — one
// for `side:"left"`, one for `side:"right"` — each showing only that side's
// own content. Both calls walk the SAME diffRoot with the SAME canonical
// key/row/column selection (same `prune`, same compact-unit wrapper — see
// renderDiffSplitEngine), so the two resulting values share the same
// container/row/column slots in the same order. (A renamed table column keeps
// its real source name in each pane, but remains the same column index.) A
// one-sided entry repeats the real key/value from the side where it exists
// instead of showing an ambiguous `null`; the missing-side copy is struck
// through. That keeps both panes aligned while making it explicit that the
// repeated content is a placeholder, not data that actually exists there.
//
// That shared shape is what makes split view's expand/collapse
// "synchronized" for free: buildNode/buildTree derive every node's `path`
// purely from the VALUE's own container key/index structure (see
// buildDictChildren/buildRoot), so aligned containers produce matching node
// paths — and therefore matching pkey(path) — in both panes' trees. Table
// header names do not participate in those container paths.
// STATE.collapsed/minimized/large/detached are a single shared singleton
// (never duplicated per pane), so toggling a box in one pane necessarily
// toggles the SAME STATE key the other pane's matching box reads too — see
// paintDiffSplit, which rebuilds BOTH panes from that one shared STATE on
// every repaint rather than trying to keep two independent layouts in sync
// after the fact.
//
// Split's visual contract deliberately distinguishes presence from a value
// change: a one-sided entry is always red in the left pane and green in the
// right pane (with only the missing-side copy struck), while a value present
// on both sides but changed is amber in both panes.
function splitNodeMissingOnSide(node, side){
return side === "left" ? node.status === "added" : node.status === "removed";
}
function splitNodeDisplayValue(node, side){
const mine = side === "left" ? node.left : node.right;
if (!splitNodeMissingOnSide(node, side)) return mine;
return side === "left" ? node.right : node.left;
}
function splitNodeAppearance(node, side){
if (node.status === "added" || node.status === "removed"){
return { cls:side === "left" ? "diff-removed" : "diff-added", strike:splitNodeMissingOnSide(node, side) };
}
if (node.status === "changed" || node.status === "typeChanged") return { cls:"diff-changed", strike:false };
return null;
}
// `nodeAt` (Phase 5b, optional): same forward-index/reverse-stash mechanism
// as buildDiffSupersetValue above, one Map per side (see the two separate
// calls in renderDiffSplitEngine/paintDiffSplit) — stashes
// node.sidePathLeft/node.sidePathRight (whichever `side` this call is for).
function buildDiffSideValue(node, path, plan, side, suppress, prune, nodeAt){
if (!node) return null;
if (nodeAt) nodeAt.set(pkey(path), node);
if (side === "left") node.sidePathLeft = path; else node.sidePathRight = path;
const mine = splitNodeDisplayValue(node, side);
const appearance = splitNodeAppearance(node, side);
const kind = node.kind;
if (kind === "primitive"){
if (node.status === "equal" || node.status === "orderChanged") return mine;
if (!suppress && appearance) plan.push({ t:"item", path, cls:appearance.cls, strike:appearance.strike });
return mine;
}
if (kind === "mixed"){
if (!suppress && appearance) plan.push({ t:"item", path, cls:appearance.cls, strike:appearance.strike });
return diffSummarizeValue(mine);
}
if (kind === "object"){
// Deliberately does NOT force childSuppress=true here (unlike
// buildDiffSupersetValue's unified-only equivalent): every box this
// engine renders is a FLAT sibling of #canvas, absolute-positioned for
// visual nesting only (see renderNode) — a nested dict/table is never
// an actual DOM descendant of this one. A CSS descendant selector like
// .box.diff-struck .dname therefore can't reach it, so a wholesale
// added/removed subtree needs its OWN instruction pushed at EVERY
// level, not just here at the top — buildOneSidedNode already stamps
// the identical status on every descendant, so simply not suppressing
// lets each nested node's own appearance fire normally below.
const childSuppress = suppress;
if (!suppress && (node.status === "added" || node.status === "removed")){
plan.push({ t:"container", path, cls:appearance.cls, strike:appearance.strike });
}
const obj = {};
for (const c of node.children){
if (prune && c.status === "equal") continue;
const k = c.path[c.path.length - 1].k;
// A renamed key whose value is a scalar shows each pane's own real
// key name (oldKey on the left, newKey on the right) — the same
// per-pane treatment a renamed table COLUMN already gets (see
// displayCol below). A renamed key whose value is itself a
// CONTAINER (object/array/set/table) keeps the shared
// "oldKey → newKey" compound text on both sides instead: that text
// doubles as this box's own path segment, and STATE.collapsed/etc.
// key off pkey(path) (see this function's own doc comment above on
// why aligned paths keep both panes' expand/collapse — and
// therefore vertical layout — in sync); giving each pane a
// different real name here would split that box into two
// independently-keyed boxes and could knock the panes out of
// alignment for everything below it.
const isScalarRename = c.kind === "primitive" || c.kind === "mixed";
const displayKey = c.renamedFrom
? (isScalarRename ? (side === "left" ? c.renamedFrom : k) : (c.renamedFrom + " → " + k))
: k;
const childPath = path.concat([displayKey]);
// Mirrors unified's same-named fix: a rename onto an otherwise-equal
// container has nothing inside it to color, so tint just its NAME
// label (nameOnly) instead of leaving it uncolored or painting the
// whole box as if its contents had changed too.
if (c.renamedFrom && !childSuppress && !isScalarRename){
plan.push({ t:"container", path: childPath, cls:"diff-changed", nameOnly:true });
}
setOwnKey(obj, displayKey, buildDiffSideValue(c, childPath, plan, side, childSuppress, prune, nodeAt));
}
return obj;
}
if (kind === "set"){
// Canonical order gives a one-sided member the same slot in both panes;
// buildDiffSideValue repeats and strikes it on the missing side just like
// a one-sided dict field. Not forcing childSuppress here for the same
// flat-DOM reason as the object branch above.
const childSuppress = suppress;
if (!suppress && (node.status === "added" || node.status === "removed")){
plan.push({ t:"container", path, cls:appearance.cls, strike:appearance.strike });
}
const sorted = node.children.slice().sort((a, b) => {
const ka = a.path[a.path.length - 1].k, kb = b.path[b.path.length - 1].k;
return ka < kb ? -1 : ka > kb ? 1 : 0;
});
const kept = prune ? sorted.filter(c => c.status !== "equal") : sorted;
const s = new Set();
kept.forEach((c, i) => { s.add(buildDiffSideValue(c, path.concat([i]), plan, side, childSuppress, prune, nodeAt)); });
return s;
}
if (kind === "array"){
// Not forced true here either (see the object branch's comment) — a
// wholesale one-sided array still needs its OWN per-item cell/rowOrder
// instructions below, since each item is its own flat DOM sibling too.
const childSuppress = suppress;
if (!suppress && (node.status === "added" || node.status === "removed")){
plan.push({ t:"container", path, cls:appearance.cls, strike:appearance.strike });
}
// Every item keeps its slot so insertions/removals cannot shift the later
// rows out of alignment between panes.
const kept = prune ? node.children.filter(c => c.status !== "equal") : node.children;
const arr = [];
kept.forEach((c, i) => {
if (!childSuppress){
if (c.status === "orderChanged") plan.push({ t:"rowOrder", tablePath: path, row: i });
else if (c.status !== "equal"){
const a = splitNodeAppearance(c, side);
if (a) plan.push({ t:"cell", tablePath: path, row: i, col: 0, cls:a.cls, strike:a.strike });
}
}
// The array itself renders as a table cell, so its cell instruction
// above is authoritative; suppress a redundant item instruction from
// the child leaf while still building the repeated placeholder value.
arr.push(buildDiffSideValue(c, path.concat([i]), plan, side, true, prune, nodeAt));
});
return arr;
}
if (kind === "table"){
// Not forced true here either (see the object branch's comment) — a
// wholesale one-sided table still needs its own per-row/per-cell
// instructions below (rows are real
descendants of this table's
// OWN box, so those already cascade via CSS, but pushing them
// explicitly here is harmless and keeps this branch consistent with
// object/set/array rather than relying on that distinction holding).
const childSuppress = suppress;
if (!suppress && (node.status === "added" || node.status === "removed")){
plan.push({ t:"container", path, cls:appearance.cls, strike:appearance.strike });
}
const allCols = node.order ? node.order.canonicalColumns
: (node.children[0] ? node.children[0].children.map(cell => cell.path[cell.path.length - 1].k) : []);
const keptRowNodes = prune ? node.children.filter(r => r.status !== "equal") : node.children;
const keyCols = new Set((node.match && node.match.keyColumns) || []);
const renamedColumnByRight = new Map(((node.match && node.match.renamedColumns) || []).map(r => [r.right, r.left]));
let cols = prune ? allCols.filter(c => keyCols.has(c) ||
(node.match && node.match.addedColumns.includes(c)) || (node.match && node.match.removedColumns.includes(c)) ||
renamedColumnByRight.has(c) ||
(node.order && node.order.movedColumns && node.order.movedColumns.has(c)) ||
node.children.some(r => { const cell = r.children.find(cc => cc.path[cc.path.length-1].k === c); return cell && cell.status !== "equal"; })
) : allCols;
if (prune && !cols.length) cols = allCols;
if (!childSuppress){
cols.forEach((c, ci) => {
if (renamedColumnByRight.has(c)){
plan.push({ t:"colHeader", tablePath:path, col:ci, cls:"diff-changed" });
} else if (node.match && node.match.addedColumns.includes(c)){
const a = splitNodeAppearance({status:"added"}, side);
plan.push({ t:"colHeader", tablePath:path, col:ci, cls:a.cls, strike:a.strike });
} else if (node.match && node.match.removedColumns.includes(c)){
const a = splitNodeAppearance({status:"removed"}, side);
plan.push({ t:"colHeader", tablePath:path, col:ci, cls:a.cls, strike:a.strike });
} else if (node.order && node.order.movedColumns && node.order.movedColumns.has(c)){
plan.push({ t:"colHeader", tablePath:path, col:ci, cls:"diff-order" });
}
});
}
return keptRowNodes.map((rowNode, ri) => {
// One-sided rows are repeated in both panes; the missing-side copy is
// struck rather than collapsed to an all-null row.
const wholeRowTint = !childSuppress && (rowNode.status === "added" || rowNode.status === "removed");
const rowAppearance = wholeRowTint ? splitNodeAppearance(rowNode, side) : null;
if (!childSuppress && !wholeRowTint && rowNode.order && rowNode.order.changed) plan.push({ t:"rowOrder", tablePath:path, row:ri });
const cellsByCol = new Map(rowNode.children.map(cell => [cell.path[cell.path.length - 1].k, cell]));
const obj = {};
if (nodeAt) nodeAt.set(pkey(path.concat([ri])), rowNode);
if (side === "left") rowNode.sidePathLeft = path.concat([ri]); else rowNode.sidePathRight = path.concat([ri]);
cols.forEach((c, ci) => {
const displayCol = side === "left" && renamedColumnByRight.has(c) ? renamedColumnByRight.get(c) : c;
const cell = cellsByCol.get(c);
if (!cell){
setOwnKey(obj, displayCol, null);
if (wholeRowTint) plan.push({ t:"cell", tablePath:path, row:ri, col:ci, cls:rowAppearance.cls, strike:rowAppearance.strike });
return;
}
if (wholeRowTint){
plan.push({ t:"cell", tablePath:path, row:ri, col:ci, cls:rowAppearance.cls, strike:rowAppearance.strike });
setOwnKey(obj, displayCol, buildDiffSideValue(cell, path.concat([ri, displayCol]), plan, side, true, prune, nodeAt));
return;
}
if (!childSuppress && cell.status !== "equal"){
const a = splitNodeAppearance(cell, side);
if (a) plan.push({ t:"cell", tablePath:path, row:ri, col:ci, cls:a.cls, strike:a.strike });
}
if (prune && cell.status === "equal" && !keyCols.has(c)){ setOwnKey(obj, displayCol, null); return; }
// The enclosing table cell carries the visual instruction, so avoid
// emitting an unresolvable nested item instruction for the same leaf.
setOwnKey(obj, displayCol, buildDiffSideValue(cell, path.concat([ri, displayCol]), plan, side, true, prune, nodeAt));
});
return obj;
});
}
return null;
}
// Human-readable, inherently-unique label for a diff node, built from
// diffRoot's OWN typed path (not the plain superset path above) — used
// only as the Compact/Ultracompact synthetic top-level key and on-screen
// breadcrumb, e.g. "root > customer > address" or "root > rows[id=2] > val".
// "/"-joined, matching the deep-chain notation this app already uses for
// a compressed single-child dict chain (see buildNode's displayLabel —
// "telemetry/exporter/otlp") — not a separate " > " notation invented for
// diff mode. No "root" segment either: the normal viewer never shows a
// root box/label at all (renderNode skips it — see node.isRoot), so a
// unit whose path starts at the top level reads as just "correlations",
// not "root > correlations". Row/item identity is appended directly onto
// the preceding segment ("rows[id=2]"), not slash-joined, since it's
// naming a position WITHIN that segment, not a further level down.
function diffNodeBreadcrumb(node){
const parts = [];
const appendToLast = (label) => { if (parts.length) parts[parts.length - 1] += label; else parts.push(label); };
for (const seg of (node.path || [])){
if (seg.t === "key" || seg.t === "col") parts.push(seg.k);
else if (seg.t === "member") parts.push("{" + seg.k + "}");
else if (seg.t === "row") appendToLast("[" + (seg.k && typeof seg.k === "object" ? Object.entries(seg.k).map(([k,v]) => k + "=" + diffValDisplay(v)).join(",") : seg.k) + "]");
else if (seg.t === "item") appendToLast("[" + (seg.i != null ? seg.i : ("+" + seg.ri)) + "]");
}
return parts.length ? parts.join("/") : "(root)";
}
// Compact scope: the innermost dict/table/array/set that directly holds a
// difference — ancestors that differ only because ONE nested child
// container differs are skipped entirely (not shown), so the same
// difference isn't shown twice at two nesting depths. Tables/arrays/sets
// are always their own unit (never drilled into further) since their
// "leaves" are rows/cells/members, not nested dicts.
function collectCompactUnits(node, out){
if (!node || node.status === "equal") return;
if (node.kind === "primitive" || node.kind === "mixed") return;
if (node.status === "added" || node.status === "removed" || node.kind === "table"){ out.push(node); return; }
if (node.kind === "object" || node.kind === "set" || node.kind === "array"){
// A renamed child counts as a direct leaf diff regardless of ITS OWN
// kind: a key rename onto an otherwise-fully-equal nested container
// (see findKeyRenames/diffObjects) has no primitive/mixed/added/removed
// descendant anywhere beneath it to trip this check the normal way, so
// without `c.renamedFrom` here the recursion below would drill straight
// through and find nothing — the rename would simply vanish from
// Compact/Ultracompact scope instead of surfacing at its actual, exact
// location (this node, the one directly holding the renamed key).
const directLeafDiff = node.children.some(c =>
c.status !== "equal" && (c.kind === "primitive" || c.kind === "mixed" || c.status === "added" || c.status === "removed" || c.renamedFrom));
if (directLeafDiff || node.kind !== "object"){ out.push(node); return; }
for (const c of node.children) collectCompactUnits(c, out);
}
}
let diffScope = "full"; // "full" | "compact" | "ultracompact"
let currentDiffColorPlan = [];
// ---- split view (Phase 4) state — see renderDiffSplitEngine/paintDiffSplit
let currentDiffColorPlanLeft = [], currentDiffColorPlanRight = [];
let splitLeftValue = null, splitRightValue = null; // cached per-side display values (see renderDiffSplitEngine)
// Full per-pane snapshots of the SAME registries buildTree() reassigns
// (ROOT/NODES/NODE_BY_ID/ITEM_BY_ID/COLUMN_BY_ID/CELL_BY_ID) — captured
// right after each side's own buildTree() call in paintDiffSplit, before the
// OTHER side's buildTree() call reassigns those globals out from under it.
// Needed because applyDiffColorPlan (and anything else that resolves a path
// via the global NODES/ITEM_BY_ID rather than scoped DOM queries) only ever
// sees whichever pane was built LAST — see activateSplitPaneRegs/showPeek,
// the one place a color plan gets reapplied OUTSIDE paintDiffSplit's own
// carefully-ordered build-then-color-then-build-then-color sequence.
let splitLeftRegs = null, splitRightRegs = null;
let splitScrollSyncing = false;
// Which pane's own color plan applies to whatever canvas is currently
// ACTIVE_CANVAS_ID — used by showPeek (a peek panel needs THAT pane's plan,
// not the other one's) rather than always reaching for the unified-only
// currentDiffColorPlan.
function activeDiffColorPlan(){
if (!diffMode) return [];
if (diffView !== "split") return currentDiffColorPlan;
return ACTIVE_CANVAS_ID === "canvasLeft" ? currentDiffColorPlanLeft : currentDiffColorPlanRight;
}
// Swaps ROOT/NODES/NODE_BY_ID/ITEM_BY_ID/COLUMN_BY_ID/CELL_BY_ID to a
// previously-captured pane snapshot — see splitLeftRegs/splitRightRegs above.
function activateSplitPaneRegs(regs){
if (!regs) return;
ROOT = regs.ROOT; NODES = regs.NODES; NODE_BY_ID = regs.NODE_BY_ID;
ITEM_BY_ID = regs.ITEM_BY_ID; COLUMN_BY_ID = regs.COLUMN_BY_ID; CELL_BY_ID = regs.CELL_BY_ID;
}
// Walks a node up to its tree root and reports which split pane (if any) it
// belongs to, plus that pane's canvas/wrap ids and full registry snapshot —
// lets showPeek re-point ACTIVE_CANVAS_ID/ACTIVE_WRAP_ID and restore the
// matching NODES/ITEM_BY_ID/etc BEFORE touching the DOM, regardless of
// which pane paintDiffSplit happened to build last.
function nodePaneInfo(node){
let n = node;
while (n && n.parent) n = n.parent;
if (n && splitLeftRegs && n === splitLeftRegs.ROOT) return { canvas:"canvasLeft", wrap:"canvasWrapLeft", regs: splitLeftRegs };
if (n && splitRightRegs && n === splitRightRegs.ROOT) return { canvas:"canvasRight", wrap:"canvasWrapRight", regs: splitRightRegs };
return null;
}
// Mirrors loadValue's tree-building steps for a diff superset value —
// deliberately does NOT touch currentValue/activeFormat, which
// describe the separate normal-mode document diff mode never uses (see
// exitDiffMode) and which must come back untouched whenever diff mode ends.
// `preserve` (see renderDiffViaEngine) distinguishes a genuine new VIEW
// (entering diff mode, a scope toggle, Unified<->Split) — where starting
// from a clean, uncollapsed slate is correct, since the superset value
// itself is a different shape — from a plain re-render of the SAME view
// after its underlying value changed (a SelfEdit commit, or a live
// two-document diff's own side being retyped): there, wiping
// collapsed/minimized/large/detached on every keystroke-driven commit
// would silently un-collapse the whole graph out from under an edit that
// never touched most of it, and re-running fullLayout()'s packer fresh
// re-optimizes (potentially reorganizes) boxes whose content never
// changed — see the PRESERVING ARRANGEMENT banner above fullLayout(). Both
// are exactly the same "prevent re-optimizing the view after an edit"
// guarantee applySourceEditCommand already gets in single-value mode.
// `preserve` may also BE an arrangement snapshotDictArrangement() took
// earlier: the diff-help popover's revert (showDiffHelpView) puts back the
// committed view's own arrangement, not the preview tree's ROOT would give.
function loadDiffTree(supersetValue, preserve){
if (searchActive) clearSearch();
const priorArrangement = preserve instanceof Map ? preserve
: (preserve && ROOT) ? snapshotDictArrangement() : null;
if (!preserve){
STATE.collapsed = new Set(); STATE.minimized = new Set();
STATE.large = new Set(); STATE.detached = new Set(); STATE.linkPinned = new Set();
STATE.kidsShown = new Map(); STATE.kidsRevealed = new Set();
STATE.linkIncluded = new Set(); STATE.linkExcluded = new Set(); // see exitDiff
autoRevealedLargePkeys.clear();
}
// buildTree unconditionally wipes undoStack/redoStack — see
// preserveSelfEditHistory's own banner comment on why that has to be
// guarded HERE (this is every Unified repaint's one shared choke point),
// not by every individual caller of renderDiffViaEngine remembering to.
preserveSelfEditHistory(() => { buildTree(supersetValue); });
fullLayout();
if (priorArrangement) reapplyDictArrangement(priorArrangement);
render();
}
// Finds the real DOM elements for every colorPlan instruction and tints
// them — the exact same __id/__cellId/__colId/__nodeId property lookup
// the undo/redo history hover highlight already uses (see historyHoverEls
// in the UNDO/REDO HISTORY DROPDOWNS section), just keyed by path instead
// of by a single command's nodeId. Called once right after render(), and
// again by render() itself on every later repaint (resize, collapse/
// expand, theme, search) — see render()'s own diffMode hook — so the
// coloring never has to be "remembered" by anything other than the plan.
function applyDiffColorPlan(plan, canvasArg){
const canvas = canvasArg || canvasEl();
if (!canvas || !plan || !plan.length) return;
// Keyed by REAL path (toRealPath): a value shown inside an included link
// or a link window is its target's own, and wears the target's tint —
// so every copy of an edited value shows the edit (see LINK INCLUSION).
const itemIdsByPath = new Map();
for (const [id, entry] of ITEM_BY_ID){
const k = pkey(toRealPath(entry.item.path));
if (itemIdsByPath.has(k)) itemIdsByPath.get(k).push(id); else itemIdsByPath.set(k, [id]);
}
// Nodes shown under a view path, by the real path they show (an included
// box's own name is its link's key, not the target's — so a rename-only
// tint, which is about that name, skips it; see nodesAt).
const viewCopies = new Map();
for (const [pk, n] of NODES){
if (!n.inView || n.kind === "prim") continue;
const real = pkey(toRealPath(n.path));
if (real === pk) continue;
if (viewCopies.has(real)) viewCopies.get(real).push(n); else viewCopies.set(real, [n]);
}
const nodesAt = (path, nameOnly) => {
const k = pkey(path), n = NODES.get(k), out = n ? [n] : [];
for (const c of (viewCopies.get(k) || [])) if (!(nameOnly && c.linkIncl)) out.push(c);
return out;
};
// A table (or overflowed scalar-fields block) that's peeked/expanded/
// detached renders its SAME logical cells into MORE THAN ONE DOM element
// at once — the normal in-place box, plus a "large" expand-in-place
// overlay and/or a detached side panel (see renderOverlays), all sharing
// the same __id/__cellId/__colId/__nodeId since registerTableIds/etc
// assign ids once per logical cell, not per rendered element. Every copy
// needs the SAME diff tint, so these are id -> ARRAY of elements, not
// id -> one element — a single-value map here previously let whichever
// copy rendered LAST (the overlay, since renderOverlays runs after the
// main tree) silently win, leaving the original in-place table untinted
// whenever that same table was also detached/expanded.
const addTo = (map, key, el) => { if (map.has(key)) map.get(key).push(el); else map.set(key, [el]); };
const pcellById = new Map(), nodeElById = new Map(), cellElById = new Map(), colElById = new Map();
canvas.querySelectorAll(".pcell").forEach(el => addTo(pcellById, el.__id, el));
// __ownerNodeId (always set — see its assignment in buildToolbar/
// renderNode), not __nodeId (only set when the label is itself a rename
// target — a Set member's box still needs tinting even though its
// position-only label isn't renamable).
canvas.querySelectorAll(".dname, .tname").forEach(el => addTo(nodeElById, el.__ownerNodeId, el));
canvas.querySelectorAll("td[title]").forEach(el => addTo(cellElById, el.__cellId, el));
canvas.querySelectorAll("th[title]").forEach(el => addTo(colElById, el.__colId, el));
// "item" tints the .pcell itself — several scalar fields can share one
// enclosing .box (see renderPrim), so escalating to .closest(".box")
// here would wrongly paint every sibling field the same color.
// "container" tints the WHOLE box (a nested dict/table that's entirely
// added/removed/reordered is its own single box, one-to-one).
const applyVisual = (el, cls, strike) => {
if (cls) el.classList.add(cls);
if (strike) el.classList.add("diff-struck");
};
const tintDirect = (els, cls, strike) => { if (els) for (const el of els) applyVisual(el, cls, strike); };
const tintBox = (els, cls, strike) => { if (els) for (const el of els) applyVisual(el.closest(".box") || el, cls, strike); };
for (const inst of plan){
if (inst.t === "item"){
for (const id of (itemIdsByPath.get(pkey(inst.path)) || [])) tintDirect(pcellById.get(id), inst.cls, inst.strike);
} else if (inst.t === "container"){
for (const n of nodesAt(inst.path, inst.nameOnly)){
const els = nodeElById.get(n.id);
// A renamed container's own children never changed — nothing inside
// it needs coloring, and painting the WHOLE box misleadingly implies
// its contents did too (see the rename-onto-a-container comment
// above, in both buildDiffSupersetValue and buildDiffSideValue).
// Only the name label itself gets tinted here.
if (inst.nameOnly) tintDirect(els, inst.cls, inst.strike);
else tintBox(els, inst.cls, inst.strike);
}
} else if (inst.t === "cell"){
for (const tn of nodesAt(inst.tablePath)){
const cid = tn.table && tn.table.cellIds[inst.row] && tn.table.cellIds[inst.row][inst.col];
if (cid != null){ const els = cellElById.get(cid); if (els) for (const el of els) applyVisual(el, inst.cls, inst.strike); }
}
} else if (inst.t === "colHeader"){
for (const tn of nodesAt(inst.tablePath)){
const cid = tn.table && tn.table.colIds && tn.table.colIds[inst.col];
if (cid != null){ const els = colElById.get(cid); if (els) for (const el of els) applyVisual(el, inst.cls, inst.strike); }
}
} else if (inst.t === "rowOrder"){
for (const tn of nodesAt(inst.tablePath)){
const cid = tn.table && tn.table.cellIds[inst.row] && tn.table.cellIds[inst.row][0];
const els = cid != null ? cellElById.get(cid) : null;
if (els) for (const el of els){ const tr = el.closest("tr"); if (tr) tr.classList.add("diff-row-order"); }
}
}
}
}
// The diff-help popover (the i button in the diff/edit header) explains
// the six layout x scope views, and each of its rows is also a picker:
// resting on a row previews that view behind the popover, clicking it
// switches to it. `h.committed` is the view dismissing returns to, so a
// preview never sticks on its own — see showDiffHelpView.
let currentDiffHelp = null;
// Set while the popover itself repaints the diff. renderDiffHeader closes
// the popover on every OTHER repaint, since that one changed the view out
// from under it.
let diffHelpRepainting = false;
// How long the pointer rests on a row before its view is previewed:
// sweeping across the popover to reach one row shouldn't repaint the whole
// diff once for every row it passes over.
const DIFF_HELP_PREVIEW_DELAY_MS = 120;
const DIFF_WRAP_IDS = ["canvasWrap", "canvasWrapLeft", "canvasWrapRight"];
const DIFF_CANVAS_IDS = ["canvas", "canvasLeft", "canvasRight"];
function sameDiffView(a, b){ return a.view === b.view && a.scope === b.scope; }
function diffHelpRowView(row){ return { view:row.dataset.view, scope:row.dataset.scope }; }
// `keepView` is renderDiffHeader's call: a repaint someone else started
// already owns the view, so whatever is on screen stays. Every other
// dismiss (Escape, outside click, the i button, a row click) returns to
// h.committed first.
function dismissDiffHelp(keepView){
const h = currentDiffHelp;
if (!h) return;
clearTimeout(h.timer);
if (h.btn) h.btn.setAttribute("aria-expanded", "false");
h.el.remove();
h.teardown();
currentDiffHelp = null;
if (!keepView) showDiffHelpView(h, h.committed);
}
function captureDiffScrolls(){
return {
wraps: DIFF_WRAP_IDS.map(id => { const w = document.getElementById(id); return w ? [w.scrollLeft, w.scrollTop] : null; }),
overlays: DIFF_CANVAS_IDS.map(id => captureOverlayScrolls(document.getElementById(id))),
};
}
function restoreDiffScrolls(saved){
DIFF_WRAP_IDS.forEach((id, i) => {
const w = document.getElementById(id);
if (w && saved.wraps[i]) w.scrollTo(saved.wraps[i][0], saved.wraps[i][1]);
});
DIFF_CANVAS_IDS.forEach((id, i) => restoreOverlayScrolls(document.getElementById(id), saved.overlays[i]));
}
// Puts `target` ({view, scope}) on screen for popover `h`, which may be
// dismissed already. Leaving h.committed first saves everything a real
// switch discards: STATE's sets, the Unified box arrangement, scroll
// positions and an active search. Coming back to h.committed restores all
// of it, so hovering a row and moving away changes nothing.
function showDiffHelpView(h, target){
if (!diffMode || !diffRoot) return;
const onScreen = { view:diffView, scope:diffScope };
if (sameDiffView(target, onScreen)) return;
const restore = sameDiffView(target, h.committed) ? h.saved : null;
diffHelpRepainting = true;
try {
if (restore){
h.saved = null;
if (searchActive) clearSearch();
Object.assign(STATE, restore.sets);
autoRevealedLargePkeys = restore.autoRevealed;
// Truthy, so loadDiffTree keeps the sets just restored (Split never
// wipes them anyway).
applyDiffViewScope(target.view, target.scope, restore.arrangement || true);
} else {
if (!h.saved && sameDiffView(onScreen, h.committed)){
const si = document.getElementById("searchInput");
const query = searchActive && si ? si.value : "", nav = navIndex;
// Back to the pre-search baseline, which is what a revert restores
// before searching again.
if (searchActive) clearSearch();
h.saved = {
sets: snapshotSearchSets(), autoRevealed: new Set(autoRevealedLargePkeys),
arrangement: diffView === "unified" && ROOT ? snapshotDictArrangement() : null,
scrolls: captureDiffScrolls(), query, nav,
};
}
applyDiffViewScope(target.view, target.scope);
}
} finally {
diffHelpRepainting = false;
}
if (restore && restore.query){
const si = document.getElementById("searchInput");
if (si) si.value = restore.query;
applySearch(restore.query);
if (restore.nav > 0) focusMatch(restore.nav);
} else if (restore){
restoreDiffScrolls(restore.scrolls);
}
if (currentDiffHelp === h) syncDiffHelp(h);
}
// Marks the row on screen as .preview (and the popover as .previewing,
// which strikes through the committed row's tag) whenever it isn't the
// committed one, and re-points the i button bookkeeping at the
// #diffInfoBtn the repaint's renderDiffHeader just rebuilt, so that button
// still closes the popover instead of reopening it.
function syncDiffHelp(h){
const onScreen = { view:diffView, scope:diffScope };
const previewing = !sameDiffView(onScreen, h.committed);
h.el.classList.toggle("previewing", previewing);
h.el.querySelectorAll(".diff-help-scope").forEach(row => {
row.classList.toggle("preview", previewing && sameDiffView(diffHelpRowView(row), onScreen));
});
const btn = document.getElementById("diffInfoBtn");
if (btn && btn !== h.btn){
h.btn = btn; h.dismissOpts.ignore = btn;
btn.setAttribute("aria-expanded", "true");
}
}
function scheduleDiffHelpView(h, target){
clearTimeout(h.timer);
h.timer = setTimeout(() => {
if (currentDiffHelp === h) showDiffHelpView(h, target || h.committed);
}, DIFF_HELP_PREVIEW_DELAY_MS);
}
// A row click: that view becomes the committed one and the popover closes.
// A different view drops h.saved (it belonged to the view being left), so
// the switch starts from the same clean slate the header's buttons give.
function commitDiffHelpView(h, target){
if (currentDiffHelp !== h) return;
if (!sameDiffView(target, h.committed)){ h.committed = target; h.saved = null; }
dismissDiffHelp();
}
// Builds one scope description row, marked .active (background + left bar,
// never color alone — see diff_plan.md's "avoid relying on color alone")
// when it's the actual diffScope/diffView combination on screen right now.
function diffHelpScopeRow(view, key, label, text){
const active = diffView === view && diffScope === key;
return '';
}
function showDiffHelp(btn){
if (currentDiffHelp){ dismissDiffHelp(); return; }
const pop = document.createElement("div");
pop.className = "diff-help-pop";
pop.setAttribute("role", "dialog");
pop.setAttribute("aria-label", "Diff view guide");
const splitActive = diffView === "split";
const unifiedActive = diffView === "unified";
pop.innerHTML =
'
What the diff views showhover to preview, click to switch
' +
'
' +
'
Split
' +
diffHelpScopeRow("split", "full", "Full", "Complete left and right data in aligned panes. One-sided data is red on the left and green on the right, with the missing-side copy struck through; changed values are amber on both sides.") +
diffHelpScopeRow("split", "compact", "Compact", "The innermost changed containers in two aligned panes, with breadcrumbs and all nearby unchanged fields, rows, and cells retained as context.") +
diffHelpScopeRow("split", "ultracompact", "Ultracompact", "The same containers and breadcrumbs as Compact, pruned to changed data plus identifiers and order markers needed to interpret it.") +
'' +
'
Unified
' +
diffHelpScopeRow("unified", "full", "Full", "One complete superset view. Added data is green, removed data red, and old → new changes amber at one logical location.") +
diffHelpScopeRow("unified", "compact", "Compact", "One superset containing the innermost changed containers, with breadcrumbs and complete local context.") +
diffHelpScopeRow("unified", "ultracompact", "Ultracompact", "The same containers and breadcrumbs as Compact, showing only changes plus identifiers and order markers.") +
'' +
'
';
document.body.appendChild(pop);
const r = btn.getBoundingClientRect();
const pw = pop.offsetWidth, ph = pop.offsetHeight;
pop.style.left = Math.max(8, Math.min(r.left, window.innerWidth - pw - 8)) + "px";
pop.style.top = Math.max(8, Math.min(r.bottom + 6, window.innerHeight - ph - 8)) + "px";
btn.setAttribute("aria-expanded", "true");
// Kept on `h` so syncDiffHelp can swap in the rebuilt button: the
// outside-click check reads opts.ignore each time.
const dismissOpts = { dismiss:dismissDiffHelp, ignore:btn };
const h = { el:pop, btn, dismissOpts, committed:{ view:diffView, scope:diffScope }, saved:null, timer:0 };
h.teardown = makeDismissablePopover(pop, dismissOpts);
currentDiffHelp = h;
pop.querySelectorAll(".diff-help-scope").forEach(row => {
const target = diffHelpRowView(row);
row.addEventListener("mouseenter", () => scheduleDiffHelpView(h, target));
row.addEventListener("mouseleave", () => scheduleDiffHelpView(h, null));
row.addEventListener("click", () => commitDiffHelpView(h, target));
});
}
function renderDiffHeader(){
// Runs on every diff-mode transition (entry, exit, scope/view toggle,
// swap — see renderDiffViaEngine's two branches and exitDiffMode, its
// only callers) INCLUDING Split, which bypasses render() entirely (see
// render()'s own refreshSourcePanel() call for why that one alone isn't
// enough) — so this is the one place guaranteed to run after every such
// change, diff-mode-wise. Unconditional (before the early-return below)
// so exitDiffMode's call here also repaints the single-value pane.
refreshSourcePanel();
// selfEditMode is really still just ONE document (unlike a normal two-
// document diff, where "the" filename would be ambiguous) — see
// updateFileNameLabel's own diffMode/selfEditMode visibility check.
updateFileNameLabel();
const bar = document.getElementById("diffHeader");
if (!bar) return;
if (!diffHelpRepainting) dismissDiffHelp(true);
if (!diffMode || !diffRoot){ bar.hidden = true; bar.innerHTML = ""; return; }
bar.hidden = false;
const counts = diffCounts(diffRoot);
const summaryParts = ["added", "removed", "changed", "typeChanged", "orderChanged", "mixed"]
.filter(k => counts[k])
.map(k => '' + counts[k] + " " + escHtml(k) + '');
const summary = summaryParts.length ? summaryParts.join(", ") : "no differences";
const scopeBtn = (key, label) => '';
const layoutBtn = (key, label) => '';
// Truncated with an ellipsis + native title tooltip (same guardrail as
// #fileNameLabel's .filename-chip) — a long source path/filename must
// not push the layout/scope controls out of the header or off-screen.
// The name itself is a button: click it to replace just that side (see
// startDiffReplaceSide) — "allow clicking on the file as button to
// replace that file with another one for diff." Rendered as plain,
// non-interactive text when embedded instead — the host owns loading
// there (same reasoning as Diff…/Paste…/Load being [data-embed-hide]),
// and unlike those this one couldn't just be data-embed-hide: it's
// reachable mid-diff-session, and a replacement that turns out to need
// an interpreter has no visible UI to supply one — [data-embed-hide]
// hides #diffIntakePanel too. A disabled