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
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 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 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 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 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 {
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 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 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 let _ = self.ApplicationHandle.emit(
663 "sky://editor/applyEdits",
664 serde_json::json!({
665 "uri": UriString,
666 "edits": TextEdits,
667 }),
668 );
669
670 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
697async 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 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 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
785fn 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
800fn 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
832fn 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
883fn 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
910fn 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}