Skip to main content

Mountain/Environment/
WorkspaceProvider.rs

1//! # WorkspaceProvider (Environment)
2//!
3//! RESPONSIBILITIES:
4//! - Implements
5//!   [`WorkspaceProvider`](CommonLibrary::Workspace::WorkspaceProvider) and
6//!   [`WorkspaceEditApplier`](CommonLibrary::Workspace::WorkspaceEditApplier)
7//!   traits for [`MountainEnvironment`]
8//! - Manages multi-root workspace folder operations and configuration
9//! - Provides workspace trust management and file discovery capabilities
10//! - Handles workspace edit application and custom editor routing
11//!
12//! ARCHITECTURAL ROLE:
13//! - Core provider in the Environment system, exposing workspace-level
14//!   functionality to frontend via gRPC through the `AirService`
15//! - Workspace provider is one of the foundational services alongside Document,
16//!   Configuration, and Diagnostic providers
17//! - Integrates with `ApplicationState` for persistent workspace folder storage
18//!
19//! ERROR HANDLING:
20//! - Uses [`CommonError`](CommonLibrary::Error::CommonError) for all operations
21//! - Application state lock errors are mapped using
22//!   [`Utility::ErrorMapping::MapApplicationStateLockErrorToCommonError`]
23//! - Some operations are stubbed with logging (FindFilesInWorkspace, OpenFile,
24//!   ApplyWorkspaceEdit)
25//!
26//! PERFORMANCE:
27//! - Workspace folder lookup uses O(n) linear search through folder list
28//! - Lock contention on `ApplicationState.Workspace.WorkspaceFolders` should be
29//!   minimized
30//! - File discovery and workspace edit application are not yet optimized
31//!
32//! VS CODE REFERENCE:
33//! - `vs/workbench/services/workspace/browser/workspaceService.ts` - workspace
34//!   service implementation
35//! - `vs/workbench/contrib/files/common/editors/textFileEditor.ts` - file
36//!   editor integration
37//! - `vs/platform/workspace/common/workspace.ts` - workspace types and
38//!   interfaces
39//!
40//! TODO:
41//! - Implement actual file search with glob pattern matching
42//! - Implement file opening with workspace-relative paths
43//! - Complete workspace edit application logic
44//! - Add workspace event propagation to subscribers
45//! - Implement custom editor routing by view type
46//!
47//! MODULE CONTENTS:
48//! - [`WorkspaceProvider`](CommonLibrary::Workspace::WorkspaceProvider)
49//!   implementation:
50//! - `GetWorkspaceFoldersInfo` - enumerate all workspace folders
51//! - `GetWorkspaceFolderInfo` - find folder containing a URI
52//! - `GetWorkspaceName` - workspace identifier from state
53//! - `GetWorkspaceConfigurationPath` - .code-workspace path
54//! - `IsWorkspaceTrusted` - trust status check
55//! - `RequestWorkspaceTrust` - trust acquisition (stub)
56//! - `FindFilesInWorkspace` - file discovery (stub)
57//! - `OpenFile` - file opening (stub)
58//! - [`WorkspaceEditApplier`](CommonLibrary::Workspace::WorkspaceEditApplier)
59//!   implementation:
60//! - `ApplyWorkspaceEdit` - edit application (stub)
61//! - Data types: [`(Url, String, usize)`] tuple for folder info (URI, name,
62//!   index)
63
64use std::{
65	collections::HashMap,
66	path::PathBuf,
67	sync::{Arc, Mutex, OnceLock},
68	time::{Duration, Instant},
69};
70
71use CommonLibrary::{
72	DTO::WorkspaceEditDTO::WorkspaceEditDTO,
73	Error::CommonError::CommonError,
74	Workspace::{WorkspaceEditApplier::WorkspaceEditApplier, WorkspaceProvider::WorkspaceProvider},
75};
76use async_trait::async_trait;
77use globset::GlobBuilder;
78use ignore::WalkBuilder;
79use serde_json::Value;
80use tokio::sync::Notify;
81use url::Url;
82
83use super::{MountainEnvironment::MountainEnvironment, Utility};
84use crate::dev_log;
85
86/// Process-wide LRU cache for `FindFilesInWorkspace`. Cache key folds
87/// every input that influences the walk; TTL is short so we never serve
88/// a stale result after a file-system mutation. Entry budget is small
89/// to bound memory across many workspace folders + glob shapes.
90///
91/// Why: the workbench's `ISearchService` fires `findFiles` per-keystroke
92/// during Cmd+P fuzzy match (typically 5-10 calls in 200 ms) AND per
93/// breadcrumb / quick-pick refresh. Each walk traverses tens of
94/// thousands of files; a 0.5-3 ms HashMap lookup short-circuits all
95/// but the first walk in a typing burst.
96const FIND_FILES_CACHE_TTL:Duration = Duration::from_millis(2500);
97const FIND_FILES_CACHE_CAPACITY:usize = 128;
98
99#[derive(Hash, Eq, PartialEq, Clone)]
100struct FindFilesCacheKey {
101	Folders:Vec<PathBuf>,
102	Include:String,
103	Exclude:Option<String>,
104	Cap:usize,
105	UseIgnoreFiles:bool,
106	FollowSymlinks:bool,
107	RestrictBase:Option<String>,
108}
109
110struct FindFilesCacheEntry {
111	Result:Vec<Url>,
112	StoredAt:Instant,
113}
114
115fn FindFilesCache() -> &'static Mutex<HashMap<FindFilesCacheKey, FindFilesCacheEntry>> {
116	static CACHE:OnceLock<Mutex<HashMap<FindFilesCacheKey, FindFilesCacheEntry>>> = OnceLock::new();
117	CACHE.get_or_init(|| Mutex::new(HashMap::with_capacity(FIND_FILES_CACHE_CAPACITY)))
118}
119
120/// Insert into the cache with simple bounded-size eviction. When the
121/// table reaches capacity we drop the oldest half in one pass; this
122/// avoids tracking access order per entry while still keeping memory
123/// bounded under sustained workbench traffic.
124fn FindFilesCachePut(Key:FindFilesCacheKey, Result:Vec<Url>) {
125	if let Ok(mut Guard) = FindFilesCache().lock() {
126		if Guard.len() >= FIND_FILES_CACHE_CAPACITY {
127			let Cutoff = Instant::now() - FIND_FILES_CACHE_TTL;
128			Guard.retain(|_, V| V.StoredAt > Cutoff);
129			if Guard.len() >= FIND_FILES_CACHE_CAPACITY {
130				let DropCount = Guard.len() / 2;
131				let StaleKeys:Vec<FindFilesCacheKey> = Guard.iter().take(DropCount).map(|(K, _)| K.clone()).collect();
132				for K in StaleKeys {
133					Guard.remove(&K);
134				}
135			}
136		}
137		Guard.insert(Key, FindFilesCacheEntry { Result, StoredAt:Instant::now() });
138	}
139}
140
141fn FindFilesCacheGet(Key:&FindFilesCacheKey) -> Option<Vec<Url>> {
142	let Guard = FindFilesCache().lock().ok()?;
143	let Entry = Guard.get(Key)?;
144	if Entry.StoredAt.elapsed() > FIND_FILES_CACHE_TTL {
145		return None;
146	}
147	Some(Entry.Result.clone())
148}
149
150/// Drop every cached find-files result. Callers: workspace folder
151/// add/remove (`UpdateWorkspaceFolders`), file system watcher events
152/// from Mountain's notifier, explicit refresh from the renderer.
153/// Cache holds for at most `FIND_FILES_CACHE_TTL` anyway, so missing
154/// an invalidation point here is bounded latency, not correctness.
155pub fn ClearFindFilesCache() {
156	if let Ok(mut Guard) = FindFilesCache().lock() {
157		Guard.clear();
158	}
159}
160
161/// Single-flight registry: keys with a walk currently in progress
162/// share the same `Notify` so concurrent callers awaiting the same
163/// `(folders, include, exclude, cap, flags)` don't each kick off
164/// their own filesystem walk.
165///
166/// Why: log audit (`20260501T053137`) showed 1023 `findFiles` calls
167/// during one extension-boot session, with the cache hit rate
168/// at ~67% (687 hits, 333 misses). The 333 misses fired BEFORE
169/// the first walker for any given key populated the cache, so
170/// each one independently re-walked the same tree. With the
171/// single-flight guard the leader walks once, every concurrent
172/// follower awaits, then reads the freshly-populated cache.
173fn FindFilesInFlight() -> &'static Mutex<HashMap<FindFilesCacheKey, Arc<Notify>>> {
174	static IN_FLIGHT:OnceLock<Mutex<HashMap<FindFilesCacheKey, Arc<Notify>>>> = OnceLock::new();
175	IN_FLIGHT.get_or_init(|| Mutex::new(HashMap::new()))
176}
177
178#[async_trait]
179impl WorkspaceProvider for MountainEnvironment {
180	/// Retrieves information about all currently open workspace folders.
181	async fn GetWorkspaceFoldersInfo(&self) -> Result<Vec<(Url, String, usize)>, CommonError> {
182		dev_log!("workspaces", "[WorkspaceProvider] Getting workspace folders info.");
183		let FoldersGuard = self
184			.ApplicationState
185			.Workspace
186			.WorkspaceFolders
187			.lock()
188			.map_err(Utility::ErrorMapping::MapApplicationStateLockErrorToCommonError)?;
189		Ok(FoldersGuard.iter().map(|f| (f.URI.clone(), f.Name.clone(), f.Index)).collect())
190	}
191
192	/// Retrieves information for the specific workspace folder that contains a
193	/// given URI.
194	async fn GetWorkspaceFolderInfo(&self, URIToMatch:Url) -> Result<Option<(Url, String, usize)>, CommonError> {
195		let FoldersGuard = self
196			.ApplicationState
197			.Workspace
198			.WorkspaceFolders
199			.lock()
200			.map_err(Utility::ErrorMapping::MapApplicationStateLockErrorToCommonError)?;
201		for Folder in FoldersGuard.iter() {
202			if URIToMatch.as_str().starts_with(Folder.URI.as_str()) {
203				return Ok(Some((Folder.URI.clone(), Folder.Name.clone(), Folder.Index)));
204			}
205		}
206
207		Ok(None)
208	}
209
210	/// Gets the name of the current workspace.
211	async fn GetWorkspaceName(&self) -> Result<Option<String>, CommonError> {
212		self.ApplicationState.GetWorkspaceIdentifier().map(Some)
213	}
214
215	/// Gets the path to the workspace configuration file (`.code-workspace`).
216	async fn GetWorkspaceConfigurationPath(&self) -> Result<Option<PathBuf>, CommonError> {
217		Ok(self
218			.ApplicationState
219			.Workspace
220			.WorkspaceConfigurationPath
221			.lock()
222			.map_err(Utility::ErrorMapping::MapApplicationStateLockErrorToCommonError)?
223			.clone())
224	}
225
226	/// Checks if the current workspace is trusted.
227	async fn IsWorkspaceTrusted(&self) -> Result<bool, CommonError> {
228		Ok(self
229			.ApplicationState
230			.Workspace
231			.IsTrusted
232			.load(std::sync::atomic::Ordering::Relaxed))
233	}
234
235	/// Requests workspace trust from the user.
236	async fn RequestWorkspaceTrust(&self, _Options:Option<Value>) -> Result<bool, CommonError> {
237		dev_log!(
238			"workspaces",
239			"warn: [WorkspaceProvider] RequestWorkspaceTrust is not implemented; defaulting to trusted."
240		);
241		Ok(true)
242	}
243
244	/// Finds files in the workspace matching the specified query.
245	///
246	/// Uses `ignore::WalkBuilder::build_parallel()` to walk every
247	/// registered workspace folder on OS threads, respecting
248	/// `.gitignore` / `.ignore` / `.git/info/exclude` when
249	/// `use_ignore_files` is true. Matches each entry's relative
250	/// path against `IncludePatternDTO` (glob), filters out hidden
251	/// dirs by default, drops to native symlink behaviour when
252	/// `follow_symlinks` is false. Returns deduplicated `file://`
253	/// URIs capped at `MaxResults` (default 10_000).
254	///
255	/// `IncludePatternDTO` accepts:
256	///   - String: bare glob (`"**/*.rs"`)
257	///   - `{ pattern: "..." }`: structured form
258	///   - `{ base, pattern }`: VS Code RelativePattern shape (base restricts
259	///     the walk to that subfolder; falls back to all workspace folders if
260	///     `base` doesn't resolve to a known folder)
261	///
262	/// `ExcludePatternDTO` follows the same shapes; null/missing
263	/// disables the exclude phase. The `node_modules`, `target`,
264	/// `dist`, `.git` directories are auto-skipped via
265	/// `WalkBuilder::standard_filters` regardless of `use_ignore_files`
266	/// to keep walks bounded on monorepos that don't carry a
267	/// top-level `.gitignore`.
268	async fn FindFilesInWorkspace(
269		&self,
270		IncludePatternDTO:Value,
271		ExcludePatternDTO:Option<Value>,
272		MaxResults:Option<usize>,
273		UseIgnoreFiles:bool,
274		FollowSymlinks:bool,
275	) -> Result<Vec<Url>, CommonError> {
276		dev_log!("workspaces", "[WorkspaceProvider] FindFilesInWorkspace called");
277
278		let IncludePattern = ExtractGlobPattern(&IncludePatternDTO);
279		let IncludePattern = match IncludePattern {
280			Some(P) if !P.is_empty() => P,
281			_ => {
282				dev_log!("workspaces", "[FindFilesInWorkspace] empty include pattern → []");
283				return Ok(Vec::new());
284			},
285		};
286		// Diagnostic: capture the actual include pattern + the input
287		// DTO shape so the log makes the "every findFiles returns 0"
288		// pattern debuggable. The pattern is the most common source
289		// of zero-results - VS Code's internal callers sometimes pass
290		// a `RelativePattern` whose `pattern` is `**/*.json` plus a
291		// `base` that doesn't intersect any workspace folder, which
292		// silently falls through to the all-folders walk but with a
293		// pattern like `/**/*.json` (leading slash) that globset
294		// then fails to match against the relative paths produced by
295		// `Path.strip_prefix(...)`.
296		dev_log!(
297			"workspaces",
298			"[FindFilesInWorkspace] include={} dto_shape={}",
299			IncludePattern,
300			if IncludePatternDTO.is_string() {
301				"string"
302			} else if IncludePatternDTO.is_object() {
303				"object"
304			} else if IncludePatternDTO.is_null() {
305				"null"
306			} else {
307				"other"
308			}
309		);
310		let ExcludePattern = ExcludePatternDTO
311			.as_ref()
312			.and_then(ExtractGlobPattern)
313			.filter(|P| !P.is_empty());
314		let Cap = MaxResults.unwrap_or(10_000).max(1);
315
316		let IncludeMatcher = GlobBuilder::new(&IncludePattern)
317			.literal_separator(false)
318			.build()
319			.map(|G| G.compile_matcher())
320			.map_err(|Error| {
321				CommonError::InvalidArgument { ArgumentName:"IncludePattern".into(), Reason:Error.to_string() }
322			})?;
323		let ExcludeMatcher = match &ExcludePattern {
324			Some(P) => {
325				Some(
326					GlobBuilder::new(P)
327						.literal_separator(false)
328						.build()
329						.map(|G| G.compile_matcher())
330						.map_err(|Error| {
331							CommonError::InvalidArgument {
332								ArgumentName:"ExcludePattern".into(),
333								Reason:Error.to_string(),
334							}
335						})?,
336				)
337			},
338			None => None,
339		};
340
341		// Optional `base` from a RelativePattern restricts the walk to
342		// a subfolder. Resolved against any registered workspace
343		// folder; if it doesn't match, walk all folders (matches
344		// VS Code's behaviour).
345		let RestrictBase = ExtractRelativeBase(&IncludePatternDTO);
346
347		let Folders:Vec<PathBuf> = self
348			.ApplicationState
349			.Workspace
350			.WorkspaceFolders
351			.lock()
352			.map_err(Utility::ErrorMapping::MapApplicationStateLockErrorToCommonError)?
353			.iter()
354			.filter_map(|Folder| Folder.URI.to_file_path().ok())
355			.collect();
356		if Folders.is_empty() {
357			dev_log!("workspaces", "[FindFilesInWorkspace] no workspace folders → []");
358			return Ok(Vec::new());
359		}
360
361		let WalkRoots:Vec<PathBuf> = match &RestrictBase {
362			Some(Base) => {
363				let BasePath = PathBuf::from(Base);
364				if Folders.iter().any(|F| BasePath.starts_with(F) || F.starts_with(&BasePath)) {
365					vec![BasePath]
366				} else {
367					Folders.clone()
368				}
369			},
370			None => Folders.clone(),
371		};
372
373		// Cache lookup: return a clone of the stored result when the same
374		// (folders, include, exclude, cap, flags) tuple was walked within
375		// the TTL window. The workbench fires findFiles repeatedly during
376		// Cmd+P typing - serving the second-and-later calls from cache
377		// drops the per-keystroke latency from "walk the tree" to a
378		// HashMap lookup.
379		let CacheKey = FindFilesCacheKey {
380			Folders:WalkRoots.clone(),
381			Include:IncludePattern.clone(),
382			Exclude:ExcludePattern.clone(),
383			Cap,
384			UseIgnoreFiles,
385			FollowSymlinks,
386			RestrictBase:RestrictBase.clone(),
387		};
388		if let Some(Cached) = FindFilesCacheGet(&CacheKey) {
389			dev_log!("workspaces", "[FindFilesInWorkspace] cache hit → {} match(es)", Cached.len());
390			return Ok(Cached);
391		}
392
393		// Single-flight: if another caller is already walking for this
394		// exact key, register as a follower and await the leader's
395		// completion notify, then read the freshly-populated cache.
396		// Otherwise we ARE the leader and proceed with the walk; on
397		// completion we wake all waiters.
398		// Lock-scope is restructured into an enum return so the
399		// std::sync::MutexGuard is fully dropped BEFORE any `.await`
400		// in either branch - otherwise the future is `!Send` and
401		// tokio refuses to spawn it across worker threads.
402		enum SingleFlightRole {
403			Follower(Arc<Notify>),
404			Leader(Arc<Notify>),
405		}
406		let RoleResolved:SingleFlightRole = {
407			let mut Guard = FindFilesInFlight()
408				.lock()
409				.map_err(|Error| CommonError::StateLockPoisoned { Context:Error.to_string() })?;
410			match Guard.get(&CacheKey) {
411				Some(Existing) => SingleFlightRole::Follower(Existing.clone()),
412				None => {
413					let LeaderNotify = Arc::new(Notify::new());
414					Guard.insert(CacheKey.clone(), LeaderNotify.clone());
415					SingleFlightRole::Leader(LeaderNotify)
416				},
417			}
418		};
419		let LeaderNotify:Arc<Notify> = match RoleResolved {
420			SingleFlightRole::Follower(WaitNotify) => {
421				dev_log!(
422					"workspaces",
423					"[FindFilesInWorkspace] singleflight wait - leader walk in progress for include={}",
424					IncludePattern
425				);
426				WaitNotify.notified().await;
427				return Ok(FindFilesCacheGet(&CacheKey).unwrap_or_default());
428			},
429			SingleFlightRole::Leader(N) => N,
430		};
431
432		// Defensive: if anything between here and the cache-put panics
433		// or returns Err, waiters would block forever. Guard with a
434		// drop-time notify-and-remove via a small RAII helper.
435		struct LeaderGuard {
436			Key:FindFilesCacheKey,
437			Notify:Arc<Notify>,
438			Completed:bool,
439		}
440		impl Drop for LeaderGuard {
441			fn drop(&mut self) {
442				if !self.Completed {
443					if let Ok(mut Guard) = FindFilesInFlight().lock() {
444						Guard.remove(&self.Key);
445					}
446					self.Notify.notify_waiters();
447				}
448			}
449		}
450		let mut Leader = LeaderGuard { Key:CacheKey.clone(), Notify:LeaderNotify, Completed:false };
451
452		let Results:Arc<Mutex<Vec<Url>>> = Arc::new(Mutex::new(Vec::with_capacity(Cap.min(1024))));
453		let Cap = Cap;
454
455		for Root in WalkRoots {
456			if Results.lock().map(|G| G.len() >= Cap).unwrap_or(true) {
457				break;
458			}
459			let RootForRel = Root.clone();
460			let IncludeMatcher = IncludeMatcher.clone();
461			let ExcludeMatcher = ExcludeMatcher.clone();
462			let ResultsArc = Results.clone();
463
464			let mut Builder = WalkBuilder::new(&Root);
465			Builder
466				.standard_filters(UseIgnoreFiles)
467				.git_ignore(UseIgnoreFiles)
468				.git_global(UseIgnoreFiles)
469				.git_exclude(UseIgnoreFiles)
470				.ignore(UseIgnoreFiles)
471				.parents(UseIgnoreFiles)
472				.follow_links(FollowSymlinks)
473				.hidden(true);
474
475			Builder.build_parallel().run(|| {
476				let RootForRel = RootForRel.clone();
477				let IncludeMatcher = IncludeMatcher.clone();
478				let ExcludeMatcher = ExcludeMatcher.clone();
479				let ResultsArc = ResultsArc.clone();
480				Box::new(move |EntryResult| {
481					if ResultsArc.lock().map(|G| G.len() >= Cap).unwrap_or(true) {
482						return ignore::WalkState::Quit;
483					}
484					let Entry = match EntryResult {
485						Ok(E) => E,
486						Err(_) => return ignore::WalkState::Continue,
487					};
488					if !Entry.file_type().map(|T| T.is_file()).unwrap_or(false) {
489						return ignore::WalkState::Continue;
490					}
491					let Path = Entry.path();
492					let Relative = match Path.strip_prefix(&RootForRel) {
493						Ok(R) => R.to_string_lossy().replace('\\', "/"),
494						Err(_) => Path.to_string_lossy().to_string(),
495					};
496					if let Some(Excl) = &ExcludeMatcher {
497						if Excl.is_match(&Relative) {
498							return ignore::WalkState::Continue;
499						}
500					}
501					if !IncludeMatcher.is_match(&Relative) {
502						return ignore::WalkState::Continue;
503					}
504					if let Ok(FileUrl) = Url::from_file_path(Path) {
505						let mut Guard = match ResultsArc.lock() {
506							Ok(G) => G,
507							Err(_) => return ignore::WalkState::Quit,
508						};
509						if Guard.len() < Cap {
510							Guard.push(FileUrl);
511						}
512						if Guard.len() >= Cap {
513							return ignore::WalkState::Quit;
514						}
515					}
516					ignore::WalkState::Continue
517				})
518			});
519		}
520
521		let Final = Arc::try_unwrap(Results)
522			.map_err(|_| {
523				CommonError::Unknown { Description:"FindFilesInWorkspace: result Arc had outstanding refs".into() }
524			})?
525			.into_inner()
526			.map_err(|Error| CommonError::StateLockPoisoned { Context:Error.to_string() })?;
527		dev_log!(
528			"workspaces",
529			"[FindFilesInWorkspace] returned {} match(es) include={} exclude={:?} roots={}",
530			Final.len(),
531			IncludePattern,
532			ExcludePattern,
533			CacheKey.Folders.len()
534		);
535		FindFilesCachePut(CacheKey.clone(), Final.clone());
536
537		// Successful walk + cache put: clear the in-flight entry and
538		// wake any followers BEFORE the LeaderGuard drop fires so
539		// followers see `Completed=true` and skip the drop-time
540		// fallback path.
541		{
542			if let Ok(mut Guard) = FindFilesInFlight().lock() {
543				Guard.remove(&CacheKey);
544			}
545			Leader.Notify.notify_waiters();
546			Leader.Completed = true;
547		}
548
549		Ok(Final)
550	}
551
552	/// Opens a file in the editor by emitting the same
553	/// `sky://editor/openDocument` event the workbench's
554	/// `IEditorService.openEditor(uri)` path produces. Sky's bridge
555	/// listens on this event and forwards through to the live
556	/// `__CEL_SERVICES__.Commands.executeCommand("vscode.open", …)`
557	/// inside the Output workbench bundle, which is what actually
558	/// surfaces the file in the editor area.
559	///
560	/// Path resolution: accepts an absolute path (already a `PathBuf`).
561	/// Constructs a `file://` URI via `Url::from_file_path` for
562	/// proper percent-encoding of unicode / special chars; falls
563	/// back to a manual prefix for relative paths (rare; Mountain
564	/// callers always pass absolute paths via the trait).
565	async fn OpenFile(&self, path:PathBuf) -> Result<(), CommonError> {
566		use tauri::Emitter;
567		dev_log!("workspaces", "[WorkspaceProvider] OpenFile called for: {:?}", path);
568
569		let UriString = match Url::from_file_path(&path) {
570			Ok(U) => U.to_string(),
571			Err(_) => format!("file://{}", path.to_string_lossy()),
572		};
573
574		self.ApplicationHandle
575			.emit(
576				"sky://editor/openDocument",
577				serde_json::json!({
578					"uri": UriString,
579					"viewColumn": null,
580				}),
581			)
582			.map_err(|Error| CommonError::UserInterfaceInteraction { Reason:Error.to_string() })?;
583
584		Ok(())
585	}
586}
587
588#[async_trait]
589impl WorkspaceEditApplier for MountainEnvironment {
590	/// Applies a workspace edit. Two-tier behaviour:
591	///
592	///   1. Emit `sky://editor/applyEdits` per URI so the workbench's
593	///      `BulkEditService` applies edits to documents currently open in the
594	///      editor (the canonical path - keeps undo / dirty state intact).
595	///   2. For URIs that aren't currently tracked by the document mirror, fall
596	///      through to a direct on-disk apply: read the file, sort edits by
597	///      descending offset, splice each edit's `newText` into place, write
598	///      atomically. Lets refactoring extensions touch files the user hasn't
599	///      opened.
600	///
601	/// Each `TextEdit` is a JSON shape matching VS Code's
602	/// `TextEditDTO`: `{ range: { start: {line, character}, end:
603	/// {line, character} }, newText: string }`. Line/character are
604	/// zero-based.
605	async fn ApplyWorkspaceEdit(&self, Edit:WorkspaceEditDTO) -> Result<bool, CommonError> {
606		use tauri::Emitter;
607		dev_log!("workspaces", "[WorkspaceEditApplier] Applying workspace edit");
608
609		let WorkspaceEditDTO { Edits } = Edit;
610		let DocumentMirror = &self.ApplicationState.Feature.Documents;
611		let mut AnyFailure = false;
612
613		for (DocumentURIValue, TextEdits) in Edits {
614			let UriString = DocumentURIValue
615				.as_str()
616				.map(String::from)
617				.or_else(|| DocumentURIValue.get("value").and_then(Value::as_str).map(String::from))
618				.unwrap_or_default();
619			if UriString.is_empty() {
620				dev_log!("workspaces", "warn: [WorkspaceEditApplier] empty URI in edit; skipping");
621				continue;
622			}
623
624			// Tier 1: workbench-open document → emit Sky event.
625			let _ = self.ApplicationHandle.emit(
626				"sky://editor/applyEdits",
627				serde_json::json!({
628					"uri": UriString,
629					"edits": TextEdits,
630				}),
631			);
632
633			// Tier 2: if the document mirror doesn't know this URI,
634			// also splice the edits to disk so refactors that touch
635			// closed files actually mutate them. The renderer's
636			// edit-apply path is a no-op on URIs it doesn't host -
637			// the dual emit is safe (event lands in renderer for the
638			// same-document case; on-disk writes happen for closed
639			// files only).
640			let IsOpen = DocumentMirror.Get(&UriString).is_some();
641			if !IsOpen {
642				if let Err(Error) = ApplyEditsToDisk(&UriString, &TextEdits).await {
643					AnyFailure = true;
644					dev_log!(
645						"workspaces",
646						"warn: [WorkspaceEditApplier] on-disk apply failed for {}: {}",
647						UriString,
648						Error
649					);
650				}
651			}
652		}
653
654		Ok(!AnyFailure)
655	}
656}
657
658/// Splice a list of `TextEditDTO`-shaped edits into the file at
659/// `UriString`. Edits are applied in **descending** start offset so
660/// each subsequent edit's offsets stay valid. Errors propagate as
661/// `CommonError::FromStandardIOError` for read/write failures and
662/// `CommonError::InvalidArgument` for malformed edits.
663async fn ApplyEditsToDisk(UriString:&str, TextEdits:&[Value]) -> Result<(), CommonError> {
664	use std::path::Path;
665	let RawPath = if let Some(Stripped) = UriString.strip_prefix("file://") {
666		percent_decode(Stripped)
667	} else if UriString.starts_with('/') {
668		UriString.to_string()
669	} else {
670		return Err(CommonError::InvalidArgument {
671			ArgumentName:"uri".into(),
672			Reason:format!("ApplyWorkspaceEdit: unsupported scheme in {}", UriString),
673		});
674	};
675	let Path = Path::new(&RawPath);
676
677	let Original = tokio::fs::read_to_string(Path)
678		.await
679		.map_err(|Error| CommonError::FromStandardIOError(Error, Path.to_path_buf(), "ApplyWorkspaceEdit.Read"))?;
680
681	// Convert (line, character) positions to absolute byte offsets via
682	// a single line-prefix scan. Edits referencing positions past EOF
683	// are clamped to EOF (matches VS Code's bulk-edit forgiving
684	// semantics on truncated files).
685	let LineOffsets = ComputeLineOffsets(&Original);
686	let mut WithOffsets:Vec<(usize, usize, String)> = Vec::with_capacity(TextEdits.len());
687	for Edit in TextEdits {
688		let StartLine = Edit.pointer("/range/start/line").and_then(Value::as_u64).unwrap_or(0) as usize;
689		let StartChar = Edit.pointer("/range/start/character").and_then(Value::as_u64).unwrap_or(0) as usize;
690		let EndLine = Edit
691			.pointer("/range/end/line")
692			.and_then(Value::as_u64)
693			.unwrap_or(StartLine as u64) as usize;
694		let EndChar = Edit
695			.pointer("/range/end/character")
696			.and_then(Value::as_u64)
697			.unwrap_or(StartChar as u64) as usize;
698		let NewText = Edit.get("newText").and_then(Value::as_str).unwrap_or("").to_string();
699		let StartOffset = LinePosToOffset(&LineOffsets, &Original, StartLine, StartChar);
700		let EndOffset = LinePosToOffset(&LineOffsets, &Original, EndLine, EndChar);
701		WithOffsets.push((StartOffset, EndOffset, NewText));
702	}
703
704	WithOffsets.sort_by(|A, B| B.0.cmp(&A.0));
705
706	let mut Mutated = Original;
707	for (Start, End, NewText) in WithOffsets {
708		let SafeStart = Start.min(Mutated.len());
709		let SafeEnd = End.max(SafeStart).min(Mutated.len());
710		Mutated.replace_range(SafeStart..SafeEnd, &NewText);
711	}
712
713	// Write via tempfile + rename for atomicity. Avoids torn writes
714	// if the process is killed mid-mutation.
715	let TempPath = Path.with_extension(format!(
716		"{}.land-tmp-{}",
717		Path.extension().and_then(|E| E.to_str()).unwrap_or("tmp"),
718		std::process::id()
719	));
720	tokio::fs::write(&TempPath, Mutated.as_bytes())
721		.await
722		.map_err(|Error| CommonError::FromStandardIOError(Error, TempPath.clone(), "ApplyWorkspaceEdit.Write"))?;
723	tokio::fs::rename(&TempPath, Path)
724		.await
725		.map_err(|Error| CommonError::FromStandardIOError(Error, Path.to_path_buf(), "ApplyWorkspaceEdit.Rename"))?;
726	Ok(())
727}
728
729/// Pre-compute the byte offset of the start of every line.
730fn ComputeLineOffsets(Source:&str) -> Vec<usize> {
731	let mut Offsets = Vec::with_capacity(Source.len() / 40 + 1);
732	Offsets.push(0);
733	for (Index, Byte) in Source.bytes().enumerate() {
734		if Byte == b'\n' {
735			Offsets.push(Index + 1);
736		}
737	}
738	Offsets
739}
740
741/// Resolve `(line, character)` to an absolute byte offset. Character is
742/// counted in **UTF-16 code units** to match VS Code's
743/// `Range`/`Position` semantics. Falls back gracefully when line/char
744/// is past EOF.
745fn LinePosToOffset(LineOffsets:&[usize], Source:&str, Line:usize, Character:usize) -> usize {
746	if Line >= LineOffsets.len() {
747		return Source.len();
748	}
749	let LineStart = LineOffsets[Line];
750	let LineEnd = if Line + 1 < LineOffsets.len() {
751		LineOffsets[Line + 1].saturating_sub(1)
752	} else {
753		Source.len()
754	};
755	let LineText = &Source[LineStart..LineEnd.min(Source.len())];
756	let mut Utf16Count:usize = 0;
757	for (ByteOffset, Char) in LineText.char_indices() {
758		if Utf16Count >= Character {
759			return LineStart + ByteOffset;
760		}
761		Utf16Count += Char.len_utf16();
762	}
763	LineStart + LineText.len()
764}
765
766/// Minimal percent-decode for `file://` URI paths. Reuses the
767/// project's existing helpers when possible; this self-contained
768/// version avoids an extra crate import.
769fn percent_decode(Input:&str) -> String {
770	let mut Out = String::with_capacity(Input.len());
771	let mut Bytes = Input.as_bytes().iter().peekable();
772	while let Some(&Byte) = Bytes.next() {
773		if Byte == b'%' {
774			let H = Bytes.next().copied();
775			let L = Bytes.next().copied();
776			if let (Some(H), Some(L)) = (H, L) {
777				if let (Some(Hi), Some(Lo)) = (HexDigit(H), HexDigit(L)) {
778					Out.push((Hi * 16 + Lo) as char);
779					continue;
780				}
781				Out.push('%');
782				Out.push(H as char);
783				Out.push(L as char);
784				continue;
785			}
786			Out.push('%');
787		} else {
788			Out.push(Byte as char);
789		}
790	}
791	Out
792}
793
794fn HexDigit(Byte:u8) -> Option<u8> {
795	match Byte {
796		b'0'..=b'9' => Some(Byte - b'0'),
797		b'a'..=b'f' => Some(Byte - b'a' + 10),
798		b'A'..=b'F' => Some(Byte - b'A' + 10),
799		_ => None,
800	}
801}
802
803/// Extract a glob string from any of the shapes a caller can hand us:
804///   - Bare string: `"**/*.rs"` → returned as-is.
805///   - Object with `pattern`: `{ pattern: "..." }` (or `{ base, pattern }` for
806///     VS Code's `RelativePattern`).
807///   - Object whose `value` field is a string: legacy serialised form.
808fn ExtractGlobPattern(Pattern:&Value) -> Option<String> {
809	if let Some(S) = Pattern.as_str() {
810		return Some(S.to_string());
811	}
812	if let Some(Obj) = Pattern.as_object() {
813		if let Some(P) = Obj.get("pattern").and_then(Value::as_str) {
814			return Some(P.to_string());
815		}
816		if let Some(P) = Obj.get("value").and_then(Value::as_str) {
817			return Some(P.to_string());
818		}
819		if let Some(P) = Obj.get("Pattern").and_then(Value::as_str) {
820			return Some(P.to_string());
821		}
822	}
823	None
824}
825
826/// Extract a `base` directory from a `RelativePattern`-shaped value.
827/// VS Code's `RelativePattern` carries `{ base, pattern }` (or
828/// `{ baseUri, pattern }`); when present, the walk must be restricted
829/// to `base`. Returns `None` for plain glob strings.
830fn ExtractRelativeBase(Pattern:&Value) -> Option<String> {
831	let Obj = Pattern.as_object()?;
832	if let Some(B) = Obj.get("base").and_then(Value::as_str) {
833		return Some(B.to_string());
834	}
835	if let Some(B) = Obj.get("baseUri") {
836		if let Some(S) = B.as_str() {
837			if let Some(Stripped) = S.strip_prefix("file://") {
838				return Some(Stripped.to_string());
839			}
840			return Some(S.to_string());
841		}
842		if let Some(P) = B.as_object().and_then(|O| O.get("path")).and_then(Value::as_str) {
843			return Some(P.to_string());
844		}
845		if let Some(P) = B.as_object().and_then(|O| O.get("fsPath")).and_then(Value::as_str) {
846			return Some(P.to_string());
847		}
848	}
849	None
850}