From 11be410388e830489a697095607ef4470a254387 Mon Sep 17 00:00:00 2001 From: Wei Wang Date: Fri, 14 Aug 2026 14:16:15 -0400 Subject: [PATCH 01/10] [wip] add standalone workspace name index (Phase 1) Complete, dup-preserving index of entry names inside .sldd/.mat/.slx sources, independent of the relationship and usage graphs. Pure extractor (nameExtract.ts) split from vscode I/O (nameIndex.ts). Co-Authored-By: Claude Opus 4.8 --- src/host/nameExtract.ts | 80 +++++++++++++++++++++++++++ src/host/nameIndex.ts | 114 +++++++++++++++++++++++++++++++++++++++ test/nameExtract.test.ts | 111 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 305 insertions(+) create mode 100644 src/host/nameExtract.ts create mode 100644 src/host/nameIndex.ts create mode 100644 test/nameExtract.test.ts diff --git a/src/host/nameExtract.ts b/src/host/nameExtract.ts new file mode 100644 index 0000000..025d7bc --- /dev/null +++ b/src/host/nameExtract.ts @@ -0,0 +1,80 @@ +// Copyright 2026 The MathWorks, Inc. +// Pure (vscode-free) core of the workspace name index: turns already-parsed +// Simulink data-source content into flat, dup-preserving name records. Split +// from nameIndex.ts (which does the file I/O + parser dispatch) so the +// name-extraction rules are unit-testable without touching the filesystem. +// +// This module is deliberately independent of the usage graph (usageResolve.ts) +// and the relationship graph: it answers only "what entry names exist, and +// where", never how they resolve or relate. Duplicate names across files are +// preserved (each becomes its own record) so a global "search entries by name" +// can list every occurrence. +import { uriBasename } from '../common/pathUtil.js'; + +export type EntryKind = 'sldd' | 'mat' | 'workspace' | 'block'; + +export interface NameRecord { + name: string; + sourceUri: string; + sourceLabel: string; + kind: EntryKind; +} + +// Entry names from an .sldd (JSON or binary/zip; both share the in-memory +// __MW_TEXT_PARTS__ shape). Traversal mirrors usageGraph's slddSummary: +// content.__MW_TEXT_PARTS__['__MW_TEXT_PART__/data/chunk0'].__MW_TEXT_content.entries[].name. +export function namesFromSldd(content: Record, sourceUri: string): NameRecord[] { + const label = uriBasename(sourceUri); + const parts = content?.__MW_TEXT_PARTS__ as Record | undefined; + const chunk = parts?.['__MW_TEXT_PART__/data/chunk0'] as Record | undefined; + const inner = chunk?.__MW_TEXT_content as Record | undefined; + const entries = (inner?.entries as { name?: string }[] | undefined) ?? []; + const records: NameRecord[] = []; + for (const entry of entries) { + const name = entry?.name; + if (!name) continue; // drop empty/falsy names + records.push({ name, sourceUri, sourceLabel: label, kind: 'sldd' }); + } + return records; +} + +// Variable names from a parsed .mat. +export function namesFromMat(parsed: { variables: { name?: string }[] }, sourceUri: string): NameRecord[] { + const label = uriBasename(sourceUri); + const records: NameRecord[] = []; + for (const v of parsed?.variables ?? []) { + const name = v?.name; + if (!name) continue; // drop empty/falsy names + records.push({ name, sourceUri, sourceLabel: label, kind: 'mat' }); + } + return records; +} + +// Model-workspace variable names (kind 'workspace') plus referenced block names +// (kind 'block') from a parsed .slx — both live in the same model file. A block +// is emitted once even if it uses multiple params, deduped WITHIN this file via +// a Set (the usage of a block many times is a graph concern, not a name one). +export function namesFromSlx( + parsed: { workspace?: { name?: string }[]; blockParamUsages?: { blockName?: string }[] }, + sourceUri: string, +): NameRecord[] { + const label = uriBasename(sourceUri); + const records: NameRecord[] = []; + + for (const v of parsed?.workspace ?? []) { + const name = v?.name; + if (!name) continue; // drop empty/falsy names + records.push({ name, sourceUri, sourceLabel: label, kind: 'workspace' }); + } + + const seenBlocks = new Set(); + for (const u of parsed?.blockParamUsages ?? []) { + const name = u?.blockName; + if (!name) continue; // drop empty/falsy names + if (seenBlocks.has(name)) continue; // one record per block within this file + seenBlocks.add(name); + records.push({ name, sourceUri, sourceLabel: label, kind: 'block' }); + } + + return records; +} diff --git a/src/host/nameIndex.ts b/src/host/nameIndex.ts new file mode 100644 index 0000000..fb1ce03 --- /dev/null +++ b/src/host/nameIndex.ts @@ -0,0 +1,114 @@ +// Copyright 2026 The MathWorks, Inc. +// Workspace-wide index of entry NAMES inside Simulink data sources, powering a +// global "search entries by name" feature. It is deliberately standalone: it +// does not depend on the relationship graph or the usage graph, and it reads +// only names (never resolves them). +// +// The index is a Map — one bucket per file — so that +// (a) duplicate names within and across files are preserved (each occurrence is +// its own record), and (b) an incremental update after a file change is a +// single-key replace rather than a full rebuild. Built LAZILY on first query +// and cached via a module Promise; invalidated wholesale via invalidate(). +// +// This module does the vscode file I/O + parser dispatch; the pure +// name-extraction core lives in nameExtract.ts (unit-tested). +import * as vscode from 'vscode'; +import { parseSlx } from '../dex/datamodel/parser/SlxParser.js'; +import { parseMat } from '../dex/datamodel/parser/MatParser.js'; +import { parseBinarySldd } from '../dex/datamodel/parser/BinarySlddParser.js'; +import { isZipBytes } from './slddFormat.js'; +import { toArrayBuffer } from '../common/bytes.js'; +import { basename } from '../common/pathUtil.js'; +import { namesFromSldd, namesFromMat, namesFromSlx, type NameRecord } from './nameExtract.js'; + +export type { EntryKind, NameRecord } from './nameExtract.js'; + +// uriString -> that file's name records. Null when the lazy build hasn't run. +let index: Map | null = null; +let buildPromise: Promise | null = null; + +// Drop the whole index; the next ensureIndex() rebuilds it. Called on any +// workspace file create/delete/change where a targeted reindex isn't enough. +export function invalidate(): void { + index = null; + buildPromise = null; +} + +export async function ensureIndex(): Promise { + if (!buildPromise) buildPromise = build(); + return buildPromise; +} + +export async function listEntries(): Promise { + await ensureIndex(); + const out: NameRecord[] = []; + for (const bucket of index?.values() ?? []) out.push(...bucket); + return out; +} + +// Re-read + parse just this file and replace its bucket. Judgment call: if the +// lazy build hasn't happened yet (index is null), this is a no-op — building an +// index off a single file would give incomplete answers, so we let the first +// listEntries() do the full scan instead. Once built, this keeps the index +// current after an edit without a full rebuild. +export async function reindexFile(uri: vscode.Uri): Promise { + if (!index) return; + const records = await recordsForFile(uri); + index.set(uri.toString(), records); +} + +// Drop one file's bucket (e.g. the file was deleted). Safe before build. +export function removeFile(uriString: string): void { + index?.delete(uriString); +} + +async function build(): Promise { + const map = new Map(); + let uris: vscode.Uri[]; + try { + uris = await vscode.workspace.findFiles('**/*.{slx,sldd,mat}'); + } catch { + index = map; + return; + } + await Promise.all( + uris.map(async (uri) => { + const records = await recordsForFile(uri); + if (records.length > 0) map.set(uri.toString(), records); + }), + ); + index = map; +} + +// Read + parse a single file's NAMES ONLY. Any read/parse failure (corrupt or +// unreadable file) contributes nothing. +async function recordsForFile(uri: vscode.Uri): Promise { + let ab: ArrayBuffer; + try { + ab = toArrayBuffer(await vscode.workspace.fs.readFile(uri)); + } catch { + return []; + } + const path = uri.path; + const uriString = uri.toString(); + try { + if (path.endsWith('.slx')) { + const parsed = parseSlx(ab, basename(path)); + return namesFromSlx(parsed, uriString); + } + if (path.endsWith('.mat')) { + const parsed = parseMat(ab); + return namesFromMat(parsed, uriString); + } + if (path.endsWith('.sldd')) { + const bytes = new Uint8Array(ab); + const content = isZipBytes(bytes) + ? (parseBinarySldd(ab) as Record) + : (JSON.parse(new TextDecoder().decode(bytes)) as Record); + return namesFromSldd(content, uriString); + } + } catch { + /* unreadable/corrupt file contributes nothing */ + } + return []; +} diff --git a/test/nameExtract.test.ts b/test/nameExtract.test.ts new file mode 100644 index 0000000..2eff19a --- /dev/null +++ b/test/nameExtract.test.ts @@ -0,0 +1,111 @@ +// Copyright 2026 The MathWorks, Inc. +import { describe, it, expect } from 'vitest'; +import { + namesFromSldd, + namesFromMat, + namesFromSlx, + type NameRecord, +} from '../src/host/nameExtract.js'; + +// Build the in-memory .sldd content shape (__MW_TEXT_PARTS__ ... entries[]). +function slddContent(entries: { name?: string }[]): Record { + return { + __MW_TEXT_PARTS__: { + '__MW_TEXT_PART__/data/chunk0': { + __MW_TEXT_content: { entries }, + }, + }, + }; +} + +describe('namesFromSldd', () => { + it('extracts entry names with kind sldd and the uri basename as sourceLabel', () => { + const content = slddContent([{ name: 'Kp' }, { name: 'Ts' }]); + const records = namesFromSldd(content, 'file:///w/dict.sldd'); + expect(records).toEqual([ + { name: 'Kp', sourceUri: 'file:///w/dict.sldd', sourceLabel: 'dict.sldd', kind: 'sldd' }, + { name: 'Ts', sourceUri: 'file:///w/dict.sldd', sourceLabel: 'dict.sldd', kind: 'sldd' }, + ]); + }); + + it('drops empty/missing names', () => { + const content = slddContent([{ name: 'Keep' }, { name: '' }, {}, { name: undefined }]); + const records = namesFromSldd(content, 'file:///w/dict.sldd'); + expect(records.map((r) => r.name)).toEqual(['Keep']); + }); + + it('returns [] for empty / malformed content', () => { + expect(namesFromSldd({}, 'file:///w/dict.sldd')).toEqual([]); + expect(namesFromSldd(slddContent([]), 'file:///w/dict.sldd')).toEqual([]); + }); +}); + +describe('namesFromMat', () => { + it('extracts variable names with kind mat', () => { + const records = namesFromMat({ variables: [{ name: 'Mv' }, { name: 'Gain' }] }, 'file:///w/data.mat'); + expect(records).toEqual([ + { name: 'Mv', sourceUri: 'file:///w/data.mat', sourceLabel: 'data.mat', kind: 'mat' }, + { name: 'Gain', sourceUri: 'file:///w/data.mat', sourceLabel: 'data.mat', kind: 'mat' }, + ]); + }); + + it('drops empty/missing names and tolerates empty input', () => { + expect(namesFromMat({ variables: [{ name: '' }, {}, { name: 'X' }] }, 'file:///w/d.mat').map((r) => r.name)).toEqual([ + 'X', + ]); + expect(namesFromMat({ variables: [] }, 'file:///w/d.mat')).toEqual([]); + }); +}); + +describe('namesFromSlx', () => { + it('extracts workspace vars (kind workspace) and block names (kind block)', () => { + const parsed = { + workspace: [{ name: 'Ts' }], + blockParamUsages: [{ blockName: 'Gain1' }, { blockName: 'Sum1' }], + }; + const records = namesFromSlx(parsed, 'file:///w/plant.slx'); + expect(records).toEqual([ + { name: 'Ts', sourceUri: 'file:///w/plant.slx', sourceLabel: 'plant.slx', kind: 'workspace' }, + { name: 'Gain1', sourceUri: 'file:///w/plant.slx', sourceLabel: 'plant.slx', kind: 'block' }, + { name: 'Sum1', sourceUri: 'file:///w/plant.slx', sourceLabel: 'plant.slx', kind: 'block' }, + ]); + }); + + it('emits ONE record for a block that appears in multiple param usages', () => { + const parsed = { + blockParamUsages: [ + { blockName: 'Gain1' }, + { blockName: 'Gain1' }, + { blockName: 'Gain1' }, + ], + }; + const records = namesFromSlx(parsed, 'file:///w/plant.slx'); + expect(records).toHaveLength(1); + expect(records[0]).toMatchObject({ name: 'Gain1', kind: 'block' }); + }); + + it('drops empty/missing names in both workspace and blocks', () => { + const parsed = { + workspace: [{ name: '' }, { name: 'Keep' }, {}], + blockParamUsages: [{ blockName: '' }, { blockName: 'B' }, {}], + }; + const records = namesFromSlx(parsed, 'file:///w/plant.slx'); + expect(records.map((r) => r.name)).toEqual(['Keep', 'B']); + }); + + it('returns [] for empty input', () => { + expect(namesFromSlx({}, 'file:///w/plant.slx')).toEqual([]); + expect(namesFromSlx({ workspace: [], blockParamUsages: [] }, 'file:///w/plant.slx')).toEqual([]); + }); +}); + +describe('dup-preserving across sources', () => { + it('the same entry name in two different sources yields two distinct records', () => { + const a = namesFromSldd(slddContent([{ name: 'Shared' }]), 'file:///w/a.sldd'); + const b = namesFromSldd(slddContent([{ name: 'Shared' }]), 'file:///w/b.sldd'); + const all = [...a, ...b]; + expect(all).toHaveLength(2); + expect(all.map((r) => r.sourceUri)).toEqual(['file:///w/a.sldd', 'file:///w/b.sldd']); + expect(new Set(all.map((r) => r.name))).toEqual(new Set(['Shared'])); + }); +}); From 1275dc027215bd714714ce00ead3fa83f7ac5aab Mon Sep 17 00:00:00 2001 From: Wei Wang Date: Fri, 14 Aug 2026 14:19:58 -0400 Subject: [PATCH 02/10] [wip] wire entry-name search UI + incremental sync (Phase 2+3) QuickPick overlay over the name index, reached via a search icon in the Data Explorer tree title bar and the command palette. Picking an entry opens its source and selects the row (reuses navigate.requestSelect). Watcher + live-edit handlers keep the index in sync per file. Co-Authored-By: Claude Opus 4.8 --- package.json | 13 ++++++++ src/extension.ts | 36 +++++++++++++++++---- src/host/searchSources.ts | 68 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 111 insertions(+), 6 deletions(-) create mode 100644 src/host/searchSources.ts diff --git a/package.json b/package.json index 858dec0..5fbb00d 100644 --- a/package.json +++ b/package.json @@ -128,6 +128,12 @@ "title": "View as Table", "category": "Data Explorer", "icon": "$(table)" + }, + { + "command": "dataExplorer.searchDataSources", + "title": "Search Data Source Entries", + "category": "Data Explorer", + "icon": "$(search)" } ], "menus": { @@ -152,6 +158,13 @@ "command": "dataExplorer.viewAsTable", "when": "resourceExtname == .sldd" } + ], + "view/title": [ + { + "command": "dataExplorer.searchDataSources", + "when": "view == dataExplorer.sections", + "group": "navigation@1" + } ] } }, diff --git a/src/extension.ts b/src/extension.ts index 68fbd0a..794cd36 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -8,8 +8,10 @@ import { BinarySlddEditorProvider } from './host/BinarySlddEditorProvider.js'; import { HealthDecorationProvider } from './host/HealthDecorationProvider.js'; import { invalidate, findNode } from './host/SlddModel.js'; import { isEditableJsonSlddBytes, exceedsTextSyncLimit, isZipBytes } from './host/slddFormat.js'; -import { handleNavigate } from './host/navigate.js'; +import { handleNavigate, requestSelect } from './host/navigate.js'; import { invalidateUsageGraph } from './host/usageGraph.js'; +import { searchDataSources } from './host/searchSources.js'; +import { listEntries, reindexFile, removeFile } from './host/nameIndex.js'; import { isSectionRowId } from './common/sectionRowId.js'; const SUPPORTED_RE = /\.(sldd|mat|slx|prj)$/; @@ -158,13 +160,24 @@ export function activate(context: vscode.ExtensionContext): void { piProvider, ), watcher, - // Files added/removed change the root list. - watcher.onDidCreate(refreshAll), - watcher.onDidDelete(refreshAll), + // Files added/removed change the root list. Also keep the name index in sync: + // reindex the new file / drop the removed file's bucket. Both index ops are + // no-ops until the index is first built (by the first search), so they're + // cheap when search has never been opened. + watcher.onDidCreate((uri) => { + void reindexFile(uri); + refreshAll(); + }), + watcher.onDidDelete((uri) => { + removeFile(uri.toString()); + refreshAll(); + }), // A file's contents changed: drop its cached model (table) and rebuild the - // reference index (tree), since edits may add or remove references. + // reference index (tree), since edits may add or remove references. Also + // reindex its entry names (no-op until the index is first built). watcher.onDidChange((uri) => { invalidate(uri.toString()); + void reindexFile(uri); refreshAll(); }), // Live edits in an open editor: invalidate the cached model and refresh. @@ -173,8 +186,11 @@ export function activate(context: vscode.ExtensionContext): void { invalidate(e.document.uri.toString()); } // A dirty-state transition on any supported file changes the "modified" - // health badge, so refresh decorations for supported docs. + // health badge, so refresh decorations for supported docs. Also re-sync + // the name index for live entry-name edits (e.g. renaming an entry in an + // open .sldd); reindexFile is a no-op until the index is first built. if (SUPPORTED_RE.test(e.document.uri.path)) { + void reindexFile(e.document.uri); refreshAll(); } }), @@ -222,6 +238,14 @@ export function activate(context: vscode.ExtensionContext): void { /* ignore */ } }), + // Global entry-name search overlay: pick an entry by name across all data + // sources, then open its source file and select the matching row. + vscode.commands.registerCommand('dataExplorer.searchDataSources', () => + searchDataSources(listEntries, async (sourceUri, entryName) => { + requestSelect(sourceUri, entryName); + await openInBestEditor(vscode.Uri.parse(sourceUri), { preview: true }); + }), + ), ); } diff --git a/src/host/searchSources.ts b/src/host/searchSources.ts new file mode 100644 index 0000000..ebafd07 --- /dev/null +++ b/src/host/searchSources.ts @@ -0,0 +1,68 @@ +// Copyright 2026 The MathWorks, Inc. +// Global search over ENTRY NAMES inside data sources — the named entries a +// Simulink data source contains (dictionary/MAT variables, model-workspace +// params, block signals). This is deliberately scoped: +// - NOT file names — VS Code's built-in Search panel / quick-open covers those. +// - NOT cell values — each table's in-tab search covers those. +// It's presented as a QuickPick overlay (not a tree/view) so it never competes +// with the built-in Search view for panel real estate: it pops up, resolves, and +// dismisses on accept. +import * as vscode from 'vscode'; +import type { NameRecord, EntryKind } from './nameIndex.js'; +import { themeIconFor } from './iconMap.js'; + +// Per-kind dex icon id, mapped to a ThemeIcon via themeIconFor. Chosen to echo +// how each kind renders elsewhere in the extension. +const ICON_ID_BY_KIND: Record = { + sldd: 'wsDefault', + mat: 'wsNumeric', + workspace: 'wsParameters', + block: 'wsSignal', +}; + +function iconIdForKind(kind: EntryKind): string { + return ICON_ID_BY_KIND[kind]; +} + +// A QuickPick item that carries its originating NameRecord so onDidAccept can +// resolve the picked entry back to its source file + entry name. +interface EntryItem extends vscode.QuickPickItem { + entry: NameRecord; +} + +// Show the search overlay. `listEntries` supplies the (lazily built) name index; +// `reveal` opens the entry's source and selects the row. Both are injected so +// this module stays free of the index/editor wiring (that lives in extension.ts). +export async function searchDataSources( + listEntries: () => Promise, + reveal: (sourceUri: string, entryName: string) => void | Promise, +): Promise { + const qp = vscode.window.createQuickPick(); + qp.title = 'Search Data Source Entries'; + qp.placeholder = 'Search entries by name across all data sources'; + qp.matchOnDescription = true; + + qp.onDidAccept(() => { + const picked = qp.selectedItems[0]; + qp.hide(); + if (picked) void reveal(picked.entry.sourceUri, picked.entry.name); + }); + qp.onDidHide(() => qp.dispose()); + + qp.busy = true; + qp.show(); + try { + const records = await listEntries(); + records.sort( + (a, b) => a.name.localeCompare(b.name) || a.sourceLabel.localeCompare(b.sourceLabel), + ); + qp.items = records.map((rec) => ({ + label: rec.name, + description: rec.sourceLabel, + iconPath: themeIconFor(iconIdForKind(rec.kind)), + entry: rec, + })); + } finally { + qp.busy = false; + } +} From 319b6f16869f4a58efbbb6e6ee176036ddbbb6f0 Mon Sep 17 00:00:00 2001 From: Wei Wang Date: Fri, 14 Aug 2026 14:26:55 -0400 Subject: [PATCH 03/10] [wip] add name-index integration smoke test (Phase 4) Proves the eager, standalone name index end-to-end in a real VS Code: builds a complete list from UNOPENED fixture files, spans JSON + zip .sldd, and preserves the cross-file `structArray` duplicate. Pure extraction rules stay unit-tested in test/nameExtract.test.ts. Co-Authored-By: Claude Opus 4.8 --- test-integration/suite/nameIndex.test.ts | 91 ++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 test-integration/suite/nameIndex.test.ts diff --git a/test-integration/suite/nameIndex.test.ts b/test-integration/suite/nameIndex.test.ts new file mode 100644 index 0000000..bf87f08 --- /dev/null +++ b/test-integration/suite/nameIndex.test.ts @@ -0,0 +1,91 @@ +// Copyright 2026 The MathWorks, Inc. +// Integration tests for the workspace name index, run inside a real VS Code so +// `vscode.workspace.findFiles` and `workspace.fs.readFile` resolve against the +// fixture workspace (binary.sldd, data.sldd, params.sldd, model.slx). The pure +// name-extraction rules are unit-tested in test/nameExtract.test.ts; here we +// prove the end-to-end contract the vitest suite cannot reach: +// - the index builds a COMPLETE name list from files that are never OPENED +// (the whole point of eager, standalone indexing); +// - it spans every format (.sldd JSON, .sldd zip/binary, .slx); +// - it is DUP-PRESERVING across files (the same name in two sources yields two +// records, never a collapsed single entry). +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import { invalidate, ensureIndex, listEntries } from '../../src/host/nameIndex'; + +suite('workspace name index', () => { + suiteSetup(async () => { + await vscode.extensions.getExtension('mathworks.simulink-data-explorer')?.activate(); + }); + + setup(() => { + // Start from a clean slate so each test triggers a full, deterministic build + // off the on-disk fixtures (no leakage from a prior test's edits/reindex). + invalidate(); + }); + + test('builds a complete index from files that are never opened', async () => { + // No editor is opened here — listEntries() alone drives the eager scan. + await ensureIndex(); + const entries = await listEntries(); + assert.ok(entries.length > 0, 'the index is non-empty'); + + // Every record carries the four fields the search overlay relies on. + for (const e of entries) { + assert.ok(e.name, 'a record has a non-empty name'); + assert.ok(e.sourceUri, 'a record has a source URI'); + assert.ok(e.sourceLabel, 'a record has a source label (basename)'); + assert.ok( + ['sldd', 'mat', 'workspace', 'block'].includes(e.kind), + `a record has a known kind (got ${e.kind})`, + ); + } + }); + + test('lists .sldd entry names from an unopened JSON dictionary', async () => { + const entries = await listEntries(); + const fromData = entries.filter((e) => e.sourceLabel === 'data.sldd'); + const names = fromData.map((e) => e.name); + // Spot-check a few names that exist in the fixture data.sldd. + for (const expected of ['PI', 'Number', 'Struct', 'stringArray']) { + assert.ok(names.includes(expected), `data.sldd contributes "${expected}"`); + } + assert.ok(fromData.every((e) => e.kind === 'sldd'), 'all data.sldd records are kind "sldd"'); + }); + + test('lists entry names from an unopened compressed-binary (zip) .sldd', async () => { + // binary.sldd starts with the PK zip magic (0x50 0x4B) — it exercises the + // parseBinarySldd path, not JSON.parse. + const entries = await listEntries(); + const fromBinary = entries.filter((e) => e.sourceLabel === 'binary.sldd'); + assert.ok(fromBinary.length > 0, 'the zip .sldd contributes entry names'); + assert.ok(fromBinary.every((e) => e.kind === 'sldd'), 'all binary.sldd records are kind "sldd"'); + }); + + test('preserves duplicate names across files (never collapsed)', async () => { + // "structArray" exists in BOTH data.sldd and params.sldd in the fixture — a + // complete, dup-preserving index must surface both occurrences as distinct + // records so search can navigate to either source. + const entries = await listEntries(); + const structArrays = entries.filter((e) => e.name === 'structArray'); + const labels = structArrays.map((e) => e.sourceLabel).sort(); + assert.ok(labels.includes('data.sldd'), 'the data.sldd occurrence is present'); + assert.ok(labels.includes('params.sldd'), 'the params.sldd occurrence is present'); + assert.ok( + structArrays.length >= 2, + `both occurrences are distinct records (got ${structArrays.length})`, + ); + }); + + test('spans multiple .sldd sources (JSON + zip)', async () => { + const entries = await listEntries(); + const labels = new Set(entries.map((e) => e.sourceLabel)); + // The flat fixture workspace has three .sldd sources that carry entries. + // (model.slx is a minimal fixture with no model-workspace vars or block→param + // usages, so it contributes no name records — the .slx extraction path is + // covered by the vitest unit suite, test/nameExtract.test.ts.) + for (const f of ['data.sldd', 'params.sldd', 'binary.sldd']) { + assert.ok(labels.has(f), `the index includes entries from ${f}`); + } + }); +}); From 0a1fd595d60ddfd5ffab13384442474c4b2b5681 Mon Sep 17 00:00:00 2001 From: Wei Wang Date: Fri, 14 Aug 2026 15:07:09 -0400 Subject: [PATCH 04/10] Wire cross-tab row selection into the binary .sldd table view The writable compressed-binary .sldd editor (BinarySlddEditorProvider) was the one table provider missing the navigate-select wiring the other two have, so a global-search / Usage-link click opened the tab but never selected the target row. Mirror BinaryEditorProvider: drain consumePendingSelect() on first paint (just-opened case) and subscribe wireNavigateSelect() for live navigations to an already-open view, disposing it on teardown. Co-Authored-By: Claude Opus 4.8 --- src/host/BinarySlddEditorProvider.ts | 12 ++++++++++++ src/host/navigate.ts | 3 ++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/host/BinarySlddEditorProvider.ts b/src/host/BinarySlddEditorProvider.ts index 044fe2e..691b273 100644 --- a/src/host/BinarySlddEditorProvider.ts +++ b/src/host/BinarySlddEditorProvider.ts @@ -49,6 +49,7 @@ import { deleteFromSource, } from './editorHub.js'; import { basename } from '../common/pathUtil.js'; +import { wireNavigateSelect, consumePendingSelect } from './navigate.js'; import type { TableToHostMessage } from '../common/protocol.js'; // srcId prefix so the editable model never collides with the read-only @@ -185,6 +186,12 @@ export class BinarySlddEditorProvider implements vscode.CustomEditorProvider, uriString: string, From 7d42f6ca735babb0903826300475f3c3a9fcf70a Mon Sep 17 00:00:00 2001 From: Wei Wang Date: Fri, 14 Aug 2026 15:09:47 -0400 Subject: [PATCH 05/10] Extract shared drainNavigateSelect helper for the just-opened nav case All three table providers had their own copy of the first-paint "drain the pending cross-tab selection" logic (BinaryEditorProvider even wrapped it in a local closure). Hoist it into navigate.ts as drainNavigateSelect(webview, uri), have wireNavigateSelect reuse it for the live case, and make consumePendingSelect module-private now that it has no external callers. No behavior change. Co-Authored-By: Claude Opus 4.8 --- src/host/BinaryEditorProvider.ts | 12 +++--------- src/host/BinarySlddEditorProvider.ts | 9 ++------- src/host/SlddTextEditorProvider.ts | 7 ++----- src/host/navigate.ts | 26 ++++++++++++++++++++------ 4 files changed, 27 insertions(+), 27 deletions(-) diff --git a/src/host/BinaryEditorProvider.ts b/src/host/BinaryEditorProvider.ts index 89a44a7..c6f9a0a 100644 --- a/src/host/BinaryEditorProvider.ts +++ b/src/host/BinaryEditorProvider.ts @@ -14,7 +14,7 @@ import { buildMatRows } from './matRowBuilder.js'; import { readProjectStore } from './projectStore.js'; import { isEditableJsonSlddBytes, exceedsTextSyncLimit, exceedsStringDecodeLimit, isZipBytes } from './slddFormat.js'; import { annotateDataRows, annotateModelRows } from './usageGraph.js'; -import { wireNavigateSelect, consumePendingSelect } from './navigate.js'; +import { wireNavigateSelect, drainNavigateSelect } from './navigate.js'; import { basename } from '../common/pathUtil.js'; import { toArrayBuffer } from '../common/bytes.js'; import type { TableToHostMessage } from '../common/protocol.js'; @@ -157,12 +157,6 @@ export class BinaryEditorProvider implements vscode.CustomReadonlyEditorProvider return toArrayBuffer(await vscode.workspace.fs.readFile(document.uri)); }; - // If a cross-tab navigation targeted this file (e.g. it was just opened by a - // Usage-link click), select the requested row now that rows exist. - const drainNavSelect = (): void => { - const navName = consumePendingSelect(uriString); - if (navName) webview.postMessage({ type: 'selectByName', name: navName }); - }; // Read/parse the file host-side and push rows to the webview. On failure, // drop the cached model and post a banner. @@ -182,7 +176,7 @@ export class BinaryEditorProvider implements vscode.CustomReadonlyEditorProvider columnLabels: PROJECT_COLUMN_LABELS, editable: false, }); - drainNavSelect(); + drainNavigateSelect(webview, uriString); return; } @@ -211,7 +205,7 @@ export class BinaryEditorProvider implements vscode.CustomReadonlyEditorProvider editable: false, notice, }); - drainNavSelect(); + drainNavigateSelect(webview, uriString); } catch (err) { invalidate(uriString); webview.postMessage({ diff --git a/src/host/BinarySlddEditorProvider.ts b/src/host/BinarySlddEditorProvider.ts index 691b273..5fcf1b7 100644 --- a/src/host/BinarySlddEditorProvider.ts +++ b/src/host/BinarySlddEditorProvider.ts @@ -49,7 +49,7 @@ import { deleteFromSource, } from './editorHub.js'; import { basename } from '../common/pathUtil.js'; -import { wireNavigateSelect, consumePendingSelect } from './navigate.js'; +import { wireNavigateSelect, drainNavigateSelect } from './navigate.js'; import type { TableToHostMessage } from '../common/protocol.js'; // srcId prefix so the editable model never collides with the read-only @@ -186,12 +186,7 @@ export class BinarySlddEditorProvider implements vscode.CustomEditorProvider, + uriString: string, +): void { + const name = consumePendingSelect(uriString); + if (name) void webview.postMessage({ type: 'selectByName', name }); +} + // Wire live cross-tab selection for an already-open editor: when a navigation // targets THIS file, consume its pending entry (so it can't re-fire on a later // repaint) and ask the webview to select the named row. The just-opened case is -// drained separately in each provider's first paint via consumePendingSelect. +// drained separately in each provider's first paint via drainNavigateSelect. // Returns the subscription for the caller to dispose on panel teardown. Shared -// verbatim by all three table providers (SlddTextEditorProvider, -// BinaryEditorProvider, BinarySlddEditorProvider). +// by all three table providers (SlddTextEditorProvider, BinaryEditorProvider, +// BinarySlddEditorProvider). export function wireNavigateSelect( webview: Pick, uriString: string, ): vscode.Disposable { return onNavigateSelect((e) => { if (e.uri !== uriString) return; - consumePendingSelect(uriString); - void webview.postMessage({ type: 'selectByName', name: e.name }); + drainNavigateSelect(webview, uriString); }); } From 8e1190da591e676992cef1e5dab2a37093a4d6b7 Mon Sep 17 00:00:00 2001 From: Wei Wang Date: Fri, 14 Aug 2026 15:17:45 -0400 Subject: [PATCH 06/10] Add Cmd+K G / Ctrl+K G keybinding for global entry search A discoverable shortcut alongside the tree title-bar search icon. Uses the Cmd/Ctrl+K G chord, which is unbound in VS Code's defaults (verified against a clean profile) so it overrides no built-in. Global (no when clause) to match the workspace-wide scope of the search. Co-Authored-By: Claude Opus 4.8 --- package.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 5fbb00d..61bc736 100644 --- a/package.json +++ b/package.json @@ -166,7 +166,14 @@ "group": "navigation@1" } ] - } + }, + "keybindings": [ + { + "command": "dataExplorer.searchDataSources", + "key": "ctrl+k g", + "mac": "cmd+k g" + } + ] }, "scripts": { "build:webview": "vite build", From 0a889c19a571fb22588d9e3bdf9fc5bae9774286 Mon Sep 17 00:00:00 2001 From: Wei Wang Date: Fri, 14 Aug 2026 15:20:36 -0400 Subject: [PATCH 07/10] Use Cmd+Alt+E / Ctrl+Alt+E for global entry search (was Cmd+K G) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cmd+K is VS Code's chord leader, so Cmd+K bindings shadow / race with built-in chords. Switch to a leaderless single combo. Verified Cmd+Alt+E is unbound on all platforms by decoding the default keymap from the bundled VS Code 1.133.0 build (Cmd+Alt+F is Replace, Cmd+Alt+S is taken, Cmd+Alt+G is mac-only — E is free everywhere and mnemonic for "Entries"). Co-Authored-By: Claude Opus 4.8 --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 61bc736..6ddd7ef 100644 --- a/package.json +++ b/package.json @@ -170,8 +170,8 @@ "keybindings": [ { "command": "dataExplorer.searchDataSources", - "key": "ctrl+k g", - "mac": "cmd+k g" + "key": "ctrl+alt+e", + "mac": "cmd+alt+e" } ] }, From e7a3ac3bb45161037d63c3b3875f49ccaf4920e0 Mon Sep 17 00:00:00 2001 From: Wei Wang Date: Fri, 14 Aug 2026 15:29:17 -0400 Subject: [PATCH 08/10] Show loading spinner in all table views, only after a 500ms delay The loading overlay lived only in BinaryEditorProvider, so opening a large editable JSON .sldd (SlddTextEditorProvider) or compressed-binary .sldd (BinarySlddEditorProvider) showed a blank table with no feedback during the synchronous host parse. Hoist the overlay markup into a shared LOADING_OVERLAY_HTML in webviewHtml.ts and include it in all three table views. Also change the reveal to be delay-gated: the webview arms a 500ms timer at boot and only shows the spinner if the first setRows/error hasn't arrived by then, so a fast open never flashes a spinner. The webview renderer runs this timer independently of the busy extension host, so the delay is honored during parse. Co-Authored-By: Claude Opus 4.8 --- src/host/BinaryEditorProvider.ts | 10 +++------- src/host/BinarySlddEditorProvider.ts | 3 ++- src/host/SlddTextEditorProvider.ts | 3 ++- src/host/webviewHtml.ts | 12 ++++++++++++ src/webview/table-main.ts | 24 ++++++++++++++++++++---- 5 files changed, 39 insertions(+), 13 deletions(-) diff --git a/src/host/BinaryEditorProvider.ts b/src/host/BinaryEditorProvider.ts index c6f9a0a..22f0c0a 100644 --- a/src/host/BinaryEditorProvider.ts +++ b/src/host/BinaryEditorProvider.ts @@ -1,6 +1,6 @@ // Copyright 2026 The MathWorks, Inc. import * as vscode from 'vscode'; -import { renderWebviewHtml } from './webviewHtml.js'; +import { renderWebviewHtml, LOADING_OVERLAY_HTML } from './webviewHtml.js'; import { getModelFromBytes, getProjectModel, invalidate } from './SlddModel.js'; import { buildRows, @@ -281,14 +281,10 @@ export class BinaryEditorProvider implements vscode.CustomReadonlyEditorProvider return renderWebviewHtml(webview, distRoot, { scriptFile: 'table.js', title: 'Data Explorer', - body: ` - + body: ` -
-
-
Loading…
-
+${LOADING_OVERLAY_HTML} `, }); } diff --git a/src/host/BinarySlddEditorProvider.ts b/src/host/BinarySlddEditorProvider.ts index 5fcf1b7..1be0814 100644 --- a/src/host/BinarySlddEditorProvider.ts +++ b/src/host/BinarySlddEditorProvider.ts @@ -18,7 +18,7 @@ // cached model of the same file. import * as vscode from 'vscode'; import { unzipSync, zipSync } from 'fflate'; -import { renderWebviewHtml } from './webviewHtml.js'; +import { renderWebviewHtml, LOADING_OVERLAY_HTML } from './webviewHtml.js'; import { buildRows, COLUMNS, COLUMN_LABELS, COLUMN_GROUPS, type ClipMark } from './rowBuilder.js'; import { sectionRules } from './sectionRules.js'; import { parseBinarySlddParts } from '../dex/datamodel/parser/BinarySlddParser.js'; @@ -486,6 +486,7 @@ export class BinarySlddEditorProvider implements vscode.CustomEditorProvider