diff --git a/package-lock.json b/package-lock.json index e84b3a6..3c29665 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@lit/context": "^1.1.6", "data-explorer-core": "github:mathworks/data-explorer-core#v1.54.0", - "data-explorer-view": "github:mathworks/data-explorer-view#v1.2.3", + "data-explorer-view": "github:mathworks/data-explorer-view#v1.2.4", "fast-xml-parser": "^5.8.0", "fflate": "^0.8.3", "highlight.js": "^11.11.1", @@ -2923,8 +2923,8 @@ } }, "node_modules/data-explorer-view": { - "version": "1.2.3", - "resolved": "git+ssh://git@github.com/mathworks/data-explorer-view.git#618edfc92c4a744824776c4feead204572c18893", + "version": "1.2.4", + "resolved": "git+ssh://git@github.com/mathworks/data-explorer-view.git#1059c6f07752a54264d7fae67aee2acfa7524acd", "license": "BSD-3-Clause", "peerDependencies": { "data-explorer-core": "^1.54.0", diff --git a/package.json b/package.json index 4de96be..d6795be 100644 --- a/package.json +++ b/package.json @@ -244,7 +244,7 @@ "dependencies": { "@lit/context": "^1.1.6", "data-explorer-core": "github:mathworks/data-explorer-core#v1.54.0", - "data-explorer-view": "github:mathworks/data-explorer-view#v1.2.3", + "data-explorer-view": "github:mathworks/data-explorer-view#v1.2.4", "fast-xml-parser": "^5.8.0", "fflate": "^0.8.3", "highlight.js": "^11.11.1", diff --git a/src/host/BinarySlddEditorProvider.ts b/src/host/BinarySlddEditorProvider.ts index 31f7c39..55c91c3 100644 --- a/src/host/BinarySlddEditorProvider.ts +++ b/src/host/BinarySlddEditorProvider.ts @@ -149,6 +149,7 @@ export class BinarySlddEditorProvider implements vscode.CustomEditorProvider drainNavigateSelect(webview, uriString), }); diff --git a/src/host/SlddTextEditorProvider.ts b/src/host/SlddTextEditorProvider.ts index 140770d..e049b65 100644 --- a/src/host/SlddTextEditorProvider.ts +++ b/src/host/SlddTextEditorProvider.ts @@ -1,214 +1,36 @@ // Copyright 2026 The MathWorks, Inc. +// +// Custom editor for EDITABLE JSON .sldd, backed by VS Code's native TextDocument +// (CustomTextEditorProvider), on view's JSON dictionary engine (data-explorer-view/host `JsonEngine`; +// jsonDocument.ts). Because every edit is a WorkspaceEdit on that TextDocument, undo/redo, dirty +// state, save, and revert are all handled natively by VS Code — a SINGLE undo stack shared with the +// plain-text view. Editing in either the table or the text view live-syncs to the other, and +// Cmd+Z / Cmd+Shift+Z work identically in both. +// +// The tables are the engine's: their paints, the narrow repaints that keep them in step with every +// change of the text, their gestures and the Status column's baseline. What stays here is this +// extension's own: the webview, the messages every table answers alike, "Location in Text"'s +// reveal of the text editor beside the table, and the Add gallery's keybinding. Each panel is one +// of the engine's tables, posted as this editor has always posted it (engineTable.ts). +// +// Binary .sldd / .slx / .mat / .prj are handled by the read-only +// BinaryEditorProvider instead (they can't be opened as text documents). import * as vscode from 'vscode'; +import { basename } from 'data-explorer-view/model'; +import { answerMatrixRequest } from 'data-explorer-view/host'; import { renderTableWebview } from './webviewHtml.js'; -import { - buildEntryRows, - COLUMNS, - COLUMN_LABELS, - COLUMN_GROUPS, - type ClipMark, - rowPlannerFor, - LAZY_ROW_BUDGET, - lazyRowsBanner, - sectionRules, - sourceWarnings, - warningBanner, - buildSectionRowId, - basename, -} from 'data-explorer-view/model'; -import { - getModel, - invalidate, - findNode, - peekModel, - openSourceView, - parseDetached, - releaseSourceView, - answerChildRequest, - postOrReport, - answerMatrixRequest, -} from 'data-explorer-view/host'; -import { findEntrySpan, detectIndent } from './entrySplice.js'; -import { clipMarkChange, clipMarkOfDoc } from './clipMarks.js'; -import { - baselineOwed, - captureBaseline, - computeModified, - isEntryModified, - clearBaseline, - diskBaselineUsable, - sameDiskStamp, - type BaselineSource, - type DiskStamp, -} from './slddBaseline.js'; -import { - applyEntryOps, - cellEdit, - findEntryByName, - insertOp, - mutateEntry, - opsOfPastedEntries, - patchOfPairs, - removalPairs, - removeEntries, - removeOp, - replaceOp, - type AppliedOp, - type EntryOpPair, - type Removal, -} from './entryOps.js'; -import { - hintFor, - isPaintedEcho, - planEntrySync, - planKnownChange, - planKnownOps, - planOwnEdit, - type HostEdit, - type KnownEdit, - type PaintedWrite, - type RangeReplacement, -} from './jsonEntrySync.js'; -import { clipboardState } from './clipboard.js'; -import { getDrag, clearDrag } from './dragState.js'; -import { - registerWebview, - unregisterWebview, - registerAddGalleryView, - unregisterAddGalleryView, - registerSourceDeleter, - unregisterSourceDeleter, - broadcastClipboardState, - broadcastDragState, - deleteFromSource, - NOT_FOUND_REASON, - type DeleteTarget, - type SourceDeleteResult, -} from './editorHub.js'; -import { runTransfer, type TransferAdapter, type TransferDeps } from './transfer.js'; -import { clipboardRegister, dragRegister } from './transferRegisters.js'; -import { - deleteEntry, - deleteChild, - deleteChildren, - addChild as addChildEdit, - addNewEntry, - pasteEntries, - deleteEntriesByName, - findOwningEntry, - reselectAfterRemoval, - refuseDisallowedPaste, - sectionByName, - sectionTarget, - reserializeEntry, - type SectionTarget, - type StructuralResult, - type TextPatch, -} from './structuralEdit.js'; -import { - catalogFunctionRenameOf, - catalogRenameOf, - editRefusalText, - entrySelectorOf, - minimalReplacement, - planDeletion, - scJsonFunctionRenameEdits, - scJsonRenameEdits, - type EntrySelector, -} from 'data-explorer-core'; -import { copyEntriesToClipboard, dragEntries } from './clipboardAction.js'; -import { annotateDataRows, annotateDataRowsNow } from './usageGraph.js'; -import { subscribeUsageRepost } from './usageRepostWatch.js'; +import { registerAddGalleryView, unregisterAddGalleryView } from './editorHub.js'; import { wireNavigateSelect, drainNavigateSelect } from './navigate.js'; -import { parsesAsJson } from './slddFormat.js'; -import { wideLanding } from './wideRepaint.js'; import type { TableToHostMessage } from '../common/protocol.js'; -import { addChildRefusal, addEntryRefusal, deleteRefusal, describeSource, refusedMessage, type RefusedGesture } from './refusal.js'; +import { describeSource } from './refusal.js'; import { dispatchCommon, type CommonHandlers } from './tableDispatch.js'; -import { answerEditTextRequest } from './editTextRequest.js'; - -/** - * What belongs to the DOCUMENT rather than to one of its tabs. - * - * A CustomTextEditorProvider has no custom document object to hang state on — VS Code owns - * the TextDocument — so the per-document facts live here, keyed by URI, and each call of - * resolveCustomTextEditor opens one view against them and closes it on dispose. - * - * The view count guards two things a URI-keyed registry already treats as document-scoped: - * the "Modified" baseline (slddBaseline) and the cross-document-move deleter (editorHub). - * Tearing either down when ANY tab closed left a surviving split panel showing no Modified - * marks at all — with no baseline, computeModified answers "nothing is modified" by design — - * and unable to complete a move, while the document itself stayed open and edited. And - * because captureBaseline OVERWRITES by URI, letting each tab take its own baseline when it - * opened re-baselined an already-edited document against the EDITED text, clearing the - * marks in every tab at once. - * - * BinarySlddEditorProvider carries these same two facts on its custom document - * (`views` / `baselineCaptured`) and for the same reasons; this is that one rule on the - * other path. - * - * Where the baseline came from is document-scoped for the same reason (BaselineSource, see - * slddBaseline.ts): it is the file on disk, and the moments the document is known to read what - * the file says — clean at an open, a save, a revert, a reload — are the document's, not a tab's. - * So is the list of every tab's repaint, because a new baseline changes the marks in all of them - * and only one tab's event may be the one that noticed — and so is the file's stamp at the last of - * those moments, which every tab's check for a reload asks first (see checkAgainstDisk). - */ -type DocState = { - views: number; - baseline: BaselineSource; - repaints: Set<() => void>; - diskStamp: DiskStamp | null; -}; -const openDocs = new Map(); - -/** Count one more open view of this document, and answer what it already knows. */ -function openView(uriString: string): DocState { - const state = openDocs.get(uriString) ?? { - views: 0, - baseline: { captured: false, pending: false, version: -1, cleanAt: -1 }, - repaints: new Set<() => void>(), - diskStamp: null, - }; - state.views++; - openDocs.set(uriString, state); - return state; -} - -/** - * Close one view of this document; true when it was the LAST, i.e. the document-scoped - * teardown is now owed. Forgets the document then, so the next open captures a fresh - * baseline rather than trusting one that has been cleared. - */ -function closeView(uriString: string): boolean { - const state = openDocs.get(uriString); - // Defensive: an untracked view is treated as the last one, so a teardown can never be - // skipped — leaking a stale baseline is the worse of the two failures. - if (!state) return true; - if (--state.views > 0) return false; - openDocs.delete(uriString); - return true; -} +import { engineTable, jsonRows } from './engineTable.js'; +import { openJsonTable } from './jsonDocument.js'; +import { closingOnThrow } from './perDocument.js'; -// Custom editor for EDITABLE JSON .sldd, backed by VS Code's native TextDocument -// (CustomTextEditorProvider). Because every edit is a WorkspaceEdit on that -// TextDocument, undo/redo, dirty state, save, and revert are all handled -// natively by VS Code — a SINGLE undo stack shared with the plain-text view. -// Editing in either the table or the text view live-syncs to the other, and -// Cmd+Z / Cmd+Shift+Z work identically in both. -// -// Binary .sldd / .slx / .mat / .prj are handled by the read-only -// BinaryEditorProvider instead (they can't be opened as text documents). export class SlddTextEditorProvider implements vscode.CustomTextEditorProvider { public static readonly viewType = 'dataExplorer.tableView'; - // Clipboard and drag state are shared across BOTH the JSON and binary table - // editors (they back the same webview + the same singletons), so the live- - // webview registry and its broadcasts live in editorHub.ts. Without the - // broadcast, a second already-open .sldd would never learn the clipboard now - // has content (Paste stays disabled), and a drag begun in one .sldd would not - // reach another to predict its drop. See editorHub.js. - // Relay selection to the Property Inspector (wired in extension.ts). public onSelect?: (uriString: string, rowIds: string[]) => void; @@ -249,2041 +71,102 @@ export class SlddTextEditorProvider implements vscode.CustomTextEditorProvider { const uriString = document.uri.toString(); const name = basename(document.uri.path) || 'document'; // Core's messages name this source by its id (the uri string), which every message shown here - // spells as the file's name, as the MAT-file editor's do; a failed edit says so in the banner. + // spells as the file's name. const describe = describeSource(uriString, name); - const failedEdit = (err: unknown): string => 'Failed to apply edit: ' + describe((err as Error).message); - // A refused Add Child, Delete or Add, in core's words, said as every table editor says one. - const postRefusal = (gesture: RefusedGesture, reason: string) => webview.postMessage(refusedMessage(gesture, reason, describe)); - // Which builder plans this file's rows AND answers a fetch for them (rowPlanner.ts). - // An editable view plans too, which took some arguing with the comment below about - // NOT capping these rows — but a plan is not a truncation. Every entry name is - // delivered, each entry's rows stay one contiguous run for the splice, and a - // structural edit resolves its target through the MODEL by node id, which is whole - // whatever the payload held. What a plan withholds is depth under a name, and the - // user can ask for it. - const planner = rowPlannerFor(name); - - // The baseline is PER DOCUMENT — not per tab — so a second tab does not re-baseline an - // edited document to its edited text. See openDocs. - const docState = openView(uriString); - // This view holds the tree it registers under the uri, until it closes (see onDidDispose). - openSourceView(uriString); - - /** - * Whether both the tree this document holds and every row on screen were built from the - * text as it now reads. - * - * The precondition of the entry-scoped repaint, and the reason a stretch of invalid - * JSON cannot leave a stale row behind. While the document does not parse, each - * keystroke's repaint fails and paints nothing; if the keystroke that finally makes it - * parse were repainted narrowly, only the entry that keystroke was in would be - * refreshed — every other entry the user touched during the invalid stretch would keep - * the rows it had before. So a failed repaint withdraws the narrow path until a full - * one has succeeded. - * - * It covers the MODEL as well as the rows because that is what lets an edit keep the - * tree it is holding instead of re-parsing 46 MB to rebuild one that was already - * correct (see liveModel), and what lets it skip the validity gate: a tree built from - * this text is proof the text parsed. Every path that leaves the two out of step has to - * clear it — which is the one obligation this flag imposes and the only way it can lie. - * - * The wide repaint sets it only when its rows LAND, not when it has parsed: in between, the - * rows on screen are still the old ones — for seconds, when the usage graph is cold — and a - * narrow repaint in that window is spliced into THEM, so the rows the wide repaint is holding - * are read again before they may land (see wideRepaint.ts). - */ - let modelInSync = false; - - /** - * How many wide repaints this view has begun — the stamp a write painted ahead of its echo - * carries (PaintedWrite, HostEdit), and the reason that echo can be trusted. - * - * A paint goes out before its write, so for the length of the write the screen is ahead of - * the text. Nothing about a wide repaint waits for that write: a save, a webview asking for - * its rows, a clipboard broadcast over stale rows all read the text as it stands, which is - * the text from before the edit, and rebuild the tree and every row from it. An echo - * stamped before such a repaint is an echo of a paint that is no longer on screen, so it is - * treated as the change it is (isPaintedEcho, hintFor). - */ - let wideEpoch = 0; - - /** - * How many times this view has changed its rows, or put its tree out of step, OUTSIDE a wide - * repaint — what a wide repaint in flight has to know happened behind it (see wideLanding). - * - * Per VIEW, deliberately, rather than read off the tree the session holds: two tabs of one - * document share that tree, and each one's wide repaint registers a fresh one, so a repaint - * that asked "is the tree still mine?" was answered no by the other tab's, read again, and - * answered the other tab the same way — for ever. - */ - let rowsTouched = 0; - - /** - * This view's panel has closed. Set FIRST in onDidDispose, and asked by everything that can - * still be running when it is: a wide repaint waits seconds for the usage graph, and the disk - * reads below wait on the file system. A wide repaint that woke after the last tab closed and - * read the text again registered a fresh tree of it — tens of MB on a real dictionary — that - * the close had just released and no tab was left to release again. - */ - let disposed = false; - - /** - * The webview has asked for its rows (`ready`). Until it does it has no listener: a full - * payload posted before then — for a new baseline, or a change nothing narrow could plan — is - * a parse and a row build thrown away, ahead of the one `ready` asks for anyway. - */ - let webviewReady = false; - - // A narrow repaint: one entry's run of rows replaced, removed or added. Counted, because a - // wide repaint in flight read its text before this and would paint over it (wideLanding). - const postRowPatch = (msg: Record): void => { - if (disposed) return; - rowsTouched++; - void webview.postMessage(msg); - this.onChanged?.(uriString); - }; - - // A failure's banner, posted after the repaint that clears the way for it — which a closed - // view does not get, so neither does the banner. - const postBanner = (message: string): void => { - if (!disposed) void webview.postMessage({ type: 'error', message }); - }; - - // The rows or the tree no longer match the text (see modelInSync) — said through here by - // every path but the wide repaint itself, which is what such a declaration is waiting for. - const leaveStep = (): void => { - modelInSync = false; - rowsTouched++; - }; - - /** - * The tree for this document — the one already built when it still says what the text - * says, a fresh parse when it does not. - * - * The registered tree, not this module's cache: `invalidate` is called on every keystroke - * in an open .sldd (extension.ts drops the cache so the tree view and the usage graph - * re-read), so getModel would re-parse every time. On a 47.8 MB customer dictionary that - * parse is ~190 ms, paid on a keypress the user is waiting on. - * - * Deliberately does NOT touch modelInSync: a rebuild here brings the MODEL up to the text - * and leaves the ROWS wherever they were, so the narrow repaint stays withdrawn until a - * full one has run. - */ - const liveModel = (): any => { - if (modelInSync) { - const held = peekModel(uriString); - if (held) return held; - } - invalidate(uriString); - return getModel(uriString, name, document.getText()); - }; - - /** - * Bring the model back in step with the text, painting nothing. - * - * For the one case that needs it: an edit that mutated the tree and then could not write - * the text. Re-parsing is the repair — the text is the truth in this format — and the rows - * on screen still match it, so nothing has to be repainted; what must not happen is - * leaving a mutated tree that the text does not say, for the next edit to build on. - */ - const rebuildModel = (): void => { - invalidate(uriString); - try { - getModel(uriString, name, document.getText()); - } catch { - // The text does not parse, so nothing can be trusted: the next repaint goes wide and - // reports the parse error. - leaveStep(); - } - }; - - // The newest wide repaint, for an older one whose rows it replaces to resolve with. - let latestPost: Promise = Promise.resolve(); - - // Rebuild the model from the live TextDocument text and push rows to the - // webview. Called on open, on every text change that is not a single entry's - // (see syncOneEntry), and on save. The Usage column is filled asynchronously - // from the shared workspace usage graph. - // - // Returns when the rows are on the wire, for the same reason postEntryRows does: a banner - // that has to outlive a repaint has to be posted after it. A repaint whose rows are dropped - // (see wideLanding) returns when the rows that replace them are. - const post = (): Promise => { - // A closed view repaints nothing — and must not register the tree a repaint parses — and a - // webview not yet loaded is painted when it asks (see webviewReady). - if (disposed || !webviewReady) return Promise.resolve(); - // Counted before the text is read: a write painted before this point is never again taken - // for one whose rows are on screen (isPaintedEcho), and a repaint begun before it is no - // longer the newest (wideLanding). - const epoch = ++wideEpoch; - // modelInSync is NOT set here, only when these rows land — which can be seconds away (the - // usage graph, below). Until then the flag says what it said: rows in step stay in step - // through the narrow repaints, each of which this repaint then reads again for - // (wideLanding), and rows out of step stay withdrawn from them — which is what a repair - // declares before it begins (repaintStale). - latestPost = paintWide(epoch); - return latestPost; - }; - - /** - * The wide repaint as a REPAIR: for rows already known not to say what the text does — a - * change nothing narrow could plan, a narrow repaint that threw, a paint the file never took. - * Out of step from the moment that is known rather than from when the repair lands, so no - * change in between is spliced narrowly into rows the repair is about to replace. - */ - const repaintStale = (): Promise => { - leaveStep(); - return post(); - }; - - const paintWide = (epoch: number): Promise => { - try { - invalidate(uriString); - const version = document.version; - const touched = rowsTouched; - const node = getModel(uriString, name, document.getText()); - // From this read, if it is a text known to be the file's (see baselineOwed): parsed - // fresh here, so it is never a tree an edit has changed ahead of its write. - if (baselineOwed(docState.baseline, version)) { - captureBaseline(uriString, node); - docState.baseline.captured = true; - docState.baseline.version = version; - } - const modified = computeModified(uriString, node); - const clipMark = clipMarkOfDoc(uriString); - const planned = planner.payload(node, LAZY_ROW_BUDGET, { modifiedNames: modified, clipMark }); - const rows = planned.rows; - // Fill the Usage column from the shared usage graph, then post. The graph - // builds lazily on first use and is cached, so only the very first open in - // a session pays the scan cost; subsequent posts resolve near-instantly. - return annotateDataRows(uriString, rows) - .catch(() => false) - .then((): Promise | void => { - // Closed while the graph was awaited: nothing to post to, and no re-read (see disposed). - if (disposed) return; - // Only if these rows are still the answer: the newest repaint, over the text they - // were built from, with nothing painted or put out of step behind them. A newer - // repaint lands for this one; anything else that moved while the graph was awaited - // is read again. - const landing = wideLanding( - { epoch, version, touched }, - { epoch: wideEpoch, version: document.version, touched: rowsTouched }, - ); - if (landing === 'superseded') return latestPost; - if (landing === 'reread') return post(); - // Every row below is built from `node`, which is still what the text says. - modelInSync = true; - // Through postOrReport: an undeliverable payload — one too large for the - // webview RPC to serialize — rejects asynchronously, where the catch below - // cannot see it, and the table would wait on its spinner for ever. NOT - // capped like the read-only view's: these rows are editable, and a - // structural edit computed against a truncated row set is a worse failure - // than an honest banner saying the payload could not be shown. - void postOrReport( - webview, - { - type: 'setRows', - docUri: uriString, - rows, - columns: COLUMNS, - columnLabels: COLUMN_LABELS, - columnGroups: COLUMN_GROUPS, - editable: true, - // Backed by a TextDocument, so "Location in Text" has a target. - hasTextView: true, - // Recomputed on every repaint, which for this view means on every - // keystroke in the plain-text editor: a dictionary the user is midway - // through fixing should stop warning the moment it reads whole. Through - // the plan as well, so a dictionary too large to deliver whole says so - // here instead of looking like one with shallow entries. - warnings: lazyRowsBanner(planned.plan, warningBanner(sourceWarnings(node))), - }, - name, - ); - // Ship this document's section drop-rules so the webview can predict - // a drop (dropDecision) live on dragover without a host round-trip. - webview.postMessage({ type: 'sectionRules', docUri: uriString, rules: sectionRules(node) }); - webview.postMessage({ type: 'clipboardState', ...clipboardState() }); - // The mark these rows CARRY, not the one the clipboard holds now. A copy or a cut that - // landed while they waited on the Usage column repainted the entries it marked at - // once, ahead of these, which have just landed over that with the mark from before: - // so the entries that moved are painted again, after them (clipMarks.ts). - paintedMark = clipMark; - repaintClipMark(); - drainNavigateSelect(webview, uriString); - this.onChanged?.(uriString); - }); - } catch (err) { - invalidate(uriString); - // Nothing was painted, so what is on screen is older than the text (see - // modelInSync): the next repaint has to be a full one. - modelInSync = false; - webview.postMessage({ - type: 'error', - message: `Failed to parse ${name}: ${describe((err as Error).message)}`, - }); - return Promise.resolve(); - } - }; - - // One entry's rows, as a full rebuild would have built them: the two whole-model passes - // post() runs are asked about a single entry here (computeModified would serialize all - // 64,700 entries of a big dictionary to answer about one). Everything else about the view — - // columns, banners, section rules, the clipboard state — is what the last setRows left, - // because none of it can change under an edit inside one entry. - const entryRowsOf = (entry: any): any[] => { - const modified = new Set(); - if (isEntryModified(uriString, entry)) modified.add(entry.name); - // Planned to the same budget the payload used: ONE entry can be the whole problem - // (a 1000x1000 double is 1,000,001 rows from a single entry), and a repaint that - // ignored the budget would blow past what the RPC can carry on the one path where - // the user is mid-edit and watching. - return buildEntryRows( - entry, - entry.parent?.name ?? '', - modified, - clipMarkOfDoc(uriString), - undefined, - LAZY_ROW_BUDGET, - ); - }; - - /** - * Repaint ONE entry: splice its rows over the run the table already holds under `entryRowId`. - * - * Returns the promise, for the callers that have something to say AFTER the rows: fresh rows - * clear the error banner (see the webview's `updateEntryRows`), so a banner posted beside - * them has to come second or it is wiped by them. - */ - const postEntryRows = (entryRowId: string, entry: any): Promise => { - const rows = entryRowsOf(entry); - return annotateDataRows(uriString, rows) - .catch(() => false) - .then(() => { - postRowPatch({ type: 'updateEntryRows', entryRowId, rows }); - }); - }; - - /** - * The same repaint, posted in THIS synchronous run — which is the only way an edit can be on - * screen before the host goes off to do the rest of the work. - * - * A postMessage issued from a promise callback cannot reach the renderer until the host next - * yields, and on the edit path that is ~100 ms of span scan away. Measured in real VS Code - * against a host that then blocks for 120 ms: posted synchronously the renderer has it in - * 1 ms, posted from a `.then` it has it in 120 ms. Which makes the await for the Usage graph — - * the only asynchronous thing about a repaint — the difference between an edit that appears - * instantly and one that appears when the scan is done. - * - * When the graph is COLD the rows still go out now, and the Usage column catches up in a - * second post. Usage is the one cell this document cannot answer on its own, and the graph - * that answers it spans the workspace: over a folder of real dictionaries a rebuild costs - * ~5.8 s (measured; 4 files, 98 MB). Holding 180 ms of finished rows for it is how an undo - * came to take seven seconds. A column that fills in a moment later is the smaller cost, - * and it is only ever paid when something invalidated the graph. - */ - const postEntryRowsNow = (entryRowId: string, entry: any): void => { - const rows = entryRowsOf(entry); - const filled = annotateDataRowsNow(uriString, rows); - postRowPatch({ type: 'updateEntryRows', entryRowId, rows }); - if (!filled) void postEntryRows(entryRowId, entry); - }; - - /** - * Repaint what a STRUCTURAL change did: entries that left the table, arrived in it, or were - * rebuilt — as the rows those three amount to, and nothing else. - * - * The rest of this provider's narrow repaints are one entry replaced in place, which is - * every edit INSIDE an entry. A delete, a paste, a drop and their undos change which - * entries the dictionary has, so the row COUNT changes and the rows the table keeps are the - * ones the host never mentions. That is the whole message set: an empty replacement is a - * removal (see spliceEntryRows), an insert has to be told its place, and the webview - * answers a message it cannot place by asking for a full payload rather than guessing. - * - * Posted in THIS synchronous run for the same reason postEntryRowsNow is: a postMessage from - * a `.then` cannot reach the renderer until the host next yields, which on these paths is a - * text write away. A cold Usage graph is caught up in a second post per entry. - * - * Same shape as the binary provider's repaintOps, deliberately — a delete has to mean the - * same thing in the two .sldd formats, and the way these two once differed is exactly the - * bug class this repo keeps finding. - */ - const repaintOps = (applied: readonly AppliedOp[]): void => { - try { - for (const op of applied) { - if (op.kind === 'remove') { - // An empty replacement IS the removal — see spliceEntryRows. - postRowPatch({ type: 'updateEntryRows', entryRowId: op.entryRowId, rows: [] }); - continue; - } - const section = op.entry?.parent; - if (!section) throw new Error(`"${op.entry?.name}" is not in a section.`); - const rows = entryRowsOf(op.entry); - const filled = annotateDataRowsNow(uriString, rows); - if (op.kind === 'replace') { - postRowPatch({ type: 'updateEntryRows', entryRowId: op.entryRowId, rows }); - } else { - postRowPatch({ - type: 'insertEntryRows', - sectionRowId: buildSectionRowId(section.name), - beforeRowId: op.beforeRowId, - rows, - }); - } - // The rows are on screen; the Usage column follows when the graph is cold. Addressed - // by the entry's CURRENT id, which for an insert is the run just added. - if (!filled) void postEntryRows(op.kind === 'replace' ? op.entryRowId : op.entry.id, op.entry); - } - } catch (err) { - // The model change has already landed, so the table must not be left showing the rows - // from before it. Repaint wide, then say so — after, because fresh rows are how the - // webview learns a failure is over and would wipe a banner posted first. - void repaintStale().then(() => postBanner(`Failed to update the row: ${describe((err as Error).message)}`)); - } - }; - - /** - * What the table edit in flight will need when its own change event arrives: the entry it - * changed — under the id the model and the rows it painted now share — and the range - * replacement it submitted. - * - * Set immediately before the WorkspaceEdit and spent by the very next change event, - * whatever that event turns out to be — an expectation that outlived its edit must not be - * waiting when someone types in the text view. What makes spending it safe is that the - * event has to PROVE it is that edit, byte for byte (planOwnEdit / isEchoOfEdit); a - * mismatch costs the wide repaint the user has always had, never a wrong row. - */ - let hostEdit: HostEdit | null = null; - - /** - * A write whose rows are ALREADY on screen — so the change event it fires has nothing left - * to paint. - * - * The structural edits (delete an entry, paste, drop, move) change the model with ops and - * paint before they write, exactly as a cell edit does. Their echo cannot then be a - * confirmation the way a cell edit's is: the bytes a delete writes are an element and a - * comma removed, a paste's are an element with no name in the model yet, and neither reads - * back as "this one entry now says that". Nothing narrow can plan them, so the change event - * would fall through to the wide repaint — 1.4 s on a 47.8 MB dictionary, spent rebuilding - * rows the user is already looking at. - * - * Same one-shot, prove-it-first rule as `hostEdit`: set immediately before the write, spent - * by the very next change event, and honoured only if that event is this write byte for byte - * (isEchoOfEdit). A write whose event never arrives leaves the flag behind, and the next - * change — a keystroke in the text view, an undo — cannot match it, so it costs a repaint - * rather than skipping one. And honoured only while its rows are still on screen: a wide - * repaint begun in the meantime read the text from before the write (see wideEpoch). - */ - let paintedWrite: PaintedWrite | null = null; - - /** - * The edits this host has written to this document, newest last — kept PAST their own - * events, because VS Code can hand any of them back at any time as an undo or a redo. - * - * That is the whole reason an undo was slow: the change event says only "these bytes - * replaced those", so the host went looking for the entry it had already written down — - * `document.getText()` (35 ms on a 47.8 MB dictionary) and a full structural walk of it - * (138 ms), for an element it could name from memory. See planKnownChange. - * - * Bounded, because undo history is not: the last sixteen edits cover the depth anyone - * undoes interactively, and the cost of a miss is the scan, which is what every undo used - * to cost. Each holds two entry-sized strings, so this is kilobytes beside a document - * measured in tens of megabytes. - * - * Bounded by SIZE too, since one edit is not: a same-document move writes a region reaching - * from the entry it took out to the end of the entries array, which on a real dictionary is - * megabytes of text in each direction. Sixteen of those held to save a repaint is the wrong - * trade — that undo repaints wide, as it always has. - */ - const knownEdits: KnownEdit[] = []; - const REMEMBER_LIMIT = 1_000_000; - const remember = (edit: KnownEdit): void => { - if (edit.submitted.text.length + edit.replaced.length > REMEMBER_LIMIT) return; - knownEdits.push(edit); - if (knownEdits.length > 16) knownEdits.shift(); - }; - - /** - * Repaint the entry THIS host just wrote, from the bytes it wrote. - * - * The whole point of step 2: the change event a table edit fires tells the host nothing it - * does not already know, so it does not have to be treated like a stranger's keystroke. - * Instead of scanning the array for the element that changed and re-parsing it (~120 ms on - * a 46 MB dictionary, on top of the ~101 ms span scan and the ~92 ms validity parse the - * edit no longer pays), the entry is rebuilt from the text that was submitted and its rows - * spliced over the run the table is holding. - * - * Rebuilding rather than repainting from the mutated node — 7 ms rather than 3 — is what - * keeps this exactly as correct as the re-parse it replaces: the rows come from the file's - * own bytes, so a mutation the format cannot express (a rename with nowhere to go, a - * Description that is never serialized, an architectural kind that is re-derived on read) - * shows on screen as what the file will say, not as what the user typed. - * - * For a table edit those rows are now a CONFIRMATION: applyEdit painted the same entry from - * the mutated node before it wrote a byte, because waiting for this event costs ~200 ms on a - * big dictionary and the model had the answer immediately. So this is where the file gets the - * last word — over a paint it agrees with cell for cell, swept in jsonHostEditRepaint. It - * still repaints rather than merely reconciling the model: the day the two disagree, the - * bytes are what the user should be left looking at. - */ - // A change event as the plain range replacements every path here reasons about. One reading - // of an event, so the four of them cannot disagree about what it said. - const changesOf = (e: vscode.TextDocumentChangeEvent): RangeReplacement[] => - e.contentChanges.map((c) => ({ - rangeOffset: c.rangeOffset, - rangeLength: c.rangeLength, - text: c.text, - })); - - const syncOwnEdit = (e: vscode.TextDocumentChangeEvent, hint: HostEdit | null): boolean => { - if (!hint) return false; - const plan = planOwnEdit(changesOf(e), hint); - if (!plan) return false; - // Same two preconditions the text-view path has: rows that match the text, and a tree - // still registered for this document to apply the op to. - if (!modelInSync) return false; - const model = peekModel(uriString); - if (!model) return false; - try { - const applied = applyEntryOps(model, [plan.op]); - const op = applied[0]; - // PRECONDITION (untested): applyEntryOps answers a `replace` op with a `replace`, one - // per op. Checked rather than asserted so a shape change falls back to the full repaint - // instead of painting rows for an entry it did not resolve. - if (!op || op.kind !== 'replace') return false; - void postEntryRows(plan.entryRowId, op.entry); - return true; - } catch { - // The model may now be half-changed, so the caller's full repaint is the repair: it - // re-parses the text, which is the truth in this format. - invalidate(uriString); - leaveStep(); - return false; - } - }; - - /** - * Repaint an UNDO or a REDO of one of this host's own STRUCTURAL edits — a delete, a paste, - * a drop, a move. - * - * The sibling below recovers a cell edit's undo by re-parsing the bytes that came back, - * because those bytes are one element and an element says everything about the entry it - * names. These edits write bytes that say nothing on their own: a deleted element and the - * comma that held it, an insertion the model has no name for yet, a move spanning two places - * at once. So what they MEANT is remembered with them as model ops, and either direction is - * recognised byte-for-byte at its exact offset (planKnownOps). - * - * Applying those ops is what makes the undo cost the edit: the entry comes back from the - * record the delete kept, rather than out of a fresh parse of 47.8 MB, and only the rows it - * occupies are repainted. - * - * Same two state preconditions as every narrow path — rows that match the text, and a tree - * still registered to apply the ops to. A refusal costs the wide repaint, which is what - * every structural undo used to cost. - */ - const syncKnownOps = (e: vscode.TextDocumentChangeEvent): boolean => { - if (e.contentChanges.length !== 1 || knownEdits.length === 0) return false; - if (!modelInSync) return false; - const model = peekModel(uriString); - if (!model) return false; - const ops = planKnownOps(changesOf(e), knownEdits); - if (!ops || ops.length === 0) return false; - try { - repaintOps(applyEntryOps(model, ops)); - return true; - } catch { - // The model may now be half-changed, so the caller's full repaint is the repair: it - // re-parses the text, which is the truth in this format. - invalidate(uriString); - leaveStep(); - return false; - } - }; - - /** - * Repaint the entry an UNDO or a REDO of one of this host's own edits touched. - * - * The last leg of the round trip. A table edit paints from the model at once and its echo - * is recognised (syncOwnEdit) — but pressing Cmd+Z on that same edit arrived as a - * stranger's change and paid the full recovery below, ~175 ms of reading and walking 47.8 MB - * to find an element the host wrote itself. Now the pair it wrote is remembered - * (knownEdits) and either direction of it is matched byte-for-byte at its exact offset, so - * the changed element is the text already in hand. - * - * The row id comes from the entry as the MODEL now holds it, read before the op detaches - * it: an undo and its edit are one document apart, so by the time this runs the rows on - * screen are the ones that edit painted — including the name a rename gave them, which is - * the id the undone rows have to be spliced over. - * - * Same two state preconditions as the paths either side of it, and a refusal costs the - * recovery path, which is what an undo used to cost always. - */ - const syncKnownChange = (e: vscode.TextDocumentChangeEvent): boolean => { - if (e.contentChanges.length !== 1 || knownEdits.length === 0) return false; - if (!modelInSync) return false; - const model = peekModel(uriString); - if (!model) return false; - try { - const plan = planKnownChange(model, changesOf(e), knownEdits); - if (!plan) return false; - const entryRowId = plan.entry.id; - const applied = applyEntryOps(model, [ - { kind: 'replace', rowId: plan.entry.id, record: plan.record, fromDocumentText: plan.fromDocumentText }, - ]); - const op = applied[0]; - // PRECONDITION (untested): applyEntryOps answers a `replace` op with a `replace`, one - // per op. Checked rather than asserted so a shape change falls back to the full repaint - // instead of painting rows for an entry it did not resolve. - if (!op || op.kind !== 'replace') return false; - // In THIS run, so the undone value is on screen ~1 ms after Cmd+Z — see - // postEntryRowsNow. - postEntryRowsNow(entryRowId, op.entry); - return true; - } catch { - // The model may now be half-changed, so the caller's full repaint is the repair: it - // re-parses the text, which is the truth in this format. - invalidate(uriString); - leaveStep(); - return false; - } - }; - - /** - * Sync ONE entry from the text, or say no. - * - * The narrow answer to "the text changed, and the host did not change it": find the entry - * the change is inside, rebuild that entry from its own JSON, and repaint its rows. What it - * replaces is a full re-parse + full row rebuild + full postMessage on every keystroke — - * ~1.3 s of host work and a ~120 MB payload on a 46 MB dictionary, against ~82 ms to - * locate the entry and 0.3 ms to rebuild it. - * - * Still the path for a table edit whose own event did not come back recognisable - * (syncOwnEdit ran first and refused). It needs no hint for that: the edit painted before it - * wrote, so the rows on screen carry the entry as the model now names it — a rename's new id - * included — and that is the entry the plan resolves. - * - * Returns false for anything it is not sure about, and the caller repaints the old way. - * See jsonEntrySync.ts for what "sure" means; the checks that live HERE are the ones - * about state rather than text: - * - one content change, because a batch's later offsets are stated against the text - * before the batch and so do not locate anything in the text after it; - * - rows that match the text (modelInSync) and a tree still registered for this - * document to apply the op to. - */ - const syncOneEntry = (e: vscode.TextDocumentChangeEvent): boolean => { - if (e.contentChanges.length !== 1) return false; - if (!modelInSync) return false; - const model = peekModel(uriString); - if (!model) return false; - try { - const change = e.contentChanges[0]; - const plan = planEntrySync(model, e.document.getText(), { - rangeOffset: change.rangeOffset, - text: change.text, - }); - if (!plan) return false; - const entryRowId = plan.entry.id; - const applied = applyEntryOps(model, [ - { kind: 'replace', rowId: plan.entry.id, record: plan.record, fromDocumentText: plan.fromDocumentText }, - ]); - const op = applied[0]; - // PRECONDITION (untested): applyEntryOps answers a `replace` op with a `replace`, - // one per op. Checked rather than asserted so a shape change here falls back to the - // full repaint instead of painting rows for an entry it did not resolve. - if (!op || op.kind !== 'replace') return false; - // In THIS run, not from a promise: an undo, a redo and a keystroke in the text view all - // arrive here, and the rows are already built — see postEntryRowsNow. - postEntryRowsNow(entryRowId, op.entry); - return true; - } catch { - // The model may now be half-changed, so the caller's full repaint is not just a - // fallback but the repair: it re-parses the text, which is the truth in this - // format, and re-registers the tree built from it. - invalidate(uriString); - leaveStep(); - return false; - } - }; - - /** - * The clipboard mark this view has already painted, and the narrow repaint that keeps it - * current. - * - * A cut or copy makes no document edit, yet the source row must gain or lose its affordance, - * so the hub asks every open table to repaint on every clipboard broadcast — and "repaint" - * used to mean the whole table here, which made a copy in ANY open .sldd cost this document a - * full re-parse and rebuild (~1.4 s on a 47.8 MB dictionary). Only the entries the two marks - * name can be affected: the ones the clipboard just took and the ones it held before. A - * broadcast that changes neither — the usual case, the mark belonging to another document — - * now costs nothing at all. - * - * An affected entry that is no longer in the model is skipped rather than repainted wide: its - * rows are already gone (a cut+paste moves the very entry that held the mark), so there is - * nothing left to un-dim. - * - * Same shape as the binary provider's repaintClipMark, deliberately — the affordance has to - * mean the same thing in the two .sldd formats. The one difference is the guard below, which - * this format needs and that one has no equivalent of: rows here can be older than the text - * (see modelInSync), and only a full repaint can fix that. - */ - let paintedMark: ClipMark | undefined; - const repaintClipMark = (): void => { - const next = clipMarkOfDoc(uriString); - // Every entry either mark names (clipMarkChange), or none when they are the same mark. - const affected = clipMarkChange(paintedMark, next); - if (!affected) return; - // The rows on screen are not what the text says, so painting two entries over them would - // leave the rest stale — this is the wide repaint every broadcast used to be. - if (!modelInSync) { - void post(); - return; - } - let model: any; - try { - model = liveModel(); - } catch { - // No tree to name the affected entries in — and a throw here would break the hub's - // broadcast to every OTHER view. The wide repaint is both the fallback and where the - // parse error gets reported; paintedMark is left alone because nothing was painted. - void post(); - return; - } - paintedMark = next; - const ops: AppliedOp[] = []; - const seen = new Set(); - for (const { section, name } of affected) { - const entry = findEntryByName(model, section, name); - if (!entry || seen.has(entry.id)) continue; - seen.add(entry.id); - ops.push({ kind: 'replace', entryRowId: entry.id, entry }); - } - if (ops.length > 0) repaintOps(ops); - }; - - // Track this webview (mapped to its repaint) so clipboard-state changes from - // any editor reach it — including a lazy cut, which only marks the clipboard - // and needs a repaint to show the source row's affordance. - registerWebview(webview, repaintClipMark); - // Register the keyboard's way into the Add gallery. The command (dataExplorer.addEntry) - // has no view of its own to act on, so each editable view says here how to open its - // gallery, and the hub picks the one whose panel is active. - registerAddGalleryView(webviewPanel, () => void webview.postMessage({ type: 'openAddGallery' })); - // Register how to delete named entries from THIS document, so a cross- - // document move whose SOURCE is this .sldd can complete its source-delete - // via a format-appropriate edit (here: a full-text WorkspaceEdit). - registerSourceDeleter(uriString, (targets) => deleteFromSourceDocument(uriString, targets)); - - /** - * Write ONE region of the document. Feeds VS Code's native undo stack (so undo/redo and - * the dirty flag are automatic) and fires onDidChangeTextDocument, which repaints the - * table and any open text view. - * - * Every write in this provider is byte-scoped, because a WorkspaceEdit is stored as it was - * handed over: a full-document replace makes VS Code rewrite all 47.8 MB of a real - * dictionary to say a 1 KB thing, and keeps that rewrite as the undo step, so undoing it - * costs the same again. The region comes from the transform that knows it (structuralEdit's - * TextPatch) or, for the folded multi-step writes that cannot name one, from comparing the - * two texts (minimalReplacement). - * - * Throws when the document refuses the write. VS Code refuses a WorkspaceEdit whose document - * moved under it, and a refusal is a dropped edit — which, now that every structural path - * paints before it writes, would leave the table showing an edit the file never took. The - * callers' repair (undoPaint, or the wide repaint) is what makes that visible instead of - * silent. - */ - const writePatch = async (patch: TextPatch): Promise => { - const edit = new vscode.WorkspaceEdit(); - const range = new vscode.Range( - document.positionAt(patch.offset), - document.positionAt(patch.offset + patch.length), - ); - edit.replace(document.uri, range, patch.text); - const written = await vscode.workspace.applyEdit(edit); - if (!written) throw new Error('the document rejected the edit (it may have changed on disk)'); - }; - - // The one region to write to make the document read exactly `newText`, for the folded - // multi-step edits that cannot name theirs. Named rather than written here, because a caller - // that paints before it writes has to REMEMBER what it wrote (see paintedWrite). - const patchFor = (result: { newText: string; patch?: TextPatch }, ownText: string): TextPatch => - result.patch ?? minimalReplacement(ownText, result.newText); - - // The range replacement a patch amounts to, as a change event will report it back. - const submittedOf = (patch: TextPatch): RangeReplacement => ({ - rangeOffset: patch.offset, - rangeLength: patch.length, - text: patch.text, - }); - - // Guard against applying an edit while the text view holds invalid JSON - // (a mid-edit state). Returns true when the current text parses; otherwise - // posts an error banner and returns false so the caller can bail. - // - // A tree built from this text is already proof that it parses, so an in-step document is - // waved through instead of parsed a second time (~92 ms of the ~290 ms an edit used to - // spend before it changed anything). Out of step, the parse happens — which is exactly the - // case the guard exists for, since that is what a mid-edit text view looks like. - const ensureValidJson = (): boolean => { - if (modelInSync || parsesAsJson(document.getText())) return true; - webview.postMessage({ - type: 'error', - message: - "Can't apply edit — the document has invalid JSON (likely mid-edit in the text view). Fix the text, then retry.", + // This tab's table of the document, which says each of its repaints to this provider's hook. + // Closed again if anything below throws before the panel's dispose is wired (closingOnThrow), so + // that no engine is left holding this document for a later tab. + closingOnThrow(openJsonTable(document, name, () => this.onChanged?.(uriString)), ({ table, close }) => { + // Posted as this editor's post() posted a paint: the rows, then in the same run what follows + // them, then the Usage link's selection to drain. The webview's `ready` opens the table. + const panel = engineTable(table, webview, { + name, + awaitRows: false, + setRows: jsonRows, + afterPaint: () => drainNavigateSelect(webview, uriString), }); - return false; - }; - - /** - * Where an entry-scoped paint left its entry: the run the table now holds it under, the id - * the file still spells it by (the two differ by a rename), and the wide repaints begun when - * the paint went out. - */ - type PaintedRun = { rowId: string; fileRowId: string; epoch: number }; - - /** - * Take back a paint the text never confirmed, and say why. - * - * The price of painting before writing: for the ~200 ms between the two the screen is ahead - * of the file, and every way out of that window that is not a successful write has to put - * the file's answer back — or the user is left looking at an edit that did not happen. - * - * Re-reads the text (the truth in this format, and the repair for a tree that was mutated - * for a write that failed), repaints the entry from it, and posts the banner AFTER those - * rows: fresh rows are how the webview learns a failure is over, so a banner posted before - * them is one they wipe (see the `updateEntryRows` handler in table-main). - * - * Two ids, because a rename's paint re-keyed the run: the entry is found in the re-read tree - * by the id the FILE still spells, and its rows go over the run the PAINT left. Spliced over - * the old id, the repair missed and the table asked for the whole payload to recover. And - * neither, once a wide repaint has begun since the paint: it read the text the write never - * reached and built a tree of its own, so its rows are already the file's answer. - */ - const undoPaint = (painted: PaintedRun, message: string): void => { - if (painted.epoch !== wideEpoch) { - void latestPost.then(() => postBanner(message)); - return; - } - rebuildModel(); - const fresh = findNode(uriString, painted.fileRowId); - if (fresh) { - void postEntryRows(painted.rowId, fresh).then(() => postBanner(message)); - } else { - // The re-read cannot find what was painted — pathological (the write that failed is the - // reason the entry is still spelled the way the rows say), and the one case where - // correcting the screen is worth the ~700 ms a full repaint costs on a big dictionary. - void repaintStale().then(() => postBanner(message)); - } - }; - // --- Value edit / rename (byte-scoped entry-span splice) -------------------- - // - // The edit path, and the one place in this provider where nothing is re-discovered: it - // changes the tree it is already holding, paints the entry it changed, and then writes it. - // What it used to do first — parse the document to check it is valid, then parse it again to - // rebuild a model that was already right — cost ~282 ms of the ~290 ms an edit spent on a - // 47.8 MB dictionary before anything changed. - // - // The ORDER of what is left is the point, and it is cheapest-first on purpose: - // - // mutate the model ~0.2 ms - // PAINT ~0.3 ms posted synchronously, so the table has the answer - // ~1 ms after the keystroke — see postEntryRowsNow - // locate the entry's text ~100 ms a full structural walk of 47.8 MB (findEntrySpan) - // write the text ~90–200 ms VS Code's cost for a 1.2 KB splice into a 1.6 M-line - // document, plus the change event it fires later still - // - // Locating first would read better — refuse before touching anything — but it puts the whole - // ~100 ms scan ahead of the paint and hands back none of the win, which is the entire reason - // the order is this way round. The cost of paying it in this order is that everything after - // the paint can still fail, so both exits below (no span, anything thrown) call undoPaint. - // - // The paint used to be last, on that change event: the host wrote the text, waited for VS - // Code to hand it back, and rebuilt the entry from the bytes (syncOwnEdit). Correct, and a - // quarter of a second of waiting for an answer the model had all along — which is why a - // binary dictionary, whose provider paints from the mutated node and touches no - // TextDocument at all, felt immediate on the same file. The echo repaint still runs and is - // still what the file says; it now CONFIRMS a paint rather than being it. The two agree - // cell for cell — swept over every editable cell of three dictionaries in - // jsonHostEditRepaint.test.ts, which is what makes painting early honest rather than - // optimistic. - const applyEdit = async (msg: { - rowId: string; - columnId: string; - oldValue: string; - newValue: string; - }): Promise => { - // The entry this edit has already changed in the model and shown the user, if it got that - // far — what a failure has to take back. Set the moment the mutation succeeds, so it reads - // as "the screen and the tree may be ahead of the file", which is the exact window - // undoPaint exists to close. Null before that: nothing to take back. - let painted: PaintedRun | null = null; - try { - if (!ensureValidJson()) return; - const currentText = document.getText(); - - liveModel(); - const node = findNode(uriString, msg.rowId); - if (!node) { - webview.postMessage({ type: 'error', message: 'Could not locate the edited item in the model.' }); - return; - } - - // Owning top-level entry; pre-edit name for span lookup (rename mutates it). - const entry = findOwningEntry(node); - if (!entry) { - webview.postMessage({ type: 'error', message: 'Could not locate the owning entry in the model.' }); - return; - } - // Snapshot the entry's selector BEFORE the mutation: a rename changes - // `name`, and the span lookup has to find the entry as the text still - // spells it. The uuid half is rename-stable, and is what keeps the lookup - // off a same-named entry in another namespace (see core's entrySelector.ts). - const entrySelectorForLookup = entrySelectorOf(entry); - const isRename = msg.columnId === 'Name'; - // And its row id, for the same reason one step further on: the rows on screen - // carry the entry as the table last painted it, so the paint this edit makes - // has to splice over THAT id, not the one a rename is about to mint. Everything - // after the paint addresses the id the paint left (`painted.rowId`). - const entryRowIdOnScreen = entry.id; - // And the rename to carry into the System Composer catalog, for the same - // before-the-mutation reason: the catalog names the entry as the document still - // does. Null for all but a rename of a catalogued architectural entry. - const catalogRename = catalogRenameOf(msg.columnId, msg.newValue, node, entry); - // And a service interface's function, which the catalog keeps a copy of: read here for - // the same reason, since the catalog spells the function as the document still does. - // Never non-null together with catalogRename (that one is the entry, this one a row - // under it). - const functionRename = catalogFunctionRenameOf(msg.columnId, msg.newValue, node, entry); - - // Mutated through entryOps so the session's node index follows a rename: an id is a - // PATH, so renaming an entry rekeys it and everything under it. The wide re-parse used - // to repair that as a side effect — and the repaint's replace op, which detaches this - // very subtree, unindexes it by the ids the mutation leaves behind. - const { result, unchanged } = cellEdit(entry, node, msg.columnId, msg.newValue); - if (result && typeof result === 'object' && result.error) { - // Invalid cell input: show the dex error dialog inside the webview - // (scoped to the table view, not a window-blocking native modal), - // then repaint so the cell reverts from the rejected text back to - // its previous value. A refused setProperty changes nothing, so the - // entry's own rows are all that need repainting — and they still say - // what the text says. - webview.postMessage({ - type: 'validationError', - // Named as the session's editProperty names one, which the MAT-file editor shows: - // `Cannot edit "": .` (core's editRefusalText). - reason: describe(editRefusalText(node, msg.columnId, result.reason)), - invalidValue: msg.newValue, - previousValue: msg.oldValue, - }); - void postEntryRows(entryRowIdOnScreen, entry); - return; - } - // The cell's own text set back (cellEdit): nothing changed, so nothing is painted or written. - if (unchanged) return; - - // PAINT — synchronously, and before anything expensive: the model now says what the user - // typed, and the renderer has it ~1 ms after the keystroke. Everything below this line - // (~100 ms of span scan, ~90–200 ms of VS Code writing the text, and the change event it - // fires later still) happens with the answer already on screen. - // - // From here to the write the screen is ahead of the file, so every way out of that - // window that is not a write has to take the paint back (undoPaint). - // The rows the paint posts carry the entry as the mutation left it, so from here on the - // table holds it under `entry.id` — the new id, after a rename — while the file still - // spells it the old way. - painted = { rowId: entry.id, fileRowId: entryRowIdOnScreen, epoch: wideEpoch }; - postEntryRowsNow(entryRowIdOnScreen, entry); - // After a rename node.id reflects the new name, and the rows just posted carry it — - // so the row to re-select exists by the time this arrives. - if (isRename) webview.postMessage({ type: 'selectRows', rowIds: [node.id] }); - - // --- and only now the expensive half: where in 47.8 MB of text that entry lives ------ - const indent = detectIndent(currentText); - const entryText = reserializeEntry(entry, indent); - const span = findEntrySpan(currentText, entrySelectorForLookup); - if (!span) { - // The tree has been mutated and painted, and the text cannot be — so the two now - // disagree, and the text is the truth here. - undoPaint(painted, 'Could not locate the entry text to update.'); - return; - } - // The System Composer interface dictionary is a part of THIS document, and it lists - // the entry BY NAME — so a rename that changes only the entry leaves the file - // saying two different things: the entry is no longer classified (a struct type - // re-reads as a plain data interface) and the catalog defines an interface no entry - // backs. Empty for every rename no definition carries, which is nearly all of them. - // A function of a service interface is spelled in the catalog too (its `p_Name`, and - // the `p_FunctionPrototype` that declares it), so its rename carries the same way. - const scEdits = catalogRename - ? scJsonRenameEdits(currentText, catalogRename.oldName, catalogRename.newName) - : functionRename - ? scJsonFunctionRenameEdits(currentText, functionRename) - : []; - - // Byte-scoped range replace: only the edited entry's span changes, so - // sibling entries stay byte-identical. Native undo groups this edit. - const startPos = document.positionAt(span.offset); - const endPos = document.positionAt(span.offset + span.length); - const edit = new vscode.WorkspaceEdit(); - edit.replace(document.uri, new vscode.Range(startPos, endPos), entryText); - // In the SAME WorkspaceEdit, so the two halves of one rename are one undo step and - // cannot be half-applied. Every range is addressed against the pre-edit text, which - // is what both spans were found in, and the catalog part cannot overlap the entries - // array either span was scanned from. - scEdits.forEach((scEdit) => { - edit.replace( - document.uri, - new vscode.Range(document.positionAt(scEdit.start), document.positionAt(scEdit.end)), - scEdit.text, - ); - }); - // Set last, immediately before the edit, so the change event this edit fires is - // the one that spends it (see hostEdit). The entry id is read AFTER the mutation - // (a rename has already moved it), which is also the id the paint left the rows - // under — so the echo's repaint lands on the run the table is holding. - // - // No hint when the catalog rode along: the event will report several changes, which - // is not an echo of one entry span and not a change any narrow path can plan, so it - // takes the wide repaint. Said here rather than discovered there — a rename that - // reclassifies an entry is exactly the case whose rows have to come from a re-read. - const submitted = { rangeOffset: span.offset, rangeLength: span.length, text: entryText }; - hostEdit = - scEdits.length > 0 - ? null - : { entryId: entry.id, submitted, epoch: wideEpoch }; - // Awaited but no longer waited ON: the table was painted before this line and the echo - // this fires only confirms it (syncOwnEdit). What the await is still for is the answer — - // VS Code refuses a WorkspaceEdit whose document moved under it, and a refusal is a - // dropped edit, which the tree, the rows and the file would otherwise disagree about in - // silence. Thrown so the one repair below covers it too. - const written = await vscode.workspace.applyEdit(edit); - if (!written) throw new Error('the document rejected the edit (it may have changed on disk)'); - // Remembered only now that the document really holds it, and only for the single-span - // shape: an undo of this edit will write `replaced` back over `entryText` at the same - // offset, which is a change the host can recognise instead of going looking for - // (syncKnownChange). A rename that carried the catalog with it is several ranges, whose - // undo reports several changes and is planned by nothing narrow. - if (scEdits.length === 0) { - remember({ - submitted, - replaced: currentText.slice(span.offset, span.offset + span.length), + // --- Location in Text (reveal the row's entry in the plain-text view) ------- + // The engine resolves the right-clicked row to its owning top-level entry and that entry's + // `{...}` span in the live JSON, and says in the table why it cannot; this opens the native + // text editor and reveals the span. Nested children (struct fields, bus elements) have no + // standalone JSON span, so they fall back to their owning entry's object. + const applyLocateInText = async (msg: { rowId: string }): Promise => { + const span = table.locateInText(msg.rowId); + if (!span) return; + try { + // showTextDocument opens the native text editor (same view as + // "View as Text") beside the table and selects the entry's span, + // scrolling it into view. A second tab for the same URI is expected — + // table and text coexist (see the viewAsText note in extension.ts). + await vscode.window.showTextDocument(document, { + selection: new vscode.Range(document.positionAt(span.offset), document.positionAt(span.offset + span.length)), + viewColumn: vscode.ViewColumn.Beside, + preview: false, }); + } catch (err) { + webview.postMessage({ type: 'error', message: `Failed to locate in text: ${describe((err as Error).message)}` }); } - } catch (err) { - hostEdit = null; - const message = failedEdit(err); - if (painted) { - // Whatever threw may have left the tree saying something the document does not — and, - // since it threw after the paint, the screen too. - undoPaint(painted, message); - } else { - // Nothing was painted and nothing was mutated, so the rows on screen are the ones the - // last repaint left and they still say what the file says. The banner is the whole - // answer. - webview.postMessage({ type: 'error', message }); - } - } - }; - - // --- Copy (read-only; snapshots the entry into the host clipboard) ---------- - // Shared with the binary provider, which is what makes a failed copy report - // the same way in both formats (it used to be silent here). - // - // From the tree this view already holds when it is in step with the text (liveModel), as - // the binary provider's copy is: a copy changes no text, so re-parsing could only rebuild - // the tree that is there — ~190 ms of a 47.8 MB dictionary per Ctrl+C. Out of step, it is - // the re-parse it always was, and a text that does not parse is reported as before. - const applyCopy = (rowIds: readonly string[], mode: 'cut' | 'copy'): void => { - copyEntriesToClipboard(rowIds, mode, uriString, { - refresh: () => { - liveModel(); - }, - findNode: (rowId) => findNode(uriString, rowId), - post: (message) => webview.postMessage({ ...message, message: describe(message.message) }), - broadcast: broadcastClipboardState, - }); - }; - - // --- Structural edits (delete / add-child / paste / drop) ------------------- - // - // Every one of them now has the shape the value edit above has — change the model, PAINT, - // then write the text — and for the same reason: the model can answer at once, and everything - // after the paint (locating the text, VS Code writing it, the change event it fires) happens - // with the answer already on screen. What used to happen instead was that the write's change - // event triggered the WIDE repaint: re-parse the document, diff every entry for its Modified - // mark, rebuild every row, post the lot — ~1.45 s and ~120 MB on a 47.8 MB dictionary, to add - // one row or take one away. - // - // What each edit changed is ALSO stated as entry-level ops, kept beside the bytes it wrote, so - // its undo can be applied rather than discovered (syncKnownOps). Both directions therefore - // cost what the edit cost. - // - // They are three functions rather than one because they differ in who is ahead of whom: - // a nested child added or deleted — the transform mutates the model and reserializes the - // owning entry's own span, so as far as the text is concerned it IS a value edit, and it - // rides the same hostEdit/knownEdits rules; - // a top-level ENTRY deleted — the transform is text-only (it needs the entry still attached - // to work out where the selection lands), so the model change is an op applied beside it; - // paste and drop — the transform attaches its new nodes itself (prepareEntryForPaste must, - // to ask the section for a unique name), so the model is already ahead and what is owed is - // the session index and the ops (opsOfPastedEntries). - // - // A structural paint is the one repaint that cannot be taken back row by row — an entry may - // have left the table or arrived in it — so a write that fails resyncs the whole view instead. - // That is the old cost, paid only when an edit did not happen. - const resyncWide = (message: string): void => { - void repaintStale().then(() => postBanner(message)); - }; - - /** - * A paste or drop refused before it painted: put the model back, and say why. - * - * Nothing was painted and nothing was written, so the rows on screen still say what the text - * says. The MODEL may not: prepareEntryForPaste attaches each new entry to its section before - * the entry is serialized, and a same-document move has already taken its sources out - * (moveRemovals). So a refusal after that point — a derived section's Constant rule, or a - * value core will not write (a MatWriteError) — leaves a tree the text never said. Held as it - * is, the next paste names its copy past an entry the file never got (`Bus2` where `Bus1` was - * owed), and an edit of a cut entry cannot find it. The text is the truth in this format, so - * the tree is re-read from it; the rows need nothing. - */ - const refusedStructural = (message: string): void => { - rebuildModel(); - webview.postMessage({ type: 'error', message }); - }; - - /** - * The prologue every structural edit shares: the mid-edit JSON gate, a tree that matches the - * text, and the node the row names — resolved ONCE, because which shape a delete takes - * depends on what the row turned out to be. - */ - const onStructuralTarget = async (rowId: string, run: (node: any) => Promise): Promise => { - let node: any; - try { - if (!ensureValidJson()) return; - liveModel(); - node = findNode(uriString, rowId); - } catch (err) { - webview.postMessage({ type: 'error', message: failedEdit(err) }); - return; - } - if (!node) { - webview.postMessage({ type: 'error', message: 'Could not locate the item in the model.' }); - return; - } - await run(node); - }; - - /** - * A structural edit WITHIN one entry: add a nested child, delete one. - * - * Entry-scoped in both halves — the transform reserializes the owning entry's span, and the - * repaint replaces that entry's run of rows — so nothing else in the table moves and nothing - * else in the text changes. Which also means the bytes it writes are an entry span replaced by - * an entry span, exactly what a cell edit writes: its echo is confirmed and its undo planned - * by the paths that already read an element back out of the bytes (syncOwnEdit, - * syncKnownChange), with no ops to remember. - */ - const applyEntryStructural = async ( - node: any, - transform: (text: string, node: any) => StructuralResult, - ): Promise => { - // What a failure has to take back, set the moment the screen is ahead of the file. See - // applyEdit, whose window this is the same one. - let painted: PaintedRun | null = null; - try { - const currentText = document.getText(); - const entry = findOwningEntry(node); - if (!entry) { - webview.postMessage({ type: 'error', message: 'Could not locate the owning entry in the model.' }); - return; - } - // A nested child changes nothing about the entry's own name, so the id the rows carry and - // the id the model spells are one id. - const entryRowId = entry.id; - - // Through entryOps for the reason applyEdit is: the transform adds or removes a node - // inside this subtree, a node id is a PATH, and nothing re-indexes the ids beneath it now - // that the repaint no longer re-parses the document. - const result = mutateEntry(entry, () => transform(currentText, node)); - - // PAINT — in this run, before the ~100 ms span scan the transform already did and the - // write below. - painted = { rowId: entryRowId, fileRowId: entryRowId, epoch: wideEpoch }; - postEntryRowsNow(entryRowId, entry); - if (result.selectId) webview.postMessage({ type: 'selectRows', rowIds: [result.selectId] }); - - const patch = patchFor(result, currentText); - const submitted = submittedOf(patch); - // Set immediately before the write, so the event this write fires is the one that spends - // it (see hostEdit). - hostEdit = { entryId: entry.id, submitted, epoch: wideEpoch }; - await writePatch(patch); - remember({ submitted, replaced: currentText.slice(patch.offset, patch.offset + patch.length) }); - } catch (err) { - hostEdit = null; - const message = failedEdit(err); - // The tree was mutated and the rows painted for a write that did not happen, and the text - // is the truth in this format. - if (painted) undoPaint(painted, message); - else webview.postMessage({ type: 'error', message }); - } - }; - - /** - * Delete a top-level ENTRY: its element leaves the array, its run of rows leaves the table. - * - * The one shape whose transform touches no model at all, because it needs the entry where it - * stands: which element to cut is the entry's selector, and where the selection lands is its - * neighbour's row. So the model change is an op applied beside it — and captured BEFORE it, - * while the entry still has a section and a position for an undo to put it back in. - */ - const applyDeleteEntry = async (entry: any): Promise => { - let painted = false; - try { - const currentText = document.getText(); - const model = peekModel(uriString); - if (!model) throw new Error('the document has no model to edit'); - const entryRowId = entry.id; - const pairs: EntryOpPair[] = [{ redo: removeOp(entryRowId), undo: insertOp(entry) }]; - - const result = deleteEntry(currentText, entry); - // Model, then paint: the removal takes the entry out of the tree and its rows out of the - // table, and the row id is all either of them needs. - const applied = applyEntryOps(model, [pairs[0].redo]); - painted = true; - repaintOps(applied); - if (result.selectId) webview.postMessage({ type: 'selectRows', rowIds: [result.selectId] }); - - const patch = patchFor(result, currentText); - const submitted = submittedOf(patch); - // The rows are already on screen, and the bytes this writes — an element and the comma - // that held it, gone — are not an element anything can read back. Both facts are what the - // change event needs to be told (paintedWrite), and what its undo needs (the ops). - paintedWrite = { submitted, epoch: wideEpoch }; - await writePatch(patch); - remember({ - submitted, - replaced: currentText.slice(patch.offset, patch.offset + patch.length), - patch: patchOfPairs(pairs), - }); - } catch (err) { - paintedWrite = null; - const message = failedEdit(err); - if (painted) resyncWide(message); - else webview.postMessage({ type: 'error', message }); - } - }; - - /** - * Delete SEVERAL rows as ONE edit. - * - * Delete is the row-granular action (see data-explorer-view's src/model/actions/operands.ts): its operands can be a - * mix of whole entries and nested children, across sections, and all of it has to land - * in a single write. This format's undo stack is VS Code's own, and it steps by WRITE — - * so two writes would mean Cmd+Z taking back part of one gesture and leaving the rest. - * - * READ THE MODEL FIRST. Every removal takes a node out of the tree, so the selectors the - * text splice needs, the row to select afterwards, and the insert ops an undo needs are - * all read while the nodes are still attached. That ordering is the whole reason this is - * one function rather than a loop over applyDeleteEntry. - */ - const applyDeleteMany = async (rowIds: readonly string[]): Promise => { - let painted = false; - try { - if (!ensureValidJson()) return; - const model = liveModel(); - if (!model) throw new Error('the document has no model to edit'); - const currentText = document.getText(); - const plan = planDeletion(rowIds, (rowId) => findNode(uriString, rowId)); - if (!plan.entries.length && !plan.childGroups.length) { - webview.postMessage({ type: 'error', message: 'Could not locate the items in the model.' }); - return; - } - // Asked of every row the plan removes, before any leaves (refusal.ts). - const refused = deleteRefusal([...plan.entries, ...plan.childGroups.flatMap((g) => g.children)].map((n) => n.id)); - if (refused) { - postRefusal('delete', refused); - return; - } - - // Read off the live tree, before anything leaves it. - const selectors = plan.entries.map((entry) => entrySelectorOf(entry)); - // Where the selection lands: the last entry removal's neighbour — and a neighbour - // that is ITSELF being deleted is not a place to leave the selection, so the doomed - // siblings come out of the list first. `.filter` preserves order, so the surviving - // neighbour it picks is the one the user will actually see. When the plan removes no - // whole entry, the child groups answer instead (childSelectId, below). - const doomed = new Set(plan.entries); - const lastEntry = plan.entries[plan.entries.length - 1]; - const selectId = lastEntry - ? reselectAfterRemoval( - ((lastEntry.parent?.children ?? []) as any[]).filter((e) => e === lastEntry || !doomed.has(e)), - lastEntry, - buildSectionRowId(lastEntry.parent?.name ?? ''), - ) - : null; - - // MODEL, the whole entries first: removeEntries refuses (a record that will not - // serialize, an entry the index has lost) before it removes anything, so going first - // is what keeps a refusal from leaving the child groups below half-done. Its pairs are - // captured interleaved with the removals, which is what puts an undo back in order. - const entryRemoval = removeEntries(model, plan.entries); - // What gets REMEMBERED is these plus one pair per child group (below). - const pairs: EntryOpPair[] = [...entryRemoval.pairs]; - - // TEXT — children first, each group one splice; then the whole entries, by selector. - let working = currentText; - const childApplied: AppliedOp[] = []; - let childSelectId: string | null = null; - for (const group of plan.childGroups) { - // A child leaving rewrites its entry, so the entry's own record is what either - // direction restores — and the undo side is the entry WITH the child, which only - // exists now. Remembered as an op like everything else, because an undo of this - // edit is answered by planKnownOps and nothing after it looks at the bytes: the - // field would come back in the text and stay missing from the model and the table. - const undo = replaceOp(group.entry, group.entry.id); - const result = mutateEntry(group.entry, () => deleteChildren(working, group.children)); - working = result.newText; - childSelectId = result.selectId ?? childSelectId; - pairs.push({ redo: replaceOp(group.entry, group.entry.id), undo }); - childApplied.push({ kind: 'replace', entryRowId: group.entry.id, entry: group.entry }); - } - working = deleteEntriesByName(working, selectors); - - // PAINT — in this run, before the write. - const applied = [...childApplied, ...entryRemoval.applied]; - painted = true; - repaintOps(applied); - const landing = selectId ?? childSelectId; - if (landing) webview.postMessage({ type: 'selectRows', rowIds: [landing] }); - - const patch = minimalReplacement(currentText, working); - const submitted = submittedOf(patch); - paintedWrite = { submitted, epoch: wideEpoch }; - await writePatch(patch); - remember({ - submitted, - replaced: currentText.slice(patch.offset, patch.offset + patch.length), - patch: patchOfPairs(pairs), - }); - } catch (err) { - paintedWrite = null; - const message = failedEdit(err); - // Unpainted, the rows still say what the text says, but the tree may not: the entries - // leave it first, then each child group, and a group that throws leaves the ones before - // it done. The text is the truth in this format, so the tree is re-read from it. - if (painted) resyncWide(message); - else refusedStructural(message); - } - }; - - const applyDelete = (msg: { rowIds: string[] }): Promise => { - const rowIds = msg.rowIds; - // One row keeps the narrow paths it already had: an entry's element cut out of the - // array, or one child spliced into its entry. Both write one region the transform - // NAMED, which is cheaper and better-tested than a whole-text diff. - if (rowIds.length === 1) { - return onStructuralTarget(rowIds[0], async (node) => { - // Core's reason, asked before anything changes (refusal.ts). - const refused = deleteRefusal([node.id]); - if (refused) postRefusal('delete', refused); - else await (node.isEntry ? applyDeleteEntry(node) : applyEntryStructural(node, deleteChild)); - }); - } - return applyDeleteMany(rowIds); - }; - - const applyAddChild = (msg: { rowId: string }): Promise => - onStructuralTarget(msg.rowId, async (node) => { - const refused = addChildRefusal(node.id); - if (refused) postRefusal('addChild', refused); - else await applyEntryStructural(node, addChildEdit); - }); - - // Cut is LAZY: it only marks the entry on the clipboard (in cut mode) and - // makes no text edit yet. The source is removed at PASTE time — so a - // same-document move becomes a single combined WorkspaceEdit (one undo step) - // and the cut source row can show its dimmed affordance until pasted. This - // mirrors data explorer's ClipboardService, whose cut() marks only. - const applyCut = (rowIds: readonly string[]): void => applyCopy(rowIds, 'cut'); - - // --- Location in Text (reveal the row's entry in the plain-text view) ------- - // Resolve the right-clicked row to its owning top-level entry, locate that - // entry's `{...}` span in the live JSON, then open the native text editor and - // reveal the span. Nested children (struct fields, bus elements) have no - // standalone JSON span, so we fall back to their owning entry's object — the - // same entry-scoped granularity every other structural operation uses. - const applyLocateInText = async (msg: { rowId: string }): Promise => { - try { - const currentText = document.getText(); - invalidate(uriString); - getModel(uriString, name, currentText); - const node = findNode(uriString, msg.rowId); - if (!node) { - webview.postMessage({ type: 'error', message: 'Could not locate the item in the model.' }); - return; - } - const entry = findOwningEntry(node); - if (!entry) { - webview.postMessage({ type: 'error', message: 'Could not locate the owning entry in the model.' }); - return; - } - const span = findEntrySpan(currentText, entrySelectorOf(entry)); - if (!span) { - webview.postMessage({ type: 'error', message: `Could not locate "${entry.name}" in the text.` }); - return; - } - const startPos = document.positionAt(span.offset); - const endPos = document.positionAt(span.offset + span.length); - // showTextDocument opens the native text editor (same view as - // "View as Text") beside the table and selects the entry's span, - // scrolling it into view. A second tab for the same URI is expected — - // table and text coexist (see the viewAsText note in extension.ts). - await vscode.window.showTextDocument(document, { - selection: new vscode.Range(startPos, endPos), - viewColumn: vscode.ViewColumn.Beside, - preview: false, - }); - } catch (err) { - webview.postMessage({ type: 'error', message: `Failed to locate in text: ${describe((err as Error).message)}` }); - } - }; - - /** - * A same-document move's REMOVALS, stated as ops and applied to the live model — or nothing, - * which means this move cannot take the narrow path at all. - * - * Nothing is the honest answer for two different reasons, and the caller owes the same thing - * for both: re-read the model from the trimmed text, as this path always used to. The model it - * is holding would otherwise keep an entry the text has just lost — and that is not only a - * stale row, it is a WRONG PASTE: `prepareEntryForPaste` asks the section for a unique name, - * so a source still standing there pushes the copy to "PI1" and the move renames what it - * moved. - * - * Narrow for ANY number of sources, in an order an undo can replay — entryOps' removalPairs, - * the one removal every path in both providers shares. - */ - const moveRemovals = (model: any, targets: readonly EntrySelector[]): Removal | null => { - try { - return removalPairs(model, targets); - } catch { - // Nothing was removed (removalPairs refuses before it mutates), and the caller's re-read - // answers this the same way as a source it could not find. - return null; - } - }; - - // --- Paste and drop: one transfer, two registers ------------------------------- - // - // What a paste or a drop MEANS is transfer.ts's, shared with the binary editor: which - // register and when it is let go of, the target resolved before anything changes, the - // same-section no-op, a same-document move as ONE edit that removes the sources first so the - // pasted copies keep their names, a cross-document move as the paste here and then the - // delete in the source's own format (editorHub), and what each way of failing says. What is - // left here is what an edit IS in this format: the mid-edit JSON gate, the model changed and - // PAINTED before the WorkspaceEdit is written, the write told it is already on screen - // (paintedWrite), and the ops remembered beside it so its undo can be applied rather than - // discovered (remember). - // - // The target comes from the row that was clicked or dropped on — which may be a section - // HEADER (`section:`), the only target an empty section has. findNode cannot resolve a - // header id, which is why these paths resolve their own section rather than going through - // onStructuralTarget, whose node lookup would bail. - // - // A fresh adapter per transfer, because onFailure has to know how far this one got: a - // structural paint cannot be taken back row by row — entries arrived, and a move's sources - // left — so a write that fails after it resyncs the whole view, while a refusal before it - // has only the tree to put back (refusedStructural). - const transferAdapter = (): TransferAdapter => { - let painted = false; - return { - docUri: uriString, - guard: ensureValidJson, - resolveTarget: (rowId) => sectionTarget(liveModel(), findNode(uriString, rowId), rowId), - validate: (target, payloads) => refuseDisallowedPaste(target.section, payloads), - commit: async ({ target, payloads, removeFirst }) => { - const docText = document.getText(); - let section = target.section; - // What the edit amounts to in the model, and what it leaves to repaint: the removals a - // move makes, then the entries the paste attaches. - const pairs: EntryOpPair[] = []; - const applied: AppliedOp[] = []; - let narrow = true; - - // A same-document move removes the sources first (in the same text) so the paste keeps - // their names, which makes the whole move ONE WorkspaceEdit = one undo step. And with it - // goes the paste's ability to name the region it changes: its patch is stated against - // this trimmed text, not against the document, so only the two texts together say what - // to write. - let workingText = docText; - let againstDocument = true; - if (removeFirst.length > 0) { - workingText = deleteEntriesByName(docText, removeFirst); - againstDocument = false; - const removals = moveRemovals(target.model, removeFirst); - if (removals) { - pairs.push(...removals.pairs); - applied.push(...removals.applied); - } else { - // See moveRemovals: the model has to be re-read from the trimmed text, or the paste - // renames the entries it is moving. The section is found again in that tree by NAME - // — the row may have been one of the entries just removed — and a tree without it - // is a failure the repair below answers, never a paste into the old tree's section. - narrow = false; - leaveStep(); - invalidate(uriString); - section = sectionByName(getModel(uriString, name, workingText), target.sectionName); - if (!section) throw new Error('Could not resolve the target section.'); - } - } - - // Read AFTER any removal, because a move within one section shortens the very array the - // paste is about to append to. Everything from here on is what the paste added. - const addedFrom = (section.children as any[]).length; - const pasted = pasteEntries(workingText, section, payloads); - if (narrow) { - const added = opsOfPastedEntries(section, addedFrom); - pairs.push(...added.pairs); - applied.push(...added.applied); - // PAINT — the model already holds the pasted entries (prepareEntryForPaste attached - // them), so the rows go out now, ahead of the write. - painted = true; - repaintOps(applied); - } else { - // The tree registered above describes the trimmed text, not the document: the change - // event this write fires has to repaint the old way, from a re-parse. - leaveStep(); - } - if (pasted.selectIds.length) webview.postMessage({ type: 'selectRows', rowIds: pasted.selectIds }); - - const patch = againstDocument ? patchFor(pasted, docText) : minimalReplacement(docText, pasted.newText); - const submitted = submittedOf(patch); - if (narrow) paintedWrite = { submitted, epoch: wideEpoch }; - await writePatch(patch); - if (narrow) { - remember({ - submitted, - replaced: docText.slice(patch.offset, patch.offset + patch.length), - patch: patchOfPairs(pairs), - }); - } - }, - onFailure: (err) => { - paintedWrite = null; - const message = failedEdit(err); - // Rows for entries that arrived or left cannot be put back one run at a time — the wide - // repaint is the repair, and it is only ever reached by an edit that did not happen. - if (painted) resyncWide(message); - else refusedStructural(message); - }, - report: (message) => void webview.postMessage({ type: 'error', message: describe(message) }), }; - }; - // A move that left its original behind is said where it stays, not only in the banner a row - // update clears: see TransferDeps.warn. - const transferDeps: TransferDeps = { - deleteFromSource, - warn: (message) => void vscode.window.showWarningMessage(message), - }; - - const applyPaste = (msg: { rowId: string }) => - runTransfer({ rowId: msg.rowId }, clipboardRegister(broadcastClipboardState), transferAdapter(), transferDeps); - /** - * Create ONE new default entry from the Add gallery. - * - * A paste with no source: the same insert into the entries array, the same ops for the - * undo, the same narrow repaint — only what the new entry IS differs, and that is - * `createEntry`, i.e. core's `addEntry` (see structuralEdit). The section comes from the - * message rather than from a row, because a tile states its own destination. - * - * Always narrow. The paste's wide fallback exists for a same-document MOVE whose sources - * the model can no longer find; an add removes nothing, so there is no second text and - * nothing to re-resolve. - * - * Call it through `queueStructural`, never directly — see the note there. - */ - const applyAddEntry = async (msg: { - section: string; - className: string; - rename: boolean; - }): Promise => { - let painted = false; - try { - if (!ensureValidJson()) return; - const model = liveModel(); - const section = sectionByName(model, msg.section); - if (!section) { - webview.postMessage({ type: 'error', message: 'Could not resolve the target section.' }); - return; - } - // Core's reason, asked before anything changes (refusal.ts). - const refused = addEntryRefusal(section.id, msg.className); - if (refused) { - postRefusal('add', refused); - return; - } - const currentText = document.getText(); - // Read BEFORE the add, for the reason the paste reads it after its removals: this is - // the mark the new tail is measured from. - const addedFrom = (section.children as any[]).length; - const result = addNewEntry(currentText, section, msg.className); - // The entry is already attached to the section (addEntry did it), so this both - // indexes the new subtree and states the insert an undo has to take back. - const added = opsOfPastedEntries(section, addedFrom); - // PAINT — the rows go out ahead of the write, as every narrow edit here does. - painted = true; - repaintOps(added.applied); - if (result.selectId) { - webview.postMessage({ type: 'selectRows', rowIds: [result.selectId] }); - // AFTER the selection, which is what expands and scrolls the row into view. Only - // for an unpinned add: during a pinned run the user is clicking tiles, and an - // editor stealing focus between two of them is worse than an unnamed entry. - if (msg.rename) webview.postMessage({ type: 'beginRename', rowId: result.selectId }); - } - - const patch = patchFor(result, currentText); - const submitted = submittedOf(patch); - paintedWrite = { submitted, epoch: wideEpoch }; - await writePatch(patch); - remember({ - submitted, - replaced: currentText.slice(patch.offset, patch.offset + patch.length), - patch: patchOfPairs(added.pairs), - }); - } catch (err) { - paintedWrite = null; - const message = failedEdit(err); - // An entry may be in the tree and not in the file — `addEntry` attaches it before the - // splice can fail — so both arms have to put the text's answer back. Painted, the rows - // named an entry that does not exist and only a wide repaint can withdraw them; - // unpainted, the rows are still right and it is the tree the next edit would build on. - if (painted) resyncWide(message); - else { - rebuildModel(); - webview.postMessage({ type: 'error', message }); - } - } - }; - - /** - * Structural edits, one document state at a time. - * - * Every structural handler here reads `document.getText()` when it starts and applies its - * WorkspaceEdit at the end, with an await between the two — and with the paint, the - * paintedWrite flag and the remembered ops all keyed to that one write. Two handlers in - * flight at once break all of that: the second reads the text from before the first's - * write, computes its own offsets there, and writes over a document that has moved; the - * first's change event then spends the second's paintedWrite. The Add gallery's pin made - * that concrete first — it exists so the user can click three tiles in a second, and a - * second add entered inside the first's write computed the same insertion offset - * (`findEntriesArrayInsertion`) and left the FILE listing the two entries in the opposite - * order from the MODEL, the narrow-vs-wide divergence the rows reorder under on the next - * full repaint (`test/addEntryHost.test.ts` pins that hazard). A held-down Cmd+V is the same - * thing for a paste — the chord auto-repeats — and a Delete, an Add Child or a drop landing - * during a paste's write is the same race by hand. - * - * So every structural gesture waits for the write in front of it; none is dropped or - * coalesced. The `catch` keeps one failed edit from poisoning the chain and silently - * swallowing every edit after it — each handler reports its own failures, and the next one - * starts clean. A cell edit is not queued: it is one span, typed by hand, and its echo has - * its own one-shot (hostEdit). - * - * A drop reads its register when the message ARRIVES, not when its turn comes (applyDrop), - * because the source's dragEnd follows it at once and clears the register. A paste reads - * the clipboard when its turn comes, which is what makes the second of two queued pastes of - * one cut find the clipboard that cut left — empty — instead of pasting it again. - * - * The compressed-binary provider needs none of this: its edits are synchronous from - * `document.chunkXml` to `pushEdit`, so two messages cannot interleave around a write. - */ - let structuralChain: Promise = Promise.resolve(); - const queueStructural = (run: () => Promise): Promise => { - structuralChain = structuralChain.catch(() => {}).then(run); - return structuralChain; - }; + // --- Message wiring --------------------------------------------------------- + // The messages every table view answers alike (tableDispatch.ts), keyed by message type. + const common: CommonHandlers = { + ready: () => panel.ready(), + select: (rowIds) => this.onSelect?.(uriString, rowIds), + navigate: (target) => this.onNavigate?.(target, uriString), + // A Variable Editor panel is opening: send its cells. One shared answer for all four + // webview hosts — see matrixRequest.ts. + requestMatrix: (nodeId) => answerMatrixRequest(webview, uriString, nodeId), + // A Value editor is opening on a summary: its whole text, read off the tree the text says. + requestEditText: (rowId) => table.requestEditText?.(rowId), + // A row the payload deferred is being opened: send the rows under it, through postOrReport. + requestChildren: (nodeId) => panel.children(nodeId), + // Single native stack: the table view is the active editor when its menu is used, so + // these target the shared TextDocument undo history. + undo: () => void vscode.commands.executeCommand('undo'), + redo: () => void vscode.commands.executeCommand('redo'), + }; - // --- Drag start: snapshot the dragged rows into the host drag register ------ - // Mirrors applyCopy but for possibly-many rows: each row's owning entry is - // serialized (the payload the eventual paste uses) alongside the display - // facts (class/kind) the webview needs to predict the drop. Then broadcast - // the payload-free descriptor so every open table can render live feedback. - // Shared with every other table (clipboardAction.dragEntries); only getting a live - // model differs between the formats. - const applyDragStart = (msg: { rowIds: string[] }): void => { - dragEntries(msg.rowIds, uriString, { - // From the tree as it stands, for the reason applyCopy gives: a drag start changes no - // text, and it is the first thing a drag does, so a re-parse here is latency before the - // cursor can even predict a drop. - refresh: () => { - liveModel(); - }, - findNode: (rowId) => findNode(uriString, rowId), - // Said, as a failed copy is: the snapshot that fails is the serialize of every dragged - // entry, so this is where a value core will not write (a MatWriteError) says so. - post: (message) => webview.postMessage({ ...message, message: describe(message.message) }), - broadcast: broadcastDragState, + // This editor's own: its edits, the clipboard and row drags, each the table's command (the + // structural ones one text state at a time, which the table sees to), and Location in Text. + const sub = webview.onDidReceiveMessage((msg: TableToHostMessage) => { + if (dispatchCommon(msg, common)) return; + if (msg?.type === 'edit') table.edit?.(msg); + else if (msg?.type === 'copy') table.copy?.(msg.rowIds); + else if (msg?.type === 'delete') table.delete?.(msg.rowIds); + else if (msg?.type === 'addChild') table.addChild?.(msg.rowId); + else if (msg?.type === 'cut') table.cut?.(msg.rowIds); + else if (msg?.type === 'paste') table.paste?.(msg.rowId); + else if (msg?.type === 'addEntry') table.addEntry?.(msg); + else if (msg?.type === 'dragStart') table.dragStart?.(msg.rowIds); + else if (msg?.type === 'dragEnd') table.dragEnd?.(); + else if (msg?.type === 'drop') table.drop?.(msg.rowId, msg.mode); + else if (msg?.type === 'locateInText') void applyLocateInText(msg); }); - }; - - // --- Drag end: clear the register and tell every webview the drag is over --- - const applyDragEnd = (): void => { - clearDrag(); - broadcastDragState(); - }; - - // --- Drop: the drag register's transfer -------------------------------------- - // The same transfer as a paste (above), read from the drag register — captured as the drop - // arrives, and let go of however the drop ends. - const applyDrop = (msg: { rowId: string; mode: 'copy' | 'move' }) => { - const register = dragRegister(msg.mode, broadcastDragState); - return queueStructural(() => runTransfer({ rowId: msg.rowId }, register, transferAdapter(), transferDeps)); - }; - - // Remove named entries from THIS document on behalf of another document's paste or drop - // (a cross-file move). Opens the document (whether or not its table is still in front) - // and applies the removal as one WorkspaceEdit. - // - // The strict-JSON gate matters more here than on the paths that edit THIS - // document from its own table: the gesture happened in another file, so nothing the user - // did there tells them what state this one is in — it may be open in a text view with - // half-typed JSON, or have been left invalid by an external tool. The splice - // is tolerant enough to still find and remove a span in that text, which - // writes back a file that was already broken and is now missing an entry too - // (and, because it is invalid, no longer openable as a table to see that). - // Refusing leaves the source untouched; the move's paste half has already - // succeeded, so the user is left with a copy rather than a mangled original. - // - // Every outcome is ANSWERED, never posted: this view may be a background tab or already - // disposed, and the user is looking at the view the paste landed in, which reports it - // (editorHub's SourceDeleter). That includes VS Code refusing the edit, which used to be - // dropped — applyEdit answers false rather than throwing, and a move that ignored it left - // a silent duplicate. - const deleteFromSourceDocument = async (sourceUri: string, targets: DeleteTarget[]): Promise => { - const uri = vscode.Uri.parse(sourceUri); - const srcDoc = await vscode.workspace.openTextDocument(uri); - const srcText = srcDoc.getText(); - if (!parsesAsJson(srcText)) return { removed: false, reason: 'the file has invalid JSON' }; - const trimmed = deleteEntriesByName(srcText, targets); - // Renamed or deleted since the cut: nothing answers to those names any more. - if (trimmed === srcText) return { removed: false, reason: NOT_FOUND_REASON }; - // Byte-scoped, for the same reason every write to THIS document is: the source may be - // another 47.8 MB dictionary, and removing one entry from it should not rewrite it. - const patch = minimalReplacement(srcText, trimmed); - const edit = new vscode.WorkspaceEdit(); - const range = new vscode.Range( - srcDoc.positionAt(patch.offset), - srcDoc.positionAt(patch.offset + patch.length), - ); - edit.replace(uri, range, patch.text); - const written = await vscode.workspace.applyEdit(edit); - return written - ? { removed: true } - : { removed: false, reason: 'the document rejected the edit (it may have changed on disk)' }; - }; - - // --- The baseline: the file on disk ------------------------------------------ - // - // A Modified mark says an entry differs from what the FILE holds. It used to be measured - // against the text this document had the first time it painted, replaced only by a save — - // so a file reloaded after another tool changed it was marked in a document with nothing to - // save, a revert left the marks measured against the old text, and a document already dirty - // when the table opened (a window restored with unsaved changes) was measured against its - // own edits and marked nothing. - // - // What VS Code says, and when, is the whole difficulty (measured in the integration host): - // a change event carries the dirty state from BEFORE it — an edit to a clean document reads - // clean — and the dirty-state event that follows, with no content changes, carries the new - // one. A revert and a save end in one of those reading clean. A reload from disk ends in - // nothing: its change reads clean, and no dirty-state event follows. So a revert and a save - // are told by that event, and a reload by comparing the text with the file. - - // Whether the last change this view saw was an undo or a redo. Back to a clean document that - // way is back to a text the baseline was already taken from — the save or the open that made - // it clean — so it needs neither a capture nor a repaint. - let lastByHistory = false; - - // Every tab of this document repaints wide, and the first to read the text takes the new - // baseline from it (baselineOwed); the others mark against it. - const repaintAll = (): void => { - for (const repaint of docState.repaints) repaint(); - }; - const repaintThis = (): void => void post(); - docState.repaints.add(repaintThis); - - // The file's stamp, or null when it cannot be had. - const statDisk = async (): Promise => { - try { - const stat = await vscode.workspace.fs.stat(document.uri); - return { mtime: stat.mtime, size: stat.size }; - } catch { - return null; - } - }; - - // The file's bytes, or null when they cannot be had. - const readDisk = async (): Promise => { - try { - return await vscode.workspace.fs.readFile(document.uri); - } catch { - return null; - } - }; - // The document still reads clean at `version` — the one text a stat or a read begun there - // can speak for. - const stillCleanAt = (version: number): boolean => !document.isDirty && document.version === version; - - // Taken whenever the document is known to read what the file does; kept only if it still - // does by the time the stat answers. - const stampDisk = (version: number): void => { - void statDisk().then((stamp) => { - if (stamp && stillCleanAt(version)) docState.diskStamp = stamp; + // Register the keyboard's way into the Add gallery. The command (dataExplorer.addEntry) + // has no view of its own to act on, so each editable view says here how to open its + // gallery, and the hub picks the one whose panel is active. + registerAddGalleryView(webviewPanel, () => void webview.postMessage({ type: 'openAddGallery' })); + const navSub = wireNavigateSelect(webview, uriString); + + // The editor/title "View as Text" toggle is gated on the built-in + // `activeCustomEditorId == dataExplorer.tableView` context key (set + // automatically by VS Code), so no custom context key is needed here. + + webview.html = this.getHtml(webview, distRoot); + webviewPanel.onDidDispose(() => { + // First, so nothing still in flight is posted to this view from here on. + panel.dispose(); + // Its registers, its drag, its listeners and its hold on the tree; with the LAST tab of this + // document its source deleter and its baseline (see jsonDocument.ts). + close(); + unregisterAddGalleryView(webviewPanel); + sub.dispose(); + navSub.dispose(); }); - }; - - /** This document now reads what its file on disk does — whose stamp is `stamp`, if known. */ - const noteClean = (stamp?: DiskStamp): void => { - docState.baseline.cleanAt = document.version; - if (stamp) docState.diskStamp = stamp; - else stampDisk(document.version); - if (docState.baseline.version !== document.version) repaintAll(); - }; - - /** - * Whether a change that left the document reading clean was a reload from disk — which says - * so with no dirty-state event — or an edit to a clean document, which reads clean the same - * way until its own dirty-state event arrives. - * - * The file decides: the document reads what the file holds, or it does not. But it is asked - * only if it has CHANGED since the document last matched it, which its stat says: a reload - * follows a change on disk, and an edit — the common case by far — leaves the file as it - * was. Read first, as this used to, it cost every tab a read of the whole file on the first - * edit after every save (47.8 MB on a real dictionary, after every autosave). The stat is - * the wait, too: an edit's dirty-state event has usually arrived by the time it answers. - */ - const checkAgainstDisk = async (version: number): Promise => { - const stamp = await statDisk(); - if (!stamp || disposed || !stillCleanAt(version)) return; - if (docState.baseline.version === version || sameDiskStamp(docState.diskStamp, stamp)) return; - const bytes = await readDisk(); - if (!bytes || disposed || !stillCleanAt(version)) return; - if (new TextDecoder().decode(bytes) !== document.getText()) return; - noteClean(stamp); - }; - - /** - * The baseline of a document opened with unsaved changes: the file's own bytes, parsed where - * nothing else can see them. Until it lands nothing is marked (no baseline yet); a file that - * cannot be read is baselined from the document's text, as every open used to be. - */ - const baselineFromDisk = async (): Promise => { - docState.baseline.pending = true; - const cleanAtBefore = docState.baseline.cleanAt; - const bytes = await readDisk(); - docState.baseline.pending = false; - // The DOCUMENT's read, not this view's: a tab that closed meanwhile leaves it to the tabs - // still open (repaintAll reaches only those). Only the last one closing ends it — before the - // parse, since a baseline then would outlive the document. - if (openDocs.get(uriString) !== docState) return; - // A save while the file was read: baselined from the saved text already, or — if no tab - // could repaint — owed from the file as it now stands, which these bytes may predate. - if (!diskBaselineUsable(docState.baseline, cleanAtBefore)) { - if (!docState.baseline.captured) void baselineFromDisk(); - return; - } - let tree: any = null; - try { - tree = bytes ? parseDetached(name, new TextDecoder().decode(bytes)) : null; - } catch { - tree = null; - } - if (tree) { - captureBaseline(uriString, tree); - docState.baseline.captured = true; - docState.baseline.version = -1; - } - repaintAll(); - }; - - if (!document.isDirty) { - docState.baseline.cleanAt = document.version; - stampDisk(document.version); - } else if (!docState.baseline.captured && !docState.baseline.pending) { - void baselineFromDisk(); - } - - // The node a row names in the tree the document's text says now, as an edit finds it; null - // where there is none to read (text that does not parse). - const liveFind = (rowId: string): any => { - try { - liveModel(); - return findNode(uriString, rowId); - } catch { - return null; - } - }; - - // --- Message wiring --------------------------------------------------------- - // The messages every table view answers alike (tableDispatch.ts), keyed by message type. - const common: CommonHandlers = { - ready: () => { - webviewReady = true; - void post(); - }, - select: (rowIds) => this.onSelect?.(uriString, rowIds), - navigate: (target) => this.onNavigate?.(target, uriString), - // A Variable Editor panel is opening: send its cells. One shared answer for all four - // webview hosts — see matrixRequest.ts. - requestMatrix: (nodeId) => answerMatrixRequest(webview, uriString, nodeId), - // A Value editor is opening on a summary: send its whole text, read off the tree the - // text says, as an edit is (editTextRequest.ts). - requestEditText: (rowId) => answerEditTextRequest((m) => webview.postMessage(m), (id) => liveFind(id), rowId, describe), - // A row the payload deferred is being opened: send the rows under it (childRequest.ts). - // `annotateDataRows` is handed in rather than imported there so that module stays - // loadable under vitest — see its UsageFiller. - requestChildren: (nodeId) => void answerChildRequest(webview, uriString, nodeId, name, annotateDataRows, planner), - // Single native stack: the table view is the active editor when its menu is used, so - // these target the shared TextDocument undo history. - undo: () => void vscode.commands.executeCommand('undo'), - redo: () => void vscode.commands.executeCommand('redo'), - }; - - const sub = webview.onDidReceiveMessage((msg: TableToHostMessage) => { - if (dispatchCommon(msg, common)) return; - if (msg?.type === 'edit') { - void applyEdit(msg); - } else if (msg?.type === 'copy') { - applyCopy(msg.rowIds, 'copy'); - } else if (msg?.type === 'delete') { - void queueStructural(() => applyDelete(msg)); - } else if (msg?.type === 'addChild') { - void queueStructural(() => applyAddChild(msg)); - } else if (msg?.type === 'cut') { - applyCut(msg.rowIds); - } else if (msg?.type === 'paste') { - void queueStructural(() => applyPaste(msg)); - } else if (msg?.type === 'addEntry') { - void queueStructural(() => applyAddEntry(msg)); - } else if (msg?.type === 'dragStart') { - applyDragStart(msg); - } else if (msg?.type === 'dragEnd') { - applyDragEnd(); - } else if (msg?.type === 'drop') { - void applyDrop(msg); - } else if (msg?.type === 'locateInText') { - void applyLocateInText(msg); - } - }); - - const navSub = wireNavigateSelect(webview, uriString); - - // Repaint on ANY change to this document: table edits, text-view edits, undo, - // and redo all arrive here. A change inside ONE entry repaints just that entry's - // rows; anything else rebuilds every row, and setRows (webview side) preserves - // expansion and re-applies selection, so the tree doesn't collapse under the user. - const changeSub = vscode.workspace.onDidChangeTextDocument((e) => { - if (e.document.uri.toString() !== uriString) return; - // An event carrying NO content changes changed no text, so nothing on screen can be - // stale because of it. VS Code fires one for the dirty-state flip after every edit (and - // again when a save clears it), and the narrow repaint refuses any change count but one - // — so this phantom went wide: re-parse the document, rebuild every row, postMessage the - // lot. ~1.6 s on a 47.8 MB dictionary, after every edit, undo, redo and save, behind - // which the user's next keystroke waited. - // - // Returned BEFORE the hint is spent, deliberately: which of the two events arrives first - // is VS Code's business, and an expectation dropped by the empty one would send the - // host's own edit down the recovery path it exists to avoid. - // - // It is also the one event that says the document has turned clean — a save or a revert - // (see noteClean). - if (e.contentChanges.length === 0) { - if (!e.document.isDirty && !lastByHistory) noteClean(); - return; - } - lastByHistory = - e.reason === vscode.TextDocumentChangeReason.Undo || e.reason === vscode.TextDocumentChangeReason.Redo; - // A change to a document that read clean before it, from neither this view's own write nor - // the undo stack, may be a reload from disk, which says so no other way. - if (!e.document.isDirty && !lastByHistory && hostEdit === null && paintedWrite === null) { - void checkAgainstDisk(e.document.version); - } - // Spent or dropped here, exactly once, whichever branch the event takes: an expectation - // that survived its own event must not be waiting for the next one. - const hint = hintFor(hostEdit, wideEpoch); - hostEdit = null; - // Spent on the same terms, and first: a structural edit painted its own rows before it - // wrote, so if this event is that write there is nothing left to do. Checked before - // anything that reads the document, since being sure costs three comparisons. - const painted = paintedWrite; - paintedWrite = null; - // And only while the rows it painted are still the ones on screen (isPaintedEcho). - if (painted && isPaintedEcho(changesOf(e), painted, wideEpoch)) return; - // The host's own edit first, since it is the one case that needs no discovery at all. - if (syncOwnEdit(e, hint)) return; - // Then an undo or a redo of an edit it wrote earlier: the other change it needs no - // discovery for, and the one the recovery path below was making the user wait ~175 ms of - // reading and walking for. Structural first — a delete's undo and a cell edit's are - // recognised the same way, but only one of them has an element in the bytes to read. - if (syncKnownOps(e)) return; - if (syncKnownChange(e)) return; - if (syncOneEntry(e)) return; - void repaintStale(); - }); - - // On save the text IS the file, so the marks clear: the next repaint takes it as the baseline - // (once, whether this or the dirty-state event that clears it comes first). - const saveSub = vscode.workspace.onDidSaveTextDocument((doc) => { - if (doc.uri.toString() === uriString) noteClean(); - }); - - // A model created or deleted in the workspace can add or remove a block that uses one of these - // entries, and nothing about this document changes when it does (usageRepostWatch.ts). Held - // back while a write this view painted ahead of is in flight: the repost re-reads the text, - // which until the write lands is the text from BEFORE the edit on screen — the edit would - // vanish from the table until the write's echo, no longer trusted after a wide repaint - // (isPaintedEcho), repainted the whole of it a second time. - const usageSub = subscribeUsageRepost( - () => void post(), - () => paintedWrite !== null || hostEdit !== null, - ); - - // The editor/title "View as Text" toggle is gated on the built-in - // `activeCustomEditorId == dataExplorer.tableView` context key (set - // automatically by VS Code), so no custom context key is needed here. - - webview.html = this.getHtml(webview, distRoot); - webviewPanel.onDidDispose(() => { - // First, so nothing still in flight acts for this view from here on. - disposed = true; - unregisterWebview(webview); - unregisterAddGalleryView(webviewPanel); - // If a drag originated from this now-closing view, drop it so a stale - // register can't complete against another document. - if (getDrag()?.sourceDocUri === uriString) { - clearDrag(); - broadcastDragState(); - } - sub.dispose(); - changeSub.dispose(); - saveSub.dispose(); - docState.repaints.delete(repaintThis); - usageSub.dispose(); - navSub.dispose(); - // Per VIEW, paired with the open above: the count is what decides which close is the - // last, and it is shared with the read-only view, which registers under the same uri. - // The last one drops the cached tree and the session's source, which nothing released. - releaseSourceView(uriString); - // Only once the LAST tab of this document closes. Both of these are keyed by URI, - // i.e. document-scoped, so tearing them down when ANY tab closed left a surviving - // split panel showing no Modified marks and unable to complete a cross-document - // move — while the document itself stayed open and editable. See openDocs. - if (closeView(uriString)) { - unregisterSourceDeleter(uriString); - clearBaseline(uriString); - } }); } diff --git a/src/host/engineTable.ts b/src/host/engineTable.ts index 51a692a..4c0150c 100644 --- a/src/host/engineTable.ts +++ b/src/host/engineTable.ts @@ -9,12 +9,13 @@ // - then what follows the rows (the drop rules, the clipboard, the clip mark: the engine's // trailer, which waits for `opened(view)`; trailerAfterDelivery): once that post has settled, // where the editor awaited it (the MAT-file editor, `awaitRows`), or at once, in the same run, -// where it did not (the compressed dictionary editor); +// where it did not (the dictionary editors); // - then whatever the host runs after a paint (`afterPaint`: the Usage link's selection to drain). // Everything else the engine says, while a paint's rows are on their way or not, is posted at once, // in the order it was said: a gesture's rows during that await go out ahead of the trailer, as they // did. Each message is spelled as the editors spelled it, keys in their order; a `setRows` by the -// editor's own builder below (matRows, binaryRows), which its provider hands over as `setRows`. +// editor's own builder below (matRows, binaryRows, jsonRows), which its provider hands over as +// `setRows`. import type { EditableTableSource, TableChange, TableView } from 'data-explorer-view/model'; import { postOrReport } from 'data-explorer-view/host'; @@ -31,12 +32,17 @@ export interface EngineTableOptions { name: string; /** * Whether what follows a paint's rows waits for their post to settle (the MAT-file editor's - * `post()` awaited it), or goes out at once, in the same run (the compressed dictionary - * editor's did not; its `ready` was answered in the run that asked, by a repaint, and so is it - * here). Default: true. + * `post()` awaited it), or goes out at once, in the same run (the dictionary editors' did not). + * Default: true. */ awaitRows?: boolean; - /** A whole paint's rows, as the editor's own `setRows` literal spelled them (matRows, binaryRows). */ + /** + * Whether the webview's `ready` opens the table, whose answer is then painted (the MAT-file and + * JSON dictionary editors'), or is answered in the run that asked, by a repaint (the compressed + * dictionary editor's). Default: true. + */ + readyOpens?: boolean; + /** A whole paint's rows, as the editor's own `setRows` literal spelled them (matRows, binaryRows, jsonRows). */ setRows(view: TableView): Record; /** Called with each whole paint's view, before its rows are posted (the lock on a read-only tab). */ onRows?(view: TableView): void; @@ -73,6 +79,22 @@ export const binaryRows = (view: TableView) => ({ warnings: view.warnings, }); +/** + * A whole paint's rows, as the JSON dictionary editor's `post()` spelled its `setRows`: no notice, no + * gallery list, and a text view for "Location in Text" to reveal (`hasTextView`). + */ +export const jsonRows = (view: TableView) => ({ + type: 'setRows', + docUri: view.docUri, + rows: view.rows, + columns: view.columns, + columnLabels: view.columnLabels, + columnGroups: view.columnGroups, + editable: view.editable, + hasTextView: true, + warnings: view.warnings, +}); + /** * The panel's end of `table`, until `dispose()`. `ready()` answers the webview's `ready`, and * `children()` its fetch of the rows under a row. @@ -84,6 +106,10 @@ export function engineTable( ): { ready(): void; children(nodeId: string): void; dispose(): void } { let closed = false; const { name } = options; + // The view the last `open()` answered, which is painted once however many asked for it (two + // `ready`s before the first paint lands are answered by one paint, as the editor's newest repaint + // was the one that landed). + let answered: TableView | undefined; const { setRows } = options; @@ -144,9 +170,13 @@ export function engineTable( ready() { // In the run that asked: the engine's repaint says its rows at once, and they are posted as // every repaint's are. - if (options.awaitRows === false) return void table.repaintAll(); + if (options.readyOpens === false) return void table.repaintAll(); table.open().then( - (view) => (closed ? undefined : paint(view)), + (view) => { + if (closed || view === answered) return; + answered = view; + return paint(view); + }, // A table closed while it was asked has nowhere to say it (SourceClosed). (err) => { if (closed || (err as { name?: unknown } | null)?.name === 'SourceClosed') return; diff --git a/src/host/entrySplice.ts b/src/host/entrySplice.ts index 72554a8..3eaa8cc 100644 --- a/src/host/entrySplice.ts +++ b/src/host/entrySplice.ts @@ -1,185 +1,16 @@ // Copyright 2026 The MathWorks, Inc. // -// Where in a JSON .sldd's text a given entry lives — the finders every write to that text -// goes through (a table cell edit, a delete, an add-child, a paste, a drop). +// Where in a JSON .sldd's text a given entry lives: core's finders (data-explorer-core's +// entrySplice), which this file was word for word. Re-exported under the names this extension's +// tests and structuralEdit.ts import until they are rehomed (step 6b's V4 and V6); the JSON +// editor's engine (view's JsonEngine) uses core's directly. // -// These used to answer by building a jsonc parse tree of the whole document, which is a node -// for every token in the file to settle a question about one element: 552 ms on a 46 MB -// customer dictionary, paid on every single edit. The answer now comes from -// jsonEntryScan.indexEntries — the same scan the text-view repaint already used, at 82 ms — -// so the two halves of this file's editing story share ONE account of where the entries are -// instead of holding two that can drift. See entrySpliceScan.test.ts, which pins that index -// against jsonc-parser's tree element for element. -import { indexEntries, type EntryElementSpan } from './jsonEntryScan.js'; -import { toEntrySelector, type EntrySelector } from 'data-explorer-core'; - -// The metadata uuid an element's text declares, or null. Parsing the element is affordable -// here because this is only reached to break a tie between same-named candidates, of which a -// real file has at most a handful — never once per element. -function elementUuid(text: string, el: EntryElementSpan): string | null { - try { - const parsed: unknown = JSON.parse(text.slice(el.offset, el.offset + el.length)); - const metadata = ((parsed ?? {}) as { metadata?: unknown }).metadata; - const uuid = ((metadata ?? {}) as { uuid?: unknown }).uuid; - return typeof uuid === 'string' ? uuid : null; - } catch { - return null; - } -} - -// Index within the entries array of the element the selector names, or -1. An element with no -// string name of its own can never match: the scan reports that as a null name, and no -// selector's name is ever null (core's entrySelector.ts always yields a string), so a numeric or -// missing name is unfindable without a guard for it here. -// -// Names are matched first, and the uuid is consulted ONLY to break a tie: entry -// names are unique per namespace, not per file, so `Array` in Design and `Array` -// in Other Data are two entries with one name. Keying on the name alone made a -// same-named pair resolve to whichever came first in the array, so deleting the -// SECOND one spliced out the first — the user watched the row they had not -// touched disappear while the one they deleted stayed. When the selector carries -// no uuid (a bare-name caller), or no candidate matches it, the first name match -// stands, which is what a file with no duplicate always yields. -function indexOfEntryElement(text: string, elements: EntryElementSpan[], selector: EntrySelector): number { - const matches: number[] = []; - elements.forEach((el, i) => { - if (el.name === selector.name) matches.push(i); - }); - if (matches.length === 0) return -1; - if (matches.length === 1 || !selector.uuid) return matches[0]; - const exact = matches.find((i) => elementUuid(text, elements[i]) === selector.uuid); - return exact ?? matches[0]; -} - -/** - * Locate the `{...}` span of the entry object the selector identifies (see - * core's entrySelector.ts — a bare string means "whichever entry has this name"). - * - * Returns the element's offset/length, or null if the entries array cannot be - * scanned or no element matches. Never throws. - */ -export function findEntrySpan( - text: string, - target: string | EntrySelector, -): { offset: number; length: number } | null { - const elements = indexEntries(text)?.elements ?? []; - const idx = indexOfEntryElement(text, elements, toEntrySelector(target)); - if (idx < 0) return null; - const { offset, length } = elements[idx]; - return { offset, length }; -} - -/** - * Locate the text span to REMOVE to delete the entry the selector identifies - * from the entries array — the element object plus the one comma that joins it - * to its siblings (the preceding comma when it's the last element, otherwise the - * following comma), and the whitespace between. Removing this span leaves valid - * JSON. Returns null if the array or element is not found. - */ -export function findEntryElementSpan( - text: string, - target: string | EntrySelector, -): { offset: number; length: number } | null { - const elements = indexEntries(text)?.elements ?? []; - const idx = indexOfEntryElement(text, elements, toEntrySelector(target)); - if (idx < 0) return null; - const el = elements[idx]; - - if (elements.length === 1) { - // Only element: remove just it, leaving `[ ]`. - return { offset: el.offset, length: el.length }; - } - if (idx < elements.length - 1) { - // Not last: remove from this element's start up to the next element's start - // (covers the trailing comma + whitespace before the next element). - const next = elements[idx + 1]; - return { offset: el.offset, length: next.offset - el.offset }; - } - // Last element: remove from the previous element's end (covers the preceding - // comma + whitespace) through the end of this element. - const prev = elements[idx - 1]; - const start = prev.offset + prev.length; - return { offset: start, length: el.offset + el.length - start }; -} - -/** - * The whitespace immediately before `offset` — for normally formatted text, the indent of - * the line `offset` sits on. - * - * Deliberately NOT "everything between the line start and `offset`". An entries element is - * not guaranteed to begin its line: a minified dictionary puts every element on one line, - * and our own insert into an EMPTY array used to leave the first element on the `[` line. - * Slicing the whole line prefix then treated `"entries": [` as indentation and copied it - * into the file verbatim, producing a document that no longer parsed as JSON — so the - * second add to a freshly emptied dictionary corrupted it. Taking only the whitespace run - * degrades to `''` on a minified file instead, which is the right answer there. - */ -export function indentBefore(text: string, offset: number): string { - let i = offset; - while (i > 0 && (text[i - 1] === ' ' || text[i - 1] === '\t')) i--; - return text.slice(i, offset); -} - -/** - * Compute where to INSERT a new element in the entries array and how the array - * is indented, so paste and add can append a uniquely-named entry. Returns: - * - `offset`: text offset to insert at (just after the last element, or just - * inside `[` for an empty array), - * - `needsLeadingComma`: whether a `,` must precede the inserted element, - * - `elementIndent`: the whitespace the new element's `{` should sit behind, - * - `trailingBreak`: text that must follow the element — the line break and array - * indent that put the closing `]` back on its own line when the array was empty, - * and `''` when an existing element already established that layout. - * Returns null if the entries array can't be found. - */ -export function findEntriesArrayInsertion(text: string): { - offset: number; - needsLeadingComma: boolean; - elementIndent: string; - trailingBreak: string; -} | null { - const index = indexEntries(text); - if (!index) return null; - const elements = index.elements; - if (elements.length > 0) { - const last = elements[elements.length - 1]; - return { - offset: last.offset + last.length, - needsLeadingComma: true, - elementIndent: indentBefore(text, last.offset), - trailingBreak: '', - }; - } - // Empty array `[]` or `[ ]`: insert just after the `[`, which is where the scan anchored. - // - // The depth is DERIVED from the array's own line rather than assumed: `entries` sits five - // levels deep in every dictionary MATLAB writes, but hardcoding that made the one case - // with nothing to copy from the only case that could be wrong, and it is the case a brand - // new dictionary hits first. - const arrayLineStart = text.lastIndexOf('\n', index.arrayStart) + 1; - const arrayIndent = /^[ \t]*/.exec(text.slice(arrayLineStart, index.arrayStart))![0]; - return { - offset: index.arrayStart + 1, - needsLeadingComma: false, - elementIndent: arrayIndent + detectIndent(text), - trailingBreak: '\n' + arrayIndent, - }; -} - -/** - * Detect the indent unit used by the source text. - * - * Returns the leading whitespace of the first indented line: "\t" for tabs, or - * the run of spaces for a space-indented file. Falls back to two spaces when no - * indented line is found. - */ -export function detectIndent(text: string): string { - const match = /^(\t+| +)\S/m.exec(text); - if (!match) { - return ' '; - } - // A tab-indented file's unit is ONE tab however deep that first line sits; a - // space-indented file's unit is the whole run of spaces on it. - const whitespace = match[1]; - return whitespace[0] === '\t' ? '\t' : whitespace; -} +// `findEntryElementSpan` here is the JSON one, which core exports as `findJsonEntryElementSpan`: +// core's own `findEntryElementSpan` is the binary dictionary's (its XML finder). +export { + detectIndent, + findEntriesArrayInsertion, + findEntrySpan, + findJsonEntryElementSpan as findEntryElementSpan, + indentBefore, +} from 'data-explorer-core'; diff --git a/src/host/jsonDocument.ts b/src/host/jsonDocument.ts new file mode 100644 index 0000000..afa482d --- /dev/null +++ b/src/host/jsonDocument.ts @@ -0,0 +1,125 @@ +// Copyright 2026 The MathWorks, Inc. +// +// The JSON dictionary editor's document: view's JSON dictionary engine (data-explorer-view/host +// `JsonEngine`), which holds what belongs to the document rather than to one of its tables (the +// Status column's baseline, where it came from, the file's stamp, every table's repaint), with VS +// Code's end of every hook it takes. One engine per open document, shared by its tables and made by +// the first of them (perDocument.ts), as the editor's own document state was, and only while it is +// still that TextDocument, open: the engine reads and writes that document's text. +// +// The text is the TextDocument's (textDocumentStore.ts): its edits are WorkspaceEdits, its undo, +// redo, dirty state, save, revert, reload and hot-exit backup VS Code's own. The tree is read into +// the registry over core's module-level session (`defaultRegistry`), where the Property Inspector, +// the other editors and extension.ts's keystroke invalidate find it. Notices are notifications, the +// registers are this extension's (hubRegisters.ts), the Usage column is the workspace's usage graph. +import * as vscode from 'vscode'; +import type { Notify } from 'data-explorer-view/model'; +import { JsonEngine, defaultRegistry } from 'data-explorer-view/host'; +import { hubRegisters } from './hubRegisters.js'; +import { PerDocument, sameOpenDocument } from './perDocument.js'; +import { textDocumentStore, type TextDocumentApi } from './textDocumentStore.js'; +import { annotateDataRows, annotateDataRowsNow } from './usageGraph.js'; +import { subscribeUsageRepost } from './usageRepostWatch.js'; + +/** A notice of the engine's, said as VS Code says one (the JSON engine says only a transfer's warning). */ +const notify: Notify = (notice) => { + if (notice.level === 'warning') void vscode.window.showWarningMessage(notice.message); + else if (notice.level === 'error') void vscode.window.showErrorMessage(notice.message); + else if (notice.ms === undefined) vscode.window.setStatusBarMessage(notice.message); + else vscode.window.setStatusBarMessage(notice.message, notice.ms); +}; + +/** VS Code's end of a TextDocument's text: a WorkspaceEdit, the workspace's document events, Undo and Redo. */ +const textApi = (document: vscode.TextDocument): TextDocumentApi => ({ + applyEdit: (replacements) => { + const edit = new vscode.WorkspaceEdit(); + for (const r of replacements) edit.replace(document.uri, new vscode.Range(r.start, r.end), r.text); + return vscode.workspace.applyEdit(edit); + }, + onDidChangeTextDocument: (listener) => vscode.workspace.onDidChangeTextDocument(listener), + onDidSaveTextDocument: (listener) => vscode.workspace.onDidSaveTextDocument(listener), + undo: () => void vscode.commands.executeCommand('undo'), + redo: () => void vscode.commands.executeCommand('redo'), + reasons: { undo: vscode.TextDocumentChangeReason.Undo, redo: vscode.TextDocumentChangeReason.Redo }, +}); + +/** A document's engine, and the TextDocument whose text it reads and writes. */ +interface HeldEngine { + engine: JsonEngine; + document: vscode.TextDocument; + dispose(): void; +} + +const engines = new PerDocument(); + +function engineOf(document: vscode.TextDocument, name: string): JsonEngine { + const uri = document.uri.toString(); + return new JsonEngine({ + registry: defaultRegistry, + uri, + name, + text: textDocumentStore(document, textApi(document)), + registers: hubRegisters(), + notify, + usage: { + fill: (rows) => annotateDataRows(uri, rows), + fillNow: (rows) => annotateDataRowsNow(uri, rows), + }, + // A model created or deleted in the workspace can add or remove a block that uses one of the + // document's entries (usageRepostWatch.ts). + usageReposts: { subscribe: (repost, busy) => subscribeUsageRepost(repost, busy) }, + // The file behind the document, for its baseline: its stamp and its bytes, or null when they + // cannot be had. + disk: { + stat: async () => { + try { + const stat = await vscode.workspace.fs.stat(document.uri); + return { mtime: stat.mtime, size: stat.size }; + } catch { + return null; + } + }, + read: async () => { + try { + return await vscode.workspace.fs.readFile(document.uri); + } catch { + return null; + } + }, + }, + // The editor posts a paint's rows, then in the same run what follows them and its own drain. + trailerAfterDelivery: true, + }); +} + +/** + * A table of `document`, named `name`, until its `close()`: the document's engine, made by its first + * table, and closed with its last. `changed` is called after every repaint of this table, once its + * rows are sent (the provider's own onChanged). + */ +export function openJsonTable( + document: vscode.TextDocument, + name: string, + changed: () => void, +): { table: ReturnType; close(): void } { + // A held engine of another TextDocument of this uri, or of one closed since (a table that was + // never closed), is not this document's: it goes, and this table makes a fresh one. + const held = engines.open( + document.uri.toString(), + (): HeldEngine => { + const engine = engineOf(document, name); + return { engine, document, dispose: () => engine.dispose() }; + }, + (h) => sameOpenDocument(h, document), + ); + const table = held.value.engine.view({ changed }); + return { + table, + close: () => { + // Its registers, its listeners and its hold on the tree; with the last table the document's + // source deleter and its baseline, and then the engine. + table.dispose(); + held.close(); + }, + }; +} diff --git a/src/host/jsonEntryScan.ts b/src/host/jsonEntryScan.ts index 8a2db6e..07b0bf9 100644 --- a/src/host/jsonEntryScan.ts +++ b/src/host/jsonEntryScan.ts @@ -1,339 +1,16 @@ // Copyright 2026 The MathWorks, Inc. // -// Which entry of a JSON .sldd a text change landed in — found by SCANNING the text, -// never by parsing it. -// -// WHY NOT PARSE. Every keystroke in the plain-text view of a `.sldd` repaints the table, -// and that repaint used to mean: JSON.parse the whole file, rebuild the whole model, -// rebuild every row, and post the lot to the webview. On a 46 MB dictionary (64,700 -// entries, 317,000 rows) that is ~1.3 s of host work and a ~120 MB postMessage — per -// keystroke. Rebuilding just the entry that changed costs 0.3 ms, so the only question -// worth answering fast is WHICH entry, and jsonc-parser's parseTree answers it in 483 ms -// on that file (it builds a node for every token in the document, which is most of the -// work we are trying to avoid). A scan of the entries array answers it in 82 ms. -// -// WHAT IT PROMISES. Three facts about the entries array, all cheap and all checkable: -// - how many elements it holds, -// - the span of each, and the entry name each one spells, and -// - which one CONTAINS a given change, if any. -// It deliberately does not validate the JSON. That is what makes it fast, and it is safe -// because the caller checks the count against the model it already has and re-parses the -// one element it is told about — see jsonEntrySync.ts, which turns "no" into the ordinary -// full repaint. -// -// WHO ELSE ASKS. entrySplice.ts — the finders every table edit, delete, add-child and -// paste use to locate the text they REWRITE. It built a jsonc parse tree for that (552 ms -// on the 46 MB dictionary, per edit) until it was pointed here instead. So the names below -// are load-bearing for text the host writes, not only for rows it paints; the invariant -// that they agree with jsonc-parser's tree element for element is pinned in -// entrySpliceScan.test.ts. - -import { DATA_PART_KEY, TEXT_CONTENT, TEXT_PARTS } from 'data-explorer-core'; - -/** One element of the entries array, as text. */ -export interface EntryElementSpan { - offset: number; - length: number; - /** Position in the entries array, counting from 0. */ - index: number; - /** - * The element's OWN top-level `"name"` value, or null when it declares none or one - * that is not a string (which no selector can match — see core's entrySelector.ts). - */ - name: string | null; -} - -/** Every element of the entries array, and where the array opens. */ -export interface EntriesIndex { - /** Offset of the `[` that opens the entries array — where an insert into an empty one goes. */ - arrayStart: number; - /** The elements, in array order. */ - elements: EntryElementSpan[]; -} - -/** What the scan found. `hit` is null when the change fell outside every element. */ -export interface EntryScan { - /** Elements in the entries array — ALL of them, not just up to the hit. */ - count: number; - hit: EntryElementSpan | null; -} - -/** The half-open text range a single content change occupies in the NEW text. */ -export interface ChangeRegion { - start: number; - end: number; -} - -const QUOTE = 0x22; // " -const BACKSLASH = 0x5c; // \ -const LBRACE = 0x7b; // { -const RBRACE = 0x7d; // } -const LBRACKET = 0x5b; // [ -const RBRACKET = 0x5d; // ] -const COLON = 0x3a; // : -const COMMA = 0x2c; // , -const NAME_KEY = '"name"'; - -// The fixed path to the entries array: -// root → "__MW_TEXT_PARTS__" → "__MW_TEXT_PART__/data/chunk0" -// → "__MW_TEXT_content" → "entries"[] -// The only walk of these keys in this host: entrySplice.ts asks this scan for the same -// answer rather than building a parse tree for it (see the header), so there is no second -// copy of the walk to keep in step by hand. All four keys are long and unlikely to occur -// as text elsewhere; a false anchor would still have to survive the caller's -// element-count check. -// -// The three container keys are CORE's, published for exactly this. Every other reader in -// this host holds a parsed object and asks core's `slddChunkContent`; this one walks the -// raw text for byte OFFSETS, so it is the one place that needs the strings themselves — -// which is why the host kept a copy of them for as long as core did not publish them, and -// why that copy sat in the file least able to notice being wrong: a missed key silently -// yields "no entries array", and the edit falls back to rewriting the whole document. -// -// The ORDER is this file's own — core names the three keys, not the walk between them — -// and it is pinned by a real dictionary MATLAB wrote as text, where the wrong order -// returns -1. `entries` stays below, where the walk stops being a container lookup. -const PATH_KEYS = [TEXT_PARTS, DATA_PART_KEY, TEXT_CONTENT] as const; - -// The index just past the closing quote of the string starting at `open`, or -1. -// -// The quote is found with indexOf so the bytes are walked by the engine rather than by this -// loop, which then only has to settle whether the quote it landed on was escaped: a quote -// preceded by an ODD number of backslashes is, an even number is not (`"a\\"` ends). -function afterString(text: string, open: number): number { - for (let from = open + 1; ; ) { - const quote = text.indexOf('"', from); - if (quote < 0) return -1; // unterminated string: mid-edit text - let back = quote - 1; - let slashes = 0; - while (back > open && text.charCodeAt(back) === BACKSLASH) { - slashes++; - back--; - } - if (slashes % 2 === 0) return quote + 1; - from = quote + 1; - } -} - -/** The index of the first non-whitespace character at or after `from`. */ -function skipWhitespace(text: string, from: number): number { - let i = from; - while (i < text.length && text.charCodeAt(i) <= 0x20) i++; - return i; -} - -// The index just past `"key":`, searching from `from`, or -1. The colon is required so a -// key-shaped string in a VALUE cannot anchor the walk. -function afterKey(text: string, key: string, from: number): number { - const quoted = `"${key}"`; - for (let at = text.indexOf(quoted, from); at >= 0; at = text.indexOf(quoted, at + 1)) { - const colon = skipWhitespace(text, at + quoted.length); - if (text.charCodeAt(colon) === COLON) return colon + 1; - } - return -1; -} - -/** - * The offset of the `[` that opens the entries array, or -1 when the text does not spell - * the path to it (a file of another shape, or one mid-edit). - */ -export function findEntriesArrayStart(text: string): number { - let at = 0; - for (const key of PATH_KEYS) { - at = afterKey(text, key, at); - if (at < 0) return -1; - // Each of these keys holds an object; requiring the brace keeps the walk on the - // structure rather than letting it drift into a following key of the same name. - at = skipWhitespace(text, at); - if (text.charCodeAt(at) !== LBRACE) return -1; - at += 1; - } - at = afterKey(text, 'entries', at); - if (at < 0) return -1; - at = skipWhitespace(text, at); - return text.charCodeAt(at) === LBRACKET ? at : -1; -} - -// A string at depth 2 is one of the element's own KEYS when the nearest non-whitespace -// character before it opens the object or separates its properties; a VALUE is the one that -// follows the `:`. This is the whole reason the name reported for an element is the ENTRY's -// own — never one nested in its properties, and never a value that happens to spell "name". -function isKeyPosition(text: string, quote: number): boolean { - let back = quote - 1; - while (back >= 0 && text.charCodeAt(back) <= 0x20) back--; - const ch = back >= 0 ? text.charCodeAt(back) : 0; - return ch === LBRACE || ch === COMMA; -} - -// The string a `"..."` slice spells, or null when it does not spell one. Almost every entry -// name is plain text, so the common case is a slice; JSON.parse is paid only for a name that -// carries an escape. -function decodeString(raw: string): string | null { - if (!raw.includes('\\')) return raw.slice(1, -1); - try { - const value: unknown = JSON.parse(raw); - return typeof value === 'string' ? value : null; - } catch { - return null; - } -} - -// What may sit between two elements of the array: whitespace, and the one comma that joins -// them (none before the first element, none after the last). -// -// This is checked because a SCALAR element — `[ {…}, 1, {…} ]` — opens no brace and so is -// invisible to the walk below, and an element the walk cannot see is worse than one it -// refuses: deleting an entry removes the span from its own start to the NEXT element's start -// (entrySplice.findEntryElementSpan, which is how the joining comma goes with it), so an -// unseen neighbour in between would be deleted along with it. Refusing costs a "Could not -// locate…" on a file no writer of ours produces; answering would lose data. -function isElementGap(text: string, from: number, to: number, expectComma: boolean): boolean { - let commas = 0; - for (let i = from; i < to; i++) { - const ch = text.charCodeAt(i); - if (ch <= 0x20) continue; - if (ch !== COMMA) return false; - commas++; - } - return commas === (expectComma ? 1 : 0); -} - -/** - * Walk the entries array, reporting every element's span and name — or null when the array - * cannot be walked to its end (a truncated or malformed document, or one mid-edit). - * - * Every caller's answer is derived from this ONE walk, so there is a single account of where - * the entries are: what the repaint locates a change in and what the splice rewrites cannot - * disagree. - */ -function walkElements(text: string, arrayStart: number): EntryElementSpan[] | null { - const elements: EntryElementSpan[] = []; - let depth = 0; - let elementStart = -1; - let name: string | null = null; - let nameSeen = false; - // Where the run of text between elements begins: just inside the `[`, then just after each - // element's `}`. See isElementGap. - let gapFrom = -1; - - // Only five characters can change what this walk knows, so the walk HOPS between them - // with a sticky regex instead of looking at every character itself. The engine's search is - // native; the 46 MB dictionary measures 82 ms this way against 152 ms for a per-character - // loop, which is most of what a keystroke on that file now costs. - const structural = /["{}[\]]/g; - structural.lastIndex = arrayStart; - for (;;) { - const next = structural.exec(text); - if (next === null) break; // no structure left: the array never closed - const i = next.index; - const ch = text.charCodeAt(i); - if (ch === QUOTE) { - const after = afterString(text, i); - if (after < 0) return null; // unterminated string: mid-edit text - structural.lastIndex = after; - // The element's own `"name"`, taken from the FIRST such key exactly as a parse tree's - // property lookup does — including when its value is not a string, which leaves the - // name null so that nothing the model spells can match this element. - if (depth === 2 && !nameSeen && after - i === NAME_KEY.length && text.startsWith(NAME_KEY, i) && isKeyPosition(text, i)) { - const colon = skipWhitespace(text, after); - if (text.charCodeAt(colon) === COLON) { - nameSeen = true; - const valueAt = skipWhitespace(text, colon + 1); - if (text.charCodeAt(valueAt) === QUOTE) { - const valueEnd = afterString(text, valueAt); - if (valueEnd < 0) return null; // unterminated name: mid-edit text - name = decodeString(text.slice(valueAt, valueEnd)); - // Resuming past the value keeps the walk from reading its bytes twice; it holds - // no structure this walk cares about. - structural.lastIndex = valueEnd; - } - } - } - continue; - } - if (ch === LBRACE || ch === LBRACKET) { - depth++; - if (depth === 1) gapFrom = i + 1; // just inside the `[` that opens the array - if (depth === 2) { - // An element that is not an object cannot be an entry; refuse the whole scan - // rather than count it, so the caller never trusts a count it should not. - if (ch !== LBRACE) return null; - if (!isElementGap(text, gapFrom, i, elements.length > 0)) return null; - elementStart = i; - name = null; - nameSeen = false; - } - continue; - } - // What is left is a closer, `}` or `]` — the regex matches nothing else. - depth--; - if (depth === 1) { - if (ch !== RBRACE || elementStart < 0) return null; - elements.push({ offset: elementStart, length: i + 1 - elementStart, index: elements.length, name }); - elementStart = -1; - gapFrom = i + 1; - continue; - } - if (depth === 0) { - // The array closed. Anything else closing here is not the shape we scanned for, and - // anything but whitespace after the last element is an element this walk never saw. - return ch === RBRACKET && isElementGap(text, gapFrom, i, false) ? elements : null; - } - if (depth < 0) return null; - } - // Ran off the end without closing the array. - return null; -} - -/** - * Count the entries array's elements and find the one containing `region`. - * - * Returns null when the array cannot be walked to its end — a truncated or malformed - * document, which the caller answers with the full repaint that reports the parse error. - * - * CONTAINMENT. The region must lie within one element's `{...}` span AND touch its - * interior. Both halves matter: - * - Within, inclusive of the braces, because a table cell edit arrives as a range - * replace of the whole element (see SlddTextEditorProvider.applyEdit) and is exactly - * as safe as a change inside it: everything outside the span is byte-identical, so - * the array's structure is whatever it was, and the element itself is re-parsed. - * - Touching the interior, so that a zero-width change AT a brace — the caret left - * after deleting the comma that separates two elements — is refused. That deletion - * is outside every element, and calling it "inside this one" would report an - * unchanged entry while the document silently stopped being valid JSON. - */ -export function scanEntries(text: string, arrayStart: number, region: ChangeRegion): EntryScan | null { - const elements = walkElements(text, arrayStart); - if (!elements) return null; - const hit = - elements.find((el) => { - const end = el.offset + el.length; - return region.start >= el.offset && region.end <= end && region.start < end && region.end > el.offset; - }) ?? null; - return { count: elements.length, hit }; -} - -/** - * Every element of the entries array, and where the array opens — or null when the text does - * not spell the path to it, or the array cannot be walked to its end. - * - * This is the index the splice finders (entrySplice.ts) resolve a selector against, and the - * one a repaint locates a change in. Callers that hold it hold the whole account of the - * array's shape; nobody has to walk the document twice to ask a second question about it. - */ -export function indexEntries(text: string): EntriesIndex | null { - const arrayStart = findEntriesArrayStart(text); - if (arrayStart < 0) return null; - const elements = walkElements(text, arrayStart); - return elements ? { arrayStart, elements } : null; -} - -/** - * The entries array element a single content change landed in, and how many elements the - * array holds — or null when the text does not answer both. - */ -export function locateChangedEntry(text: string, region: ChangeRegion): EntryScan | null { - const start = findEntriesArrayStart(text); - if (start < 0) return null; - return scanEntries(text, start, region); -} +// Which entry of a JSON .sldd a text change landed in, found by scanning the text: core's +// (data-explorer-core's jsonEntryScan), which this file was word for word. Re-exported under the +// names this extension's tests import until they are rehomed (step 6b's V6); the JSON editor's +// engine (view's JsonEngine) uses core's directly. +export { + findEntriesArrayStart, + indexEntries, + locateChangedEntry, + scanEntries, + type ChangeRegion, + type EntriesIndex, + type EntryElementSpan, + type EntryScan, +} from 'data-explorer-core'; diff --git a/src/host/jsonEntrySync.ts b/src/host/jsonEntrySync.ts index 2ed88da..408ebd4 100644 --- a/src/host/jsonEntrySync.ts +++ b/src/host/jsonEntrySync.ts @@ -1,430 +1,22 @@ // Copyright 2026 The MathWorks, Inc. // -// Turn one text change in a JSON .sldd into one entry-level model op — or into nothing, -// which means "repaint the old way". -// -// This is the JSON half of the narrow repaint. The binary provider owns its undo stack, so -// it knows what each change was and can describe it as ops directly (entryOps.ts). Here the -// document is a TextDocument the user may be typing into, and the only thing the host is -// handed is a range of text that changed. So the op has to be RECOVERED from the text: -// find the entry the change is inside (jsonEntryScan.ts), re-parse just that element, and -// replace that one entry in the model that is already built. -// -// EVERY DOUBT IS A "NO". A plan is only produced when the text says something narrow -// beyond argument: the change lies in one element, the array still holds exactly as many -// elements as the model has entries, that element parses on its own, and it still names -// the same entry in the same section. Anything else — a structural edit, a mid-edit -// document that is not valid JSON, a change to the file's header, an entry that moved -// namespace — returns null, and the caller falls back to the full parse-and-repaint, -// which is what happens today for all of them. So the failure mode is the old latency, -// never a wrong table. -// -// WHY A WRONG GUESS COULD NOT CORRUPT THE FILE. In this format the TextDocument is the -// truth and the model is only a view of it: saving writes the text, and every structural -// edit re-parses the text before touching it. A plan that resolved the wrong entry would -// mis-paint a row until the next full repaint, not write anything wrong to disk. - -import { locateChangedEntry, type ChangeRegion } from './jsonEntryScan.js'; -import type { EntryOp, EntryPatch, EntryRecord } from './entryOps.js'; - -/** The one entry a change touched, and the record the text now spells for it. */ -export interface EntrySyncPlan { - /** The live node in the model the caller passed. */ - entry: any; - /** Its element re-parsed — exactly what `SectionNode.parseEntry` takes. */ - record: EntryRecord; - /** - * Always true: the record is the document's own text, so the replace op built from this plan - * says so (EntryOp's `fromDocumentText`) and core reads it as the file's read does. - */ - fromDocumentText: true; -} - -/** A single `vscode.TextDocumentContentChangeEvent`, as much of it as matters here. */ -export interface TextChange { - /** Offset in the text BEFORE the change. */ - rangeOffset: number; - /** The text that replaced that range. */ - text: string; -} - -/** - * A whole range replacement — what the host SUBMITS to the document, and what a change event - * reports back. - * - * The plan above needs only where the new text starts and what it says (it reads the entry - * back out of the document either way). Recognising an edit needs the length too, because - * that is the difference between "the same text was written here" and "the same text - * replaced the same bytes". - */ -export interface RangeReplacement extends TextChange { - /** How many characters of the old text the replacement covered. */ - rangeLength: number; -} - -/** - * Whether a change event is the echo of `submitted` — the edit THIS host just made. - * - * The table's edit path mutates the model, serializes the entry, and splices it into the - * text; the change event that splice fires carries nothing the host does not already know. - * So it repaints from the node it changed, and the whole recovery the text-view path needs — - * scan the array, re-parse the element, rebuild the entry a second time — is skipped. - * - * What makes that safe is asking the event to prove it is the same edit, byte for byte, - * rather than trusting a flag set beforehand. A submitted edit whose event never arrives (or - * arrives changed, e.g. line endings normalized on the way in) leaves the expectation - * behind; spending it on the NEXT change — a keystroke in the text view, an undo, a save - * fixup — would repaint one entry and leave stale rows for whatever really changed. Refusing - * costs the wide repaint the user has always had. - * - * A batch is refused outright: only a lone change has an offset that means anything in the - * text after it, which is the same reason planEntrySync's caller refuses one. - */ -export function isEchoOfEdit(changes: readonly RangeReplacement[], submitted: RangeReplacement): boolean { - if (changes.length !== 1) return false; - const change = changes[0]; - return ( - change.rangeOffset === submitted.rangeOffset && - change.rangeLength === submitted.rangeLength && - change.text === submitted.text - ); -} - -/** - * A write the host painted AHEAD of: its rows went out before a byte was written, so the change - * event the write fires has nothing left to repaint — as long as those rows are still the ones - * on screen. - * - * `epoch` is what says they are. It counts the wide repaints the view has begun, read when the - * paint went out; a wide repaint begun since has replaced every row, and it read the text as it - * was before this write landed, because nothing waits for a write. See isPaintedEcho. - */ -export interface PaintedWrite { - /** The range replacement handed to the document. */ - submitted: RangeReplacement; - /** How many wide repaints the view had begun when this write's rows were painted. */ - epoch: number; -} - -/** - * Whether a change event is the echo of a write whose rows are still on screen — the one case - * where the event can be skipped. - * - * Byte for byte, as isEchoOfEdit asks, AND with no wide repaint begun since the paint. A save, a - * webview asking for its rows (`ready`) and a clipboard broadcast over rows out of step each - * repaint the whole table without waiting for a write in flight, and each reads the text as it - * stands, which until the write lands is the text from BEFORE the edit: the screen goes back to - * the old rows and the model is rebuilt from the old text. Skipped then, the echo left both - * saying what the file no longer does. Refused, it is a change like any other, applied to the - * tree that repaint built — the price is the repaint, never a wrong row. - */ -export function isPaintedEcho( - changes: readonly RangeReplacement[], - painted: PaintedWrite, - epoch: number, -): boolean { - return painted.epoch === epoch && isEchoOfEdit(changes, painted.submitted); -} - -/** - * An edit the host wrote, kept AFTER its own event has come and gone — because VS Code can - * hand it back again, in either direction, whenever the user asks. - * - * An undo of a range replacement writes the replaced bytes back over the replacement, at the - * same offset; a redo writes the replacement again. So the pair is all it takes to recognise - * either one, and recognising it is what turns an undo from a stranger's change (find the - * entry: `getText()` + a full structural walk, 35 ms + 138 ms on a 47.8 MB dictionary) into an - * element already in hand. - */ -export interface KnownEdit { - /** The range replacement handed to the document. */ - submitted: RangeReplacement; - /** The text it wrote over — what an undo of it writes back. */ - replaced: string; - /** - * The model ops that restore either side of it, for a STRUCTURAL edit — one whose meaning - * cannot be read back out of the bytes it writes (see planKnownOps). A cell edit needs none: - * the bytes it writes are one element, and re-parsing that element says everything. - */ - patch?: EntryPatch; -} - -/** - * What the host remembers about the edit it just submitted, until the change event arrives. - * - * Set immediately before the WorkspaceEdit and spent by the very next change event, once. - * - * ONE id, because the edit has painted by the time this is set: the model holds the entry under - * the name the edit gave it, and so do the rows on screen. It used to carry the pre-rename id as - * well, for the rows — from when the echo was the paint, and the table still held the entry under - * its old name. Once the paint came first, that id named a run the table no longer had, every - * rename's confirmation missed it, and the table asked for the whole payload to recover. - */ -export interface HostEdit { - /** The edited entry as the MODEL and the ROWS ON SCREEN now spell it — after any rename. */ - entryId: string; - /** The range replacement handed to the document. */ - submitted: RangeReplacement; - /** - * How many wide repaints the view had begun when this was set — the same stamp, for the same - * reason, as PaintedWrite's: a wide repaint begun since has replaced the rows `entryId` names - * (see hintFor). - */ - epoch: number; -} - -/** - * The expectation a change event may use, or null. - * - * The hint names the rows the edit painted and the entry as the edit left the model. A wide - * repaint begun since has rebuilt both from the text before the write landed, so neither is - * true any more, and the event is a change like any other to the tree that repaint built — - * which the recovery path plans by itself (planEntrySync), from the rows that are on screen. - */ -export function hintFor(hint: HostEdit | null, epoch: number): HostEdit | null { - return hint && hint.epoch === epoch ? hint : null; -} - -/** The op the host's own edit amounts to, and the run of rows to paint it over. */ -export interface OwnEditPlan { - op: EntryOp; - /** The row id the table is holding the entry under (see HostEdit.entryId). */ - entryRowId: string; -} - -/** - * The entry op the host's OWN edit amounts to — no scanning, no re-parse of the document. - * - * The table's edit path mutates the model, serializes the entry, and splices it into the text. - * The change event that splice fires carries nothing the host does not already know, so the - * whole recovery the text-view path needs — scan the array, find the element the change is in, - * re-parse it, check the entry count still matches — is skipped: the changed element IS the - * text that was submitted. - * - * WHY THE ENTRY IS REBUILT FROM THAT TEXT rather than repainted from the node the edit mutated, - * which is what the binary provider does and is cheaper still. A mutation is not a re-parse, and - * where the two disagree the repaint would show something the next wide repaint takes away. The - * three disagreements this path was written around have since been fixed at the model layer — a - * rename now moves the systemComposer catalog with it, and the two cells whose text serialize() - * dropped (a Description a node has nowhere to write, the Name of an object's Value row) are - * refused instead of accepted. Reading the bytes back is what made this path correct BEFORE they - * were, and what keeps it from depending on the next one being found: "the table says what the - * file says" holds by construction, and still costs a fraction of finding the entry again (7 ms - * against 290 ms on a 46 MB dictionary). - * - * A change that does not prove itself to be the echo is refused (see isEchoOfEdit), and the - * caller falls back to the recovery path, which reads the same bytes the slow way. - */ -export function planOwnEdit( - changes: readonly RangeReplacement[], - hint: HostEdit, -): OwnEditPlan | null { - if (!isEchoOfEdit(changes, hint.submitted)) return null; - let record: unknown; - try { - record = JSON.parse(hint.submitted.text); - } catch { - return null; - } - if (!record || typeof record !== 'object' || Array.isArray(record)) return null; - // The op resolves the entry as the MODEL spells it (the mutation renamed it already), and the - // repaint lands on the same id, which is the one the paint left the rows under. - // The record is the text now in the document, so the op says so (see EntryOp). - return { - op: { kind: 'replace', rowId: hint.entryId, record: record as EntryRecord, fromDocumentText: true }, - entryRowId: hint.entryId, - }; -} - -function metaString(metadata: unknown, key: string): string { - const bag = (metadata ?? {}) as Record; - const value = bag[key]; - return typeof value === 'string' ? value : ''; -} - -// The live entry a record names, and the model's total entry count, in one walk. -// -// Resolution is by uuid FIRST because it is the rename-stable half of an entry's identity: -// a rename typed into the text view changes `name` and nothing else, so the entry the -// model holds still answers to the old one. The name is the fallback for a record that -// carries no uuid. Either way an ambiguous answer is no answer — a file with two entries -// of one uuid, or of one name across two namespaces, is exactly where guessing would paint -// the row the user did not touch. -function resolve(model: any, uuid: string, name: string): { count: number; entry: any } { - let count = 0; - let byUuid: any = null; - let uuidHits = 0; - let byName: any = null; - let nameHits = 0; - for (const section of (model?.children ?? []) as any[]) { - for (const entry of (section?.children ?? []) as any[]) { - count++; - if (uuid && metaString(entry.metadata, 'uuid') === uuid) { - byUuid = entry; - uuidHits++; - } - if (entry.name === name) { - byName = entry; - nameHits++; - } - } - } - if (uuidHits === 1) return { count, entry: byUuid }; - if (uuidHits === 0 && nameHits === 1) return { count, entry: byName }; - return { count, entry: null }; -} - -/** - * The entry one element's text spells, resolved in the model that is already built. - * - * The half both paths share: where the element text CAME from is the only thing they disagree - * about (a scan of the document, or an edit the host recognises), and everything after it — - * does it parse, does it name an entry this model holds, does it still belong in the same - * section — is the same question asked of the same bytes. - * - * `arrayCount` is the number of elements the array was found to hold, for the caller that - * scanned for them: one element is one entry, so a count that still matches the model proves - * the change restructured nothing. The caller that recognised its own edit passes none, and - * needs none — a range replacement of one element by one element (which is what the parse - * below proves the text to be) cannot add or remove an element. - */ -function planFromElementText(model: any, elementText: string, arrayCount?: number): EntrySyncPlan | null { - let record: unknown; - try { - record = JSON.parse(elementText); - } catch { - return null; // mid-edit element: the full repaint reports it as the parse error it is - } - if (!record || typeof record !== 'object' || Array.isArray(record)) return null; - const entryRecord = record as EntryRecord; - const name = entryRecord.name; - if (typeof name !== 'string' || name.length === 0) return null; - - const { count, entry } = resolve(model, metaString(entryRecord.metadata, 'uuid'), name); - if (arrayCount !== undefined && count !== arrayCount) return null; - if (!entry) return null; - - // The section an entry lands in is derived from these two metadata fields alone - // (SlddNode.getSectionKey). A change to either MOVES the entry to another section, which - // is not a replacement in place, so it is not this path's business. - const from = entry.metadata; - if (metaString(entryRecord.metadata, 'namespace') !== metaString(from, 'namespace')) return null; - if (metaString(entryRecord.metadata, 'isderived') !== metaString(from, 'isderived')) return null; - - return { entry, record: entryRecord, fromDocumentText: true }; -} - -/** - * Whether a change event is `known` being UNDONE — the mirror of isEchoOfEdit. - * - * VS Code states an undo the way it states any other change: these bytes replaced those. What - * makes this one recognisable is that the host wrote the bytes being replaced, so it knows all - * three numbers in advance — same offset, a range as long as what it wrote, and the text it - * wrote over coming back verbatim. - */ -function isUndoOfEdit(changes: readonly RangeReplacement[], known: KnownEdit): boolean { - if (changes.length !== 1) return false; - const change = changes[0]; - return ( - change.rangeOffset === known.submitted.rangeOffset && - change.rangeLength === known.submitted.text.length && - change.text === known.replaced - ); -} - -/** - * The entry op an undo or a redo of one of the host's OWN edits amounts to — no - * `document.getText()`, no scan. - * - * This is the third and last of the "table → text → table" round trip. A cell edit paints from - * the model immediately (applyEdit) and its echo is recognised (planOwnEdit); the undo of that - * same edit used to arrive as a stranger and pay the full recovery — pull the whole document - * out as a string (35 ms on 47.8 MB) and walk it structurally to find the element the change - * is in (138 ms) — to learn something the host wrote down when it made the edit. - * - * `known` is the ring of edits the host still remembers, newest last; it is searched - * newest-first because that is the order undo works in. The plan is built from the CHANGE's - * own text, not from the remembered copy: the match has just proved the two are the same - * bytes, and planning from what the document actually now says is the version of that - * statement that cannot drift. - * - * Every guard that matters is inherited: recognition is byte-exact at an exact offset (an edit - * elsewhere shifts the offset and the match simply fails), a batch is refused because its - * later offsets are stated against the text before it, and the element still has to parse and - * still has to name an entry this model holds in the same section. A miss costs the scan, - * which is what every undo used to cost. - */ -export function planKnownChange( - model: any, - changes: readonly RangeReplacement[], - known: readonly KnownEdit[], -): EntrySyncPlan | null { - if (changes.length !== 1) return null; - for (let i = known.length - 1; i >= 0; i--) { - const past = known[i]; - if (isEchoOfEdit(changes, past.submitted) || isUndoOfEdit(changes, past)) { - return planFromElementText(model, changes[0].text); - } - } - return null; -} - -/** - * The model ops an undo or a redo of one of the host's own STRUCTURAL edits amounts to. - * - * planKnownChange above recovers a cell edit's undo by re-parsing the bytes that came back, - * which works because those bytes are one element and an element says everything about the - * entry it names. A structural edit's bytes do not. A delete writes back an element AND the - * comma that separated it; a paste's undo writes back nothing at all; a move's write spans two - * places at once. There is no element to read, so what the change MEANS is not in its text — it - * is in what the host knew when it made the edit, which is why the ops are remembered with it. - * - * Recognition is the same byte-exact test in both directions (isEchoOfEdit for a redo, - * isUndoOfEdit for an undo), against the same newest-first ring, and inherits the same guards: - * an edit anywhere else shifts the offset and the match fails, and a batch is refused because - * its later offsets are stated against the text before it. An edit remembered without ops - * answers null and is left to the paths that read bytes. - * - * A miss costs the wide repaint, which is what every structural undo used to cost. - */ -export function planKnownOps( - changes: readonly RangeReplacement[], - known: readonly KnownEdit[], -): EntryOp[] | null { - if (changes.length !== 1) return null; - for (let i = known.length - 1; i >= 0; i--) { - const past = known[i]; - if (!past.patch) continue; - if (isEchoOfEdit(changes, past.submitted)) return past.patch.redo; - if (isUndoOfEdit(changes, past)) return past.patch.undo; - } - return null; -} - -/** - * The entry op one text change amounts to, or null when it does not amount to one. - * - * `model` is the tree already built for this document, in the state the text was in - * BEFORE the change; `newText` is the document as it now reads. - */ -export function planEntrySync(model: any, newText: string, change: TextChange): EntrySyncPlan | null { - // A multi-change event is refused by the caller, not here: only a lone change has - // offsets that mean anything in the new text (see SlddTextEditorProvider). - const region: ChangeRegion = { - start: change.rangeOffset, - end: change.rangeOffset + change.text.length, - }; - const scan = locateChangedEntry(newText, region); - if (!scan || !scan.hit) return null; - - // The element the change landed in, and THE structural guard with it — the reason nothing - // here has to reason about what the change did to the array: one element is one entry - // (SectionNode.parseEntry always adds exactly one child), so a count that still matches the - // model proves no element was added, removed, or split. A change that restructured the array - // fails it and goes wide. - return planFromElementText( - model, - newText.slice(scan.hit.offset, scan.hit.offset + scan.hit.length), - scan.count, - ); -} +// One text change in a JSON .sldd as one entry-level model op, or nothing: core's (data-explorer- +// core's jsonEntrySync), which this file was word for word. Re-exported under the names this +// extension's tests import until they are rehomed (step 6b's V6); the JSON editor's engine (view's +// JsonEngine) uses core's directly. +export { + hintFor, + isEchoOfEdit, + isPaintedEcho, + planEntrySync, + planKnownChange, + planKnownOps, + planOwnEdit, + type EntrySyncPlan, + type HostEdit, + type KnownEdit, + type OwnEditPlan, + type PaintedWrite, + type RangeReplacement, + type TextChange, +} from 'data-explorer-core'; diff --git a/src/host/perDocument.ts b/src/host/perDocument.ts new file mode 100644 index 0000000..7962429 --- /dev/null +++ b/src/host/perDocument.ts @@ -0,0 +1,77 @@ +// Copyright 2026 The MathWorks, Inc. +// +// One object per open document, shared by every table of it: made by the first table to open, and +// disposed with the last to close, so the next one to open makes a fresh one. For an editor that +// VS Code hands only a TextDocument per tab (a CustomTextEditorProvider has no document object of +// its own): what was the JSON editor's module-level map of document state by uri (`openDocs`), kept +// across provider instances as that was. +// +// The object is bound to the TextDocument it was made for (the JSON editor's engine reads and +// writes that document's text), so a table only joins it while it is still that document, open: a +// table that was never closed (a resolve that threw before its panel's dispose was wired) must not +// hand a later tab an engine over a closed document's text, whose edit ranges would then be applied, +// by uri, to the live one. Such a stale object is disposed, and the new table makes a fresh one. +// +// vscode-free, so it is unit-tested (test/perDocument.test.ts). + +/** Documents by uri, each with the count of tables open on it. */ +export class PerDocument { + private readonly held = new Map(); + + /** + * A table of the document at `uri` opens: its object, made with `make` if no table holds one that + * `fits` (by default any), and the table's `close`, which disposes the object once no table is + * left (once, however often called). A held object that does not fit is disposed first, with every + * table still holding it, whose close then has nothing left to dispose. + */ + open(uri: string, make: () => T, fits: (held: T) => boolean = () => true): { value: T; close(): void } { + let entry = this.held.get(uri); + if (entry && !fits(entry.value)) { + this.held.delete(uri); + entry.gone = true; + entry.value.dispose(); + entry = undefined; + } + if (!entry) { + entry = { value: make(), tables: 0 }; + this.held.set(uri, entry); + } + entry.tables++; + const held = entry; + let closed = false; + return { + value: held.value, + close: () => { + if (closed || held.gone) return; + closed = true; + if (--held.tables > 0) return; + if (this.held.get(uri) === held) this.held.delete(uri); + held.value.dispose(); + }, + }; + } + + /** The object a table of `uri` holds, if any does. */ + peek(uri: string): T | undefined { + return this.held.get(uri)?.value; + } +} + +/** Whether an object made for `held` still fits a table of `document`: the same document, and still open. */ +export function sameOpenDocument(held: { readonly document: D }, document: D): boolean { + return held.document === document && !document.isClosed; +} + +/** + * `rest`, run with a table just opened; if it throws, the table is closed before the throw goes on, + * so that nothing is left holding the document for a later table (its close is what a disposed + * panel would have called, had `rest` lived to wire it). + */ +export function closingOnThrow(opened: T, rest: (opened: T) => R): R { + try { + return rest(opened); + } catch (err) { + opened.close(); + throw err; + } +} diff --git a/src/host/slddFormat.ts b/src/host/slddFormat.ts index 652a417..cfb5bd1 100644 --- a/src/host/slddFormat.ts +++ b/src/host/slddFormat.ts @@ -1,86 +1,16 @@ // Copyright 2026 The MathWorks, Inc. - -// Pure content-format detection for .sldd routing. A .sldd is either editable -// JSON (→ text-backed table view with native undo/redo) or a zip/binary archive -// (→ read-only binary view). Kept VS-Code-free so it is unit-testable. - -/** True if `bytes` begins with the ZIP local-file-header magic (PK\x03\x04). */ -export function isZipBytes(bytes: Uint8Array): boolean { - return ( - bytes.length >= 4 && - bytes[0] === 0x50 && - bytes[1] === 0x4b && - bytes[2] === 0x03 && - bytes[3] === 0x04 - ); -} - -// V8 refuses to create a string longer than 0x1fffffe8 (~512 MB) code units, so -// `new TextDecoder().decode(bytes)` THROWS above that — we can neither validate -// nor JSON.parse the content, so no table can be built. (JSON .sldd is ~ASCII, -// so UTF-16 code-unit count ≈ byte length; gating on byte length is safe and -// only conservative for the multibyte case, which .sldd never hits.) Such files -// are routed to the plain text editor instead. Distinct from and larger than -// TEXT_SYNC_LIMIT: 50 MB–512 MB JSON .sldd still renders as a read-only table. -export const STRING_DECODE_LIMIT = 0x1fffffe8; - -/** True if `bytes` is too large to decode into a single JS string (V8 limit). */ -export function exceedsStringDecodeLimit(bytes: Uint8Array): boolean { - return bytes.length > STRING_DECODE_LIMIT; -} - -/** - * True if `text` is well-formed JSON. - * - * Every JSON .sldd write is a text splice, and the splice helpers parse with - * jsonc-parser, which is deliberately tolerant: it recovers from a missing brace - * or a stray character and still reports an entry span. That tolerance is right - * for READING text the user is mid-way through typing, but a splice computed - * against a recovered parse writes back text that was never valid and now has a - * chunk cut out of it. So each write path checks the document parses strictly - * FIRST, and reports rather than edits when it does not. - */ -export function parsesAsJson(text: string): boolean { - try { - JSON.parse(text); - return true; - } catch { - return false; - } -} - -/** - * True if the bytes are an editable JSON .sldd: not a zip archive AND valid - * JSON. Binary/zip .sldd and non-JSON content return false (→ read-only view). - * Content too large to decode also returns false (→ handled upstream), so this - * never attempts a decode that would throw. - */ -export function isEditableJsonSlddBytes(bytes: Uint8Array): boolean { - if (isZipBytes(bytes)) return false; - if (exceedsStringDecodeLimit(bytes)) return false; - try { - return parsesAsJson(new TextDecoder().decode(bytes)); - } catch { - // decode itself can throw on malformed input; treat that as not-JSON. - return false; - } -} - -// VS Code refuses to mirror a TextDocument larger than TextModel._MODEL_SYNC_LIMIT -// (50 MB) into the extension host. Our editable JSON .sldd table is a -// CustomTextEditorProvider, which depends on that mirror — so past this size, -// resolving it throws "Unable to retrieve document from URI" in the ext host -// before our provider code ever runs, and the table fails to open. Such files -// are routed to the read-only byte-backed view instead (it reads bytes directly -// via workspace.fs, so it isn't subject to the sync limit). Editing is -// impossible above the limit regardless, since VS Code won't sync the document. // -// VS Code measures the model in UTF-16 code units; we gate on byte length, which -// is always >= the UTF-16 length for UTF-8. So any file kept on the editable path -// (byte length <= limit) is guaranteed to sync — no false downgrades. -export const TEXT_SYNC_LIMIT = 50 * 1024 * 1024; - -/** True if `bytes` is too large for VS Code to sync as an editable TextDocument. */ -export function exceedsTextSyncLimit(bytes: Uint8Array): boolean { - return bytes.length > TEXT_SYNC_LIMIT; -} +// Pure content-format detection for .sldd routing: core's (data-explorer-core's slddFormat), which +// this file was word for word. A .sldd is either editable JSON (→ text-backed table view with +// native undo/redo) or a zip/binary archive; a JSON one too large for the extension host to sync +// as a TextDocument opens read-only. Re-exported under the names extension.ts, the binary +// providers and this extension's tests import until they are rehomed (step 6b's V6). +export { + STRING_DECODE_LIMIT, + TEXT_SYNC_LIMIT, + exceedsStringDecodeLimit, + exceedsTextSyncLimit, + isEditableJsonSlddBytes, + isZipBytes, + parsesAsJson, +} from 'data-explorer-core'; diff --git a/src/host/textDocumentStore.ts b/src/host/textDocumentStore.ts new file mode 100644 index 0000000..7915d99 --- /dev/null +++ b/src/host/textDocumentStore.ts @@ -0,0 +1,95 @@ +// Copyright 2026 The MathWorks, Inc. +// +// A JSON dictionary's TextDocument as view's JSON engine reads and writes its text (data-explorer-view +// /host's TextStore): the engine's rules were the JSON editor's, written against a TextDocument, so +// this hands it that document as the editor used it, call for call. +// +// - The text, its version and its dirty flag are the document's, read when asked: a change's +// listener reads them as the editor read them off the event's document, which is this one. +// - One edit is ONE WorkspaceEdit, a replacement per patch in the order given, every range taken +// against the text as it stands when asked, then applied; whatever the apply answers (true, +// false, or a rejection) is the edit's answer. Built before anything is awaited, as the editor +// built its own, so nothing can move the text between the ranges and the apply. +// - Every listener is a subscription of its own to the document's changes and saves, filtered by +// uri, as each tab subscribed: the listeners run in the order they subscribed, after +// extension.ts's own (its keystroke invalidate), as they did. A change is its replacements as the +// event lists them, and an undo or a redo says so. +// - No reload: VS Code reloads a clean TextDocument from its file itself, and the engine hears that +// as any other change. Save, revert and a hot-exit backup are VS Code's too. +// +// vscode-free, so its rules are pinned in vitest (test/textDocumentStore.test.ts): the VS Code calls +// are handed in (jsonDocument.ts). +import type { TextPatch } from 'data-explorer-core'; +import type { TextStore, TextStoreChange } from 'data-explorer-view/host'; + +/** What this needs of a TextDocument (vscode.TextDocument, whose positions are `P`). */ +export interface TextDocumentLike

