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