Skip to content

The code_editor architecture tour

example/code_editor/ is a Flutter capstone: a modular code editor where every UI element beyond the text area and tab bar is contributed by plugins. The shell knows zero specific plugins. 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 left 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 left-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 (dart_language, sql_language) declare formatter_pipeline as a dependencies entry. They register additional hooks at higher priorities (10, 20, 50) for their language’s idiomatic formatting. The runtime guarantees that if formatter_pipeline is disabled, both language plugins disable too, transitively.

Toggle the SQL plugin off in the dialog and:

  1. The shell’s updateSessionSettings runs.
  2. The runtime detects that sql_language is now disabled.
  3. sql_language.detach runs, cancelling its formatter hooks.
  4. The shell re-renders. The SQL panel still works (text is still there), but invoking format leaves the SQL-specific transforms unapplied.

Toggle formatter_pipeline itself off and dependency cascade kicks in: both dart_language and sql_language get disabled too. The shell’s chip list reflects all three becoming inactive.

The shell mounts Plugin Kit Dialog as the customization UI. The user opens it, toggles a plugin, and saves. The shell’s onSave callback calls runtime.updateSettings(...) against the dialog’s returned RuntimeSettings. The runtime reconciles. The shell re-emits collection events. 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.