Skip to content

Commit 48be854

Browse files
fix(Mountain): Implement extension type filtering and post-install activation
Three related fixes address the "install succeeded but nothing happened" symptom: 1. **Extension type filtering** (`Extensions.rs`): VS Code's `getInstalled(type?)` IPC passes an optional `ExtensionType` filter (0=System, 1=User). Previously this filter was silently dropped and every call returned the full list as `type: 0, isBuiltin: true`. Now the handler respects the filter and properly distinguishes VSIX-installed extensions from built-ins based on the scanner's `isBuiltin` field. 2. **Post-install activation burst** (`Extension.rs`): After `$deltaExtensions` adds an extension to Cocoon's registry, we now fire `onStartupFinished` activation events. Without this burst, extensions with `onStartupFinished` (like `Anthropic.claude-code`) register but never activate - their sidebar contributions and commands silently no-op until the next full workbench restart. 3. **Sidebar scan handlers** (`mod.rs`): Forward `extensions:scanSystemExtensions` → `getInstalled(type=0)` and `extensions:scanUserExtensions` → `getInstalled(type=1)`. The VS Code channel client doesn't always pass explicit type filters, so the override ensures User extensions appear under "Installed" with an Uninstall action. Impact: VSIX extensions now show under "Installed" with Uninstall, activate immediately after install without reload, and the Extensions sidebar refresh works correctly.
1 parent c61c0b1 commit 48be854

3 files changed

Lines changed: 176 additions & 16 deletions

File tree

Source/IPC/WindServiceHandler/Extension.rs

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,34 @@ const COCOON_SIDE_CAR_IDENTIFIER:&str = "cocoon-main";
2424
/// blocked on a stalled extension host.
2525
const COCOON_DELTA_TIMEOUT_MS:u64 = 10_000;
2626

