1mod loading;
4
5use std::collections::HashMap;
6use std::hash::{DefaultHasher, Hash, Hasher};
7use std::path::Component;
8use std::path::{Path, PathBuf};
9
10use anyhow::{Context as _, Result, bail};
11
12use self::loading::IncrementalFileLoader;
13use crate::TextBuffer;
14
15const INITIAL_LOAD_BYTES: usize = 64 * 1024;
16const FULL_LOAD_CHUNK_BYTES: usize = 64 * 1024;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
20pub struct BufferId(u64);
21
22impl BufferId {
23 #[inline]
24 pub fn get(self) -> u64 {
25 self.0
26 }
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum BufferKind {
32 File,
34 Ui,
36}
37
38#[derive(Debug, Clone)]
40pub struct BufferMeta {
41 pub id: BufferId,
42 pub kind: BufferKind,
43 pub display_name: String,
44 pub path: Option<PathBuf>,
45 pub dirty: bool,
46 pub is_new_file: bool,
47}
48
49#[derive(Debug, Clone)]
51pub struct BufferSummary {
52 pub id: BufferId,
53 pub kind: BufferKind,
54 pub display_name: String,
55 pub path: Option<PathBuf>,
56 pub dirty: bool,
57 pub is_new_file: bool,
58 pub is_active: bool,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum BufferLoadPhase {
64 NotLoading,
65 Loading,
66 Complete,
67 Failed,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct BufferLoadStatus {
73 pub phase: BufferLoadPhase,
74 pub bytes_loaded: usize,
75 pub total_bytes: Option<usize>,
76 pub error: Option<String>,
77}
78
79impl BufferLoadStatus {
80 #[inline]
81 fn not_loading() -> Self {
82 Self {
83 phase: BufferLoadPhase::NotLoading,
84 bytes_loaded: 0,
85 total_bytes: None,
86 error: None,
87 }
88 }
89}
90
91#[derive(Debug)]
92struct BufferRecord {
93 meta: BufferMeta,
94 buffer: TextBuffer,
95 clean_fingerprint: u64,
96 clean_len_chars: usize,
97 loader: Option<IncrementalFileLoader>,
98 load_status: BufferLoadStatus,
99}
100
101#[derive(Debug)]
103pub struct EditorSession {
104 buffers: HashMap<BufferId, BufferRecord>,
105 path_index: HashMap<PathBuf, BufferId>,
106 mru: Vec<BufferId>,
107 active: Option<BufferId>,
108 next_id: u64,
109 launch_dir: PathBuf,
110}
111
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct FilePathSyncResult {
114 pub remapped_ids: Vec<BufferId>,
115 pub closed_ids: Vec<BufferId>,
116}
117
118impl Default for EditorSession {
119 fn default() -> Self {
120 Self {
121 buffers: HashMap::new(),
122 path_index: HashMap::new(),
123 mru: Vec::new(),
124 active: None,
125 next_id: 0,
126 launch_dir: PathBuf::new(),
127 }
128 }
129}
130
131impl EditorSession {
132 pub fn open_initial_file(path: impl AsRef<Path>) -> Result<Self> {
134 let launch_dir = std::env::current_dir().context("failed to resolve current directory")?;
135 let launch_dir = std::fs::canonicalize(&launch_dir).unwrap_or(launch_dir);
136
137 let mut session = Self {
138 launch_dir,
139 ..Self::default()
140 };
141 let _ = session.open_file(path)?;
142 Ok(session)
143 }
144
145 pub fn open_initial_unnamed() -> Result<Self> {
147 let launch_dir = std::env::current_dir().context("failed to resolve current directory")?;
148 let launch_dir = std::fs::canonicalize(&launch_dir).unwrap_or(launch_dir);
149
150 let mut session = Self {
151 launch_dir,
152 ..Self::default()
153 };
154 session.open_unnamed_buffer();
155 Ok(session)
156 }
157
158 pub fn open_file(&mut self, path: impl AsRef<Path>) -> Result<BufferId> {
163 let normalized = normalize_path(path.as_ref())?;
164
165 if let Some(existing) = self.path_index.get(&normalized).copied() {
166 let _ = self.activate(existing);
167 return Ok(existing);
168 }
169
170 let file_exists = normalized.exists();
171 let mut buffer = TextBuffer::new();
172 let mut loader = None;
173 let mut load_status = BufferLoadStatus::not_loading();
174
175 if file_exists {
176 let mut incremental = IncrementalFileLoader::open(&normalized)?;
177 load_status = BufferLoadStatus {
178 phase: BufferLoadPhase::Loading,
179 bytes_loaded: 0,
180 total_bytes: incremental.total_bytes(),
181 error: None,
182 };
183
184 match incremental.read_chunk(INITIAL_LOAD_BYTES) {
185 Ok(chunk) => {
186 if !chunk.text.is_empty() {
187 let at = buffer.len_chars();
188 buffer.rope_mut().insert(at, &chunk.text);
189 }
190
191 load_status.bytes_loaded = incremental.bytes_loaded();
192 load_status.total_bytes = incremental.total_bytes();
193 if chunk.eof {
194 load_status.phase = BufferLoadPhase::Complete;
195 } else {
196 load_status.phase = BufferLoadPhase::Loading;
197 loader = Some(incremental);
198 }
199 }
200 Err(err) => {
201 load_status.phase = BufferLoadPhase::Failed;
202 load_status.error = Some(err.to_string());
203 load_status.bytes_loaded = incremental.bytes_loaded();
204 load_status.total_bytes = incremental.total_bytes();
205 }
206 }
207 }
208
209 let id = self.alloc_id();
210 let meta = BufferMeta {
211 id,
212 kind: BufferKind::File,
213 display_name: self.display_path(&normalized),
214 path: Some(normalized.clone()),
215 dirty: false,
216 is_new_file: !file_exists,
217 };
218 let clean_fingerprint = if matches!(load_status.phase, BufferLoadPhase::Complete) {
219 content_fingerprint(&buffer)
220 } else {
221 hash_text("")
222 };
223 let clean_len_chars = if matches!(load_status.phase, BufferLoadPhase::Complete) {
224 buffer.len_chars()
225 } else {
226 0
227 };
228
229 self.buffers.insert(
230 id,
231 BufferRecord {
232 meta,
233 buffer,
234 clean_fingerprint,
235 clean_len_chars,
236 loader,
237 load_status,
238 },
239 );
240 self.path_index.insert(normalized, id);
241 let _ = self.activate(id);
242
243 Ok(id)
244 }
245
246 pub fn open_ui_buffer(&mut self, name: impl Into<String>, initial_text: &str) -> BufferId {
248 let id = self.alloc_id();
249 let meta = BufferMeta {
250 id,
251 kind: BufferKind::Ui,
252 display_name: name.into(),
253 path: None,
254 dirty: false,
255 is_new_file: false,
256 };
257
258 self.buffers.insert(
259 id,
260 BufferRecord {
261 meta,
262 buffer: TextBuffer::from_str(initial_text),
263 clean_fingerprint: hash_text(initial_text),
264 clean_len_chars: initial_text.chars().count(),
265 loader: None,
266 load_status: BufferLoadStatus::not_loading(),
267 },
268 );
269 let _ = self.activate(id);
270
271 id
272 }
273
274 pub fn open_unnamed_buffer(&mut self) -> BufferId {
276 let id = self.alloc_id();
277 let meta = BufferMeta {
278 id,
279 kind: BufferKind::File,
280 display_name: "[No Name]".to_string(),
281 path: None,
282 dirty: false,
283 is_new_file: true,
284 };
285
286 self.buffers.insert(
287 id,
288 BufferRecord {
289 meta,
290 buffer: TextBuffer::new(),
291 clean_fingerprint: hash_text(""),
292 clean_len_chars: 0,
293 loader: None,
294 load_status: BufferLoadStatus::not_loading(),
295 },
296 );
297 let _ = self.activate(id);
298 id
299 }
300
301 #[inline]
302 pub fn active_id(&self) -> BufferId {
303 self.active
304 .expect("editor session must always have an active buffer")
305 }
306
307 pub fn activate(&mut self, id: BufferId) -> bool {
309 if !self.buffers.contains_key(&id) {
310 return false;
311 }
312
313 self.active = Some(id);
314 self.promote_mru(id);
315 true
316 }
317
318 #[inline]
319 pub fn active_buffer(&self) -> &TextBuffer {
320 self.buffer(self.active_id())
321 .expect("active buffer must exist in session map")
322 }
323
324 #[inline]
325 pub fn active_buffer_mut(&mut self) -> &mut TextBuffer {
326 let id = self.active_id();
327 &mut self
328 .buffers
329 .get_mut(&id)
330 .expect("active buffer must exist in session map")
331 .buffer
332 }
333
334 #[inline]
335 pub fn active_meta(&self) -> &BufferMeta {
336 self.meta(self.active_id())
337 .expect("active metadata must exist in session map")
338 }
339
340 #[inline]
341 pub fn active_meta_mut(&mut self) -> &mut BufferMeta {
342 let id = self.active_id();
343 &mut self
344 .buffers
345 .get_mut(&id)
346 .expect("active metadata must exist in session map")
347 .meta
348 }
349
350 #[inline]
351 pub fn active_buffer_load_status(&self) -> BufferLoadStatus {
352 self.buffer_load_status(self.active_id())
353 .unwrap_or_else(BufferLoadStatus::not_loading)
354 }
355
356 #[inline]
357 pub fn active_buffer_is_fully_loaded(&self) -> bool {
358 self.buffer_is_fully_loaded(self.active_id())
359 .unwrap_or(true)
360 }
361
362 #[inline]
363 pub fn buffer_load_status(&self, id: BufferId) -> Option<BufferLoadStatus> {
364 self.buffers.get(&id).map(|rec| rec.load_status.clone())
365 }
366
367 #[inline]
368 pub fn buffer_is_fully_loaded(&self, id: BufferId) -> Option<bool> {
369 self.buffers.get(&id).map(|rec| {
370 matches!(
371 rec.load_status.phase,
372 BufferLoadPhase::NotLoading | BufferLoadPhase::Complete
373 )
374 })
375 }
376
377 #[inline]
378 pub fn set_active_dirty(&mut self, dirty: bool) {
379 self.active_meta_mut().dirty = dirty;
380 }
381
382 pub fn recompute_active_dirty(&mut self) -> bool {
385 let id = self.active_id();
386 let rec = self
387 .buffers
388 .get_mut(&id)
389 .expect("active buffer must exist in session map");
390
391 let current_len = rec.buffer.len_chars();
392 if current_len != rec.clean_len_chars {
393 rec.meta.dirty = true;
394 return true;
395 }
396
397 let current = content_fingerprint(&rec.buffer);
398 rec.meta.dirty = current != rec.clean_fingerprint;
399 rec.meta.dirty
400 }
401
402 pub fn mark_active_clean(&mut self) {
404 let id = self.active_id();
405 let rec = self
406 .buffers
407 .get_mut(&id)
408 .expect("active buffer must exist in session map");
409 rec.clean_fingerprint = content_fingerprint(&rec.buffer);
410 rec.clean_len_chars = rec.buffer.len_chars();
411 rec.meta.dirty = false;
412 }
413
414 #[inline]
415 pub fn any_dirty(&self) -> bool {
416 self.buffers.values().any(|rec| rec.meta.dirty)
417 }
418
419 pub fn poll_loading(&mut self, max_bytes: usize) -> usize {
423 if max_bytes == 0 {
424 return 0;
425 }
426
427 let ids: Vec<BufferId> = self.mru.clone();
428 let mut remaining = max_bytes;
429 let mut total_read = 0usize;
430
431 for id in ids {
432 if remaining == 0 {
433 break;
434 }
435 let want = remaining.min(FULL_LOAD_CHUNK_BYTES);
436 match self.load_step_for(id, want) {
437 Ok(read) => {
438 total_read = total_read.saturating_add(read);
439 remaining = remaining.saturating_sub(read);
440 }
441 Err(_) => {
442 }
444 }
445 }
446
447 total_read
448 }
449
450 pub fn ensure_buffer_loaded_through_line(
453 &mut self,
454 id: BufferId,
455 line: usize,
456 max_bytes: usize,
457 ) -> Result<()> {
458 let mut remaining = max_bytes;
459
460 while self
461 .buffers
462 .get(&id)
463 .map(|rec| {
464 matches!(rec.load_status.phase, BufferLoadPhase::Loading)
465 && rec.buffer.len_lines() <= line
466 })
467 .unwrap_or(false)
468 && remaining > 0
469 {
470 let want = remaining.min(FULL_LOAD_CHUNK_BYTES);
471 let read = self.load_step_for(id, want)?;
472 if read == 0 {
473 break;
474 }
475 remaining = remaining.saturating_sub(read);
476 }
477
478 let status = self
479 .buffers
480 .get(&id)
481 .map(|rec| rec.load_status.clone())
482 .unwrap_or_else(BufferLoadStatus::not_loading);
483 if matches!(status.phase, BufferLoadPhase::Failed) {
484 let msg = status
485 .error
486 .unwrap_or_else(|| "buffer load failed".to_string());
487 bail!("{msg}");
488 }
489 Ok(())
490 }
491
492 pub fn ensure_buffer_fully_loaded(&mut self, id: BufferId) -> Result<()> {
494 loop {
495 let phase = self
496 .buffers
497 .get(&id)
498 .map(|rec| rec.load_status.phase)
499 .unwrap_or(BufferLoadPhase::NotLoading);
500 match phase {
501 BufferLoadPhase::NotLoading | BufferLoadPhase::Complete => return Ok(()),
502 BufferLoadPhase::Failed => {
503 let msg = self
504 .buffers
505 .get(&id)
506 .and_then(|rec| rec.load_status.error.clone())
507 .unwrap_or_else(|| "buffer load failed".to_string());
508 bail!("{msg}");
509 }
510 BufferLoadPhase::Loading => {
511 let read = self.load_step_for(id, FULL_LOAD_CHUNK_BYTES)?;
512 if read == 0 {
513 continue;
514 }
515 }
516 }
517 }
518 }
519
520 pub fn switch_next_mru(&mut self) -> Option<BufferId> {
522 if self.mru.is_empty() {
523 return None;
524 }
525
526 if self.mru.len() > 1 {
527 self.mru.rotate_left(1);
528 }
529
530 let id = self.mru[0];
531 self.active = Some(id);
532 Some(id)
533 }
534
535 pub fn switch_prev_mru(&mut self) -> Option<BufferId> {
537 if self.mru.is_empty() {
538 return None;
539 }
540
541 if self.mru.len() > 1 {
542 self.mru.rotate_right(1);
543 }
544
545 let id = self.mru[0];
546 self.active = Some(id);
547 Some(id)
548 }
549
550 pub fn summaries(&self) -> Vec<BufferSummary> {
551 let active = self.active;
552 self.mru
553 .iter()
554 .filter_map(|id| self.buffers.get(id).map(|rec| (id, rec)))
555 .map(|(id, rec)| BufferSummary {
556 id: *id,
557 kind: rec.meta.kind,
558 display_name: rec.meta.display_name.clone(),
559 path: rec.meta.path.clone(),
560 dirty: rec.meta.dirty,
561 is_new_file: rec.meta.is_new_file,
562 is_active: Some(*id) == active,
563 })
564 .collect()
565 }
566
567 pub fn sync_file_buffers_with_paths(
569 &mut self,
570 renames: &[(PathBuf, PathBuf)],
571 deletions: &[PathBuf],
572 ) -> FilePathSyncResult {
573 let renames: Vec<(PathBuf, PathBuf)> = renames
574 .iter()
575 .map(|(old_path, new_path)| {
576 (normalize_sync_path(old_path), normalize_sync_path(new_path))
577 })
578 .collect();
579 let deletions: Vec<PathBuf> = deletions
580 .iter()
581 .map(|path| normalize_sync_path(path))
582 .collect();
583
584 let mut remaps: Vec<(BufferId, PathBuf, PathBuf)> = Vec::new();
585 let mut deletion_candidates = Vec::new();
586
587 for (id, rec) in &self.buffers {
588 let Some(path) = rec.meta.path.as_ref() else {
589 continue;
590 };
591
592 let Some(next_path) = remap_synced_path(path, &renames, &deletions) else {
593 deletion_candidates.push(*id);
594 continue;
595 };
596
597 if next_path != *path {
598 remaps.push((*id, path.clone(), next_path));
599 }
600 }
601
602 let mut remapped_ids = Vec::with_capacity(remaps.len());
603 let mut closed_ids = Vec::new();
604 for (id, old_path, new_path) in remaps {
605 let display_name = self.display_path(&new_path);
606 self.path_index.remove(&old_path);
607 self.path_index.insert(new_path.clone(), id);
608
609 if let Some(rec) = self.buffers.get_mut(&id) {
610 rec.meta.path = Some(new_path.clone());
611 rec.meta.display_name = display_name;
612 }
613
614 remapped_ids.push(id);
615 }
616
617 for id in deletion_candidates {
618 let Some((old_path, was_dirty)) = self
619 .buffers
620 .get(&id)
621 .and_then(|rec| rec.meta.path.clone().map(|path| (path, rec.meta.dirty)))
622 else {
623 continue;
624 };
625
626 if was_dirty || self.buffers.len() <= 1 {
627 orphan_file_buffer(self, id, old_path);
628 continue;
629 }
630
631 if self.close_buffer(id) {
632 closed_ids.push(id);
633 } else {
634 orphan_file_buffer(self, id, old_path);
635 }
636 }
637
638 FilePathSyncResult {
639 remapped_ids,
640 closed_ids,
641 }
642 }
643
644 pub fn close_buffer(&mut self, id: BufferId) -> bool {
648 if !self.buffers.contains_key(&id) || self.buffers.len() <= 1 {
649 return false;
650 }
651
652 if let Some(rec) = self.buffers.remove(&id)
653 && let Some(path) = rec.meta.path
654 {
655 self.path_index.remove(&path);
656 }
657
658 if let Some(pos) = self.mru.iter().position(|cur| *cur == id) {
659 self.mru.remove(pos);
660 }
661
662 if self.active == Some(id) {
663 self.active = self.mru.first().copied();
664 }
665
666 self.active.is_some()
667 }
668
669 #[inline]
671 pub fn close_active_buffer(&mut self) -> bool {
672 self.close_buffer(self.active_id())
673 }
674
675 pub fn save_active(&mut self) -> Result<()> {
677 let id = self.active_id();
678 self.ensure_buffer_fully_loaded(id)?;
679 let rec = self
680 .buffers
681 .get_mut(&id)
682 .expect("active buffer must exist in session map");
683
684 match rec.meta.kind {
685 BufferKind::File => {
686 let path = rec
687 .meta
688 .path
689 .as_ref()
690 .context("file buffer is missing path metadata")?;
691 let mut content = rec.buffer.to_string();
692 if !content.is_empty() && !content.ends_with('\n') {
693 content.push('\n');
694 rec.buffer = TextBuffer::from_str(&content);
695 }
696
697 std::fs::write(path, &content)
698 .with_context(|| format!("failed to write file: {}", path.display()))?;
699
700 rec.clean_fingerprint = content_fingerprint(&rec.buffer);
701 rec.clean_len_chars = rec.buffer.len_chars();
702 rec.meta.dirty = false;
703 rec.meta.is_new_file = false;
704 Ok(())
705 }
706 BufferKind::Ui => bail!("cannot save UI buffer"),
707 }
708 }
709
710 #[inline]
711 pub fn buffer(&self, id: BufferId) -> Option<&TextBuffer> {
712 self.buffers.get(&id).map(|rec| &rec.buffer)
713 }
714
715 #[inline]
716 pub fn buffer_mut(&mut self, id: BufferId) -> Option<&mut TextBuffer> {
717 self.buffers.get_mut(&id).map(|rec| &mut rec.buffer)
718 }
719
720 #[inline]
721 pub fn meta(&self, id: BufferId) -> Option<&BufferMeta> {
722 self.buffers.get(&id).map(|rec| &rec.meta)
723 }
724
725 fn load_step_for(&mut self, id: BufferId, max_bytes: usize) -> Result<usize> {
726 let rec = match self.buffers.get_mut(&id) {
727 Some(rec) => rec,
728 None => return Ok(0),
729 };
730
731 if !matches!(rec.load_status.phase, BufferLoadPhase::Loading) {
732 return Ok(0);
733 }
734
735 let (chunk, bytes_loaded, total_bytes, is_eof) = match rec.loader.as_mut() {
736 Some(loader) => {
737 let chunk = match loader.read_chunk(max_bytes) {
738 Ok(chunk) => chunk,
739 Err(err) => {
740 rec.load_status.phase = BufferLoadPhase::Failed;
741 rec.load_status.error = Some(err.to_string());
742 rec.load_status.bytes_loaded = loader.bytes_loaded();
743 rec.load_status.total_bytes = loader.total_bytes();
744 rec.loader = None;
745 return Err(err);
746 }
747 };
748 (
749 chunk,
750 loader.bytes_loaded(),
751 loader.total_bytes(),
752 loader.is_eof(),
753 )
754 }
755 None => {
756 rec.load_status.phase = BufferLoadPhase::Complete;
757 rec.load_status.error = None;
758 if rec.meta.path.is_some() {
759 rec.clean_fingerprint = content_fingerprint(&rec.buffer);
760 rec.clean_len_chars = rec.buffer.len_chars();
761 }
762 return Ok(0);
763 }
764 };
765
766 if !chunk.text.is_empty() {
767 let at = rec.buffer.len_chars();
768 rec.buffer.rope_mut().insert(at, &chunk.text);
769 }
770
771 rec.load_status.bytes_loaded = bytes_loaded;
772 rec.load_status.total_bytes = total_bytes;
773
774 if chunk.eof || is_eof {
775 rec.load_status.phase = BufferLoadPhase::Complete;
776 rec.load_status.error = None;
777 if rec.meta.path.is_some() {
778 rec.clean_fingerprint = content_fingerprint(&rec.buffer);
779 rec.clean_len_chars = rec.buffer.len_chars();
780 }
781 rec.loader = None;
782 } else {
783 rec.load_status.phase = BufferLoadPhase::Loading;
784 rec.load_status.error = None;
785 }
786
787 Ok(chunk.bytes_read)
788 }
789
790 fn alloc_id(&mut self) -> BufferId {
791 self.next_id = self.next_id.saturating_add(1);
792 BufferId(self.next_id)
793 }
794
795 fn promote_mru(&mut self, id: BufferId) {
796 if let Some(pos) = self.mru.iter().position(|cur| *cur == id) {
797 self.mru.remove(pos);
798 }
799 self.mru.insert(0, id);
800 }
801
802 fn display_path(&self, path: &Path) -> String {
803 if self.launch_dir.as_os_str().is_empty() {
804 return path.display().to_string();
805 }
806
807 relative_path(path, &self.launch_dir)
808 .unwrap_or_else(|| path.to_path_buf())
809 .display()
810 .to_string()
811 }
812}
813
814fn normalize_path(path: &Path) -> Result<PathBuf> {
815 let path = if path.is_absolute() {
816 path.to_path_buf()
817 } else {
818 std::env::current_dir()
819 .context("failed to resolve current directory")?
820 .join(path)
821 };
822
823 Ok(std::fs::canonicalize(&path).unwrap_or(path))
824}
825
826fn orphan_file_buffer(session: &mut EditorSession, id: BufferId, old_path: PathBuf) {
827 session.path_index.remove(&old_path);
828
829 if let Some(rec) = session.buffers.get_mut(&id) {
830 rec.meta.path = None;
831 rec.meta.display_name = orphaned_display_name(&rec.meta.display_name);
832 rec.meta.is_new_file = true;
833 rec.meta.dirty = true;
834 rec.clean_fingerprint = hash_text("");
835 rec.clean_len_chars = 0;
836 }
837}
838
839fn orphaned_display_name(current_display_name: &str) -> String {
840 const ORPHANED_SUFFIX: &str = " [orphaned]";
841 if current_display_name.ends_with(ORPHANED_SUFFIX) {
842 current_display_name.to_string()
843 } else {
844 format!("{current_display_name}{ORPHANED_SUFFIX}")
845 }
846}
847
848fn normalize_sync_path(path: &Path) -> PathBuf {
849 if let Ok(canonical) = std::fs::canonicalize(path) {
850 return canonical;
851 }
852
853 let absolute = if path.is_absolute() {
854 path.to_path_buf()
855 } else {
856 std::env::current_dir()
857 .map(|cwd| cwd.join(path))
858 .unwrap_or_else(|_| path.to_path_buf())
859 };
860
861 let Some(parent) = absolute.parent() else {
862 return absolute;
863 };
864 let Some(name) = absolute.file_name() else {
865 return absolute;
866 };
867
868 let normalized_parent = normalize_sync_path(parent);
869 normalized_parent.join(name)
870}
871
872fn remap_synced_path(
873 path: &Path,
874 renames: &[(PathBuf, PathBuf)],
875 deletions: &[PathBuf],
876) -> Option<PathBuf> {
877 let mut best_rename: Option<(&PathBuf, &PathBuf)> = None;
878 for (old_path, new_path) in renames {
879 if !path_matches_or_is_descendant(path, old_path) {
880 continue;
881 }
882
883 let replace = match best_rename {
884 Some((best_old, _)) => old_path.components().count() > best_old.components().count(),
885 None => true,
886 };
887 if replace {
888 best_rename = Some((old_path, new_path));
889 }
890 }
891
892 let mut mapped = if let Some((old_path, new_path)) = best_rename {
893 replace_path_prefix(path, old_path, new_path)
894 .expect("matched rename path must support prefix replacement")
895 } else {
896 path.to_path_buf()
897 };
898
899 for deleted_path in deletions {
900 if path_matches_or_is_descendant(&mapped, deleted_path) {
901 return None;
902 }
903 }
904
905 mapped = std::fs::canonicalize(&mapped).unwrap_or(mapped);
906 Some(mapped)
907}
908
909fn path_matches_or_is_descendant(path: &Path, target: &Path) -> bool {
910 path == target || path.strip_prefix(target).is_ok()
911}
912
913fn replace_path_prefix(path: &Path, old_prefix: &Path, new_prefix: &Path) -> Option<PathBuf> {
914 let suffix = path.strip_prefix(old_prefix).ok()?;
915 let mut out = new_prefix.to_path_buf();
916 if !suffix.as_os_str().is_empty() {
917 out.push(suffix);
918 }
919 Some(out)
920}
921
922fn relative_path(path: &Path, base: &Path) -> Option<PathBuf> {
923 let path_components: Vec<Component<'_>> = path.components().collect();
924 let base_components: Vec<Component<'_>> = base.components().collect();
925
926 let mut shared = 0usize;
927 let max_shared = path_components.len().min(base_components.len());
928 while shared < max_shared && path_components[shared] == base_components[shared] {
929 shared += 1;
930 }
931
932 if shared == 0 {
933 return None;
934 }
935
936 let mut rel = PathBuf::new();
937
938 for comp in &base_components[shared..] {
939 if matches!(comp, Component::Normal(_)) {
940 rel.push("..");
941 }
942 }
943
944 for comp in &path_components[shared..] {
945 rel.push(comp.as_os_str());
946 }
947
948 if rel.as_os_str().is_empty() {
949 Some(PathBuf::from("."))
950 } else {
951 Some(rel)
952 }
953}
954
955fn content_fingerprint(buffer: &TextBuffer) -> u64 {
956 let mut hasher = DefaultHasher::new();
957 for chunk in buffer.rope().chunks() {
958 chunk.hash(&mut hasher);
959 }
960 hasher.finish()
961}
962
963fn hash_text(text: &str) -> u64 {
964 let mut hasher = DefaultHasher::new();
965 text.hash(&mut hasher);
966 hasher.finish()
967}
968
969#[cfg(test)]
970mod tests {
971 use super::*;
972
973 use std::fs;
974 use std::io::Write;
975 use std::time::{SystemTime, UNIX_EPOCH};
976
977 fn temp_path(tag: &str) -> PathBuf {
978 let nanos = SystemTime::now()
979 .duration_since(UNIX_EPOCH)
980 .expect("clock went backwards")
981 .as_nanos();
982 std::env::temp_dir().join(format!("redox_session_test_{tag}_{nanos}.txt"))
983 }
984
985 fn large_text(lines: usize) -> String {
986 let mut out = String::new();
987 for i in 0..lines {
988 out.push_str(&format!("line-{i:05} abcdefghijklmnopqrstuvwxyz\n"));
989 }
990 out
991 }
992
993 #[test]
994 fn opening_second_file_creates_and_activates_new_buffer() {
995 let path_a = temp_path("open_second_a");
996 let path_b = temp_path("open_second_b");
997 fs::write(&path_a, "aaa").expect("failed to write temp file");
998 fs::write(&path_b, "bbb").expect("failed to write temp file");
999
1000 let mut session = EditorSession::open_initial_file(&path_a).expect("open initial failed");
1001 let first = session.active_id();
1002 let second = session.open_file(&path_b).expect("open second failed");
1003
1004 assert_ne!(first, second);
1005 assert_eq!(session.active_id(), second);
1006 assert_eq!(session.active_buffer().to_string(), "bbb");
1007 assert!(!session.active_meta().display_name.starts_with('/'));
1008
1009 let _ = fs::remove_file(path_a);
1010 let _ = fs::remove_file(path_b);
1011 }
1012
1013 #[test]
1014 fn opening_same_path_reuses_existing_buffer() {
1015 let path = temp_path("dedup");
1016 fs::write(&path, "hello").expect("failed to write temp file");
1017
1018 let mut session = EditorSession::open_initial_file(&path).expect("open initial failed");
1019 let first = session.active_id();
1020 let second = session.open_file(&path).expect("open same failed");
1021
1022 assert_eq!(first, second);
1023 assert_eq!(session.summaries().len(), 1);
1024
1025 let _ = fs::remove_file(path);
1026 }
1027
1028 #[test]
1029 fn open_initial_unnamed_creates_empty_file_buffer() {
1030 let session = EditorSession::open_initial_unnamed().expect("open unnamed failed");
1031 let meta = session.active_meta();
1032
1033 assert_eq!(meta.kind, BufferKind::File);
1034 assert_eq!(meta.display_name, "[No Name]");
1035 assert!(meta.path.is_none());
1036 assert!(meta.is_new_file);
1037 assert_eq!(session.active_buffer().to_string(), "");
1038 }
1039
1040 #[test]
1041 fn missing_path_creates_empty_new_file_buffer() {
1042 let missing = temp_path("missing");
1043 if missing.exists() {
1044 fs::remove_file(&missing).expect("failed to remove existing fixture");
1045 }
1046
1047 let session = EditorSession::open_initial_file(&missing).expect("open initial failed");
1048
1049 assert!(session.active_buffer().is_empty());
1050 assert!(session.active_meta().is_new_file);
1051 assert_eq!(
1052 session.active_meta().path.as_ref(),
1053 Some(&normalize_path(&missing).unwrap())
1054 );
1055 }
1056
1057 #[test]
1058 fn mru_switching_rotates_active_buffer() {
1059 let path_a = temp_path("mru_a");
1060 let path_b = temp_path("mru_b");
1061 let path_c = temp_path("mru_c");
1062 fs::write(&path_a, "a").expect("failed to write temp file");
1063 fs::write(&path_b, "b").expect("failed to write temp file");
1064 fs::write(&path_c, "c").expect("failed to write temp file");
1065
1066 let mut session = EditorSession::open_initial_file(&path_a).expect("open initial failed");
1067 let _ = session.open_file(&path_b).expect("open second failed");
1068 let _ = session.open_file(&path_c).expect("open third failed");
1069
1070 let first = session.active_id();
1071 let second = session.switch_next_mru().expect("switch next failed");
1072 let third = session.switch_next_mru().expect("switch next failed");
1073 let back = session.switch_prev_mru().expect("switch prev failed");
1074
1075 assert_ne!(first, second);
1076 assert_ne!(second, third);
1077 assert_eq!(second, back);
1078
1079 let _ = fs::remove_file(path_a);
1080 let _ = fs::remove_file(path_b);
1081 let _ = fs::remove_file(path_c);
1082 }
1083
1084 #[test]
1085 fn any_dirty_detects_hidden_dirty_buffers() {
1086 let path_a = temp_path("dirty_a");
1087 let path_b = temp_path("dirty_b");
1088 fs::write(&path_a, "a").expect("failed to write temp file");
1089 fs::write(&path_b, "b").expect("failed to write temp file");
1090
1091 let mut session = EditorSession::open_initial_file(&path_a).expect("open initial failed");
1092 let id_a = session.active_id();
1093 let _ = session.open_file(&path_b).expect("open second failed");
1094
1095 let _ = session.activate(id_a);
1096 let cursor = session.active_buffer().clamp_pos(crate::Pos::new(0, 1));
1097 let _ = session.active_buffer_mut().insert(cursor, "x");
1098 let _ = session.recompute_active_dirty();
1099 let _ = session.switch_next_mru();
1100
1101 assert!(session.any_dirty());
1102
1103 let _ = fs::remove_file(path_a);
1104 let _ = fs::remove_file(path_b);
1105 }
1106
1107 #[test]
1108 fn save_active_writes_and_clears_dirty() {
1109 let path = temp_path("save_active");
1110 fs::write(&path, "old").expect("failed to write temp file");
1111
1112 let mut session = EditorSession::open_initial_file(&path).expect("open initial failed");
1113 let cursor = session.active_buffer().clamp_pos(crate::Pos::new(0, 3));
1114 let _ = session.active_buffer_mut().insert(cursor, "_new");
1115 let _ = session.recompute_active_dirty();
1116
1117 session.save_active().expect("save failed");
1118
1119 assert!(!session.active_meta().dirty);
1120 let on_disk = fs::read_to_string(&path).expect("failed to read temp file");
1121 assert_eq!(on_disk, "old_new\n");
1122 assert_eq!(session.active_buffer().to_string(), "old_new\n");
1123 assert!(!session.recompute_active_dirty());
1124
1125 let _ = fs::remove_file(path);
1126 }
1127
1128 #[test]
1129 fn save_active_appends_trailing_newline_for_non_empty_file() {
1130 let path = temp_path("save_active_trailing_newline");
1131 fs::write(&path, "hello").expect("failed to write temp file");
1132
1133 let mut session = EditorSession::open_initial_file(&path).expect("open initial failed");
1134 session.save_active().expect("save failed");
1135
1136 assert_eq!(
1137 fs::read_to_string(&path).expect("failed to read temp file"),
1138 "hello\n"
1139 );
1140 assert_eq!(session.active_buffer().to_string(), "hello\n");
1141 assert!(!session.recompute_active_dirty());
1142
1143 let _ = fs::remove_file(path);
1144 }
1145
1146 #[test]
1147 fn dirty_tracking_clears_when_content_returns_to_clean_snapshot() {
1148 let path = temp_path("dirty_revert");
1149 fs::write(&path, "hello").expect("failed to write temp file");
1150
1151 let mut session = EditorSession::open_initial_file(&path).expect("open initial failed");
1152 let end = session.active_buffer().clamp_pos(crate::Pos::new(0, 5));
1153 let _ = session.active_buffer_mut().insert(end, "!");
1154 assert!(session.recompute_active_dirty());
1155
1156 let sel = crate::Selection::empty(crate::Pos::new(0, 6));
1157 let _ = session.active_buffer_mut().backspace(sel);
1158 assert!(!session.recompute_active_dirty());
1159
1160 let _ = fs::remove_file(path);
1161 }
1162
1163 #[test]
1164 fn incremental_open_starts_loading_for_large_file() {
1165 let path = temp_path("incremental_open");
1166 let text = large_text(6000);
1167 fs::write(&path, &text).expect("failed to write temp file");
1168
1169 let session = EditorSession::open_initial_file(&path).expect("open initial failed");
1170 let status = session.active_buffer_load_status();
1171
1172 assert_eq!(status.phase, BufferLoadPhase::Loading);
1173 assert!(status.bytes_loaded > 0);
1174 assert!(status.total_bytes.unwrap_or(0) > status.bytes_loaded);
1175 assert!(!session.active_buffer_is_fully_loaded());
1176
1177 let _ = fs::remove_file(path);
1178 }
1179
1180 #[test]
1181 fn poll_loading_increases_loaded_bytes_monotonically() {
1182 let path = temp_path("poll_monotonic");
1183 let text = large_text(8000);
1184 fs::write(&path, &text).expect("failed to write temp file");
1185
1186 let mut session = EditorSession::open_initial_file(&path).expect("open initial failed");
1187 let mut prev = session.active_buffer_load_status().bytes_loaded;
1188
1189 for _ in 0..10 {
1190 let _ = session.poll_loading(8 * 1024);
1191 let now = session.active_buffer_load_status().bytes_loaded;
1192 assert!(now >= prev);
1193 prev = now;
1194 }
1195
1196 let _ = fs::remove_file(path);
1197 }
1198
1199 #[test]
1200 fn demand_loading_reaches_target_line_or_eof() {
1201 let path = temp_path("demand_line");
1202 let text = large_text(9000);
1203 fs::write(&path, &text).expect("failed to write temp file");
1204
1205 let mut session = EditorSession::open_initial_file(&path).expect("open initial failed");
1206 let id = session.active_id();
1207 let target = 3500usize;
1208 session
1209 .ensure_buffer_loaded_through_line(id, target, 256 * 1024)
1210 .expect("demand load failed");
1211
1212 let loaded_lines = session.active_buffer().len_lines();
1213 let phase = session.active_buffer_load_status().phase;
1214 assert!(loaded_lines > target || phase == BufferLoadPhase::Complete);
1215
1216 let _ = fs::remove_file(path);
1217 }
1218
1219 #[test]
1220 fn ensure_fully_loaded_completes_and_matches_disk() {
1221 let path = temp_path("full_load");
1222 let text = large_text(7500);
1223 fs::write(&path, &text).expect("failed to write temp file");
1224
1225 let mut session = EditorSession::open_initial_file(&path).expect("open initial failed");
1226 let id = session.active_id();
1227 session
1228 .ensure_buffer_fully_loaded(id)
1229 .expect("full load should succeed");
1230
1231 assert_eq!(
1232 session.active_buffer_load_status().phase,
1233 BufferLoadPhase::Complete
1234 );
1235 assert_eq!(session.active_buffer().to_string(), text);
1236
1237 let _ = fs::remove_file(path);
1238 }
1239
1240 #[test]
1241 fn full_load_handles_utf8_chunk_boundaries() {
1242 let path = temp_path("utf8_boundaries");
1243 let text = "😀alpha\nβeta\nこんにちは\n".repeat(7000);
1244 fs::write(&path, &text).expect("failed to write temp file");
1245
1246 let mut session = EditorSession::open_initial_file(&path).expect("open initial failed");
1247 let id = session.active_id();
1248 session
1249 .ensure_buffer_fully_loaded(id)
1250 .expect("full load should succeed");
1251
1252 assert_eq!(session.active_buffer().to_string(), text);
1253
1254 let _ = fs::remove_file(path);
1255 }
1256
1257 #[test]
1258 fn invalid_utf8_sets_failed_phase_and_blocks_full_load() {
1259 let path = temp_path("invalid_utf8_incremental");
1260 let mut file = fs::File::create(&path).expect("failed to create temp file");
1261 let prefix = "ok\n".repeat(30_000);
1262 file.write_all(prefix.as_bytes())
1263 .expect("failed to write prefix");
1264 file.write_all(&[0xff])
1265 .expect("failed to write invalid byte");
1266 file.flush().expect("failed to flush");
1267
1268 let mut session = EditorSession::open_initial_file(&path).expect("open initial failed");
1269 let id = session.active_id();
1270 let err = session
1271 .ensure_buffer_fully_loaded(id)
1272 .expect_err("expected invalid utf8 error");
1273 assert!(err.to_string().contains("not valid UTF-8"));
1274 assert_eq!(
1275 session.active_buffer_load_status().phase,
1276 BufferLoadPhase::Failed
1277 );
1278 assert!(!session.active_buffer().is_empty());
1279
1280 let _ = fs::remove_file(path);
1281 }
1282
1283 #[test]
1284 fn background_loading_does_not_mark_dirty() {
1285 let path = temp_path("load_not_dirty");
1286 let text = large_text(7000);
1287 fs::write(&path, &text).expect("failed to write temp file");
1288
1289 let mut session = EditorSession::open_initial_file(&path).expect("open initial failed");
1290 let _ = session.poll_loading(128 * 1024);
1291 assert!(!session.active_meta().dirty);
1292
1293 let id = session.active_id();
1294 session
1295 .ensure_buffer_fully_loaded(id)
1296 .expect("full load should succeed");
1297 let end = session.active_buffer().clamp_pos(crate::Pos::new(0, 5));
1298 let _ = session.active_buffer_mut().insert(end, "!");
1299 assert!(session.recompute_active_dirty());
1300
1301 let _ = fs::remove_file(path);
1302 }
1303
1304 #[test]
1305 fn save_active_forces_full_load_before_write() {
1306 let path = temp_path("save_gate");
1307 let text = large_text(8500);
1308 fs::write(&path, &text).expect("failed to write temp file");
1309
1310 let mut session = EditorSession::open_initial_file(&path).expect("open initial failed");
1311 assert_eq!(
1312 session.active_buffer_load_status().phase,
1313 BufferLoadPhase::Loading
1314 );
1315
1316 session.save_active().expect("save should force full load");
1317 assert_eq!(
1318 session.active_buffer_load_status().phase,
1319 BufferLoadPhase::Complete
1320 );
1321
1322 let on_disk = fs::read_to_string(&path).expect("failed to read file");
1323 assert_eq!(on_disk, text);
1324
1325 let _ = fs::remove_file(path);
1326 }
1327
1328 #[test]
1329 fn sync_file_buffers_with_paths_remaps_open_descendants_after_directory_rename() {
1330 let root = std::env::temp_dir().join(format!(
1331 "redox_session_sync_dir_{}",
1332 SystemTime::now()
1333 .duration_since(UNIX_EPOCH)
1334 .expect("clock went backwards")
1335 .as_nanos()
1336 ));
1337 let old_dir = root.join("old");
1338 let new_dir = root.join("new");
1339 fs::create_dir_all(&old_dir).expect("failed to create old directory");
1340
1341 let file_path = old_dir.join("nested.txt");
1342 fs::write(&file_path, "hello").expect("failed to write nested fixture");
1343
1344 let mut session =
1345 EditorSession::open_initial_file(&file_path).expect("open initial failed");
1346 let file_id = session.active_id();
1347
1348 fs::rename(&old_dir, &new_dir).expect("failed to rename directory");
1349 let result =
1350 session.sync_file_buffers_with_paths(&[(old_dir.clone(), new_dir.clone())], &[]);
1351
1352 assert_eq!(result.remapped_ids, vec![file_id]);
1353 assert!(result.closed_ids.is_empty());
1354 let renamed_file = std::fs::canonicalize(new_dir.join("nested.txt"))
1355 .expect("renamed nested file should exist");
1356 assert_eq!(session.active_meta().path.as_ref(), Some(&renamed_file));
1357 assert_eq!(
1358 session
1359 .open_file(&renamed_file)
1360 .expect("reopen should reuse remapped buffer"),
1361 file_id
1362 );
1363
1364 let _ = fs::remove_file(new_dir.join("nested.txt"));
1365 let _ = fs::remove_dir_all(root);
1366 }
1367
1368 #[test]
1369 fn sync_file_buffers_with_paths_closes_deleted_buffers() {
1370 let path_a = temp_path("sync_delete_a");
1371 let path_b = temp_path("sync_delete_b");
1372 fs::write(&path_a, "a").expect("failed to write temp file");
1373 fs::write(&path_b, "b").expect("failed to write temp file");
1374
1375 let mut session = EditorSession::open_initial_file(&path_a).expect("open initial failed");
1376 let doomed_id = session.open_file(&path_b).expect("open second failed");
1377
1378 fs::remove_file(&path_b).expect("failed to remove doomed file");
1379 let result = session.sync_file_buffers_with_paths(&[], std::slice::from_ref(&path_b));
1380
1381 assert!(result.remapped_ids.is_empty());
1382 assert_eq!(result.closed_ids, vec![doomed_id]);
1383 assert_eq!(session.summaries().len(), 1);
1384 assert!(session.meta(doomed_id).is_none());
1385
1386 let _ = fs::remove_file(path_a);
1387 }
1388
1389 #[test]
1390 fn sync_file_buffers_with_paths_orphans_dirty_deleted_buffer() {
1391 let path_a = temp_path("sync_orphan_dirty_a");
1392 let path_b = temp_path("sync_orphan_dirty_b");
1393 fs::write(&path_a, "a").expect("failed to write temp file");
1394 fs::write(&path_b, "b").expect("failed to write temp file");
1395
1396 let mut session = EditorSession::open_initial_file(&path_a).expect("open initial failed");
1397 let dirty_id = session.open_file(&path_b).expect("open second failed");
1398 let cursor = session.active_buffer().clamp_pos(crate::Pos::new(0, 1));
1399 let _ = session.active_buffer_mut().insert(cursor, "!");
1400 assert!(session.recompute_active_dirty());
1401
1402 fs::remove_file(&path_b).expect("failed to remove doomed file");
1403 let result = session.sync_file_buffers_with_paths(&[], std::slice::from_ref(&path_b));
1404
1405 assert!(result.remapped_ids.is_empty());
1406 assert!(result.closed_ids.is_empty());
1407 let meta = session.meta(dirty_id).expect("dirty buffer should remain");
1408 assert!(meta.dirty);
1409 assert!(meta.path.is_none());
1410 assert!(meta.display_name.ends_with(" [orphaned]"));
1411 assert_eq!(session.active_buffer().to_string(), "b!");
1412
1413 let reopened_id = session
1414 .open_file(&path_b)
1415 .expect("reopen should create new buffer");
1416 assert_ne!(reopened_id, dirty_id);
1417
1418 let _ = fs::remove_file(path_a);
1419 }
1420
1421 #[test]
1422 fn sync_file_buffers_with_paths_orphans_last_remaining_deleted_buffer() {
1423 let path = temp_path("sync_orphan_last");
1424 fs::write(&path, "hello").expect("failed to write temp file");
1425
1426 let mut session = EditorSession::open_initial_file(&path).expect("open initial failed");
1427 let doomed_id = session.active_id();
1428
1429 fs::remove_file(&path).expect("failed to remove doomed file");
1430 let result = session.sync_file_buffers_with_paths(&[], std::slice::from_ref(&path));
1431
1432 assert!(result.remapped_ids.is_empty());
1433 assert!(result.closed_ids.is_empty());
1434 assert_eq!(session.summaries().len(), 1);
1435 let meta = session.meta(doomed_id).expect("last buffer should remain");
1436 assert!(meta.path.is_none());
1437 assert!(meta.dirty);
1438 assert!(meta.is_new_file);
1439 assert!(meta.display_name.ends_with(" [orphaned]"));
1440 assert_eq!(session.active_buffer().to_string(), "hello");
1441 }
1442
1443 #[test]
1444 fn orphaned_loading_buffer_stays_unsaved_after_load_completes() {
1445 let path = temp_path("sync_orphan_loading");
1446 let text = large_text(9000);
1447 fs::write(&path, &text).expect("failed to write temp file");
1448
1449 let mut session = EditorSession::open_initial_file(&path).expect("open initial failed");
1450 let doomed_id = session.active_id();
1451 assert_eq!(
1452 session.active_buffer_load_status().phase,
1453 BufferLoadPhase::Loading
1454 );
1455
1456 fs::remove_file(&path).expect("failed to remove doomed file");
1457 let result = session.sync_file_buffers_with_paths(&[], std::slice::from_ref(&path));
1458 assert!(result.closed_ids.is_empty());
1459
1460 session
1461 .ensure_buffer_fully_loaded(doomed_id)
1462 .expect("orphaned buffer should still finish loading");
1463
1464 let meta = session
1465 .meta(doomed_id)
1466 .expect("orphaned buffer should remain");
1467 assert!(meta.path.is_none());
1468 assert!(meta.dirty);
1469 assert!(meta.is_new_file);
1470 assert!(session.recompute_active_dirty());
1471 assert_eq!(session.active_buffer().to_string(), text);
1472 }
1473
1474 #[test]
1475 fn sync_file_buffers_with_paths_deletes_directory_descendants() {
1476 let root = std::env::temp_dir().join(format!(
1477 "redox_session_sync_delete_dir_{}",
1478 SystemTime::now()
1479 .duration_since(UNIX_EPOCH)
1480 .expect("clock went backwards")
1481 .as_nanos()
1482 ));
1483 let doomed_dir = root.join("doomed");
1484 fs::create_dir_all(&doomed_dir).expect("failed to create doomed directory");
1485
1486 let clean_path = doomed_dir.join("clean.txt");
1487 let dirty_path = doomed_dir.join("dirty.txt");
1488 fs::write(&clean_path, "clean").expect("failed to write clean fixture");
1489 fs::write(&dirty_path, "dirty").expect("failed to write dirty fixture");
1490
1491 let mut session =
1492 EditorSession::open_initial_file(&clean_path).expect("open initial failed");
1493 let clean_id = session.active_id();
1494 let dirty_id = session
1495 .open_file(&dirty_path)
1496 .expect("open dirty file failed");
1497
1498 let cursor = session.active_buffer().clamp_pos(crate::Pos::new(0, 5));
1499 let _ = session.active_buffer_mut().insert(cursor, "!");
1500 assert!(session.recompute_active_dirty());
1501
1502 fs::remove_dir_all(&doomed_dir).expect("failed to remove doomed directory");
1503 let result = session.sync_file_buffers_with_paths(&[], std::slice::from_ref(&doomed_dir));
1504
1505 assert!(result.remapped_ids.is_empty());
1506 assert_eq!(result.closed_ids, vec![clean_id]);
1507 assert!(session.meta(clean_id).is_none());
1508
1509 let dirty_meta = session
1510 .meta(dirty_id)
1511 .expect("dirty descendant should remain");
1512 assert!(dirty_meta.path.is_none());
1513 assert!(dirty_meta.display_name.ends_with(" [orphaned]"));
1514 assert!(dirty_meta.dirty);
1515 assert!(dirty_meta.is_new_file);
1516 assert_eq!(session.active_buffer().to_string(), "dirty!");
1517
1518 let summaries = session.summaries();
1519 assert_eq!(summaries.len(), 1);
1520 assert_eq!(summaries[0].id, dirty_id);
1521
1522 let _ = fs::remove_dir_all(root);
1523 }
1524}