1use 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
44const 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
87fn 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
125pub fn ClearFindFilesCache() {
131 if let Ok(mut Guard) = FindFilesCache().lock() {
132 Guard.clear();
133 }
134}
135
136fn 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 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 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 async fn GetWorkspaceName(&self) -> Result<Option<String>, CommonError> {
191 self.ApplicationState.GetWorkspaceIdentifier().map(Some)
192 }
193
194 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 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 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 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 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 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 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 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 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 {
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 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 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 let _ = self.ApplicationHandle.emit(
615 "sky://editor/applyEdits",
616 serde_json::json!({
617 "uri": UriString,
618 "edits": TextEdits,
619 }),
620 );
621
622 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
647async 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 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 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
718fn 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
730fn 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
755fn 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
792fn 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
815fn 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}