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