Skip to content

Commit 9137d85

Browse files
ww-mwclaude
andcommitted
Route oversized JSON .sldd to read-only view; refresh on save
A JSON .sldd larger than VS Code's 50 MB TextDocument sync limit (_MODEL_SYNC_LIMIT) cannot open in the editable table view: the ext host can't mirror an over-limit document and throws "Unable to retrieve document from URI" before any extension code runs. Gate the editable routing on exceedsTextSyncLimit() at both call sites (extension.ts, BinaryEditorProvider redirect) so such files fall through to the read-only byte-backed view, which reads bytes directly and opens them fine. Surface a persistent #dex-notice banner explaining the read-only downgrade — shown only for the size case, not for expected-read-only binary/zip .sldd. Because the ext host holds no mirror of an over-limit document, onDidChangeTextDocument never fires for it, so the read-only view's live-sync was dead. Replace it with a file-scoped FileSystemWatcher that observes the disk directly (independent of document syncing), so the view refreshes when text-view edits are saved at any file size. The banner sets that expectation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 7c5039c commit 9137d85

6 files changed

Lines changed: 191 additions & 13 deletions

File tree

src/extension.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { BinaryEditorProvider } from './host/BinaryEditorProvider.js';
66
import { SlddTextEditorProvider } from './host/SlddTextEditorProvider.js';
77
import { HealthDecorationProvider } from './host/HealthDecorationProvider.js';
88
import { invalidate, findNode } from './host/SlddModel.js';
9-
import { isEditableJsonSlddBytes } from './host/slddFormat.js';
9+
import { isEditableJsonSlddBytes, exceedsTextSyncLimit } from './host/slddFormat.js';
1010
import { handleNavigate } from './host/navigate.js';
1111
import { invalidateUsageGraph } from './host/usageGraph.js';
1212

@@ -19,10 +19,17 @@ function isSlddUri(uri: vscode.Uri | undefined): boolean {
1919
// True if the .sldd at `uri` is editable JSON (not zip/binary). Editable JSON
2020
// opens in the CustomTextEditorProvider (native undo/redo); binary/zip .sldd and
2121
// all other formats open in the read-only BinaryEditorProvider.
22+
//
23+
// A JSON .sldd larger than VS Code's TextDocument sync limit is NOT treated as
24+
// editable: the CustomTextEditorProvider can't resolve it (the ext host can't
25+
// mirror an over-limit document — it throws "Unable to retrieve document from
26+
// URI"), so it falls through to the read-only byte-backed view, which opens it
27+
// fine. See exceedsTextSyncLimit in slddFormat.ts.
2228
async function isEditableJsonSldd(uri: vscode.Uri): Promise<boolean> {
2329
if (!uri.path.endsWith('.sldd')) return false;
2430
try {
25-
return isEditableJsonSlddBytes(await vscode.workspace.fs.readFile(uri));
31+
const bytes = await vscode.workspace.fs.readFile(uri);
32+
return isEditableJsonSlddBytes(bytes) && !exceedsTextSyncLimit(bytes);
2633
} catch {
2734
return false;
2835
}

src/host/BinaryEditorProvider.ts

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
} from './rowBuilder.js';
1212
import { buildMatRows } from './matRowBuilder.js';
1313
import { readProjectStore } from './projectStore.js';
14-
import { isEditableJsonSlddBytes } from './slddFormat.js';
14+
import { isEditableJsonSlddBytes, exceedsTextSyncLimit } from './slddFormat.js';
1515
import { annotateDataRows, annotateModelRows } from './usageGraph.js';
1616
import { onNavigateSelect, consumePendingSelect } from './navigate.js';
1717

@@ -65,6 +65,12 @@ export class BinaryEditorProvider implements vscode.CustomReadonlyEditorProvider
6565
const uriString = document.uri.toString();
6666
const name = document.uri.path.split('/').pop() ?? 'document';
6767

68+
// A read-only banner shown above the table. Set only for the surprising case:
69+
// a JSON .sldd that WOULD be editable but is over VS Code's TextDocument sync
70+
// limit (see below). Binary/zip .sldd — expected read-only — leave this unset
71+
// so no banner appears. Passed to the webview in the setRows payload.
72+
let notice: string | undefined;
73+
6874
// This byte-backed editor is the DEFAULT for *.sldd because it can open any
6975
// bytes (binary/zip .sldd fail to load as a TextDocument, so the text-backed
7076
// tableView can't be the default). But editable JSON .sldd should open in the
@@ -74,7 +80,12 @@ export class BinaryEditorProvider implements vscode.CustomReadonlyEditorProvider
7480
if (name.endsWith('.sldd')) {
7581
try {
7682
const bytes = await vscode.workspace.fs.readFile(document.uri);
77-
if (isEditableJsonSlddBytes(bytes)) {
83+
// Editable JSON .sldd redirects to the text-backed table view — BUT only
84+
// when VS Code can actually mirror it as a TextDocument. Over the sync
85+
// limit, the tableView provider can't resolve (the ext host throws
86+
// "Unable to retrieve document from URI"), so keep such files here and
87+
// render them read-only. See exceedsTextSyncLimit in slddFormat.ts.
88+
if (isEditableJsonSlddBytes(bytes) && !exceedsTextSyncLimit(bytes)) {
7889
// Carry the incoming tab's preview state through the redirect: an
7990
// Explorer single-click opens this binary tab as a PREVIEW tab, and the
8091
// table it redirects to should stay a preview tab too (not pin). VS
@@ -92,6 +103,17 @@ export class BinaryEditorProvider implements vscode.CustomReadonlyEditorProvider
92103
webviewPanel.dispose();
93104
return;
94105
}
106+
// A JSON .sldd that stayed here (not redirected) did so ONLY because it's
107+
// over the sync limit — otherwise it would be editable. That's surprising
108+
// (a JSON dictionary the user expects to edit), so explain the read-only
109+
// downgrade. Binary/zip .sldd skips this (isEditableJsonSlddBytes false).
110+
if (isEditableJsonSlddBytes(bytes)) {
111+
const mb = Math.round(bytes.byteLength / (1024 * 1024));
112+
notice =
113+
`Read-only: this dictionary is ${mb} MB, above VS Code's 50 MB editing limit. ` +
114+
`To edit the JSON directly, use "Reopen Editor With… → Text Editor"; ` +
115+
`this view refreshes when you save.`;
116+
}
95117
} catch {
96118
// Unreadable → fall through and let the read-only render report the error.
97119
}
@@ -161,6 +183,7 @@ export class BinaryEditorProvider implements vscode.CustomReadonlyEditorProvider
161183
columns: COLUMNS,
162184
columnLabels: COLUMN_LABELS,
163185
editable: false,
186+
notice,
164187
});
165188
drainNavSelect();
166189
} catch (err) {
@@ -194,19 +217,32 @@ export class BinaryEditorProvider implements vscode.CustomReadonlyEditorProvider
194217
webview.postMessage({ type: 'selectByName', name: e.name });
195218
});
196219

