1mod 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum BufferKind {
32 File,
34 Ui,
36}
37
38#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum BufferLoadPhase {
64 NotLoading,
65 Loading,
66 Complete,
67 Failed,
68}
69
70#[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#[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 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 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 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 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 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 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 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 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 }
433 }
434 }
435
436 total_read
437 }
438
439 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 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 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 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 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 #[inline]
583 pub fn close_active_buffer(&mut self) -> bool {
584 self.close_buffer(self.active_id())
585 }
586
587 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}