Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

All notable changes to the Simulink Data Explorer extension.

## [Unreleased]

- You can now add variables, strings and Simulink objects to MAT-files from the Add gallery.
- Bus elements in MAT-files can be added and removed.
- Properties of Simulink objects in MAT-files can be edited in the Properties panel.

## [1.43.0] — 2026-10-21

- MAT-files can now be edited and saved.
Expand Down
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@
},
"dependencies": {
"@lit/context": "^1.1.6",
"data-explorer-core": "github:mathworks/data-explorer-core#v1.44.0",
"data-explorer-core": "github:mathworks/data-explorer-core#v1.45.0",
"fast-xml-parser": "^5.8.0",
"fflate": "^0.8.3",
"highlight.js": "^11.11.1",
Expand Down
39 changes: 39 additions & 0 deletions src/common/addCatalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,45 @@ export const OMITTED: readonly { className: string; section: string; why: string
},
];

/**
* Tiles only a MAT-file's gallery offers, each with the category it joins there. A `string` is a
* MATLAB value a MAT-file holds and a dictionary does not (no section admits one), so it has no
* place in ADD_CATALOG, which is every dictionary's gallery as it is.
*/
export const MAT_ONLY_TILES: readonly { category: string; after: string; tile: GalleryTile }[] = [
{
category: 'Parameters',
after: 'MatlabStruct',
tile: { label: 'MATLAB String', className: 'string', section: 'design', iconId: 'wsString' },
},
];

/**
* A MAT-file's gallery: the tiles of `classes`, the classes core says the file can take a new
* variable of (MatNode.getAllowedTypes), and no other. A MAT-file has no sections, so a class is
* offered once, by its Design Data tile; the Architectural Data tiles of the same classes, and
* every class core does not list (an enumeration, the variants, a configuration set), are left
* out, and so is a category none of whose tiles is left. The `.mat`-only tiles join where
* MAT_ONLY_TILES says. Every category left is Design Data's alone, so no tile is badged.
*/
export function matCatalog(classes: readonly string[]): GalleryCategory[] {
const offered = new Set(classes);
const out: GalleryCategory[] = [];
for (const c of ADD_CATALOG) {
const tiles: GalleryTile[] = [];
for (const t of c.tiles) {
if (t.section === 'design' && offered.has(t.className)) tiles.push(t);
for (const extra of MAT_ONLY_TILES) {
if (extra.category === c.title && extra.after === t.className && t.section === 'design' && offered.has(extra.tile.className)) {
tiles.push(extra.tile);
}
}
}
if (tiles.length > 0) out.push(category(c.title, tiles));
}
return out;
}