197-
// Live-sync when the underlying document changes: covers external /
198-
// text-view edits to the file. Re-parse and repaint the read-only view.
199-
const changeSub = vscode.workspace.onDidChangeTextDocument((e) => {
200-
if (e.document.uri.toString() === uriString) {
201-
invalidate(uriString);
202-
void post();
203-
}
204-
});
220+
// Live-sync when the file on disk changes: covers external edits AND edits
221+
// made in the plain-text view once saved. We watch the DISK, not the
222+
// TextDocument, on purpose: a JSON .sldd routed here is over VS Code's 50 MB
223+
// sync limit, so the ext host holds no mirror of it and
224+
// onDidChangeTextDocument NEVER fires for it (the same limit that forced the
225+
// read-only downgrade — see slddFormat.ts). A FileSystemWatcher observes the
226+
// disk directly, independent of document syncing, so it fires on save at any
227+
// size. Because this view always reads bytes from disk, unsaved edits can't
228+
// be reflected anyway — refresh-on-save is the achievable contract, and the
229+
// banner tells the user so.
230+
const watcher = vscode.workspace.createFileSystemWatcher(
231+
new vscode.RelativePattern(vscode.Uri.joinPath(document.uri, '..'), name),
232+
);
233+
const onDiskChange = () => {
234+
invalidate(uriString);
235+
void post();
236+
};
237+
const changeSub = watcher.onDidChange(onDiskChange);
238+
const createSub = watcher.onDidCreate(onDiskChange);
205239

