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.
Run it
Section titled “Run it”Try the live web build at plugin-kit.saad-ardati.dev/code-editor, or run it locally:
cd example/code_editorflutter pub getflutter runThe 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 architecture in one sentence
Section titled “The architecture in one sentence”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.
The shell
Section titled “The shell”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.
How a plugin contributes UI
Section titled “How a plugin contributes UI”Take the terminal plugin (lib/app/plugins/terminal_plugin.dart):
- Append a descriptor when the shell asks. The plugin subscribes to
CollectPanelsin itsattachand pushes aPanelDescriptor(id: 'terminal', title: 'Terminal', position: PanelPosition.bottom). - Register a factory in
register, namespaced underpanel:registry.registerSingleton<PanelWidgetFactory>(ServiceSlots.panel('terminal'), // ServiceId('panel.terminal')TerminalPanelFactory(),); - 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.
Shell plugins
Section titled “Shell plugins”The five shell plugins all follow the descriptor-plus-factory pattern. They live in lib/app/plugins/:
| Plugin | Contributes |
|---|---|
ai_assist_plugin | A right-side AI panel with a fake chat surface. |
git_plugin | A left-side panel showing branch state and changed files. |
minimap_plugin | A right-edge mini-map of the document. |
runner_plugin | A toolbar action that runs the active document. |
terminal_plugin | A 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.
Domain plugins (the formatter pipeline)
Section titled “Domain plugins (the formatter pipeline)”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:
- The shell’s
updateSessionSettingsruns. - The runtime detects that
sql_languageis now disabled. sql_language.detachruns, cancelling its formatter hooks.- 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.
Settings dialog wired in
Section titled “Settings dialog wired in”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.
What to take from it
Section titled “What to take from it”Three patterns that scale to non-editor apps:
- 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.
- 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.
- 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.