{ + readonly uri: { toString(): string }; + readonly version: number; + readonly isDirty: boolean; + getText(): string; + positionAt(offset: number): P; +} + +/** What this needs of a TextDocumentChangeEvent. */ +export interface TextDocumentChangeLike { + readonly document: { readonly uri: { toString(): string } }; + readonly contentChanges: readonly { readonly rangeOffset: number; readonly rangeLength: number; readonly text: string }[]; + readonly reason?: number; +} + +/** VS Code's end: the workspace's edit, its document events, its Undo and Redo. */ +export interface TextDocumentApi

{ + /** One WorkspaceEdit of the document holding these replacements, in this order, applied. */ + applyEdit(replacements: readonly { start: P; end: P; text: string }[]): PromiseLike; + onDidChangeTextDocument(listener: (e: TextDocumentChangeLike) => void): { dispose(): void }; + onDidSaveTextDocument(listener: (document: { readonly uri: { toString(): string } }) => void): { dispose(): void }; + /** The `undo` and `redo` commands. */ + undo(): void; + redo(): void; + /** TextDocumentChangeReason's Undo and Redo. */ + readonly reasons: { readonly undo: number; readonly redo: number }; +} + +/** `document`'s text as a TextStore, through `api`. */ +export function textDocumentStore

(document: TextDocumentLike

, api: TextDocumentApi

): TextStore { + const uri = document.uri.toString(); + const reasonOf = (reason: number | undefined): 'undo' | 'redo' | undefined => + reason === api.reasons.undo ? 'undo' : reason === api.reasons.redo ? 'redo' : undefined; + return { + get text() { + return document.getText(); + }, + get version() { + return document.version; + }, + get dirty() { + return document.isDirty; + }, + apply(patches: readonly TextPatch[]): Promise { + const replacements = patches.map((p) => ({ + start: document.positionAt(p.offset), + end: document.positionAt(p.offset + p.length), + text: p.text, + })); + return Promise.resolve(api.applyEdit(replacements)); + }, + undo: () => api.undo(), + redo: () => api.redo(), + onChange(listener: (change: TextStoreChange) => void): () => void { + const sub = api.onDidChangeTextDocument((e) => { + if (e.document.uri.toString() !== uri) return; + const changes = e.contentChanges.map((c) => ({ rangeOffset: c.rangeOffset, rangeLength: c.rangeLength, text: c.text })); + const reason = reasonOf(e.reason); + listener(reason ? { changes, reason } : { changes }); + }); + return () => sub.dispose(); + }, + onSave(listener: () => void): () => void { + const sub = api.onDidSaveTextDocument((saved) => { + if (saved.uri.toString() === uri) listener(); + }); + return () => sub.dispose(); + }, + }; +} diff --git a/test/coreReExports.test.ts b/test/coreReExports.test.ts new file mode 100644 index 0000000..20c72c8 --- /dev/null +++ b/test/coreReExports.test.ts @@ -0,0 +1,42 @@ +// Copyright 2026 The MathWorks, Inc. +// +// Four host files that were core's code word for word (jsonEntryScan, entrySplice, jsonEntrySync, +// slddFormat) are now core's, re-exported under the names this extension's tests and its other +// files import: each name is core's own function or value. And entrySplice's +// `findEntryElementSpan` is the JSON one, core's `findJsonEntryElementSpan`, not core's own +// `findEntryElementSpan`, which is the binary dictionary's XML finder: a plain re-export of the +// name would have handed every JSON caller the wrong function. +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import * as core from 'data-explorer-core'; +import * as jsonEntryScan from '../src/host/jsonEntryScan.js'; +import * as entrySplice from '../src/host/entrySplice.js'; +import * as jsonEntrySync from '../src/host/jsonEntrySync.js'; +import * as slddFormat from '../src/host/slddFormat.js'; + +const PARAMS = readFileSync(join(import.meta.dirname, '..', 'test-integration', 'fixtures', 'workspace', 'params.sldd'), 'utf8'); + +const ALIASES: Record = { findEntryElementSpan: 'findJsonEntryElementSpan' }; + +describe("the host files that were core's, re-exported", () => { + for (const [file, mod] of Object.entries({ jsonEntryScan, entrySplice, jsonEntrySync, slddFormat })) { + it(`${file}.ts exports core's own, under the names it always had`, () => { + const names = Object.keys(mod); + expect(names.length).toBeGreaterThan(0); + for (const name of names) { + expect((mod as Record)[name], name).toBe((core as Record)[ALIASES[name] ?? name]); + } + }); + } + + it("entrySplice's findEntryElementSpan is the JSON finder: it finds a JSON entry's element, where core's XML one does not", () => { + expect(entrySplice.findEntryElementSpan).toBe(core.findJsonEntryElementSpan); + expect(entrySplice.findEntryElementSpan).not.toBe(core.findEntryElementSpan); + const name = (JSON.parse(PARAMS).__MW_TEXT_PARTS__['__MW_TEXT_PART__/data/chunk0'].__MW_TEXT_content.entries as { name: string }[])[0].name; + const span = entrySplice.findEntryElementSpan(PARAMS, name); + expect(span, `the JSON finder finds "${name}"`).not.toBeNull(); + expect(PARAMS.slice(span!.offset, span!.offset + span!.length)).toContain(`"${name}"`); + expect((core.findEntryElementSpan as (text: string, selector: string) => unknown)(PARAMS, name)).toBeNull(); + }); +}); diff --git a/test/engineTable.test.ts b/test/engineTable.test.ts index a3463d4..7fc24d7 100644 --- a/test/engineTable.test.ts +++ b/test/engineTable.test.ts @@ -10,8 +10,9 @@ import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; import { createSession } from 'data-explorer-core'; import { createRegisters } from 'data-explorer-view/model'; -import { BinaryEngine, EditStack, EditStackIndex, MatEngine } from 'data-explorer-view/host'; -import { binaryRows, engineTable, matRows } from '../src/host/engineTable.js'; +import { BinaryEngine, EditStack, EditStackIndex, JsonEngine, MatEngine, defaultRegistry } from 'data-explorer-view/host'; +import { binaryRows, engineTable, jsonRows, matRows } from '../src/host/engineTable.js'; +import { textDocumentStore } from '../src/host/textDocumentStore.js'; /* eslint-disable @typescript-eslint/no-explicit-any */ @@ -187,6 +188,7 @@ describe("the compressed dictionary editor's panel of the engine's table (rows n const relay = engineTable(engine.view(), panel, { name: 'params.sldd', awaitRows: false, + readyOpens: false, setRows: binaryRows, afterPaint: () => said.push(`drained after ${posted.map((m) => m.type).join(',')}`), }); @@ -213,9 +215,109 @@ describe("the compressed dictionary editor's panel of the engine's table (rows n return true; }, }; - engineTable(engine.view(), panel, { name: 'params.sldd', awaitRows: false, setRows: binaryRows }).ready(); + engineTable(engine.view(), panel, { name: 'params.sldd', awaitRows: false, readyOpens: false, setRows: binaryRows }).ready(); await settle(); expect(posted.map((m) => m.type)).toEqual(['sectionRules', 'clipboardState', 'error']); expect(posted.at(-1).message).toMatch(/^Failed to show params\.sldd: /); }); }); + +const PARAMS_JSON = readFileSync(join(import.meta.dirname, '..', 'test-integration', 'fixtures', 'workspace', 'params.sldd'), 'utf8'); + +/** A JSON dictionary over a TextDocument that holds still (textDocumentStore.ts, a fake document and workspace). */ +function jsonDictionary(uri: string) { + const document = { uri: { toString: () => uri }, version: 1, isDirty: false, getText: () => PARAMS_JSON, positionAt: (o: number) => o }; + const text = textDocumentStore(document, { + applyEdit: async () => false, + onDidChangeTextDocument: () => ({ dispose: () => {} }), + onDidSaveTextDocument: () => ({ dispose: () => {} }), + undo: () => {}, + redo: () => {}, + reasons: { undo: 2, redo: 3 }, + }); + return new JsonEngine({ + registry: defaultRegistry, + uri, + name: 'params.sldd', + text, + registers: createRegisters(), + disk: { stat: async () => null, read: async () => null }, + trailerAfterDelivery: true, + }); +} + +/** Turns until `ok`, for a paint that waits on its Usage column. */ +async function until(ok: () => boolean): Promise { + for (let i = 0; i < 50 && !ok(); i++) await new Promise((r) => setTimeout(r, 0)); + expect(ok()).toBe(true); +} + +describe("the JSON dictionary editor's panel of the engine's table (ready opens, rows not awaited)", () => { + it("posts a paint as the editor's post() did: the rows, then in the same run what follows them, then the drain", async () => { + const engine = jsonDictionary('file:///work/relay-json-order.sldd'); + const table = engine.view(); + const posted: any[] = []; + const said: string[] = []; + const panel = { postMessage: async (m: any) => (posted.push(m), true) }; + const relay = engineTable(table, panel, { + name: 'params.sldd', + awaitRows: false, + setRows: jsonRows, + afterPaint: () => said.push(`drained after ${posted.map((m) => m.type).join(',')}`), + }); + relay.ready(); + await until(() => said.length > 0); + expect(posted.map((m) => m.type)).toEqual(['setRows', 'sectionRules', 'clipboardState']); + expect(said).toEqual(['drained after setRows,sectionRules,clipboardState']); + expect(Object.keys(posted[0])).toEqual(['type', 'docUri', 'rows', 'columns', 'columnLabels', 'columnGroups', 'editable', 'hasTextView', 'warnings']); + expect(posted[0].hasTextView).toBe(true); + // A repaint the engine starts: its rows, what follows them, then the drain, in that order. + posted.length = 0; + said.length = 0; + await table.repaintAll(); + expect(posted.map((m) => m.type)).toEqual(['setRows', 'sectionRules', 'clipboardState']); + expect(said).toEqual(['drained after setRows,sectionRules,clipboardState']); + relay.dispose(); + table.dispose(); + engine.dispose(); + }); + + it('answers two readys before the first paint lands with one paint, as the newest repaint was the one that landed', async () => { + const engine = jsonDictionary('file:///work/relay-json-twice.sldd'); + const table = engine.view(); + const posted: any[] = []; + let drained = 0; + const panel = { postMessage: async (m: any) => (posted.push(m), true) }; + const relay = engineTable(table, panel, { name: 'params.sldd', awaitRows: false, setRows: jsonRows, afterPaint: () => void drained++ }); + relay.ready(); + relay.ready(); + await until(() => drained > 0); + await new Promise((r) => setTimeout(r, 10)); + expect(posted.map((m) => m.type)).toEqual(['setRows', 'sectionRules', 'clipboardState']); + expect(drained).toBe(1); + relay.dispose(); + table.dispose(); + engine.dispose(); + }); + + it('says rows it cannot deliver failed to show, after what follows them, as the unawaited post did', async () => { + const engine = jsonDictionary('file:///work/relay-json-refused.sldd'); + const table = engine.view(); + const posted: any[] = []; + const panel = { + postMessage: async (m: any) => { + if (m.type === 'setRows') throw new Error('the payload is too large to post'); + posted.push(m); + return true; + }, + }; + const relay = engineTable(table, panel, { name: 'params.sldd', awaitRows: false, setRows: jsonRows }); + relay.ready(); + await until(() => posted.some((m) => m.type === 'error')); + expect(posted.map((m) => m.type)).toEqual(['sectionRules', 'clipboardState', 'error']); + expect(posted.at(-1).message).toMatch(/^Failed to show params\.sldd: /); + relay.dispose(); + table.dispose(); + engine.dispose(); + }); +}); diff --git a/test/messageDispatch.test.ts b/test/messageDispatch.test.ts index 8308410..5311bff 100644 --- a/test/messageDispatch.test.ts +++ b/test/messageDispatch.test.ts @@ -221,9 +221,10 @@ const PROVIDERS: Provider[] = [ // this regex finds is the thing that guarantees the fetch is answered by the same // rule the payload was built with — the branch it replaced named one builder, and // was true of exactly the one format that had been planned so far. - // A table of one of view's engines (`engine.view()`) is planned by the engine's own view, which - // takes the same planner (data-explorer-view's MatView). - defersRows: /rowPlannerFor\(/.test(source) || /\.engine\.view\(\)/.test(source), + // A table of one of view's engines (`engine.view()`, or the JSON engine's through + // jsonDocument.ts's `openJsonTable`) is planned by the engine's own view, which takes the same + // planner (data-explorer-view's MatView, BinaryView and JsonView). + defersRows: /rowPlannerFor\(/.test(source) || /\.engine\.view\(\)|\bopenJsonTable\(/.test(source), ...answered(source), }; }); diff --git a/test/multiSelectInvariants.test.ts b/test/multiSelectInvariants.test.ts index 11aa352..4643570 100644 --- a/test/multiSelectInvariants.test.ts +++ b/test/multiSelectInvariants.test.ts @@ -414,7 +414,7 @@ describe('a drag is a no-op exactly where the same cut and paste is (V-002)', () const text = blankCommentsAndKeepLines( readFileSync(fileURLToPath(new URL(`../src/host/${provider}.ts`, import.meta.url)), 'utf8'), ); - if (/\.engine\.view\(\)/.test(text)) { + if (/\.engine\.view\(\)|\bopenJsonTable\(/.test(text)) { expect(text).toMatch(/msg\?\.type === 'paste'\) table\.paste\?\.\(/); expect(text).toMatch(/msg\?\.type === 'drop'\) table\.drop\?\.\(/); } else { diff --git a/test/perDocument.test.ts b/test/perDocument.test.ts new file mode 100644 index 0000000..63ec34d --- /dev/null +++ b/test/perDocument.test.ts @@ -0,0 +1,154 @@ +// Copyright 2026 The MathWorks, Inc. +// +// One object per open document, shared by its tables (perDocument.ts): the JSON editor's engine, +// made by a document's first table and disposed with its last, so the next table to open makes a +// fresh one, as the editor's module-level document state was forgotten with its last tab. Across +// provider instances too, since the map is the module's and not a provider's. +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { PerDocument, closingOnThrow, sameOpenDocument } from '../src/host/perDocument.js'; + +const A = 'file:///work/a.sldd'; +const B = 'file:///work/b.sldd'; + +function made() { + const log: string[] = []; + let n = 0; + const make = (uri: string) => () => { + const id = `${uri.slice(-6)}#${++n}`; + log.push(`make ${id}`); + return { id, dispose: () => void log.push(`dispose ${id}`) }; + }; + return { log, make }; +} + +describe("one object per document, its tables'", () => { + it('is made by the first table, shared by the next, and disposed with the last', () => { + const docs = new PerDocument<{ id: string; dispose(): void }>(); + const { log, make } = made(); + const first = docs.open(A, make(A)); + const second = docs.open(A, make(A)); + expect(second.value).toBe(first.value); + expect(log).toEqual(['make a.sldd#1']); + first.close(); + expect(log, 'a split panel closing leaves it to the one still showing it').toEqual(['make a.sldd#1']); + expect(docs.peek(A)).toBe(second.value); + second.close(); + expect(log).toEqual(['make a.sldd#1', 'dispose a.sldd#1']); + expect(docs.peek(A)).toBeUndefined(); + }); + + it('makes a fresh one for a table that opens after the last closed, and keeps each document its own', () => { + const docs = new PerDocument<{ id: string; dispose(): void }>(); + const { log, make } = made(); + const a = docs.open(A, make(A)); + const b = docs.open(B, make(B)); + expect(b.value).not.toBe(a.value); + a.close(); + const again = docs.open(A, make(A)); + expect(again.value).not.toBe(a.value); + expect(log).toEqual(['make a.sldd#1', 'make b.sldd#2', 'dispose a.sldd#1', 'make a.sldd#3']); + b.close(); + again.close(); + }); + + it('counts a table closing once, however often it is told to', () => { + const docs = new PerDocument<{ id: string; dispose(): void }>(); + const { log, make } = made(); + const first = docs.open(A, make(A)); + const second = docs.open(A, make(A)); + first.close(); + first.close(); + expect(docs.peek(A), 'a second close of one table does not spend the other table’s hold').toBe(second.value); + second.close(); + expect(log.filter((l) => l.startsWith('dispose'))).toEqual(['dispose a.sldd#1']); + }); +}); + +/** A TextDocument as the fit asks of it: its identity, and whether VS Code has closed it. */ +const textDocument = () => ({ isClosed: false }); + +/** An engine as the JSON editor holds one: made for one document, disposed once. */ +function heldFor(log: string[]) { + let n = 0; + return (document: { isClosed: boolean }) => () => { + const id = `engine#${++n}`; + log.push(`make ${id}`); + return { id, document, dispose: () => void log.push(`dispose ${id}`) }; + }; +} + +describe("a held engine is only the document's while it is that TextDocument, open", () => { + it('disposes an engine held for another TextDocument of the uri, and makes this table a fresh one, which the stale table’s close leaves alone', () => { + const docs = new PerDocument<{ id: string; document: { isClosed: boolean }; dispose(): void }>(); + const log: string[] = []; + const make = heldFor(log); + const first = textDocument(); + const second = textDocument(); + // A table that was never closed (its resolve threw before its dispose was wired) holds the first. + const stale = docs.open(A, make(first), (h) => sameOpenDocument(h, first)); + const fresh = docs.open(A, make(second), (h) => sameOpenDocument(h, second)); + expect(fresh.value).not.toBe(stale.value); + expect(fresh.value.document).toBe(second); + expect(log).toEqual(['make engine#1', 'dispose engine#1', 'make engine#2']); + stale.close(); + expect(docs.peek(A), 'a close of the stale table does not take the fresh engine').toBe(fresh.value); + expect(log).toEqual(['make engine#1', 'dispose engine#1', 'make engine#2']); + fresh.close(); + expect(log.at(-1)).toBe('dispose engine#2'); + }); + + it('disposes an engine whose TextDocument VS Code has closed since, and makes a fresh one even for that same object', () => { + const docs = new PerDocument<{ id: string; document: { isClosed: boolean }; dispose(): void }>(); + const log: string[] = []; + const make = heldFor(log); + const document = textDocument(); + const first = docs.open(A, make(document), (h) => sameOpenDocument(h, document)); + const same = docs.open(A, make(document), (h) => sameOpenDocument(h, document)); + expect(same.value, 'the same open document: one engine').toBe(first.value); + document.isClosed = true; + const after = docs.open(A, make(document), (h) => sameOpenDocument(h, document)); + expect(after.value).not.toBe(first.value); + expect(log).toEqual(['make engine#1', 'dispose engine#1', 'make engine#2']); + first.close(); + same.close(); + after.close(); + }); +}); + +describe('a table opened by a resolve that throws', () => { + it('is closed before the throw goes on, and leaves no engine held for a later tab', () => { + const docs = new PerDocument<{ id: string; dispose(): void }>(); + const { log, make } = made(); + expect(() => + closingOnThrow(docs.open(A, make(A)), () => { + throw new Error('the webview refused its options'); + }), + ).toThrow('the webview refused its options'); + expect(docs.peek(A)).toBeUndefined(); + expect(log).toEqual(['make a.sldd#1', 'dispose a.sldd#1']); + }); + + it('stays open when nothing throws, and answers what the rest answered', () => { + const docs = new PerDocument<{ id: string; dispose(): void }>(); + const { log, make } = made(); + const opened = docs.open(A, make(A)); + expect(closingOnThrow(opened, (o) => o.value.id)).toBe('a.sldd#1'); + expect(docs.peek(A)).toBe(opened.value); + expect(log).toEqual(['make a.sldd#1']); + opened.close(); + }); + + it('is how the JSON editor opens each tab’s table, and how its document keeps an engine to its own TextDocument', () => { + // SlddTextEditorProvider imports vscode, so where it opens the table is read from its source. + const read = (f: string) => readFileSync(fileURLToPath(new URL(`../src/host/${f}`, import.meta.url)), 'utf8'); + const provider = read('SlddTextEditorProvider.ts'); + expect(provider).toMatch(/closingOnThrow\(openJsonTable\(document, name, /); + expect(provider.match(/\bopenJsonTable\(/g)).toHaveLength(1); + // And the panel's dispose is wired inside it, after which the table's close is the panel's. + const wrapped = provider.slice(provider.indexOf('closingOnThrow(openJsonTable(')); + expect(wrapped).toContain('webviewPanel.onDidDispose('); + expect(read('jsonDocument.ts')).toMatch(/\(h\) => sameOpenDocument\(h, document\)/); + }); +}); diff --git a/test/slddParts.test.ts b/test/slddParts.test.ts index ba1e9fd..7f860aa 100644 --- a/test/slddParts.test.ts +++ b/test/slddParts.test.ts @@ -29,8 +29,8 @@ import { describe, it, expect } from 'vitest'; import { readFileSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; import { unzipSync, zipSync } from 'fflate'; -import { DATA_PART_XML, parseBinarySldd, readSlddContent, slddChunkContent } from 'data-explorer-core'; -import { findEntriesArrayStart, indexEntries } from '../src/host/jsonEntryScan.js'; +import { DATA_PART_XML, parseBinarySldd, readSlddContent, scanEntries as coreScanEntries, slddChunkContent } from 'data-explorer-core'; +import { findEntriesArrayStart, indexEntries, scanEntries } from '../src/host/jsonEntryScan.js'; const root = join(import.meta.dirname, '..'); const BINARY = 'test/fixtures/compressed.sldd'; @@ -131,12 +131,13 @@ describe('no host file keeps its own copy of the part names', () => { }); } - it('and the file that navigates the part takes the names from core', () => { + it('and no host file navigates the part: the scan that does is core’s', () => { // The other direction: a scan for absent literals also passes for a file that stopped - // navigating the part at all, so name the one that must still be doing it. The compressed - // dictionary editor no longer does: its engine (view's BinaryEngine) reads and writes the - // package through core's own readBinarySlddPackage and writeBinarySlddPackage. - expect(code('src/host/jsonEntryScan.ts'), 'src/host/jsonEntryScan.ts must import the names').toContain("from 'data-explorer-core'"); - expect(code('src/host/jsonEntryScan.ts')).toContain('TEXT_PARTS'); + // navigating the part at all, so say where the navigating went. Neither dictionary editor + // does it any more: their engines (view's BinaryEngine and JsonEngine) read the package through + // core's readBinarySlddPackage and the text through core's jsonEntryScan, and the one file here + // that used to walk the text part is now core's scan, re-exported and nothing else. + expect(code('src/host/jsonEntryScan.ts').trim()).toMatch(/^export \{[^}]*\} from 'data-explorer-core';$/); + expect(scanEntries).toBe(coreScanEntries); }); }); diff --git a/test/textDocumentStore.test.ts b/test/textDocumentStore.test.ts new file mode 100644 index 0000000..6144882 --- /dev/null +++ b/test/textDocumentStore.test.ts @@ -0,0 +1,175 @@ +// Copyright 2026 The MathWorks, Inc. +// +// A JSON dictionary's TextDocument as the JSON engine's text (textDocumentStore.ts): the editor's +// own calls, one for one. An edit is ONE WorkspaceEdit holding every patch, in order, its ranges +// taken against the text as it stood when asked, and its answer is the apply's, a rejection +// included. Every listener is a subscription of its own, filtered by uri; a change is the event's +// replacements as listed, with undo and redo said. The text, its version and its dirty flag are the +// document's, read when asked. Over a fake document and workspace, so the VS Code calls are the +// ones recorded. +import { describe, it, expect } from 'vitest'; +import { textDocumentStore, type TextDocumentApi, type TextDocumentChangeLike } from '../src/host/textDocumentStore.js'; + +const URI = 'file:///work/params.sldd'; +const UNDO = 2; +const REDO = 3; + +/** A document whose text, version and dirty flag a case sets, and whose positions are their offsets, tagged. */ +function fakeDocument(text = '{"a": 1, "b": 2}') { + return { + uri: { toString: () => URI }, + version: 1, + isDirty: false, + text, + getText() { + return this.text; + }, + positionAt: (offset: number) => `@${offset}`, + }; +} + +/** A workspace that records each WorkspaceEdit and its subscriptions, and answers an edit as told. */ +function fakeApi(answer: () => PromiseLike = () => Promise.resolve(true)) { + const edits: { start: string; end: string; text: string }[][] = []; + const changeListeners: ((e: TextDocumentChangeLike) => void)[] = []; + const saveListeners: ((d: { uri: { toString(): string } }) => void)[] = []; + const commands: string[] = []; + let disposed = 0; + const api: TextDocumentApi = { + applyEdit: (replacements) => { + edits.push(replacements.map((r) => ({ ...r }))); + return answer(); + }, + onDidChangeTextDocument: (listener) => { + changeListeners.push(listener); + return { dispose: () => void (disposed++, changeListeners.splice(changeListeners.indexOf(listener), 1)) }; + }, + onDidSaveTextDocument: (listener) => { + saveListeners.push(listener); + return { dispose: () => void (disposed++, saveListeners.splice(saveListeners.indexOf(listener), 1)) }; + }, + undo: () => void commands.push('undo'), + redo: () => void commands.push('redo'), + reasons: { undo: UNDO, redo: REDO }, + }; + const change = (e: Partial & { uri?: string }) => { + const event: TextDocumentChangeLike = { + document: { uri: { toString: () => e.uri ?? URI } }, + contentChanges: e.contentChanges ?? [], + ...(e.reason === undefined ? {} : { reason: e.reason }), + }; + for (const l of [...changeListeners]) l(event); + }; + const save = (uri = URI) => { + for (const l of [...saveListeners]) l({ uri: { toString: () => uri } }); + }; + return { api, edits, change, save, commands, changeListeners, saveListeners, disposed: () => disposed }; +} + +describe("a TextDocument's text, as the JSON engine reads and writes it", () => { + it('makes one edit ONE WorkspaceEdit, a replacement per patch in the order given, its ranges from the text as it stands', async () => { + const doc = fakeDocument(); + const ws = fakeApi(); + const store = textDocumentStore(doc, ws.api); + const written = store.apply([ + { offset: 6, length: 1, text: '10' }, + { offset: 14, length: 1, text: '20' }, + ]); + // Built before anything is awaited: the edit is with the workspace already. + expect(ws.edits).toEqual([ + [ + { start: '@6', end: '@7', text: '10' }, + { start: '@14', end: '@15', text: '20' }, + ], + ]); + expect(await written).toBe(true); + // An entry's span and a catalog's ranges, one undo step: still one edit, in the caller's order. + void store.apply([ + { offset: 14, length: 1, text: 'x' }, + { offset: 6, length: 0, text: 'y' }, + ]); + expect(ws.edits[1].map((r) => r.start)).toEqual(['@14', '@6']); + }); + + it("answers what the workspace's apply answers: true, false, or its rejection", async () => { + const doc = fakeDocument(); + let answer: () => PromiseLike = () => Promise.resolve(false); + const ws = fakeApi(() => answer()); + const store = textDocumentStore(doc, ws.api); + expect(await store.apply([{ offset: 0, length: 0, text: ' ' }])).toBe(false); + answer = () => Promise.reject(new Error('Overlapping ranges are not allowed!')); + await expect(store.apply([{ offset: 0, length: 1, text: 'a' }, { offset: 0, length: 1, text: 'b' }])).rejects.toThrow( + 'Overlapping ranges are not allowed!', + ); + }); + + it("reads the document's text, version and dirty flag when asked, never a copy", () => { + const doc = fakeDocument('{}'); + const store = textDocumentStore(doc, fakeApi().api); + expect([store.text, store.version, store.dirty]).toEqual(['{}', 1, false]); + doc.text = '{"a": 1}'; + doc.version = 2; + doc.isDirty = true; + expect([store.text, store.version, store.dirty]).toEqual(['{"a": 1}', 2, true]); + }); + + it("hears this document's changes as their replacements, in the event's order, with an undo or a redo said", () => { + const ws = fakeApi(); + const store = textDocumentStore(fakeDocument(), ws.api); + const heard: unknown[] = []; + store.onChange((c) => void heard.push(c)); + ws.change({ contentChanges: [{ rangeOffset: 14, rangeLength: 1, text: '3' }, { rangeOffset: 6, rangeLength: 1, text: '9' }] }); + ws.change({ contentChanges: [{ rangeOffset: 6, rangeLength: 1, text: '1' }], reason: UNDO }); + ws.change({ contentChanges: [{ rangeOffset: 6, rangeLength: 1, text: '9' }], reason: REDO }); + ws.change({ contentChanges: [] }); + ws.change({ uri: 'file:///work/other.sldd', contentChanges: [{ rangeOffset: 0, rangeLength: 0, text: 'x' }] }); + expect(heard).toEqual([ + { + changes: [ + { rangeOffset: 14, rangeLength: 1, text: '3' }, + { rangeOffset: 6, rangeLength: 1, text: '9' }, + ], + }, + { changes: [{ rangeOffset: 6, rangeLength: 1, text: '1' }], reason: 'undo' }, + { changes: [{ rangeOffset: 6, rangeLength: 1, text: '9' }], reason: 'redo' }, + { changes: [] }, + ]); + // No reason key at all where VS Code gives none (a typed change, a reload, the dirty flip). + expect(Object.keys(heard[0] as object)).toEqual(['changes']); + }); + + it('gives every listener a subscription of its own, in the order they listen, each ended on its own', () => { + const ws = fakeApi(); + const store = textDocumentStore(fakeDocument(), ws.api); + const order: string[] = []; + const stopFirst = store.onChange(() => void order.push('first')); + store.onChange(() => void order.push('second')); + expect(ws.changeListeners).toHaveLength(2); + ws.change({ contentChanges: [{ rangeOffset: 0, rangeLength: 0, text: ' ' }] }); + expect(order).toEqual(['first', 'second']); + stopFirst(); + expect(ws.changeListeners).toHaveLength(1); + expect(ws.disposed()).toBe(1); + }); + + it("hears this document's saves, and no other's", () => { + const ws = fakeApi(); + const store = textDocumentStore(fakeDocument(), ws.api); + let saves = 0; + const stop = store.onSave(() => void saves++); + ws.save(); + ws.save('file:///work/other.sldd'); + expect(saves).toBe(1); + stop(); + ws.save(); + expect(saves).toBe(1); + }); + + it("undoes and redoes through VS Code's commands", () => { + const ws = fakeApi(); + const store = textDocumentStore(fakeDocument(), ws.api); + store.undo(); + store.redo(); + expect(ws.commands).toEqual(['undo', 'redo']); + }); +}); diff --git a/test/viewPin.test.ts b/test/viewPin.test.ts index 619180e..22e4901 100644 --- a/test/viewPin.test.ts +++ b/test/viewPin.test.ts @@ -24,9 +24,9 @@ import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; // A release tag, and the commit it points at. -const REF = 'v1.2.3'; -const COMMIT = '618edfc92c4a744824776c4feead204572c18893'; -const VERSION = '1.2.3'; +const REF = 'v1.2.4'; +const COMMIT = '1059c6f07752a54264d7fae67aee2acfa7524acd'; +const VERSION = '1.2.4'; const SPEC = `github:mathworks/data-explorer-view#${REF}`; diff --git a/vitest.config.ts b/vitest.config.ts index db58c71..bd94829 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -27,6 +27,8 @@ export default defineConfig({ 'src/host/BinarySlddEditorProvider.ts', // The compressed dictionary editor's document: view's engine with VS Code's end of its hooks. 'src/host/binaryDocument.ts', + // The JSON dictionary editor's document: view's engine with VS Code's end of its hooks. + 'src/host/jsonDocument.ts', 'src/host/MatEditorProvider.ts', // The MAT-file editor's document: view's engine with VS Code's end of its hooks. 'src/host/matDocument.ts',