Part 14 — Views micro-DSL: tree views, locations, view containers
The Views micro-DSL declares workspace-level UI panels — the kind of tree that lives in the explorer sidebar or in a custom activity-bar view container. A Requirements DSL gets a "Requirements" tree showing every Epic with its Features and Stories underneath. A Workflow DSL gets a "Workflows" tree showing every workflow with its stages. The micro-DSL produces the contributes.views and contributes.viewsContainers entries plus the registered TreeDataProvider implementations; refreshes are driven by the kernel EditLog so the tree updates whenever a relevant PatchBus operation happens.
Views and the Custom Editor host (article 21) are the two UI-surface micro-DSLs. The boundary, named in Boundary justification below: Views shows the workspace; the Custom Editor host shows the document. Both render trees; both react to EditLog events; their scopes do not overlap.
Concern
The author wants a "Requirements" tree in the activity bar. The tree's roots are Epics. Each Epic shows Features as children; each Feature shows Acceptance Criteria; each AC shows whether it is satisfied. Inline actions on a Feature: "Add AC", "Run satisfying tests". The tree should refresh when the user edits any .req.ts file — adding a Feature should add a tree node without manual refresh.
Hand-writing the tree requires implementing TreeDataProvider, walking the workspace, building a typed tree of items, exposing the inline actions, wiring onDidChangeTreeData to a workspace-watcher event source. The Views micro-DSL collapses these into @View plus @TreeProvider decorators with kernel-aware defaults.
The Surface
import { View, TreeProvider, TreeItem, InlineAction } from '@frenchexdev/ide-dsl-views';
import { Concept, ChildLink } from '@frenchexdev/ide-dsl-kernel';
@View({
id: 'requirements.tree',
name: 'Requirements',
location: { container: 'requirements', icon: 'lucide:layers-3' },
})
export class RequirementsTree {
@TreeProvider({ rootConcept: 'cmf.req.Epic' })
static rootProvider(ctx) {
return ctx.workspace.allConceptsOfType('cmf.req.Epic');
}
@TreeProvider({ childrenOf: 'cmf.req.Epic', via: 'features' })
static featuresOfEpic(epic) {
return epic.features;
}
@TreeProvider({ childrenOf: 'cmf.req.Feature', via: 'acceptance' })
static acsOfFeature(feature) {
return feature.acceptance;
}
@TreeItem({ for: 'cmf.req.Feature' })
static itemForFeature(feature) {
return {
label: `${feature.id} — ${feature.title}`,
icon: 'lucide:flag',
tooltip: feature.title,
contextValue: 'requirements.feature',
};
}
@InlineAction({ for: 'cmf.req.Feature', command: 'requirements.runSatisfyingTests', icon: 'lucide:play' })
static runTestsAction = Symbol();
}import { View, TreeProvider, TreeItem, InlineAction } from '@frenchexdev/ide-dsl-views';
import { Concept, ChildLink } from '@frenchexdev/ide-dsl-kernel';
@View({
id: 'requirements.tree',
name: 'Requirements',
location: { container: 'requirements', icon: 'lucide:layers-3' },
})
export class RequirementsTree {
@TreeProvider({ rootConcept: 'cmf.req.Epic' })
static rootProvider(ctx) {
return ctx.workspace.allConceptsOfType('cmf.req.Epic');
}
@TreeProvider({ childrenOf: 'cmf.req.Epic', via: 'features' })
static featuresOfEpic(epic) {
return epic.features;
}
@TreeProvider({ childrenOf: 'cmf.req.Feature', via: 'acceptance' })
static acsOfFeature(feature) {
return feature.acceptance;
}
@TreeItem({ for: 'cmf.req.Feature' })
static itemForFeature(feature) {
return {
label: `${feature.id} — ${feature.title}`,
icon: 'lucide:flag',
tooltip: feature.title,
contextValue: 'requirements.feature',
};
}
@InlineAction({ for: 'cmf.req.Feature', command: 'requirements.runSatisfyingTests', icon: 'lucide:play' })
static runTestsAction = Symbol();
}@View declares the panel's id, name, and location (which container, which icon). @TreeProvider declares hierarchy: a root provider returns the top-level items; per-Concept child providers walk @ChildLink declarations. @TreeItem customises the rendering of each item (label, icon, tooltip). @InlineAction adds an action button to a tree item, referencing a command id from the Commands micro-DSL.
The micro-DSL is heavily kernel-aware: every @TreeProvider({ childrenOf: 'X', via: 'linkName' }) consults the Structure Model to validate that X has a @ChildLink named linkName and uses the kernel's typed AST navigation to walk it. Authors who declare a non-existent link receive a build-time error.
Kernel boundary
Reads:
- The Structure Model — for
@ChildLinkwalks and validation. - The workspace AST — for tree population.
- The
LanguageRegistry— for filtering items by language. - The
CommandRegistry— for inline action references.
Writes:
- None directly. Inline actions invoke commands; the commands mutate through PatchBus.
The kernel exposes a walkChildLinks helper used by both Views and Symbols (article 16); both micro-DSLs walk the same shape but for different purposes (Views for the visible tree, Symbols for the searchable index).
The EditLog subscription is straightforward: every insertChild, removeChild, or setProperty on a relevant property triggers onDidChangeTreeData for the affected subtree. The Views micro-DSL aggregates EditLog deltas in 100ms windows to avoid thrashing the tree on large refactors.
Emitted artefacts
// package.json contributions (merged)
{
"contributes": {
"viewsContainers": {
"activitybar": [
{
"id": "requirements",
"title": "Requirements",
"icon": "$(layers)"
}
]
},
"views": {
"requirements": [
{
"id": "requirements.tree",
"name": "Requirements",
"icon": "$(layers)"
}
]
},
"menus": {
"view/item/context": [
{
"command": "requirements.runSatisfyingTests",
"when": "view == requirements.tree && viewItem == requirements.feature",
"group": "inline"
}
]
}
}
}// package.json contributions (merged)
{
"contributes": {
"viewsContainers": {
"activitybar": [
{
"id": "requirements",
"title": "Requirements",
"icon": "$(layers)"
}
]
},
"views": {
"requirements": [
{
"id": "requirements.tree",
"name": "Requirements",
"icon": "$(layers)"
}
]
},
"menus": {
"view/item/context": [
{
"command": "requirements.runSatisfyingTests",
"when": "view == requirements.tree && viewItem == requirements.feature",
"group": "inline"
}
]
}
}
}// extension.ts (excerpt, generated)
import * as vscode from 'vscode';
import { viewProviderRegistry, kernel } from './_registry.generated';
export function activate(ctx: vscode.ExtensionContext) {
for (const [id, providerFactory] of viewProviderRegistry.entries()) {
const provider = providerFactory(kernel);
ctx.subscriptions.push(vscode.window.registerTreeDataProvider(id, provider));
kernel.onEditLogAppend(() => provider.refresh());
}
}// extension.ts (excerpt, generated)
import * as vscode from 'vscode';
import { viewProviderRegistry, kernel } from './_registry.generated';
export function activate(ctx: vscode.ExtensionContext) {
for (const [id, providerFactory] of viewProviderRegistry.entries()) {
const provider = providerFactory(kernel);
ctx.subscriptions.push(vscode.window.registerTreeDataProvider(id, provider));
kernel.onEditLogAppend(() => provider.refresh());
}
}Composition with peers
- Commands (article 13) — every inline action references a command id.
- Symbols (article 16) — both walk Concept hierarchy; Symbols is for searchable navigation, Views is for browsable navigation.
- Custom Editor host (article 21) — orthogonal scope (workspace vs. document).
- Extension host — registers the
TreeDataProviders.
MPS aspect referent
MPS' Logical View and the Project Tree in the MPS workbench. MPS exposes a project-wide tree of language modules, models, roots, and per-Concept structure. We translate the same idea to a VSCode activity-bar tree: the tree is browsable, hierarchical, refreshes on workspace changes. We do not adopt MPS' multi-perspective tree (Logical / Physical / Inheritance) — VSCode does not have the chrome for it — and instead allow multiple @View declarations to coexist, each with its own perspective.
Boundary justification
Why not in the Custom Editor host? Scope. Views aggregates state from the entire workspace; the Custom Editor host renders one document at a time. A user opens a Custom Editor on feature-checkout.req.ts and sees that file's projections; they consult the Views tree to see every requirements file's structure. Same data, different scopes, different micro-DSLs.
Why not in Symbols? Both walk hierarchy. The distinction: Symbols answers structural-navigation queries against the LSP (workspace/symbol, textDocument/documentSymbol) and is consumed primarily through the editor's "Go to symbol" UI; Views provides a persistent panel the user can browse. Symbols is for the keyboard-driven user; Views is for the browse-driven user.
Requirements
FEAT-MICRODSL-14 in assets/features.ts:
- treeProviderDeclarativeSurface — the Surface section shows
@TreeProviderwithrootConcept,childrenOf,via. - viewLocationContributionExplained — the Surface and Emitted artefacts sections show the
viewsContainersandviewsblocks. - refreshSemanticsFromKernelEditLogStated — the Kernel boundary section names the EditLog subscription and the 100ms aggregation window.
- boundaryAgainstCustomEditorJustified — the Boundary justification section names the workspace-vs-document scope argument.
Article 15 picks up with Formatter, the deterministic-pretty-print micro-DSL.