Skip to content

The code_editor architecture tour

example/code_editor/ is a Flutter capstone: a modular code editor where plugins contribute extension surfaces like side panels and toolbar actions. The shell still owns core chrome such as the chip bar, toolbar, tabs, and status bar. It emits collection events, reads the descriptors plugins append, resolves widget factories from the registry, and renders.

This is the longest-running example in the repo and the most architecturally complete. If the villain_lair examples teach the runtime concept-by-concept, code_editor teaches what those concepts look like assembled into a working app.

Try the live web build at plugin-kit.saad-ardati.dev/code-editor, or run it locally:

Terminal window
cd example/code_editor
flutter pub get
flutter run

The app opens with a SQL document, a right panel for git, a bottom panel for the terminal and runner, and an AI assist panel on the right. Every one of those is a separate plugin.

The shell is a StatefulWidget that holds a PluginRuntime, listens for session-bus events, and renders the descriptors plugins append to mutable collection events. Plugins contribute what should appear (descriptors) and how to render it (widget factories), separately. The shell stitches both together at build time.

shell plugins
───── ───────
emits CollectPanels ─► append PanelDescriptor(id, title, position)
─► ...
resolves PanelWidgetFactory in 'panel' namespace
◄─ registers PanelWidgetFactory implementation
◄─ ...
renders panel chrome around factory.build(context)

The two halves are deliberately split. Descriptors are pure Dart (just data). Widget factories are Flutter (PanelWidgetFactory.build returns a Widget). A plugin that wants to live in a non-Flutter package can declare descriptors there and register the factory from a Flutter-side adapter.

Two files do most of the architectural work.

lib/app/contributions.dart declares the pure Dart side: namespaces (ServiceSlots.panel), descriptors (PanelDescriptor, ToolbarActionDescriptor, etc.), and the collection events the shell emits (CollectPanels, CollectToolbarActions, etc.). No Flutter imports.

lib/app/factories.dart declares the Flutter side: abstract widget factories (PanelWidgetFactory, ToolbarActionWidgetFactory). Plugins implement these and register them in the registry under the matching namespace.

lib/app/editor_app.dart is the shell StatefulWidget. It boots the runtime, opens a session, subscribes to session-bus events for refresh, and on every setState re-emits the collection events to discover what plugins want to contribute now.

Take the terminal plugin (lib/app/plugins/terminal_plugin.dart):

  1. Append a descriptor when the shell asks. The plugin subscribes to CollectPanels in its attach and pushes a PanelDescriptor(id: 'terminal', title: 'Terminal', position: PanelPosition.bottom).
  2. Register a factory in register, namespaced under panel:
    registry.registerSingleton<PanelWidgetFactory>(
    ServiceSlots.panel('terminal'), // ServiceId('panel.terminal')
    () => TerminalPanelFactory(),
    );
  3. The factory implements PanelWidgetFactory.build(BuildContext) and returns the actual terminal widget.

When the shell renders, it walks the descriptors, looks up PanelWidgetFactory for each by (panel, descriptor.id), and lets the factory build the body. The shell paints the chrome (collapse/expand, title bar) around it. The plugin owns everything else inside.

Disable the terminal plugin via RuntimeSettings, the shell re-emits CollectPanels on the next reconciliation, the terminal descriptor is gone, and the panel disappears. The shell never knew which plugin was contributing.

The five shell plugins all follow the descriptor-plus-factory pattern. They live in lib/app/plugins/:

PluginContributes
ai_assist_pluginA right-side AI panel with a fake chat surface.
git_pluginA right-side panel showing branch state and changed files.
minimap_pluginA right-edge mini-map of the document.
runner_pluginA toolbar action that runs the active document.
terminal_pluginA bottom panel with a terminal interface.

Each one is a small, self-contained example of the contribution pattern. Read any single file to see how a plugin participates in the shell without the shell knowing it exists.

The shell plugins handle UI. The domain plugins in lib/plugins/ do real work: language support, debugging, formatting, linting.

The most architecturally interesting one is formatter_pipeline. It is split into a generic base and two language-specific subclasses, demonstrating priority cascades and plugin dependencies in one feature.

Base pipeline (formatter_pipeline.dart) registers low-level hooks on FormatDocumentEvent at priority 0 (whitespace trimming, indent normalization). It is the foundation everything else builds on.

Language-specific plugins (PluginId('dart_formatter'), PluginId('sql_formatter')) declare PluginId('formatter_pipeline') as a dependency. They register additional FormatDocumentEvent hooks at priorities 3 and 5, while the base pipeline already runs priorities 0 and 10. The runtime guarantees that if PluginId('formatter_pipeline') is disabled, both formatter plugins disable too, transitively.

PluginId('sql_formatter') and PluginId('formatter_pipeline') are behavior plugins in _behaviorPlugins(). In this demo they are always on, and the chip bar only toggles the shell plugins in _pluginLabels (PluginId('runner'), PluginId('git'), PluginId('terminal'), PluginId('ai_assist'), PluginId('minimap')).

To observe the formatter dependency cascade, disable PluginId('formatter_pipeline') in session settings. The runtime then disables PluginId('dart_formatter') and PluginId('sql_formatter') transitively.

The shell uses a built-in chip bar as its customization UI. Each toggle rebuilds RuntimeSettings from the chip state and calls _runtime.updateSessionSettings(...) immediately. The runtime reconciles, the shell re-emits collection events, and the UI converges on the new state without a restart.

The example uses Plugin Kit on itself: every primitive the docs describe is visible in this one app, working end to end.

Three patterns that scale to non-editor apps:

  1. Descriptors plus factories. Splitting “what to render” from “how to render” lets your plugin layer stay portable. The descriptors are pure Dart; the factories are framework-specific.
  2. Collection events for contributions. A mutable collection event the shell emits is the simplest way to let plugins contribute zero-or-more items without registering each individually.
  3. Priority cascades for layered features. A base plugin registers low-priority hooks; language- or feature-specific plugins layer higher-priority hooks on top through the registry. Dependencies make the cascade safe to disable.