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 launch_dir(&self) -> &Path {
364 &self.launch_dir
365 }
366
367 #[inline]
368 pub fn buffer_load_status(&self, id: BufferId) -> Option<BufferLoadStatus> {
369 self.buffers.get(&id).map(|rec| rec.load_status.clone())
370 }
371
372 #[inline]
373 pub fn buffer_is_fully_loaded(&self, id: BufferId) -> Option<bool> {
374 self.buffers.get(&id).map(|rec| {
375 matches!(
376 rec.load_status.phase,
377 BufferLoadPhase::NotLoading | BufferLoadPhase::Complete
378 )
379 })
380 }
381
382 #[inline]
383 pub fn set_active_dirty(&mut self, dirty: bool) {
384 self.active_meta_mut().dirty = dirty;
385 }
386
387 pub fn recompute_active_dirty(&mut self) -> bool {
390 let id = self.active_id();
391 let rec = self
392 .buffers
393 .get_mut(&id)
394 .expect("active buffer must exist in session map");
395
396 let current_len = rec.buffer.len_chars();
397 if current_len != rec.clean_len_chars {
398 rec.meta.dirty = true;
399 return true;
400 }
401
402 let current = content_fingerprint(&rec.buffer);
403 rec.meta.dirty = current != rec.clean_fingerprint;
404 rec.meta.dirty
405 }
406
407 pub fn mark_active_clean(&mut self) {
409 let id = self.active_id();
410 let rec = self
411 .buffers
412 .get_mut(&id)
413 .expect("active buffer must exist in session map");
414 rec.clean_fingerprint = content_fingerprint(&rec.buffer);
415 rec.clean_len_chars = rec.buffer.len_chars();
416 rec.meta.dirty = false;
417 }
418
419 #[inline]
420 pub fn any_dirty(&self) -> bool {
421 self.buffers.values().any(|rec| rec.meta.dirty)
422 }
423
424 pub fn poll_loading(&mut self, max_bytes: usize) -> usize {
428 if max_bytes == 0 {
429 return 0;
430 }
431
432 let ids: Vec<BufferId> = self.mru.clone();
433 let mut remaining = max_bytes;
434 let mut total_read = 0usize;
435
436 for id in ids {
437 if remaining == 0 {
438 break;
439 }
440 let want = remaining.min(FULL_LOAD_CHUNK_BYTES);
441 match self.load_step_for(id, want) {
442 Ok(read) => {
443 total_read = total_read.saturating_add(read);
444 remaining = remaining.saturating_sub(read);
445 }
446 Err(_) => {
447 }
449 }
450 }
451
452 total_read
453 }
454
455 pub fn ensure_buffer_loaded_through_line(
458 &mut self,
459 id: BufferId,
460 line: usize,
461 max_bytes: usize,
462 ) -> Result<()> {
463 let mut remaining = max_bytes;
464
465 while self
466 .buffers
467 .get(&id)
468 .map(|rec| {
469 matches!(rec.load_status.phase, BufferLoadPhase::Loading)
470 && rec.buffer.len_lines() <= line
471 })
472 .unwrap_or(false)
473 && remaining > 0
474 {
475 let want = remaining.min(FULL_LOAD_CHUNK_BYTES);
476 let read = self.load_step_for(id, want)?;
477 if read == 0 {
478 break;
479 }
480 remaining = remaining.saturating_sub(read);
481 }
482
483 let status = self
484 .buffers
485 .get(&id)
486 .map(|rec| rec.load_status.clone())
487 .unwrap_or_else(BufferLoadStatus::not_loading);
488 if matches!(status.phase, BufferLoadPhase::Failed) {
489 let msg = status
490 .error
491 .unwrap_or_else(|| "buffer load failed".to_string());
492 bail!("{msg}");
493 }
494 Ok(())
495 }
496
497 pub fn ensure_buffer_fully_loaded(&mut self, id: BufferId) -> Result<()> {
499 loop {
500 let phase = self
501 .buffers
502 .get(&id)
503 .map(|rec| rec.load_status.phase)
504 .unwrap_or(BufferLoadPhase::NotLoading);
505 match phase {
506 BufferLoadPhase::NotLoading | BufferLoadPhase::Complete => return Ok(()),
507 BufferLoadPhase::Failed => {
508 let msg = self
509 .buffers
510 .get(&id)
511 .and_then(|rec| rec.load_status.error.clone())
512 .unwrap_or_else(|| "buffer load failed".to_string());
513 bail!("{msg}");
514 }
515 BufferLoadPhase::Loading => {
516 let read = self.load_step_for(id, FULL_LOAD_CHUNK_BYTES)?;
517 if read == 0 {
518 continue;
519 }
520 }
521 }
522 }
523 }
524
525 pub fn switch_next_mru(&mut self) -> Option<BufferId> {
527 if self.mru.is_empty() {
528 return None;
529 }
530
531 if self.mru.len() > 1 {
532 self.mru.rotate_left(1);
533 }
534
535 let id = self.mru[0];
536 self.active = Some(id);
537 Some(id)
538 }
539
540 pub fn switch_prev_mru(&mut self) -> Option<BufferId> {
542 if self.mru.is_empty() {
543 return None;
544 }
545
546 if self.mru.len() > 1 {
547 self.mru.rotate_right(1);
548 }
549
550 let id = self.mru[0];
551 self.active = Some(id);
552 Some(id)
553 }
554
555 pub fn summaries(&self) -> Vec<BufferSummary> {
556 let active = self.active;
557 self.mru
558 .iter()
559 .filter_map(|id| self.buffers.get(id).map(|rec| (id, rec)))
560 .map(|(id, rec)| BufferSummary {
561 id: *id,
562 kind: rec.meta.kind,
563 display_name: rec.meta.display_name.clone(),
564 path: rec.meta.path.clone(),
565 dirty: rec.meta.dirty,
566 is_new_file: rec.meta.is_new_file,
567 is_active: Some(*id) == active,
568 })
569 .collect()
570 }
571
572 pub fn sync_file_buffers_with_paths(
574 &mut self,
575 renames: &[(PathBuf, PathBuf)],
576 deletions: &[PathBuf],
577 ) -> FilePathSyncResult {
578 let renames: Vec<(PathBuf, PathBuf)> = renames
579 .iter()
580 .map(|(old_path, new_path)| {
581 (normalize_sync_path(old_path), normalize_sync_path(new_path))
582 })
583 .collect();
584 let deletions: Vec<PathBuf> = deletions
585 .iter()
586 .map(|path| normalize_sync_path(path))
587 .collect();
588
589 let mut remaps: Vec<(BufferId, PathBuf, PathBuf)> = Vec::new();
590 let mut deletion_candidates = Vec::new();
591
592 for (id, rec) in &self.buffers {
593 let Some(path) = rec.meta.path.as_ref() else {
594 continue;
595 };
596
597 let Some(next_path) = remap_synced_path(path, &renames, &deletions) else {
598 deletion_candidates.push(*id);
599 continue;
600 };
601
602 if next_path != *path {
603 remaps.push((*id, path.clone(), next_path));
604 }
605 }
606
607 let mut remapped_ids = Vec::with_capacity(remaps.len());
608 let mut closed_ids = Vec::new();
609 for (id, old_path, new_path) in remaps {
610 let display_name = self.display_path(&new_path);
611 self.path_index.remove(&old_path);
612 self.path_index.insert(new_path.clone(), id);
613
614 if let Some(rec) = self.buffers.get_mut(&id) {
615 rec.meta.path = Some(new_path.clone());
616 rec.meta.display_name = display_name;
617 }
618
619 remapped_ids.push(id);
620 }
621
622 for id in deletion_candidates {
623 let Some((old_path, was_dirty)) = self
624 .buffers
625 .get(&id)
626 .and_then(|rec| rec.meta.path.clone().map(|path| (path, rec.meta.dirty)))
627 else {
628 continue;
629 };
630
631 if was_dirty || self.buffers.len() <= 1 {
632 orphan_file_buffer(self, id, old_path);
633 continue;
634 }
635
636 if self.close_buffer(id) {
637 closed_ids.push(id);
638 } else {
639 orphan_file_buffer(self, id, old_path);
640 }
641 }
642
643 FilePathSyncResult {
644 remapped_ids,
645 closed_ids,
646 }
647 }
648
649 pub fn close_buffer(&mut self, id: BufferId) -> bool {
653 if !self.buffers.contains_key(&id) || self.buffers.len() <= 1 {
654 return false;
655 }
656
657 if let Some(rec) = self.buffers.remove(&id)
658 && let Some(path) = rec.meta.path
659 {
660 self.path_index.remove(&path);
661 }
662
663 if let Some(pos) = self.mru.iter().position(|cur| *cur == id) {
664 self.mru.remove(pos);
665 }
666
667 if self.active == Some(id) {
668 self.active = self.mru.first().copied();
669 }
670
671 self.active.is_some()
672 }
673
674 #[inline]
676 pub fn close_active_buffer(&mut self) -> bool {
677 self.close_buffer(self.active_id())
678 }
679
680 pub fn save_active(&mut self) -> Result<()> {
682 let id = self.active_id();
683 self.ensure_buffer_fully_loaded(id)?;
684 let rec = self
685 .buffers
686 .get_mut(&id)
687 .expect("active buffer must exist in session map");
688
689 match rec.meta.kind {
690 BufferKind::File => {
691 let path = rec
692 .meta
693 .path
694 .as_ref()
695 .context("file buffer is missing path metadata")?;
696 let mut content = rec.buffer.to_string();
697 if !content.is_empty() && !content.ends_with('\n') {
698 content.push('\n');
699 rec.buffer = TextBuffer::from_str(&content);
700 }
701
702 std::fs::write(path, &content)
703 .with_context(|| format!("failed to write file: {}", path.display()))?;
704
705 rec.clean_fingerprint = content_fingerprint(&rec.buffer);
706 rec.clean_len_chars = rec.buffer.len_chars();
707 rec.meta.dirty = false;
708 rec.meta.is_new_file = false;
709 Ok(())
710 }
711 BufferKind::Ui => bail!("cannot save UI buffer"),
712 }
713 }
714
715 #[inline]
716 pub fn buffer(&self, id: BufferId) -> Option<&TextBuffer> {
717 self.buffers.get(&id).map(|rec| &rec.buffer)
718 }
719
720 #[inline]
721 pub fn buffer_mut(&mut self, id: BufferId) -> Option<&mut TextBuffer> {
722 self.buffers.get_mut(&id).map(|rec| &mut rec.buffer)
723 }
724
725 #[inline]
726 pub fn meta(&self, id: BufferId) -> Option<&BufferMeta> {
727 self.buffers.get(&id).map(|rec| &rec.meta)
728 }
729
730 fn load_step_for(&mut self, id: BufferId, max_bytes: usize) -> Result<usize> {
731 let rec = match self.buffers.get_mut(&id) {
732 Some(rec) => rec,
733 None => return Ok(0),
734 };
735
736 if !matches!(rec.load_status.phase, BufferLoadPhase::Loading) {
737 return Ok(0);
738 }
739
740 let (chunk, bytes_loaded, total_bytes, is_eof) = match rec.loader.as_mut() {
741 Some(loader) => {
742 let chunk = match loader.read_chunk(max_bytes) {
743 Ok(chunk) => chunk,
744 Err(err) => {
745 rec.load_status.phase = BufferLoadPhase::Failed;
746 rec.load_status.error = Some(err.to_string());
747 rec.load_status.bytes_loaded = loader.bytes_loaded();
748 rec.load_status.total_bytes = loader.total_bytes();
749 rec.loader = None;
750 return Err(err);
751 }
752 };
753 (
754 chunk,
755 loader.bytes_loaded(),
756 loader.total_bytes(),
757 loader.is_eof(),
758 )
759 }
760 None => {
761 rec.load_status.phase = BufferLoadPhase::Complete;
762 rec.load_status.error = None;
763 if rec.meta.path.is_some() {
764 rec.clean_fingerprint = content_fingerprint(&rec.buffer);
765 rec.clean_len_chars = rec.buffer.len_chars();
766 }
767 return Ok(0);
768 }
769 };
770
771 if !chunk.text.is_empty() {
772 let at = rec.buffer.len_chars();
773 rec.buffer.rope_mut().insert(at, &chunk.text);
774 }
775
776 rec.load_status.bytes_loaded = bytes_loaded;
777 rec.load_status.total_bytes = total_bytes;
778
779 if chunk.eof || is_eof {
780 rec.load_status.phase = BufferLoadPhase::Complete;
781 rec.load_status.error = None;
782 if rec.meta.path.is_some() {
783 rec.clean_fingerprint = content_fingerprint(&rec.buffer);
784 rec.clean_len_chars = rec.buffer.len_chars();
785 }
786 rec.loader = None;
787 } else {
788 rec.load_status.phase = BufferLoadPhase::Loading;
789 rec.load_status.error = None;
790 }
791
792 Ok(chunk.bytes_read)
793 }
794
795 fn alloc_id(&mut self) -> BufferId {
796 self.next_id = self.next_id.saturating_add(1);
797 BufferId(self.next_id)
798 }
799
800 fn promote_mru(&mut self, id: BufferId) {
801 if let Some(pos) = self.mru.iter().position(|cur| *cur == id) {
802 self.mru.remove(pos);
803 }
804 self.mru.insert(0, id);
805 }
806
807 fn display_path(&self, path: &Path) -> String {
808 if self.launch_dir.as_os_str().is_empty() {
809 return path.display().to_string();
810 }
811
812 relative_path(path, &self.launch_dir)
813 .unwrap_or_else(|| path.to_path_buf())
814 .display()
815 .to_string()
816 }
817}
818
819fn normalize_path(path: &Path) -> Result<PathBuf> {
820 let path = if path.is_absolute() {
821 path.to_path_buf()
822 } else {
823 std::env::current_dir()
824 .context("failed to resolve current directory")?
825 .join(path)
826 };
827
828 Ok(std::fs::canonicalize(&path).unwrap_or(path))
829}
830
831fn orphan_file_buffer(session: &mut EditorSession, id: BufferId, old_path: PathBuf) {
832 session.path_index.remove(&old_path);
833
834 if let Some(rec) = session.buffers.get_mut(&id) {
835 rec.meta.path = None;
836 rec.meta.display_name = orphaned_display_name(&rec.meta.display_name);
837 rec.meta.is_new_file = true;
838 rec.meta.dirty = true;
839 rec.clean_fingerprint = hash_text("");
840 rec.clean_len_chars = 0;
841 }
842}
843
844fn orphaned_display_name(current_display_name: &str) -> String {
845 const ORPHANED_SUFFIX: &str = " [orphaned]";
846 if current_display_name.ends_with(ORPHANED_SUFFIX) {
847 current_display_name.to_string()
848 } else {
849 format!("{current_display_name}{ORPHANED_SUFFIX}")
850 }
851}
852
853fn normalize_sync_path(path: &Path) -> PathBuf {
854 if let Ok(canonical) = std::fs::canonicalize(path) {
855 return canonical;
856 }
857
858 let absolute = if path.is_absolute() {
859 path.to_path_buf()
860 } else {
861 std::env::current_dir()
862 .map(|cwd| cwd.join(path))
863 .unwrap_or_else(|_| path.to_path_buf())
864 };
865
866 let Some(parent) = absolute.parent() else {
867 return absolute;
868 };
869 let Some(name) = absolute.file_name() else {
870 return absolute;
871 };
872
873 let normalized_parent = normalize_sync_path(parent);
874 normalized_parent.join(name)
875}
876
877fn remap_synced_path(
878 path: &Path,
879 renames: &[(PathBuf, PathBuf)],
880 deletions: &[PathBuf],
881) -> Option<PathBuf> {
882 let mut best_rename: Option<(&PathBuf, &PathBuf)> = None;
883 for (old_path, new_path) in renames {
884 if !path_matches_or_is_descendant(path, old_path) {
885 continue;
886 }
887
888 let replace = match best_rename {
889 Some((best_old, _)) => old_path.components().count() > best_old.components().count(),
890 None => true,
891 };
892 if replace {
893 best_rename = Some((old_path, new_path));
894 }
895 }
896
897 let mut mapped = if let Some((old_path, new_path)) = best_rename {
898 replace_path_prefix(path, old_path, new_path)
899 .expect("matched rename path must support prefix replacement")
900 } else {
901 path.to_path_buf()
902 };
903
904 for deleted_path in deletions {
905 if path_matches_or_is_descendant(&mapped, deleted_path) {
906 return None;
907 }
908 }
909
910 mapped = std::fs::canonicalize(&mapped).unwrap_or(mapped);
911 Some(mapped)
912}
913
914fn path_matches_or_is_descendant(path: &Path, target: &Path) -> bool {
915 path == target || path.strip_prefix(target).is_ok()
916}
917
918fn replace_path_prefix(path: &Path, old_prefix: &Path, new_prefix: &Path) -> Option<PathBuf> {
919 let suffix = path.strip_prefix(old_prefix).ok()?;
920 let mut out = new_prefix.to_path_buf();
921 if !suffix.as_os_str().is_empty() {
922 out.push(suffix);
923 }
924 Some(out)
925}
926
927fn relative_path(path: &Path, base: &Path) -> Option<PathBuf> {
928 let path_components: Vec<Component<'_>> = path.components().collect();
929 let base_components: Vec<Component<'_>> = base.components().collect();
930
931 let mut shared = 0usize;
932 let max_shared = path_components.len().min(base_components.len());
933 while shared < max_shared && path_components[shared] == base_components[shared] {
934 shared += 1;
935 }
936
937 if shared == 0 {
938 return None;
939 }
940
941 let mut rel = PathBuf::new();
942
943 for comp in &base_components[shared..] {
944 if matches!(comp, Component::Normal(_)) {
945 rel.push("..");
946 }
947 }
948
949 for comp in &path_components[shared..] {
950 rel.push(comp.as_os_str());
951 }
952
953 if rel.as_os_str().is_empty() {
954 Some(PathBuf::from("."))
955 } else {
956 Some(rel)
957 }
958}
959
960fn content_fingerprint(buffer: &TextBuffer) -> u64 {
961 let mut hasher = DefaultHasher::new();
962 for chunk in buffer.rope().chunks() {
963 chunk.hash(&mut hasher);
964 }
965 hasher.finish()
966}
967
968fn hash_text(text: &str) -> u64 {
969 let mut hasher = DefaultHasher::new();
970 text.hash(&mut hasher);
971 hasher.finish()
972}
973
974#[cfg(test)]
975mod tests {
976 use super::*;
977
978 use std::fs;
979 use std::io::Write;
980 use std::time::{SystemTime, UNIX_EPOCH};
981
982 fn temp_path(tag: &str) -> PathBuf {
983 let nanos = SystemTime::now()
984 .duration_since(UNIX_EPOCH)
985 .expect("clock went backwards")
986 .as_nanos();
987 std::env::temp_dir().join(format!("redox_session_test_{tag}_{nanos}.txt"))
988 }
989
990 fn large_text(lines: usize) -> String {
991 let mut out = String::new();
992 for i in 0..lines {
993 out.push_str(&format!("line-{i:05} abcdefghijklmnopqrstuvwxyz\n"));
994 }
995 out
996 }
997
998 #[test]
999 fn opening_second_file_creates_and_activates_new_buffer() {
1000 let path_a = temp_path("open_second_a");
1001 let path_b = temp_path("open_second_b");
1002 fs::write(&path_a, "aaa").expect("failed to write temp file");
1003 fs::write(&path_b, "bbb").expect("failed to write temp file");
1004
1005 let mut session = EditorSession::open_initial_file(&path_a).expect("open initial failed");
1006 let first = session.active_id();
1007 let second = session.open_file(&path_b).expect("open second failed");
1008
1009 assert_ne!(first, second);
1010 assert_eq!(session.active_id(), second);
1011 assert_eq!(session.active_buffer().to_string(), "bbb");
1012 assert!(!session.active_meta().display_name.starts_with('/'));
1013
1014 let _ = fs::remove_file(path_a);
1015 let _ = fs::remove_file(path_b);
1016 }
1017
1018 #[test]
1019 fn opening_same_path_reuses_existing_buffer() {
1020 let path = temp_path("dedup");
1021 fs::write(&path, "hello").expect("failed to write temp file");
1022
1023 let mut session = EditorSession::open_initial_file(&path).expect("open initial failed");
1024 let first = session.active_id();
1025 let second = session.open_file(&path).expect("open same failed");
1026
1027 assert_eq!(first, second);
1028 assert_eq!(session.summaries().len(), 1);
1029
1030 let _ = fs::remove_file(path);
1031 }
1032
1033 #[test]
1034 fn open_initial_unnamed_creates_empty_file_buffer() {
1035 let session = EditorSession::open_initial_unnamed().expect("open unnamed failed");
1036 let meta = session.active_meta();
1037
1038 assert_eq!(meta.kind, BufferKind::File);
1039 assert_eq!(meta.display_name, "[No Name]");
1040 assert!(meta.path.is_none());
1041 assert!(meta.is_new_file);
1042 assert_eq!(session.active_buffer().to_string(), "");
1043 }
1044
1045 #[test]
1046 fn missing_path_creates_empty_new_file_buffer() {
1047 let missing = temp_path("missing");
1048 if missing.exists() {
1049 fs::remove_file(&missing).expect("failed to remove existing fixture");
1050 }
1051
1052 let session = EditorSession::open_initial_file(&missing).expect("open initial failed");
1053
1054 assert!(session.active_buffer().is_empty());
1055 assert!(session.active_meta().is_new_file);
1056 assert_eq!(
1057 session.active_meta().path.as_ref(),
1058 Some(&normalize_path(&missing).unwrap())
1059 );
1060 }
1061
1062 #[test]
1063 fn mru_switching_rotates_active_buffer() {
1064 let path_a = temp_path("mru_a");
1065 let path_b = temp_path("mru_b");
1066 let path_c = temp_path("mru_c");
1067 fs::write(&path_a, "a").expect("failed to write temp file");
1068 fs::write(&path_b, "b").expect("failed to write temp file");
1069 fs::write(&path_c, "c").expect("failed to write temp file");
1070
1071 let mut session = EditorSession::open_initial_file(&path_a).expect("open initial failed");
1072 let _ = session.open_file(&path_b).expect("open second failed");
1073 let _ = session.open_file(&path_c).expect("open third failed");
1074
1075 let first = session.active_id();
1076 let second = session.switch_next_mru().expect("switch next failed");
1077 let third = session.switch_next_mru().expect("switch next failed");
1078 let back = session.switch_prev_mru().expect("switch prev failed");
1079
1080 assert_ne!(first, second);
1081 assert_ne!(second, third);
1082 assert_eq!(second, back);
1083
1084 let _ = fs::remove_file(path_a);
1085 let _ = fs::remove_file(path_b);
1086 let _ = fs::remove_file(path_c);
1087 }
1088
1089 #[test]
1090 fn any_dirty_detects_hidden_dirty_buffers() {
1091 let path_a = temp_path("dirty_a");
1092 let path_b = temp_path("dirty_b");
1093 fs::write(&path_a, "a").expect("failed to write temp file");
1094 fs::write(&path_b, "b").expect("failed to write temp file");
1095
1096 let mut session = EditorSession::open_initial_file(&path_a).expect("open initial failed");
1097 let id_a = session.active_id();
1098 let _ = session.open_file(&path_b).expect("open second failed");
1099
1100 let _ = session.activate(id_a);
1101 let cursor = session.active_buffer().clamp_pos(crate::Pos::new(0, 1));
1102 let _ = session.active_buffer_mut().insert(cursor, "x");
1103 let _ = session.recompute_active_dirty();
1104 let _ = session.switch_next_mru();
1105
1106 assert!(session.any_dirty());
1107
1108 let _ = fs::remove_file(path_a);
1109 let _ = fs::remove_file(path_b);
1110 }
1111
1112 #[test]
1113 fn save_active_writes_and_clears_dirty() {
1114 let path = temp_path("save_active");
1115 fs::write(&path, "old").expect("failed to write temp file");
1116
1117 let mut session = EditorSession::open_initial_file(&path).expect("open initial failed");
1118 let cursor = session.active_buffer().clamp_pos(crate::Pos::new(0, 3));
1119 let _ = session.active_buffer_mut().insert(cursor, "_new");
1120 let _ = session.recompute_active_dirty();
1121
1122 session.save_active().expect("save failed");
1123
1124 assert!(!session.active_meta().dirty);
1125 let on_disk = fs::read_to_string(&path).expect("failed to read temp file");
1126 assert_eq!(on_disk, "old_new\n");
1127 assert_eq!(session.active_buffer().to_string(), "old_new\n");
1128 assert!(!session.recompute_active_dirty());
1129
1130 let _ = fs::remove_file(path);
1131 }
1132
1133 #[test]
1134 fn save_active_appends_trailing_newline_for_non_empty_file() {
1135 let path = temp_path("save_active_trailing_newline");
1136 fs::write(&path, "hello").expect("failed to write temp file");
1137
1138 let mut session = EditorSession::open_initial_file(&path).expect("open initial failed");
1139 session.save_active().expect("save failed");
1140
1141 assert_eq!(
1142 fs::read_to_string(&path).expect("failed to read temp file"),
1143 "hello\n"
1144 );
1145 assert_eq!(session.active_buffer().to_string(), "hello\n");
1146 assert!(!session.recompute_active_dirty());
1147
1148 let _ = fs::remove_file(path);
1149 }
1150
1151 #[test]
1152 fn dirty_tracking_clears_when_content_returns_to_clean_snapshot() {
1153 let path = temp_path("dirty_revert");
1154 fs::write(&path, "hello").expect("failed to write temp file");
1155
1156 let mut session = EditorSession::open_initial_file(&path).expect("open initial failed");
1157 let end = session.active_buffer().clamp_pos(crate::Pos::new(0, 5));
1158 let _ = session.active_buffer_mut().insert(end, "!");
1159 assert!(session.recompute_active_dirty());
1160
1161 let sel = crate::Selection::empty(crate::Pos::new(0, 6));
1162 let _ = session.active_buffer_mut().backspace(sel);
1163 assert!(!session.recompute_active_dirty());
1164
1165 let _ = fs::remove_file(path);
1166 }
1167
1168 #[test]
1169 fn incremental_open_starts_loading_for_large_file() {
1170 let path = temp_path("incremental_open");
1171 let text = large_text(6000);
1172 fs::write(&path, &text).expect("failed to write temp file");
1173
1174 let session = EditorSession::open_initial_file(&path).expect("open initial failed");
1175 let status = session.active_buffer_load_status();
1176
1177 assert_eq!(status.phase, BufferLoadPhase::Loading);
1178 assert!(status.bytes_loaded > 0);
1179 assert!(status.total_bytes.unwrap_or(0) > status.bytes_loaded);
1180 assert!(!session.active_buffer_is_fully_loaded());
1181
1182 let _ = fs::remove_file(path);
1183 }
1184
1185 #[test]
1186 fn poll_loading_increases_loaded_bytes_monotonically() {
1187 let path = temp_path("poll_monotonic");
1188 let text = large_text(8000);
1189 fs::write(&path, &text).expect("failed to write temp file");
1190
1191 let mut session = EditorSession::open_initial_file(&path).expect("open initial failed");
1192 let mut prev = session.active_buffer_load_status().bytes_loaded;
1193
1194 for _ in 0..10 {
1195 let _ = session.poll_loading(8 * 1024);
1196 let now = session.active_buffer_load_status().bytes_loaded;
1197 assert!(now >= prev);
1198 prev = now;
1199 }
1200
1201 let _ = fs::remove_file(path);
1202 }
1203
1204 #[test]
1205 fn demand_loading_reaches_target_line_or_eof() {
1206 let path = temp_path("demand_line");
1207 let text = large_text(9000);
1208 fs::write(&path, &text).expect("failed to write temp file");
1209
1210 let mut session = EditorSession::open_initial_file(&path).expect("open initial failed");
1211 let id = session.active_id();
1212 let target = 3500usize;
1213 session
1214 .ensure_buffer_loaded_through_line(id, target, 256 * 1024)
1215 .expect("demand load failed");
1216
1217 let loaded_lines = session.active_buffer().len_lines();
1218 let phase = session.active_buffer_load_status().phase;
1219 assert!(loaded_lines > target || phase == BufferLoadPhase::Complete);
1220
1221 let _ = fs::remove_file(path);
1222 }
1223
1224 #[test]
1225 fn ensure_fully_loaded_completes_and_matches_disk() {
1226 let path = temp_path("full_load");
1227 let text = large_text(7500);
1228 fs::write(&path, &text).expect("failed to write temp file");
1229
1230 let mut session = EditorSession::open_initial_file(&path).expect("open initial failed");
1231 let id = session.active_id();
1232 session
1233 .ensure_buffer_fully_loaded(id)
1234 .expect("full load should succeed");
1235
1236 assert_eq!(
1237 session.active_buffer_load_status().phase,
1238 BufferLoadPhase::Complete
1239 );
1240 assert_eq!(session.active_buffer().to_string(), text);
1241
1242 let _ = fs::remove_file(path);
1243 }
1244
1245 #[test]
1246 fn full_load_handles_utf8_chunk_boundaries() {
1247 let path = temp_path("utf8_boundaries");
1248 let text = "😀alpha\nβeta\nこんにちは\n".repeat(7000);
1249 fs::write(&path, &text).expect("failed to write temp file");
1250
1251 let mut session = EditorSession::open_initial_file(&path).expect("open initial failed");
1252 let id = session.active_id();
1253 session
1254 .ensure_buffer_fully_loaded(id)
1255 .expect("full load should succeed");
1256
1257 assert_eq!(session.active_buffer().to_string(), text);
1258
1259 let _ = fs::remove_file(path);
1260 }
1261
1262 #[test]
1263 fn invalid_utf8_sets_failed_phase_and_blocks_full_load() {
1264 let path = temp_path("invalid_utf8_incremental");
1265 let mut file = fs::File::create(&path).expect("failed to create temp file");
1266 let prefix = "ok\n".repeat(30_000);
1267 file.write_all(prefix.as_bytes())
1268 .expect("failed to write prefix");
1269 file.write_all(&[0xff])
1270 .expect("failed to write invalid byte");
1271 file.flush().expect("failed to flush");
1272
1273 let mut session = EditorSession::open_initial_file(&path).expect("open initial failed");
1274 let id = session.active_id();
1275 let err = session
1276 .ensure_buffer_fully_loaded(id)
1277 .expect_err("expected invalid utf8 error");
1278 assert!(err.to_string().contains("not valid UTF-8"));
1279 assert_eq!(
1280 session.active_buffer_load_status().phase,
1281 BufferLoadPhase::Failed
1282 );
1283 assert!(!session.active_buffer().is_empty());
1284
1285 let _ = fs::remove_file(path);
1286 }
1287
1288 #[test]
1289 fn background_loading_does_not_mark_dirty() {
1290 let path = temp_path("load_not_dirty");
1291 let text = large_text(7000);
1292 fs::write(&path, &text).expect("failed to write temp file");
1293
1294 let mut session = EditorSession::open_initial_file(&path).expect("open initial failed");
1295 let _ = session.poll_loading(128 * 1024);
1296 assert!(!session.active_meta().dirty);
1297
1298 let id = session.active_id();
1299 session
1300 .ensure_buffer_fully_loaded(id)
1301 .expect("full load should succeed");
1302 let end = session.active_buffer().clamp_pos(crate::Pos::new(0, 5));
1303 let _ = session.active_buffer_mut().insert(end, "!");
1304 assert!(session.recompute_active_dirty());
1305
1306 let _ = fs::remove_file(path);
1307 }
1308
1309 #[test]
1310 fn save_active_forces_full_load_before_write() {
1311 let path = temp_path("save_gate");
1312 let text = large_text(8500);
1313 fs::write(&path, &text).expect("failed to write temp file");
1314
1315 let mut session = EditorSession::open_initial_file(&path).expect("open initial failed");
1316 assert_eq!(
1317 session.active_buffer_load_status().phase,
1318 BufferLoadPhase::Loading
1319 );
1320
1321 session.save_active().expect("save should force full load");
1322 assert_eq!(
1323 session.active_buffer_load_status().phase,
1324 BufferLoadPhase::Complete
1325 );
1326
1327 let on_disk = fs::read_to_string(&path).expect("failed to read file");
1328 assert_eq!(on_disk, text);
1329
1330 let _ = fs::remove_file(path);
1331 }
1332
1333 #[test]
1334 fn sync_file_buffers_with_paths_remaps_open_descendants_after_directory_rename() {
1335 let root = std::env::temp_dir().join(format!(
1336 "redox_session_sync_dir_{}",
1337 SystemTime::now()
1338 .duration_since(UNIX_EPOCH)
1339 .expect("clock went backwards")
1340 .as_nanos()
1341 ));
1342 let old_dir = root.join("old");
1343 let new_dir = root.join("new");
1344 fs::create_dir_all(&old_dir).expect("failed to create old directory");
1345
1346 let file_path = old_dir.join("nested.txt");
1347 fs::write(&file_path, "hello").expect("failed to write nested fixture");
1348
1349 let mut session =
1350 EditorSession::open_initial_file(&file_path).expect("open initial failed");
1351 let file_id = session.active_id();
1352
1353 fs::rename(&old_dir, &new_dir).expect("failed to rename directory");
1354 let result =
1355 session.sync_file_buffers_with_paths(&[(old_dir.clone(), new_dir.clone())], &[]);
1356
1357 assert_eq!(result.remapped_ids, vec![file_id]);
1358 assert!(result.closed_ids.is_empty());
1359 let renamed_file = std::fs::canonicalize(new_dir.join("nested.txt"))
1360 .expect("renamed nested file should exist");
1361 assert_eq!(session.active_meta().path.as_ref(), Some(&renamed_file));
1362 assert_eq!(
1363 session
1364 .open_file(&renamed_file)
1365 .expect("reopen should reuse remapped buffer"),
1366 file_id
1367 );
1368
1369 let _ = fs::remove_file(new_dir.join("nested.txt"));
1370 let _ = fs::remove_dir_all(root);
1371 }
1372
1373 #[test]
1374 fn sync_file_buffers_with_paths_closes_deleted_buffers() {
1375 let path_a = temp_path("sync_delete_a");
1376 let path_b = temp_path("sync_delete_b");
1377 fs::write(&path_a, "a").expect("failed to write temp file");
1378 fs::write(&path_b, "b").expect("failed to write temp file");
1379
1380 let mut session = EditorSession::open_initial_file(&path_a).expect("open initial failed");
1381 let doomed_id = session.open_file(&path_b).expect("open second failed");
1382
1383 fs::remove_file(&path_b).expect("failed to remove doomed file");
1384 let result = session.sync_file_buffers_with_paths(&[], std::slice::from_ref(&path_b));
1385
1386 assert!(result.remapped_ids.is_empty());
1387 assert_eq!(result.closed_ids, vec![doomed_id]);
1388 assert_eq!(session.summaries().len(), 1);
1389 assert!(session.meta(doomed_id).is_none());
1390
1391 let _ = fs::remove_file(path_a);
1392 }
1393
1394 #[test]
1395 fn sync_file_buffers_with_paths_orphans_dirty_deleted_buffer() {
1396 let path_a = temp_path("sync_orphan_dirty_a");
1397 let path_b = temp_path("sync_orphan_dirty_b");
1398 fs::write(&path_a, "a").expect("failed to write temp file");
1399 fs::write(&path_b, "b").expect("failed to write temp file");
1400
1401 let mut session = EditorSession::open_initial_file(&path_a).expect("open initial failed");
1402 let dirty_id = session.open_file(&path_b).expect("open second failed");
1403 let cursor = session.active_buffer().clamp_pos(crate::Pos::new(0, 1));
1404 let _ = session.active_buffer_mut().insert(cursor, "!");
1405 assert!(session.recompute_active_dirty());
1406
1407 fs::remove_file(&path_b).expect("failed to remove doomed file");
1408 let result = session.sync_file_buffers_with_paths(&[], std::slice::from_ref(&path_b));
1409
1410 assert!(result.remapped_ids.is_empty());
1411 assert!(result.closed_ids.is_empty());
1412 let meta = session.meta(dirty_id).expect("dirty buffer should remain");
1413 assert!(meta.dirty);
1414 assert!(meta.path.is_none());
1415 assert!(meta.display_name.ends_with(" [orphaned]"));
1416 assert_eq!(session.active_buffer().to_string(), "b!");
1417
1418 let reopened_id = session
1419 .open_file(&path_b)
1420 .expect("reopen should create new buffer");
1421 assert_ne!(reopened_id, dirty_id);
1422
1423 let _ = fs::remove_file(path_a);
1424 }
1425
1426 #[test]
1427 fn sync_file_buffers_with_paths_orphans_last_remaining_deleted_buffer() {
1428 let path = temp_path("sync_orphan_last");
1429 fs::write(&path, "hello").expect("failed to write temp file");
1430
1431 let mut session = EditorSession::open_initial_file(&path).expect("open initial failed");
1432 let doomed_id = session.active_id();
1433
1434 fs::remove_file(&path).expect("failed to remove doomed file");
1435 let result = session.sync_file_buffers_with_paths(&[], std::slice::from_ref(&path));
1436
1437 assert!(result.remapped_ids.is_empty());
1438 assert!(result.closed_ids.is_empty());
1439 assert_eq!(session.summaries().len(), 1);
1440 let meta = session.meta(doomed_id).expect("last buffer should remain");
1441 assert!(meta.path.is_none());
1442 assert!(meta.dirty);
1443 assert!(meta.is_new_file);
1444 assert!(meta.display_name.ends_with(" [orphaned]"));
1445 assert_eq!(session.active_buffer().to_string(), "hello");
1446 }
1447
1448 #[test]
1449 fn orphaned_loading_buffer_stays_unsaved_after_load_completes() {
1450 let path = temp_path("sync_orphan_loading");
1451 let text = large_text(9000);
1452 fs::write(&path, &text).expect("failed to write temp file");
1453
1454 let mut session = EditorSession::open_initial_file(&path).expect("open initial failed");
1455 let doomed_id = session.active_id();
1456 assert_eq!(
1457 session.active_buffer_load_status().phase,
1458 BufferLoadPhase::Loading
1459 );
1460
1461 fs::remove_file(&path).expect("failed to remove doomed file");
1462 let result = session.sync_file_buffers_with_paths(&[], std::slice::from_ref(&path));
1463 assert!(result.closed_ids.is_empty());
1464
1465 session
1466 .ensure_buffer_fully_loaded(doomed_id)
1467 .expect("orphaned buffer should still finish loading");
1468
1469 let meta = session
1470 .meta(doomed_id)
1471 .expect("orphaned buffer should remain");
1472 assert!(meta.path.is_none());
1473 assert!(meta.dirty);
1474 assert!(meta.is_new_file);
1475 assert!(session.recompute_active_dirty());
1476 assert_eq!(session.active_buffer().to_string(), text);
1477 }
1478
1479 #[test]
1480 fn sync_file_buffers_with_paths_deletes_directory_descendants() {
1481 let root = std::env::temp_dir().join(format!(
1482 "redox_session_sync_delete_dir_{}",
1483 SystemTime::now()
1484 .duration_since(UNIX_EPOCH)
1485 .expect("clock went backwards")
1486 .as_nanos()
1487 ));
1488 let doomed_dir = root.join("doomed");
1489 fs::create_dir_all(&doomed_dir).expect("failed to create doomed directory");
1490
1491 let clean_path = doomed_dir.join("clean.txt");
1492 let dirty_path = doomed_dir.join("dirty.txt");
1493 fs::write(&clean_path, "clean").expect("failed to write clean fixture");
1494 fs::write(&dirty_path, "dirty").expect("failed to write dirty fixture");
1495
1496 let mut session =
1497 EditorSession::open_initial_file(&clean_path).expect("open initial failed");
1498 let clean_id = session.active_id();
1499 let dirty_id = session
1500 .open_file(&dirty_path)
1501 .expect("open dirty file failed");
1502
1503 let cursor = session.active_buffer().clamp_pos(crate::Pos::new(0, 5));
1504 let _ = session.active_buffer_mut().insert(cursor, "!");
1505 assert!(session.recompute_active_dirty());
1506
1507 fs::remove_dir_all(&doomed_dir).expect("failed to remove doomed directory");
1508 let result = session.sync_file_buffers_with_paths(&[], std::slice::from_ref(&doomed_dir));
1509
1510 assert!(result.remapped_ids.is_empty());
1511 assert_eq!(result.closed_ids, vec![clean_id]);
1512 assert!(session.meta(clean_id).is_none());
1513
1514 let dirty_meta = session
1515 .meta(dirty_id)
1516 .expect("dirty descendant should remain");
1517 assert!(dirty_meta.path.is_none());
1518 assert!(dirty_meta.display_name.ends_with(" [orphaned]"));
1519 assert!(dirty_meta.dirty);
1520 assert!(dirty_meta.is_new_file);
1521 assert_eq!(session.active_buffer().to_string(), "dirty!");
1522
1523 let summaries = session.summaries();
1524 assert_eq!(summaries.len(), 1);
1525 assert_eq!(summaries[0].id, dirty_id);
1526
1527 let _ = fs::remove_dir_all(root);
1528 }
1529}