Skip to main content

redox_core/session/
mod.rs

1//! Multi-buffer session model for higher-level editor frontends.
2
3use std::collections::HashMap;
4use std::hash::{DefaultHasher, Hash, Hasher};
5use std::path::Component;
6use std::path::{Path, PathBuf};
7
8use anyhow::{Context as _, Result, bail};
9
10use crate::{TextBuffer, io};
11
12/// Stable buffer identifier within an [`EditorSession`].
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
14pub struct BufferId(u64);
15
16impl BufferId {
17    #[inline]
18    pub fn get(self) -> u64 {
19        self.0
20    }
21}
22
23/// Buffer classification.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum BufferKind {
26    /// File-backed editable buffer.
27    File,
28    /// Ephemeral/UI buffer (this is what I'll be using for picker/menu surfaces).
29    Ui,
30}
31
32/// Buffer metadata tracked by session management.
33#[derive(Debug, Clone)]
34pub struct BufferMeta {
35    pub id: BufferId,
36    pub kind: BufferKind,
37    pub display_name: String,
38    pub path: Option<PathBuf>,
39    pub dirty: bool,
40    pub is_new_file: bool,
41}
42
43/// Listing row for buffer UIs such as `:ls`.
44#[derive(Debug, Clone)]
45pub struct BufferSummary {
46    pub id: BufferId,
47    pub kind: BufferKind,
48    pub display_name: String,
49    pub path: Option<PathBuf>,
50    pub dirty: bool,
51    pub is_new_file: bool,
52    pub is_active: bool,
53}
54
55#[derive(Debug, Clone)]
56struct BufferRecord {
57    meta: BufferMeta,
58    buffer: TextBuffer,
59    clean_fingerprint: u64,
60}
61
62/// Multi-buffer editor session with active buffer + MRU ordering.
63#[derive(Debug)]
64pub struct EditorSession {
65    buffers: HashMap<BufferId, BufferRecord>,
66    path_index: HashMap<PathBuf, BufferId>,
67    mru: Vec<BufferId>,
68    active: Option<BufferId>,
69    next_id: u64,
70    launch_dir: PathBuf,
71}
72
73impl Default for EditorSession {
74    fn default() -> Self {
75        Self {
76            buffers: HashMap::new(),
77            path_index: HashMap::new(),
78            mru: Vec::new(),
79            active: None,
80            next_id: 0,
81            launch_dir: PathBuf::new(),
82        }
83    }
84}
85
86impl EditorSession {
87    /// Build a new session with a single initial file buffer.
88    pub fn open_initial_file(path: impl AsRef<Path>) -> Result<Self> {
89        let launch_dir = std::env::current_dir().context("failed to resolve current directory")?;
90        let launch_dir = std::fs::canonicalize(&launch_dir).unwrap_or(launch_dir);
91
92        let mut session = Self {
93            launch_dir,
94            ..Self::default()
95        };
96        let _ = session.open_file(path)?;
97        Ok(session)
98    }
99
100    /// Open (or switch to) a file-backed buffer.
101    ///
102    /// - Existing open path: activates and returns existing buffer ID.
103    /// - Missing path: creates an empty file-backed buffer marked as new.
104    pub fn open_file(&mut self, path: impl AsRef<Path>) -> Result<BufferId> {
105        let normalized = normalize_path(path.as_ref())?;
106
107        if let Some(existing) = self.path_index.get(&normalized).copied() {
108            let _ = self.activate(existing);
109            return Ok(existing);
110        }
111
112        let file_exists = normalized.exists();
113        let buffer = if file_exists {
114            io::load_buffer(&normalized)?
115        } else {
116            TextBuffer::new()
117        };
118
119        let id = self.alloc_id();
120        let meta = BufferMeta {
121            id,
122            kind: BufferKind::File,
123            display_name: self.display_path(&normalized),
124            path: Some(normalized.clone()),
125            dirty: false,
126            is_new_file: !file_exists,
127        };
128        let clean_fingerprint = content_fingerprint(&buffer);
129
130        self.buffers.insert(
131            id,
132            BufferRecord {
133                meta,
134                buffer,
135                clean_fingerprint,
136            },
137        );
138        self.path_index.insert(normalized, id);
139        let _ = self.activate(id);
140
141        Ok(id)
142    }
143
144    /// Open an in-memory UI buffer (future floating picker/menu hook).
145    pub fn open_ui_buffer(&mut self, name: impl Into<String>, initial_text: &str) -> BufferId {
146        let id = self.alloc_id();
147        let meta = BufferMeta {
148            id,
149            kind: BufferKind::Ui,
150            display_name: name.into(),
151            path: None,
152            dirty: false,
153            is_new_file: false,
154        };
155
156        self.buffers.insert(
157            id,
158            BufferRecord {
159                meta,
160                buffer: TextBuffer::from_str(initial_text),
161                clean_fingerprint: hash_text(initial_text),
162            },
163        );
164        let _ = self.activate(id);
165
166        id
167    }
168
169    #[inline]
170    pub fn active_id(&self) -> BufferId {
171        self.active
172            .expect("editor session must always have an active buffer")
173    }
174
175    /// Activate the target buffer and promote it to the top of MRU order.
176    pub fn activate(&mut self, id: BufferId) -> bool {
177        if !self.buffers.contains_key(&id) {
178            return false;
179        }
180
181        self.active = Some(id);
182        self.promote_mru(id);
183        true
184    }
185
186    #[inline]
187    pub fn active_buffer(&self) -> &TextBuffer {
188        self.buffer(self.active_id())
189            .expect("active buffer must exist in session map")
190    }
191
192    #[inline]
193    pub fn active_buffer_mut(&mut self) -> &mut TextBuffer {
194        let id = self.active_id();
195        &mut self
196            .buffers
197            .get_mut(&id)
198            .expect("active buffer must exist in session map")
199            .buffer
200    }
201
202    #[inline]
203    pub fn active_meta(&self) -> &BufferMeta {
204        self.meta(self.active_id())
205            .expect("active metadata must exist in session map")
206    }
207
208    #[inline]
209    pub fn active_meta_mut(&mut self) -> &mut BufferMeta {
210        let id = self.active_id();
211        &mut self
212            .buffers
213            .get_mut(&id)
214            .expect("active metadata must exist in session map")
215            .meta
216    }
217
218    #[inline]
219    pub fn set_active_dirty(&mut self, dirty: bool) {
220        self.active_meta_mut().dirty = dirty;
221    }
222
223    /// Recompute active buffer dirty state by comparing current contents against
224    /// the last clean snapshot (opened-from-disk or last successful save).
225    pub fn recompute_active_dirty(&mut self) -> bool {
226        let id = self.active_id();
227        let rec = self
228            .buffers
229            .get_mut(&id)
230            .expect("active buffer must exist in session map");
231
232        let current = content_fingerprint(&rec.buffer);
233        rec.meta.dirty = current != rec.clean_fingerprint;
234        rec.meta.dirty
235    }
236
237    /// Record the active buffer's current contents as the clean snapshot.
238    pub fn mark_active_clean(&mut self) {
239        let id = self.active_id();
240        let rec = self
241            .buffers
242            .get_mut(&id)
243            .expect("active buffer must exist in session map");
244        rec.clean_fingerprint = content_fingerprint(&rec.buffer);
245        rec.meta.dirty = false;
246    }
247
248    #[inline]
249    pub fn any_dirty(&self) -> bool {
250        self.buffers.values().any(|rec| rec.meta.dirty)
251    }
252
253    /// Cycle to the next buffer in MRU order.
254    pub fn switch_next_mru(&mut self) -> Option<BufferId> {
255        if self.mru.is_empty() {
256            return None;
257        }
258
259        if self.mru.len() > 1 {
260            self.mru.rotate_left(1);
261        }
262
263        let id = self.mru[0];
264        self.active = Some(id);
265        Some(id)
266    }
267
268    /// Cycle to the previous buffer in MRU order.
269    pub fn switch_prev_mru(&mut self) -> Option<BufferId> {
270        if self.mru.is_empty() {
271            return None;
272        }
273
274        if self.mru.len() > 1 {
275            self.mru.rotate_right(1);
276        }
277
278        let id = self.mru[0];
279        self.active = Some(id);
280        Some(id)
281    }
282
283    pub fn summaries(&self) -> Vec<BufferSummary> {
284        let active = self.active;
285        self.mru
286            .iter()
287            .filter_map(|id| self.buffers.get(id).map(|rec| (id, rec)))
288            .map(|(id, rec)| BufferSummary {
289                id: *id,
290                kind: rec.meta.kind,
291                display_name: rec.meta.display_name.clone(),
292                path: rec.meta.path.clone(),
293                dirty: rec.meta.dirty,
294                is_new_file: rec.meta.is_new_file,
295                is_active: Some(*id) == active,
296            })
297            .collect()
298    }
299
300    /// Close a buffer by id, activating the next MRU buffer if needed.
301    ///
302    /// Returns `false` if the id does not exist or this is the last remaining buffer.
303    pub fn close_buffer(&mut self, id: BufferId) -> bool {
304        if !self.buffers.contains_key(&id) || self.buffers.len() <= 1 {
305            return false;
306        }
307
308        if let Some(rec) = self.buffers.remove(&id)
309            && let Some(path) = rec.meta.path
310        {
311            self.path_index.remove(&path);
312        }
313
314        if let Some(pos) = self.mru.iter().position(|cur| *cur == id) {
315            self.mru.remove(pos);
316        }
317
318        if self.active == Some(id) {
319            self.active = self.mru.first().copied();
320        }
321
322        self.active.is_some()
323    }
324
325    /// Close the currently active buffer.
326    #[inline]
327    pub fn close_active_buffer(&mut self) -> bool {
328        self.close_buffer(self.active_id())
329    }
330
331    /// Save the active file-backed buffer.
332    pub fn save_active(&mut self) -> Result<()> {
333        let id = self.active_id();
334        let rec = self
335            .buffers
336            .get_mut(&id)
337            .expect("active buffer must exist in session map");
338
339        match rec.meta.kind {
340            BufferKind::File => {
341                let path = rec
342                    .meta
343                    .path
344                    .as_ref()
345                    .context("file buffer is missing path metadata")?;
346                let content = rec.buffer.to_string();
347
348                std::fs::write(path, &content)
349                    .with_context(|| format!("failed to write file: {}", path.display()))?;
350
351                rec.clean_fingerprint = hash_text(&content);
352                rec.meta.dirty = false;
353                rec.meta.is_new_file = false;
354                Ok(())
355            }
356            BufferKind::Ui => bail!("cannot save UI buffer"),
357        }
358    }
359
360    #[inline]
361    pub fn buffer(&self, id: BufferId) -> Option<&TextBuffer> {
362        self.buffers.get(&id).map(|rec| &rec.buffer)
363    }
364
365    #[inline]
366    pub fn buffer_mut(&mut self, id: BufferId) -> Option<&mut TextBuffer> {
367        self.buffers.get_mut(&id).map(|rec| &mut rec.buffer)
368    }
369
370    #[inline]
371    pub fn meta(&self, id: BufferId) -> Option<&BufferMeta> {
372        self.buffers.get(&id).map(|rec| &rec.meta)
373    }
374
375    fn alloc_id(&mut self) -> BufferId {
376        self.next_id = self.next_id.saturating_add(1);
377        BufferId(self.next_id)
378    }
379
380    fn promote_mru(&mut self, id: BufferId) {
381        if let Some(pos) = self.mru.iter().position(|cur| *cur == id) {
382            self.mru.remove(pos);
383        }
384        self.mru.insert(0, id);
385    }
386
387    fn display_path(&self, path: &Path) -> String {
388        if self.launch_dir.as_os_str().is_empty() {
389            return path.display().to_string();
390        }
391
392        relative_path(path, &self.launch_dir)
393            .unwrap_or_else(|| path.to_path_buf())
394            .display()
395            .to_string()
396    }
397}
398
399fn normalize_path(path: &Path) -> Result<PathBuf> {
400    let path = if path.is_absolute() {
401        path.to_path_buf()
402    } else {
403        std::env::current_dir()
404            .context("failed to resolve current directory")?
405            .join(path)
406    };
407
408    Ok(std::fs::canonicalize(&path).unwrap_or(path))
409}
410
411fn relative_path(path: &Path, base: &Path) -> Option<PathBuf> {
412    let path_components: Vec<Component<'_>> = path.components().collect();
413    let base_components: Vec<Component<'_>> = base.components().collect();
414
415    let mut shared = 0usize;
416    let max_shared = path_components.len().min(base_components.len());
417    while shared < max_shared && path_components[shared] == base_components[shared] {
418        shared += 1;
419    }
420
421    if shared == 0 {
422        return None;
423    }
424
425    let mut rel = PathBuf::new();
426
427    for comp in &base_components[shared..] {
428        if matches!(comp, Component::Normal(_)) {
429            rel.push("..");
430        }
431    }
432
433    for comp in &path_components[shared..] {
434        rel.push(comp.as_os_str());
435    }
436
437    if rel.as_os_str().is_empty() {
438        Some(PathBuf::from("."))
439    } else {
440        Some(rel)
441    }
442}
443
444fn content_fingerprint(buffer: &TextBuffer) -> u64 {
445    hash_text(&buffer.to_string())
446}
447
448fn hash_text(text: &str) -> u64 {
449    let mut hasher = DefaultHasher::new();
450    text.hash(&mut hasher);
451    hasher.finish()
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457
458    use std::fs;
459    use std::time::{SystemTime, UNIX_EPOCH};
460
461    fn temp_path(tag: &str) -> PathBuf {
462        let nanos = SystemTime::now()
463            .duration_since(UNIX_EPOCH)
464            .expect("clock went backwards")
465            .as_nanos();
466        std::env::temp_dir().join(format!("redox_session_test_{tag}_{nanos}.txt"))
467    }
468
469    #[test]
470    fn opening_second_file_creates_and_activates_new_buffer() {
471        let path_a = temp_path("open_second_a");
472        let path_b = temp_path("open_second_b");
473        fs::write(&path_a, "aaa").expect("failed to write temp file");
474        fs::write(&path_b, "bbb").expect("failed to write temp file");
475
476        let mut session = EditorSession::open_initial_file(&path_a).expect("open initial failed");
477        let first = session.active_id();
478        let second = session.open_file(&path_b).expect("open second failed");
479
480        assert_ne!(first, second);
481        assert_eq!(session.active_id(), second);
482        assert_eq!(session.active_buffer().to_string(), "bbb");
483        assert!(!session.active_meta().display_name.starts_with('/'));
484
485        let _ = fs::remove_file(path_a);
486        let _ = fs::remove_file(path_b);
487    }
488
489    #[test]
490    fn opening_same_path_reuses_existing_buffer() {
491        let path = temp_path("dedup");
492        fs::write(&path, "hello").expect("failed to write temp file");
493
494        let mut session = EditorSession::open_initial_file(&path).expect("open initial failed");
495        let first = session.active_id();
496        let second = session.open_file(&path).expect("open same failed");
497
498        assert_eq!(first, second);
499        assert_eq!(session.summaries().len(), 1);
500
501        let _ = fs::remove_file(path);
502    }
503
504    #[test]
505    fn missing_path_creates_empty_new_file_buffer() {
506        let missing = temp_path("missing");
507        if missing.exists() {
508            fs::remove_file(&missing).expect("failed to remove existing fixture");
509        }
510
511        let session = EditorSession::open_initial_file(&missing).expect("open initial failed");
512
513        assert!(session.active_buffer().is_empty());
514        assert!(session.active_meta().is_new_file);
515        assert_eq!(
516            session.active_meta().path.as_ref(),
517            Some(&normalize_path(&missing).unwrap())
518        );
519    }
520
521    #[test]
522    fn mru_switching_rotates_active_buffer() {
523        let path_a = temp_path("mru_a");
524        let path_b = temp_path("mru_b");
525        let path_c = temp_path("mru_c");
526        fs::write(&path_a, "a").expect("failed to write temp file");
527        fs::write(&path_b, "b").expect("failed to write temp file");
528        fs::write(&path_c, "c").expect("failed to write temp file");
529
530        let mut session = EditorSession::open_initial_file(&path_a).expect("open initial failed");
531        let _ = session.open_file(&path_b).expect("open second failed");
532        let _ = session.open_file(&path_c).expect("open third failed");
533
534        let first = session.active_id();
535        let second = session.switch_next_mru().expect("switch next failed");
536        let third = session.switch_next_mru().expect("switch next failed");
537        let back = session.switch_prev_mru().expect("switch prev failed");
538
539        assert_ne!(first, second);
540        assert_ne!(second, third);
541        assert_eq!(second, back);
542
543        let _ = fs::remove_file(path_a);
544        let _ = fs::remove_file(path_b);
545        let _ = fs::remove_file(path_c);
546    }
547
548    #[test]
549    fn any_dirty_detects_hidden_dirty_buffers() {
550        let path_a = temp_path("dirty_a");
551        let path_b = temp_path("dirty_b");
552        fs::write(&path_a, "a").expect("failed to write temp file");
553        fs::write(&path_b, "b").expect("failed to write temp file");
554
555        let mut session = EditorSession::open_initial_file(&path_a).expect("open initial failed");
556        let id_a = session.active_id();
557        let _ = session.open_file(&path_b).expect("open second failed");
558
559        let _ = session.activate(id_a);
560        let cursor = session.active_buffer().clamp_pos(crate::Pos::new(0, 1));
561        let _ = session.active_buffer_mut().insert(cursor, "x");
562        let _ = session.recompute_active_dirty();
563        let _ = session.switch_next_mru();
564
565        assert!(session.any_dirty());
566
567        let _ = fs::remove_file(path_a);
568        let _ = fs::remove_file(path_b);
569    }
570
571    #[test]
572    fn save_active_writes_and_clears_dirty() {
573        let path = temp_path("save_active");
574        fs::write(&path, "old").expect("failed to write temp file");
575
576        let mut session = EditorSession::open_initial_file(&path).expect("open initial failed");
577        let cursor = session.active_buffer().clamp_pos(crate::Pos::new(0, 3));
578        let _ = session.active_buffer_mut().insert(cursor, "_new");
579        let _ = session.recompute_active_dirty();
580
581        session.save_active().expect("save failed");
582
583        assert!(!session.active_meta().dirty);
584        let on_disk = fs::read_to_string(&path).expect("failed to read temp file");
585        assert_eq!(on_disk, "old_new");
586
587        let _ = fs::remove_file(path);
588    }
589
590    #[test]
591    fn dirty_tracking_clears_when_content_returns_to_clean_snapshot() {
592        let path = temp_path("dirty_revert");
593        fs::write(&path, "hello").expect("failed to write temp file");
594
595        let mut session = EditorSession::open_initial_file(&path).expect("open initial failed");
596        let end = session.active_buffer().clamp_pos(crate::Pos::new(0, 5));
597        let _ = session.active_buffer_mut().insert(end, "!");
598        assert!(session.recompute_active_dirty());
599
600        let sel = crate::Selection::empty(crate::Pos::new(0, 6));
601        let _ = session.active_buffer_mut().backspace(sel);
602        assert!(!session.recompute_active_dirty());
603
604        let _ = fs::remove_file(path);
605    }
606}