A folder's hierarchy as a tree: each project and folder, the files in it, and under each file the files it references (a model's referenced models and its External Data, a dictionary's referenced dictionaries), opened one level at a time. A reference no file in the folder answers reads as a warning; a file that closes a circular reference, or has unsaved changes, carries a badge. It draws the rows the VS Code extension's sidebar draws for the same folder.
The rows come from a FolderHierarchy (data-explorer-view/model), which reads the folder
through core's source cache: the app lists the folder's files and reads them, and
connectHierarchy wires the two together. A click or Enter on a row that opens a file is the
app's to act on:
import { FolderHierarchy, sourceUri } from 'data-explorer-view/model';
import { connectHierarchy, defineHierarchy } from 'data-explorer-view/hierarchy';
defineHierarchy();
const tree = document.querySelector('dex-hierarchy');
const hierarchy = new FolderHierarchy({
// The folder's supported files, in a stable order.
files: { list: async () => paths.sort().map((path) => ({ uriString: sourceUri(path), path })) },
// A reader for those files: a version for each (a change of it is a change of the file), its
// bytes, and a project's store. In Electron or Node, core's `nodeSourceReader(files)` and
// `readProjectStoreFromDisk(path)` (`data-explorer-core/node`).
reader: (files) => ({ ...myReader(files), projectStore: async (file) => myProjectStore(file.path) }),
});
const handle = connectHierarchy(tree, hierarchy, {
empty: document.querySelector('#no-files'),
expandTopLevel: true, // the folder's own row open on first show; default closed
});
handle.on('open', (uri) => openTab(uri));
// When a file in the folder is created, deleted or saved:
hierarchy.rebuild();handle.reveal(uri) shows a file in the tree, as an editor's explorer follows the active
editor: it opens the rows above the file's row, selects it and scrolls it into view, and sends
nothing ({ choose: true } sends dex-hierarchy-selected, and so open, as a click does). A file
can be a row in several places, under each file that references it, so the rule is fixed: the
selected row stays when it is already the file's (the user chose it there); otherwise the row is
the shallowest, and of several at one depth the first from the top. hierarchy.pathTo(uri)
answers that row's path, for a host that draws the rows in a tree of its own. reveal resolves
false, and leaves the tree as it was, for a file that is no row.
tabs.onActivate((tab) => handle.reveal(tab.uri));A model names the dictionaries and models it links by their file names alone. The tree resolves
such a name by its own rule (the copy in the referrer's project or folder, else the first in the
folder), and hierarchy.resolveLink(link, fromUri) answers by the same rule for a link, so a
table beside the tree opens the file the tree shows: give it to connectTable as resolveLink,
and a link's file arrives on navigate as uri.
const table = connectTable(tableElement, doc.table, {
resolveLink: (link, fromUri) => hierarchy.resolveLink(link, fromUri),
});
table.on('navigate', ({ link, uri }) => openTab(uri ?? fileOf(link))); // uri: the file the tree showsresolveReference(name, referrerUri) is the same rule for a bare name.
A project's row heads its group, and opens the project's marker: its id is the marker's uri
(a .prj, or a matlab.toml). A folder session's projectIn(folderUri) answers the row of the
project whose marker is in a folder, for a link that names a project by its folder (see
folder-session.md).
A page that cannot read files (a sandboxed Electron window, an embedded browser view) has its
folder read where the files are, across one message channel, in the wire format beside the
table's (data-explorer-view/model's FolderRequestMessage, FolderAnswerMessage,
FolderChangedMessage). serveFolder(folder, port) answers from the same files and reader
a hierarchy beside its files is given; folderReader(port) is both of them on the page's side.
The host's end is on data-explorer-view/serve, which loads nothing else, so a main process
that bundles it bundles a few kilobytes. portFromMainPort(port) (Electron's MessagePortMain,
by its shape) and portFromMessagePort(port) (a page's, a worker's) make the ports; the README
shows the whole of it in Electron, main, preload and page.
The page is not trusted, and the host reads only the files it listed. A question names its file
by uri, which is matched exactly against the host's own last listing, and the file read is the
listing's own, never a path the page sends. Any other uri is refused and nothing is read for it:
one outside the folder, one with .. in it, a prefix that looks like the folder's, another
scheme, another spelling of a listed file (nothing is decoded, resolved or normalised), or one
asked before the folder is listed. A project store is answered only for a listed file (a folder
session's listed() resolves once the folder has been listed). The page's
reader answers a refused question null, as core's readers answer for a file they were not given.
// Where the files are: Electron's main process, over a MessageChannelMain port given to the page.
import { listedFiles, portFromMainPort, serveFolder, sourceUri } from 'data-explorer-view/serve';
const files = { list: async () => listedFiles(await listFolder(root)) }; // core's node/folder listing
const served = serveFolder({ files, reader }, portFromMainPort(port1));
watchFolder(root, (paths) => served.changed(paths?.map(sourceUri))); // core's node/folder; no paths: anything
// The page.
import { FolderHierarchy, folderReader, portFromMessagePort } from 'data-explorer-view/model';
const folder = folderReader(portFromMessagePort(port2));
const hierarchy = new FolderHierarchy({ files: folder, reader: () => folder });
folder.onChange((uris) => hierarchy.rebuild()); // uris: what the host saw change, when it knows
const bytes = await folder.bytes(file); // the page reads the files it opens through it tooThe uris changed sends are a hint: they do not say whether a file was created, deleted or
saved, which the page learns by listing the folder again, and each call is one message. Core's
watchFolder (data-explorer-core/node/folder, 1.50 or later) reports a burst of a watcher's
events once, by the listing's rules, and says watching: false, with its error, for a folder
it cannot follow. A listing that stopped at its limit
(listedFiles keeps core's truncated) reaches the page as one, and the tree ends with a row
that says only the first files are shown.
Over a worker's port, or a MessagePort, serveFolder(folder, port, { transfer: true }) hands
each file's bytes over rather than copying them.
| Tag | Class | Define | Entry |
|---|---|---|---|
<dex-hierarchy> |
DexHierarchy |
defineHierarchy(tag = 'dex-hierarchy') |
data-explorer-view/hierarchy |
type HierarchyItem[] · default [] · property only
The top-level rows. Replacing them asks loadChildren again for every open row. Default none.
type ((item: HierarchyItem) => Promise<HierarchyItem[]>) | null · default null · property only
What answers the rows under a row, as it is first opened: a FolderHierarchy's children.
Null, the default, opens every row onto nothing.
type string[] · default [] · property only
The keys of the open rows. The tree changes it as the user opens and closes rows; a row whose key is here opens as soon as it is shown. Default none.
type string | null · default null · property only
The key of the selected row, or null. The tree changes it as the user chooses; setting it sends nothing.
type string · default 'Files' · attribute label
What a screen reader calls the tree. Default Files. Attribute label.
type boolean · default false · attribute expand-top-level
Open each top-level row (a folder's own row, each project's) the first time the tree shows it,
by adding its key to expandedKeys, as a table opens its sections; after that the row stays
as the user leaves it, through new items too. Another folder's rows are new rows, and open.
Off, the default, a folder opens closed. Attribute expand-top-level.
Show a row: open each row of path but the last (the rows from a top-level row down to it,
as FolderHierarchy's pathTo gives them), select the last, and scroll it into view as soon as
it is drawn, once the rows above it are read. The keyboard goes on from it. It takes no focus,
and a choice of the user's before the row is drawn leaves the scroll undone.
Sends nothing, unless options.choose: then dex-hierarchy-selected for the row, as a click
on it sends, without opening or closing it. connectHierarchy's reveal(uri) finds the path to
a file's row.
Each is a CustomEvent that bubbles and is composed (it leaves the shadow root), declared on
HTMLElementEventMap, DocumentEventMap and WindowEventMap, so a listener's e.detail is
typed with no cast, on the element, the document or the window.
A row was chosen, by a click or Enter, and is now the selected one: the file it opens, or null for a row that is no file. Sent again each time a row is chosen.
detail is a HierarchySelectedDetail:
/** What `dex-hierarchy-selected` carries: the row chosen. */
export interface HierarchySelectedDetail {
/** The file the row opens (its `id`), or null for a row that is no file: a folder, External Data, a reference no file answers. */
uri: string | null;
/** The row's key, as `selectedKey` now holds it. */
key: string;
/** The row. */
item: HierarchyItem;
}
/** One row of a folder's hierarchy, as a tree draws it. */
export interface HierarchyItem {
/** What the row reads: a file's name, a project's or a folder's, `External Data`, or the name a reference names. */
label: string;
/**
* A file's kind, `group` for a project, a folder or a model's External Data, `missing` for a
* reference no file answers, or `notice` for the row that says something of the folder rather
* than being in it (that only its first files are shown).
*/
kind: NodeKind | 'notice';
/** What a group is (`kind` is `group`). */
groupKind?: GroupKind;
/** The uri of the file the row opens: a file's own, a project's marker. None on a row that is no file. */
id?: string;
/**
* A file row's mark: `cycle` when it closes a circular reference, else `modified` when it has
* unsaved changes, else null. Not on a row that is no file.
*/
health?: HealthState | null;
/** `collapsed` when the row has rows under it (`children()` answers them), `none` when it has none. */
collapsible: 'none' | 'collapsed';
/** What follows the label: why the row is unlike the others (`unresolved reference`, `↻ circular reference`). */
description?: string;
/** More than the label says, for a tooltip. */
tooltip?: string;
/** Its icon's id in the package's icons/. None on an unresolved reference, which draws a warning. */
icon?: string;
/**
* The row among its siblings, after its parent's key: the same row has the same key after a
* rebuild, so a tree keeps what was open.
*/
key: string;
}
export type NodeKind = SourceType | 'missing' | 'group';
export type SourceType = 'model' | 'sldd' | 'mat' | 'project';
export type GroupKind = 'external-data' | 'project' | 'folder';
export type HealthState = 'cycle' | 'modified';defineHierarchy() defines these first, under their default names, then <dex-hierarchy>:
It has no ::part() names and no slots.
Each falls back to the light default when the page sets none (see data-explorer-view/theme.css).
| Token | Light default | |
|---|---|---|
--dex-bg-hover |
#e8e8e8 |
|
--dex-color-accent-bg |
#cde4f7 |
|
--dex-color-modified |
#b45309 |
The Status column's "Modified". |
--dex-color-text |
#333 |
|
--dex-color-text-muted |
#666 |
|
--dex-color-text-secondary |
#555 |
|
--dex-color-warning |
#d83b01 |
|
--dex-focus-ring |
0 0 0 2px rgba(0, 120, 212, 0.4) |
|
--dex-font-family |
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif |
|
--dex-font-size |
13px |
|
--dex-row-height |
28px |
|
--dex-selection-fg |
#333 |
The ink on a selected row, whose surface is --dex-color-accent-bg. |