diff --git a/crates/app-tauri/tests/dto_plugins.rs b/crates/app-tauri/tests/dto_plugins.rs index a350e1ba..b20a6628 100644 --- a/crates/app-tauri/tests/dto_plugins.rs +++ b/crates/app-tauri/tests/dto_plugins.rs @@ -50,6 +50,7 @@ fn runtime_catalog_dto_carries_bundle_hash_and_contributions() { publisher: None, version: "1.2.3".to_owned(), bundle_url: "idea-plugin://dev.acme.gitgraph/1.2.3/abc/dist/index.js".to_owned(), + plugin_root: "/data/idea/plugins/installed/dev.acme.gitgraph".to_owned(), icon_url: None, content_hash: "abc".to_owned(), capabilities: vec![PluginCapability::Ui, PluginCapability::Tooling], @@ -64,6 +65,10 @@ fn runtime_catalog_dto_carries_bundle_hash_and_contributions() { "idea-plugin://dev.acme.gitgraph/1.2.3/abc/dist/index.js" ); assert_eq!(value["plugins"][0]["contentHash"], "abc"); + assert_eq!( + value["plugins"][0]["pluginRoot"], + "/data/idea/plugins/installed/dev.acme.gitgraph" + ); assert_eq!( value["plugins"][0]["capabilities"], serde_json::json!(["ui", "tooling"]) diff --git a/crates/application/src/plugin/mod.rs b/crates/application/src/plugin/mod.rs index cba849eb..e59e52c6 100644 --- a/crates/application/src/plugin/mod.rs +++ b/crates/application/src/plugin/mod.rs @@ -184,6 +184,8 @@ pub struct PluginRuntimePlugin { pub version: String, /// Bundle URL. pub bundle_url: String, + /// Canonical host-local root of the installed plugin package. + pub plugin_root: String, /// Icon URL. pub icon_url: Option, /// Content hash. @@ -3022,6 +3024,10 @@ pub(crate) async fn runtime_plugin_from_entry( &descriptor.registry.content_hash, &descriptor.manifest.main, )?; + let plugin_root = packages + .installed_plugin_root(&descriptor.manifest.id) + .await + .map_err(map_store)?; let icon_url = match &descriptor.manifest.icon { Some(icon) => Some(checked_plugin_asset_url( packages, @@ -3038,6 +3044,7 @@ pub(crate) async fn runtime_plugin_from_entry( publisher: descriptor.manifest.publisher, version: descriptor.manifest.version.as_str().to_owned(), bundle_url: bundle, + plugin_root: plugin_root.as_str().to_owned(), icon_url, content_hash: descriptor.registry.content_hash.as_str().to_owned(), capabilities: descriptor.manifest.capabilities, @@ -4203,6 +4210,10 @@ mod tests { let catalog = usecase.execute().await.unwrap(); assert_eq!(catalog.plugins.len(), 1); + assert_eq!( + catalog.plugins[0].plugin_root, + "/installed/dev.acme.gitgraph" + ); assert_eq!( catalog.plugins[0].capabilities, vec![ diff --git a/crates/application/src/slash_command.rs b/crates/application/src/slash_command.rs index 90e5cd5d..0ef6d163 100644 --- a/crates/application/src/slash_command.rs +++ b/crates/application/src/slash_command.rs @@ -426,6 +426,7 @@ mod tests { publisher: None, version: "1.0.0".to_owned(), bundle_url: "idea-plugin://dev.acme.plugin/1.0.0/hash/dist/index.js".to_owned(), + plugin_root: "/plugins/installed/dev.acme.plugin".to_owned(), icon_url: None, content_hash: "abc".to_owned(), capabilities: vec![domain::PluginCapability::Ui], diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index a35dee3d..5b942c74 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -382,6 +382,8 @@ pub struct PluginRuntimePluginDto { pub version: String, /// Bundle URL. pub bundle_url: String, + /// Canonical host-local root of the installed plugin package. + pub plugin_root: String, /// Icon URL. pub icon_url: Option, /// Content hash. @@ -414,6 +416,7 @@ impl From for PluginRuntimePluginDto { publisher: value.publisher, version: value.version, bundle_url: value.bundle_url, + plugin_root: value.plugin_root, icon_url: value.icon_url, content_hash: value.content_hash, capabilities: value.capabilities, diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 117da86a..4ae0246c 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -440,6 +440,19 @@ pub trait PluginPackageStore: Send + Sync { /// Lists committed package roots. async fn list_installed(&self) -> Result, PluginStoreError>; + /// Resolves the host-local root of one committed plugin package. + async fn installed_plugin_root( + &self, + plugin_id: &crate::PluginId, + ) -> Result { + self.list_installed() + .await? + .into_iter() + .find(|package| package.plugin_id.as_ref() == Some(plugin_id)) + .map(|package| LocalPath::new(package.root)) + .ok_or(PluginStoreError::NotFound) + } + /// Reads `idea-plugin.json` from a package root. async fn read_manifest( &self, diff --git a/crates/infrastructure/src/plugin/mod.rs b/crates/infrastructure/src/plugin/mod.rs index b1964638..2491dd63 100644 --- a/crates/infrastructure/src/plugin/mod.rs +++ b/crates/infrastructure/src/plugin/mod.rs @@ -141,6 +141,21 @@ impl PluginPackageStore for FsPluginPackageStore { Ok(out) } + async fn installed_plugin_root( + &self, + plugin_id: &PluginId, + ) -> Result { + let root = self.installed_plugin_dir(plugin_id); + let canonical = root.canonicalize().map_err(|error| { + if error.kind() == io::ErrorKind::NotFound { + PluginStoreError::NotFound + } else { + PluginStoreError::Io(error.to_string()) + } + })?; + Ok(LocalPath::new(canonical.to_string_lossy().into_owned())) + } + async fn read_manifest( &self, package: &PluginPackageRef, diff --git a/crates/infrastructure/tests/plugin_install_load.rs b/crates/infrastructure/tests/plugin_install_load.rs index d8659781..e4ca5fcc 100644 --- a/crates/infrastructure/tests/plugin_install_load.rs +++ b/crates/infrastructure/tests/plugin_install_load.rs @@ -134,6 +134,13 @@ async fn installs_reference_fixture_and_loads_runtime_catalog() { assert!(plugin .bundle_url .starts_with("idea-plugin://dev.idea.fixtures.reference-minimal/0.1.0/")); + assert_eq!( + PathBuf::from(&plugin.plugin_root), + app_data + .join("plugins/installed/dev.idea.fixtures.reference-minimal") + .canonicalize() + .unwrap() + ); assert_eq!(plugin.contributes.menus.len(), 1); assert_eq!(plugin.contributes.menu_items.len(), 1); @@ -183,6 +190,13 @@ async fn installs_sdk_hello_plugin_and_loads_runtime_catalog() { .bundle_url .starts_with("idea-plugin://com.example.hello-plugin/0.1.0/")); assert!(plugin.bundle_url.ends_with("/dist/index.js")); + assert_eq!( + PathBuf::from(&plugin.plugin_root), + app_data + .join("plugins/installed/com.example.hello-plugin") + .canonicalize() + .unwrap() + ); assert_eq!(plugin.contributes.menus[0].id, "hello-plugin.menu"); assert_eq!(plugin.contributes.menus[0].label.as_str(), "Hello Plugin"); assert_eq!( diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index a4f61a0b..841a820d 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -2004,6 +2004,8 @@ export interface PluginRuntimePlugin { */ activationScope?: "app" | "project"; bundleUrl: string; + /** Canonical host-local root of the active installed plugin package. */ + pluginRoot: string; iconUrl?: string; contentHash: string; contributes: PluginContributionDto; diff --git a/frontend/src/features/plugins/PluginRuntimeProvider.test.tsx b/frontend/src/features/plugins/PluginRuntimeProvider.test.tsx index 0a6b7149..16fc313b 100644 --- a/frontend/src/features/plugins/PluginRuntimeProvider.test.tsx +++ b/frontend/src/features/plugins/PluginRuntimeProvider.test.tsx @@ -23,6 +23,7 @@ function runtimePlugin(overrides: Partial = {}): PluginRunt displayName: "Refreshable v1", version: "1.0.0", bundleUrl: dataUrl("export function activate() {}"), + pluginRoot: "/data/idea/plugins/installed/dev.acme.refreshable", contentHash: "v1", contributes: { menus: [], menuItems: [], layouts: [], mcpServers: [] }, ...overrides, diff --git a/frontend/src/features/plugins/plugins.test.tsx b/frontend/src/features/plugins/plugins.test.tsx index 782f037b..70b3a5f8 100644 --- a/frontend/src/features/plugins/plugins.test.tsx +++ b/frontend/src/features/plugins/plugins.test.tsx @@ -101,6 +101,7 @@ class ProjectScopedRuntimeCatalogPluginGateway extends MockPluginGateway { version: "1.0.0", activationScope: "project", bundleUrl: this.bundleUrl, + pluginRoot: "/data/idea/plugins/installed/dev.acme.project-plugin", contentHash: "project-plugin", contributes: { menus: [], menuItems: [], layouts: [], mcpServers: [] }, }, @@ -127,6 +128,7 @@ class MixedActivationScopeRuntimeCatalogPluginGateway extends MockPluginGateway capabilities: ["tooling"], activationScope: "app", bundleUrl: this.failingAppBundleUrl, + pluginRoot: "/data/idea/plugins/installed/dev.acme.app-needs-project", contentHash: "app-needs-project", contributes: { menus: [], menuItems: [], layouts: [], mcpServers: [] }, }, @@ -136,6 +138,7 @@ class MixedActivationScopeRuntimeCatalogPluginGateway extends MockPluginGateway version: "1.0.0", activationScope: "project", bundleUrl: this.projectBundleUrl, + pluginRoot: "/data/idea/plugins/installed/dev.acme.project-plugin", contentHash: "project-plugin", contributes: { menus: [], menuItems: [], layouts: [], mcpServers: [] }, }, diff --git a/frontend/src/plugins/runtime/loader.test.ts b/frontend/src/plugins/runtime/loader.test.ts index 90a2382f..892f1cee 100644 --- a/frontend/src/plugins/runtime/loader.test.ts +++ b/frontend/src/plugins/runtime/loader.test.ts @@ -29,6 +29,7 @@ function entry(overrides: Partial & { bundleUrl: string }): id: "mock.plugin", displayName: "Mock Plugin", version: "1.0.0", + pluginRoot: "/data/idea/plugins/installed/mock.plugin", contentHash: "abc123", contributes: emptyContributes(), ...overrides, @@ -39,17 +40,25 @@ describe("loadPlugins", () => { it("loads a well-formed plugin bundle and calls activate(ctx)", async () => { const bundle = dataUrl(` export function activate(ctx) { - globalThis.__activatedWith = ctx.pluginId; + globalThis.__activatedWith = { pluginId: ctx.pluginId, pluginRoot: ctx.pluginRoot }; return {}; } `); const { registry, failures } = await loadPlugins( - [entry({ id: "dev.acme.one", displayName: "One", bundleUrl: bundle })], + [entry({ + id: "dev.acme.one", + displayName: "One", + bundleUrl: bundle, + pluginRoot: "/data/idea/plugins/installed/dev.acme.one", + })], gateways, ); expect(failures).toEqual([]); expect(registry.list().map((p) => p.pluginId)).toEqual(["dev.acme.one"]); - expect((globalThis as Record).__activatedWith).toBe("dev.acme.one"); + expect((globalThis as Record).__activatedWith).toEqual({ + pluginId: "dev.acme.one", + pluginRoot: "/data/idea/plugins/installed/dev.acme.one", + }); }); it("loads an SDK-style default export containing activate(ctx)", async () => { @@ -242,6 +251,7 @@ describe("loadPlugins", () => { "menu", "pluginDisplayName", "pluginId", + "pluginRoot", "services", "storage", "subscriptions", diff --git a/frontend/src/plugins/runtime/loader.ts b/frontend/src/plugins/runtime/loader.ts index 21767ab4..839b90e1 100644 --- a/frontend/src/plugins/runtime/loader.ts +++ b/frontend/src/plugins/runtime/loader.ts @@ -38,6 +38,7 @@ export type { PluginGatewaySet } from "./registry"; export interface IdeaPluginContext { pluginId: string; + pluginRoot: string; pluginDisplayName: string; version: string; logger: PluginLogger; @@ -270,6 +271,7 @@ async function loadOne( const pluginId = safePluginId(entry); const displayName = nonEmptyString(entryObject.displayName) ?? pluginId; const version = nonEmptyString(entryObject.version) ?? ""; + const pluginRoot = nonEmptyString(entryObject.pluginRoot); let activation: void | PluginActivation = undefined; const subscriptions: Disposable[] = []; try { @@ -280,6 +282,9 @@ async function loadOne( if (!bundleUrl) { throw new Error("missing plugin bundle URL"); } + if (!pluginRoot) { + throw new Error("missing installed plugin root"); + } installHostReactImportMap(); const mod = resolveIdeaPluginModule( @@ -308,6 +313,7 @@ async function loadOne( const ctx: IdeaPluginContext = { pluginId, + pluginRoot, pluginDisplayName: displayName, version, logger: createPluginLogger(entry), diff --git a/sdk/IdeaSDK b/sdk/IdeaSDK index 68019dec..e3a0f46f 160000 --- a/sdk/IdeaSDK +++ b/sdk/IdeaSDK @@ -1 +1 @@ -Subproject commit 68019dec58e25cc4f7c8e45b1b7e35f76a7eb95c +Subproject commit e3a0f46f9d8bbe431a6b09cfeaa20cc6b49d076f