Skip to main content

redox_core/session/
mod.rs

1//! Multi-buffer session model for higher-level editor frontends.
2
3mod 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/// Stable buffer identifier within an [`EditorSession`].
19#[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/// Buffer classification.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum BufferKind {
32    /// File-backed editable buffer.
33    File,
34    /// Ephemeral UI buffer for editor surfaces.
35    Ui,
36}
37
38/// Buffer metadata tracked by session management.
39#[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/// Listing row for buffer UIs such as `:ls`.
50#[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/// Loading phase for a file-backed buffer.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum BufferLoadPhase {
64    NotLoading,
65    Loading,
66    Complete,
67    Failed,
68}
69
70/// Snapshot status for file loading progress.
71#[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/// Multi-buffer editor session with active buffer + MRU ordering.
102#[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    /// Build a new session with a single initial file buffer.
133    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    /// Build a new session with one empty unnamed file buffer.
146    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    /// Open (or switch to) a file-backed buffer.
159    ///
160    /// - Existing open path: activates and returns existing buffer ID.
161    /// - Missing path: creates an empty file-backed buffer marked as new.
162    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    /// Open an in-memory UI buffer.
247    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    /// Open a new unnamed file buffer and activate it.
275    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    /// Activate the target buffer and promote it to the top of MRU order.
308    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    /// Recompute active buffer dirty state by comparing current contents against
383    /// the last clean snapshot (opened-from-disk or last successful save).
384    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    /// Record the active buffer's current contents as the clean snapshot.
403    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    /// Poll incremental loaders and append up to `max_bytes` across open buffers.
420    ///
421    /// Returns the number of bytes read from disk.
422    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                    // Error status is stored in-buffer; continue polling others.
443                }
444            }
445        }
446
447        total_read
448    }
449
450    /// Ensure a file-backed buffer has loaded enough text to include `line`,
451    /// or until the bounded read budget is exhausted.
452    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    /// Ensure a file-backed buffer is fully loaded to EOF.
493    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    /// Cycle to the next buffer in MRU order.
521    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    /// Cycle to the previous buffer in MRU order.
536    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    /// Reconcile open file buffers after external filesystem renames or deletions.
568    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    /// Close a buffer by id, activating the next MRU buffer if needed.
645    ///
646    /// Returns `false` if the id does not exist or this is the last remaining buffer.
647    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    /// Close the currently active buffer.
670    #[inline]
671    pub fn close_active_buffer(&mut self) -> bool {
672        self.close_buffer(self.active_id())
673    }
674
675    /// Save the active file-backed buffer.
676    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}