Skip to main content

Mountain/Environment/
WorkspaceProvider.rs

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