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
504 let IncludeMatcher = IncludeMatcher.clone();
505
506 let ExcludeMatcher = ExcludeMatcher.clone();
507
508 let ResultsArc = ResultsArc.clone();
509
510 Box::new(move |EntryResult| {
511 if ResultsArc.lock().map(|G| G.len() >= Cap).unwrap_or(true) {
512 return ignore::WalkState::Quit;
513 }
514
515 let Entry = match EntryResult {
516 Ok(E) => E,
517 Err(_) => return ignore::WalkState::Continue,
518 };
519
520 if !Entry.file_type().map(|T| T.is_file()).unwrap_or(false) {
521 return ignore::WalkState::Continue;
522 }
523
524 let Path = Entry.path();
525
526 let Relative = match Path.strip_prefix(&RootForRel) {
527 Ok(R) => R.to_string_lossy().replace('\\', "/"),
528 Err(_) => Path.to_string_lossy().to_string(),
529 };
530
531 if let Some(Excl) = &ExcludeMatcher {
532 if Excl.is_match(&Relative) {
533 return ignore::WalkState::Continue;
534 }
535 }
536
537 if !IncludeMatcher.is_match(&Relative) {
538 return ignore::WalkState::Continue;
539 }
540
541 if let Ok(FileUrl) = Url::from_file_path(Path) {
542 let mut Guard = match ResultsArc.lock() {
543 Ok(G) => G,
544 Err(_) => return ignore::WalkState::Quit,
545 };
546
547 if Guard.len() < Cap {
548 Guard.push(FileUrl);
549 }
550
551 if Guard.len() >= Cap {
552 return ignore::WalkState::Quit;
553 }
554 }
555
556 ignore::WalkState::Continue
557 })
558 });
559 }
560
561 let Final = Arc::try_unwrap(Results)
562 .map_err(|_| {
563 CommonError::Unknown { Description:"FindFilesInWorkspace: result Arc had outstanding refs".into() }
564 })?
565 .into_inner()
566 .map_err(|Error| CommonError::StateLockPoisoned { Context:Error.to_string() })?;
567
568 dev_log!(
569 "workspaces",
570 "[FindFilesInWorkspace] returned {} match(es) include={} exclude={:?} roots={}",
571 Final.len(),
572 IncludePattern,
573 ExcludePattern,
574 CacheKey.Folders.len()
575 );
576
577 FindFilesCachePut(CacheKey.clone(), Final.clone());
578
579 {
584 if let Ok(mut Guard) = FindFilesInFlight().lock() {
585 Guard.remove(&CacheKey);
586 }
587
588 Leader.Notify.notify_waiters();
589
590 Leader.Completed = true;
591 }
592
593 Ok(Final)
594 }
595
596 async fn OpenFile(&self, path:PathBuf) -> Result<(), CommonError> {
610 use tauri::Emitter;
611
612 dev_log!("workspaces", "[WorkspaceProvider] OpenFile called for: {:?}", path);
613
614 let UriString = match Url::from_file_path(&path) {
615 Ok(U) => U.to_string(),
616
617 Err(_) => format!("file://{}", path.to_string_lossy()),
618 };
619
620 self.ApplicationHandle
621 .emit(
622 "sky://editor/openDocument",
623 serde_json::json!({
624 "uri": UriString,
625 "viewColumn": null,
626 }),
627 )
628 .map_err(|Error| CommonError::UserInterfaceInteraction { Reason:Error.to_string() })?;
629
630 Ok(())
631 }
632}
633
634#[async_trait]
635impl WorkspaceEditApplier for MountainEnvironment {
636 async fn ApplyWorkspaceEdit(&self, Edit:WorkspaceEditDTO) -> Result<bool, CommonError> {
652 use tauri::Emitter;
653
654 dev_log!("workspaces", "[WorkspaceEditApplier] Applying workspace edit");
655
656 let WorkspaceEditDTO { Edits } = Edit;
657
658 let DocumentMirror = &self.ApplicationState.Feature.Documents;
659
660 let mut AnyFailure = false;
661
662 for (DocumentURIValue, TextEdits) in Edits {
663 let UriString = DocumentURIValue
664 .as_str()
665 .map(String::from)
666 .or_else(|| DocumentURIValue.get("value").and_then(Value::as_str).map(String::from))
667 .unwrap_or_default();
668
669 if UriString.is_empty() {
670 dev_log!("workspaces", "warn: [WorkspaceEditApplier] empty URI in edit; skipping");
671
672 continue;
673 }
674
675 let _ = self.ApplicationHandle.emit(
677 "sky://editor/applyEdits",
678 serde_json::json!({
679 "uri": UriString,
680 "edits": TextEdits,
681 }),
682 );
683
684 let IsOpen = DocumentMirror.Get(&UriString).is_some();
692
693 if !IsOpen {
694 if let Err(Error) = ApplyEditsToDisk(&UriString, &TextEdits).await {
695 AnyFailure = true;
696
697 dev_log!(
698 "workspaces",
699 "warn: [WorkspaceEditApplier] on-disk apply failed for {}: {}",
700 UriString,
701 Error
702 );
703 }
704 }
705 }
706
707 Ok(!AnyFailure)
708 }
709}
710
711async fn ApplyEditsToDisk(UriString:&str, TextEdits:&[Value]) -> Result<(), CommonError> {
717 use std::path::Path;
718
719 let RawPath = if let Some(Stripped) = UriString.strip_prefix("file://") {
720 percent_decode(Stripped)
721 } else if UriString.starts_with('/') {
722 UriString.to_string()
723 } else {
724 return Err(CommonError::InvalidArgument {
725 ArgumentName:"uri".into(),
726 Reason:format!("ApplyWorkspaceEdit: unsupported scheme in {}", UriString),
727 });
728 };
729
730 let Path = Path::new(&RawPath);
731
732 let Original = tokio::fs::read_to_string(Path)
733 .await
734 .map_err(|Error| CommonError::FromStandardIOError(Error, Path.to_path_buf(), "ApplyWorkspaceEdit.Read"))?;
735
736 let LineOffsets = ComputeLineOffsets(&Original);
741
742 let mut WithOffsets:Vec<(usize, usize, String)> = Vec::with_capacity(TextEdits.len());
743
744 for Edit in TextEdits {
745 let StartLine = Edit.pointer("/range/start/line").and_then(Value::as_u64).unwrap_or(0) as usize;
746
747 let StartChar = Edit.pointer("/range/start/character").and_then(Value::as_u64).unwrap_or(0) as usize;
748
749 let EndLine = Edit
750 .pointer("/range/end/line")
751 .and_then(Value::as_u64)
752 .unwrap_or(StartLine as u64) as usize;
753
754 let EndChar = Edit
755 .pointer("/range/end/character")
756 .and_then(Value::as_u64)
757 .unwrap_or(StartChar as u64) as usize;
758
759 let NewText = Edit.get("newText").and_then(Value::as_str).unwrap_or("").to_string();
760
761 let StartOffset = LinePosToOffset(&LineOffsets, &Original, StartLine, StartChar);
762
763 let EndOffset = LinePosToOffset(&LineOffsets, &Original, EndLine, EndChar);
764
765 WithOffsets.push((StartOffset, EndOffset, NewText));
766 }
767
768 WithOffsets.sort_by(|A, B| B.0.cmp(&A.0));
769
770 let mut Mutated = Original;
771
772 for (Start, End, NewText) in WithOffsets {
773 let SafeStart = Start.min(Mutated.len());
774
775 let SafeEnd = End.max(SafeStart).min(Mutated.len());
776
777 Mutated.replace_range(SafeStart..SafeEnd, &NewText);
778 }
779
780 let TempPath = Path.with_extension(format!(
783 "{}.land-tmp-{}",
784 Path.extension().and_then(|E| E.to_str()).unwrap_or("tmp"),
785 std::process::id()
786 ));
787
788 tokio::fs::write(&TempPath, Mutated.as_bytes())
789 .await
790 .map_err(|Error| CommonError::FromStandardIOError(Error, TempPath.clone(), "ApplyWorkspaceEdit.Write"))?;
791
792 tokio::fs::rename(&TempPath, Path)
793 .await
794 .map_err(|Error| CommonError::FromStandardIOError(Error, Path.to_path_buf(), "ApplyWorkspaceEdit.Rename"))?;
795
796 Ok(())
797}
798
799fn ComputeLineOffsets(Source:&str) -> Vec<usize> {
801 let mut Offsets = Vec::with_capacity(Source.len() / 40 + 1);
802
803 Offsets.push(0);
804
805 for (Index, Byte) in Source.bytes().enumerate() {
806 if Byte == b'\n' {
807 Offsets.push(Index + 1);
808 }
809 }
810
811 Offsets
812}
813
814fn LinePosToOffset(LineOffsets:&[usize], Source:&str, Line:usize, Character:usize) -> usize {
819 if Line >= LineOffsets.len() {
820 return Source.len();
821 }
822
823 let LineStart = LineOffsets[Line];
824
825 let LineEnd = if Line + 1 < LineOffsets.len() {
826 LineOffsets[Line + 1].saturating_sub(1)
827 } else {
828 Source.len()
829 };
830
831 let LineText = &Source[LineStart..LineEnd.min(Source.len())];
832
833 let mut Utf16Count:usize = 0;
834
835 for (ByteOffset, Char) in LineText.char_indices() {
836 if Utf16Count >= Character {
837 return LineStart + ByteOffset;
838 }
839
840 Utf16Count += Char.len_utf16();
841 }
842
843 LineStart + LineText.len()
844}
845
846fn percent_decode(Input:&str) -> String {
850 let mut Out = String::with_capacity(Input.len());
851
852 let mut Bytes = Input.as_bytes().iter().peekable();
853
854 while let Some(&Byte) = Bytes.next() {
855 if Byte == b'%' {
856 let H = Bytes.next().copied();
857
858 let L = Bytes.next().copied();
859
860 if let (Some(H), Some(L)) = (H, L) {
861 if let (Some(Hi), Some(Lo)) = (HexDigit(H), HexDigit(L)) {
862 Out.push((Hi * 16 + Lo) as char);
863
864 continue;
865 }
866
867 Out.push('%');
868
869 Out.push(H as char);
870
871 Out.push(L as char);
872
873 continue;
874 }
875
876 Out.push('%');
877 } else {
878 Out.push(Byte as char);
879 }
880 }
881
882 Out
883}
884
885fn HexDigit(Byte:u8) -> Option<u8> {
886 match Byte {
887 b'0'..=b'9' => Some(Byte - b'0'),
888
889 b'a'..=b'f' => Some(Byte - b'a' + 10),
890
891 b'A'..=b'F' => Some(Byte - b'A' + 10),
892
893 _ => None,
894 }
895}
896
897fn ExtractGlobPattern(Pattern:&Value) -> Option<String> {
903 if let Some(S) = Pattern.as_str() {
904 return Some(S.to_string());
905 }
906
907 if let Some(Obj) = Pattern.as_object() {
908 if let Some(P) = Obj.get("pattern").and_then(Value::as_str) {
909 return Some(P.to_string());
910 }
911
912 if let Some(P) = Obj.get("value").and_then(Value::as_str) {
913 return Some(P.to_string());
914 }
915
916 if let Some(P) = Obj.get("Pattern").and_then(Value::as_str) {
917 return Some(P.to_string());
918 }
919 }
920
921 None
922}
923
924fn ExtractRelativeBase(Pattern:&Value) -> Option<String> {
929 let Obj = Pattern.as_object()?;
930
931 if let Some(B) = Obj.get("base").and_then(Value::as_str) {
932 return Some(B.to_string());
933 }
934
935 if let Some(B) = Obj.get("baseUri") {
936 if let Some(S) = B.as_str() {
937 if let Some(Stripped) = S.strip_prefix("file://") {
938 return Some(Stripped.to_string());
939 }
940
941 return Some(S.to_string());
942 }
943
944 if let Some(P) = B.as_object().and_then(|O| O.get("path")).and_then(Value::as_str) {
945 return Some(P.to_string());
946 }
947
948 if let Some(P) = B.as_object().and_then(|O| O.get("fsPath")).and_then(Value::as_str) {
949 return Some(P.to_string());
950 }
951 }
952
953 None
954}