206240
webview.html = this.getHtml(webview, distRoot);
207241
webviewPanel.onDidDispose(() => {
208242
sub.dispose();
243+
watcher.dispose();
209244
changeSub.dispose();
245+
createSub.dispose();
210246
navSub.dispose();
211247
});
212248
}
@@ -231,6 +267,7 @@ export class BinaryEditorProvider implements vscode.CustomReadonlyEditorProvider
231267
scriptFile: 'table.js',
232268
title: 'Data Explorer',
233269
body: ` <div id="dex-error" role="alert" style="display:none;color:var(--vscode-errorForeground,#f14c4c);padding:8px;font-family:var(--vscode-font-family,sans-serif);"></div>
270+
<div id="dex-notice" role="status" style="display:none;position:absolute;top:0;left:0;right:0;z-index:2;box-sizing:border-box;padding:6px 10px;font-family:var(--vscode-font-family,sans-serif);font-size:12px;color:var(--vscode-inputValidation-infoForeground,var(--vscode-foreground));background:var(--vscode-inputValidation-infoBackground,rgba(100,148,237,0.12));border-bottom:1px solid var(--vscode-inputValidation-infoBorder,#4084d0);"></div>
234271
<dex-tree-table style="position:absolute;inset:0;"></dex-tree-table>
235272
<dex-context-menu></dex-context-menu>`,
236273
});

src/host/slddFormat.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,22 @@ export function isEditableJsonSlddBytes(bytes: Uint8Array): boolean {
2828
return false;
2929
}
3030
}
31+
32+
// VS Code refuses to mirror a TextDocument larger than TextModel._MODEL_SYNC_LIMIT
33+
// (50 MB) into the extension host. Our editable JSON .sldd table is a
34+
// CustomTextEditorProvider, which depends on that mirror — so past this size,
35+
// resolving it throws "Unable to retrieve document from URI" in the ext host
36+
// before our provider code ever runs, and the table fails to open. Such files
37+
// are routed to the read-only byte-backed view instead (it reads bytes directly
38+
// via workspace.fs, so it isn't subject to the sync limit). Editing is
39+
// impossible above the limit regardless, since VS Code won't sync the document.
40+
//
41+
// VS Code measures the model in UTF-16 code units; we gate on byte length, which
42+
// is always >= the UTF-16 length for UTF-8. So any file kept on the editable path
43+
// (byte length <= limit) is guaranteed to sync — no false downgrades.
44+
export const TEXT_SYNC_LIMIT = 50 * 1024 * 1024;
45+
46+
/** True if `bytes` is too large for VS Code to sync as an editable TextDocument. */
47+
export function exceedsTextSyncLimit(bytes: Uint8Array): boolean {
48+
return bytes.length > TEXT_SYNC_LIMIT;
49+
}

src/webview/table-main.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,37 @@ function clearError(): void {
6262
if (el) { el.textContent = ''; el.style.display = 'none'; }
6363
}
6464

