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
112impl Default for EditorSession {
113    fn default() -> Self {
114        Self {
115            buffers: HashMap::new(),
116            path_index: HashMap::new(),
117            mru: Vec::new(),
118            active: None,
119            next_id: 0,
120            launch_dir: PathBuf::new(),
121        }
122    }
123}
124
125impl EditorSession {
126    /// Build a new session with a single initial file buffer.
127    pub fn open_initial_file(path: impl AsRef<Path>) -> Result<Self> {
128        let launch_dir = std::env::current_dir().context("failed to resolve current directory")?;
129        let launch_dir = std::fs::canonicalize(&launch_dir).unwrap_or(launch_dir);
130
131        let mut session = Self {
132            launch_dir,
133            ..Self::default()
134        };
135        let _ = session.open_file(path)?;
136        Ok(session)
137    }
138
139    /// Build a new session with one empty unnamed file buffer.
140    pub fn open_initial_unnamed() -> Result<Self> {
141        let launch_dir = std::env::current_dir().context("failed to resolve current directory")?;
142        let launch_dir = std::fs::canonicalize(&launch_dir).unwrap_or(launch_dir);
143
144        let mut session = Self {
145            launch_dir,
146            ..Self::default()
147        };
148        let id = session.alloc_id();
149        let buffer = TextBuffer::new();
150        let meta = BufferMeta {
151            id,
152            kind: BufferKind::File,
153            display_name: "[No Name]".to_string(),
154            path: None,
155            dirty: false,
156            is_new_file: true,
157        };
158
159        session.buffers.insert(
160            id,
161            BufferRecord {
162                meta,
163                buffer,
164                clean_fingerprint: hash_text(""),
165                clean_len_chars: 0,
166                loader: None,
167                load_status: BufferLoadStatus::not_loading(),
168            },
169        );
170        let _ = session.activate(id);
171        Ok(session)
172    }
173
174    /// Open (or switch to) a file-backed buffer.
175    ///
176    /// - Existing open path: activates and returns existing buffer ID.
177    /// - Missing path: creates an empty file-backed buffer marked as new.
178    pub fn open_file(&mut self, path: impl AsRef<Path>) -> Result<BufferId> {
179        let normalized = normalize_path(path.as_ref())?;
180
181        if let Some(existing) = self.path_index.get(&normalized).copied() {
182            let _ = self.activate(existing);
183            return Ok(existing);
184        }
185
186        let file_exists = normalized.exists();
187        let mut buffer = TextBuffer::new();
188        let mut loader = None;
189        let mut load_status = BufferLoadStatus::not_loading();
190
191        if file_exists {
192            let mut incremental = IncrementalFileLoader::open(&normalized)?;
193            load_status = BufferLoadStatus {
194                phase: BufferLoadPhase::Loading,
195                bytes_loaded: 0,
196                total_bytes: incremental.total_bytes(),
197                error: None,
198            };
199
200            match incremental.read_chunk(INITIAL_LOAD_BYTES) {
201                Ok(chunk) => {
202                    if !chunk.text.is_empty() {
203                        let at = buffer.len_chars();
204                        buffer.rope_mut().insert(at, &chunk.text);
205                    }
206
207                    load_status.bytes_loaded = incremental.bytes_loaded();
208                    load_status.total_bytes = incremental.total_bytes();
209                    if chunk.eof {
210                        load_status.phase = BufferLoadPhase::Complete;
211                    } else {
212                        load_status.phase = BufferLoadPhase::Loading;
213                        loader = Some(incremental);
214                    }
215                }
216                Err(err) => {
217                    load_status.phase = BufferLoadPhase::Failed;
218                    load_status.error = Some(err.to_string());
219                    load_status.bytes_loaded = incremental.bytes_loaded();
220                    load_status.total_bytes = incremental.total_bytes();
221                }
222            }
223        }
224
225        let id = self.alloc_id();
226        let meta = BufferMeta {
227            id,
228            kind: BufferKind::File,
229            display_name: self.display_path(&normalized),
230            path: Some(normalized.clone()),
231            dirty: false,
232            is_new_file: !file_exists,
233        };
234        let clean_fingerprint = if matches!(load_status.phase, BufferLoadPhase::Complete) {
235            content_fingerprint(&buffer)
236        } else {
237            hash_text("")
238        };
239        let clean_len_chars = if matches!(load_status.phase, BufferLoadPhase::Complete) {
240            buffer.len_chars()
241        } else {
242            0
243        };
244
245        self.buffers.insert(
246            id,
247            BufferRecord {
248                meta,
249                buffer,
250                clean_fingerprint,
251                clean_len_chars,
252                loader,
253                load_status,
254            },
255        );
256        self.path_index.insert(normalized, id);
257        let _ = self.activate(id);
258
259        Ok(id)
260    }
261
262    /// Open an in-memory UI buffer.
263    pub fn open_ui_buffer(&mut self, name: impl Into<String>, initial_text: &str) -> BufferId {
264        let id = self.alloc_id();
265        let meta = BufferMeta {
266            id,
267            kind: BufferKind::Ui,
268            display_name: name.into(),
269            path: None,
270            dirty: false,
271            is_new_file: false,
272        };
273
274        self.buffers.insert(
275            id,
276            BufferRecord {
277                meta,
278                buffer: TextBuffer::from_str(initial_text),
279                clean_fingerprint: hash_text(initial_text),
280                clean_len_chars: initial_text.chars().count(),
281                loader: None,
282                load_status: BufferLoadStatus::not_loading(),
283            },
284        );
285        let _ = self.activate(id);
286
287        id
288    }
289
290    #[inline]
291    pub fn active_id(&self) -> BufferId {
292        self.active
293            .expect("editor session must always have an active buffer")
294    }
295
296    /// Activate the target buffer and promote it to the top of MRU order.
297    pub fn activate(&mut self, id: BufferId) -> bool {
298        if !self.buffers.contains_key(&id) {
299            return false;
300        }
301
302        self.active = Some(id);
303        self.promote_mru(id);
304        true
305    }
306
307    #[inline]
308    pub fn active_buffer(&self) -> &TextBuffer {
309        self.buffer(self.active_id())
310            .expect("active buffer must exist in session map")
311    }
312
313    #[inline]
314    pub fn active_buffer_mut(&mut self) -> &mut TextBuffer {
315        let id = self.active_id();
316        &mut self
317            .buffers
318            .get_mut(&id)
319            .expect("active buffer must exist in session map")
320            .buffer
321    }
322
323    #[inline]
324    pub fn active_meta(&self) -> &BufferMeta {
325        self.meta(self.active_id())
326            .expect("active metadata must exist in session map")
327    }
328
329    #[inline]
330    pub fn active_meta_mut(&mut self) -> &mut BufferMeta {
331        let id = self.active_id();
332        &mut self
333            .buffers
334            .get_mut(&id)
335            .expect("active metadata must exist in session map")
336            .meta
337    }
338
339    #[inline]
340    pub fn active_buffer_load_status(&self) -> BufferLoadStatus {
341        self.buffer_load_status(self.active_id())
342            .unwrap_or_else(BufferLoadStatus::not_loading)
343    }
344
345    #[inline]
346    pub fn active_buffer_is_fully_loaded(&self) -> bool {
347        self.buffer_is_fully_loaded(self.active_id())
348            .unwrap_or(true)
349    }
350
351    #[inline]
352    pub fn buffer_load_status(&self, id: BufferId) -> Option<BufferLoadStatus> {
353        self.buffers.get(&id).map(|rec| rec.load_status.clone())
354    }
355
356    #[inline]
357    pub fn buffer_is_fully_loaded(&self, id: BufferId) -> Option<bool> {
358        self.buffers.get(&id).map(|rec| {
359            matches!(
360                rec.load_status.phase,
361                BufferLoadPhase::NotLoading | BufferLoadPhase::Complete
362            )
363        })
364    }
365
366    #[inline]
367    pub fn set_active_dirty(&mut self, dirty: bool) {
368        self.active_meta_mut().dirty = dirty;
369    }
370
371    /// Recompute active buffer dirty state by comparing current contents against
372    /// the last clean snapshot (opened-from-disk or last successful save).
373    pub fn recompute_active_dirty(&mut self) -> bool {
374        let id = self.active_id();
375        let rec = self
376            .buffers
377            .get_mut(&id)
378            .expect("active buffer must exist in session map");
379
380        let current_len = rec.buffer.len_chars();
381        if current_len != rec.clean_len_chars {
382            rec.meta.dirty = true;
383            return true;
384        }
385
386        let current = content_fingerprint(&rec.buffer);
387        rec.meta.dirty = current != rec.clean_fingerprint;
388        rec.meta.dirty
389    }
390
391    /// Record the active buffer's current contents as the clean snapshot.
392    pub fn mark_active_clean(&mut self) {
393        let id = self.active_id();
394        let rec = self
395            .buffers
396            .get_mut(&id)
397            .expect("active buffer must exist in session map");
398        rec.clean_fingerprint = content_fingerprint(&rec.buffer);
399        rec.clean_len_chars = rec.buffer.len_chars();
400        rec.meta.dirty = false;
401    }
402
403    #[inline]
404    pub fn any_dirty(&self) -> bool {
405        self.buffers.values().any(|rec| rec.meta.dirty)
406    }
407
408    /// Poll incremental loaders and append up to `max_bytes` across open buffers.
409    ///
410    /// Returns the number of bytes read from disk.
411    pub fn poll_loading(&mut self, max_bytes: usize) -> usize {
412        if max_bytes == 0 {
413            return 0;
414        }
415
416        let ids: Vec<BufferId> = self.mru.clone();
417        let mut remaining = max_bytes;
418        let mut total_read = 0usize;
419
420        for id in ids {
421            if remaining == 0 {
422                break;
423            }
424            let want = remaining.min(FULL_LOAD_CHUNK_BYTES);
425            match self.load_step_for(id, want) {
426                Ok(read) => {
427                    total_read = total_read.saturating_add(read);
428                    remaining = remaining.saturating_sub(read);
429                }
430                Err(_) => {
431                    // Error status is stored in-buffer; continue polling others.
432                }
433            }
434        }
435
436        total_read
437    }
438
439    /// Ensure a file-backed buffer has loaded enough text to include `line`,
440    /// or until the bounded read budget is exhausted.
441    pub fn ensure_buffer_loaded_through_line(
442        &mut self,
443        id: BufferId,
444        line: usize,
445        max_bytes: usize,
446    ) -> Result<()> {
447        let mut remaining = max_bytes;
448
449        while self
450            .buffers
451            .get(&id)
452            .map(|rec| {
453                matches!(rec.load_status.phase, BufferLoadPhase::Loading)
454                    && rec.buffer.len_lines() <= line
455            })
456            .unwrap_or(false)
457            && remaining > 0
458        {
459            let want = remaining.min(FULL_LOAD_CHUNK_BYTES);
460            let read = self.load_step_for(id, want)?;
461            if read == 0 {
462                break;
463            }
464            remaining = remaining.saturating_sub(read);
465        }
466
467        let status = self
468            .buffers
469            .get(&id)
470            .map(|rec| rec.load_status.clone())
471            .unwrap_or_else(BufferLoadStatus::not_loading);
472        if matches!(status.phase, BufferLoadPhase::Failed) {
473            let msg = status
474                .error
475                .unwrap_or_else(|| "buffer load failed".to_string());
476            bail!("{msg}");
477        }
478        Ok(())
479    }
480
481    /// Ensure a file-backed buffer is fully loaded to EOF.
482    pub fn ensure_buffer_fully_loaded(&mut self, id: BufferId) -> Result<()> {
483        loop {
484            let phase = self
485                .buffers
486                .get(&id)
487                .map(|rec| rec.load_status.phase)
488                .unwrap_or(BufferLoadPhase::NotLoading);
489            match phase {
490                BufferLoadPhase::NotLoading | BufferLoadPhase::Complete => return Ok(()),
491                BufferLoadPhase::Failed => {
492                    let msg = self
493                        .buffers
494                        .get(&id)
495                        .and_then(|rec| rec.load_status.error.clone())
496                        .unwrap_or_else(|| "buffer load failed".to_string());
497                    bail!("{msg}");
498                }
499                BufferLoadPhase::Loading => {
500                    let read = self.load_step_for(id, FULL_LOAD_CHUNK_BYTES)?;
501                    if read == 0 {
502                        continue;
503                    }
504                }
505            }
506        }
507    }
508
509    /// Cycle to the next buffer in MRU order.
510    pub fn switch_next_mru(&mut self) -> Option<BufferId> {
511        if self.mru.is_empty() {
512            return None;
513        }
514
515        if self.mru.len() > 1 {
516            self.mru.rotate_left(1);
517        }
518
519        let id = self.mru[0];
520        self.active = Some(id);
521        Some(id)
522    }
523
524    /// Cycle to the previous buffer in MRU order.
525    pub fn switch_prev_mru(&mut self) -> Option<BufferId> {
526        if self.mru.is_empty() {
527            return None;
528        }
529
530        if self.mru.len() > 1 {
531            self.mru.rotate_right(1);
532        }
533
534        let id = self.mru[0];
535        self.active = Some(id);
536        Some(id)
537    }
538
539    pub fn summaries(&self) -> Vec<BufferSummary> {
540        let active = self.active;
541        self.mru
542            .iter()
543            .filter_map(|id| self.buffers.get(id).map(|rec| (id, rec)))
544            .map(|(id, rec)| BufferSummary {
545                id: *id,
546                kind: rec.meta.kind,
547                display_name: rec.meta.display_name.clone(),
548                path: rec.meta.path.clone(),
549                dirty: rec.meta.dirty,
550                is_new_file: rec.meta.is_new_file,
551                is_active: Some(*id) == active,
552            })
553            .collect()
554    }
555
556    /// Close a buffer by id, activating the next MRU buffer if needed.
557    ///
558    /// Returns `false` if the id does not exist or this is the last remaining buffer.
559    pub fn close_buffer(&mut self, id: BufferId) -> bool {
560        if !self.buffers.contains_key(&id) || self.buffers.len() <= 1 {
561            return false;
562        }
563
564        if let Some(rec) = self.buffers.remove(&id)
565            && let Some(path) = rec.meta.path
566        {
567            self.path_index.remove(&path);
568        }
569
570        if let Some(pos) = self.mru.iter().position(|cur| *cur == id) {
571            self.mru.remove(pos);
572        }
573
574        if self.active == Some(id) {
575            self.active = self.mru.first().copied();
576        }
577
578        self.active.is_some()
579    }
580
581    /// Close the currently active buffer.
582    #[inline]
583    pub fn close_active_buffer(&mut self) -> bool {
584        self.close_buffer(self.active_id())
585    }
586
587    /// Save the active file-backed buffer.
588    pub fn save_active(&mut self) -> Result<()> {
589        let id = self.active_id();
590        self.ensure_buffer_fully_loaded(id)?;
591        let rec = self
592            .buffers
593            .get_mut(&id)
594            .expect("active buffer must exist in session map");
595
596        match rec.meta.kind {
597            BufferKind::File => {
598                let path = rec
599                    .meta
600                    .path
601                    .as_ref()
602                    .context("file buffer is missing path metadata")?;
603                let mut content = rec.buffer.to_string();
604                if !content.is_empty() && !content.ends_with('\n') {
605                    content.push('\n');
606                    rec.buffer = TextBuffer::from_str(&content);
607                }
608
609                std::fs::write(path, &content)
610                    .with_context(|| format!("failed to write file: {}", path.display()))?;
611
612                rec.clean_fingerprint = content_fingerprint(&rec.buffer);
613                rec.clean_len_chars = rec.buffer.len_chars();
614                rec.meta.dirty = false;
615                rec.meta.is_new_file = false;
616                Ok(())
617            }
618            BufferKind::Ui => bail!("cannot save UI buffer"),
619        }
620    }
621
622    #[inline]
623    pub fn buffer(&self, id: BufferId) -> Option<&TextBuffer> {
624        self.buffers.get(&id).map(|rec| &rec.buffer)
625    }
626
627    #[inline]
628    pub fn buffer_mut(&mut self, id: BufferId) -> Option<&mut TextBuffer> {
629        self.buffers.get_mut(&id).map(|rec| &mut rec.buffer)
630    }
631
632    #[inline]
633    pub fn meta(&self, id: BufferId) -> Option<&BufferMeta> {
634        self.buffers.get(&id).map(|rec| &rec.meta)
635    }
636
637    fn load_step_for(&mut self, id: BufferId, max_bytes: usize) -> Result<usize> {
638        let rec = match self.buffers.get_mut(&id) {
639            Some(rec) => rec,
640            None => return Ok(0),
641        };
642
643        if !matches!(rec.load_status.phase, BufferLoadPhase::Loading) {
644            return Ok(0);
645        }
646
647        let (chunk, bytes_loaded, total_bytes, is_eof) = match rec.loader.as_mut() {
648            Some(loader) => {
649                let chunk = match loader.read_chunk(max_bytes) {
650                    Ok(chunk) => chunk,
651                    Err(err) => {
652                        rec.load_status.phase = BufferLoadPhase::Failed;
653                        rec.load_status.error = Some(err.to_string());
654                        rec.load_status.bytes_loaded = loader.bytes_loaded();
655                        rec.load_status.total_bytes = loader.total_bytes();
656                        rec.loader = None;
657                        return Err(err);
658                    }
659                };
660                (
661                    chunk,
662                    loader.bytes_loaded(),
663                    loader.total_bytes(),
664                    loader.is_eof(),
665                )
666            }
667            None => {
668                rec.load_status.phase = BufferLoadPhase::Complete;
669                rec.load_status.error = None;
670                rec.clean_fingerprint = content_fingerprint(&rec.buffer);
671                rec.clean_len_chars = rec.buffer.len_chars();
672                return Ok(0);
673            }
674        };
675
676        if !chunk.text.is_empty() {
677            let at = rec.buffer.len_chars();
678            rec.buffer.rope_mut().insert(at, &chunk.text);
679        }
680
681        rec.load_status.bytes_loaded = bytes_loaded;
682        rec.load_status.total_bytes = total_bytes;
683
684        if chunk.eof || is_eof {
685            rec.load_status.phase = BufferLoadPhase::Complete;
686            rec.load_status.error = None;
687            rec.clean_fingerprint = content_fingerprint(&rec.buffer);
688            rec.clean_len_chars = rec.buffer.len_chars();
689            rec.loader = None;
690        } else {
691            rec.load_status.phase = BufferLoadPhase::Loading;
692            rec.load_status.error = None;
693        }
694
695        Ok(chunk.bytes_read)
696    }
697
698    fn alloc_id(&mut self) -> BufferId {
699        self.next_id = self.next_id.saturating_add(1);
700        BufferId(self.next_id)
701    }
702
703    fn promote_mru(&mut self, id: BufferId) {
704        if let Some(pos) = self.mru.iter().position(|cur| *cur == id) {
705            self.mru.remove(pos);
706        }
707        self.mru.insert(0, id);
708    }
709
710    fn display_path(&self, path: &Path) -> String {
711        if self.launch_dir.as_os_str().is_empty() {
712            return path.display().to_string();
713        }
714
715        relative_path(path, &self.launch_dir)
716            .unwrap_or_else(|| path.to_path_buf())
717            .display()
718            .to_string()
719    }
720}
721
722fn normalize_path(path: &Path) -> Result<PathBuf> {
723    let path = if path.is_absolute() {
724        path.to_path_buf()
725    } else {
726        std::env::current_dir()
727            .context("failed to resolve current directory")?
728            .join(path)
729    };
730
731    Ok(std::fs::canonicalize(&path).unwrap_or(path))
732}
733
734fn relative_path(path: &Path, base: &Path) -> Option<PathBuf> {
735    let path_components: Vec<Component<'_>> = path.components().collect();
736    let base_components: Vec<Component<'_>> = base.components().collect();
737
738    let mut shared = 0usize;
739    let max_shared = path_components.len().min(base_components.len());
740    while shared < max_shared && path_components[shared] == base_components[shared] {
741        shared += 1;
742    }
743
744    if shared == 0 {
745        return None;
746    }
747
748    let mut rel = PathBuf::new();
749
750    for comp in &base_components[shared..] {
751        if matches!(comp, Component::Normal(_)) {
752            rel.push("..");
753        }
754    }
755
756    for comp in &path_components[shared..] {
757        rel.push(comp.as_os_str());
758    }
759
760    if rel.as_os_str().is_empty() {
761        Some(PathBuf::from("."))
762    } else {
763        Some(rel)
764    }
765}
766
767fn content_fingerprint(buffer: &TextBuffer) -> u64 {
768    let mut hasher = DefaultHasher::new();
769    for chunk in buffer.rope().chunks() {
770        chunk.hash(&mut hasher);
771    }
772    hasher.finish()
773}
774
775fn hash_text(text: &str) -> u64 {
776    let mut hasher = DefaultHasher::new();
777    text.hash(&mut hasher);
778    hasher.finish()
779}
780
781#[cfg(test)]
782mod tests {
783    use super::*;
784
785    use std::fs;
786    use std::io::Write;
787    use std::time::{SystemTime, UNIX_EPOCH};
788
789    fn temp_path(tag: &str) -> PathBuf {
790        let nanos = SystemTime::now()
791            .duration_since(UNIX_EPOCH)
792            .expect("clock went backwards")
793            .as_nanos();
794        std::env::temp_dir().join(format!("redox_session_test_{tag}_{nanos}.txt"))
795    }
796
797    fn large_text(lines: usize) -> String {
798        let mut out = String::new();
799        for i in 0..lines {
800            out.push_str(&format!("line-{i:05} abcdefghijklmnopqrstuvwxyz\n"));
801        }
802        out
803    }
804
805    #[test]
806    fn opening_second_file_creates_and_activates_new_buffer() {
807        let path_a = temp_path("open_second_a");
808        let path_b = temp_path("open_second_b");
809        fs::write(&path_a, "aaa").expect("failed to write temp file");
810        fs::write(&path_b, "bbb").expect("failed to write temp file");
811
812        let mut session = EditorSession::open_initial_file(&path_a).expect("open initial failed");
813        let first = session.active_id();
814        let second = session.open_file(&path_b).expect("open second failed");
815
816        assert_ne!(first, second);
817        assert_eq!(session.active_id(), second);
818        assert_eq!(session.active_buffer().to_string(), "bbb");
819        assert!(!session.active_meta().display_name.starts_with('/'));
820
821        let _ = fs::remove_file(path_a);
822        let _ = fs::remove_file(path_b);
823    }
824
825    #[test]
826    fn opening_same_path_reuses_existing_buffer() {
827        let path = temp_path("dedup");
828        fs::write(&path, "hello").expect("failed to write temp file");
829
830        let mut session = EditorSession::open_initial_file(&path).expect("open initial failed");
831        let first = session.active_id();
832        let second = session.open_file(&path).expect("open same failed");
833
834        assert_eq!(first, second);
835        assert_eq!(session.summaries().len(), 1);
836
837        let _ = fs::remove_file(path);
838    }
839
840    #[test]
841    fn open_initial_unnamed_creates_empty_file_buffer() {
842        let session = EditorSession::open_initial_unnamed().expect("open unnamed failed");
843        let meta = session.active_meta();
844
845        assert_eq!(meta.kind, BufferKind::File);
846        assert_eq!(meta.display_name, "[No Name]");
847        assert!(meta.path.is_none());
848        assert!(meta.is_new_file);
849        assert_eq!(session.active_buffer().to_string(), "");
850    }
851
852    #[test]
853    fn missing_path_creates_empty_new_file_buffer() {
854        let missing = temp_path("missing");
855        if missing.exists() {
856            fs::remove_file(&missing).expect("failed to remove existing fixture");
857        }
858
859        let session = EditorSession::open_initial_file(&missing).expect("open initial failed");
860
861        assert!(session.active_buffer().is_empty());
862        assert!(session.active_meta().is_new_file);
863        assert_eq!(
864            session.active_meta().path.as_ref(),
865            Some(&normalize_path(&missing).unwrap())
866        );
867    }
868
869    #[test]
870    fn mru_switching_rotates_active_buffer() {
871        let path_a = temp_path("mru_a");
872        let path_b = temp_path("mru_b");
873        let path_c = temp_path("mru_c");
874        fs::write(&path_a, "a").expect("failed to write temp file");
875        fs::write(&path_b, "b").expect("failed to write temp file");
876        fs::write(&path_c, "c").expect("failed to write temp file");
877
878        let mut session = EditorSession::open_initial_file(&path_a).expect("open initial failed");
879        let _ = session.open_file(&path_b).expect("open second failed");
880        let _ = session.open_file(&path_c).expect("open third failed");
881
882        let first = session.active_id();
883        let second = session.switch_next_mru().expect("switch next failed");
884        let third = session.switch_next_mru().expect("switch next failed");
885        let back = session.switch_prev_mru().expect("switch prev failed");
886
887        assert_ne!(first, second);
888        assert_ne!(second, third);
889        assert_eq!(second, back);
890
891        let _ = fs::remove_file(path_a);
892        let _ = fs::remove_file(path_b);
893        let _ = fs::remove_file(path_c);
894    }
895
896    #[test]
897    fn any_dirty_detects_hidden_dirty_buffers() {
898        let path_a = temp_path("dirty_a");
899        let path_b = temp_path("dirty_b");
900        fs::write(&path_a, "a").expect("failed to write temp file");
901        fs::write(&path_b, "b").expect("failed to write temp file");
902
903        let mut session = EditorSession::open_initial_file(&path_a).expect("open initial failed");
904        let id_a = session.active_id();
905        let _ = session.open_file(&path_b).expect("open second failed");
906
907        let _ = session.activate(id_a);
908        let cursor = session.active_buffer().clamp_pos(crate::Pos::new(0, 1));
909        let _ = session.active_buffer_mut().insert(cursor, "x");
910        let _ = session.recompute_active_dirty();
911        let _ = session.switch_next_mru();
912
913        assert!(session.any_dirty());
914
915        let _ = fs::remove_file(path_a);
916        let _ = fs::remove_file(path_b);
917    }
918
919    #[test]
920    fn save_active_writes_and_clears_dirty() {
921        let path = temp_path("save_active");
922        fs::write(&path, "old").expect("failed to write temp file");
923
924        let mut session = EditorSession::open_initial_file(&path).expect("open initial failed");
925        let cursor = session.active_buffer().clamp_pos(crate::Pos::new(0, 3));
926        let _ = session.active_buffer_mut().insert(cursor, "_new");
927        let _ = session.recompute_active_dirty();
928
929        session.save_active().expect("save failed");
930
931        assert!(!session.active_meta().dirty);
932        let on_disk = fs::read_to_string(&path).expect("failed to read temp file");
933        assert_eq!(on_disk, "old_new\n");
934        assert_eq!(session.active_buffer().to_string(), "old_new\n");
935        assert!(!session.recompute_active_dirty());
936
937        let _ = fs::remove_file(path);
938    }
939
940    #[test]
941    fn save_active_appends_trailing_newline_for_non_empty_file() {
942        let path = temp_path("save_active_trailing_newline");
943        fs::write(&path, "hello").expect("failed to write temp file");
944
945        let mut session = EditorSession::open_initial_file(&path).expect("open initial failed");
946        session.save_active().expect("save failed");
947
948        assert_eq!(
949            fs::read_to_string(&path).expect("failed to read temp file"),
950            "hello\n"
951        );
952        assert_eq!(session.active_buffer().to_string(), "hello\n");
953        assert!(!session.recompute_active_dirty());
954
955        let _ = fs::remove_file(path);
956    }
957
958    #[test]
959    fn dirty_tracking_clears_when_content_returns_to_clean_snapshot() {
960        let path = temp_path("dirty_revert");
961        fs::write(&path, "hello").expect("failed to write temp file");
962
963        let mut session = EditorSession::open_initial_file(&path).expect("open initial failed");
964        let end = session.active_buffer().clamp_pos(crate::Pos::new(0, 5));
965        let _ = session.active_buffer_mut().insert(end, "!");
966        assert!(session.recompute_active_dirty());
967
968        let sel = crate::Selection::empty(crate::Pos::new(0, 6));
969        let _ = session.active_buffer_mut().backspace(sel);
970        assert!(!session.recompute_active_dirty());
971
972        let _ = fs::remove_file(path);
973    }
974
975    #[test]
976    fn incremental_open_starts_loading_for_large_file() {
977        let path = temp_path("incremental_open");
978        let text = large_text(6000);
979        fs::write(&path, &text).expect("failed to write temp file");
980
981        let session = EditorSession::open_initial_file(&path).expect("open initial failed");
982        let status = session.active_buffer_load_status();
983
984        assert_eq!(status.phase, BufferLoadPhase::Loading);
985        assert!(status.bytes_loaded > 0);
986        assert!(status.total_bytes.unwrap_or(0) > status.bytes_loaded);
987        assert!(!session.active_buffer_is_fully_loaded());
988
989        let _ = fs::remove_file(path);
990    }
991
992    #[test]
993    fn poll_loading_increases_loaded_bytes_monotonically() {
994        let path = temp_path("poll_monotonic");
995        let text = large_text(8000);
996        fs::write(&path, &text).expect("failed to write temp file");
997
998        let mut session = EditorSession::open_initial_file(&path).expect("open initial failed");
999        let mut prev = session.active_buffer_load_status().bytes_loaded;
1000
1001        for _ in 0..10 {
1002            let _ = session.poll_loading(8 * 1024);
1003            let now = session.active_buffer_load_status().bytes_loaded;
1004            assert!(now >= prev);
1005            prev = now;
1006        }
1007
1008        let _ = fs::remove_file(path);
1009    }
1010
1011    #[test]
1012    fn demand_loading_reaches_target_line_or_eof() {
1013        let path = temp_path("demand_line");
1014        let text = large_text(9000);
1015        fs::write(&path, &text).expect("failed to write temp file");
1016
1017        let mut session = EditorSession::open_initial_file(&path).expect("open initial failed");
1018        let id = session.active_id();
1019        let target = 3500usize;
1020        session
1021            .ensure_buffer_loaded_through_line(id, target, 256 * 1024)
1022            .expect("demand load failed");
1023
1024        let loaded_lines = session.active_buffer().len_lines();
1025        let phase = session.active_buffer_load_status().phase;
1026        assert!(loaded_lines > target || phase == BufferLoadPhase::Complete);
1027
1028        let _ = fs::remove_file(path);
1029    }
1030
1031    #[test]
1032    fn ensure_fully_loaded_completes_and_matches_disk() {
1033        let path = temp_path("full_load");
1034        let text = large_text(7500);
1035        fs::write(&path, &text).expect("failed to write temp file");
1036
1037        let mut session = EditorSession::open_initial_file(&path).expect("open initial failed");
1038        let id = session.active_id();
1039        session
1040            .ensure_buffer_fully_loaded(id)
1041            .expect("full load should succeed");
1042
1043        assert_eq!(
1044            session.active_buffer_load_status().phase,
1045            BufferLoadPhase::Complete
1046        );
1047        assert_eq!(session.active_buffer().to_string(), text);
1048
1049        let _ = fs::remove_file(path);
1050    }
1051
1052    #[test]
1053    fn full_load_handles_utf8_chunk_boundaries() {
1054        let path = temp_path("utf8_boundaries");
1055        let text = "😀alpha\nβeta\nこんにちは\n".repeat(7000);
1056        fs::write(&path, &text).expect("failed to write temp file");
1057
1058        let mut session = EditorSession::open_initial_file(&path).expect("open initial failed");
1059        let id = session.active_id();
1060        session
1061            .ensure_buffer_fully_loaded(id)
1062            .expect("full load should succeed");
1063
1064        assert_eq!(session.active_buffer().to_string(), text);
1065
1066        let _ = fs::remove_file(path);
1067    }
1068
1069    #[test]
1070    fn invalid_utf8_sets_failed_phase_and_blocks_full_load() {
1071        let path = temp_path("invalid_utf8_incremental");
1072        let mut file = fs::File::create(&path).expect("failed to create temp file");
1073        let prefix = "ok\n".repeat(30_000);
1074        file.write_all(prefix.as_bytes())
1075            .expect("failed to write prefix");
1076        file.write_all(&[0xff])
1077            .expect("failed to write invalid byte");
1078        file.flush().expect("failed to flush");
1079
1080        let mut session = EditorSession::open_initial_file(&path).expect("open initial failed");
1081        let id = session.active_id();
1082        let err = session
1083            .ensure_buffer_fully_loaded(id)
1084            .expect_err("expected invalid utf8 error");
1085        assert!(err.to_string().contains("not valid UTF-8"));
1086        assert_eq!(
1087            session.active_buffer_load_status().phase,
1088            BufferLoadPhase::Failed
1089        );
1090        assert!(!session.active_buffer().is_empty());
1091
1092        let _ = fs::remove_file(path);
1093    }
1094
1095    #[test]
1096    fn background_loading_does_not_mark_dirty() {
1097        let path = temp_path("load_not_dirty");
1098        let text = large_text(7000);
1099        fs::write(&path, &text).expect("failed to write temp file");
1100
1101        let mut session = EditorSession::open_initial_file(&path).expect("open initial failed");
1102        let _ = session.poll_loading(128 * 1024);
1103        assert!(!session.active_meta().dirty);
1104
1105        let id = session.active_id();
1106        session
1107            .ensure_buffer_fully_loaded(id)
1108            .expect("full load should succeed");
1109        let end = session.active_buffer().clamp_pos(crate::Pos::new(0, 5));
1110        let _ = session.active_buffer_mut().insert(end, "!");
1111        assert!(session.recompute_active_dirty());
1112
1113        let _ = fs::remove_file(path);
1114    }
1115
1116    #[test]
1117    fn save_active_forces_full_load_before_write() {
1118        let path = temp_path("save_gate");
1119        let text = large_text(8500);
1120        fs::write(&path, &text).expect("failed to write temp file");
1121
1122        let mut session = EditorSession::open_initial_file(&path).expect("open initial failed");
1123        assert_eq!(
1124            session.active_buffer_load_status().phase,
1125            BufferLoadPhase::Loading
1126        );
1127
1128        session.save_active().expect("save should force full load");
1129        assert_eq!(
1130            session.active_buffer_load_status().phase,
1131            BufferLoadPhase::Complete
1132        );
1133
1134        let on_disk = fs::read_to_string(&path).expect("failed to read file");
1135        assert_eq!(on_disk, text);
1136
1137        let _ = fs::remove_file(path);
1138    }
1139}