1use 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum BufferKind {
26 File,
28 Ui,
30}
31
32#[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#[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#[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 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 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 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 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 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 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 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 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 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 #[inline]
327 pub fn close_active_buffer(&mut self) -> bool {
328 self.close_buffer(self.active_id())
329 }
330
331 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}