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
300		let ExcludePattern = ExcludePatternDTO
301			.as_ref()
302			.and_then(ExtractGlobPattern)
303			.filter(|P| !P.is_empty());
304
305		let Cap = MaxResults.unwrap_or(10_000).max(1);
306
307		let IncludeMatcher = GlobBuilder::new(&IncludePattern)
308			.literal_separator(false)
309			.build()
310			.map(|G| G.compile_matcher())
311			.map_err(|Error| {
312				CommonError::InvalidArgument { ArgumentName:"IncludePattern".into(), Reason:Error.to_string() }
313			})?;
314
315		let ExcludeMatcher = match &ExcludePattern {
316			Some(P) => {
317				Some(
318					GlobBuilder::new(P)
319						.literal_separator(false)
320						.build()
321						.map(|G| G.compile_matcher())
322						.map_err(|Error| {
323							CommonError::InvalidArgument {
324								ArgumentName:"ExcludePattern".into(),
325								Reason:Error.to_string(),
326							}
327						})?,
328				)
329			},
330
331			None => None,
332		};
333
334		// Optional `base` from a RelativePattern restricts the walk to
335		// a subfolder. Resolved against any registered workspace
336		// folder; if it doesn't match, walk all folders (matches
337		// VS Code's behaviour).
338		let RestrictBase = ExtractRelativeBase(&IncludePatternDTO);
339
340		let Folders:Vec<PathBuf> = self
341			.ApplicationState
342			.Workspace
343			.WorkspaceFolders
344			.lock()
345			.map_err(Utility::ErrorMapping::MapApplicationStateLockErrorToCommonError)?
346			.iter()
347			.filter_map(|Folder| Folder.URI.to_file_path().ok())
348			.collect();
349
350		if Folders.is_empty() {
351			dev_log!("workspaces", "[FindFilesInWorkspace] no workspace folders → []");
352
353			return Ok(Vec::new());
354		}
355
356		let WalkRoots:Vec<PathBuf> = match &RestrictBase {
357			Some(Base) => {
358				let BasePath = PathBuf::from(Base);
359
360				if Folders.iter().any(|F| BasePath.starts_with(F) || F.starts_with(&BasePath)) {
361					vec![BasePath]
362				} else {
363					Folders.clone()
364				}
365			},
366
367			None => Folders.clone(),
368		};
369
370		// Cache lookup: return a clone of the stored result when the same
371		// (folders, include, exclude, cap, flags) tuple was walked within
372		// the TTL window. The workbench fires findFiles repeatedly during
373		// Cmd+P typing - serving the second-and-later calls from cache
374		// drops the per-keystroke latency from "walk the tree" to a
375		// HashMap lookup.
376		let CacheKey = FindFilesCacheKey {
377			Folders:WalkRoots.clone(),
378
379			Include:IncludePattern.clone(),
380
381			Exclude:ExcludePattern.clone(),
382
383			Cap,
384
385			UseIgnoreFiles,
386
387			FollowSymlinks,
388
389			RestrictBase:RestrictBase.clone(),
390		};
391
392		if let Some(Cached) = FindFilesCacheGet(&CacheKey) {
393			dev_log!("workspaces", "[FindFilesInWorkspace] cache hit → {} match(es)", Cached.len());
394
395			return Ok(Cached);
396		}
397
398		// Single-flight: if another caller is already walking for this
399		// exact key, register as a follower and await the leader's
400		// completion notify, then read the freshly-populated cache.
401		// Otherwise we ARE the leader and proceed with the walk; on
402		// completion we wake all waiters.
403		// Lock-scope is restructured into an enum return so the
404		// std::sync::MutexGuard is fully dropped BEFORE any `.await`
405		// in either branch - otherwise the future is `!Send` and
406		// tokio refuses to spawn it across worker threads.
407		enum SingleFlightRole {
408			Follower(Arc<Notify>),
409
410			Leader(Arc<Notify>),
411		}
412
413		let RoleResolved:SingleFlightRole = {
414			let mut Guard = FindFilesInFlight()
415				.lock()
416				.map_err(|Error| CommonError::StateLockPoisoned { Context:Error.to_string() })?;
417
418			match Guard.get(&CacheKey) {
419				Some(Existing) => SingleFlightRole::Follower(Existing.clone()),
420
421				None => {
422					let LeaderNotify = Arc::new(Notify::new());
423
424					Guard.insert(CacheKey.clone(), LeaderNotify.clone());
425
426					SingleFlightRole::Leader(LeaderNotify)
427				},
428			}
429		};
430
431		let LeaderNotify:Arc<Notify> = match RoleResolved {
432			SingleFlightRole::Follower(WaitNotify) => {
433				dev_log!(
434					"workspaces",
435					"[FindFilesInWorkspace] singleflight wait - leader walk in progress for include={}",
436					IncludePattern
437				);
438
439				WaitNotify.notified().await;
440
441				return Ok(FindFilesCacheGet(&CacheKey).unwrap_or_default());
442			},
443
444			SingleFlightRole::Leader(N) => N,
445		};
446
447		// Defensive: if anything between here and the cache-put panics
448		// or returns Err, waiters would block forever. Guard with a
449		// drop-time notify-and-remove via a small RAII helper.
450		struct LeaderGuard {
451			Key:FindFilesCacheKey,
452
453			Notify:Arc<Notify>,
454
455			Completed:bool,
456		}
457
458		impl Drop for LeaderGuard {
459			fn drop(&mut self) {
460				if !self.Completed {
461					if let Ok(mut Guard) = FindFilesInFlight().lock() {
462						Guard.remove(&self.Key);
463					}
464
465					self.Notify.notify_waiters();
466				}
467			}
468		}
469
470		let mut Leader = LeaderGuard { Key:CacheKey.clone(), Notify:LeaderNotify, Completed:false };
471
472		let Results:Arc<Mutex<Vec<Url>>> = Arc::new(Mutex::new(Vec::with_capacity(Cap.min(1024))));
473
474		let Cap = Cap;
475
476		for Root in WalkRoots {
477			if Results.lock().map(|G| G.len() >= Cap).unwrap_or(true) {
478				break;
479			}
480
481			let RootForRel = Root.clone();
482
483			let IncludeMatcher = IncludeMatcher.clone();
484
485			let ExcludeMatcher = ExcludeMatcher.clone();
486
487			let ResultsArc = Results.clone();
488
489			let mut Builder = WalkBuilder::new(&Root);
490
491			Builder
492				.standard_filters(UseIgnoreFiles)
493				.git_ignore(UseIgnoreFiles)
494				.git_global(UseIgnoreFiles)
495				.git_exclude(UseIgnoreFiles)
496				.ignore(UseIgnoreFiles)
497				.parents(UseIgnoreFiles)
498				.follow_links(FollowSymlinks)
499				.hidden(true);
500
501			Builder.build_parallel().run(|| {
502				let RootForRel = RootForRel.clone();
503				let IncludeMatcher = IncludeMatcher.clone();
504				let ExcludeMatcher = ExcludeMatcher.clone();
505				let ResultsArc = ResultsArc.clone();
506				Box::new(move |EntryResult| {
507					if ResultsArc.lock().map(|G| G.len() >= Cap).unwrap_or(true) {
508						return ignore::WalkState::Quit;
509					}
510					let Entry = match EntryResult {
511						Ok(E) => E,
512						Err(_) => return ignore::WalkState::Continue,
513					};
514					if !Entry.file_type().map(|T| T.is_file()).unwrap_or(false) {
515						return ignore::WalkState::Continue;
516					}
517					let Path = Entry.path();
518					let Relative = match Path.strip_prefix(&RootForRel) {
519						Ok(R) => R.to_string_lossy().replace('\\', "/"),
520						Err(_) => Path.to_string_lossy().to_string(),
521					};
522					if let Some(Excl) = &ExcludeMatcher {
523						if Excl.is_match(&Relative) {
524							return ignore::WalkState::Continue;
525						}
526					}
527					if !IncludeMatcher.is_match(&Relative) {
528						return ignore::WalkState::Continue;
529					}
530					if let Ok(FileUrl) = Url::from_file_path(Path) {
531						let mut Guard = match ResultsArc.lock() {
532							Ok(G) => G,
533							Err(_) => return ignore::WalkState::Quit,
534						};
535						if Guard.len() < Cap {
536							Guard.push(FileUrl);
537						}
538						if Guard.len() >= Cap {
539							return ignore::WalkState::Quit;
540						}
541					}
542					ignore::WalkState::Continue
543				})
544			});
545		}
546
547		let Final = Arc::try_unwrap(Results)
548			.map_err(|_| {
549				CommonError::Unknown { Description:"FindFilesInWorkspace: result Arc had outstanding refs".into() }
550			})?
551			.into_inner()
552			.map_err(|Error| CommonError::StateLockPoisoned { Context:Error.to_string() })?;
553
554		dev_log!(
555			"workspaces",
556			"[FindFilesInWorkspace] returned {} match(es) include={} exclude={:?} roots={}",
557			Final.len(),
558			IncludePattern,
559			ExcludePattern,
560			CacheKey.Folders.len()
561		);
562
563		FindFilesCachePut(CacheKey.clone(), Final.clone());
564
565		// Successful walk + cache put: clear the in-flight entry and
566		// wake any followers BEFORE the LeaderGuard drop fires so
567		// followers see `Completed=true` and skip the drop-time
568		// fallback path.
569		{
570			if let Ok(mut Guard) = FindFilesInFlight().lock() {
571				Guard.remove(&CacheKey);
572			}
573
574			Leader.Notify.notify_waiters();
575
576			Leader.Completed = true;
577		}
578
579		Ok(Final)
580	}
581
582	/// Opens a file in the editor by emitting the same
583	/// `sky://editor/openDocument` event the workbench's
584	/// `IEditorService.openEditor(uri)` path produces. Sky's bridge
585	/// listens on this event and forwards through to the live
586	/// `__CEL_SERVICES__.Commands.executeCommand("vscode.open", …)`
587	/// inside the Output workbench bundle, which is what actually
588	/// surfaces the file in the editor area.
589	///
590	/// Path resolution: accepts an absolute path (already a `PathBuf`).
591	/// Constructs a `file://` URI via `Url::from_file_path` for
592	/// proper percent-encoding of unicode / special chars; falls
593	/// back to a manual prefix for relative paths (rare; Mountain
594	/// callers always pass absolute paths via the trait).
595	async fn OpenFile(&self, path:PathBuf) -> Result<(), CommonError> {
596		use tauri::Emitter;
597
598		dev_log!("workspaces", "[WorkspaceProvider] OpenFile called for: {:?}", path);
599
600		let UriString = match Url::from_file_path(&path) {
601			Ok(U) => U.to_string(),
602
603			Err(_) => format!("file://{}", path.to_string_lossy()),
604		};
605
606		self.ApplicationHandle
607			.emit(
608				"sky://editor/openDocument",
609				serde_json::json!({
610					"uri": UriString,
611					"viewColumn": null,
612				}),
613			)
614			.map_err(|Error| CommonError::UserInterfaceInteraction { Reason:Error.to_string() })?;
615
616		Ok(())
617	}
618}
619
620#[async_trait]
621impl WorkspaceEditApplier for MountainEnvironment {
622	/// Applies a workspace edit. Two-tier behaviour:
623	///
624	///   1. Emit `sky://editor/applyEdits` per URI so the workbench's
625	///      `BulkEditService` applies edits to documents currently open in the
626	///      editor (the canonical path - keeps undo / dirty state intact).
627	///   2. For URIs that aren't currently tracked by the document mirror, fall
628	///      through to a direct on-disk apply: read the file, sort edits by
629	///      descending offset, splice each edit's `newText` into place, write
630	///      atomically. Lets refactoring extensions touch files the user hasn't
631	///      opened.
632	///
633	/// Each `TextEdit` is a JSON shape matching VS Code's
634	/// `TextEditDTO`: `{ range: { start: {line, character}, end:
635	/// {line, character} }, newText: string }`. Line/character are
636	/// zero-based.
637	async fn ApplyWorkspaceEdit(&self, Edit:WorkspaceEditDTO) -> Result<bool, CommonError> {
638		use tauri::Emitter;
639
640		dev_log!("workspaces", "[WorkspaceEditApplier] Applying workspace edit");
641
642		let WorkspaceEditDTO { Edits } = Edit;
643
644		let DocumentMirror = &self.ApplicationState.Feature.Documents;
645
646		let mut AnyFailure = false;
647
648		for (DocumentURIValue, TextEdits) in Edits {
649			let UriString = DocumentURIValue
650				.as_str()
651				.map(String::from)
652				.or_else(|| DocumentURIValue.get("value").and_then(Value::as_str).map(String::from))
653				.unwrap_or_default();
654
655			if UriString.is_empty() {
656				dev_log!("workspaces", "warn: [WorkspaceEditApplier] empty URI in edit; skipping");
657
658				continue;
659			}
660
661			// Tier 1: workbench-open document → emit Sky event.
662			let _ = self.ApplicationHandle.emit(
663				"sky://editor/applyEdits",
664				serde_json::json!({
665					"uri": UriString,
666					"edits": TextEdits,
667				}),
668			);
669
670			// Tier 2: if the document mirror doesn't know this URI,
671			// also splice the edits to disk so refactors that touch
672			// closed files actually mutate them. The renderer's
673			// edit-apply path is a no-op on URIs it doesn't host -
674			// the dual emit is safe (event lands in renderer for the
675			// same-document case; on-disk writes happen for closed
676			// files only).
677			let IsOpen = DocumentMirror.Get(&UriString).is_some();
678
679			if !IsOpen {
680				if let Err(Error) = ApplyEditsToDisk(&UriString, &TextEdits).await {
681					AnyFailure = true;
682
683					dev_log!(
684						"workspaces",
685						"warn: [WorkspaceEditApplier] on-disk apply failed for {}: {}",
686						UriString,
687						Error
688					);
689				}
690			}
691		}
692
693		Ok(!AnyFailure)
694	}
695}
696
697/// Splice a list of `TextEditDTO`-shaped edits into the file at
698/// `UriString`. Edits are applied in **descending** start offset so
699/// each subsequent edit's offsets stay valid. Errors propagate as
700/// `CommonError::FromStandardIOError` for read/write failures and
701/// `CommonError::InvalidArgument` for malformed edits.
702async fn ApplyEditsToDisk(UriString:&str, TextEdits:&[Value]) -> Result<(), CommonError> {
703	use std::path::Path;
704
705	let RawPath = if let Some(Stripped) = UriString.strip_prefix("file://") {
706		percent_decode(Stripped)
707	} else if UriString.starts_with('/') {
708		UriString.to_string()
709	} else {
710		return Err(CommonError::InvalidArgument {
711			ArgumentName:"uri".into(),
712			Reason:format!("ApplyWorkspaceEdit: unsupported scheme in {}", UriString),
713		});
714	};
715
716	let Path = Path::new(&RawPath);
717
718	let Original = tokio::fs::read_to_string(Path)
719		.await
720		.map_err(|Error| CommonError::FromStandardIOError(Error, Path.to_path_buf(), "ApplyWorkspaceEdit.Read"))?;
721
722	// Convert (line, character) positions to absolute byte offsets via
723	// a single line-prefix scan. Edits referencing positions past EOF
724	// are clamped to EOF (matches VS Code's bulk-edit forgiving
725	// semantics on truncated files).
726	let LineOffsets = ComputeLineOffsets(&Original);
727
728	let mut WithOffsets:Vec<(usize, usize, String)> = Vec::with_capacity(TextEdits.len());
729
730	for Edit in TextEdits {
731		let StartLine = Edit.pointer("/range/start/line").and_then(Value::as_u64).unwrap_or(0) as usize;
732
733		let StartChar = Edit.pointer("/range/start/character").and_then(Value::as_u64).unwrap_or(0) as usize;
734
735		let EndLine = Edit
736			.pointer("/range/end/line")
737			.and_then(Value::as_u64)
738			.unwrap_or(StartLine as u64) as usize;
739
740		let EndChar = Edit
741			.pointer("/range/end/character")
742			.and_then(Value::as_u64)
743			.unwrap_or(StartChar as u64) as usize;
744
745		let NewText = Edit.get("newText").and_then(Value::as_str).unwrap_or("").to_string();
746
747		let StartOffset = LinePosToOffset(&LineOffsets, &Original, StartLine, StartChar);
748
749		let EndOffset = LinePosToOffset(&LineOffsets, &Original, EndLine, EndChar);
750
751		WithOffsets.push((StartOffset, EndOffset, NewText));
752	}
753
754	WithOffsets.sort_by(|A, B| B.0.cmp(&A.0));
755
756	let mut Mutated = Original;
757
758	for (Start, End, NewText) in WithOffsets {
759		let SafeStart = Start.min(Mutated.len());
760
761		let SafeEnd = End.max(SafeStart).min(Mutated.len());
762
763		Mutated.replace_range(SafeStart..SafeEnd, &NewText);
764	}
765
766	// Write via tempfile + rename for atomicity. Avoids torn writes
767	// if the process is killed mid-mutation.
768	let TempPath = Path.with_extension(format!(
769		"{}.land-tmp-{}",
770		Path.extension().and_then(|E| E.to_str()).unwrap_or("tmp"),
771		std::process::id()
772	));
773
774	tokio::fs::write(&TempPath, Mutated.as_bytes())
775		.await
776		.map_err(|Error| CommonError::FromStandardIOError(Error, TempPath.clone(), "ApplyWorkspaceEdit.Write"))?;
777
778	tokio::fs::rename(&TempPath, Path)
779		.await
780		.map_err(|Error| CommonError::FromStandardIOError(Error, Path.to_path_buf(), "ApplyWorkspaceEdit.Rename"))?;
781
782	Ok(())
783}
784
785/// Pre-compute the byte offset of the start of every line.
786fn ComputeLineOffsets(Source:&str) -> Vec<usize> {
787	let mut Offsets = Vec::with_capacity(Source.len() / 40 + 1);
788
789	Offsets.push(0);
790
791	for (Index, Byte) in Source.bytes().enumerate() {
792		if Byte == b'\n' {
793			Offsets.push(Index + 1);
794		}
795	}
796
797	Offsets
798}
799
800/// Resolve `(line, character)` to an absolute byte offset. Character is
801/// counted in **UTF-16 code units** to match VS Code's
802/// `Range`/`Position` semantics. Falls back gracefully when line/char
803/// is past EOF.
804fn LinePosToOffset(LineOffsets:&[usize], Source:&str, Line:usize, Character:usize) -> usize {
805	if Line >= LineOffsets.len() {
806		return Source.len();
807	}
808
809	let LineStart = LineOffsets[Line];
810
811	let LineEnd = if Line + 1 < LineOffsets.len() {
812		LineOffsets[Line + 1].saturating_sub(1)
813	} else {
814		Source.len()
815	};
816
817	let LineText = &Source[LineStart..LineEnd.min(Source.len())];
818
819	let mut Utf16Count:usize = 0;
820
821	for (ByteOffset, Char) in LineText.char_indices() {
822		if Utf16Count >= Character {
823			return LineStart + ByteOffset;
824		}
825
826		Utf16Count += Char.len_utf16();
827	}
828
829	LineStart + LineText.len()
830}
831
832/// Minimal percent-decode for `file://` URI paths. Reuses the
833/// project's existing helpers when possible; this self-contained
834/// version avoids an extra crate import.
835fn percent_decode(Input:&str) -> String {
836	let mut Out = String::with_capacity(Input.len());
837
838	let mut Bytes = Input.as_bytes().iter().peekable();
839
840	while let Some(&Byte) = Bytes.next() {
841		if Byte == b'%' {
842			let H = Bytes.next().copied();
843
844			let L = Bytes.next().copied();
845
846			if let (Some(H), Some(L)) = (H, L) {
847				if let (Some(Hi), Some(Lo)) = (HexDigit(H), HexDigit(L)) {
848					Out.push((Hi * 16 + Lo) as char);
849
850					continue;
851				}
852
853				Out.push('%');
854
855				Out.push(H as char);
856
857				Out.push(L as char);
858
859				continue;
860			}
861
862			Out.push('%');
863		} else {
864			Out.push(Byte as char);
865		}
866	}
867
868	Out
869}
870
871fn HexDigit(Byte:u8) -> Option<u8> {
872	match Byte {
873		b'0'..=b'9' => Some(Byte - b'0'),
874
875		b'a'..=b'f' => Some(Byte - b'a' + 10),
876
877		b'A'..=b'F' => Some(Byte - b'A' + 10),
878
879		_ => None,
880	}
881}
882
883/// Extract a glob string from any of the shapes a caller can hand us:
884///   - Bare string: `"**/*.rs"` → returned as-is.
885///   - Object with `pattern`: `{ pattern: "..." }` (or `{ base, pattern }` for
886///     VS Code's `RelativePattern`).
887///   - Object whose `value` field is a string: legacy serialised form.
888fn ExtractGlobPattern(Pattern:&Value) -> Option<String> {
889	if let Some(S) = Pattern.as_str() {
890		return Some(S.to_string());
891	}
892
893	if let Some(Obj) = Pattern.as_object() {
894		if let Some(P) = Obj.get("pattern").and_then(Value::as_str) {
895			return Some(P.to_string());
896		}
897
898		if let Some(P) = Obj.get("value").and_then(Value::as_str) {
899			return Some(P.to_string());
900		}
901
902		if let Some(P) = Obj.get("Pattern").and_then(Value::as_str) {
903			return Some(P.to_string());
904		}
905	}
906
907	None
908}
909
910/// Extract a `base` directory from a `RelativePattern`-shaped value.
911/// VS Code's `RelativePattern` carries `{ base, pattern }` (or
912/// `{ baseUri, pattern }`); when present, the walk must be restricted
913/// to `base`. Returns `None` for plain glob strings.
914fn ExtractRelativeBase(Pattern:&Value) -> Option<String> {
915	let Obj = Pattern.as_object()?;
916
917	if let Some(B) = Obj.get("base").and_then(Value::as_str) {
918		return Some(B.to_string());
919	}
920
921	if let Some(B) = Obj.get("baseUri") {
922		if let Some(S) = B.as_str() {
923			if let Some(Stripped) = S.strip_prefix("file://") {
924				return Some(Stripped.to_string());
925			}
926
927			return Some(S.to_string());
928		}
929
930		if let Some(P) = B.as_object().and_then(|O| O.get("path")).and_then(Value::as_str) {
931			return Some(P.to_string());
932		}
933
934		if let Some(P) = B.as_object().and_then(|O| O.get("fsPath")).and_then(Value::as_str) {
935			return Some(P.to_string());
936		}
937	}
938
939	None
940}