CodeAssist is built as a plugin platform, and the IDE's own features are the first consumers of that platform. Java support, Kotlin support, the block editor, the AI agent and Git are not privileged host code: each is a plugin that registers through the same SPI available to you. This guide walks through that SPI end to end and builds up to two complete, shipping examples you can read in the repository.
Everything described here is the internal (one-classpath) tier: a plugin is a Gradle module compiled into
the app, declared in code. The same Plugin can instead ship as a separate app the user installs, which is
packaging rather than a different SPI; see Ship your plugin as its own app.
Contents
- Before you begin
- How the plugin model works
- The SPI, type by type
- Build your first plugin
- Contribute to extension points
- Contribute scoped services
- Listen to IDE events and log
- Add a settings page
- Add actions
- Contribute UI
- Case study: the Git plugin
- Case study: the AI Agent plugin
- Enable, disable, and dependencies
- Test your plugin
- Ship your plugin as its own app
- Appendix A: extension point index
- Appendix B: class index
- Appendix C: service index
By the end of this guide you will be able to write a plugin that:
- contributes behaviour to the engine (a language backend, an analyzer, an index, a build task, a project template, a version-control provider, …) through extension points;
- owns lazily built, scope-bound objects through scoped services;
- reacts to IDE lifecycle events and writes attributable logs through the message bus and logger;
- adds a settings page with no UI code at all;
- adds actions that appear in the toolbar, context menus, and command palette;
- adds Compose UI: dockable tool windows, full screens, app-wide overlays, editor view modes, and file tree icons;
- can be turned off by the user, taking its whole surface with it.
| Requirement | Why |
|---|---|
| The CodeAssist repository, buildable locally | Plugins are Gradle modules in the same build |
| Kotlin, and Compose Multiplatform if you contribute UI | The SPI is Kotlin; UI contributions carry @Composable bodies |
| An Android SDK on the machine | The Compose shells (:ide-ui, :ide-core, launchers) apply AGP and need it even to configure. CI sets CI_CORE_ONLY=true to build only the framework; leave it unset locally. See settings.gradle.kts |
Read architecture.md first if you have not: it explains the project model, the two-level graph, and the concurrency rules your plugin code runs under.
| Term | Meaning |
|---|---|
| Extension point (EP) | A typed, string-id-keyed slot that many implementations can be contributed to. dev.ide.platform.ExtensionPoint<T> |
| Extension | One implementation contributed to an EP |
| Extension registry | The store of contributions, hierarchical (project registry parents the application registry). dev.ide.platform.ExtensionRegistry |
| Scoped service | A lazily built, cached object bound to an APPLICATION / WORKSPACE / MODULE scope |
| Engine plugin | A dev.ide.plugin.Plugin: data-driven contributions, no Compose |
| UI plugin | A dev.ide.ui.ext.UiPlugin: Compose-bearing contributions |
| Plugin id | The stable string that attributes every contribution a plugin makes, e.g. vcs |
| Manifest | A plugin's identity and load-order metadata: dev.ide.plugin.PluginManifest |
| Essential plugin | One the IDE cannot run without; the user cannot disable it |
Before this model existed, the IDE wired its own features in a ~270-line imperative block, and third-party extensibility was a separate, weaker mechanism alongside it. That arrangement decays: the host's path is the one that gets maintained, and the plugin path falls behind.
CodeAssist inverts it. ApplicationEnvironment
builds a PluginManager over the
application registry and loads a list of plugins, and that list is the IDE. If a capability cannot be expressed
as a plugin contribution, that is treated as a gap in the SPI, not as a reason to add host wiring.
The practical consequence for you: every built-in plugin in
BuiltInPlugins.kt is a worked example of the
API you are about to use.
A feature can have up to two facets:
┌──────────────────────────────────────┐
BuiltInPlugin ───▶│ engine: dev.ide.plugin.Plugin │──▶ ExtensionRegistry (data-driven)
(one feature) │ manifest / register(reg) │ services, EPs, settings, actions
├──────────────────────────────────────┤
│ ui: dev.ide.ui.ext.UiPlugin? │──▶ UiPluginHost (Compose-bearing)
│ contributeUi(scope) │ tool windows, screens, overlays
└──────────────────────────────────────┘
They are two objects rather than one because a @Composable body cannot live in the engine module (which
knows nothing of Compose) and cannot cross the neutral IdeBackend boundary as data. They are declared
together in one BuiltInPlugin entry, so the
user's single enable/disable decision governs both halves: the engine facet's manifest carries the identity,
and only enabled plugins' UI facets are handed to the shell.
A plugin may have only an engine facet (most do), or an engine facet plus a UI facet (Git, the AI agent). For
a built-in, a UI-only plugin is not a thing: the manifest, and therefore the identity, lives on the engine
facet. An installed plugin carries its manifest in its APK instead, so it may declare either facet or
both (see section 15) and, since both halves come off one APK, may
implement both with a single class: name it in entryPoints and in uiEntryPoints and the IDE
instantiates it once. Its UI facet implements a different, narrower interface (dev.ide.plugin.ui.UiPlugin),
for the reason given in section 10.11.
How the two halves then talk to each other (the channels, the shared-holder shape, threading, teardown, and why a built-in's channel has to be different) is its own guide: plugin-facet-communication.md.
| Object | Lifetime | Notes |
|---|---|---|
ApplicationEnvironment |
One per running app | Owns the app registry, the app message bus, the plugin manager |
PluginManager |
One per app | Loads plugins once at startup, in dependency order |
UiPluginHost |
One per process | Loads UI facets once (idempotent ensureLoaded()) |
A project's PlatformCore |
One per opened project | Its registry parents the app registry |
Two rules follow from that table:
register()runs once, before any project is open. Do not resolve project state in it. Contributions that need the open project takeApplicationEnvironmentand readenv.activeEnginelazily at callback time. Every capturing built-in does exactly this. SeeCompletionBuiltinsPlugin,AndroidXmlPlugin, andIdeCoreActionsPlugininBuiltInPlugins.kt.- Enable/disable is applied on restart. The manager loads once and does not hot-swap. The catalog reflects persisted intent, not a live toggle. The same holds for the plugin apps on the device: the IDE records what changed about them while it was running (installed, updated, uninstalled) and the Plugins screen applies the lot with one restart. See §13.1.
Everything sits on two primitives from
platform-core. platform-core has no
domain knowledge: no "project", no "Android", no "Java".
Extension points.
class ExtensionPoint<T : Any>(val id: String)
interface ExtensionRegistry {
fun <T : Any> register(ep: ExtensionPoint<T>, impl: T, plugin: PluginId): Disposable
fun <T : Any> extensions(ep: ExtensionPoint<T>): List<T>
fun unregisterAll(plugin: PluginId)
}Typed, attributed to a PluginId, individually removable via the returned Disposable, and bulk-removable
per plugin. extensions(ep) returns contributions in registration order, which is why load order is declared
rather than accidental (see PluginManager).
Scoped services. (Services.kt)
SERVICE_EP carries ServiceDescriptors into a ServiceContainer with three levels: APPLICATION,
WORKSPACE, and MODULE. A service is built lazily on first resolution, cached at its scope, and disposed with
its container. So "register a service" is a specific kind of "register an extension", not a parallel
mechanism.
Package: dev.ide.plugin in module :plugin-api.
interface Plugin {
val manifest: PluginManifest
fun register(reg: PluginRegistration)
fun dispose() {}
}| Member | Why it exists |
|---|---|
manifest |
Identity and load order. manifest.id is both the attribution key for every contribution and the node id in the dependsOn graph |
register(reg) |
The single contribution hook. Runs exactly once, after every plugin in dependsOn. Everything the plugin adds goes through reg so it can be attributed and tracked |
dispose() |
Optional. Only for resources the plugin owns beyond its registry contributions, such as a background scope or a file watcher. Registry contributions are torn down automatically, so most plugins never override this |
One thing the interface does not ask of a plugin shipped as its own app: a manifest. That property is
defaulted, because such a plugin's identity is its packaged codeassist_plugin.toml, which the IDE reads
before any of the plugin's code runs and uses in preference to whatever an entry point returns. Declaring one
anyway used to be the single line that broke an already-compiled plugin whenever PluginManifest grew a
field, since Kotlin compiles a call relying on default arguments into a synthetic constructor whose descriptor
names every parameter. A built-in still overrides it: it has no packaged manifest, and the loader refuses
a plugin whose id is blank.
data class PluginManifest(
val id: String,
val name: String,
val version: String = "1.0.0",
val apiVersion: Int = PLUGIN_API_VERSION,
val dependsOn: List<String> = emptyList(),
val description: String = "",
val essential: Boolean = false,
// installed plugins only (unused for built-ins, where the class is the entry point):
val entryPoints: List<String> = emptyList(),
val uiEntryPoints: List<String> = emptyList(),
val capabilities: List<String> = emptyList(),
val minHostVersion: String? = null,
val trusted: Boolean = true,
)| Field | Why it exists | Guidance |
|---|---|---|
id |
Attribution key, dependsOn node id, persisted in the user's disabled set |
Lowercase, hyphenated, stable forever, because renaming it silently re-enables a plugin the user disabled |
name |
Shown in Settings → Plugins | Human title case, e.g. Version Control |
version |
Displayed on the plugin's row | Semantic version |
apiVersion |
Host SPI/ABI compatibility floor, bumped when this SPI changes incompatibly, including when a field is added to this class (Kotlin's synthetic default-argument constructor names every parameter, so an older plugin calls a method that no longer exists) | Leave at the default; an installed plugin declaring another value is rejected at load, which is the readable version of the linkage error it would otherwise hit |
dependsOn |
Drives the topological load order, and drops dependents when a dependency is disabled | Declare an edge whenever your contribution must land after another's |
description |
One line under the name in Settings → Plugins | Say what the user gets, not how it is implemented |
essential |
The plugin cannot be disabled; it and everything it transitively depends on stay loaded | Only for things the IDE genuinely cannot run without; ignored for an installed plugin |
entryPoints |
FQCNs the loader instantiates for an installed plugin | Unused for built-ins, where the class is the entry point |
capabilities |
Declared for the trust model | Parsed and carried; nothing reads it yet |
minHostVersion |
Rejects an installed plugin on an IDE older than it needs | Set it if you use a recently added SPI |
trusted |
Follows from the origin's signature | The host's to decide; ignored for an installed plugin |
manifest.pluginId derives the dev.ide.platform.PluginId used for attribution, so you never construct one
by hand.
The manifest is a Kotlin literal for built-ins ("manifest + entry point"). An installed plugin ships the same shape as TOML, which is why the two tiers share one SPI. See Ship your plugin as its own app.
dev.ide.plugin.PluginRegistration
is the registrar handed to register(). It exists so a plugin never threads a PluginId by hand and never
has to remember to unregister anything.
interface PluginRegistration {
val pluginId: PluginId
fun <T : Any> register(ep: ExtensionPoint<T>, impl: T): Disposable
fun <T : Any> service(key: ServiceKey<T>, level: ServiceScopeLevel, factory: ServiceFactory<T>): Disposable
fun contributeVia(block: (ExtensionRegistry, PluginId) -> Unit)
fun onDispose(d: Disposable)
val messageBus: MessageBus
fun busConnection(): MessageBusConnection
fun logger(tag: String): Logger
val hostVersion: String?
}| Member | Why it exists |
|---|---|
register(ep, impl) |
The common case. Attributes to this plugin and tracks the handle for unload |
service(key, level, factory) |
Registering a service through the raw EP would make you pass the id twice; this collapses it |
contributeVia { ext, pid -> … } |
A bridge for pre-existing (ExtensionRegistry, PluginId) facades such as ModuleTypeRegistry and ProjectTemplateRegistry. Those discard per-registration handles, so their unload relies on the bulk unregisterAll(pluginId) sweep, which is exact because they attribute to this same id |
onDispose(d) |
Ties an arbitrary Disposable to unload (LIFO with the rest) |
messageBus |
Publish: your own Topics, or the IDE's lifecycle topics |
busConnection() |
Subscribe: returns a connection already tracked for unload. A raw messageBus.connect() is not tracked, and its subscriptions outlive an unload |
logger(tag) |
A Logger whose records carry your plugin id, so the in-app Logs viewer can filter by plugin. Attribution is stamped by the platform and cannot be forged |
hostVersion |
The running IDE's version, or null when the host supplied none. The same value the loader compares against minHostVersion, so a runtime branch and a declared floor agree about what they are running on. Prefer minHostVersion to refuse an old IDE outright; read this to adapt behaviour or report the host |
dev.ide.plugin.impl.PluginManager
loadAll(plugins)topologically sorts bymanifest.dependsOn(dependencies first), then loads each. Independent plugins keep their declared relative order, so the result is deterministic.- It throws on a duplicate id, a
dependsOnnaming a plugin that is not in the set, or a dependency cycle. These are programming errors, surfaced at startup rather than as a mysterious ordering bug. unload(id)disposes the plugin's trackedDisposables LIFO, then sweepsunregisterAll(id), then callsdispose(). Both teardown paths are list removals, so running both is idempotent.
Why declared order matters, concretely: the JDT language backend must be index 0 on LANGUAGE_BACKEND_EP
because the resolution fallback backendFor relies on it. That used to be implicit registration sequencing.
Now kotlin-language, xml-language, and the analysis plugins carry dependsOn = listOf("jdt-language"),
and the manager enforces it.
dev.ide.plugin.impl.PluginCatalog is
pure and host-agnostic: it takes all manifests plus the user's persisted disabled ids and computes the set
that loads this session.
- An
essentialplugin, and everything it transitively depends on, is force-enabled, so a disabled id among them is ignored. - Any other plugin is enabled unless the user disabled it, or it transitively depends on a disabled plugin.
Dropping dependents matters: a dangling
dependsOnedge is exactly whatPluginManager.loadAllrejects.
This section builds a minimal engine-only plugin, hello, that contributes a settings page. Later sections
add actions, services, events, and UI on top of it.
You have two choices, and both are used in-tree:
| Choice | When to use it | Example |
|---|---|---|
A class in BuiltInPlugins.kt or a sibling file in :ide-core |
The plugin only wires up types that already exist in modules :ide-core depends on |
BlocksPlugin, VcsPlugin, AgentPlugin |
| A new Gradle module | The plugin brings its own engine code, its own dependencies, or its own Compose UI | :vcs-impl + :vcs-ui, :agent-impl + :agent-ui |
Start with a class in :ide-core. Move to a module as soon as the plugin needs its own dependencies: a
plugin that drags a third-party library into :ide-core puts that library on everyone's classpath.
Add it to settings.gradle.kts. Pure engine modules go in the first include(...)
block; anything applying Compose or AGP goes in the CI_CORE_ONLY guarded block, because those plugins need
the Android SDK even to configure.
include(
// …
":hello-impl", // the Hello plugin engine: <one line on what it does>
)Then a build.gradle.kts. Depend on :plugin-api (which re-exposes :platform-core via api) plus whatever
API modules hold the EPs you contribute to:
plugins {
alias(libs.plugins.kotlin.jvm)
`java-library`
}
dependencies {
api(project(":plugin-api")) // Plugin / PluginManifest / PluginRegistration + platform-core
implementation(project(":language-api")) // only if you contribute a language EP
}Finally, add implementation(project(":hello-impl")) to
app/ide-core/build.gradle.kts so BuiltInPlugins can reference your entry point.
package dev.ide.hello
import dev.ide.platform.settings.SETTINGS_PAGE_EP
import dev.ide.plugin.Plugin
import dev.ide.plugin.PluginManifest
import dev.ide.plugin.PluginRegistration
/**
* Hello, contributed as a built-in plugin. Non-essential, so the whole feature can be turned off from
* Settings > Plugins.
*/
class HelloPlugin : Plugin {
override val manifest = PluginManifest(
id = ID,
name = "Hello",
description = "A worked example: one settings page and one palette command.",
)
override fun register(reg: PluginRegistration) {
reg.register(SETTINGS_PAGE_EP, HelloSettingsPage)
}
companion object {
const val ID = "hello"
}
}Three things to notice, because they are the conventions every built-in follows:
- The id is a
const valon a companion. Anything that gates on the plugin being enabled (see Enable, disable, and dependencies) refers toHelloPlugin.IDrather than repeating a string literal. registerdoes nothing but register. No I/O, no project lookups, no thread starting. It runs during app construction, before any project exists.- The KDoc says what the user gets. The description string is user-facing; it renders in Settings.
Open BuiltInPlugins.kt and add an entry to
assemble:
object BuiltInPlugins {
fun assemble(env: ApplicationEnvironment, codecs: FacetCodecRegistry): List<BuiltInPlugin> = listOf(
BuiltInPlugin(PlatformPlugin()),
// …
BuiltInPlugin(HelloPlugin()),
)
}BuiltInPlugin(engine, ui = null) is the unified declaration described in The two facets.
Pass ui = … once the plugin has a Compose facet (see Contribute UI).
Order in this list is only a tie-break: PluginManager topologically sorts by dependsOn first. Declare a
dependsOn edge if you actually need one; do not rely on list position.
Launch the desktop shell (:ide-desktop) or install the Android app (:ide-android), then:
- Open Settings → Plugins.
Helloshould be listed with your description and an enabled toggle. - Open the settings page you registered and check the controls render.
- Toggle the plugin off, restart, and confirm the surface is gone. That round trip is the real test that the feature is a plugin rather than host code.
You do not need the app to test a plugin. Build a registry, load the plugin, assert the contributions:
@Test
fun `contributes a settings page`() {
val reg = ExtensionRegistryImpl()
PluginManager(reg).loadAll(listOf(HelloPlugin()))
assertTrue(reg.extensions(SETTINGS_PAGE_EP).any { it.id == "hello" })
}See PluginManagerTest for the
full set of behaviours the manager guarantees, and Test your plugin for more recipes.
An extension point is a typed slot. Contributing to one is a single call:
override fun register(reg: PluginRegistration) {
reg.register(LANGUAGE_BACKEND_EP, MyLanguageBackend())
reg.register(FILE_TYPE_EP, FileTypeMapping(listOf(".mylang"), MyLanguageBackend.LANGUAGE_ID))
}The full inventory is in Appendix A. A representative slice:
| Extension point | Contribute to add |
|---|---|
dev.ide.lang.LANGUAGE_BACKEND_EP |
A new editor language (parse, complete, navigate) |
dev.ide.lang.COMPILATION_CONTEXT_PROVIDER_EP |
platform.compilationContext |
dev.ide.lang.FILE_TYPE_EP |
A file suffix → language mapping |
dev.ide.analysis.ANALYZER_EP |
An inspection producing diagnostics |
dev.ide.analysis.QUICK_FIX_PROVIDER_EP |
A fix for a diagnostic |
dev.ide.analysis.ACTION_PROVIDER_EP |
A caret intention (no diagnostic needed) |
dev.ide.index.INDEX_EP |
A persisted symbol index |
dev.ide.build.BUILD_SYSTEM_EP |
Support for a new build system |
dev.ide.build.BUILD_PLUGIN_EP |
Extra tasks wired into every build graph |
dev.ide.build.SOURCE_GENERATOR_EP |
A code generator that runs before compilation |
dev.ide.vcs.VCS_PROVIDER_EP |
Another version-control system |
dev.ide.platform.settings.SETTINGS_PAGE_EP |
A settings category |
dev.ide.plugin.action.UI_ACTION_EP |
A toolbar / menu / palette command |
File-to-language routing is a registration, not a when. A file's LanguageId resolves through
FILE_TYPE_EP (FileType.kt). A mapping may point
at a language with no registered backend. That is how .pro and .md are edited as plain text and, because
the analysis pipeline dispatches by language, are never analysed as Java.
Contributions are queried live. Consumers such as
ActionManager call extensions(EP) on
every resolution rather than caching a snapshot at construction, so a plugin loaded or unloaded later is
reflected without rebuilding anything.
If your plugin is itself extensible, publish an EP from your API module:
package dev.ide.hello
import dev.ide.platform.ExtensionPoint
/** Greeters the Hello plugin consults, in registration order. */
val GREETER_EP = ExtensionPoint<Greeter>("hello.greeter")Rules that matter:
- The id string is the identity. A producer and a consumer that each construct
ExtensionPoint("hello.greeter")see the same contributions. Two EPs sharing an id with different types fail at runtime, not at compile time. - Namespace it. Platform EPs use
platform.*; use your plugin id as the prefix. - Consume it defensively.
extensions(EP).ifEmpty { builtinDefaults }is the pattern used by the Kotlin compiler-plugin EP, so a standalone test with an empty registry still behaves.
A plugin for Python, C++, Go or anything else laid out unlike a JVM module contributes on five extension
points in project-model-api, all of them published. Nothing about this path is host-only: the built-in
Android support registers exactly these.
class PythonPlugin : Plugin {
override val manifest = PluginManifest(
id = "python-support", name = "Python",
capabilities = listOf(
PluginCapabilities.MODEL_MODULE_TYPE, // it contributes a kind of module
PluginCapabilities.MODEL_FACET, // ... with configuration of its own
PluginCapabilities.LANG_BACKEND, // ... and teaches the editor the language
),
)
override fun register(reg: PluginRegistration) {
reg.register(ModuleTypeExtensionPoint, PythonModuleType) // a kind of module
reg.register(FACET_CODEC_EP, PythonFacetCodec) // its module.toml table
reg.register(ProjectTemplateExtensionPoint, PythonAppTemplate) // a Create-Project entry
reg.register(PROJECT_IMPORTER_EP, PyProjectImporter) // adopt an existing pyproject.toml
reg.register(LANGUAGE_BACKEND_EP, PythonBackend) // parse / resolve / complete
reg.register(FILE_TYPE_EP, FileTypeMapping(listOf(".py"), LanguageId("python")))
}
}A facet is a type plus its codec, always both. The core cannot serialize a Facet generically, so
ModifiableModule.putFacet refuses a facet whose key has no registered FacetCodec and FacetContainer.get
answers null for one. Two rules follow from how they are matched:
FacetKeyhas reference identity. Declare it once as avaland have the facet and the codec name that same instance; two keys sharing an id are two different keys.- The TOML table name is the on-disk identity, in a namespace flat across every plugin.
decodeis resolved by table, so pick one that reads as your domain and expect the last registration to win.
A table nobody claims is not lost: it is carried through a load and a save untouched, so a project edited with your plugin disabled keeps its configuration.
val PYTHON_FACET = FacetKey<PythonFacet>("python")
data class PythonFacet(val interpreter: String, val venv: String?) : Facet {
override val key get() = PYTHON_FACET
}
object PythonFacetCodec : FacetCodec<PythonFacet> {
override val key = PYTHON_FACET
override val tomlTable = "python" // the [python] table in module.toml
override fun encode(f: PythonFacet) = buildMap {
put("interpreter", f.interpreter)
f.venv?.let { put("venv", it) } // an unset field is an absent key, never a null
}
override fun decode(v: Map<String, Any?>) =
PythonFacet(v["interpreter"] as? String ?: "python3", v["venv"] as? String)
}Codec values must be TOML-representable (String, Boolean, Int, Long, Double, and lists or
string-keyed maps of those), so that an encode and a load-from-disk produce structurally equal values. A
null has no representation: omit the key instead, so that what encode returns matches what the loader
produces. Values are checked when they are staged, so a bad one names your table rather than failing an
unrelated save later.
version, module, sourceSets and dependencies (RESERVED_FACET_TABLES) are the model's own tables and
cannot be used as a tomlTable.
A facet can be configured by a plugin that does not own it. The facet class and its codec belong to one
plugin. A project template that scaffolds an Android module, or an importer that reads a foreign build file,
has to configure that facet without being able to name it: :android-support is not a published artifact,
so AndroidFacet is out of reach. putFacetData writes the table and the values directly, which is all such
a caller has:
override fun generate(scaffold: ProjectScaffold, args: TemplateArgs) {
scaffold.workspace.projects.first { it.name == args.name }.beginModification().apply {
addModule("app", scaffold.moduleType("android-app")).apply {
languageLevel = scaffold.languageLevel
putFacetData(
FacetData(
"android", // the table AndroidFacetCodec persists to
linkedMapOf(
"namespace" to args.packageName,
"compileSdk" to 36L,
"minSdk" to args.int("minSdk", 26).toLong(),
"isApplication" to true,
),
),
)
}
commit()
}
}The keys are the ones that plugin's encode produces; the module type's defaultFacets() supply the rest.
custom-project-templates.md is the full guide to the template this example is
taken from.
Do not reach the same end by declaring a facet of your own and pointing its codec at another plugin's table.
Codecs are resolved by table with last-registration-wins and an installed plugin loads after the built-ins,
so that takes over persistence for the table in every project on the device, not only the ones your template
created. The registry logs it; nothing else about it is visible from either plugin.
The model's vocabularies are open, so your layout does not have to lie. ContentRole, PlatformKind,
LibraryKind, LanguageLevel and DependencyScope were enums until SPI 2.0.0. They are now value types
whose built-in constants live on the companion, and a plugin declares its own:
object Py {
val PACKAGE_ROOT = ContentRole("python-package") // not "a Java source root"
val STUBS = ContentRole("python-stubs")
val PLATFORM = PlatformKind("PYTHON") // resolves a Python SDK, never android.jar
val WHEEL = LibraryKind("WHEEL") // not "a jar"
val LEVEL = LanguageLevel("PYTHON_3_12") // not JAVA_17
/** On the runtime path, never the compile one. */
val RUNTIME_REQUIRES = DependencyScope.register(
DependencyScope("RUNTIME_REQUIRES", "runtimeRequires",
onCompile = false, onRuntime = true, onTest = true),
)
}
object PythonModuleType : ModuleType {
override val id = "python-app"
override val displayName = "Python Application"
override val platform get() = Py.PLATFORM
override fun defaultSourceSets() = listOf(
SourceSetTemplate("main", DependencyScope.IMPLEMENTATION,
mapOf("src" to setOf(Py.PACKAGE_ROOT), "stubs" to setOf(Py.STUBS))),
)
override fun defaultFacets() = emptyList<FacetTemplate>()
override fun supportedBuildSystems() = setOf(BuildSystemId.NATIVE)
}Three things to know about the open vocabularies:
DependencyScopeis the one that needs registering. The others round-trip on their name alone, but a scope also carries classpath semantics (onCompile/onRuntime/onTest) that a name cannot recover.DependencyScope.register(...)from yourregistermakes a project that persisted it load with the real semantics; without it the scope still round-trips, but is re-derived permissively (on every classpath) and a[dependencies]table keyed by itsidis skipped.- An exhaustive
whenover one of them now needs anelse. That is the one way this change can stop existing plugin code from compiling, and whyPLUGIN_SPI_VERSIONwent to2.0.0. - The built-in on-disk spellings did not change.
ContentRole.SOURCEis still written asjava, a source set's scope still asIMPLEMENTATION, a level still asJAVA_17. Your own values persist under theirid/name, so pick ones unlikely to collide with those.
Adopting an existing project is PROJECT_IMPORTER_EP: detect(root) claims a folder (highest
confidence wins), resolve reads its build files into an ExternalProjectModel, and the host applies that
snapshot in one transaction. The snapshot names your module type by id and your facets by table name, so an
importer needs no reference to the classes that provide them. Pair it with BUILD_FILE_WRITER_EP if edits the
user makes in the IDE should survive the next sync.
Understanding one feature of somebody else's build system is IMPORT_CONTRIBUTOR_EP instead. An importer
is all-or-nothing: it claims a project root and owns the whole reading of it. That is right for a build system
and wrong for the common case, which is a plugin that knows what one block in a build file means. A C/C++
plugin knows externalNativeBuild { } and nothing else about Gradle; without this its only choices were to
replace the Gradle importer outright, or to re-read the build files behind its back and keep a parallel model
that the next Sync silently invalidates.
object NdkImport : ImportContributor {
override val buildSystems = setOf(BuildSystemId("gradle")) // empty = every build system
override fun contribute(request: SyncRequest, model: ExternalProjectModel) = model.copy(
modules = model.modules.map { m ->
val cmake = CMakeBlock.findIn(request.root.resolve(m.dirRelPath)) ?: return@map m
m.copy(facets = m.facets + ExternalFacet("ndk", mapOf("cmake" to cmake.path)))
}
)
}It runs after the importer and before the host applies the snapshot, so what you add is committed in the same transaction and survives a Sync like anything the importer produced. It runs on the first import too, so a project imported once and one re-synced afterwards cannot disagree about what is in it. Contributors are applied in registration order, each seeing the previous one's result, so two plugins enriching one project compose rather than race; one that throws is logged and skipped, because enriching a snapshot is an addition and failing at it must not cost the user the import.
ModuleTypeRegistry, FacetCodecRegistry, ProjectTemplateRegistry and FileIconRegistry (all in
dev.ide.model) are the read side of these four EPs, if you need to resolve rather than contribute. They
read through to the extension registry on every lookup, so a plugin that loads later is still seen.
Your language's analysis inputs are yours to supply. The host builds a CompilationContext by walking the
project model, which produces the JVM reading of a module: a classpath, a platform SDK boot classpath, a Java
language level. A virtualenv, an include path or a sysroot is none of those, so contribute a
CompilationContextProvider and the host asks you first for the languages you claim:
val PY_INTERPRETER = ContextKey<String>("python.interpreter")
object PythonContexts : CompilationContextProvider {
override val languages = setOf(LanguageId("python"))
override fun contextFor(
workspace: Workspace, module: Module, language: LanguageId, variant: Set<String>?,
): CompilationContext? {
val facet = module.facets.get(PYTHON_FACET) ?: return null // not my module after all
return object : CompilationContext {
override val sourceRoots = module.sourceSets.flatMap { it.contentRoots }
.filter { Py.PACKAGE_ROOT in it.roles }.map { it.dir }
@Suppress("UNCHECKED_CAST")
override fun <T : Any> attribute(key: ContextKey<T>): T? =
if (key === PY_INTERPRETER) facet.interpreter as T else null
}
}
}
reg.register(COMPILATION_CONTEXT_PROVIDER_EP, PythonContexts)Only sourceRoots is required: classpath and bootClasspath default to ClasspathSnapshot.EMPTY,
languageLevel to LanguageLevel.DEFAULT, outputDir to null, processors to empty. ContextKey has
reference identity like FacetKey, so the provider that writes an attribute and the backend that reads it are
the same plugin naming the same val, and the core never has to know the key exists. ClasspathEntryKind is
open too, so a language that does have a dependency path can name entries the core has no word for
(ClasspathEntryKind("INCLUDE_DIR")).
The host asks providers claiming the language in registration order and takes the first non-null answer; returning null falls back to the model-derived context, and a provider that throws is logged and skipped so one broken plugin cannot stop analysis of everything else.
If your language does have a classpath but needs something extra alongside it, start from
ModuleCompilationContext.create(workspace, module, variant), which is the model-derived context the host
would otherwise hand you, and add to what it returns.
What is still JVM-shaped underneath: Module carries classpath() and outputDir on the interface, which is
why a non-JVM Module still answers a classpath question. It is not in your way once you supply your own
context.
Also register an EditorLanguage, or your files open as grey text. This is the mistake worth calling out,
because everything above can be right and the result still looks broken. A LanguageBackend gives parsing,
resolution, completion and semantic highlighting, and all of that is asynchronous and debounced: while the
user types there is no coloring at all, and there is never a Toggle Comment, a closing bracket, or a smart
indent, because those are not parsing. They come from the editor's synchronous text layer, which is a profile:
class CppUi : UiPlugin { // the UI facet: uiEntryPoints in the manifest
override val id = "com.example.ndk"
override fun contribute(ui: UiRegistration) {
ui.editorLanguage(
EditorLanguage(
id = "cpp", // the same LanguageId the engine routes by
suffixes = listOf(".cpp", ".cc", ".cxx", ".h", ".hpp"),
syntax = SyntaxStyle.C_FAMILY,
keywords = CPP_KEYWORDS,
lineComment = "//",
blockCommentOpen = "/*", blockCommentClose = "*/",
directivePrefix = "#", // `#include <stdio.h>` reads as a directive
)
)
}
}Declare it as PluginCapabilities.UI_EDITOR_LANGUAGE. Notes:
- The profile's
idmust match theLanguageIdyourFILE_TYPE_EPmapping routes by. One language, one id, across the editor and the engine. SyntaxStyleis a small closed set (C_FAMILY,XML,HASH_COMMENT,MARKDOWN,PLAIN) and deliberately not a way to plug in a lexer: this layer runs synchronously on every visible line on every keystroke, so it is one of the few places a plugin's own code must not be. Anything a family cannot express is what the backend's semantic highlighting is for, and it layers on top.keywordsis consulted only byC_FAMILY. Do not list type names or function names: a capitalized word already colors as a type and a word followed by(already colors as a call.orderdecides who wins when two profiles claim a suffix, lowest first. The IDE's own sit at the default, so a profile that means to take over.javahas to say so.
SourceAnalyzer already exposes semantic highlighting, folding and inlay hints, but only to a plugin that
implements a whole LanguageBackend for a language. A coverage tint, a version-control change bar, a
bookmark, a "this test passed" glyph: none of those parse anything, and all of them apply to files in every
language. They contribute to platform.editorDecoration instead.
object CoverageDecorations : EditorDecorationProvider {
override val id = "coverage"
override fun appliesTo(ctx: EditorDecorationContext) =
ctx.languageId == "kotlin" || ctx.languageId == "java"
override suspend fun decorate(ctx: EditorDecorationContext): EditorDecorations {
val report = coverageFor(ctx.path) ?: return EditorDecorations.EMPTY
return EditorDecorations(
ranges = report.uncovered.map {
TextDecoration(it.start, it.end, DecorationStyle.Background, DecorationTint.Warning,
tooltip = "not covered by any test")
},
gutter = report.coveredLines.map {
GutterMark(it, iconId = "check", tint = DecorationTint.Success,
tooltip = "covered", actionId = "coverage.openReport")
},
inlays = listOf(EditorInlay(report.classHeaderOffset, "${report.percent}%")),
)
}
}Four things about that contract are worth knowing before you rely on it.
A color is a role, not a value. DecorationTint is a closed set the host resolves against the active
theme. The IDE's themes are generated from a seed, and the user picks light or dark and an accent, so there
is no literal a plugin could hard-code and stay legible. Added/Removed/Modified are there so a change
bar matches what the rest of the IDE already uses for a diff. When you genuinely own your colors (a blame
heatmap, a coverage gradient), that is what the UI tier's painter is for, not a color on this type.
Providers are pulled, not pushed. Yours runs on the editor's own debounced pass run, after the diagnostics and folding passes, so you need no channel into the UI, you are cancelled the moment the user types again, and you cannot keep an editor alive. In exchange it must be cheap and it must not block: a slow provider delays nothing but its own marks, but a provider that blocks the thread holds the pass.
Offsets are against the text you were handed, which may differ from disk. The editor re-anchors your marks in place as the user types, so they track between passes, but a mark outside the buffer is dropped rather than clamped: a mark on the wrong text is worse than no mark.
The gutter fits one glyph per line. A line that already carries the IDE's @Preview icon keeps it, and
several marks on one line collapse to the highest order. A mark with an actionId is tapped to invoke that
action, dispatched over the marked line rather than the caret, since the caret is rarely where the user
reached over to tap.
A provider declares PluginCapabilities.UI_EDITOR_DECORATION. It is the one contribution that changes how
the user's own code looks on every file it claims, rather than adding a surface they choose to open, so the
consent gate names it.
The decoration tier above covers everything expressible as data. Two things are not, and each has a UI-facet
contribution in plugin-ui-api. Reach for them second: a decoration provider is engine-tier, runs off the
composition, and its failures are contained per provider, none of which is true here.
A layer places composables at document positions. A hover card, an inline button, a code-lens row: touch targets, animation and Material components need real composables.
ui.editorLayer(
EditorLayer(id = "com.example.lens", appliesTo = { it.endsWith(".kt") }) { ctx ->
val runs = testRuns.collectAsState().value // your own store, read in composition
ctx.visibleLines.mapNotNull { line ->
val run = runs[line] ?: return@mapNotNull null
EditorWidget(EditorAnchor.AboveLine(line), key = "lens-$line") {
TextButton(onClick = { ctx.openFile(run.reportPath) }) { Text("${run.passed} passed") }
}
}
},
)Anchors are AtOffset, AfterLine (the code-lens position, just past the line's text) and AboveLine (its
own row above the line). Positioning happens in the layout phase off the editor's own row map, so a widget
scrolls with its line without recomposing, and soft wrap and collapsed folds are already accounted for.
Three rules the shape of Compose forces:
- Place only what is visible.
ctx.visibleLinesis the viewport, and the producer is asked again as the user scrolls. A widget for line 4000 is a composable for nothing. - Read state and decide, nothing else. The producer runs on every recomposition of the editor. Do the work in your engine facet and read the result here.
- A throwing producer takes the editor's composition with it. A
@Composablecall cannot be wrapped in atry, because the slot table the composition is built from has no way to unwind half a composable. TheappliesTopredicate IS guarded and is checked before anything is composed, so be selective there.
A painter draws into the canvas. This is the one thing a decoration deliberately cannot do: use colors of your own instead of the theme's named roles.
ui.editorPainter(
EditorPainter(id = "com.example.blame", layer = EditorPaintLayer.BelowText) { ctx ->
for (line in ctx.visibleLines) {
if (ctx.isHidden(line)) continue
val heat = blame.heatOf(line) ?: continue // precomputed, not computed here
drawRect(heat, Offset(0f, ctx.lineTop(line)), Size(ctx.gutterWidth, ctx.lineHeight))
}
},
)BelowText sits under the text and under the selection, which is where a fill belongs; AboveText sits over
every decoration and under the caret. The geometry arrives as functions (lineTop, xOf, isHidden) rather
than as numbers, because line * lineHeight is wrong the moment a line wraps or a fold closes and both are
normal. An offset the layout refuses answers textLeft instead of throwing.
Two rules here are not style advice:
- Do no work and allocate nothing.
paintruns every frame, including every frame of a fling on a phone. - Do not throw. A painter that throws is retired for the rest of the session, not retried: a draw that throws once throws sixty times a second, and the alternative is an editor that neither draws nor recovers. Your plugin loses its drawing until the IDE restarts, and the reason is kept for the surface that reports it.
A view mode is a surface for a tab, beside the IDE's own Code, Blocks, Preview and Split: a scene view, a data
grid, a form over a config file. Claiming isDefault for a file kind makes that kind OPEN into your pane, so
this is how a plugin comes to own a file type.
ui.viewMode(
EditorViewMode(
id = "com.example.scene",
label = "Scene",
iconId = "layers",
appliesTo = { it.endsWith(".scene") },
isDefault = { it.endsWith(".scene") }, // a .scene file opens here, not in the code editor
) { ctx ->
SceneCanvas(
json = ctx.text,
onMove = { node, x, y -> ctx.replaceText(node.start, node.end, node.moved(x, y)) },
)
},
)- The pane is a view OF the tab's buffer, not a copy of it.
ctx.textis the same document the code editor edits andreplaceTextwrites through it, so a pane's edit is undoable, is analysed, and marks the tab dirty exactly as typing does. Prefer the narrowest range that changes; replacing the whole text works and costs the user their undo granularity. - Code stays reachable even for a kind you claimed. That is deliberate, not a gap in the ownership: a pane can be wrong about a file, and a user who cannot see the text has no way to find out why.
- A claim beats the IDE's own default, including the bitmap rule, so an image editor can take over
.png. - If you own a kind the text editor cannot represent (a binary), read the file yourself through your engine
facet. The tab's buffer is a text decode of the bytes, which for a binary is garbage, and
replaceTextwould write that garbage back. appliesTodecides where the toggle offers the mode at all, andisDefaultdecides where it opens. A mode that claims a default for a file it does not otherwise claim is ignored, since its own segment would be missing from that file's toggle.
A binding is a shortcut plus the id of an action. Contribute one and the key runs your action; the user can rebind it from Settings, and a shortcut two commands claim is reported rather than resolved in silence.
reg.register(
KEY_BINDING_EP,
KeyBinding(
actionId = "com.example.hello.greet",
shortcut = Shortcut.parse("primary+alt+H")!!,
context = KeyContext.Editor, // Global fires anywhere; Editor only with a tab focused
),
)primaryis the modifier to reach for. It is Command on macOS and Control everywhere else, which is what every shortcut in this IDE meant when it was anisCtrlPressed || isMetaPressedcondition. Writingctrlormetaasks for that physical key on every platform, which is occasionally right and usually not.- The user outranks you. A rebinding beats any contribution, whatever its
order.orderonly decides between contributors, so a plugin that means to replace a built-in binding raises its order and the built-in is then reported as shadowed rather than silently dead. - Chords work:
Shortcut.parse("primary+K primary+D"). The first press is held, the second completes it, and anything that is not a continuation is re-matched on its own, so a mistyped chord costs one key. - Shift and Alt match exactly.
primary+shift+Kis notprimary+Kand will not fire it. - Check what is taken. A
Globalbinding also fires in the editor, so an app-wide shortcut competes with the editor's own;EDITOR_KEY_DEFAULTSinide-ui-apiis the list of what the editor already claims. - Declare
PluginCapabilities.UI_KEY_BINDING. A shortcut is a scarce shared resource, and the user cannot otherwise tell which plugin took a key.
Your own settings page can offer a recorder for a shortcut of yours with SettingControl.Shortcut: the user
presses the shortcut instead of typing its spec, and the spec is what gets stored, so a recorded shortcut and
a declared one are the same thing.
A key press that is not a command is not the keymap's business: caret motion, text input, and the keys a popup or a live template owns while it is open stay on the editor's own key path. They have no action id to bind to, and an IME commit is not a key press at all.
Use a service when your plugin owns an object that is expensive to build, must be shared, and must be torn down with something. Use a plain extension when the plugin contributes behaviour to a host-owned engine.
val HELLO_SERVICE = ServiceKey<HelloService>("hello.service")
override fun register(reg: PluginRegistration) {
reg.service(HELLO_SERVICE, ServiceScopeLevel.WORKSPACE) {
HelloService(getService(ENGINE_CONTEXT))
}
}ENGINE_CONTEXT is internal to :ide-core, so that exact line compiles in a built-in plugin only. An
installed plugin can name a narrower set of keys; see What you can resolve.
The factory's receiver is ServiceScope,
which gives you:
| Member | Purpose |
|---|---|
getService(key) |
Pull a dependency, including from a parent scope |
scopeObject |
The domain object this scope is bound to: the Module at MODULE, the Workspace at WORKSPACE, null at APPLICATION |
container / parent |
The containers themselves, when you need getServiceOrNull |
onDispose(d) |
Extra teardown that runs when the scope's container disposes |
Choosing a level:
| Level | Built once per | Use for |
|---|---|---|
APPLICATION |
Running app | Caches shared across projects, host capability ports, warm compilers |
WORKSPACE |
Opened project | Project-wide engines: search, dependencies, build, signing |
MODULE |
Module | Per-module analyzers |
Two properties follow from the container design:
- Lazy. A service is not built until something asks for it. A plugin whose service is never resolved costs nothing at startup.
- Cascading. An unresolved key falls back to the parent scope, so a MODULE-scoped factory can depend on a WORKSPACE-scoped service without any wiring.
getServiceOrNull is the resolution path for optional host capabilities. The platform ports (the program
interpreter, the APK installer, the custom-view and real-view runtimes, the Kotlin plugin loader and compiler
backend, the Android device tools) are APPLICATION services registered by whichever launcher is running. The
engine resolves each with getServiceOrNull and falls back to an in-process default when the port is absent,
which is the case on desktop and in a standalone test with no host. Use the same shape for anything only some
hosts provide. The keys are declared in
PlatformPorts.kt (most are internal today;
ANALYTICS_SERVICE is public).
register() runs inside ApplicationEnvironment's
constructor, once at startup, before any project is open. The application container exists by then, so an
APPLICATION-scoped service is resolvable there; a WORKSPACE- or MODULE-scoped one is not, because there is no
open project to scope it to. Prefer registering something that is handed a scope later and resolving at that
point. Four shapes cover it.
From your own service factory. The factory is lazy: it runs on first resolution, when the scope it is registered at exists. This is where a service's own dependencies belong.
reg.service(MY_SERVICE, ServiceScopeLevel.WORKSPACE) {
MyService(workspace(), getService(SOME_OTHER_KEY))
}From a Module or Workspace. Both carry a public service(key) lookup, and Module.service walks
MODULE, then WORKSPACE, then APPLICATION, so one call reaches any scope. Workspace.serviceOrNull(key) is
the same lookup for a capability whose absence is normal, so a consumer can fall back instead of throwing:
reg.register(ANALYZER_EP, object : FileAnalyzer {
override suspend fun analyze(target: AnalysisTarget): List<Diagnostic> {
val mine = target.module.service(MY_SERVICE)
val index = target.index // IndexService, handed to you directly
...
}
})What the callbacks hand you to resolve through:
| From | You get |
|---|---|
AnalysisTarget (analyzers, QuickFixProvider, ActionProvider) |
.module, .index (IndexService), .resolver (SourceAnalyzer), .parsed, .file |
ProjectAnalysisScope (project analyzers, batch lint) |
.modules, .index |
SyntheticClassContext |
.module, .workspace |
A ServiceFactory |
module(), workspace(), getService(key) |
ActionContext is the exception: a UI_ACTION_EP command is given projectRoot, activeFilePath and the
selection as plain strings and offsets, with no model object, so it can resolve nothing. An action that needs
services fits better on ACTION_PROVIDER_EP, which is given an AnalysisTarget.
From the registrar, for an APPLICATION-scoped service with no scope object in sight.
reg.dataDir is a directory this plugin owns, created on first read and removed when the plugin is
uninstalled: for a cache, a downloaded index, a small database, anything that is state rather than a setting.
It is application-scoped, so it survives a project switch; per-project content belongs in the project, through
MODULE_RESOURCES or the file system.
reg.nativeLibraryDir and reg.nativeLibrary(name) find the native libraries your plugin packaged, which
the installer unpacked when the plugin app was installed. Package one the way an Android app does, as
src/main/jniLibs/<abi>/lib<name>.so, and ask for it by its plain name:
val clang = reg.nativeLibrary("clang") // <unpacked lib dir>/libclang.so, or null
?: return reg.logger("ndk").warn("no toolchain for this device's ABI")
ProcessBuilder(clang.toString(), "--version").start()This is not a convenience, and it is the one thing dataDir cannot substitute for: since Android 10 an app
may not exec() or dlopen() a file it wrote into its own storage. A toolchain you download into dataDir
can be read but never executed. The unpacked library directory is outside app-writable storage, which is
exactly what makes it runnable, so anything a plugin needs to run has to ship inside the APK. Both members
answer null on a host that unpacks nothing (a built-in, the desktop launcher, a test), and nativeLibrary
also answers null for an ABI you did not build for, which is worth reporting as "unsupported on this device"
rather than treating as a broken install. name is a name and not a path: anything carrying a separator is
refused rather than reduced, so no spelling of it reaches another plugin's directory.
reg.appServices is a read-only
ServiceLookup over the application
container: it resolves keys, and cannot define a service, evict an instance, or dispose a scope.
private lateinit var services: ServiceLookup
override fun register(reg: PluginRegistration) {
services = reg.appServices // keep the lookup; resolve at callback time
}
private fun sdk() = services.getServiceOrNull(APP_SDK_MANAGER)Hold it rather than resolving in register: resolving during load forces the service to be built then, and
a service another plugin registers is only there once that plugin has run, so name that plugin in
manifest.dependsOn. The edge also means the user disabling it disables yours, since the catalog drops a
disabled plugin's dependents.
From the message bus, when what you need is the transition rather than the object:
reg.busConnection().subscribe(topic, listener), auto-unsubscribed on unload. See
section 7.
A key is usable only if you can name its type, and that differs by tier:
| Tier | Can name |
|---|---|
| Built-in (a module in this build) | Every key in Appendix C, internal ones included |
| Installed (its own APK) | Keys from the published SPI artifacts, plus keys it or another plugin declares |
Resolution itself is available to both tiers: reg.appServices for APPLICATION scope, and
Module.service / Workspace.service from the callbacks above. What differs is what you can name.
Seven engine capabilities are nameable from the published SPI. WORKSPACE_SERVICE is the bound Workspace.
Five are the promoted services (BUILD_CONTROL, SYMBOL_SEARCH, MODULE_SOURCES, MODULE_ANALYSIS,
MODULE_RESOURCES), each declared in the api module whose types it already speaks, and each typed to a
narrowed interface rather than to the engine class behind it. The seventh is CODE_INTERPRETER
(interp-api), the only one at APPLICATION scope: it runs the code in the user's project, and it follows whichever project is open rather
than being scoped to one (see section 14b). :ide-core registers the narrow key
alongside its own and resolves both to one instance, so a plugin and the IDE act on the same build, the same
index, and the same module model.
The narrowing is the point. The engine's build service is a 1500-line class whose width is phrased in the
console, run-picker and permission types the IDE's UI port owns; BuildControl is three members. What is
promoted is frozen as plugin API, and what is not stays internal and free to change. Everything else in
Appendix C is built-in-only, and a plugin's route to it is extension points and the contexts they pass.
Plugin-to-plugin sharing works fully in both tiers: declare a ServiceKey in an artifact both sides compile
against, register the service, and have the consumer list you in dependsOn so your register() runs
first. That is the case appServices closes for the installed tier, which had no way to resolve an
APPLICATION-scoped service at all.
Container lookup is by the key's string id, so a forged ServiceKey<Any>("ide.service.build") does return
the real instance. Do not do this. The type is internal, so what you get back is Any plus reflection
against a shape that carries no compatibility promise.
The registrar exposes the application message bus. Always subscribe through busConnection(): it returns a
MessageBusConnection already tracked for unload, so the subscriptions disappear when the plugin unloads. A
raw messageBus.connect() leaks past unload.
override fun register(reg: PluginRegistration) {
val log = reg.logger("hello")
reg.busConnection().subscribe(
BuildTopics.BUILD,
BuildEventListener { event -> // explicit listener constructor; see the note below
if (event is BuildEvent.Finished && !event.succeeded) {
log.warn("build of ${event.module} failed: ${event.failureKind}")
}
},
)
}Note:
MessageBusConnection.subscribe(topic, listener)is generic, and Kotlin does not SAM-convert a type-variable parameter. Pass the explicit listener constructor (BuildEventListener { … }), never a bare lambda. A bare lambda does not compile, and the resulting error is not obvious.
Each topic lives in the published api module that owns its payload, so both tiers can name it. The IDE is the only publisher.
| Topic | Artifact | Payload | Fires when |
|---|---|---|---|
EditorTopics.EDITOR |
plugin-api |
EditorEvent.FileOpened / FileClosed / ActiveEditorChanged / SelectionChanged |
An editor session transitions. Selection events are debounced to settle, not per keystroke |
BuildTopics.BUILD |
build-api |
BuildEvent.Started / Finished |
A compile or assemble, including the compile half of a run |
BuildTopics.RUN |
build-api |
RunEvent.Started / Finished |
A program run or Android app launch |
AnalysisTopics.ANALYSIS |
analysis-api |
AnalysisEvent(path, diagnostics) |
A file's merged diagnostics were published |
ProjectTopics.LIFECYCLE |
project-model-api |
ProjectEvent.Opened / Closed |
A project became, or stopped being, the active engine |
IndexTopics.INDEXING |
index-api |
IndexEvent.Started / Finished(status) |
Index build progress |
An installed plugin declares only plugin-api and platform-core by default, which covers
EditorTopics. Subscribing to any of the others means adding that artifact, which the BOM already versions:
compileOnly(platform("io.github.tyron12233:plugin-bom:2.10.0"))
compileOnly("io.github.tyron12233:build-api") // BuildTopicsSwitching projects publishes Opened for the incoming project and then Closed for the outgoing one, in
that order: Closed arrives while the outgoing engine is still alive, before it is disposed.
Lower-level spines are also on the same bus and available to you: dev.ide.vfs.VfsTopics (raw file changes),
dev.ide.model.event.ProjectModelTopics (model commits), and dev.ide.core.settings.SettingsTopics (which
is in ide-core, so it is nameable only from the internal tier).
Delivery contract. Synchronous, in subscription order, on whatever thread performed the transition. A build, an analysis pass and an index build run on background dispatchers; the editor events come from the UI side. Therefore:
- a listener that touches UI state must marshal to the UI thread itself;
- a slow listener slows the transition it is observing; do real work off the callback;
- publish sites guard subscribers with
runCatching, so a throwing listener cannot break the engine, but that is a safety net rather than error handling.
For plugin-to-plugin messaging, define a Topic and publish through the same bus:
val GREETED: Topic<GreetedListener> = Topic("hello.greeted", GreetedListener::class.java)
reg.messageBus.syncPublisher(GREETED).onGreeted(name)reg.logger(tag) returns a Logger whose LogRecord.source is your plugin id. That flows into the shared
Log facade and surfaces in the in-app Logs viewer, which offers a per-plugin filter chip and a source
badge, so a plugin's output is separable from the IDE's. The attribution is stamped by the platform, so one
plugin cannot log as another.
A settings page is the highest-leverage contribution in the platform: you declare typed controls and the generic settings UI renders, persists, and namespaces them. No UI code, no host edit.
dev.ide.platform.settings.SettingsPage
internal object HelloSettingsPage : SettingsPage {
override val id = "hello" // also the preference namespace: settings.hello.*
override val title = "Hello"
override val iconId = "sparkle" // resolved by the UI icon registry
override val scope = SettingsScope.APPLICATION // or PROJECT
override val order = 120 // built-ins occupy 0..99
override fun controls(): List<SettingControl> = listOf(
SettingControl.Text(
key = "greeting",
title = "Greeting",
description = "What the Hello command says.",
placeholder = "Hello, world",
),
SettingControl.Toggle(
key = "shout",
title = "Shout",
description = "Upper-case the greeting.",
default = false,
),
SettingControl.Action(
key = "reset",
title = "Reset greeting",
buttonLabel = "Reset",
destructive = true,
advanced = true,
),
)
override fun onChanged(key: String, values: PreferenceReader) { /* re-apply an effect */ }
override fun onAction(key: String, values: PreferenceReader): String? = "Greeting reset"
}| Control | Renders as |
|---|---|
SettingControl.Toggle |
On/off switch |
SettingControl.IntSlider |
Slider over [min, max] stepped by step, with an optional unit label |
SettingControl.Choice |
Segmented control / chips over options |
SettingControl.Text |
Free-text field |
SettingControl.Shortcut |
A keyboard shortcut, recorded by pressing it; stores a keymap spec |
SettingControl.Action |
A button; the press routes key to onAction |
SettingControl.Color |
A color, edited with a picker; stores an 0xAARRGGBB long |
Design notes:
- Keys are page-local. The host namespaces stored keys by page id, so
greetingcannot collide with another plugin'sgreeting. Values persist as strings undersettings.<pageId>.<key>. scopedecides where values live.APPLICATIONwrites the IDE-wide prefs file;PROJECTwrites the open project's.platform/. Pick by whether the setting is about the user or about the project.advanced = truecollapses a control into the page's Advanced group. Use it for anything a normal user should not have to read past.controls()is re-queried when the page is shown, so it can depend on current state.onChangedruns host code. This is exactly the kind of hook the trust model will gate once capabilities are enforced. Keep it cheap and side-effect-obvious.
VcsSettingsPage in VcsPlugin.kt is a short,
production example: three text fields, one of them advanced, persisting under settings.vcs.*.
CodeAssist has a hybrid action model. Choosing the right half is the main decision:
Engine action (IdeAction) |
UI host action (UiHostAction) |
|
|---|---|---|
| Module | :plugin-api |
:ide-ui-api |
| Crosses the UI boundary | Yes, as neutral DTOs | No, it lives on the UI side |
| Use for | Pure engine operations: build, re-index, refactor, generate | Driving the running UI: navigate to a screen, toggle the theme, open a file |
| Registered via | UI_ACTION_EP on the engine registrar |
scope.action(...) on a UiPlugin |
The split exists because "run a build" is expressible as data an engine can execute anywhere, while "navigate to the Dependencies screen" is meaningless outside a running Compose UI.
reg.register(
UI_ACTION_EP,
SimpleAction(
id = "hello.greet",
text = "Say Hello",
places = setOf(ActionPlaces.COMMAND_PALETTE),
iconId = "sparkle",
order = 100,
) { ctx ->
val root = ctx.projectRoot ?: return@SimpleAction ActionResult.message("No project is open")
ActionResult.message("Hello from $root")
},
)| Type | Role |
|---|---|
IdeAction |
One invocable command: id, text, iconId, places, order, isVisible, isEnabled, suspend perform |
SimpleAction |
Lambda-backed IdeAction, when a dedicated class is overkill |
ActionGroup / SimpleGroup |
Menu nesting; children(ctx) returns action and group ids, and the literal "---" inserts a divider |
ActionContext |
Read-only snapshot: place, projectRoot, activeFilePath, selectionStart/End, contextPath, plus caret and documentText for the editor places |
ActionResult |
A status message plus declarative effects |
ActionEffect |
OpenFile, Navigate, RefreshTree, ReloadFile, ApplyEdits, ApplyWorkspaceEdit, MoveCaret, Select, CreateFile, RenameFile, DeleteFile |
ActionEffect is why an engine action can ask the UI to navigate without depending on the UI: the action
returns an instruction, and the UI applies the ones it can honour and ignores the rest.
isVisible hides an action entirely; isEnabled leaves it listed but greyed. Prefer greying for an action
that is temporarily unavailable: a vanishing menu item reads as a bug.
ActionPlaces. The UI-side string
mirror is dev.ide.ui.backend.UiActionPlaces.
| Place | Where it renders | Context you get |
|---|---|---|
MAIN_TOOLBAR (mainToolbar) |
The editor top bar, in a slot beside the built-in chrome | Active file, selection |
MAIN_OVERFLOW (mainToolbar.overflow) |
Where the compact/mobile top bar folds overflow | Same |
MORE_MENU (moreMenu) |
The editor "More" sheet | Same |
FILE_CONTEXT (fileContext) |
File-tree row long-press / right-click | contextPath = the node |
EDITOR_TAB (editorTab) |
An open tab's context menu | activeFilePath = the tab's file |
COMMAND_PALETTE (commandPalette) |
The searchable palette | Global, plus the caret when an editor is focused |
EDITOR (editor) |
The Alt-Enter popup, the editor overflow menu, and the palette | caret, documentText, selection |
ActionPlace is a @JvmInline value class over a string, and an open set: a plugin can invent
its own place for its own surface without changing the platform.
An action placed on ActionPlaces.EDITOR acts on code. It is listed in the Alt-Enter popup beside the
analysis quick-fixes and intentions, in the editor's overflow menu (where an ActionGroup becomes a
submenu), and in the command palette while an editor is focused.
Two extra pieces of context arrive for these places:
CaretContextonActionContext.caret: the caretoffset, the file'slanguageId, and the innermost tree node'snodeKind/nodeStart/nodeEnd/nodeText, plusancestors(innermost first to the file root, each aCaretAncestorcarrying its own kind and span).isInside(kind)tests where the caret sits andenclosing(kind)returns what to act on.ActionContext.documentText: the live buffer, including unsaved changes. Compute edits against exactly this string; the host applies them to the same buffer, so offsets cannot drift.
Node kinds are the open string set the engine's DOM uses (class_decl, method_decl, block,
method_call, literal, and language-specific ids such as Kotlin's kt.lambda). Gate an action with
isVisible so it is listed only where it applies: a popup the user opened to fix one thing should not
fill up with actions that do not.
reg.register(
UI_ACTION_EP,
SimpleAction(
id = "hello.wrapInRun",
text = "Wrap Call in run { }",
places = setOf(ActionPlaces.EDITOR),
visible = { it.caret?.nodeKind == "method_call" },
) { ctx ->
val caret = ctx.caret ?: return@SimpleAction ActionResult.NONE
val call = ctx.documentText?.substring(caret.nodeStart, caret.nodeEnd)
?: return@SimpleAction ActionResult.NONE
ActionResult.effect(
ActionEffect.ApplyEdits(
TextEdit.replace(caret.nodeStart, caret.nodeEnd, "run { $call }"),
),
)
},
)Edits go through the editor's own text path, so a plugin's rewrite lands in the same undo step as typing
and re-triggers analysis normally. ApplyWorkspaceEdit spans files (open buffers are edited in place,
closed files are written through), and CreateFile never overwrites an existing file. Pair an edit with
Select to leave a generated name ready to type over, which is how a refactor asks for a name without a
dialog.
CaretContext is flat on purpose: listing runs on caret moves, and the snapshot has to cross the
IdeBackend port. An action that needs to walk the syntax tree, resolve a symbol, or query the project
index belongs on the analysis extension point instead:
ActionProvider on
platform.actionProvider, whose actions(ctx: EditorActionContext) receives the live DomNode, its
ancestor chain, the resolver, and the index, and returns QuickFixes producing a WorkspaceEdit. Both
tiers are listed in the same popup and menu, so the choice is about what the action needs, not where it
appears.
analysis-api is published alongside plugin-api, so an installed plugin can use either tier. The
difference is what each one costs and reaches, not who is allowed to use it:
IdeAction on EDITOR |
ActionProvider |
|
|---|---|---|
| Module | plugin-api (needs only platform-core) |
analysis-api (pulls in language-api and index-api) |
| Context | Flat CaretContext plus the buffer |
Live DOM, resolver, index, module |
| Scope | Any language, or gated on caret.languageId |
Declared languages set |
| Produces | ActionEffects, including file and caret effects |
WorkspaceEdit (text edits only) |
| Good for | Line and text rewrites, sending code elsewhere, file moves | Type-aware refactors, generated members, import fixes |
One consequence of that last row: only the portable tier can place the caret or a selection, since a
WorkspaceEdit carries edits and nothing else. An analysis-tier refactor that generates a name cannot
leave it selected for the user to type over.
ActionManager is the single consumer
of UI_ACTION_EP / ACTION_GROUP_EP. It resolves a place into an ordered, visibility-filtered list
(actionsFor), expands groups into a menu tree with separators collapsed (menuFor), and dispatches by id
(invoke). The host exposes it across the boundary as IdeBackend.actions
(ActionService), and the UI
renders UiActionItem / UiMenuGroup and applies UiActionEffects.
An unknown action id returns a message result rather than throwing, so a stale UI round trip degrades gracefully.
Module: :ide-ui-api, package dev.ide.ui.ext. This is the Compose-bearing half of the
hybrid model: contributions that render their own bodies and therefore cannot cross the IdeBackend boundary
as data.
interface UiPlugin {
val id: String
fun contributeUi(scope: UiContributionScope)
}
interface UiContributionScope {
val pluginId: String
fun action(action: UiHostAction): Registration
fun toolWindow(toolWindow: ToolWindowContribution): Registration
fun screen(screen: ScreenContribution): Registration
fun viewMode(mode: EditorViewModeContribution): Registration
fun overlay(overlay: OverlayContribution): Registration
fun tabDecoration(decoration: TabDecorationContribution): Registration
fun treeIcon(iconId: String, icon: TreeIcon): Registration
fun editorLanguage(profile: EditorLanguageProfile): Registration
}One scope covers the process-global UI registries, so there is a single place to look for what a plugin can
add to the UI. Each method returns a Registration that the plugin's unload disposes.
editorLanguage is the text layer: an
EditorLanguageProfile teaching the
editor a language's keywords, comment markers, and lexical family, which gives it coloring, Toggle Comment,
and brace-aware Enter. See custom-language-support.md for the whole language
surface.
BuiltInPlugins ApplicationEnvironment IdeBackend CodeAssistApp
BuiltInPlugin( → loads enabled engine facets → uiPlugins() → UiPluginHost.register(it)
engine, ui) exposes enabledUiPlugins UiPluginHost.ensureLoaded()
ApplicationEnvironment.enabledUiPlugins is already filtered by PluginCatalog, so a disabled plugin's UI
never reaches UiPluginHost, and there is no per-plugin gating in the UI layer at all. ensureLoaded() is
idempotent and is called from the shell at startup and defensively from the panel hosts.
scope.toolWindow(
ToolWindowContribution(
id = "hello.panel",
title = "Hello",
iconId = "sparkle",
anchor = ToolWindowAnchor.LEFT,
order = 40,
content = { ctx -> HelloPanel(ctx) },
),
)| Anchor | Renders as | Host code |
|---|---|---|
LEFT |
An icon on the left activity rail, next to Files / Search / Structure; the docked pane or the phone push drawer shows the body | buildLeftPanels in EditorLayouts.kt |
RIGHT |
A top-bar toggle and the right rail on desktop; a right-edge swipe drawer on phones | RightToolOverlay.kt, EditorCenter.kt |
BOTTOM |
An extra tab in the build console, after Problems / Log / Steps | BuildConsole.kt |
order sorts within an anchor and is merged with the built-in panels, so a contribution can slot between
them. Both the LEFT and RIGHT surfaces self-gate: with no contribution for an anchor, the host renders
nothing there, rather than a placeholder for a feature that is switched off.
content receives a ToolWindowContext:
| Member | Purpose |
|---|---|
backend: IdeBackend |
Everything the engine exposes to the UI |
activeFilePath: String? |
The file in the active editor |
fileActions: FileActions |
Platform bridges: open an external link, share or pick a file |
openScreen(id) |
Navigate to a contributed screen |
A sidebar panel is too narrow for a branch list, a commit history, or a diff, especially on a phone, so a panel's deeper flows are screens rather than nested sheets.
scope.screen(ScreenContribution("hello.detail", "Hello detail") { ctx -> HelloDetail(ctx) })ScreenContext gives you backend, fileActions, back(), and openScreen(id) for screen-to-screen
navigation. The host routes every contributed screen through one generic destination
(Screen.PluginScreen in AppNavGraph.kt), so
adding a screen needs no navigation-graph edit.
Reach a screen from: a tool window's ctx.openScreen(id), a UiHostAction navigating to it, or an engine
action returning ActionEffect.Navigate(id).
openScreen(id) pushes: your history opens on top of the screen that called it, Back returns there with
that screen's own id restored, and only the bottom of the run steps out to whatever opened the first one. A
branch list opening a diff, and the diff opening that file's history, is three entries and three presses back.
(Before SPI 2.9.0 this replaced the screen and one press left the whole run, so a panel's own detail view
could not be returned to.)
Claim the gesture when your screen has somewhere of its own to go, so a press undoes a step inside it instead of tearing it down whole:
@Composable
fun CloneScreen(ctx: ScreenContext) {
var step by remember { mutableStateOf(0) }
// Back walks the wizard backwards while there are steps left, and leaves the screen at step 0.
ScreenBackHandler(enabled = step > 0) { step-- }
...
}enabled is read on every press, so hold one handler and let its condition follow your state rather than
registering and unregistering. Nested handlers resolve innermost-first, and the claim lives exactly as long as
the composable does. ScreenBackRegistry.register(enabled, onBack) is the same thing for a controller or a
test that is not holding it from a composition.
What the host restores on the way back is which screen you were on, not what it was showing: arguments
your screen reads from a holder of your own (the VCS plugin's VcsNav.diff, say) are yours to key by screen
id if returning to the same screen twice in a run has to show two different things. The stack is capped at 32
entries, which no hand-driven flow reaches.
An app-wide floating layer rendered above every screen, for something that must appear regardless of where the user is, such as a permission prompt.
scope.overlay(OverlayContribution("hello.prompt") { ctx -> HelloPrompt(ctx.backend) })The body decides its own visibility: it typically observes a backend flow and renders nothing until there
is something to show. All registered overlays are composed by
AppOverlays.kt.
Beyond the built-in Code / Blocks / Preview / Split surfaces:
scope.viewMode(
EditorViewModeContribution(
id = "hello.hex",
label = "Hex",
appliesTo = { path -> path.endsWith(".bin") },
content = { ctx -> HexView(ctx.text) },
),
)appliesTo gates the mode per file, so it is only offered for files it can actually render. ViewModeContext
carries backend, filePath, and the live text.
The Preview (and Split) surface for a file kind the IDE has no pane for. The four built-in panes are the
Compose @Preview, Android XML layouts, resources and Markdown; a plugin adds a fifth for something else
entirely, such as a game scene or a shader.
ui.editorPreview(
EditorPreview(
id = "hello.scene",
title = "Scene",
appliesTo = { path -> path.endsWith(".scene.kt") },
content = { ctx -> ScenePane(ctx.path, ctx.text, ctx.dark, ctx::reportProblems) },
),
)appliesTo is asked per open file during composition, so keep it cheap. The built-in panes are consulted
first, which is what stops a plugin taking .xml away from the layout preview.
EditorPreviewContext carries path, the live text (the editor buffer, not the file on disk, so the body
recomposes as the user types), dark for the surface's scheme, and reportProblems. Problems go into the
same chip the built-in previews use; report on every pass, since an empty list is what clears it.
Combined with the interpreter (section 16), this is how a plugin renders what the user's code actually produces rather than a description of it.
scope.action(
SimpleUiAction(
id = "hello.openDetail",
text = "Hello detail",
places = setOf(UiActionPlaces.MORE_MENU, UiActionPlaces.COMMAND_PALETTE),
description = "Open the Hello detail screen",
iconId = "sparkle",
order = 200,
) { host -> host.openScreen("hello.detail") },
)UiActionHost is supplied by the shell at
invocation time: the action is a static contribution, while the host varies with the current screen. It
exposes backend, navigate(destination), toggleTheme(), openFile(path, offset), and message(text).
Named destinations live in UiDestinations (HUB, SETTINGS, MODULES, SDK, KEYSTORES, LOGS,
PROJECTS, DEPENDENCIES, CODE_STYLE, ICONS).
description is a subtitle for list-style menus; the "More" sheet renders it.
The IDE's own More-menu and palette entries are contributed exactly this way, through
BuiltInUiPlugin, which is the
reference example.
Icon ids are opaque strings both sides agree on, resolved by
actionIcon(iconId). An unknown id
falls back to a generic glyph, so a plugin naming an icon this build does not ship still renders.
Available today, grouped: run/play, stop, refresh/reindex, build/hammer, save, search/find,
settings/gear, terminal/console, copy, code, braces/codeStyle/format, file/doc, folder,
share, command, eye, image, sparkle/ai/chat, layers/modules, pkg/sdk, key/keystore/
signing, lightbulb/inspections/analysis, git/branch/vcs, commit/history, merge,
pullRequest, pull/fetch/download, push/upload, account/user/signIn, stash, close, plus.
File-tree icons are separate: scope.treeIcon(iconId, icon) registers into TreeIcons. Note that tree icons
are a persistent lookup, so the returned Registration is a no-op: nothing unregisters an icon.
An open editor tab carries one status dot: amber while the file has unsaved edits, and otherwise whatever a decoration reports. A decoration answers "does this tab need attention" without the user opening it.
scope.tabDecoration(
TabDecorationContribution("hello.tab.todos", order = 200) { tab ->
if (tab.path in TodoStore.flagged) TabDecoration(IconTint.Warning, "has TODOs") else null
},
)The dot is host-drawn, so a decoration is data rather than a @Composable body: a themed IconTint (use
IconTint.Fixed(color) only when the color itself carries meaning), a TabDotStyle, and a label for
accessibility. The producer, though, is composable, so it reads state and the strip re-decorates when those
reads change:
| Member | Purpose |
|---|---|
path, name |
The tab's workspace path (library://… for a library tab) and displayed name |
active, modified |
Whether the tab is focused, and whether it has unsaved edits |
staleOnDisk |
The file changed on disk while the tab held unsaved edits, so the host did not reload it |
diagnostics, errorCount, warningCount |
The diagnostics anchored to the tab's buffer |
backend |
The engine, for a producer whose state lives behind it (a build, version control, your own service) |
Three constraints come with the slot:
- One tab, one dot. Producers are asked in
order, low first, and the first non-null claims the tab. The built-ins occupy 50 (changed on disk), 100 (analysis errors and warnings) and 120 (build errors), so a lower order outranks them and a higher one fills in where they decline. The Git plugin's conflict dot goes above all of them at 40; its working-copy dot below at 200. - Two shapes, so the colors go further.
TabDotStyle.Filledis something to act on now (errors, a file that changed underneath you);Outlinedis a standing property of the file (warnings, differing from HEAD). A filled amber dot already means unsaved edits, so do not spend it on anything else. - Decide, do not work.
decorateruns once per open tab on every recomposition of the strip. Subscribe where the state is produced (AnalysisTopics.ANALYSIScarries a file's merged diagnostics to the engine facet), keep the answer in an observable store, and read that store here.
diagnostics is the tab's last analysis result. Every open tab is analyzed, not just the focused one
(OpenTabDiagnostics.kt
sweeps the rest when a tab opens and again whenever indexing or a build settles), but a background tab is not
re-analyzed while it sits there, so its diagnostics are as old as the last of those events. A decoration that
needs more than that reads a source with it, the way the built-in build dot reads
backend.build.buildState.
A UI plugin module is Compose Multiplatform with a desktop (JVM) and an Android target. Depend on :ide-ui,
which re-exposes :ide-ui-api via api, so the UiPlugin SPI and the shell's design system both come
through:
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.android.kmp.library)
alias(libs.plugins.compose)
alias(libs.plugins.kotlin.compose)
}
kotlin {
jvm("desktop") { compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } }
android {
namespace = "dev.ide.hello.ui"
compileSdk = 36
minSdk = 24
compilerOptions { jvmTarget.set(JvmTarget.JVM_17) }
}
sourceSets {
commonMain.dependencies {
implementation(project(":ide-ui"))
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.ui)
implementation(compose.components.resources)
implementation(libs.kotlinx.coroutines.core)
}
}
}services/vcs-ui/build.gradle.kts is the reference. Two conventions it follows:
- Talk to the engine only through
IdeBackend. No:ide-core, nojava.nio, no engine types. - Keep your strings in your own module.
compose.resources { publicResClass = false; packageOfResClass = "dev.ide.hello.ui.generated.resources" }keeps the generated accessor a private detail. See localization.md. Note that Compose Resources renders\'literally, so write'directly.
Everything above is the built-in UI model (dev.ide.ui.ext.UiPlugin), which lives in ide-ui-api.
A plugin shipped as its own app uses a second, narrower interface instead: dev.ide.plugin.ui.UiPlugin, from
the published plugin-ui-api. Same idea, deliberately smaller surface:
Built-in (ide-ui-api) |
Installed (plugin-ui-api) |
|
|---|---|---|
| Entry point | UiPlugin.contributeUi(scope) |
UiPlugin.contribute(ui) |
| Declared in | BuiltInPlugin(engine, ui) in BuiltInPlugins.kt |
uiEntryPoints in the packaged manifest |
| Contributions | tool windows, screens, overlays, view modes, host actions, tab decorations, tree icons, editor languages | tool windows, screens, overlays, view modes, editor previews, editor layers, editor painters, editor languages |
| A body is handed | ToolWindowContext etc., carrying the whole IdeBackend |
UiContext: active file, project path, openFile, openScreen |
| Teardown | Registration |
UiHandle |
The narrow context is the point. Publishing the built-in model would freeze IdeBackend, every concern
service and DTO in it, as plugin API, and that is the layer that changes most. An installed plugin does not
need it: its engine facet has the whole engine SPI (files, project model, indexes, analysis, the message
bus) and shares its classloader, so the panel calls into its own plugin for anything real and asks the host
only for what is a property of the running UI.
The host adapts one model onto the other in
ExternalUiPlugin.kt, so a
contributed panel lands in the same registries a built-in's does and the shell renders both the same way. Ids
are not namespaced on the way through, which is what lets an engine-side action return
ActionEffect.Navigate("com.example.screen") and open the UI facet's own screen.
Working against that narrow context in practice (what to call instead of reaching for the host, where to put the state the panel shows, and what runs on which thread) is plugin-facet-communication.md.
Loading and gating: the facets are instantiated off the classloader the engine facet was loaded from, only
for a plugin that is enabled, consented to, and whose engine facet loaded cleanly. Each class the manifest
names is instantiated once, so naming one class in both lists gives you one object rather than two halves
with separate copies of every field. A UI facet that is missing, is not a UiPlugin, or throws while being
created is reported on that plugin's row in the Plugins screen; its engine facet keeps running, which is the
reason to keep them two classes when they really are separate. Packaging, the Compose pins and the two-facet pairing are in
section 15.
Version control is the most complete plugin in the tree: it brings its own engine, its own UI, its own extension point, a settings page, a backend service, and a full enable/disable path. Deeper coverage of the Git engine itself is in version-control.md.
| Module | Contains | Why it is separate |
|---|---|---|
:vcs-api |
VcsProvider, VcsRepository, the model, credentials, forge ports, VCS_PROVIDER_EP |
A neutral SPI other providers and consumers compile against without pulling JGit |
:vcs-impl |
GitProvider, GitRepository (JGit), GitHubClient, FileAccountStore |
The engine and its heavy dependency, isolated from everything that only needs the model |
:vcs-ui |
VcsUiPlugin, GitPanel, and the seven screens |
Compose Multiplatform; cannot live in an engine module |
:ide-core |
VcsPlugin (engine facet), VcsBackend (the VcsService impl) |
The host wiring point where the plugin joins the app |
This api / impl / ui split is the repository convention, and it exists so the dependency direction stays acyclic. See modules.md.
interface VcsProvider {
val id: String
val displayName: String
fun findRoot(dir: Path): Path?
fun open(root: Path): VcsRepository
fun init(dir: Path, defaultBranch: String = "main"): VcsRepository
fun clone(url: String, target: Path, branch: String? = null, depth: Int = 0,
auth: VcsCredentials? = null, progress: VcsProgress = VcsProgress.None): VcsRepository
}
val VCS_PROVIDER_EP = ExtensionPoint<VcsProvider>("platform.vcsProvider")Why it exists: so support for Mercurial or Fossil is one more registration rather than a host change. The host asks every registered provider whether a directory is a checkout it owns and uses the first that answers.
Why the built-in Git provider is not registered on it: GitProvider needs the resolved config directory,
which is not known until a project manager exists. So VcsBackend resolves the EP first and falls back to
constructing GitProvider itself:
private val provider: VcsProvider? by lazy {
ctx.manager?.env?.platform?.extensions?.extensions(VCS_PROVIDER_EP)?.firstOrNull()
?: configDir?.let { GitProvider(it) }
}The pattern is that the extension point is the seam for additions, while a context-heavy built-in stays a
concrete field. The same reasoning applies to BUILD_SYSTEM_EP, where the built-in Java and Android build
systems stay per-project engine fields while the EP carries plugin additions.
ide-core/VcsPlugin.kt, in full:
internal class VcsPlugin : Plugin {
override val manifest = PluginManifest(
id = ID,
name = "Version Control",
description = "Git for your projects: changes, commits, branches, history, and GitHub sign-in.",
)
override fun register(reg: PluginRegistration) {
reg.register(SETTINGS_PAGE_EP, VcsSettingsPage)
}
companion object {
const val ID = "vcs"
const val PAGE = "vcs"
const val PREF_USER_NAME: String = "settings.$PAGE.userName"
const val PREF_USER_EMAIL: String = "settings.$PAGE.userEmail"
const val PREF_CLIENT_ID: String = "settings.$PAGE.githubClientId"
}
}Points that generalise to any plugin:
- The engine facet is small. It registers one settings page. It is not where the feature lives; it is where the feature's identity lives. The manifest here is what the catalog, the Plugins screen, and both gating checks key off.
- Preference keys are constants derived from the page id.
VcsBackendreadsctx.manager?.preference(VcsPlugin.PREF_USER_NAME); nothing repeats the string. internalis fine. A built-in plugin does not need to be public; onlyBuiltInPluginsreferences it.
The working copy is served by a concern backend: a VcsService implementation over the neutral UI port.
IdeServicesBackend wires it and gates it
on the plugin being enabled:
override val vcs: VcsService =
if (manager?.env?.pluginCatalog?.isEnabled(VcsPlugin.ID) != false) VcsBackend(this)
else VcsService.UnsupportedThree details that matter:
VcsService.Unsupportedis a real, complete no-op implementation, not a null. Every method onVcsServicehas a default, so the UI never branches on nullability; it askssupported()and hides the surface.!= falserather than== true: a manager-less backend (tests, a single-project harness) has no catalog, so the feature stays wired instead of silently vanishing in tests.- The gate is on the id constant, so renaming the plugin id is a compile-time change, not a silent behaviour change.
VcsBackend itself demonstrates the engine-side rules a plugin service should follow: everything crosses as
plain DTOs so the UI never sees a JGit type; shared reads are StateFlows the backend refreshes; one-shot
commands are suspend and return a result carrying a message already fit to show; and repository access is
serialized behind a mutex because JGit commands are not safe for concurrent use on the same repository.
vcs-ui/VcsUiPlugin.kt, also in full:
object VcsUiPlugin : UiPlugin {
override val id: String = "vcs-ui"
override fun contributeUi(scope: UiContributionScope) {
scope.toolWindow(
ToolWindowContribution(
id = LeftPanelId.SOURCE, // the shell's source-control rail slot
title = "Git",
iconId = "git",
anchor = ToolWindowAnchor.LEFT,
order = 40,
content = { ctx -> GitPanel(ctx) },
),
)
scope.screen(ScreenContribution(VcsService.SCREEN_BRANCHES, "Branches") { ctx -> BranchesScreen(ctx) })
scope.screen(ScreenContribution(VcsService.SCREEN_HISTORY, "History") { ctx -> HistoryScreen(ctx) })
scope.screen(ScreenContribution(VcsService.SCREEN_DIFF, "Diff") { ctx -> DiffScreen(ctx) })
scope.screen(ScreenContribution(VcsService.SCREEN_ACCOUNTS, "Accounts") { ctx -> AccountsScreen(ctx) })
scope.screen(ScreenContribution(VcsService.SCREEN_CLONE, "Clone") { ctx -> CloneScreen(ctx) })
scope.screen(ScreenContribution(VcsService.SCREEN_STASHES, "Stashes") { ctx -> StashesScreen(ctx) })
scope.screen(ScreenContribution(VcsService.SCREEN_GITHUB, "GitHub") { ctx -> GitHubScreen(ctx) })
}
}Why it is shaped this way:
- One panel, seven screens. The panel is the entry point; everything reached from it is a full screen rather than a nested sheet, because a sidebar panel cannot hold a branch list, a commit history, or a diff on a phone.
- It registers under
LeftPanelId.SOURCE, the shell's source-control slot, so it takes the rail position the placeholder used to hold and the phone bottom-nav slot that maps to it. Nothing else claims that id. This is the mechanism for taking over a well-known host slot, and the host does not special-case Git anywhere. - The screen ids are constants on
VcsService, so the engine'sauthRequiredresult and the UI'sopenScreencall cannot drift apart.
The panel body is an ordinary composable over ToolWindowContext:
internal fun GitPanel(ctx: ToolWindowContext) {
val vcs = ctx.backend.vcs
val status by vcs.status.collectAsState()
val activity by vcs.activity.collectAsState()
// The panel may be opened long after the last file-system change, so re-read on entry rather than
// trusting the cached snapshot.
LaunchedEffect(ctx.backend, hasProject) { vcs.refresh() }
fun perform(block: suspend () -> UiVcsResult) {
scope.launch {
val result = block()
if (result.message.isNotBlank()) feedback.show(result.message, isError = !result.ok)
if (result.authRequired) ctx.openScreen(VcsService.SCREEN_ACCOUNTS) // engine → UI navigation
}
}
// …
}Everything above joins the app through three edits outside the plugin's own modules:
// settings.gradle.kts: inside the CI_CORE_ONLY guarded block, because vcs-ui applies Compose + AGP
":vcs-ui",
// app/ide-core/build.gradle.kts
implementation(project(":vcs-impl"))
implementation(project(":vcs-ui"))
// ide-core/BuiltInPlugins.kt
BuiltInPlugin(VcsPlugin(), ui = VcsUiPlugin),That single BuiltInPlugin line is the whole registration. Turning the toggle off in Settings → Plugins
and restarting removes the settings page, the VcsService (the UI gets Unsupported), the Git rail panel,
the phone nav slot, and all seven screens, with no feature-specific code anywhere in the shell.
The agent is a smaller contrast to Git: the same two-facet structure, different UI surfaces.
object AgentUiPlugin : UiPlugin {
override val id: String = "agent-ui"
override fun contributeUi(scope: UiContributionScope) {
scope.toolWindow(
ToolWindowContribution(
id = "agent.chat",
title = "AI",
iconId = "sparkle",
anchor = ToolWindowAnchor.RIGHT,
content = { ctx -> ChatDrawer(ctx.backend) },
),
)
scope.overlay(
OverlayContribution("agent.permission") { ctx -> AgentPermissionDialog(ctx.backend) },
)
}
}| Difference from Git | Why |
|---|---|
RIGHT anchor rather than LEFT |
Chat is a companion to the editor, not a navigator. It gets the desktop right rail and the phone right-edge swipe drawer |
| An overlay rather than screens | The write-permission prompt must appear over any screen while a mutating tool call waits for approval. The overlay body observes backend.agent.permissionRequest and renders nothing otherwise |
| Its own id rather than a host slot id | Nothing in the shell reserves a slot for chat, so it introduces one |
The engine facet, AgentPlugin, mirrors
VcsPlugin exactly: one settings page (provider choice and API keys), an ID constant, and the same
isEnabled(AgentPlugin.ID) gate on IdeBackend.agent.
Because the RIGHT surfaces are fully plugin-derived, disabling the agent leaves the right rail and the swipe drawer rendering nothing at all, because the host has no chat-specific chrome to hide. See agentic-coding.md.
Settings → Plugins lists every plugin with its name, version, description, and a toggle, under two tabs. Built-in plugins ship inside the IDE; Installed plugins came from a separate app the user installed, and each of those rows also carries the package it came from and, if it did not load this launch, why. Each tab label carries its count, so an installed plugin is visible without switching tabs. Essential plugins show a locked "Required" pill, which never applies to an installed plugin.
Nothing on this screen is live, because plugins are loaded once per process. So the screen carries a hint
naming everything a restart would apply, and a Restart now button that applies it. What lands in that
list is both the answers given here and what has happened to the plugin apps on the device since launch:
PluginPackageWatcher (in :ide-android) watches the package manager and records an install, an install over
an existing plugin, or an uninstall on
PluginChanges. A change is listed only
when a restart would load something different, so toggling a plugin off and back on leaves nothing waiting.
The restart takes every process of the app down, including the :build daemon and :preview, since each one
loaded the installed plugins for itself; modified editor buffers are written to disk first.
The state flows: PluginsScreen → SettingsService.setPluginEnabled(id, enabled) →
SettingsBackend →
ProjectManager.setDisabledPlugins(...), persisted app-globally in prefs.properties under the key
plugins.disabled. At the next launch ApplicationEnvironment builds the catalog over the built-in manifests
plus whatever its PluginSources discovered, and loads only the enabled subset. A disabled plugin is dropped
before its code is touched at all, so an installed one never even gets a classloader.
A plugin disables cleanly when nothing it contributes is loaded and no host code has to know it exists. The recipes, in order of preference:
| Your surface | How it disappears |
|---|---|
| An EP contribution | Automatically: the plugin is never loaded, so it never registers |
| A UI facet | Automatically: enabledUiPlugins filters it out before UiPluginHost sees it |
An IdeBackend concern service |
Gate the field: if (catalog?.isEnabled(MyPlugin.ID) != false) MyBackend(this) else MyService.Unsupported |
Hard-coded UI that is not a UiPlugin |
Expose a capability flag on a backend service, read it once into IdeUiState, and render conditionally |
The last row is the fallback path, and the block editor is the worked example: a togglable plugin whose
surface is a hard-coded editor toggle rather than a UiPlugin. It works like this. BlocksPlugin registers
the only BLOCK_MAPPING_EP contribution. The generic BlockService is inert with no mapping, and exposes an
enabled flag that is true only while BLOCK_MAPPING_EP has contributions. The shell reads that once into
IdeUiState.blocksEnabled, which is safe because the plugin set is app-global and restart-applied, and hides
the Code/Blocks toggle. Follow that shape rather than checking a plugin id in the UI layer.
Declare an edge when a contribution's position in a registration-ordered EP matters, or when the plugin relies on another plugin's types or services existing.
override val manifest = PluginManifest(
id = "kotlin-language",
name = "Kotlin Language",
dependsOn = listOf("jdt-language"),
)Consequences, all of them intentional:
PluginManagerloadsjdt-languagefirst, always.- If
jdt-languageis missing from the assembled set,loadAllthrows at startup rather than misbehaving later. - If the user disables a dependency,
PluginCatalogdrops every transitive dependent too, so the load graph stays valid and no plugin runs against a missing prerequisite. - If a dependency is
essential, the catalog force-enables it and everything it depends on.
essential = true removes the user's choice, so it needs a real justification. The current essentials are
platform (the file-icon classifier and base file types), jdt-language and java-psi-language (the default
language backend and resolution fallback), and ide-core-services (the engine's scoped services). If the IDE
would merely be worse without the plugin, it is not essential.
Plugins are testable without launching the app, which is why the SPI stays free of Compose and of the engine.
@Test
fun `loads in dependency order regardless of declaration order`() {
val reg = ExtensionRegistryImpl()
val order = mutableListOf<String>()
PluginManager(reg).loadAll(
listOf(
FakePlugin("b", dependsOn = listOf("a"), loadOrder = order),
FakePlugin("a", loadOrder = order),
)
)
assertEquals(listOf("a", "b"), order)
assertEquals(listOf("a-impl", "b-impl"), reg.extensions(EP))
}@Test
fun `unload removes exactly the plugin's own contributions`() {
val reg = ExtensionRegistryImpl()
val mgr = PluginManager(reg)
mgr.loadAll(listOf(FakePlugin("a"), FakePlugin("b")))
mgr.unload(PluginId("a"))
assertEquals(listOf("b-impl"), reg.extensions(EP))
}Test the catalog directly, since it is pure:
val catalog = PluginCatalog(manifests, disabledIds = setOf("hello"))
assertFalse(catalog.isEnabled("hello"))
assertFalse(catalog.isEnabled("hello-extras")) // transitively depends on hello
assertTrue(catalog.isEnabled("platform")) // essential, force-enabledRegistries are process-global, so register in the test and dispose in a finally:
@Test
fun leftToolWindowRegisters() {
val reg = ToolWindowRegistry.register(
ToolWindowContribution("test.explorer", "Explorer", "folder", ToolWindowAnchor.LEFT) {}
)
try {
assertTrue(ToolWindowRegistry.forAnchor(ToolWindowAnchor.LEFT).any { it.id == "test.explorer" })
} finally {
reg.dispose()
}
}For rendering, :ide-ui desktop tests snapshot composables headlessly with ImageComposeScene and a
StubBackend. See ExtRegistryTest.
| Test | Covers |
|---|---|
PluginManagerTest |
Load order, unload, facade sweep, service registration |
PluginCatalogTest |
Essentials, disabled closure, dependents |
PluginBusLoggerTest |
Bus publish/subscribe and log attribution through the registrar |
ActionManagerTest |
Place resolution, menu expansion, dispatch |
ExtRegistryTest |
Tool-window anchors and palette resolution |
:ide-coreis excluded underCI_CORE_ONLY; run its tests with that flag unset.IdeAction.performissuspend; drive it withrunBlockingin tests.
interp-api lets a plugin run the code in the user's project. It is what makes a preview of a framework the
IDE knows nothing about possible: instead of parsing the user's source and drawing an approximation, the
plugin runs it and shows the result.
Two kinds of session, matching the two interpreters the IDE has.
class GamePlugin : Plugin {
override val manifest = PluginManifest(
id = "libgdx",
name = "LibGDX",
capabilities = listOf(PluginCapabilities.INTERP_RUN),
dependsOn = listOf("interpreter"),
)
private var services: ServiceLookup = ServiceLookup.Empty
override fun register(reg: PluginRegistration) { services = reg.appServices }
/** Called from the plugin's own preview pane, on every edit. */
fun preview(path: Path, buffer: String): ApplicationListener? {
val interp = services.getServiceOrNull(CODE_INTERPRETER) ?: return null
val program = when (val r = interp.lower(LowerRequest(path, entry = "MyGame", text = buffer))) {
is LowerResult.Lowered -> r.program
is LowerResult.NotReady -> return null // retry; do not report
is LowerResult.Failed -> { show(r.problems); return null }
}
val session = interp.openSource(
program,
InterpretConfig(libraryLoader = javaClass.classLoader),
)
return session.instantiate("MyGame").proxy(ApplicationListener::class.java)
}
}Three things in that example carry most of the weight:
dependsOn = ["interpreter"], so a user who disables the interpreter disables what depends on it rather than leaving it half-working, andLowerResult.NotReadyis a retry rather than a failure. A module's Kotlin classpath index builds in the background, so the first lower after a project opens legitimately answers "not yet".libraryLoader = javaClass.classLoaderbridges the framework the plugin bundles in its own APK as real, dexed code, leaving only the user's own source interpreted. This is the difference between a preview that runs at a usable speed and one that does not, and it needs no dynamic loading of anything.proxy(ApplicationListener::class.java)hands the interpreted object to code that has no idea an interpreter is involved. An interpreted object is not an instance of anything, so a framework that wants to own an object's lifecycle needs this.
For a plugin that builds first, or that needs code with no source in the project:
val session = interp.openBytecode(
BytecodeConfig(
classpath = module.classpath(DependencyScope.RUNTIME).entries.map { it.path },
interpretPrefixes = listOf("com.example."), // the user's code
libraryLoader = javaClass.classLoader, // the framework, real
)
)
session.construct("com.example.MyGame").call("create")The VM reads .class bytes off that classpath; nothing is dexed and no class loader is handed the user's
code. interpretPrefixes is worth setting: it keeps the VM from parsing a whole framework it could have
bridged instead.
There are two ways in, and the first is usually the right one.
Reuse a built-in id. A RunTaskSpec whose id carries a built-in prefix (build:, run:, assemble:) is
dispatched by the host's own pipeline, which builds the graph, runs it and streams the console. Such a
provider needs no actionFor at all:
override fun tasksFor(module: Module): List<RunTaskSpec> =
listOf(RunTaskSpec("run:${module.name}", "Run ${module.name}", group = "run"))Or build your own graph, when the run needs steps the host's pipeline does not have. BuildContext
carries the interpreter for it:
override fun actionFor(spec: RunTaskSpec, project: Project, module: Module, ctx: BuildContext): RunAction? {
val interpreter = ctx.programInterpreter ?: return null // no runner on this host
val run = InterpretExecTask(TaskName(":run"), mainClass, ::classpath, interpreter)
return RunAction(header = "Run ${module.name}", graph = OneTask(run))
}
/** `TaskGraph` is published; the engine's builder for it is not, so a plugin implements the three members. */
private class OneTask(private val task: Task) : TaskGraph {
override val tasks = listOf(task)
override fun dependencies(t: Task) = emptyList<Task>()
override fun topologicalLevels() = listOf(tasks)
}ProgramIo also models a windowed program: implement frame and windowed and the host draws the
program's frames and forwards pointer and key events, which is how the AWT/Swing support works.
- Speed. Only the user's own code should be interpreted; bridge the rest. A warm interpreted call is single-digit milliseconds on a device, the first call into a large interpreted jar pays a one-time parse, and fully interpreted UI at 60 fps is not reachable. Prefer rendering a frame per edit over an open loop.
- Bounds, not isolation. The source interpreter aborts a call that exceeds its recursion depth or wall-clock deadline, and a bytecode session can be cancelled. It runs in the IDE's process, though, so a plugin preview is not insulated the way the built-in Compose preview (which renders in its own process) is.
- The sandbox. A session defaults to the project's own preview sandbox, so a plugin's preview is held to
the rules the user configured rather than getting more access to the device than the built-in one has.
InterpretConfig.sandboxcan widen or narrow it, andInterpretHookscan refuse or stand in for individual calls: a fixed clock, a stub asset loader, a canvas of the plugin's own.
docs/plugin-interpreter.md covers the design, including what is deliberately not exposed and why.
Writing a file into a module is easy. Getting the IDE to see it is the part a plugin cannot do on its own:
the build's staleness check, the indexes, the synthetic classes (Android's R, ViewBinding) and the editor's
resolution all hang off the workspace's file-event stream, and a plugin that writes with java.nio and stops
there has produced a file nothing knows about until the next full reopen.
MODULE_RESOURCES (dev.ide.model.ModuleResources, WORKSPACE) is the write that publishes.
resourceRoots and putResourceFile are phrased in ContentRole, so they are not about Android:
val resources = workspace.service(MODULE_RESOURCES)
when (val write = resources.putResourceFile(
module,
ContentRole.RESOURCE, // src/main/resources on a JVM module
"META-INF/services/dev.ide.Thing", // may name directories; a path that escapes the root is refused
text,
onConflict = ResourceConflict.FAIL, // or RENAME (suffixes the base name) / REPLACE
)) {
is ResourceWrite.Written -> log.info("wrote ${write.file}")
is ResourceWrite.AlreadyExists -> log.info("left ${write.file} alone")
ResourceWrite.NoResourceRoot -> log.info("this module type declares no such root")
else -> log.warn("not written: $write")
}ContentRole is open, so the role can be RESOURCE, ASSETS, ANDROID_RES, or one your own module type
declares. resourceRoots(module, role) returns the module's own roots for that role with the main source
set's first, which is where a write lands: a file authored into src/debug/res is missing from every build
that does not select that variant. A module that declares no root of the role answers NoResourceRoot, so a
plugin sweeping a mixed project does not have to ask what kind of module it landed on. There is a ByteArray
overload for content that is not text.
If the module has no root yet, MODULE_SOURCES declares one (addSourceRoot) and the two compose.
Give a when over ResourceWrite an else: an outcome added in a later SPI minor must not stop your plugin
compiling.
On top of that, the same service speaks @type/name. The read side is the merged, buffer-aware repository the
IDE's own reference resolution and synthetic R read, so you see what the editor sees, a <string> typed into
an open res/ file and not yet saved included:
resources.has(module, "string", "app_name") // dependency modules and AARs included
resources.names(module, "string") // what R.string exposes
resources.find(module, ResourceFilter(rClass = "color", namePrefix = "brand_"))
resources.find(module, ResourceFilter(nameContains = "title", limit = 20))
resources.find(module, ResourceFilter(rClass = "style", allConfigs = true)) // one entry per configResourceFilter.moduleOnly narrows to what the module declares itself. That is a different question from
has: overriding a library's @string/app_name is what an override is, so "defined somewhere on the
classpath" must not be read as "taken".
The write side is the code behind the editor's own "Create @string/…" fix:
resources.putValueResource(module, "string", "greeting", "Hello") // res/values/strings.xml
resources.createResourceFile(module, "layout", "activity_detail") // res/layout/…, with a valid stubputValueResource picks the file the type conventionally lives in (strings.xml, colors.xml,
dimens.xml, else values.xml), escapes the value, and creates the file if it is not there.
isValueType / isFileType say which of the two a given R class belongs to. Conflicts are judged against
what the module declares itself in the default config, which is where the write goes.
A module whose type is not Android's answers empty from every query here and NoResourceRoot from every
write, rather than throwing.
These writes go to disk and publish, which means they write through an open editor buffer of the same
file rather than through it. When the target is a file the user is looking at, use the editor tier instead:
an ActionEffect.ApplyWorkspaceEdit from a UI_ACTION_EP action, or a WorkspaceEdit from a QuickFix,
both of which go through the editor's own text path and land in the same undo step as typing.
Everything above is the internal tier, where a plugin is a module inside the IDE. A plugin can instead be a
separate Android app the user installs: the IDE finds it through the package manager, reads its manifest,
and loads its classes off the installed APK. The Plugin you wrote does not change; only its packaging does.
A complete, buildable example of everything in this section is
samples/hello-plugin: ./gradlew :samples:hello-plugin:installDebug. Its
README also covers what changes when the plugin is a project of its own
rather than a module of this build.
You can also write one inside CodeAssist itself. New project > Plugin > CodeAssist Plugin scaffolds
everything below, wired up: it asks for a plugin id and what to contribute (a command, a settings page, both,
or a tool window panel), then generates the packaged manifest, the marker activity, and the entry point,
with the SPI already declared as a compileOnly dependency. Choosing the panel generates a UI facet instead,
and adds plugin-ui-api plus the Compose artifacts the IDE bundles, pinned; the Compose compiler needs no
declaration, since CodeAssist applies it to any module whose classpath carries the Compose runtime. While
editing codeassist_plugin.toml the IDE runs the same checks the loader makes (a malformed id, an
apiVersion this build does not load, a minHostVersion newer than the running IDE, an entry point that
names no class or a class that does not implement Plugin / UiPlugin, a missing marker activity), and
completes the manifest's keys, the plugin ids available to dependsOn, and the Plugin and UiPlugin
implementations in the project.
It also checks capabilities, which is the list the user reads at the consent gate when deciding whether to
let the plugin run at all: a value the IDE does not recognise is flagged, because it is shown to that user
exactly as written, and so is one whose facet the plugin does not declare (ui.toolWindow with no
uiEntryPoints cannot be delivered). The vocabulary is PluginCapabilities in the SPI.
Watching your plugin run. Anything your plugin logs through reg.logger(...) is attributed to its plugin
id, and Settings > Plugins offers Logs on an installed plugin's row, which opens the Logs viewer
filtered to that plugin. That is the difference between reading your own output and reading the whole IDE's.
Three things go into the plugin app, and a fourth if it has UI.
1. The plugin manifest, as res/raw/codeassist_plugin.toml. This is PluginManifest in TOML, and it is
what the IDE reads to build its catalogue, so it must agree with what your entry point contributes:
[plugin]
id = "com.example.hello"
name = "Hello"
version = "1.0.0"
apiVersion = 2
description = "Adds a Hello command and a settings page."
entryPoints = ["com.example.hello.HelloPlugin"]
uiEntryPoints = ["com.example.hello.HelloUiPlugin"] # optional; the Compose UI facet
dependsOn = ["kotlin-language"]
capabilities = ["ui.action", "ui.settingsPage", "ui.toolWindow"]
minHostVersion = "3.11.0"The two entry-point lists are independent: declare either, or both. A plugin with only uiEntryPoints is a
complete plugin (it loads, holds its place in the load order, and contributes its UI); a plugin with neither
is rejected.
id must match [A-Za-z0-9][A-Za-z0-9._-]*, the same shape as an applicationId or a Java package, which
is normally where it comes from. Case is part of the id, so write it the same way wherever another plugin
names it in dependsOn; two plugins whose ids differ only in capitalisation count as a clash and the second
is rejected. apiVersion must match the IDE's PLUGIN_API_VERSION, and minHostVersion is compared against
the running IDE's version. Anything wrong here, including an unparseable file or an id another plugin already
holds, is reported on the plugin's row in the Plugins screen rather than failing silently; a plugin listed
there with a reason and no switch is one the IDE found but could not read.
essential and trusted are ignored here: those are the IDE's to decide.
2. A marker activity in the app's AndroidManifest.xml, which is how the IDE finds the app at all, and
which doubles as your app's own screen:
<activity android:name=".PluginInfoActivity" android:exported="true">
<intent-filter>
<action android:name="dev.ide.codeassist.action.PLUGIN" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<meta-data android:name="dev.ide.codeassist.plugin.manifest"
android:resource="@raw/codeassist_plugin" />
</activity>4. A UI facet, if the plugin has UI. Implement dev.ide.plugin.ui.UiPlugin from plugin-ui-api and name
it in uiEntryPoints:
class HelloUiPlugin : UiPlugin {
override val id = "com.example.hello" // the packaged manifest's id
override fun contribute(ui: UiRegistration) {
ui.toolWindow(
ToolWindow(
id = "com.example.hello.panel",
title = "Hello",
iconId = "sparkle", // an id in the IDE's icon registry
anchor = ToolWindowAnchor.LEFT,
) { ctx -> HelloPanel(ctx) },
)
}
}
@Composable
private fun HelloPanel(ctx: UiContext) {
Text(ctx.activeFilePath?.let { "Editing ${it.substringAfterLast('/')}" } ?: "No file open")
}The two facets need not be two classes. One class may implement both and be named in entryPoints and in
uiEntryPoints; the IDE instantiates it once, so the halves share ordinary fields and the panel reads
whatever register set up. Keep them apart when they really are apart: two classes fail independently, so a
UI facet that throws is reported against your plugin while its engine half goes on working, where one class
means one failure takes both.
Either way both are instantiated off the same APK on the same classloader, so they are ordinary Kotlin to
each other: with two classes a shared object is the whole channel between them, with nothing to serialise
and no extension point in the middle. (Two different plugins cannot do this, since each gets its own
classloader, and the message bus is the channel there.) samples/hello-plugin shows the pairing: the palette
command writes to a HelloState object and the panel reads it.
Compose has to be declared, and pinned to what the IDE bundles, because your @Composable code composes into
the IDE's own runtime:
dependencies {
// The BOM carries the versions, including the Compose the IDE provides.
compileOnly(platform("io.github.tyron12233:plugin-bom:2.10.0"))
compileOnly("io.github.tyron12233:plugin-ui-api")
compileOnly("androidx.compose.runtime:runtime")
compileOnly("androidx.compose.foundation:foundation")
compileOnly("androidx.compose.ui:ui")
compileOnly("androidx.compose.material3:material3")
compileOnly("androidx.compose.ui:ui-tooling-preview") // for @Preview; see below
}Writing the versions out (androidx.compose.runtime:runtime:1.11.2, and so on) works too, and is what a
project scaffolded inside the IDE does, since it builds on-device. The BOM is there so that a plugin built
with Gradle outside the IDE has one number to get right instead of thirteen.
Two consequences worth knowing before you build one:
- A Compose version newer than the IDE's fails at first composition, not at build time.
apiVersionandminHostVersioncannot catch it: it is a linkage error inside a body that has already been registered. Compile against these versions, and against the Kotlin the IDE was built with, so the Compose compiler's output matches the runtime it lands on. - A plugin has no
Contextfor its own package. No drawables, nostringResource: icons are ids in the IDE's registry and text is Kotlin string literals.
A panel body takes a UiContext, so previewing one means having a context outside the IDE. UiContext
provides it, and the rest is an ordinary @Preview rendered by the editor's preview pane:
@Preview
@Composable
private fun HelloPanelPreview() {
HelloPanel(UiContext.preview(activeFilePath = "MainActivity.kt"))
}
@Preview
@Composable
private fun HelloPanelNoFilePreview() {
HelloPanel(UiContext.preview())
}preview() answers the projectPath and activeFilePath you give it, and its openFile, openScreen and
back do nothing, because a preview has no editor to open a file in. ScreenUiContext.preview() is the same
thing for a contributed screen. The two states above are the ones worth having as separate previews: a panel
that reads the active file is usually written against the case where there is one, and looks wrong in the
case where there is not.
The scaffolded panel comes with a preview already, and samples/hello-plugin has two. This is the whole of
what previewing gives you: the body, composed. The rail, the panel frame and the plugin's registration are
not part of it, so an id or an anchor that is wrong still shows up only once the plugin is installed.
The engine SPI is published, so the extension points in these modules are available to a plugin app:
compileOnly(platform("io.github.tyron12233:plugin-bom:2.10.0")) // one version for everything below
compileOnly("io.github.tyron12233:plugin-api") // actions, menus, palette commands, editor events
compileOnly("io.github.tyron12233:platform-core") // scoped services, settings pages, logging
compileOnly("io.github.tyron12233:project-model-api") // module types, templates, facets + codecs, file icons, project events
compileOnly("io.github.tyron12233:language-api") // file types, completion, postfix, compilation contexts
compileOnly("io.github.tyron12233:analysis-api") // analyzers, diagnostics, quick fixes, intentions, analysis events
compileOnly("io.github.tyron12233:index-api") // persisted indexes, indexing events
compileOnly("io.github.tyron12233:build-api") // build systems, build plugins, tasks, source generators, build/run events
compileOnly("io.github.tyron12233:plugin-ui-api") // tool windows, screens, overlays, editor languages (see part 4)
compileOnly("io.github.tyron12233:vcs-api") // version-control providers
compileOnly("io.github.tyron12233:agent-api") // agent tools, workspace, LLM providers
compileOnly("io.github.tyron12233:block-api") // block-editor mappingsTake only the ones you use; each brings the ones below it transitively (plugin-ui-api brings nothing: it
depends on no other CodeAssist module).
The Compose UI surfaces are reachable, but through a different, narrower model than the built-in one in
section 10: see part 4 below and section 10.11.
What is not reachable from an installed plugin is the internal ide-ui-api model itself: UI host actions and
tab decorations, which are built-in-only because they hand a body the whole IdeBackend.
3. Your plugin classes, compiled against the plugin SPI as compileOnly. They implement Plugin,
dev.ide.plugin.ui.UiPlugin, or both, and they do not declare a PluginManifest: the TOML above is this
plugin's identity, and the IDE has read it before your class is instantiated. The IDE's classloader is the
parent of your plugin's, so the SPI, the Kotlin stdlib, and the Compose runtime resolve to the IDE's copies.
Bundling your own copy of any of them does nothing: the parent wins, and shipping a mismatched version is how
you get a linkage error reported against your plugin.
The SPI is published, so it is an ordinary dependency:
dependencies {
compileOnly(platform("io.github.tyron12233:plugin-bom:2.10.0"))
compileOnly("io.github.tyron12233:plugin-api")
compileOnly("io.github.tyron12233:platform-core")
}Upgrading a plugin written against 1.x? See
Migrating a plugin to SPI 2.0.0: PLUGIN_API_VERSION moved to 3, so an
older plugin is refused at the gate, and recompiling is usually the whole migration.
Those two modules are GPL-3.0-or-later with the Classpath exception, which is what lets your plugin carry
whatever license you choose; the rest of CodeAssist is plain GPL-3.0-or-later. The SPI version is independent
of the IDE's, and PLUGIN_SPI_VERSION in plugin-api is the value a scaffolded project is generated with.
Compile with the Kotlin version the IDE was built with, or the SPI's metadata is unreadable. Under AGP 9
Kotlin is built into com.android.application and the org.jetbrains.kotlin.android plugin is rejected, so
the version is set on the build tools instead:
@file:OptIn(
org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class,
org.jetbrains.kotlin.buildtools.api.ExperimentalBuildToolsApi::class,
)
kotlin {
compilerVersion.set("2.4.0")
}with kotlin.compiler.runViaBuildToolsApi=true in gradle.properties. Without both, the build fails on
Module was compiled with an incompatible version of Kotlin.
What to expect at runtime:
- Your plugin loads in the IDE's process, under its UID and its granted permissions. Class loading isolates versions, not privileges.
- Your plugin does not run when it is installed. The IDE lists it and asks first, showing the capabilities
the manifest declares, the signing certificate of the installed package, and a plain statement that a
plugin is not sandboxed; it loads only once the user allows it. Discovery is not consent, so declare
capabilitiesaccurately: it is what the user reads before deciding. - Changing your plugin's enabled state takes effect on the IDE's next launch; the manager loads once at startup and does not hot-swap.
- Installing a new build of your plugin over the old one does not change the running IDE either, and it is the case with no other symptom: the classloader it already has reads the install path from before the update, so your plugin goes on behaving exactly as it did. The IDE reports the update on its row in Settings > Plugins and applies it with the Restart now button there. Installing from the IDE's own Run row says the same thing in the build console.
- If your entry point throws, your plugin is rolled back and skipped with the reason shown on its row. The IDE still starts, and so does every other plugin that does not depend on yours.
Half the trust model is built: the consent gate above is real, and what it shows the user is the
capabilities list, the signing certificate, and the fact that a plugin is not sandboxed. What is not
built is enforcement: nothing checks that a plugin only does what it declared. Declare capabilities
accurately anyway: today it is what the user reads before allowing your plugin, and enforcement is what it is
for.
The design discussion is in ui-extensibility-and-plugin-api.md, and the model as built is summarised in plugin-system.md.
Every published extension point, its id, the type it carries, and what contributing to it adds.
| FQN | Id | Type | Contribute to add |
|---|---|---|---|
dev.ide.platform.SERVICE_EP |
platform.service |
ServiceDescriptor<*> |
A scoped service (use reg.service(...)) |
dev.ide.platform.settings.SETTINGS_PAGE_EP |
platform.settingsPage |
SettingsPage |
A Settings category |
| FQN | Id | Type | Contribute to add |
|---|---|---|---|
dev.ide.model.ModuleTypeExtensionPoint |
platform.moduleType |
ModuleType |
A module type |
dev.ide.model.FileIconExtensionPoint |
platform.fileIcon |
FileIconProvider |
File-tree icon classification |
dev.ide.model.template.ProjectTemplateExtensionPoint |
platform.projectTemplate |
ProjectTemplate |
A Create-Project template (guide) |
dev.ide.model.FACET_CODEC_EP |
platform.facetCodec |
FacetCodec<*> |
Persistence for a module facet |
dev.ide.model.sync.PROJECT_IMPORTER_EP |
platform.projectImporter |
ProjectImporter |
Import of a foreign project layout |
dev.ide.model.sync.IMPORT_CONTRIBUTOR_EP |
platform.importContributor |
ImportContributor |
Add to a snapshot another importer produced, for one feature of a build system you do not own |
dev.ide.model.sync.BUILD_FILE_WRITER_EP |
platform.buildFileWriter |
BuildFileWriter |
Writing changes back to a build file |
| FQN | Id | Type | Contribute to add |
|---|---|---|---|
dev.ide.lang.LANGUAGE_BACKEND_EP |
platform.languageBackend |
LanguageBackend |
A language backend |
dev.ide.lang.FILE_TYPE_EP |
platform.fileType |
FileTypeMapping |
Suffix → language routing |
dev.ide.lang.completion.COMPLETION_CONTRIBUTOR_EP |
platform.completionContributor |
CompletionContribution |
Completion items |
dev.ide.lang.completion.COMPLETION_WEIGHER_EP |
platform.completionWeigher |
CompletionWeigher |
Completion ranking |
dev.ide.lang.postfix.POSTFIX_TEMPLATE_EP |
platform.postfixTemplate |
PostfixTemplate |
A postfix template |
dev.ide.lang.synthetic.SYNTHETIC_CLASS_EP |
platform.syntheticClass |
SyntheticClassProvider |
Generated classes the editor must see (e.g. R) |
dev.ide.index.INDEX_EP |
platform.index |
IndexExtension<*, *> |
A persisted index |
dev.ide.analysis.ANALYZER_EP |
platform.analyzer |
Analyzer |
An inspection |
dev.ide.analysis.DIAGNOSTIC_PROVIDER_EP |
platform.diagnosticProvider |
DiagnosticProvider |
A diagnostic source |
dev.ide.analysis.QUICK_FIX_PROVIDER_EP |
platform.quickFixProvider |
QuickFixProvider |
A fix for a diagnostic |
dev.ide.analysis.ACTION_PROVIDER_EP |
platform.actionProvider |
ActionProvider |
A caret intention |
dev.ide.plugin.editor.EDITOR_DECORATION_EP |
platform.editorDecoration |
EditorDecorationProvider |
Tinted ranges, gutter glyphs and inlays on any file |
dev.ide.plugin.keymap.KEY_BINDING_EP |
platform.keyBinding |
KeyBinding |
A keyboard shortcut for an action |
dev.ide.block.BLOCK_MAPPING_EP |
platform.blockMapping |
BlockMapping |
Block-editor projection for a language |
dev.ide.lang.kotlin.compile.KOTLIN_COMPILER_PLUGIN_EP |
platform.kotlinCompilerPlugin |
KotlinCompilerPlugin |
A Kotlin compiler plugin |
dev.ide.lang.kotlin.symbols.KOTLIN_SYNTHETIC_MEMBER_EP |
platform.kotlinSyntheticMember |
KotlinSyntheticMemberProvider |
Editor visibility of compiler-generated members |
Step-by-step guide: custom-build-plugins.md.
| FQN | Id | Type | Contribute to add |
|---|---|---|---|
dev.ide.build.BUILD_SYSTEM_EP |
platform.buildSystem |
BuildSystem |
A build system |
dev.ide.build.BUILD_PLUGIN_EP |
platform.buildPlugin |
BuildPlugin |
Tasks applied to every build graph |
dev.ide.build.RUN_TASK_PROVIDER_EP |
platform.runTaskProvider |
RunTaskProvider |
Entries in the Run picker |
dev.ide.build.SOURCE_GENERATOR_EP |
platform.sourceGenerator |
SourceGenerator |
Generated sources before compilation |
| FQN | Id | Type | Contribute to add |
|---|---|---|---|
dev.ide.plugin.action.UI_ACTION_EP |
platform.uiAction |
IdeAction |
A toolbar / menu / palette command |
dev.ide.plugin.action.ACTION_GROUP_EP |
platform.actionGroup |
ActionGroup |
Menu nesting |
dev.ide.vcs.VCS_PROVIDER_EP |
platform.vcsProvider |
VcsProvider |
Another version-control system |
dev.ide.android.support.icons.ICON_REPOSITORY_EP |
platform.iconRepository |
IconRepository |
A source for the Icon Manager |
See also extension-points.md for how the built-ins are wired through these.
Engine SPI: :plugin-api
| FQN | File |
|---|---|
dev.ide.plugin.Plugin |
Plugin.kt |
dev.ide.plugin.PluginManifest |
PluginManifest.kt |
dev.ide.plugin.PluginRegistration |
PluginRegistration.kt |
dev.ide.plugin.action.IdeAction |
IdeAction.kt |
dev.ide.plugin.action.ActionGroup |
ActionGroup.kt |
dev.ide.plugin.action.ActionPlace / ActionPlaces |
ActionPlace.kt, ActionPlaces.kt |
dev.ide.plugin.action.ActionContext |
ActionContext.kt |
dev.ide.plugin.action.ActionResult / ActionEffect |
ActionResult.kt, ActionEffect.kt |
dev.ide.plugin.action.SimpleAction / SimpleGroup |
Builders.kt |
dev.ide.plugin.action.UI_ACTION_EP / ACTION_GROUP_EP |
Actions.kt |
Engine runtime: :plugin-impl
| FQN | File |
|---|---|
dev.ide.plugin.impl.PluginManager |
PluginManager.kt |
dev.ide.plugin.impl.PluginCatalog |
PluginCatalog.kt |
dev.ide.plugin.impl.PluginRegistrationImpl |
PluginRegistrationImpl.kt |
dev.ide.plugin.impl.ActionManager |
ActionManager.kt |
Substrate: :platform-core
| FQN | File |
|---|---|
dev.ide.platform.ExtensionPoint / ExtensionRegistry |
Platform.kt |
dev.ide.platform.PluginId / Disposable |
Platform.kt |
dev.ide.platform.MessageBus / MessageBusConnection / Topic |
Platform.kt |
dev.ide.platform.ServiceKey / ServiceScope / ServiceContainer / ServiceScopeLevel |
Services.kt |
dev.ide.platform.settings.SettingsPage / SettingControl |
Settings.kt |
dev.ide.platform.log.Logger / Log |
Log.kt |
Project model: :project-model-api
| FQN | File |
|---|---|
dev.ide.model.ModuleType / SourceSetTemplate / FacetTemplate / ContentRole |
ProjectModel.kt |
dev.ide.model.PlatformKind / LanguageLevel / LibraryKind / DependencyScope |
ProjectModel.kt |
dev.ide.model.Facet / FacetKey / FacetContainer |
ProjectModel.kt |
dev.ide.model.FacetCodec / FacetData / FacetCodecRegistry |
FacetCodec.kt |
dev.ide.model.ModuleTypeRegistry / UnknownModuleType |
ModuleTypeRegistry.kt |
dev.ide.model.ProjectTemplateRegistry |
ProjectTemplateRegistry.kt |
dev.ide.model.FileIconRegistry / FileIconProvider / IconTarget |
FileIconRegistry.kt |
dev.ide.model.template.ProjectTemplate / ProjectScaffold / TemplateParameter |
ProjectTemplate.kt |
dev.ide.model.sync.ProjectImporter / ExternalProjectModel / BuildFileWriter |
ProjectSync.kt |
dev.ide.model.ModuleSources |
ModuleSources.kt |
dev.ide.model.ModuleResources / ResourceEntry / ResourceFilter / ResourceConflict / ResourceWrite |
ModuleResources.kt |
dev.ide.model.contentRootsFor |
ProjectModel.kt |
The four registries are the read side of the model's extension points; they moved here from the unpublished
:project-model-impl in SPI 2.0.0, so a plugin resolves module types, facet codecs, templates and file
icons through the same objects the host does. See
Support a language the IDE has never heard of.
Installed-plugin UI SPI: :plugin-ui-api
Published, and the only UI surface an installed plugin compiles against. See section 10.11.
| Type | What it is |
|---|---|
dev.ide.plugin.ui.UiPlugin |
The UI facet an installed plugin implements; named by uiEntryPoints |
UiRegistration |
What it registers through: toolWindow, screen, overlay, editorPreview, editorLayer, editorPainter, viewMode |
UiHandle |
Removes one contribution |
ToolWindow / Screen / Overlay |
The three contributions, each with a @Composable body |
ToolWindowAnchor |
LEFT / RIGHT / BOTTOM |
UiContext / ScreenUiContext |
What a body is handed: active file, project path, openFile, openScreen, back |
UI SPI: :ide-ui-api
| FQN | File |
|---|---|
dev.ide.ui.ext.UiPlugin / UiContributionScope / UiPluginHost |
UiPlugin.kt |
dev.ide.ui.ext.ToolWindowContribution / ToolWindowAnchor / ToolWindowContext |
ToolWindows.kt |
dev.ide.ui.ext.ScreenContribution / ScreenContext / ScreenRegistry / ScreenBackHandler |
ToolWindows.kt |
dev.ide.ui.ext.OverlayContribution / OverlayContext |
ToolWindows.kt |
dev.ide.ui.ext.EditorViewModeContribution / ViewModeContext |
ToolWindows.kt |
dev.ide.ui.ext.UiHostAction / SimpleUiAction / UiActionHost / UiDestinations / Registration |
UiActions.kt |
dev.ide.ui.ext.BuiltInUiPlugin |
BuiltInUiActions.kt |
dev.ide.ui.backend.IdeBackend |
IdeBackend.kt |
dev.ide.ui.backend.UiActionPlaces and the Ui* action DTOs |
IdeBackend.kt |
dev.ide.ui.icons.actionIcon |
ActionIcons.kt |
Published, one per owning api module. ide-core is their only publisher. See
section 7.2.
| FQN | File |
|---|---|
dev.ide.plugin.editor.EditorTopics / EditorEvent / EditorEventListener |
EditorEvents.kt |
dev.ide.build.BuildTopics / BuildEvent / RunEvent (+ listeners) |
BuildEvents.kt |
dev.ide.analysis.AnalysisTopics / AnalysisEvent / AnalysisEventListener |
AnalysisEvents.kt |
dev.ide.index.IndexTopics / IndexEvent / IndexEventListener |
IndexEvents.kt |
dev.ide.model.event.ProjectTopics / ProjectEvent / ProjectEventListener |
ProjectLifecycleEvents.kt |
Host wiring: :ide-core
| FQN | File |
|---|---|
dev.ide.core.ApplicationEnvironment |
ApplicationEnvironment.kt |
dev.ide.core.BuiltInPlugin / BuiltInPlugins |
BuiltInPlugins.kt |
dev.ide.core.VcsPlugin / VcsSettingsPage |
VcsPlugin.kt |
dev.ide.core.AgentPlugin |
AgentPlugin.kt |
dev.ide.core.IdeServicesBackend |
IdeServicesBackend.kt |
dev.ide.core.ANALYTICS_SERVICE and the platform-port ServiceKeys |
PlatformPorts.kt |
| Plugin | Engine facet | UI facet |
|---|---|---|
| Version Control | VcsPlugin.kt | VcsUiPlugin.kt |
| AI Agent | AgentPlugin.kt | AgentUiPlugin.kt |
| Block Editor, Kotlin, Java, Android, and the rest | BuiltInPlugins.kt | none |
Every service key the IDE registers, what it is for, and who can name it. Resolve one with getService /
getServiceOrNull from a service factory or from PluginRegistration.appServices (APPLICATION scope), or
with Module.service(key) / Workspace.service(key) from an extension point callback. See
Getting an instance of a service.
| Key | Scope | Interface | For |
|---|---|---|---|
dev.ide.model.WORKSPACE_SERVICE |
WORKSPACE | Workspace |
The bound workspace. The ServiceScope.workspace() helper is this lookup |
dev.ide.build.BUILD_CONTROL |
WORKSPACE | BuildControl |
Start or stop the build; compile and run a module's main and capture its output |
dev.ide.index.SYMBOL_SEARCH |
WORKSPACE | SymbolSearch |
Symbol and member lookup over the workspace indexes |
dev.ide.model.MODULE_SOURCES |
WORKSPACE | ModuleSources |
A module's source sets and source roots, including adding one |
dev.ide.model.MODULE_RESOURCES |
WORKSPACE | ModuleResources |
A module's resources: generate a file into a content root of any ContentRole and have the IDE see it, plus the Android resource model (query @type/name, write res/values entries and file resources) |
dev.ide.analysis.MODULE_ANALYSIS |
MODULE | ModuleAnalysis |
The module's SourceAnalyzer per language: resolution and diagnostics for code the plugin did not parse |
dev.ide.interp.api.CODE_INTERPRETER |
APPLICATION | CodeInterpreter |
Run the code in the user's project: lower its Kotlin with no compile step, or run its compiled classes on the bytecode VM (section 14b) |
dev.ide.platform.settings.SETTINGS_ACCESS |
APPLICATION | SettingsAccess |
Read your own settings page's stored values at any time, not only inside onChanged / onAction |
dev.ide.platform.notify.USER_MESSAGES |
APPLICATION | UserMessages |
Tell the user something from an engine facet, and show long work while it runs |
The keys an installed plugin can name. The five between WORKSPACE_SERVICE and CODE_INTERPRETER are
narrowed aliases of engine services listed further down: the interface is the promoted slice, declared in
the api module whose types it already speaks, and IdeCoreServicesPlugin registers it against the same
instance the internal key resolves. So a plugin gets the live service, and the members it can reach are the
ones the IDE committed to.
CODE_INTERPRETER is not an alias of a workspace service. It is registered at APPLICATION scope by
InterpreterPlugin and reads whichever project is open, because a plugin resolves services through
appServices and holds no project of its own. With none open it answers LowerResult.NotReady, the same
shape as "still indexing", which is what a caller should retry rather than report.
USER_MESSAGES is how an engine facet reaches the user. It has no screen of its own, so without this a
plugin could report only through the build console (if it happened to be running inside a build) or the log,
which nobody has open:
val ui = services.getServiceOrNull(USER_MESSAGES)
val progress = ui?.startProgress("Unpacking the NDK toolchain")
try {
entries.forEachIndexed { i, e ->
progress?.detail = e.name
progress?.fraction = i / entries.size.toFloat()
unpack(e)
}
ui?.info("NDK toolchain ready")
} finally {
progress?.finish() // from a finally: a handle nobody finishes is a row that never goes away
}Resolve it with getServiceOrNull and carry on without it: a headless build, a test harness and the desktop
bootstrap all run with nobody watching, and a plugin must not fail because of that. Messages posted this way
also reach the notification center, which is what makes them survive the user being elsewhere.
Asking a question is deliberately not here. A plugin that needs an answer contributes a ui.Overlay from
its UI facet and renders its own prompt: overlays exist for exactly this, and it keeps the wording, layout and
validation yours rather than a shape the host imposes.
Engine services: IdeServices.kt
The open project's decomposed concerns, registered by IdeCoreServicesPlugin in
BuiltInPlugins.kt and resolved from the
workspace container. All are internal to :ide-core, so naming one directly is built-in-only; the four
marked SPI are additionally reachable from the published SPI through the narrowed alias in the table
above.
| Key | Scope | For |
|---|---|---|
ENGINE_CONTEXT |
WORKSPACE | The engine's shared-infrastructure surface. What every service below is built from |
MODULE_ANALYZERS |
MODULE | This module's source analyzers, one per language, built on first use. SPI: MODULE_ANALYSIS |
BUILD_SERVICE |
WORKSPACE | Build and run orchestration, for the native Java/Kotlin and the Android builds. SPI: BUILD_CONTROL |
MODULE_SERVICE |
WORKSPACE | Module configuration and management, behind the Module Settings editor. SPI: MODULE_SOURCES |
DEPENDENCY_SERVICE |
WORKSPACE | Maven dependencies: add, resolve, conflicts, BOM platforms |
PROJECT_SYNC_SERVICE |
WORKSPACE | Re-importing a project whose model comes from a foreign build system |
SEARCH_SERVICE |
WORKSPACE | Go-to-symbol and member search over the index, plus find-in-files. SPI: SYMBOL_SEARCH |
REFACTOR_SERVICE |
WORKSPACE | The Java rename refactoring |
LANGUAGE_FEATURE_SERVICE |
WORKSPACE | On-demand editor features that delegate to a language backend |
KOTLIN_EDITOR_SERVICE |
WORKSPACE | Kotlin-analyzer-backed editor queries |
ANDROID_RESOURCE_SERVICE |
WORKSPACE | Android resource navigation, preview, query and authoring. SPI: MODULE_RESOURCES |
COMPOSE_PREVIEW_SERVICE |
WORKSPACE | The on-device Compose @Preview interpreter path |
ICON_MANAGER_SERVICE |
WORKSPACE | Browsable icon repositories and a module's existing drawables |
INTERPRETER_LOWERING |
WORKSPACE | Lowers a Kotlin declaration for the plugin-facing interpreter. SPI: reached through CODE_INTERPRETER |
SIGNING_SERVICE |
WORKSPACE | The keystore registry and APK signing configuration |
BLOCK_SERVICE |
WORKSPACE | The projectional (block) editor's projection of a buffer |
ACTION_MANAGER |
WORKSPACE | Resolves and dispatches what is contributed to UI_ACTION_EP / ACTION_GROUP_EP |
APP_SDK_MANAGER |
APPLICATION | One SDK download queue across every project; resolvable with no project open |
APP_KEYSTORE_REGISTRY |
APPLICATION | One keystore registry across every project |
PROJECT_TEMPLATES |
APPLICATION | The Create-Project template registry, enumerable before a project exists |
Registered by a built-in plugin rather than by the engine, in the plugin's own module. Public, but the module is unpublished, so still built-in only.
| Key | Scope | Declared in | For |
|---|---|---|---|
ANDROID_RESOURCE_REPOSITORY |
WORKSPACE | :android-support |
The shared, buffer-aware merged ResourceRepository cache for a workspace. The synthetic R resolves it through the Workspace it is handed, and parses directly when a host publishes none. A worked example of a plugin publishing a capability other code resolves by key |
Platform ports: PlatformPorts.kt
APPLICATION-scoped host capabilities the engine needs but does not own. The launcher registers whichever it
supplies; an absent one resolves to null, so getServiceOrNull is always the call here and the consumer
falls back to an in-process default. Absent is the normal state on desktop and in tests.
| Key | Visibility | For |
|---|---|---|
PROGRAM_INTERPRETER |
internal | Runs a compiled program through the bytecode VM |
APK_INSTALLER |
internal | Installs a built APK on the device |
APP_LOG_CHANNEL |
internal | Forwards the running app's log back into the IDE |
CUSTOM_VIEW_RUNTIME |
internal | Renders a project's own custom View for layout preview |
REAL_VIEW_RUNTIME |
internal | The real-view preview inspector |
KOTLIN_PLUGIN_LOADER |
internal | Loads dexed Kotlin compiler plugins |
KOTLIN_COMPILER_BACKEND |
internal | The on-device kotlinc, as a persistent forked VM |
ANDROID_DEVICE_TOOLS |
internal | aapt2, R8/D8 and the rest of the Android toolchain |
ANALYTICS_SERVICE |
public | Opt-in usage analytics; absent resolves to a no-op service |
STORE_CATALOG_SOURCE |
public | The remote Projects Store catalog |
STORE_ACCOUNT_SERVICE |
public | The store's OAuth sign-in |
STORE_SUBMISSION_SERVICE |
public | Store submissions, which need a signed-in session |
STORE_REVIEW_SERVICE |
public | Store ratings and reviews |
NOTIFICATION_PRESENTER |
public | Raises an OS-level notification |
The public keys here are public so a launcher can register them from outside :ide-core, not because they
are part of the plugin SPI. :ide-core is unpublished, so an installed plugin cannot name them either.