| title | Extension points |
|---|---|
| parent | Platform |
| nav_order | 6 |
Separate typed registries rather than one generic plugin bag: every category has its own contract, so a bad contribution fails where it is written instead of at some later lookup.
app.components.register({ component, renderer, category, role, opens });
app.commands.register({ id, title, when, args, slots, run });
app.keybindings.register({ keys, commandId, when, scopeId });
app.themes.register({ id, name, appearance, extends, colors, glyphs });
app.layouts.register({ name, component, surfaces });
app.shells.register({ id, title, component, surfaces, theme, minSize });
app.screens.register({ id, component, keepAlive });
app.resources.registerKind / registerProvider / registerViewer / registerEditor / registerAction;
app.store.registerDataProvider / registerSchema / registerPersistence;
app.services.provide(key, value);Every one returns a Disposable. Registration is reversible, which is what makes a manifest unloadable.
One source contributing to several registries at once, unwound by a single disposable:
await app.manifest.load({
source: { id: 'acme.services', version: '1.2.0', displayName: 'Services' },
requires: ['acme.core'],
requiresCapabilities: ['mouse'],
contributes: {
components: [ServiceTableDefinition],
commands: [restartCommand, scaleCommand],
keybindings: [{ keys: 'r', commandId: 'service.restart' }],
themes: [midnight],
resourceKinds: [serviceKind],
resourceViewers: [serviceViewer],
dataProviders: [servicesProvider],
computed: [{ path: '$/summary/services/down', def: { from: ['$/services/list'], select: 'count' } }],
views: [{ surface: 'sidebar', key: 'services', target: { component: 'ServiceList' } }],
},
});
app.manifest.unload('acme.services'); // everything above, undoneViews mount last, because they may name a component the same manifest just added.
app.manifest.sources() lists what is loaded, and loaded(id) answers for one. That is what lets an application show its own extensions without knowing any of them by name.
A manifest is a value, so something has to go and get it. In textide that is loadExtensions, which reads the specifiers out of .textide.json and imports each one. A module may export a manifest, an activate, or both:
// The declarative half. No code runs; the registries take the definitions.
export const manifest: Manifest = {
source: { id: 'acme.services', displayName: 'Services', description: 'What is running.' },
contributes: {
components: [ServiceListDefinition],
views: [{ surface: 'sidebar', key: 'services', target: { component: 'ServiceList' } }],
},
};
// The other half, for what a declaration cannot express - a subscription, a
// process, anything that needs the app in hand. Returns its own undo.
export function activate(app: TextUIApp, context: ExtensionContext): Disposable {
return app.store.subscribe('$/services/list', () => { /* … */ });
}New Extension in the palette writes a working one into tools/, loads it, and opens the source. It contributes a sidebar panel that counts the lines, words and characters in the open file - small enough to read in one sitting, and the fastest way to learn a plugin system is to delete things from a plugin that works.
What it writes has no imports in it, which is worth noticing rather than being a simplification. Its panel is a template renderer - a tree of data whose props are { path: '$/somewhere' }, read out of the store by the runtime and redrawn when they change:
renderer: {
kind: 'template',
template: {
component: 'box',
direction: 'column',
children: [
{ component: 'text', content: { path: '$/word-count/name' }, bold: true },
{ component: 'text', content: { path: '$/word-count/lines' }, fg: 'muted' },
],
},
},JSX compiles to exactly this data, so an extension needs nothing installed beside it and no build step to be loadable. It also rules out hooks, which are imports - which is why in the scaffold the counting lives in activate and the panel is bindings.
Add Extension takes a package name; Add Extension from File opens a picker. Either way the specifier is written into .textide.json, relative to the workspace, so it comes back next time and travels with the project.
The manifest loads first, because activate may want what it brought. Each extension gets its own bag, so one can be disposed without touching the others, and the loader keeps what only a loader knows: which specifier produced which source, which one failed and why, and which one has been turned off. app.manifest.sources() lists what loaded - it cannot list what did not.
A typed lookup table, not a dependency-injection container. No lifecycles, no scoping rules, no auto-wiring - a child falls back to its parent and that is all.
import { serviceKey } from '@textui/core';
const ApiKey = serviceKey<ApiClient>('acme.api');
app.services.provide(ApiKey, new ApiClient(url));
// in a component
const api = useRequiredService(ApiKey); // throws with the key id when missingAn organisation can publish its own component registry - a directory with a registry.json and the source files it names - and point projects at it:
textui registry add internal ../design-system/registry
textui add --registry internal ops-headerA registry manifest carries what the CLI needs to be useful later: version, files, dependencies, required and optional capabilities, variants, store bindings, related components and template membership.
canvas is the escape hatch, and it is enough for most things that do not fit the box model:
<canvas
intrinsic={{ height: 6 }}
draw={(surface, ctx) => {
for (let x = 0; x < surface.rect.width; x++) {
surface.put(x, 0, ctx.glyph('bulletFilled'), { fg: ctx.color('accent') });
}
}}
/>The surface is clipped to the node's content box and drawn in its own coordinate space, so a chart never needs to know where on screen it landed.
app.inspect(); // the component tree: rects, props, roles, bindings, render reasons
app.stats(); // renders, runs in the last frame, mounted instancesinspect() is what the test harness queries and what a development inspector would render. With diagnostics: true each instance also carries why it last rendered - mount, props, store $/path - which is usually the fastest way to find a component re-rendering for no reason.