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