Skip to main content

Mountain/Environment/
CommandProvider.rs

1//! # CommandProvider
2//!
3//! Implements `CommandExecutor` for `MountainEnvironment` - the central
4//! registry and dispatcher for all commands in Mountain. Commands are
5//! identified by string IDs and handled either by native Rust functions
6//! or proxied to extension sidecar processes via IPC.
7//!
8//! ## Execution flow
9//!
10//! 1. Caller invokes `ExecuteCommand(id, args)`.
11//! 2. Provider looks up `id` in `ApplicationState::CommandRegistry`.
12//! 3. **Native handler**: calls the Rust function pointer directly with
13//!    `AppHandle`, the main `WebviewWindow`, `ApplicationRunTime`, and `args`.
14//! 4. **Proxied handler**: sends an RPC request to the owning extension sidecar
15//!    via the Vine IPC client.
16//! 5. Returns a serialized `serde_json::Value` result or a `CommonError`.
17//!
18//! ## Special-case no-ops
19//!
20//! Three categories of unknown commands are silently returned as
21//! `Value::Null` rather than an error, to match VS Code's behaviour:
22//! - View-action auto-commands (`.focus`, `.resetViewLocation`, `.removeView`)
23//! - Workbench-internal commands with no Land backing service
24//!   (`getTelemetrySenderObject`, `testing.clearTestResults`)
25//! - Bootstrap-phase activation-race commands (`_typescript.*`, etc.)
26//!
27//! ## Lazy activation
28//!
29//! If a command is not yet in the registry but a scanned extension declares
30//! `onCommand:<id>` as an activation event, `ExecuteCommand` fires
31//! `$activateByEvent` to Cocoon, yields 50 ms for the fire-and-forget
32//! `registerCommand` notification to arrive, then retries the lookup.
33//!
34//! ## Backlog
35//!
36//! - Contribution points from extensions; enablement/disable state
37//! - Categories, grouping, aliases, deprecation
38//! - History and undo/redo stack; keyboard shortcut resolution
39//! - Permission validation; batching for related operations; telemetry
40//!
41//! VS Code reference: `vs/platform/commands/common/commands.ts`,
42//! `vs/workbench/services/commands/common/commandService.ts`.
43
44use std::{future::Future, pin::Pin, sync::Arc};
45
46use CommonLibrary::{
47	Command::CommandExecutor::CommandExecutor,
48	Error::CommonError::CommonError,
49	IPC::DTO::ProxyTarget::ProxyTarget,
50};
51use async_trait::async_trait;
52use serde_json::{Value, json};
53use tauri::{AppHandle, Manager, Runtime, WebviewWindow};
54
55use super::MountainEnvironment::MountainEnvironment;
56use crate::{RunTime::ApplicationRunTime::ApplicationRunTime, Vine::Client, dev_log};
57
58/// An enum representing the different ways a command can be handled.
59///
60/// Commands are either implemented as native Rust functions or
61/// delegated to extension sidecar processes via RPC.
62pub enum CommandHandler<R:Runtime + 'static> {
63	/// A command handled by a native, asynchronous Rust function.
64	Native(
65		fn(
66			AppHandle<R>,
67
68			WebviewWindow<R>,
69
70			Arc<ApplicationRunTime>,
71
72			Value,
73		) -> Pin<Box<dyn Future<Output = Result<Value, String>> + Send>>,
74	),
75
76	/// A command implemented in an extension and proxied to a sidecar.
77	Proxied { SideCarIdentifier:String, CommandIdentifier:String },
78}
79
80impl<R:Runtime> Clone for CommandHandler<R> {
81	fn clone(&self) -> Self {
82		match self {
83			Self::Native(Function) => Self::Native(*Function),
84
85			Self::Proxied { SideCarIdentifier, CommandIdentifier } => {
86				Self::Proxied {
87					SideCarIdentifier:SideCarIdentifier.clone(),
88
89					CommandIdentifier:CommandIdentifier.clone(),
90				}
91			},
92		}
93	}
94}
95
96#[async_trait]
97impl CommandExecutor for MountainEnvironment {
98	/// Executes a registered command by dispatching it to the appropriate
99	/// handler.
100	async fn ExecuteCommand(&self, CommandIdentifier:String, Argument:Value) -> Result<Value, CommonError> {
101		let HandlerInfoOption = self
102			.ApplicationState
103			.Extension
104			.Registry
105			.CommandRegistry
106			.lock()
107			.map_err(super::Utility::ErrorMapping::MapApplicationStateLockErrorToCommonError)?
108			.get(&CommandIdentifier)
109			.cloned();
110
111		match HandlerInfoOption {
112			Some(CommandHandler::Native(Function)) => {
113				// Per-execution line. The setContext dominator is already
114				// gated in Command/Bootstrap.rs; other native commands
115				// (openWalkthrough, etc.) fire rarely enough that the
116				// surviving tag volume is low, but `commands-verbose`
117				// keeps this opt-in for consistency.
118				dev_log!(
119					"commands-verbose",
120					"[CommandProvider] Executing NATIVE command '{}'.",
121					CommandIdentifier
122				);
123
124				let RunTime:Arc<ApplicationRunTime> =
125					self.ApplicationHandle.state::<Arc<ApplicationRunTime>>().inner().clone();
126
127				let MainWindow = self.ApplicationHandle.get_webview_window("main").ok_or_else(|| {
128					CommonError::UserInterfaceInteraction {
129						Reason:"Main window not found for command execution".into(),
130					}
131				})?;
132
133				Function(self.ApplicationHandle.clone(), MainWindow, RunTime, Argument)
134					.await
135					.map_err(|Error| CommonError::CommandExecution { CommandIdentifier, Reason:Error })
136			},
137
138			Some(CommandHandler::Proxied { SideCarIdentifier, CommandIdentifier: ProxiedCommandIdentifier }) => {
139				dev_log!(
140					"commands-verbose",
141					"[CommandProvider] Executing PROXIED command '{}' on sidecar '{}'.",
142					CommandIdentifier,
143					SideCarIdentifier
144				);
145
146				let RPCParameters = json!([ProxiedCommandIdentifier, Argument]);
147
148				let RPCMethod = format!("{}$ExecuteContributedCommand", ProxyTarget::ExtHostCommands.GetTargetPrefix());
149
150				Client::SendRequest::Fn(&SideCarIdentifier, RPCMethod, RPCParameters, 30000)
151					.await
152					.map_err(|Error| CommonError::IPCError { Description:Error.to_string() })
153			},
154
155			None => {
156				// VS Code auto-registers `<viewId>.focus`,
157				// `<viewId>.resetViewLocation`, and `<viewId>.removeView`
158				// commands when a view is contributed via the view registry.
159				// Land's webview.registerView bypasses that registry and
160				// emits a Tauri event instead, so the focus commands never
161				// get inserted. Extensions (gitlens in particular) call
162				// `commands.executeCommand('<their-view-id>.focus')` on
163				// user gesture; the Cocoon try/catch swallows the error,
164				// but the red `error:` log noise here is misleading. Treat
165				// these well-known auto-generated suffixes as silent no-ops.
166				if CommandIdentifier.ends_with(".focus")
167					|| CommandIdentifier.ends_with(".resetViewLocation")
168					|| CommandIdentifier.ends_with(".removeView")
169				{
170					// Once-per-command-id so the no-op fallback doesn't
171					// generate an N-line trail through the dev log every
172					// time the user clicks a view-action button. The
173					// first occurrence still fires (documents the probe
174					// shape); subsequent invocations of the same command
175					// are silent.
176					crate::IPC::DevLog::DebugOnce::Fn(
177						"commands",
178						&format!("view-action-noop:{}", CommandIdentifier),
179						&format!(
180							"[CommandProvider] View-action command '{}' not registered; treating as no-op \
181							 (auto-generated by view registry in stock VS Code).",
182							CommandIdentifier
183						),
184					);
185
186					return Ok(Value::Null);
187				}
188
189				// Workbench-internal commands that stock VS Code registers on
190				// the renderer side via `CommandsRegistry.registerCommand(…)`
191				// but that Land doesn't carry because the backing service
192				// doesn't exist:
193				//
194				// - `getTelemetrySenderObject` - `vs/platform/telemetry/**` registers this so
195				//   extensions can fetch a `TelemetrySender` via `commands.executeCommand`.
196				//   Land has no telemetry backend, so returning null (no sender) matches the
197				//   "telemetry disabled" code path every extension already defensively handles.
198				// - `testing.clearTestResults` - registered by
199				//   `vs/workbench/contrib/testing/browser/testExplorerActions.ts`. No
200				//   test-explorer UI in Land today; null is the correct "nothing to clear"
201				//   shape.
202				//
203				// Extensions that look these up defensively try/catch. The
204				// only observable effect of the prior error return was the
205				// red `error:` log line. Treat as silent no-ops until Land
206				// grows the corresponding services.
207				if matches!(
208					CommandIdentifier.as_str(),
209					"getTelemetrySenderObject" | "testing.clearTestResults"
210				) {
211					// `getTelemetrySenderObject` fires once per extension
212					// activation (~30+ times per boot) - same once-per-id
213					// dedup as the view-action path so the log line
214					// documents the probe but doesn't trail.
215					crate::IPC::DevLog::DebugOnce::Fn(
216						"commands",
217						&format!("workbench-internal-noop:{}", CommandIdentifier),
218						&format!(
219							"[CommandProvider] Workbench-internal command '{}' not registered; treating as no-op \
220							 (Land has no backing service).",
221							CommandIdentifier
222						),
223					);
224
225					return Ok(Value::Null);
226				}
227
228				// TOCTOU race: Cocoon's `registerCommand` notification is
229				// fire-and-forget async, so Mountain's registry doesn't
230				// reflect a just-registered command for several ms. The
231				// TypeScript extension's post-activation pipeline invokes
232				// `_typescript.configurePlugin` within the same event-loop
233				// tick as its own `registerCommand`; the intervening
234				// executeCommand finds no handler and we emit an
235				// alarming red error: line.
236				//
237				// These internal-underscore-prefixed commands (the VS Code
238				// convention for "not-user-facing, extension-internal")
239				// are all bootstrap-phase hooks the extension expects to
240				// be safely droppable if the registry hasn't caught up yet.
241				// Return Value::Null - the extension's own try/catch
242				// takes the expected "not yet available" path. The next
243				// user gesture triggers a fresh call that finds the
244				// command registered normally.
245				if CommandIdentifier.starts_with("_typescript.")
246					|| CommandIdentifier.starts_with("_extensionHost.")
247					|| CommandIdentifier.starts_with("_workbench.registerWebview")
248					|| CommandIdentifier.ends_with(".activationCompleted")
249					|| CommandIdentifier.ends_with(".activated")
250					|| CommandIdentifier.ends_with(".ready")
251				{
252					dev_log!(
253						"commands",
254						"[CommandProvider] Activation-race command '{}' not yet in registry; returning null \
255						 (extension will retry post-activation).",
256						CommandIdentifier
257					);
258
259					return Ok(Value::Null);
260				}
261
262				// Lazy activation: stock VS Code fires
263				// `$activateByEvent("onCommand:<cmd>")` whenever a
264				// command-not-found lookup matches an extension's
265				// declared activation events. The extension then
266				// registers its command during activation, and the
267				// second registry lookup succeeds. Without this flow,
268				// any extension that gates on `onCommand:<id>` (e.g.
269				// GitLens' primary commands, Roo-Cline's commands, Vim
270				// mode toggles) never activates in response to a user
271				// gesture - it just silently does nothing.
272				if LookupCommandContributingExtension(self, &CommandIdentifier) {
273					dev_log!(
274						"commands",
275						"[CommandProvider] Lazy activation for command '{}' - firing onCommand:{0}",
276						CommandIdentifier
277					);
278
279					let Event = format!("onCommand:{}", CommandIdentifier);
280
281					let ActivationResult = Client::SendRequest::Fn(
282						&"cocoon-main".to_string(),
283						"$activateByEvent".to_string(),
284						json!({ "activationEvent": Event }),
285						30_000,
286					)
287					.await;
288
289					if let Err(Error) = ActivationResult {
290						dev_log!(
291							"commands",
292							"warn: [CommandProvider] onCommand:{} activation failed: {}",
293							CommandIdentifier,
294							Error
295						);
296					}
297
298					// The registerCommand channel-drain delivers within ~16 ms.
299					// Yield for one frame so the batch flush lands before
300					// the registry re-read below.
301					tokio::time::sleep(std::time::Duration::from_millis(20)).await;
302
303					let PostActivationHandler = self
304						.ApplicationState
305						.Extension
306						.Registry
307						.CommandRegistry
308						.lock()
309						.map_err(super::Utility::ErrorMapping::MapApplicationStateLockErrorToCommonError)?
310						.get(&CommandIdentifier)
311						.cloned();
312
313					if let Some(Handler) = PostActivationHandler {
314						match Handler {
315							CommandHandler::Native(Function) => {
316								let MainWindow =
317									self.ApplicationHandle.get_webview_window("main").ok_or_else(|| {
318										CommonError::IPCError {
319											Description:"Could not find main window for lazy-activated native command"
320												.to_string(),
321										}
322									})?;
323
324								let RunTime =
325									self.ApplicationHandle.try_state::<Arc<ApplicationRunTime>>().ok_or_else(|| {
326										CommonError::IPCError {
327											Description:"ApplicationRunTime unavailable for lazy-activated native \
328											             command"
329												.to_string(),
330										}
331									})?;
332
333								return Function(
334									self.ApplicationHandle.clone(),
335									MainWindow,
336									(*RunTime).clone(),
337									Argument,
338								)
339								.await
340								.map_err(|Error| CommonError::CommandExecution { CommandIdentifier, Reason:Error });
341							},
342
343							CommandHandler::Proxied { SideCarIdentifier, CommandIdentifier: ProxiedId } => {
344								let RPCParameters = json!([ProxiedId, Argument]);
345
346								let RPCMethod = format!(
347									"{}$ExecuteContributedCommand",
348									ProxyTarget::ExtHostCommands.GetTargetPrefix()
349								);
350
351								return Client::SendRequest::Fn(&SideCarIdentifier, RPCMethod, RPCParameters, 30_000)
352									.await
353									.map_err(|Error| CommonError::IPCError { Description:Error.to_string() });
354							},
355						}
356					}
357				}
358
359				dev_log!(
360					"commands",
361					"error: [CommandProvider] Command '{}' not found in registry.",
362					CommandIdentifier
363				);
364
365				Err(CommonError::CommandNotFound { Identifier:CommandIdentifier })
366			},
367		}
368	}
369
370	/// Registers a command contributed by a sidecar process.
371	async fn RegisterCommand(&self, SideCarIdentifier:String, CommandIdentifier:String) -> Result<(), CommonError> {
372		dev_log!(
373			"commands",
374			"[CommandProvider] Registering PROXY command '{}' from sidecar '{}'",
375			CommandIdentifier,
376			SideCarIdentifier
377		);
378
379		let mut Registry = self
380			.ApplicationState
381			.Extension
382			.Registry
383			.CommandRegistry
384			.lock()
385			.map_err(super::Utility::ErrorMapping::MapApplicationStateLockErrorToCommonError)?;
386
387		Registry.insert(
388			CommandIdentifier.clone(),
389			CommandHandler::Proxied { SideCarIdentifier, CommandIdentifier },
390		);
391
392		Ok(())
393	}
394
395	/// Unregisters a previously registered command.
396	async fn UnregisterCommand(&self, _SideCarIdentifier:String, CommandIdentifier:String) -> Result<(), CommonError> {
397		dev_log!("commands", "[CommandProvider] Unregistering command '{}'", CommandIdentifier);
398
399		self.ApplicationState
400			.Extension
401			.Registry
402			.CommandRegistry
403			.lock()
404			.map_err(super::Utility::ErrorMapping::MapApplicationStateLockErrorToCommonError)?
405			.remove(&CommandIdentifier);
406
407		Ok(())
408	}
409
410	/// Gets a list of all currently registered command IDs.
411	async fn GetAllCommands(&self) -> Result<Vec<String>, CommonError> {
412		dev_log!("commands", "[CommandProvider] Getting all command identifiers.");
413
414		let Registry = self
415			.ApplicationState
416			.Extension
417			.Registry
418			.CommandRegistry
419			.lock()
420			.map_err(super::Utility::ErrorMapping::MapApplicationStateLockErrorToCommonError)?;
421
422		Ok(Registry.keys().cloned().collect())
423	}
424}
425
426/// Return `true` when some scanned extension declares
427/// `onCommand:<CommandIdentifier>` as one of its activation events. Used
428/// by the lazy-activation fallback in `ExecuteCommand` - without this
429/// check we'd fire an `$activateByEvent("onCommand:X")` for every
430/// unknown command, which would cause Cocoon to log "no extension
431/// matching event" for every typo. Scans the cached registry; no IPC.
432fn LookupCommandContributingExtension(Environment:&MountainEnvironment, CommandIdentifier:&str) -> bool {
433	let Event = format!("onCommand:{}", CommandIdentifier);
434
435	let Guard = match Environment
436		.ApplicationState
437		.Extension
438		.ScannedExtensions
439		.ScannedExtensions
440		.lock()
441	{
442		Ok(G) => G,
443
444		Err(_) => return false,
445	};
446
447	for Description in Guard.values() {
448		if let Some(Events) = &Description.ActivationEvents {
449			if Events.iter().any(|E| E == &Event) {
450				return true;
451			}
452		}
453	}
454
455	false
456}