diff --git a/package.json b/package.json
index 858dec0..6ddd7ef 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,8 +158,22 @@
"command": "dataExplorer.viewAsTable",
"when": "resourceExtname == .sldd"
}
+ ],
+ "view/title": [
+ {
+ "command": "dataExplorer.searchDataSources",
+ "when": "view == dataExplorer.sections",
+ "group": "navigation@1"
+ }
]
- }
+ },
+ "keybindings": [
+ {
+ "command": "dataExplorer.searchDataSources",
+ "key": "ctrl+alt+e",
+ "mac": "cmd+alt+e"
+ }
+ ]
},
"scripts": {
"build:webview": "vite build",
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/BinaryEditorProvider.ts b/src/host/BinaryEditorProvider.ts
index 89a44a7..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,
@@ -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({
@@ -287,14 +281,10 @@ export class BinaryEditorProvider implements vscode.CustomReadonlyEditorProvider
return renderWebviewHtml(webview, distRoot, {
scriptFile: 'table.js',
title: 'Data Explorer',
- body: `
-
+ body: `
-
+${LOADING_OVERLAY_HTML}
`,
});
}
diff --git a/src/host/BinarySlddEditorProvider.ts b/src/host/BinarySlddEditorProvider.ts
index 044fe2e..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';
@@ -49,6 +49,7 @@ import {
deleteFromSource,
} from './editorHub.js';
import { basename } from '../common/pathUtil.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
@@ -185,6 +186,7 @@ export class BinarySlddEditorProvider implements vscode.CustomEditorProvider
+${LOADING_OVERLAY_HTML}
`,
});
@@ -494,6 +501,7 @@ export class BinarySlddEditorProvider implements vscode.CustomEditorProvider
+${LOADING_OVERLAY_HTML}
`,
});
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..abc05fb
--- /dev/null
+++ b/src/host/nameIndex.ts
@@ -0,0 +1,119 @@
+// 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[] = [];
+ // NB: append with a loop, not `out.push(...bucket)`. A data source can hold
+ // tens of thousands of entries, and spreading a huge array as call arguments
+ // overflows the engine's argument limit ("Maximum call stack size exceeded").
+ for (const bucket of index?.values() ?? []) {
+ for (const rec of bucket) out.push(rec);
+ }
+ 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/src/host/navigate.ts b/src/host/navigate.ts
index 6c23747..87ea2da 100644
--- a/src/host/navigate.ts
+++ b/src/host/navigate.ts
@@ -34,26 +34,41 @@ export function requestSelect(uriString: string, name: string): void {
emitter.fire({ uri: uriString, name });
}
-export function consumePendingSelect(uriString: string): string | undefined {
+// Internal: read-and-clear the pending selection for a uri. Exposed to providers
+// only through drainNavigateSelect / wireNavigateSelect below.
+function consumePendingSelect(uriString: string): string | undefined {
const name = pending.get(uriString);
pending.delete(uriString);
return name;
}
+// Drain any pending cross-tab selection for THIS file and ask the webview to
+// select that row. Called by a table provider from its FIRST paint (once rows
+// exist) to cover the just-opened case: a Usage-link click or the global entry
+// search fires requestSelect before this editor exists to hear the live event,
+// so the target lands in the pending map and is drained here.
+export function drainNavigateSelect(
+ webview: Pick,
+ 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 both providers (SlddTextEditorProvider, BinaryEditorProvider).
+// 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);
});
}
diff --git a/src/host/searchFilter.ts b/src/host/searchFilter.ts
new file mode 100644
index 0000000..6d92aba
--- /dev/null
+++ b/src/host/searchFilter.ts
@@ -0,0 +1,25 @@
+// Copyright 2026 The MathWorks, Inc.
+// Pure (vscode-free) match/cap rule behind the global entry-search overlay.
+// Split from searchSources.ts (the QuickPick wiring) so the filter is unit-
+// testable without a live vscode — mirrors the nameExtract.ts ↔ nameIndex.ts
+// pure-core / host-IO split.
+import type { NameRecord } from './nameExtract.js';
+
+// Filter the name index for the overlay. An empty/whitespace query returns no
+// matches (the list stays empty until the user types). Otherwise a case-
+// insensitive substring match on the entry name OR its source label, preserving
+// input order and capped at `max`. The cap guards the QuickPick, which has no
+// virtual scrolling: a broad query over a large index is truncated rather than
+// handed over whole.
+export function filterEntries(records: NameRecord[], query: string, max: number): NameRecord[] {
+ const q = query.trim().toLowerCase();
+ if (!q) return [];
+ const matches: NameRecord[] = [];
+ for (const rec of records) {
+ if (rec.name.toLowerCase().includes(q) || rec.sourceLabel.toLowerCase().includes(q)) {
+ matches.push(rec);
+ if (matches.length >= max) break;
+ }
+ }
+ return matches;
+}
diff --git a/src/host/searchSources.ts b/src/host/searchSources.ts
new file mode 100644
index 0000000..c0e866e
--- /dev/null
+++ b/src/host/searchSources.ts
@@ -0,0 +1,99 @@
+// 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 { filterEntries } from './searchFilter.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;
+}
+
+// Cap on how many matches we hand to the QuickPick at once. The QuickPick list
+// has NO virtual scrolling (unlike the table), so pushing the whole index — tens
+// of thousands of entries for a large data source — makes it open and filter
+// sluggishly. We do our own filtering on each keystroke and show at most this
+// many results; a broad query is truncated with a hint rather than dumped whole.
+const MAX_RESULTS = 500;
+
+function toItem(rec: NameRecord): EntryItem {
+ return {
+ label: rec.name,
+ description: rec.sourceLabel,
+ iconPath: themeIconFor(iconIdForKind(rec.kind)),
+ entry: rec,
+ };
+}
+
+// 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).
+//
+// The list starts EMPTY and populates only as the user types: we filter the
+// in-memory index ourselves and set `qp.items` to the (capped) matches, rather
+// than handing the entire index to the un-virtualized QuickPick.
+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 = 'Type to search entries by name across all data sources';
+ qp.matchOnDescription = true;
+ // We supply already-filtered items, so don't let the QuickPick filter again on
+ // top of our results (it would hide matches whose label doesn't literally
+ // contain the query in order).
+ qp.matchOnDetail = false;
+
+ let records: NameRecord[] = [];
+
+ // Recompute the visible items for the current query. Empty query → empty list
+ // (nothing shown until the user types). Otherwise case-insensitive substring
+ // match on the entry name and its source label, capped at MAX_RESULTS.
+ const refresh = (): void => {
+ qp.items = filterEntries(records, qp.value, MAX_RESULTS).map(toItem);
+ };
+
+ qp.onDidChangeValue(refresh);
+ 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 {
+ records = await listEntries();
+ records.sort(
+ (a, b) => a.name.localeCompare(b.name) || a.sourceLabel.localeCompare(b.sourceLabel),
+ );
+ } finally {
+ qp.busy = false;
+ }
+ // The user may have typed while the index was loading; render now that it's in.
+ refresh();
+}
diff --git a/src/host/webviewHtml.ts b/src/host/webviewHtml.ts
index 37832d3..722280d 100644
--- a/src/host/webviewHtml.ts
+++ b/src/host/webviewHtml.ts
@@ -2,6 +2,18 @@
import * as vscode from 'vscode';
import { getNonce } from './nonce.js';
+// Shared loading overlay for the three table views (table.js). Starts hidden:
+// the webview only reveals it if the first payload hasn't arrived after a short
+// delay (see table-main.ts), so a fast open never flashes a spinner. The webview
+// renderer runs this timer independently of the extension host's synchronous
+// parse, so the delay is honored even while the host is busy. `hideLoading()`
+// hides it again on the first setRows/error.
+export const LOADING_OVERLAY_HTML = `
+ `;
+
// Shared webview-shell builder for all three providers (table editor, binary
// editor, Property Inspector). They differ only in the entry script, the
// document , and the markup; the CSP, , nonce, and the
diff --git a/src/webview/table-main.ts b/src/webview/table-main.ts
index 8e75db2..445ef08 100644
--- a/src/webview/table-main.ts
+++ b/src/webview/table-main.ts
@@ -113,11 +113,27 @@ function applyPendingNameSelection(): void {
pendingSelectName = null;
}
-// Hide the initial loading spinner. The host runs a synchronous parse (and, on
-// first open, a whole-workspace usage-graph scan) before it can post the first
-// message, so this covers the several-second gap between 'ready' and 'setRows'.
-// Called on the first payload (setRows) or on error — either ends the wait.
+// Loading spinner, shown only if the first payload is slow to arrive. The host
+// runs a synchronous parse (and, on first open, a whole-workspace usage-graph
+// scan) before it can post 'setRows', which can take several seconds on a large
+// file. Rather than flash a spinner on every open, we arm a timer at boot and
+// reveal the overlay only if that gap exceeds the delay below; a fast open hides
+// the (never-shown) overlay and cancels the timer, so it never flashes. The
+// webview renderer runs this timer independently of the busy extension host.
+const LOADING_SPINNER_DELAY_MS = 500;
+let loadingTimer: ReturnType | undefined = setTimeout(() => {
+ loadingTimer = undefined;
+ const el = document.getElementById('dex-loading');
+ if (el) el.style.display = 'flex';
+}, LOADING_SPINNER_DELAY_MS);
+
+// Cancel the pending reveal and hide the overlay. Called on the first payload
+// (setRows) or on error — either ends the wait.
function hideLoading(): void {
+ if (loadingTimer !== undefined) {
+ clearTimeout(loadingTimer);
+ loadingTimer = undefined;
+ }
const el = document.getElementById('dex-loading');
if (el) el.style.display = 'none';
}
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}`);
+ }
+ });
+});
diff --git a/test/filterEntries.test.ts b/test/filterEntries.test.ts
new file mode 100644
index 0000000..2dd91c7
--- /dev/null
+++ b/test/filterEntries.test.ts
@@ -0,0 +1,68 @@
+// Copyright 2026 The MathWorks, Inc.
+// Unit tests for filterEntries — the pure match/cap rule behind the global
+// entry-search overlay. The overlay itself (QuickPick wiring) needs a live
+// vscode, so only this pure core is unit-tested; the end-to-end index build is
+// covered by test-integration/suite/nameIndex.test.ts.
+import { describe, it, expect } from 'vitest';
+import { filterEntries } from '../src/host/searchFilter.js';
+import type { NameRecord } from '../src/host/nameExtract.js';
+
+function rec(name: string, sourceLabel = 'data.sldd'): NameRecord {
+ return { name, sourceUri: `file:///w/${sourceLabel}`, sourceLabel, kind: 'sldd' };
+}
+
+const RECORDS: NameRecord[] = [
+ rec('Kp'),
+ rec('Ki'),
+ rec('gain', 'model.slx'),
+ rec('gainSchedule', 'model.slx'),
+ rec('Throttle', 'params.sldd'),
+];
+
+describe('filterEntries', () => {
+ it('returns nothing for an empty or whitespace query (list stays empty until typing)', () => {
+ expect(filterEntries(RECORDS, '', 500)).toEqual([]);
+ expect(filterEntries(RECORDS, ' ', 500)).toEqual([]);
+ });
+
+ it('matches entry names case-insensitively as a substring', () => {
+ expect(filterEntries(RECORDS, 'gain', 500).map((r) => r.name)).toEqual([
+ 'gain',
+ 'gainSchedule',
+ ]);
+ // case-insensitive
+ expect(filterEntries(RECORDS, 'GAIN', 500).map((r) => r.name)).toEqual([
+ 'gain',
+ 'gainSchedule',
+ ]);
+ // interior substring, not just prefix
+ expect(filterEntries(RECORDS, 'chedul', 500).map((r) => r.name)).toEqual(['gainSchedule']);
+ });
+
+ it('also matches on the source label so a file name narrows results', () => {
+ expect(filterEntries(RECORDS, 'params', 500).map((r) => r.name)).toEqual(['Throttle']);
+ });
+
+ it('preserves input order among matches', () => {
+ // Query 'i' matches Ki, gain, gainSchedule (Throttle has no 'i', and no source
+ // label contains 'i'); the result must follow the input array order.
+ expect(filterEntries(RECORDS, 'i', 500).map((r) => r.name)).toEqual([
+ 'Ki',
+ 'gain',
+ 'gainSchedule',
+ ]);
+ });
+
+ it('caps the result count at `max` (the un-virtualized list guard)', () => {
+ const many = Array.from({ length: 1000 }, (_, i) => rec(`sig${i}`));
+ const out = filterEntries(many, 'sig', 10);
+ expect(out).toHaveLength(10);
+ // the cap keeps the first N in input order
+ expect(out[0].name).toBe('sig0');
+ expect(out[9].name).toBe('sig9');
+ });
+
+ it('returns an empty list when nothing matches', () => {
+ expect(filterEntries(RECORDS, 'zzz', 500)).toEqual([]);
+ });
+});
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']));
+ });
+});