65+
// Persistent, informational read-only banner (e.g. a JSON .sldd too large to
66+
// edit). Distinct from the transient red #dex-error. The table fills the panel
67+
// (position:absolute;inset:0), so when the banner is shown we offset the table's
68+
// top by the banner's measured height — measured, not hardcoded, so it stays
69+
// correct when the message wraps at narrow widths. Only the read-only binary
70+
// view renders #dex-notice; in the editable table view it's absent and this
71+
// no-ops.
72+
function setNotice(message: string | undefined): void {
73+
const el = document.getElementById('dex-notice');
74+
if (!el) return;
75+
if (message) {
76+
el.textContent = message;
77+
el.style.display = 'block';
78+
// Offset after layout so offsetHeight reflects the (possibly wrapped) banner.
79+
requestAnimationFrame(() => {
80+
table.style.top = el.offsetHeight + 'px';
81+
});
82+
} else {
83+
el.textContent = '';
84+
el.style.display = 'none';
85+
table.style.top = '';
86+
}
87+
}
88+
6589
window.addEventListener('message', (event: MessageEvent) => {
6690
const msg = event.data;
6791
if (msg.type === 'setRows') {
6892
clearError();
93+
// Persistent read-only notice (size-limited JSON .sldd). Undefined for the
94+
// editable table view and for expected-read-only binary .sldd, so it hides.
95+
setNotice(typeof msg.notice === 'string' ? msg.notice : undefined);
6996
const rows = msg.rows ?? [];
7097
editable = !!msg.editable;
7198
table.columns = msg.columns ?? null;

test/readonlyNotice.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// Copyright 2026 The MathWorks, Inc.
2+
// @vitest-environment happy-dom
3+
import { describe, it, expect, beforeEach } from 'vitest';
4+
5+
// The read-only binary view shows a PERSISTENT informational banner (#dex-notice)
6+
// when a JSON .sldd is too large to edit, and offsets the full-bleed table below
7+
// it. table-main.ts can't be imported in isolation (top-level acquireVsCodeApi()
8+
// + DOM wiring), so — as with readonlyEditorGate.test.ts — we mirror setNotice's
9+
// contract against the same DOM shape the host's getHtml() renders.
10+
11+
function setNotice(table: HTMLElement, message: string | undefined): void {
12+
const el = document.getElementById('dex-notice');
13+
if (!el) return;
14+
if (message) {
15+
el.textContent = message;
16+
el.style.display = 'block';
17+
// Real code defers the offset to rAF so offsetHeight reflects wrapping;
18+
// happy-dom reports 0 height, so set it synchronously here — the assertions
19+
// below check the show/hide + offset-cleared contract, not the pixel value.
20+
table.style.top = el.offsetHeight + 'px';
21+
} else {
22+
el.textContent = '';
23+
el.style.display = 'none';
24+
table.style.top = '';
25+
}
26+
}
27+
28+
describe('read-only notice banner (#dex-notice)', () => {
29+
let table: HTMLElement;
30+
31+
beforeEach(() => {
32+
document.body.innerHTML =
33+
'<div id="dex-notice" style="display:none;"></div>' +
34+
'<div id="table" style="position:absolute;inset:0;"></div>';
35+
table = document.getElementById('table')!;
36+
});
37+
38+
it('shows the banner and offsets the table when a notice is present', () => {
39+
setNotice(table, 'Read-only: this dictionary is 138 MB, above VS Code’s 50 MB editing limit.');
40+
const el = document.getElementById('dex-notice')!;
41+
expect(el.style.display).toBe('block');
42+
expect(el.textContent).toContain('138 MB');
43+
// The table is pushed down (top set), not left overlapping the banner.
44+
expect(table.style.top).not.toBe('');
45+
});
46+
47+
it('hides the banner and clears the offset when there is no notice', () => {
48+
setNotice(table, 'something'); // show first
49+
setNotice(table, undefined); // then clear
50+
const el = document.getElementById('dex-notice')!;
51+
expect(el.style.display).toBe('none');
52+
expect(el.textContent).toBe('');
53+
expect(table.style.top).toBe('');
54+
});
55+
56+
it('is a no-op when the notice element is absent (editable table view)', () => {
57+
document.body.innerHTML = '<div id="table"></div>'; // no #dex-notice
58+
const t = document.getElementById('table')!;
59+
expect(() => setNotice(t, 'x')).not.toThrow();
60+
expect(t.style.top).toBe('');
61+
});
62+
});

test/slddFormat.test.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@
44
import { describe, it, expect } from 'vitest';
55
import { readFileSync } from 'node:fs';
66
import { fileURLToPath } from 'node:url';
7-
import { isZipBytes, isEditableJsonSlddBytes } from '../src/host/slddFormat.js';
7+
import {
8+
isZipBytes,
9+
isEditableJsonSlddBytes,
10+
exceedsTextSyncLimit,
11+
TEXT_SYNC_LIMIT,
12+
} from '../src/host/slddFormat.js';
813

914
function bytesOf(relPath: string): Uint8Array {
1015
return new Uint8Array(readFileSync(fileURLToPath(new URL(relPath, import.meta.url))));
@@ -34,3 +39,24 @@ describe('slddFormat routing detection', () => {
3439
expect(isZipBytes(new Uint8Array([0x50, 0x4b]))).toBe(false); // too short
3540
});
3641
});
42+
43+
describe('exceedsTextSyncLimit (large-file routing guard)', () => {
44+
it('is false for a small file (opens in the editable table view)', () => {
45+
expect(exceedsTextSyncLimit(new TextEncoder().encode('{}'))).toBe(false);
46+
});
47+
48+
it('is false exactly at the limit (boundary: <= limit stays editable)', () => {
49+
// A real allocation this large is wasteful; fake the length VS Code measures.
50+
expect(exceedsTextSyncLimit({ length: TEXT_SYNC_LIMIT } as Uint8Array)).toBe(false);
51+
});
52+
53+
it('is true one byte past the limit (routes to the read-only view)', () => {
54+
expect(exceedsTextSyncLimit({ length: TEXT_SYNC_LIMIT + 1 } as Uint8Array)).toBe(true);
55+
});
56+
57+
it('matches VS Code TextModel._MODEL_SYNC_LIMIT (50 MB)', () => {
58+
// If VS Code ever changes this constant, this test flags that our routing
59+
// threshold has drifted from it. See slddFormat.ts for why they must agree.
60+
expect(TEXT_SYNC_LIMIT).toBe(50 * 1024 * 1024);
61+
});
62+
});

0 commit comments

Comments
 (0)