27-
/// Tell Cocoon to diff the extension registry by the provided descriptors.
28-
/// Fire-and-forget: a missing Cocoon (LAND_SPAWN_COCOON=false) or a transient
29-
/// RPC failure is logged but does not fail the install/uninstall IPC call.
27+
/// Tell Cocoon to diff the extension registry by the provided descriptors,
28+
/// then fire the "already-satisfied" activation events on the result so
29+
/// newly-added extensions activate without a workbench reload.
30+
///
31+
/// Fire-and-forget: a missing Cocoon (LAND_SPAWN_COCOON=false) or a
32+
/// transient RPC failure is logged but does not fail the install/uninstall
33+
/// IPC call.
34+
///
35+
/// # Why the follow-up `$activateByEvent` burst
36+
///
37+
/// `$deltaExtensions` adds the extension to Cocoon's registry and indexes
38+
/// its `activationEvents`, but it does **not** fire those events. The
39+
/// workbench boots by emitting `$activateByEvent("*")` exactly once; any
40+
/// extension installed after that point never sees its startup events
41+
/// re-fired. Without this burst a VSIX with `onStartupFinished` (the most
42+
/// common activation trigger used by tool-extensions like
43+
/// `Anthropic.claude-code`) registers but never activates - its sidebar
44+
/// contributions, commands and webview panels all silently no-op until
45+
/// the next full boot, which is exactly the "install succeeded but
46+
/// nothing happened" symptom.
47+
///
48+
/// We fire the events we know are currently satisfied: `"*"` (always
49+
/// satisfied) and `"onStartupFinished"` (satisfied once Mountain has
50+
/// crossed lifecycle phase Ready, which is true by the time any user
51+
/// interaction - including "Install from VSIX…" - could have reached
52+
/// this handler). `workspaceContains:*` activation is handled Cocoon-side
53+
/// by `WorkspaceContainsActivator`, which subscribes to the
54+
/// `deltaExtensions` emitter and re-scans automatically.
3055
fn NotifyCocoonDeltaExtensions(ToAdd:Vec<Value>, ToRemove:Vec<Value>) {
3156
tokio::spawn(async move {
3257
let Parameters = json!({
@@ -49,8 +74,53 @@ fn NotifyCocoonDeltaExtensions(ToAdd:Vec<Value>, ToRemove:Vec<Value>) {
4974
// Non-fatal - most commonly hit when Cocoon is intentionally
5075
// off (LAND_SPAWN_COCOON=false) or still booting.
5176
dev_log!("extensions", "warn: $deltaExtensions failed (non-fatal): {}", Error);
77+
// Skip the activation burst when delta itself failed: firing
78+
// activation events against a Cocoon that didn't receive the
79+
// registry update would match zero extensions and waste two
80+
// round-trips per install.
81+
return;
5282
},
5383
}
84+
85+
// Activation events are sent sequentially (not spawned in parallel)
86+
// so they observe the registry state that `$deltaExtensions` just
87+
// committed. Only `onStartupFinished` is fired here - it is the
88+
// one event that's guaranteed to be already satisfied by the time
89+
// user interaction could reach the install handler (lifecycle
90+
// phase is past `Ready`). Firing Cocoon's `"*"` pseudo-event
91+
// would over-activate: Cocoon treats `"*"` as "collect every
92+
// extension that declares any activation event", which would
93+
// eagerly load lazy-activation extensions like
94+
// `onLanguage:python` or `onCommand:foo` that should stay dormant
95+
// until their real trigger fires.
96+
for Event in ["onStartupFinished"] {
97+
let ActivationParameters = json!({ "activationEvent": Event });
98+
match Vine::Client::SendRequest(
99+
&COCOON_SIDE_CAR_IDENTIFIER.to_string(),
100+
"$activateByEvent".to_string(),
101+
ActivationParameters,
102+
COCOON_DELTA_TIMEOUT_MS,
103+
)
104+
.await
105+
{
106+
Ok(Response) => {
107+
dev_log!(
108+
"extensions",
109+
"$activateByEvent({}) post-delta applied: {}",
110+
Event,
111+
Response
112+
);
113+
},
114+
Err(Error) => {
115+
dev_log!(
116+
"extensions",
117+
"warn: $activateByEvent({}) post-delta failed (non-fatal): {}",
118+
Event,
119+
Error
120+
);
121+
},
122+
}
123+
}
54124
});
55125
}
56126

Source/IPC/WindServiceHandlers/Extensions.rs

Lines changed: 63 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,48 @@ use crate::{
1313
dev_log,
1414
};
1515

16+
/// VS Code's `ExtensionType` enum - mirror the numeric values used by the
17+
/// renderer's `getInstalled(type?)` IPC so the filter in `GetInstalledArgs`
18+
/// matches what the channel client sends.
19+
///
20+
/// `src/vs/platform/extensions/common/extensions.ts` in the pinned VS Code
21+
/// dependency:
22+
/// ```ts
23+
/// export const enum ExtensionType { System = 0, User = 1 }
24+
/// ```
25+
const EXTENSION_TYPE_SYSTEM:u8 = 0;
26+
const EXTENSION_TYPE_USER:u8 = 1;
27+
1628
/// Return scanned extensions reshaped as VS Code's `ILocalExtension[]`
1729
/// so `ExtensionManagementChannelClient.getInstalled` can destructure
1830
/// `extension.identifier.id`, `extension.manifest.*`, and
1931
/// `extension.location` without blowing up.
20-
pub async fn handle_extensions_get_installed(runtime:Arc<ApplicationRunTime>) -> Result<Value, String> {
32+
///
33+
/// # Argument contract
34+
///
35+
/// `args[0]` is the optional `ExtensionType` filter VS Code passes in:
36+
/// - `0` (System) → only return built-in extensions.
37+
/// - `1` (User) → only return VSIX-installed extensions.
38+
/// - `null` / missing → return every known extension.
39+
///
40+
/// Previously this filter was silently dropped and every call returned the
41+
/// full list hardcoded as `type: 0, isBuiltin: true`. That produced three
42+
/// cascading symptoms:
43+
/// 1. VSIX-installed extensions (e.g. `Anthropic.claude-code`) showed up
44+
/// under "Built-in" in the Extensions sidebar and had no Uninstall
45+
/// action because the UI keys off `type === User`.
46+
/// 2. The trusted-publishers boot migration iterated every extension as
47+
/// User and attempted `manifest.publisher.toLowerCase()` against
48+
/// System manifests.
49+
/// 3. `extensions:scanUserExtensions` (which shares the user-only
50+
/// semantic) returned zero, making the "Install from VSIX…" refresh
51+
/// appear to do nothing even when the install itself succeeded.
52+
pub async fn handle_extensions_get_installed(
53+
runtime:Arc<ApplicationRunTime>,
54+
args:Vec<Value>,
55+
) -> Result<Value, String> {
56+
let TypeFilter:Option<u8> = args.first().and_then(|V| V.as_u64()).map(|N| N as u8);
57+
2158
let Extensions = runtime
2259
.Environment
2360
.GetExtensions()
@@ -26,7 +63,20 @@ pub async fn handle_extensions_get_installed(runtime:Arc<ApplicationRunTime>) ->
2663

2764
let Wrapped:Vec<Value> = Extensions
2865
.into_iter()
29-
.map(|Manifest| {
66+
.filter_map(|Manifest| {
67+
// `isBuiltin` is authored by the scanner; default to `true` for
68+
// safety when the field is missing (matches the pre-filter
69+
// hardcoded behaviour so we never drop an extension the renderer
70+
// used to see).
71+
let IsBuiltin = Manifest.get("isBuiltin").and_then(Value::as_bool).unwrap_or(true);
72+
let ExtensionType = if IsBuiltin { EXTENSION_TYPE_SYSTEM } else { EXTENSION_TYPE_USER };
73+
74+
if let Some(Wanted) = TypeFilter {
75+
if Wanted != ExtensionType {
76+
return None;
77+
}
78+
}
79+
3080
let Publisher = Manifest
3181
.get("publisher")
3282
.and_then(Value::as_str)
@@ -71,10 +121,10 @@ pub async fn handle_extensions_get_installed(runtime:Arc<ApplicationRunTime>) ->
71121
Map.entry("version".to_string()).or_insert_with(|| json!("0.0.0"));
72122
}
73123

74-
json!({
124+
Some(json!({
75125
// IExtension (base)
76-
"type": 0, // ExtensionType.System
77-
"isBuiltin": true,
126+
"type": ExtensionType,
127+
"isBuiltin": IsBuiltin,
78128
"identifier": { "id": Id },
79129
"manifest": Manifest,
80130
"location": Location,
@@ -93,15 +143,20 @@ pub async fn handle_extensions_get_installed(runtime:Arc<ApplicationRunTime>) ->
93143
"updated": false,
94144
"pinned": false,
95145
"forceAutoUpdate": false,
96-
"source": "system",
146+
// `source` distinguishes the disk origin: built-ins ship with
147+
// the bundle ("system"); VSIX-installed extensions live under
148+
// `~/.land/extensions/*` ("vsix"). The sidebar keys off this
149+
// for the "Uninstall" gesture.
150+
"source": if IsBuiltin { "system" } else { "vsix" },
97151
"size": 0,
98-
})
152+
}))
99153
})
100154
.collect();
101155

102156
dev_log!(
103157
"extensions",
104-
"extensions:getInstalled returning {} ILocalExtension-shaped entries",
158+
"extensions:getInstalled type={:?} returning {} ILocalExtension-shaped entries",
159+
TypeFilter,
105160
Wrapped.len()
106161
);
107162

Source/IPC/WindServiceHandlers/mod.rs

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -300,12 +300,47 @@ pub async fn mountain_ipc_invoke(app_handle:AppHandle, command:String, args:Vec<
300300
.collect::<Vec<_>>()
301301
.join(" ");
302302
dev_log!("extensions", "{} args={}", command, ArgsSummary);
303-
handle_extensions_get_installed(runtime.clone()).await
303+
// `scanSystemExtensions` is conceptually
304+
// `getInstalled(type=ExtensionType.System)`, so override
305+
// `args[0]` to `0` before forwarding. Without the override
306+
// a plain alias would inherit whatever the caller passed
307+
// in args[0] (which for the VS Code channel client is
308+
// usually `null`) and leak User extensions into the
309+
// System list - the same bug we just fixed at the
310+
// handler layer, one level up.
311+
let EffectiveArgs = if command == "extensions:scanSystemExtensions" {
312+
let mut Overridden = args.clone();
313+
if Overridden.is_empty() {
314+
Overridden.push(Value::Null);
315+
}
316+
Overridden[0] = json!(0);
317+
Overridden
318+
} else {
319+
args.clone()
320+
};
321+
handle_extensions_get_installed(runtime.clone(), EffectiveArgs).await
322+
},
323+
"extensions:scanUserExtensions" => {
324+
// User-scope scan. Forward to the unified handler with
325+
// `type=ExtensionType.User (1)` so VSIX-installed
326+
// extensions under `~/.land/extensions/*` come back
327+
// even when the caller didn't pass an explicit type
328+
// filter (VS Code's channel client does that on
329+
// scan-user-extensions, which is why the sidebar
330+
// previously saw an empty list after every
331+
// Install-from-VSIX).
332+
dev_log!("extensions", "{} (forwarded to getInstalled with type=User)", command);
333+
let mut UserArgs = args.clone();
334+
if UserArgs.is_empty() {
335+
UserArgs.push(Value::Null);
336+
}
337+
UserArgs[0] = json!(1);
338+
handle_extensions_get_installed(runtime.clone(), UserArgs).await
304339
},
305-
"extensions:scanUserExtensions" | "extensions:getUninstalled" => {
306-
// Land doesn't support user-installed extensions yet - the
307-
// workbench treats an empty array as "no user extensions",
308-
// which is correct for the current Mountain architecture.
340+
"extensions:getUninstalled" => {
341+
// Uninstalled state (extensions soft-deleted but kept in
342+
// the profile) isn't tracked yet; an empty array is the
343+
// correct "nothing pending uninstall" response.
309344
dev_log!("extensions", "{} (returning [])", command);
310345
Ok(Value::Array(Vec::new()))
311346
},

0 commit comments

Comments
 (0)