/** Every tile, flat, in display order — the order a keyboard walk follows. */
export function allTiles(): GalleryTile[] {
return ADD_CATALOG.flatMap((c) => c.tiles as GalleryTile[]);
Expand Down
33 changes: 27 additions & 6 deletions src/common/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,11 @@ export interface SetRowsMessage {
*/
hasTextView?: boolean;
/**
* What the Add button (and its key) creates. Absent: an entry of the class the user picks
* in the gallery, a dictionary's. `'variable'`: one plain variable, at once, with its name
* editor open, a MAT-file's, whose Add (core's) makes `var` = 0 and nothing else.
* What the Add gallery offers. Absent: every tile, a dictionary's, each naming its section.
* `{classes}`: a MAT-file's, the tiles of the classes core says the file can take a new
* variable of (MatNode.getAllowedTypes), in no section.
*/
add?: 'variable';
add?: { classes: readonly string[] };
/**
* Whether rows can be copied, cut, pasted and dragged. Absent is true. False for a
* MAT-file, whose variables have no dictionary entry form to travel as: the menu leaves
Expand Down Expand Up @@ -231,6 +231,15 @@ export interface ValidationErrorMessage {
export interface ShowPropsMessage {
type: 'showProps';
groups: any[];
// The node the groups are of, which an edit made in the panel names (PropertyEditMessage).
nodeId?: string;
}

/** The panel's last edit was refused: why, in core's words. Follows the showProps that reverts it. */
export interface PropertyRefusedMessage {
type: 'propertyRefused';
propName: string;
reason: string;
}

/** Clear the inspector (nothing selected). */
Expand Down Expand Up @@ -351,7 +360,7 @@ export type HostToTableMessage =
| ChildRowsMessage;

/** Every message the property-inspector webview can receive from the host. */
export type HostToPropsMessage = ShowPropsMessage | EmptyMessage | MatrixCellsMessage;
export type HostToPropsMessage = ShowPropsMessage | EmptyMessage | MatrixCellsMessage | PropertyRefusedMessage;

// --- Webview -> Host (table view -> providers) --------------------------------

Expand Down Expand Up @@ -556,4 +565,16 @@ export type ProjectToHostMessage = ReadyMessage | OpenFileMessage;
// same thing from either webview, and the host answers both with the same closure. The
// same goes for `RequestMatrixMessage`: the inspector's Value row opens the same Variable
// Editor the table's does, and it is answered by the same function.
export type PropsToHostMessage = ReadyMessage | NavigateMessage | RequestMatrixMessage;
/**
* An edit committed in the Property Inspector: property `propName` (core's key) of node
* `nodeId`, the node the panel shows. Taken only where the host gave the panel an edit channel.
*/
export interface PropertyEditMessage {
type: 'editProperty';
nodeId: string;
propName: string;
newValue: string;
oldValue: string;
}

export type PropsToHostMessage = ReadyMessage | NavigateMessage | RequestMatrixMessage | PropertyEditMessage;
20 changes: 15 additions & 5 deletions src/extension.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright 2026 The MathWorks, Inc.
import * as vscode from 'vscode';
import { SectionsTreeProvider } from './host/SectionsTreeProvider.js';
import { PropertiesViewProvider } from './host/PropertiesViewProvider.js';
import { PropertiesViewProvider, type PropertyEditHandler } from './host/PropertiesViewProvider.js';
import { BinaryEditorProvider } from './host/BinaryEditorProvider.js';
import { SlddTextEditorProvider } from './host/SlddTextEditorProvider.js';
import { BinarySlddEditorProvider } from './host/BinarySlddEditorProvider.js';
Expand Down Expand Up @@ -96,16 +96,18 @@ export function activate(context: vscode.ExtensionContext): void {
// Section rows (id `section:<key>`) have no properties, so clear the PI.
// The selection the inspector was last asked to show, so a view whose tree is replaced (a
// MAT-file reverted or reloaded from disk) can have its node shown again from the new tree.
// `editor` is the edit channel of a document whose host takes edits made in the inspector (a
// MAT-file's); the inspector is read-only for every other.
let lastSelection: { uriString: string; rowIds: string[] } | undefined;
const showSelection = (uriString: string, rowIds: string[]): void => {
const showSelection = (uriString: string, rowIds: string[], editor?: PropertyEditHandler): void => {
lastSelection = { uriString, rowIds };
const first = rowIds && rowIds.length > 0 ? rowIds[0] : undefined;
if (!first || isSectionRowId(first)) {
piProvider.clear();
return;
}
const node = findNode(uriString, first);
if (node) piProvider.showNode(node, uriString);
if (node) piProvider.showNode(node, uriString, editor);
else piProvider.clear();
};

Expand Down Expand Up @@ -139,12 +141,20 @@ export function activate(context: vscode.ExtensionContext): void {
// A MAT-file opens in this writable provider (edits through core's session, save
// through core's writer). Shares the selection→PI and navigate wiring.
const matProvider = new MatEditorProvider(context);
matProvider.onSelect = (uriString, rowIds) => showSelection(uriString, rowIds);
// Its inspector takes edits: each is a gesture of the MAT-file editor's, after which the node is
// shown again (a mode edit of a NumericType derives its other properties), with the reason
// core gave where it refused.
const matInspector = (uriString: string): PropertyEditHandler => (nodeId, propName, newValue, oldValue) => {
const result = matProvider.editFromInspector(uriString, nodeId, propName, newValue, oldValue);
showSelection(uriString, [result.nodeId ?? nodeId], matInspector(uriString));
if (result.refused) piProvider.refuse(propName, result.refused);
};
matProvider.onSelect = (uriString, rowIds) => showSelection(uriString, rowIds, matInspector(uriString));
matProvider.onNavigate = navigate;
// Node ids are paths, so the node the inspector shows is found again in the new tree, or the
// inspector is cleared if it is gone; it never goes on showing a node of the tree that left.
matProvider.onReplaced = (uriString) => {
if (lastSelection?.uriString === uriString) showSelection(uriString, lastSelection.rowIds);
if (lastSelection?.uriString === uriString) showSelection(uriString, lastSelection.rowIds, matInspector(uriString));
};

// Health badges/colors on tree rows (missing/cycle/modified). The tree
Expand Down
111 changes: 104 additions & 7 deletions src/host/MatEditorProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import { binaryEditSrcId } from '../common/srcId.js';
import { basename } from '../common/pathUtil.js';
import { toArrayBuffer } from '../common/bytes.js';
import type { TableToHostMessage } from '../common/protocol.js';
import { matCatalog } from '../common/addCatalog.js';

/** One read of the file behind a document, with the stamp it was read under (stat first). */
interface DiskRead {
Expand Down Expand Up @@ -479,6 +480,41 @@ function backupSource(id: string): { uri: vscode.Uri; stamp: DiskStamp | undefin
return { uri: vscode.Uri.parse(id), stamp: undefined };
}

/**
* The classes `mat` can take a new variable of, as core lists them (MatNode.getAllowedTypes): a
* plain variable and a struct, and the classes of MATLAB's own default objects where the file's
* objects can be added to. A core without the list adds a plain variable only, as it always has.
*/
/** What an edit from the Property Inspector came to: the node's id after it, or why it was refused. */
export interface InspectorResult {
nodeId?: string;
refused?: string;
}
type InspectorEdit = (nodeId: string, propName: string, newValue: string, oldValue: string) => InspectorResult;

function allowedClasses(mat: any): readonly string[] {
if (typeof mat?.getAllowedTypes !== 'function') return ['MatlabVariable'];
const classes = mat.getAllowedTypes();
return Array.isArray(classes) ? classes.filter((c: unknown): c is string => typeof c === 'string') : [];
}

/** The label the gallery's tile of `className` shows ('MATLAB Structure'), or the class itself. */
function tileLabelOf(className: string): string {
return matCatalog([className]).flatMap((c) => c.tiles).find((t) => t.className === className)?.label ?? className;
}

/** The label the inspector shows property `key` of `node` by, or the key where it shows none. */
function inspectorLabelOf(node: any, key: string): string {
try {
const prop = node?.toPIObject?.()?.propertySheet?.properties?.find((p: any) => p.name === key);
return typeof prop?.displayName === 'string' && prop.displayName !== '' ? prop.displayName : key;
} catch {
return key;
}
}

const sameList = (a: readonly string[], b: readonly string[]): boolean => a.length === b.length && a.every((x, i) => x === b[i]);

export class MatEditorProvider implements vscode.CustomEditorProvider<MatDocument> {
public static readonly viewType = 'dataExplorer.matView';

Expand All @@ -490,6 +526,9 @@ export class MatEditorProvider implements vscode.CustomEditorProvider<MatDocumen
// Inspector's node again from the new tree, if it is this document's).
public onReplaced?: (uriString: string) => void;

// Each open view's edit of a node from the Property Inspector, by document (editFromInspector).
private readonly inspectorEdits = new Map<string, InspectorEdit>();

private readonly _onDidChangeCustomDocument = new vscode.EventEmitter<vscode.CustomDocumentEditEvent<MatDocument>>();
readonly onDidChangeCustomDocument = this._onDidChangeCustomDocument.event;

Expand Down Expand Up @@ -559,6 +598,12 @@ export class MatEditorProvider implements vscode.CustomEditorProvider<MatDocumen
return { planned, version: document.version };
};

// The classes the gallery was last told the file can take a new variable of. Core's answer
// (MatNode.getAllowedTypes) can change with the file — a reload, a subsystem a save no longer
// writes as read — so a gesture that changes it repaints the whole table, which tells the
// gallery again (gesture, below).
let lastClasses: readonly string[] = [];

const post = async (): Promise<void> => {
let mat: any;
try {
Expand Down Expand Up @@ -592,8 +637,8 @@ export class MatEditorProvider implements vscode.CustomEditorProvider<MatDocumen
columnGroups: COLUMN_GROUPS,
editable: !notice,
notice,
// A MAT-file adds a variable, not an entry of a class picked from the gallery.
add: 'variable',
// The gallery offers the classes core says this file can take a new variable of.
add: { classes: lastClasses = allowedClasses(mat) },
// Nor does it copy, cut, paste or drag one: a variable has no dictionary entry form.
clipboard: false,
warnings: document.describeBanner(lazyRowsBanner(built.planned.plan, warningBanner(sourceWarnings(mat)))),
Expand Down Expand Up @@ -657,6 +702,7 @@ export class MatEditorProvider implements vscode.CustomEditorProvider<MatDocumen
return null;
}
document.repaint(observed);
if (!sameList(allowedClasses(mat), lastClasses)) void post();
return observed;
};

Expand Down Expand Up @@ -699,6 +745,35 @@ export class MatEditorProvider implements vscode.CustomEditorProvider<MatDocumen
if (msg.columnId === 'Name') webview.postMessage({ type: 'selectRows', rowIds: [node.id] });
};

// An edit from the Property Inspector: the same gesture as a table edit, through core's
// editProperty with the inspector's key (wordLength, dataScope, …), its rows repainted from the
// tree; a refusal is returned for the inspector to say, the variable repainted as it was.
const applyInspectorEdit: InspectorEdit = (nodeId, propName, newValue, oldValue) => {
const node = findNode(nodeId);
if (!node) return { refused: 'Could not locate the item in the model.' };
// What the user knows the property as: the inspector's label for it ('Word Length'), not its key.
const label = inspectorLabelOf(node, propName);
let result: unknown;
const observed = gesture('Edit ' + label, (mat) => {
DataModel.setActive(mat, node);
result = DataModel.editProperty(node.id, propName, newValue, oldValue);
});
if (!observed) return { refused: `${name} cannot be edited.` };
const refused = result as { error?: boolean; reason?: string } | boolean;
if (refused && typeof refused === 'object' && refused.error) {
for (let v = node; v; v = v.parent) {
if (v.parent === document.model()) {
repaintVariables([v]);
break;
}
}
return { refused: document.describe(refused.reason) };
}
if (refused === false) return { refused: `Could not edit ${label} of this item.` };
if (propName === 'Name') webview.postMessage({ type: 'selectRows', rowIds: [node.id] });
return { nodeId: node.id };
};

// Delete the rows the gesture named, as ONE edit: the variables in one batch (one step), and
// each row under a variable on its own (one step each). Nodes are resolved first and each is
// deleted by the id it has at that moment: an element's id is its index, which the removal of
Expand Down Expand Up @@ -742,17 +817,24 @@ export class MatEditorProvider implements vscode.CustomEditorProvider<MatDocumen
if (landing && !doomed.has(landing)) webview.postMessage({ type: 'selectRows', rowIds: [landing.id] });
};

// Add one variable: core names it `var` (then `var1`, …) and makes it the double 0, the only
// value a MAT-file's Add takes. Then its name editor, as the gallery's add opens it.
const applyAddEntry = (msg: { rename: boolean }) => {
// Add one variable of the class the gallery's tile names: core's addEntry, which names it (the
// class's own default name, `var` for a plain variable, with a counter where it is taken) and
// makes MATLAB's default value of it — the double 0, a struct of no field, or MATLAB's own
// default object of a Simulink class or a `string`. Then its name editor, unless the gallery is
// pinned. An add core refuses (a class the file takes no new variable of, a file no save of
// which can be written, objects no new one can join) adds nothing, and says core's reason.
const applyAddEntry = (msg: { className?: string; rename: boolean }) => {
const className = msg.className || 'MatlabVariable';
let added: any = null;
let refusal: string | null = null;
const observed = gesture('Add', (mat) => {
DataModel.setActiveContext(mat);
added = DataModel.addEntry('', 'MatlabVariable');
added = DataModel.addEntry('', className);
if (!added) refusal = (DataModel as any).addEntryRefusal?.() ?? null;
});
if (!observed) return;
if (!added) {
webview.postMessage({ type: 'error', message: `Could not add a variable to ${name}.` });
webview.postMessage({ type: 'error', message: refusal ? document.describe(refusal) : `Could not add a ${tileLabelOf(className)} to ${name}.` });
return;
}
webview.postMessage({ type: 'selectRows', rowIds: [added.id] });
Expand Down Expand Up @@ -819,15 +901,30 @@ export class MatEditorProvider implements vscode.CustomEditorProvider<MatDocumen

webview.html = renderTableWebview(webview, distRoot);

this.inspectorEdits.set(uriString, applyInspectorEdit);

webviewPanel.onDidDispose(() => {
document.views.delete(view);
if (this.inspectorEdits.get(uriString) === applyInspectorEdit) this.inspectorEdits.delete(uriString);
unregisterAddGalleryView(webviewPanel);
sub.dispose();
navSub.dispose();
usageSub.dispose();
});
}

/**
* An edit made in the Property Inspector of node `nodeId` of the document at `uriString`: one
* gesture through core's session, as a table edit is (editProperty with the PI's key, one undo
* step, the rows repainted), whose refusal comes back as the reason, in core's words, rather
* than as a cell's validation error. Returns the node's id after the edit (a rename moves it),
* so the inspector shows it again, derived properties and all.
*/
editFromInspector(uriString: string, nodeId: string, propName: string, newValue: string, oldValue: string): InspectorResult {
const edit = this.inspectorEdits.get(uriString);
return edit ? edit(nodeId, propName, newValue, oldValue) : { refused: 'The file is no longer open for editing.' };
}

// --- Save / backup / revert --------------------------------------------------------------

async saveCustomDocument(document: MatDocument, _token: vscode.CancellationToken): Promise<void> {
Expand Down
Loading
Loading