1use lattice_runtime::RuntimeError;
32#[allow(unused_imports)]
37use lattice_syntax::{Lang, Syntax};
38
39use super::{App, BufferId};
40#[allow(unused_imports)]
44use crate::buffer_registry::{BufferData, BufferEntry, DocumentEntry};
45#[allow(unused_imports)]
46use crate::buffers::{BufferFlags, BufferKind};
47use crate::help::HelpContent;
48impl App {
54 pub fn activate_document(&mut self, id: BufferId) {
66 if self.mutate_editor_with(move |e| e.activate_document(id)) {
67 self.activate_buffer_state();
68 }
69 }
70
71 pub fn activate_buffer(&mut self, id: BufferId) {
84 if self.mutate_editor_with(move |e| e.activate_buffer(id)) {
85 self.activate_buffer_state();
86 }
87 self.mutate_editor(move |e| {
96 e.publish_render_state();
97 });
98 }
99
100 pub fn activate_file_tree(&mut self, id: BufferId) {
104 self.mutate_editor(move |e| e.activate_file_tree(id));
106 }
107
108 pub fn activate_oil(&mut self, id: BufferId) {
110 self.mutate_editor(move |e| e.activate_oil(id));
112 }
113
114 pub(super) fn activate_help_in_pane(&mut self, id: BufferId) {
116 self.mutate_editor(move |e| e.activate_help_in_pane(id));
118 }
119
120 pub(super) fn do_edit(&mut self, path: Option<std::path::PathBuf>, force: bool) {
152 let outcome = self.mutate_editor_with(move |e| e.do_edit(path, force));
162 self.handle_do_edit_outcome(outcome);
163 }
164
165 pub(super) fn handle_do_edit_outcome(
168 &mut self,
169 outcome: lattice_host::dispatch::DoEditOutcome,
170 ) {
171 use lattice_host::dispatch::DoEditOutcome;
172 match outcome {
173 DoEditOutcome::NoFileName | DoEditOutcome::Failed => {}
174 DoEditOutcome::Directory(dir) => self.do_open_oil(Some(dir)),
175 DoEditOutcome::Reloaded(signals)
176 | DoEditOutcome::Activated(signals)
177 | DoEditOutcome::Opened(signals) => {
178 for s in signals {
179 self.handle_renderer_signal(s);
180 }
181 }
182 }
183 }
184
185 pub(super) fn push_recent_file(&mut self, path: &std::path::Path) {
192 let path = path.to_path_buf();
193 self.mutate_editor(move |e| e.push_recent_file(&path));
194 }
195
196 pub(super) fn do_write(&mut self, path: Option<std::path::PathBuf>) {
202 self.mutate_editor(move |e| e.do_write(path));
204 }
205
206 pub(super) fn do_quit(&mut self, force: bool, scope: lattice_grammar::QuitScope) {
214 if scope == lattice_grammar::QuitScope::Pane && self.panes().tree.len() > 1 {
222 self.do_close_pane();
223 return;
224 }
225 self.mutate_editor_with(move |e| e.do_quit(force, scope));
226 }
227
228 pub(super) fn do_close_pane(&mut self) {
237 self.mutate_editor_with(move |e| e.do_close_pane());
238 self.gc_unreferenced_panel_buffers();
239 }
240
241 pub(super) fn gc_unreferenced_panel_buffers(&mut self) {}
250
251 pub(super) fn load_active_pane(&mut self) {
268 self.mutate_editor(|e| e.load_active_pane());
270 }
271
272 pub(super) fn find_document_by_path(&self, path: &std::path::Path) -> Option<BufferId> {
285 let path = path.to_path_buf();
289 self.read_editor(move |e| e.find_document_by_path(&path))
290 }
291
292 pub(super) fn snapshot_active_document(&mut self) {
306 self.mutate_editor(|e| e.snapshot_active_document());
308 }
309
310 pub(super) fn activate_buffer_state(&mut self) {
314 let signals = self.mutate_editor_with(|e| e.activate_buffer_state());
316 for sig in signals {
317 self.handle_renderer_signal(sig);
318 }
319 }
320
321 pub(super) fn active_pane_buffer_id(&self) -> BufferId {
325 self.read_editor(move |e| e.active_pane_buffer_id())
326 }
327
328 pub(super) fn snapshot_active_pane(&mut self) {
339 self.mutate_editor(|e| e.snapshot_active_pane());
341 }
342
343 pub fn pane_status_label(&self, pane: &crate::pane::PaneState) -> String {
376 let provider = self
377 .pane_render_provider(pane.buffer_id)
378 .map(|p| (p.status)(self, pane));
379 let rs = self.render_state.load();
380 lattice_host::modeline::pane_path_segment(pane, &rs, provider.as_deref())
381 }
382
383 pub(super) fn jump_to_file_line_col(&mut self, path: &std::path::Path, line: u32, col: u32) {
389 let path = path.to_path_buf();
392 let signals = self.mutate_editor_with(move |e| e.jump_to_file_line_col(&path, line, col));
393 for s in signals {
394 self.handle_renderer_signal(s);
395 }
396 }
397
398 pub(crate) fn open_help_in_pane(&mut self, content: HelpContent) -> BufferId {
433 let (id, signals) = self.mutate_editor_with(move |e| e.open_help_in_pane(content));
435 for s in signals {
436 self.handle_renderer_signal(s);
437 }
438 id
439 }
440
441 pub(super) fn seed_empty_document_locals(&mut self, buffer_id: BufferId) {
445 self.mutate_editor(move |e| e.seed_empty_document_locals(buffer_id));
447 }
448
449 pub(crate) fn document_syntax_for(&self, id: BufferId) -> Option<lattice_syntax::SyntaxHandle> {
478 self.read_editor(move |e| e.document_syntax_for(id).cloned())
482 }
483
484 pub(super) fn save_blocking(&mut self) -> Result<std::path::PathBuf, RuntimeError> {
495 self.mutate_editor_with(|e| e.save_blocking())
497 }
498
499 pub(super) fn save_as_blocking(&self, path: std::path::PathBuf) -> Result<(), RuntimeError> {
525 self.read_editor(move |e| e.save_as_blocking(path))
528 }
529
530 }
533
534#[cfg(test)]
541impl App {
542 pub(crate) fn document_folds_for(&self, id: BufferId) -> &[crate::app::Fold] {
543 let ad = self.ad();
544 if id == ad.document_buffer_id && matches!(ad.buffer_kind, BufferKind::Document) {
545 return &self.editor.folds;
546 }
547 self.editor
548 .buffer_locals
549 .get(&id)
550 .and_then(|l| l.get::<crate::modes::DocumentFolds>())
551 .map(|f| f.0.as_slice())
552 .unwrap_or(&[])
553 }
554
555 pub(crate) fn document_last_parsed_text_version_for(&self, id: BufferId) -> u64 {
556 let ad = self.ad();
557 if id == ad.document_buffer_id && matches!(ad.buffer_kind, BufferKind::Document) {
558 return self.editor.last_parsed_text_version;
559 }
560 self.editor
561 .buffer_locals
562 .get(&id)
563 .and_then(|l| l.get::<crate::modes::DocumentLastParsedTextVersion>())
564 .map(|v| v.0)
565 .unwrap_or(0)
566 }
567
568 pub(crate) fn document_last_synced_syntax_version_for(&self, id: BufferId) -> u64 {
569 let ad = self.ad();
570 if id == ad.document_buffer_id && matches!(ad.buffer_kind, BufferKind::Document) {
571 return self.editor.last_synced_syntax_version;
572 }
573 self.editor
574 .buffer_locals
575 .get(&id)
576 .and_then(|l| l.get::<crate::modes::DocumentLastSyncedSyntaxVersion>())
577 .map(|v| v.0)
578 .unwrap_or(0)
579 }
580}
581
582#[cfg(test)]
583mod tests {
584 #![allow(clippy::unwrap_used, clippy::panic)]
585
586 use super::*;
587 use crate::app::test_helpers::{
588 app_with, attach_test_syntax, fresh_workspace, invoke_motion, press, set_rust_syntax,
589 settle_mode, submit_ex, unique_tempdir, write_temp_file, write_workspace_config,
590 };
591 use crate::app::*;
592 use lattice_protocol::edit::Edit;
593
594 fn oil_follow(a: &mut App) {
603 let id = a.editor.active_pane_buffer_id();
604 let line = a.editor.cursor.line as usize;
605 let dir = a.editor.oil_dir_for(id).expect("oil dir");
606 let entry = a
607 .editor
608 .oil_snapshot_for(id)
609 .and_then(|s| s.snapshot_entries().get(line).cloned())
610 .expect("entry under cursor");
611 let target = dir.join(&entry.name);
612 if entry.is_dir {
613 a.apply_effect(lattice_grammar::Effect::OilNavigate {
614 view: id,
615 dir: target,
616 focus: None,
617 });
618 } else {
619 a.apply_effect(lattice_grammar::Effect::OpenBufferAt {
620 path: Some(target),
621 position: lattice_protocol::Position::ZERO,
622 force: false,
623 content: None,
624 activate_minor: None,
625 });
626 }
627 }
628
629 #[test]
630 fn maybe_reparse_syntax_drains_pending_edits_and_updates_version() {
631 let mut a = app_with("hello", 5);
632 attach_test_syntax(&mut a, lattice_syntax::Lang::Rust);
633 let initial_synced = a.editor.last_synced_syntax_version;
634 a.apply_edit_blocking(Edit::insert(Position::new(0, 5), " world"))
635 .unwrap();
636 assert_eq!(a.editor.pending_syntax_edits.len(), 1);
637 a.maybe_reparse_syntax();
640 assert_eq!(a.editor.pending_syntax_edits.len(), 0);
642 assert!(a.editor.last_synced_syntax_version > initial_synced);
645 assert_eq!(
646 a.editor.last_synced_syntax_version,
647 a.editor.document.text_version()
648 );
649 }
650
651 #[test]
652 fn edit_loads_named_file() {
653 let dir = unique_tempdir();
654 let path = dir.join("hello.txt");
655 std::fs::write(&path, "loaded contents\nsecond line").unwrap();
656 let mut a = app_with("original", 10);
657 let cmd = format!("e {}", path.display());
658 submit_ex(&mut a, &cmd);
659 assert_eq!(a.editor.document.text(), "loaded contents\nsecond line");
660 assert_eq!(a.editor.cursor, Position::ZERO);
661 std::fs::remove_dir_all(&dir).ok();
662 }
663
664 #[test]
665 fn autoread_stamps_fingerprint_on_load() {
666 use lattice_host::autoread::OnDiskFingerprint;
669 let dir = unique_tempdir();
670 let path = dir.join("hello.txt");
671 std::fs::write(&path, "loaded contents\n").unwrap();
672 let mut a = app_with("original", 10);
673 submit_ex(&mut a, &format!("e {}", path.display()));
674
675 let id = a.editor.document_buffer_id;
676 let fp = a
677 .editor
678 .on_disk_fingerprints
679 .get(&id)
680 .expect("fingerprint stamped on load");
681 let expected = OnDiskFingerprint::from_path_and_text(&path, "loaded contents\n");
682 assert!(
683 fp.same_content(&expected),
684 "load fingerprint matches file content"
685 );
686 std::fs::remove_dir_all(&dir).ok();
687 }
688
689 #[test]
690 fn autoread_restamps_fingerprint_on_save_to_match_disk() {
691 use lattice_host::autoread::OnDiskFingerprint;
696 let dir = unique_tempdir();
697 let path = dir.join("edit.txt");
698 std::fs::write(&path, "old\n").unwrap();
699 let mut a = app_with("original", 10);
700 submit_ex(&mut a, &format!("e {}", path.display()));
701 let id = a.editor.document_buffer_id;
702 let before = a.editor.on_disk_fingerprints.get(&id).cloned().unwrap();
703
704 a.apply_edit_blocking(Edit::insert(Position::new(0, 0), "new-"))
706 .unwrap();
707 assert!(a.editor.document.dirty());
708 a.save_blocking().unwrap();
709
710 let after = a.editor.on_disk_fingerprints.get(&id).cloned().unwrap();
711 assert!(
712 !before.same_content(&after),
713 "content changed ⇒ fingerprint changed"
714 );
715 let disk = std::fs::read_to_string(&path).unwrap();
718 let disk_fp = OnDiskFingerprint::from_path_and_text(&path, &disk);
719 assert!(
720 after.same_content(&disk_fp),
721 "post-save fingerprint matches on-disk content"
722 );
723 std::fs::remove_dir_all(&dir).ok();
724 }
725
726 #[test]
727 fn autoread_removes_fingerprint_on_buffer_delete() {
728 let dir = unique_tempdir();
731 let a_path = dir.join("a.txt");
732 let b_path = dir.join("b.txt");
733 std::fs::write(&a_path, "aaa\n").unwrap();
734 std::fs::write(&b_path, "bbb\n").unwrap();
735 let mut app = app_with("original", 10);
736 submit_ex(&mut app, &format!("e {}", a_path.display()));
737 let a_id = app.editor.document_buffer_id;
738 submit_ex(&mut app, &format!("e {}", b_path.display()));
739 assert!(app.editor.on_disk_fingerprints.contains_key(&a_id));
740
741 submit_ex(&mut app, &format!("e {}", a_path.display()));
743 assert_eq!(app.editor.document_buffer_id, a_id);
744 submit_ex(&mut app, "bd");
745 assert!(
746 !app.editor.on_disk_fingerprints.contains_key(&a_id),
747 "fingerprint dropped on :bd"
748 );
749 std::fs::remove_dir_all(&dir).ok();
750 }
751
752 #[test]
758 fn edit_refuses_when_dirty() {
759 let dir = unique_tempdir();
760 let path = dir.join("guarded.txt");
761 std::fs::write(&path, "on disk").unwrap();
762 let mut a = app_with("scratch", 10);
763 submit_ex(&mut a, &format!("e {}", path.display()));
764 a.apply(Action::EnterMode(ModalState::Insert));
765 a.apply(Action::Insert("X".into()));
766 a.apply(Action::EnterMode(ModalState::Normal));
767 assert!(a.editor.document.dirty());
768 submit_ex(&mut a, "e");
769 let msg = a.editor.last_message.as_ref().unwrap();
770 assert_eq!(msg.level, EchoLevel::Error);
771 assert_eq!(a.editor.document.text(), "Xon disk");
773 std::fs::remove_dir_all(&dir).ok();
774 }
775
776 #[test]
777 fn edit_force_overrides_dirty_guard() {
778 let dir = unique_tempdir();
779 let path = dir.join("forced.txt");
780 std::fs::write(&path, "loaded").unwrap();
781 let mut a = app_with("dirty content", 10);
782 a.apply(Action::EnterMode(ModalState::Insert));
783 a.apply(Action::Insert("Z".into()));
784 a.apply(Action::EnterMode(ModalState::Normal));
785 let cmd = format!("e! {}", path.display());
786 submit_ex(&mut a, &cmd);
787 assert_eq!(a.editor.document.text(), "loaded");
788 std::fs::remove_dir_all(&dir).ok();
789 }
790
791 #[test]
792 fn edit_preserves_registers_across_swap() {
793 let dir = unique_tempdir();
794 let path = dir.join("preserve.txt");
795 std::fs::write(&path, "new content").unwrap();
796 let mut a = app_with("hello world", 10);
797 let inv = CommandInvocation::of(a.editor.builtins.yank.0).with_target(
798 lattice_grammar::Target::Motion(
799 a.editor.builtins.word_forward,
800 lattice_grammar::Args::None,
801 ),
802 );
803 a.apply(Action::Invoke(inv));
804 assert!(a.editor.unnamed_register.is_some());
805 let cmd = format!("e {}", path.display());
806 submit_ex(&mut a, &cmd);
807 assert!(a.editor.unnamed_register.is_some());
809 std::fs::remove_dir_all(&dir).ok();
810 }
811
812 #[test]
813 fn edit_resets_per_document_state() {
814 let dir = unique_tempdir();
815 let path = dir.join("reset.txt");
816 std::fs::write(&path, "fresh").unwrap();
817 let mut a = app_with("aaa\nbbb\nccc", 10);
818 a.editor.cursor = Position::new(2, 1);
819 a.apply(invoke_motion(a.editor.builtins.goto_first_line));
820 let history_pre = a.editor.position_history.len();
822 assert!(history_pre > 0);
823 let cmd = format!("e {}", path.display());
824 submit_ex(&mut a, &cmd);
825 assert!(
834 a.editor.position_history.len() >= history_pre,
835 "position_history must survive fresh-file open (jump list is session-wide)"
836 );
837 assert_eq!(a.editor.cursor, Position::ZERO);
840 std::fs::remove_dir_all(&dir).ok();
841 }
842
843 #[test]
847 fn edit_unknown_path_opens_a_new_buffer() {
848 let mut a = app_with("hello", 10);
849 submit_ex(&mut a, "e /absolutely/does/not/exist/anywhere.txt");
850 let msg = a.editor.last_message.as_ref().unwrap();
851 assert_eq!(msg.level, EchoLevel::Info);
852 assert!(msg.text.ends_with("[New]"), "{}", msg.text);
853 assert_eq!(a.editor.document.text(), "");
854 assert!(!a.editor.document.dirty());
855 assert!(!std::path::Path::new("/absolutely/does/not/exist/anywhere.txt").exists());
856 }
857
858 #[test]
859 fn split_pane_horizontal_creates_second_pane() {
860 let mut a = app_with("xx", 10);
861 a.apply(Action::SplitPaneHorizontal);
862 assert_eq!(a.editor.pane_tree.len(), 2);
863 assert_eq!(a.editor.pane_tree.active_index(), 0);
865 }
866
867 #[test]
868 fn split_pane_vertical_creates_second_pane() {
869 let mut a = app_with("xx", 10);
870 a.apply(Action::SplitPaneVertical);
871 assert_eq!(a.editor.pane_tree.len(), 2);
872 }
873
874 #[test]
875 fn close_pane_collapses_split() {
876 let mut a = app_with("xx", 10);
877 a.apply(Action::SplitPaneVertical);
878 a.apply(Action::ClosePane);
879 assert_eq!(a.editor.pane_tree.len(), 1);
880 }
881
882 #[test]
883 fn only_pane_collapses_all_other_panes() {
884 let mut a = app_with("xx", 10);
886 a.apply(Action::SplitPaneVertical);
887 a.apply(Action::SplitPaneHorizontal);
888 assert_eq!(a.editor.pane_tree.len(), 3);
889 a.apply(Action::OnlyPane);
890 assert_eq!(a.editor.pane_tree.len(), 1);
891 }
892
893 #[test]
894 fn only_pane_single_pane_is_a_noop() {
895 let mut a = app_with("xx", 10);
897 a.apply(Action::OnlyPane);
898 assert_eq!(a.editor.pane_tree.len(), 1);
899 assert!(!a.editor.should_quit);
900 }
901
902 #[test]
903 fn quit_with_multiple_panes_closes_active_pane() {
904 let mut a = app_with("xx", 10);
905 a.apply(Action::SplitPaneVertical);
906 assert_eq!(a.editor.pane_tree.len(), 2);
907 a.do_quit(false, lattice_grammar::QuitScope::Pane);
908 assert!(
909 !a.editor.should_quit,
910 "extra pane: :q must not exit the editor"
911 );
912 assert_eq!(a.editor.pane_tree.len(), 1);
913 }
914
915 #[test]
916 fn quit_with_multiple_panes_skips_dirty_check() {
917 let mut a = app_with("xx", 10);
918 a.apply(Action::SplitPaneVertical);
919 a.apply(Action::EnterMode(ModalState::Insert));
920 a.apply(Action::Insert("z".into()));
921 a.apply(Action::EnterMode(ModalState::Normal));
922 assert!(a.editor.document.dirty());
923 a.do_quit(false, lattice_grammar::QuitScope::Pane);
924 assert!(!a.editor.should_quit);
925 assert_eq!(a.editor.pane_tree.len(), 1);
926 }
927
928 #[test]
929 fn quit_with_last_pane_clean_quits_editor() {
930 let mut a = app_with("xx", 10);
931 a.do_quit(false, lattice_grammar::QuitScope::Pane);
932 assert!(a.editor.should_quit);
933 }
934
935 #[test]
936 fn quit_last_pane_with_other_tabs_closes_tab_not_editor() {
937 let mut a = app_with("xx", 10);
940 a.apply(Action::NewTab);
941 assert_eq!(a.editor.tabs.len(), 2);
942 assert_eq!(a.editor.pane_tree.len(), 1, "new tab starts with one pane");
943 a.do_quit(false, lattice_grammar::QuitScope::Pane);
944 assert!(
945 !a.editor.should_quit,
946 ":q with other tabs open must not quit the editor"
947 );
948 assert_eq!(a.editor.tabs.len(), 1, ":q closed the tab, leaving one");
949 }
950
951 #[test]
952 fn quit_all_with_other_tabs_quits_editor() {
953 let mut a = app_with("xx", 10);
955 a.apply(Action::NewTab);
956 assert_eq!(a.editor.tabs.len(), 2);
957 a.do_quit(false, lattice_grammar::QuitScope::All);
958 assert!(
959 a.editor.should_quit,
960 ":qa must quit regardless of how many tabs are open"
961 );
962 }
963
964 #[test]
965 fn quit_all_with_multiple_panes_quits_editor() {
966 let mut a = app_with("xx", 10);
969 a.apply(Action::SplitPaneVertical);
970 assert_eq!(a.editor.pane_tree.len(), 2);
971 a.do_quit(false, lattice_grammar::QuitScope::All);
972 assert!(
973 a.editor.should_quit,
974 ":qa must exit the editor regardless of pane count"
975 );
976 }
977
978 #[test]
979 fn quit_all_dirty_refuses_then_force_quits() {
980 let mut a = app_with("xx", 10);
983 a.apply(Action::SplitPaneVertical);
984 a.apply(Action::EnterMode(ModalState::Insert));
985 a.apply(Action::Insert("z".into()));
986 a.apply(Action::EnterMode(ModalState::Normal));
987 assert!(a.editor.document.dirty());
988 a.do_quit(false, lattice_grammar::QuitScope::All);
989 assert!(
990 !a.editor.should_quit,
991 ":qa must honor the dirty guard (no force)"
992 );
993 assert_eq!(a.editor.pane_tree.len(), 2);
995 a.do_quit(true, lattice_grammar::QuitScope::All);
996 assert!(a.editor.should_quit, ":qa! forces past the dirty guard");
997 }
998
999 #[test]
1000 fn quit_with_last_pane_dirty_refuses() {
1001 let mut a = app_with("xx", 10);
1002 a.apply(Action::EnterMode(ModalState::Insert));
1003 a.apply(Action::Insert("z".into()));
1004 a.apply(Action::EnterMode(ModalState::Normal));
1005 a.do_quit(false, lattice_grammar::QuitScope::Pane);
1006 assert!(!a.editor.should_quit);
1007 assert!(
1008 a.editor
1009 .last_message
1010 .as_ref()
1011 .map(|m| m.text.contains("no write since last change"))
1012 .unwrap_or(false)
1013 );
1014 }
1015
1016 #[test]
1017 fn quit_force_with_last_pane_dirty_quits() {
1018 let mut a = app_with("xx", 10);
1019 a.apply(Action::EnterMode(ModalState::Insert));
1020 a.apply(Action::Insert("z".into()));
1021 a.apply(Action::EnterMode(ModalState::Normal));
1022 a.do_quit(true, lattice_grammar::QuitScope::Pane);
1023 assert!(a.editor.should_quit);
1024 }
1025
1026 #[test]
1033 fn quit_with_dirty_messages_buffer_still_quits() {
1034 let mut a = app_with("xx", 10);
1035 let msgs = a.ensure_messages_buffer();
1036 a.append_to_owned_buffer(msgs, "boot record line\n");
1040 assert!(
1041 a.editor.buffers.document_dirty(msgs),
1042 "test pre-condition: *messages* buffer reports dirty after append",
1043 );
1044 a.do_quit(false, lattice_grammar::QuitScope::Pane);
1045 assert!(
1046 a.editor.should_quit,
1047 ":q must skip the dirty guard for NoFile = true buffers (e.g. *messages*)",
1048 );
1049 }
1050
1051 #[test]
1052 fn open_help_popup_preserves_doc_pane_cursor_for_render() {
1053 let mut a = app_with("line0\nline1\nline2\nline3\nline4\n", 5);
1062 a.editor.cursor = Position::new(3, 2);
1063 a.editor.scroll = 1;
1064 a.do_lsp_status();
1065 let pane = a.editor.pane_tree.active();
1069 assert_eq!(
1070 pane.cursor,
1071 Position::new(3, 2),
1072 "doc's pre-help cursor must be stashed onto pane.cursor"
1073 );
1074 assert_eq!(pane.scroll, 1);
1075 }
1076
1077 #[test]
1078 fn split_inherits_cursor_and_scroll_from_active() {
1079 let mut a = app_with("a\nb\nc\nd", 10);
1080 a.editor.cursor = Position::new(2, 0);
1081 a.editor.scroll = 1;
1082 a.apply(Action::SplitPaneVertical);
1083 let panes = a.editor.pane_tree.leaves();
1085 assert_eq!(panes[0].cursor.line, 2);
1086 assert_eq!(panes[0].scroll, 1);
1087 assert_eq!(panes[1].cursor.line, 2);
1088 assert_eq!(panes[1].scroll, 1);
1089 }
1090
1091 #[test]
1092 fn edit_new_file_registers_a_second_buffer() {
1093 let path = write_temp_file("a", "alpha\n");
1094 let mut a = app_with("xx", 10);
1095 let initial_id = a.editor.document_buffer_id;
1096 a.editor
1097 .set_command_line_text(&format!("e {}", path.display()));
1098 a.editor.modal = ModalState::Command;
1099 a.apply(Action::CommandLineSubmit);
1100 assert_eq!(a.editor.buffers.listed_ids_sorted().len(), 2);
1103 assert_ne!(a.editor.document_buffer_id, initial_id);
1104 assert_eq!(a.editor.document.text(), "alpha\n");
1105 let _ = std::fs::remove_file(path);
1106 }
1107
1108 #[test]
1109 fn ls_renders_help_with_every_open_buffer() {
1110 let path = write_temp_file("c", "x\n");
1111 let mut a = app_with("xx", 10);
1112 a.editor
1113 .set_command_line_text(&format!("e {}", path.display()));
1114 a.editor.modal = ModalState::Command;
1115 a.apply(Action::CommandLineSubmit);
1116 a.editor.set_command_line_text("ls");
1117 a.editor.modal = ModalState::Command;
1118 a.apply(Action::CommandLineSubmit);
1119 let h = a.popup_help().expect("buffers help");
1120 let body = h.content.as_string();
1121 assert!(body.contains("4 open buffer"));
1129 assert!(body.contains("3 document"));
1130 assert!(body.contains("1 message"));
1131 assert!(body.contains("*lsp*"));
1132 assert!(body.contains("*messages*"));
1133 let _ = std::fs::remove_file(path);
1134 }
1135
1136 #[test]
1137 fn tree_open_makes_filetree_active() {
1138 let dir = std::env::temp_dir().join(format!("lattice-tree-test-{}", std::process::id()));
1139 std::fs::create_dir_all(&dir).ok();
1140 std::fs::write(dir.join("a.txt"), "alpha").ok();
1141 let mut a = app_with("xx", 10);
1142 a.editor
1143 .set_command_line_text(&format!("Filetree {}", dir.display()));
1144 a.editor.modal = ModalState::Command;
1145 a.apply(Action::CommandLineSubmit);
1146 assert_eq!(a.editor.active_buffer, BufferKind::FileTree);
1147 assert_eq!(a.editor.buffers.file_tree_ids_sorted().len(), 1);
1148 std::fs::remove_dir_all(&dir).ok();
1149 }
1150
1151 #[test]
1152 fn tree_close_returns_to_document() {
1153 let dir = std::env::temp_dir().join(format!("lattice-tree-close-{}", std::process::id()));
1154 std::fs::create_dir_all(&dir).ok();
1155 let mut a = app_with("xx", 10);
1156 a.editor
1157 .set_command_line_text(&format!("Filetree {}", dir.display()));
1158 a.editor.modal = ModalState::Command;
1159 a.apply(Action::CommandLineSubmit);
1160 a.apply(Action::HelpDismiss);
1161 assert_eq!(a.editor.active_buffer, BufferKind::Document);
1162 assert_eq!(a.editor.buffers.file_tree_ids_sorted().len(), 0);
1163 std::fs::remove_dir_all(&dir).ok();
1164 }
1165
1166 #[test]
1167 fn tree_motion_routes_through_active_buffer() {
1168 let dir = std::env::temp_dir().join(format!("lattice-tree-motion-{}", std::process::id()));
1169 std::fs::create_dir_all(&dir).ok();
1170 std::fs::write(dir.join("a.txt"), "x").ok();
1171 std::fs::write(dir.join("b.txt"), "y").ok();
1172 let mut a = app_with("xx", 10);
1173 a.editor
1174 .set_command_line_text(&format!("Filetree {}", dir.display()));
1175 a.editor.modal = ModalState::Command;
1176 a.apply(Action::CommandLineSubmit);
1177 let line_down = a.editor.builtins.line_down;
1178 a.apply(Action::Invoke(CommandInvocation::of(line_down.0)));
1179 assert_eq!(a.editor.cursor.line, 1);
1183 std::fs::remove_dir_all(&dir).ok();
1184 }
1185
1186 #[tokio::test(flavor = "multi_thread")]
1190 async fn tree_follow_on_file_opens_document_buffer() {
1191 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1192 let dir = std::env::temp_dir().join(format!("lattice-tree-follow-{}", std::process::id()));
1193 let _ = std::fs::remove_dir_all(&dir);
1194 std::fs::create_dir_all(&dir).ok();
1195 std::fs::write(dir.join("alpha.txt"), "hello").ok();
1196 let mut a = app_with("xx", 10);
1197 a.do_open_file_tree(Some(dir.clone()));
1198 assert!(
1199 settle_mode(&mut a, "file-tree-mode").await,
1200 "file-tree-mode must activate on the tree buffer",
1201 );
1202 let id = a.editor.active_pane_buffer_id();
1203 let line = a
1204 .editor
1205 .file_tree_entries_for(id)
1206 .and_then(|es| {
1207 es.iter().position(|e| {
1208 e.path
1209 .file_name()
1210 .map(|n| n == "alpha.txt")
1211 .unwrap_or(false)
1212 })
1213 })
1214 .expect("alpha.txt row present");
1215 a.editor.cursor.line = line as u32;
1216 press(&mut a, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
1218 assert_eq!(a.editor.active_buffer, BufferKind::Document);
1221 assert_eq!(a.editor.buffers.file_tree_ids_sorted().len(), 1);
1222 assert_eq!(a.editor.document.text(), "hello");
1223 std::fs::remove_dir_all(&dir).ok();
1224 }
1225
1226 #[test]
1227 fn open_help_in_pane_registers_buffer_and_activates_pane() {
1228 let mut app = app_with("hi\n", 5);
1229 let buf = HelpContent::from_lines("test-help", vec!["# heading".into(), "body".into()]);
1230 let id = app.open_help_in_pane(buf);
1231 assert!(app.editor.buffers.contains_help(id));
1233 assert_eq!(app.active_pane_buffer_id(), id);
1235 assert!(matches!(app.editor.active_buffer, BufferKind::Help));
1236 assert_eq!(app.popup_help().unwrap().title, "test-help");
1238 assert!(app.editor.buffers.help_ids_sorted().contains(&id));
1240 }
1241
1242 #[test]
1243 fn open_help_in_pane_dedups_by_title() {
1244 let mut app = app_with("hi\n", 5);
1245 let id1 = app.open_help_in_pane(HelpContent::from_lines("lsp:rust", vec!["v1".into()]));
1246 let id2 = app.open_help_in_pane(HelpContent::from_lines(
1247 "lsp:rust",
1248 vec!["v2 (refreshed)".into()],
1249 ));
1250 assert_eq!(id1, id2, "same title returns same BufferId");
1251 let body = app.popup_help().unwrap().content.as_string();
1253 assert!(body.contains("refreshed"));
1254 assert_eq!(app.editor.buffers.help_ids_sorted().len(), 1);
1256 }
1257
1258 #[test]
1259 fn active_pane_content_height_subtracts_status_row_always() {
1260 let mut app = app_with("hi\n", 5);
1262 assert_eq!(app.active_pane_content_height(20), 19);
1263 app.editor
1266 .pane_tree
1267 .split_active(crate::pane::SplitOrientation::Horizontal);
1268 app.editor.publish_render_state();
1272 let content = app.active_pane_content_height(20);
1273 assert_eq!(content, 9);
1275 }
1276
1277 #[test]
1278 fn persistent_lsp_log_level_applies_from_toml_tree() {
1279 let mut app = app_with("hi\n", 5);
1281 let toml_text = "[lsp-mode]\nlog-level = \"debug\"\n";
1282 app.editor.lsp_config_tree.store(std::sync::Arc::new(
1284 toml_text.parse::<toml::Table>().expect("toml parse"),
1285 ));
1286 app.apply_persistent_lsp_editor_options();
1287 let instance = lattice_lsp::InstanceKey::new(
1292 std::sync::Arc::<str>::from("rust"),
1293 std::sync::Arc::<std::path::Path>::from(std::path::Path::new("/tmp/test-ws")),
1294 );
1295 app.editor.lsp_logger.log(
1296 Some(&instance),
1297 lattice_lsp::LogLevel::Debug,
1298 lattice_lsp::LogSource::Client,
1299 "after-toml",
1300 );
1301 let recs = app.editor.lsp_logger.snapshot_instance(&instance);
1302 assert!(
1303 recs.iter().any(|r| r.message == "after-toml"),
1304 "Debug record should pass through after TOML log-level=debug",
1305 );
1306 }
1307
1308 #[test]
1309 fn persistent_lsp_log_level_legacy_key_warns_then_applies() {
1310 let mut app = app_with("hi\n", 5);
1313 let toml_text = "[lsp]\nlog-level = \"debug\"\n";
1314 app.editor.lsp_config_tree.store(std::sync::Arc::new(
1316 toml_text.parse::<toml::Table>().expect("toml parse"),
1317 ));
1318 app.apply_persistent_lsp_editor_options();
1319 let msg = app.editor.last_message.as_ref().expect("deprecation warn");
1320 assert_eq!(msg.level, crate::app::EchoLevel::Warn);
1321 assert!(
1322 msg.text.contains("`lsp.log-level` is deprecated")
1323 && msg.text.contains("`lsp-mode.log-level`"),
1324 "echo should name old + new keys, got {}",
1325 msg.text,
1326 );
1327 let instance = lattice_lsp::InstanceKey::new(
1329 std::sync::Arc::<str>::from("rust"),
1330 std::sync::Arc::<std::path::Path>::from(std::path::Path::new("/tmp/test-ws")),
1331 );
1332 app.editor.lsp_logger.log(
1333 Some(&instance),
1334 lattice_lsp::LogLevel::Debug,
1335 lattice_lsp::LogSource::Client,
1336 "after-legacy-toml",
1337 );
1338 let recs = app.editor.lsp_logger.snapshot_instance(&instance);
1339 assert!(
1340 recs.iter().any(|r| r.message == "after-legacy-toml"),
1341 "legacy key should still apply for one minor version",
1342 );
1343 }
1344
1345 #[test]
1346 fn persistent_lsp_log_level_canonical_wins_over_legacy() {
1347 let mut app = app_with("hi\n", 5);
1351 let toml_text = "[lsp]\nlog-level = \"trace\"\n[lsp-mode]\nlog-level = \"debug\"\n";
1352 app.editor.lsp_config_tree.store(std::sync::Arc::new(
1354 toml_text.parse::<toml::Table>().expect("toml parse"),
1355 ));
1356 app.apply_persistent_lsp_editor_options();
1357 assert!(
1359 app.editor.last_message.is_none(),
1360 "canonical present ⇒ silent; got {:?}",
1361 app.editor.last_message,
1362 );
1363 }
1364
1365 #[test]
1366 fn persistent_lsp_log_level_warns_on_unknown_value() {
1367 let mut app = app_with("hi\n", 5);
1368 let toml_text = "[lsp-mode]\nlog-level = \"babble\"\n";
1369 app.editor.lsp_config_tree.store(std::sync::Arc::new(
1371 toml_text.parse::<toml::Table>().expect("toml parse"),
1372 ));
1373 app.apply_persistent_lsp_editor_options();
1374 let msg = app.editor.last_message.as_ref().expect("warn echo");
1375 assert!(
1376 msg.text.contains("lsp-mode.log-level") && msg.text.contains("babble"),
1377 "echo should name the key + value, got {}",
1378 msg.text
1379 );
1380 }
1381
1382 #[test]
1383 fn persistent_lsp_log_level_silent_when_missing() {
1384 let mut app = app_with("hi\n", 5);
1385 app.editor.last_message = None;
1386 app.editor
1388 .lsp_config_tree
1389 .store(std::sync::Arc::new(toml::Table::new()));
1390 app.apply_persistent_lsp_editor_options();
1391 assert!(
1392 app.editor.last_message.is_none(),
1393 "no echo when key is absent (default applies)",
1394 );
1395 }
1396
1397 #[test]
1398 fn load_persistent_config_applies_scalar_override_from_project_toml() {
1399 let ws = fresh_workspace("scalar-override");
1400 write_workspace_config(&ws, "tabstop = 2\n");
1401 let mut a = app_with("", 5);
1402 assert_eq!(
1405 *a.editor
1406 .config
1407 .get_typed::<lattice_config::Tabstop>()
1408 .unwrap(),
1409 4
1410 );
1411 a.load_persistent_config(Some(&ws));
1412 assert_eq!(
1413 *a.editor
1414 .config
1415 .get_typed::<lattice_config::Tabstop>()
1416 .unwrap(),
1417 2
1418 );
1419 }
1420
1421 #[test]
1422 fn load_persistent_config_buckets_per_language_section() {
1423 let ws = fresh_workspace("per-lang-bucket");
1424 write_workspace_config(
1425 &ws,
1426 "[completion.per-language.markdown]\n\
1427 auto_trigger = false\n\
1428 [completion.per-language.rust]\n\
1429 auto_trigger = true\n",
1430 );
1431 let mut a = app_with("", 5);
1432 a.load_persistent_config(Some(&ws));
1433 let paths = a.pending_structural_section_paths("completion.per-language");
1436 assert_eq!(paths.len(), 2);
1437 assert!(paths.contains(&"completion.per-language.markdown".to_string()));
1438 assert!(paths.contains(&"completion.per-language.rust".to_string()));
1439 let md = a
1441 .take_pending_structural_section("completion.per-language.markdown")
1442 .expect("markdown section drained");
1443 assert_eq!(
1444 md.get("auto_trigger").and_then(|v| v.as_bool()),
1445 Some(false),
1446 );
1447 let after = a.pending_structural_section_paths("completion.per-language");
1449 assert_eq!(after, vec!["completion.per-language.rust".to_string()]);
1450 }
1451
1452 #[test]
1453 fn load_persistent_config_collects_unknown_plugin_section_for_later_drain() {
1454 let ws = fresh_workspace("plugin-deferred");
1458 write_workspace_config(&ws, "[plugin.rust-analyzer]\nclippy = true\n");
1459 let mut a = app_with("", 5);
1460 a.load_persistent_config(Some(&ws));
1461 let paths = a.pending_structural_section_paths("plugin");
1462 assert_eq!(paths, vec!["plugin.rust-analyzer".to_string()]);
1463 let body = a
1464 .take_pending_structural_section("plugin.rust-analyzer")
1465 .expect("plugin section drained");
1466 assert_eq!(body.get("clippy").and_then(|v| v.as_bool()), Some(true));
1467 }
1468
1469 #[test]
1470 fn load_persistent_config_warning_surfaces_on_unknown_key() {
1471 let ws = fresh_workspace("unknown-key");
1472 write_workspace_config(&ws, "no_such_option = 42\n");
1473 let mut a = app_with("", 5);
1474 a.load_persistent_config(Some(&ws));
1475 let msg = a.editor.last_message.as_ref().expect("warning echoed");
1477 assert_eq!(msg.level, EchoLevel::Warn);
1478 assert!(msg.text.contains("config:"), "got `{}`", msg.text);
1479 assert!(msg.text.contains("no_such_option"), "got `{}`", msg.text,);
1480 }
1481
1482 #[test]
1483 fn tree_sitter_source_emits_definition_position_symbols_for_rust() {
1484 let source = "fn outer(arg: i32) {\n let local = arg;\n}\n";
1485 let mut a = app_with(source, 10);
1486 set_rust_syntax(&mut a, source);
1487 a.editor.modal = ModalState::Insert;
1488 a.editor.cursor = Position::new(2, 1);
1492 a.do_completion_trigger();
1493 let state = a.editor.insert_completion.as_ref().expect("popup");
1494 let tree_sitter_id = lattice_completion::TREE_SITTER_SYMBOL_SOURCE_ID;
1495 let ts_texts: Vec<&str> = state
1496 .raw
1497 .iter()
1498 .filter(|c| c.source.as_ref().map(|s| s.as_str()) == Some(tree_sitter_id))
1499 .map(|c| c.text.as_str())
1500 .collect();
1501 for expected in &["outer", "arg", "local"] {
1502 assert!(
1503 ts_texts.contains(expected),
1504 "expected `{expected}` in tree-sitter candidates: {ts_texts:?}",
1505 );
1506 }
1507 }
1508
1509 #[test]
1510 fn tree_sitter_source_skipped_by_per_language_override() {
1511 let source = "fn outer() {\n let local = 1;\n}\n";
1512 let mut a = app_with(source, 10);
1513 set_rust_syntax(&mut a, source);
1514 a.editor.modal = ModalState::Insert;
1515 a.editor.cursor = Position::new(2, 1);
1516 a.editor.per_language_completion.insert(
1519 String::new(),
1520 lattice_completion::PerLanguageOverrides {
1521 sources: Some(vec![lattice_completion::SourceId::new(
1522 lattice_completion::BufferWordsSource::ID,
1523 )]),
1524 ..Default::default()
1525 },
1526 );
1527 a.do_completion_trigger();
1528 let state = a.editor.insert_completion.as_ref().expect("popup");
1529 let tree_sitter_id = lattice_completion::TREE_SITTER_SYMBOL_SOURCE_ID;
1530 for cand in &state.raw {
1531 let src = cand.source.as_ref().map(|s| s.as_str()).unwrap_or("");
1532 assert_ne!(
1533 src, tree_sitter_id,
1534 "tree-sitter source filtered out for this language",
1535 );
1536 }
1537 }
1538
1539 #[test]
1540 fn tree_sitter_and_buffer_words_emit_independently_for_same_name() {
1541 let source = "fn outer() {\n outer();\n}\n";
1551 let mut a = app_with(source, 10);
1552 set_rust_syntax(&mut a, source);
1553 a.editor.modal = ModalState::Insert;
1554 a.editor.cursor = Position::new(2, 1);
1555 a.do_completion_trigger();
1556 let state = a.editor.insert_completion.as_ref().expect("popup");
1557 let raw_sources: Vec<&str> = state
1558 .raw
1559 .iter()
1560 .filter(|c| c.text == "outer")
1561 .map(|c| c.source.as_ref().map(|s| s.as_str()).unwrap_or(""))
1562 .collect();
1563 assert!(
1564 raw_sources.contains(&lattice_completion::BufferWordsSource::ID),
1565 "buffer-words copy present in raw set: {raw_sources:?}",
1566 );
1567 assert!(
1568 raw_sources.contains(&lattice_completion::TREE_SITTER_SYMBOL_SOURCE_ID),
1569 "tree-sitter copy present in raw set: {raw_sources:?}",
1570 );
1571 let rendered_outer: Vec<&str> = state
1572 .rendered
1573 .iter()
1574 .filter(|c| c.raw.text == "outer")
1575 .map(|c| c.raw.source.as_ref().map(|s| s.as_str()).unwrap_or(""))
1576 .collect();
1577 assert_eq!(rendered_outer.len(), 1, "popup deduped to one row");
1578 assert_eq!(
1579 rendered_outer[0],
1580 lattice_completion::BufferWordsSource::ID,
1581 "higher-priority source's row survives the dedup",
1582 );
1583 }
1584
1585 #[test]
1586 fn tree_sitter_source_silent_without_syntax_attached() {
1587 let mut a = app_with("alpha bravo charlie", 5);
1591 a.editor.modal = ModalState::Insert;
1592 a.editor.cursor = Position::new(0, 19);
1593 a.do_completion_trigger();
1594 if let Some(state) = a.editor.insert_completion.as_ref() {
1595 let tree_sitter_id = lattice_completion::TREE_SITTER_SYMBOL_SOURCE_ID;
1596 for cand in &state.raw {
1597 assert_ne!(
1598 cand.source.as_ref().map(|s| s.as_str()),
1599 Some(tree_sitter_id),
1600 );
1601 }
1602 }
1603 }
1604
1605 #[test]
1606 fn load_persistent_config_silent_when_no_files_present() {
1607 let ws = fresh_workspace("no-files");
1608 let mut a = app_with("", 5);
1611 let prior = a.editor.last_message.clone();
1612 a.load_persistent_config(Some(&ws));
1613 assert_eq!(a.editor.last_message, prior);
1616 }
1617
1618 #[test]
1619 fn open_help_in_pane_seeds_help_locals() {
1620 let mut a = app_with("hi", 5);
1621 let help = crate::help::HelpContent::from_lines(
1622 "test-locals",
1623 vec".to_string(),
1626 ],
1627 );
1628 let help_id = a.open_help_in_pane(help);
1629 let locals = a
1630 .editor
1631 .buffer_locals
1632 .get(&help_id)
1633 .expect("buffer_locals should be populated for help buffer");
1634 let links = locals
1636 .get::<crate::modes::HelpLinks>()
1637 .expect("HelpLinks local seeded");
1638 assert_eq!(links.0.len(), 1);
1639 let anchors = locals
1644 .get::<crate::modes::HelpAnchors>()
1645 .expect("HelpAnchors local seeded (possibly empty)");
1646 assert_eq!(anchors.0.len(), 0);
1647 }
1648
1649 #[test]
1650 fn file_tree_locals_carry_owner_metadata() {
1651 let tmp =
1652 std::env::temp_dir().join(format!("lattice-m3-2-c-2-meta-{}", std::process::id()));
1653 let _ = std::fs::create_dir_all(&tmp);
1654 let mut a = app_with("hi", 5);
1655 a.do_open_file_tree(Some(tmp.clone()));
1656 let tree_id = a.active_pane_buffer_id();
1657 let locals = a.editor.buffer_locals.get(&tree_id).unwrap();
1658 let tree_descriptors: Vec<_> = locals
1664 .iter_descriptors()
1665 .filter(|d| d.owner_mode == "file-tree-mode")
1666 .collect();
1667 assert!(
1668 tree_descriptors.len() >= 3,
1669 "file-tree-mode should seed root/entries/nerd-fonts, got {tree_descriptors:?}",
1670 );
1671 for d in &tree_descriptors {
1672 assert!(
1673 d.name.starts_with("file-tree-mode."),
1674 "file-tree-mode local name {:?} should be namespaced under file-tree-mode",
1675 d.name,
1676 );
1677 }
1678 let _ = std::fs::remove_dir_all(&tmp);
1679 }
1680
1681 #[test]
1682 fn boot_seeds_initial_document_locals() {
1683 let a = app_with("hello", 10);
1688 let id = a.editor.document_buffer_id;
1689 let locals = a
1690 .editor
1691 .buffer_locals
1692 .get(&id)
1693 .expect("initial document has buffer_locals");
1694 assert!(
1695 locals.get::<crate::modes::DocumentSyntax>().is_some(),
1696 "DocumentSyntax local seeded"
1697 );
1698 assert!(
1699 locals
1700 .get::<crate::modes::DocumentLastParsedTextVersion>()
1701 .is_some(),
1702 "DocumentLastParsedTextVersion local seeded"
1703 );
1704 assert!(
1705 locals
1706 .get::<crate::modes::DocumentLastSyncedSyntaxVersion>()
1707 .is_some(),
1708 "DocumentLastSyncedSyntaxVersion local seeded"
1709 );
1710 assert!(
1711 locals.get::<crate::modes::DocumentFolds>().is_some(),
1712 "DocumentFolds local seeded"
1713 );
1714 }
1715
1716 #[test]
1717 fn snapshot_active_document_mirrors_into_locals() {
1718 let mut a = app_with("hello\nworld", 10);
1723 let active_id = a.editor.document_buffer_id;
1724 a.editor.folds.push(crate::app::Fold {
1727 start_line: 0,
1728 end_line: 1,
1729 closed: false,
1730 identity: None,
1731 });
1732 a.editor.last_parsed_text_version = 42;
1733 a.editor.last_synced_syntax_version = 41;
1734 a.snapshot_active_document();
1735 let locals = a
1736 .editor
1737 .buffer_locals
1738 .get(&active_id)
1739 .expect("locals exist for active id");
1740 let parsed = locals
1741 .get::<crate::modes::DocumentLastParsedTextVersion>()
1742 .expect("last_parsed mirrored");
1743 let synced = locals
1744 .get::<crate::modes::DocumentLastSyncedSyntaxVersion>()
1745 .expect("last_synced mirrored");
1746 let folds = locals
1747 .get::<crate::modes::DocumentFolds>()
1748 .expect("folds mirrored");
1749 assert_eq!(parsed.0, 42);
1750 assert_eq!(synced.0, 41);
1751 assert_eq!(folds.0.len(), 1);
1752 assert_eq!(folds.0[0].start_line, 0);
1753 }
1754
1755 #[test]
1756 fn popup_dismiss_does_not_jolt_backdrop_scroll() {
1757 let many_lines: String = (0..50).map(|i| format!("line {i}\n")).collect();
1764 let mut a = app_with(&many_lines, 30);
1765 a.editor.cursor = lattice_protocol::position::Position::new(40, 0);
1767 a.editor.scroll = 30;
1768 let pre_cursor = a.editor.cursor;
1769 let pre_scroll = a.editor.scroll;
1770 a.do_lsp_status();
1772 a.set_viewport_height(
1775 a.help_popup_inner_height(30)
1776 .unwrap_or(a.editor.viewport_height),
1777 );
1778 a.dismiss_popup();
1780 assert_eq!(a.editor.cursor, pre_cursor, "cursor restored");
1785 assert_eq!(a.editor.scroll, pre_scroll, "scroll restored without jolt");
1786 }
1787
1788 #[test]
1789 fn popup_with_long_content_scrolls_when_cursor_descends() {
1790 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1797 let lines: Vec<String> = (0..50).map(|i| format!("popup line {i}")).collect();
1798 let mut a = app_with("backdrop\n", 30);
1799 let buf = crate::help::HelpContent::from_lines("status", lines);
1800 a.open_popup(buf, crate::popup::PopupPlacement::Centered);
1801 let inner = a
1804 .help_popup_inner_height(30)
1805 .expect("popup inner height available");
1806 a.set_viewport_height(inner);
1807 for _ in 0..(inner + 5) {
1811 a.set_viewport_height(
1812 a.help_popup_inner_height(30)
1813 .unwrap_or(a.editor.viewport_height),
1814 );
1815 crate::app::test_helpers::press(
1816 &mut a,
1817 KeyEvent::new(KeyCode::Char('j'), KeyModifiers::empty()),
1818 );
1819 }
1820 assert!(
1821 a.editor.cursor.line >= inner,
1822 "cursor should descend past the visible viewport, got line {} (inner {})",
1823 a.editor.cursor.line,
1824 inner
1825 );
1826 assert!(
1827 a.editor.scroll > 0,
1828 "scroll should advance once cursor leaves the visible window, got scroll {}",
1829 a.editor.scroll
1830 );
1831 assert!(
1832 a.editor.cursor.line < a.editor.scroll + inner,
1833 "cursor must still be inside the scrolled viewport (cursor {}, scroll {}, inner {})",
1834 a.editor.cursor.line,
1835 a.editor.scroll,
1836 inner
1837 );
1838 }
1839
1840 #[test]
1841 fn document_syntax_for_inactive_resolves_through_locals() {
1842 use crate::buffer_registry::{BufferData, BufferEntry, DocumentEntry};
1846 use crate::buffers::{BufferFlags, BufferId};
1847 let mut a = app_with("active", 5);
1848 let inactive_id = BufferId::next();
1852 let doc_handle = a.editor.document.as_arc();
1853 a.editor.buffers.insert(BufferEntry {
1854 id: inactive_id,
1855 flags: BufferFlags::default(),
1856 data: BufferData::Document(DocumentEntry {
1857 id: inactive_id,
1858 handle: doc_handle,
1859 }),
1860 name: None,
1861 });
1862 a.seed_empty_document_locals(inactive_id);
1863 assert!(
1866 a.document_syntax_for(inactive_id).is_none(),
1867 "accessor returns None for empty locals"
1868 );
1869 assert_eq!(a.document_last_parsed_text_version_for(inactive_id), 0);
1872 assert_eq!(a.document_last_synced_syntax_version_for(inactive_id), 0);
1873 assert!(a.document_folds_for(inactive_id).is_empty());
1875 }
1876
1877 #[test]
1878 fn document_locals_carry_owner_metadata() {
1879 let a = app_with("hi", 10);
1880 let id = a.editor.document_buffer_id;
1881 let locals = a.editor.buffer_locals.get(&id).unwrap();
1882 let descriptors: Vec<_> = locals.iter_descriptors().collect();
1883 for d in descriptors
1884 .iter()
1885 .filter(|d| d.name.starts_with("text-mode."))
1886 {
1887 assert_eq!(d.owner_mode, "text-mode");
1888 }
1889 let names: Vec<_> = descriptors.iter().map(|d| d.name).collect();
1891 assert!(names.contains(&"text-mode.syntax"));
1892 assert!(names.contains(&"text-mode.last-parsed-text-version"));
1893 assert!(names.contains(&"text-mode.last-synced-syntax-version"));
1894 assert!(names.contains(&"text-mode.folds"));
1895 }
1896
1897 #[test]
1898 fn oil_open_then_write_creates_a_new_file_on_disk() {
1899 let tmp =
1904 std::env::temp_dir().join(format!("lattice-oil-create-test-{}", std::process::id()));
1905 let _ = std::fs::remove_dir_all(&tmp);
1906 std::fs::create_dir_all(&tmp).expect("create tmp");
1907
1908 let mut a = app_with("hi", 5);
1909 a.do_open_oil(Some(tmp.clone()));
1910 assert_eq!(a.editor.active_buffer, BufferKind::Oil);
1911
1912 a.editor
1914 .apply_edit_batch_blocking(vec![lattice_protocol::edit::Edit::insert(
1915 lattice_protocol::position::Position::ZERO,
1916 "newfile.txt\n".to_string(),
1917 )])
1918 .expect("insert edit");
1919
1920 a.do_write(None);
1922 let msg = a.editor.last_message.as_ref().expect("write echo");
1923 assert!(
1924 msg.text.contains("oil: applied"),
1925 "expected oil-apply success echo, got: {}",
1926 msg.text,
1927 );
1928 let new_path = tmp.join("newfile.txt");
1929 assert!(
1930 new_path.exists(),
1931 "expected {} to be created",
1932 new_path.display(),
1933 );
1934
1935 let _ = std::fs::remove_dir_all(&tmp);
1936 }
1937
1938 #[test]
1939 fn oil_navigate_up_from_oil_buffer_goes_to_parent() {
1940 let tmp = std::env::temp_dir().join(format!("lattice-oil-up-test-{}", std::process::id()));
1943 let _ = std::fs::remove_dir_all(&tmp);
1944 std::fs::create_dir_all(tmp.join("nested")).expect("nested");
1945
1946 let mut a = app_with("hi", 5);
1947 a.do_open_oil(Some(tmp.join("nested")));
1948 let oil_id = a.active_pane_buffer_id();
1949 assert_eq!(
1950 a.editor
1951 .buffer_locals
1952 .get(&oil_id)
1953 .and_then(|l| l.get::<crate::modes::OilDir>())
1954 .map(|d| d.0.clone())
1955 .unwrap_or_default(),
1956 tmp.join("nested"),
1957 );
1958 a.apply(crate::app::Action::OilNavigateUp);
1960 let dir_after = a.oil_dir_for(oil_id).unwrap_or_default();
1962 assert_eq!(dir_after, tmp);
1963
1964 let _ = std::fs::remove_dir_all(&tmp);
1965 }
1966
1967 #[test]
1968 fn oil_navigate_up_from_document_opens_oil_at_parent() {
1969 let mut a = app_with("hi", 5);
1973 assert_eq!(a.editor.active_buffer, BufferKind::Document);
1975 a.apply(crate::app::Action::OilNavigateUp);
1976 assert_eq!(a.editor.active_buffer, BufferKind::Oil);
1978 }
1979
1980 #[test]
1981 fn oil_navigate_up_from_document_lands_on_the_edited_file() {
1982 let tmp =
1986 std::env::temp_dir().join(format!("lattice-oil-focus-doc-test-{}", std::process::id()));
1987 let _ = std::fs::remove_dir_all(&tmp);
1988 std::fs::create_dir_all(tmp.join("adir")).expect("adir");
1989 std::fs::write(tmp.join("target.txt"), "x").expect("target");
1990
1991 let mut a = app_with("hi", 5);
1992 a.do_edit(Some(tmp.join("target.txt")), false);
1994 assert_eq!(a.editor.active_buffer, BufferKind::Document);
1995
1996 a.apply(crate::app::Action::OilNavigateUp);
1997 assert_eq!(a.editor.active_buffer, BufferKind::Oil);
1998 assert_eq!(a.editor.cursor.line, 1);
2001
2002 let _ = std::fs::remove_dir_all(&tmp);
2003 }
2004
2005 #[test]
2006 fn oil_navigate_up_lands_on_the_directory_left() {
2007 let tmp =
2011 std::env::temp_dir().join(format!("lattice-oil-focus-up-test-{}", std::process::id()));
2012 let _ = std::fs::remove_dir_all(&tmp);
2013 std::fs::create_dir_all(tmp.join("adir")).expect("adir");
2016 std::fs::create_dir_all(tmp.join("nested")).expect("nested");
2017
2018 let mut a = app_with("hi", 5);
2019 a.do_open_oil(Some(tmp.join("nested")));
2020 assert_eq!(a.editor.active_buffer, BufferKind::Oil);
2021
2022 a.apply(crate::app::Action::OilNavigateUp);
2023 assert_eq!(
2024 a.oil_dir_for(a.active_pane_buffer_id()).unwrap_or_default(),
2025 tmp
2026 );
2027 assert_eq!(a.editor.cursor.line, 1);
2029
2030 let _ = std::fs::remove_dir_all(&tmp);
2031 }
2032
2033 #[test]
2034 fn oil_normal_mode_o_then_insert_then_write_creates_file() {
2035 let tmp =
2040 std::env::temp_dir().join(format!("lattice-oil-o-write-test-{}", std::process::id()));
2041 let _ = std::fs::remove_dir_all(&tmp);
2042 std::fs::create_dir_all(&tmp).expect("create tmp");
2043 std::fs::write(tmp.join("seed.txt"), "x").expect("seed");
2046
2047 let mut a = app_with("hi", 5);
2048 a.do_open_oil(Some(tmp.clone()));
2049 a.apply(crate::app::Action::OpenLineBelow);
2050 assert_eq!(a.editor.modal, lattice_grammar::ModalState::Insert);
2051 a.apply(crate::app::Action::Insert("new.rs".into()));
2052 a.apply(crate::app::Action::EnterMode(
2053 lattice_grammar::ModalState::Normal,
2054 ));
2055 a.do_write(None);
2056 let new_path = tmp.join("new.rs");
2057 assert!(
2058 new_path.exists(),
2059 "expected {} to be created via the keystroke path; \
2060 write echo: {:?}",
2061 new_path.display(),
2062 a.editor.last_message,
2063 );
2064
2065 let _ = std::fs::remove_dir_all(&tmp);
2066 }
2067
2068 #[tokio::test]
2081 async fn directory_listing_mode_activates_on_oil_and_the_file_tree() {
2082 let mode = lattice_listing::listing_mode::DirectoryListingMode::mode_id();
2083
2084 for kind in ["oil", "file-tree"] {
2085 let tmp = unique_tempdir();
2086 std::fs::write(tmp.join("existing.txt"), "x").expect("seed");
2087
2088 let mut a = app_with("hi", 5);
2089 match kind {
2090 "oil" => a.do_open_oil(Some(tmp.clone())),
2091 _ => a.do_open_file_tree(Some(tmp.clone())),
2092 }
2093 let buffer_id = a.active_pane_buffer_id();
2094
2095 let active = crate::app::test_helpers::settle(&mut a, |app: &App| {
2096 app.editor
2097 .active_modes
2098 .get(&buffer_id)
2099 .map(|m| m.is_active(mode))
2100 .unwrap_or(false)
2101 })
2102 .await;
2103 assert!(
2104 active,
2105 "{kind}: directory-listing-mode must activate — it owns the \
2106 icons and themed row colours for this buffer"
2107 );
2108
2109 let _ = std::fs::remove_dir_all(&tmp);
2110 }
2111 }
2112
2113 #[tokio::test]
2125 async fn oil_write_round_trips_rename_create_and_delete() {
2126 let tmp = unique_tempdir();
2127 std::fs::write(tmp.join("old.txt"), "keep").unwrap();
2128 std::fs::write(tmp.join("doomed.txt"), "bye").unwrap();
2129
2130 let mut a = app_with("hi", 10);
2131 a.do_open_oil(Some(tmp.clone()));
2132 let oil_id = a.active_pane_buffer_id();
2133
2134 let _ = crate::app::test_helpers::settle_mode(&mut a, "directory-listing-mode").await;
2137
2138 let listing = a
2139 .editor
2140 .buffers
2141 .document_handle(oil_id)
2142 .map(|h| h.snapshot().buffer.as_string())
2143 .expect("oil document");
2144 assert!(
2145 listing.contains("old.txt") && listing.contains("doomed.txt"),
2146 "precondition: both files listed, got {listing:?}"
2147 );
2148 for line in listing.lines() {
2151 assert!(
2152 line == "old.txt" || line == "doomed.txt",
2153 "oil's rope must hold bare filenames only, got {line:?}"
2154 );
2155 }
2156
2157 a.editor.replace_owned_buffer(oil_id, "new.txt\ndoomed.txt");
2162 a.mutate_editor(|e| {
2163 e.do_write(None);
2164 });
2165 assert!(tmp.join("new.txt").exists(), "rename: new.txt must exist");
2166 assert!(
2167 !tmp.join("old.txt").exists(),
2168 "rename: old.txt must be gone"
2169 );
2170 assert_eq!(
2171 std::fs::read_to_string(tmp.join("new.txt")).unwrap(),
2172 "keep",
2173 "a rename must move the CONTENT, not create an empty file"
2174 );
2175
2176 a.editor.replace_owned_buffer(oil_id, "new.txt\nfresh.txt");
2179 a.mutate_editor(|e| {
2180 e.do_write(None);
2181 });
2182 assert!(
2183 !tmp.join("doomed.txt").exists(),
2184 "delete: doomed.txt must be gone"
2185 );
2186 assert!(
2187 tmp.join("fresh.txt").exists(),
2188 "create: fresh.txt must exist"
2189 );
2190 assert!(
2191 tmp.join("new.txt").exists(),
2192 "the untouched row must survive the second write"
2193 );
2194
2195 let _ = std::fs::remove_dir_all(&tmp);
2196 }
2197
2198 #[test]
2199 fn oil_keystroke_pipeline_inserts_into_oil_rope() {
2200 let tmp =
2208 std::env::temp_dir().join(format!("lattice-oil-keystroke-test-{}", std::process::id()));
2209 let _ = std::fs::remove_dir_all(&tmp);
2210 std::fs::create_dir_all(&tmp).expect("create tmp");
2211 std::fs::write(tmp.join("existing.txt"), "x").expect("seed");
2213
2214 let mut a = app_with("hi", 5);
2215 a.do_open_oil(Some(tmp.clone()));
2216 let oil_id = a.active_pane_buffer_id();
2217 let initial = a
2219 .editor
2220 .buffers
2221 .document_handle(oil_id)
2222 .map(|h| h.snapshot().buffer.as_string());
2223 assert!(
2224 initial
2225 .as_ref()
2226 .map(|s| s.contains("existing.txt"))
2227 .unwrap_or(false),
2228 "expected `existing.txt` in initial listing: {:?}",
2229 initial,
2230 );
2231 a.editor.modal = lattice_grammar::ModalState::Insert;
2234 a.apply(crate::app::Action::Insert("foo".into()));
2235 let after = a
2236 .editor
2237 .buffers
2238 .document_handle(oil_id)
2239 .map(|h| h.snapshot().buffer.as_string());
2240 assert!(
2241 after.as_ref().map(|s| s.contains("foo")).unwrap_or(false),
2242 "expected `foo` to land in oil rope: {:?}",
2243 after,
2244 );
2245
2246 let _ = std::fs::remove_dir_all(&tmp);
2247 }
2248
2249 #[test]
2250 fn oil_navigate_up_re_mirrors_dir_into_buffer_locals() {
2251 let tmp = std::env::temp_dir().join(format!(
2258 "lattice-oil-nav-up-mirror-test-{}",
2259 std::process::id()
2260 ));
2261 let _ = std::fs::remove_dir_all(&tmp);
2262 std::fs::create_dir_all(tmp.join("sub")).expect("sub");
2263 std::fs::write(tmp.join("a.txt"), "a").expect("a");
2264 std::fs::write(tmp.join("sub/inside.txt"), "i").expect("inside");
2265
2266 let mut a = app_with("hi", 5);
2267 a.do_open_oil(Some(tmp.join("sub")));
2269 let oil_id = a.active_pane_buffer_id();
2270 assert_eq!(
2272 a.editor
2273 .buffer_locals
2274 .get(&oil_id)
2275 .and_then(|l| l.get::<crate::modes::OilDir>())
2276 .map(|d| d.0.clone())
2277 .unwrap_or_default(),
2278 tmp.join("sub"),
2279 );
2280 a.do_oil_navigate_up();
2282 let dir_after = a.oil_dir_for(oil_id).unwrap_or_default();
2285 assert_eq!(
2286 dir_after, tmp,
2287 "OilDir should reflect the post-navigate dir",
2288 );
2289
2290 a.editor.cursor.line = 0;
2297 a.editor.cursor.byte = 0;
2298 let names: Vec<String> = a
2300 .editor
2301 .oil_snapshot_for(oil_id)
2302 .map(|s| {
2303 s.snapshot_entries()
2304 .iter()
2305 .map(|e| e.name.clone())
2306 .collect::<Vec<String>>()
2307 })
2308 .unwrap_or_default();
2309 let a_txt_row = names
2310 .iter()
2311 .position(|n| n == "a.txt")
2312 .expect("a.txt in listing");
2313 a.editor.cursor.line = a_txt_row as u32;
2314 oil_follow(&mut a);
2315 let opened = a.editor.document.path().map(|p| p.to_path_buf());
2316 assert_eq!(opened, Some(tmp.join("a.txt")));
2317
2318 let _ = std::fs::remove_dir_all(&tmp);
2319 }
2320
2321 #[test]
2322 fn oil_open_with_relative_dir_stores_absolute_oil_dir() {
2323 let tmp =
2330 std::env::temp_dir().join(format!("lattice-oil-relative-dir-{}", std::process::id()));
2331 let _ = std::fs::remove_dir_all(&tmp);
2332 std::fs::create_dir_all(tmp.join("sub")).expect("sub");
2333 std::fs::write(tmp.join("sub/inside.txt"), "i").expect("inside");
2334
2335 let Ok(cwd) = std::env::current_dir() else {
2341 return;
2342 };
2343 let Ok(rel) = tmp.join("sub").strip_prefix(&cwd).map(|p| p.to_path_buf()) else {
2344 let mut a = app_with("hi", 5);
2351 a.do_open_oil(Some(tmp.join("sub")));
2352 let oil_id = a.active_pane_buffer_id();
2353 let stored = a.oil_dir_for(oil_id).unwrap_or_default();
2354 assert!(
2355 stored.is_absolute(),
2356 "OilDir should always be absolute; got {stored:?}",
2357 );
2358 a.do_oil_navigate_up();
2362 let after = a.oil_dir_for(oil_id).unwrap_or_default();
2363 assert_eq!(after, tmp, "navigate-up should land on tmp's absolute path");
2364 let _ = std::fs::remove_dir_all(&tmp);
2365 return;
2366 };
2367
2368 let mut a = app_with("hi", 5);
2369 a.do_open_oil(Some(rel.clone()));
2372 let oil_id = a.active_pane_buffer_id();
2373 let stored = a.oil_dir_for(oil_id).unwrap_or_default();
2374 assert!(
2375 stored.is_absolute(),
2376 "OilDir should always be absolute, even when opened relative; got {stored:?}",
2377 );
2378 a.do_oil_navigate_up();
2380 let after = a.oil_dir_for(oil_id).unwrap_or_default();
2381 assert_eq!(
2382 after, tmp,
2383 "navigate-up from relative-opened oil should reach the absolute parent",
2384 );
2385 let _ = std::fs::remove_dir_all(&tmp);
2386 }
2387
2388 #[test]
2389 fn oil_navigate_into_then_open_file_uses_correct_subdir_path() {
2390 let tmp = std::env::temp_dir().join(format!(
2395 "lattice-oil-nav-into-then-open-test-{}",
2396 std::process::id()
2397 ));
2398 let _ = std::fs::remove_dir_all(&tmp);
2399 std::fs::create_dir_all(tmp.join("sub")).expect("sub");
2400 std::fs::write(tmp.join("sub/inside.txt"), "i").expect("inside");
2401
2402 let mut a = app_with("hi", 5);
2403 a.do_open_oil(Some(tmp.clone()));
2404 let oil_id = a.active_pane_buffer_id();
2405 let names: Vec<String> = a
2407 .editor
2408 .oil_snapshot_for(oil_id)
2409 .map(|s| {
2410 s.snapshot_entries()
2411 .iter()
2412 .map(|e| e.name.clone())
2413 .collect::<Vec<String>>()
2414 })
2415 .unwrap_or_default();
2416 let sub_row = names
2417 .iter()
2418 .position(|n| n == "sub")
2419 .expect("sub in listing");
2420 a.editor.cursor.line = sub_row as u32;
2421 a.editor.cursor.byte = 0;
2422 oil_follow(&mut a);
2423 let dir_after = a.oil_dir_for(oil_id).unwrap_or_default();
2426 assert_eq!(dir_after, tmp.join("sub"));
2427 let names_after: Vec<String> = a
2429 .editor
2430 .oil_snapshot_for(oil_id)
2431 .map(|s| {
2432 s.snapshot_entries()
2433 .iter()
2434 .map(|e| e.name.clone())
2435 .collect()
2436 })
2437 .unwrap_or_default();
2438 let inside_row = names_after
2439 .iter()
2440 .position(|n| n == "inside.txt")
2441 .expect("inside.txt in listing");
2442 a.editor.cursor.line = inside_row as u32;
2443 oil_follow(&mut a);
2444 let opened = a.editor.document.path().map(|p| p.to_path_buf());
2445 assert_eq!(opened, Some(tmp.join("sub/inside.txt")));
2446
2447 let _ = std::fs::remove_dir_all(&tmp);
2448 }
2449
2450 #[test]
2451 fn oil_follow_uses_app_cursor_not_oil_internal_cursor() {
2452 let tmp = std::env::temp_dir().join(format!(
2461 "lattice-oil-follow-cursor-test-{}",
2462 std::process::id()
2463 ));
2464 let _ = std::fs::remove_dir_all(&tmp);
2465 std::fs::create_dir_all(&tmp).expect("tmp");
2466 std::fs::write(tmp.join("alpha.txt"), "a").expect("a");
2469 std::fs::write(tmp.join("beta.txt"), "b").expect("b");
2470 std::fs::write(tmp.join("gamma.txt"), "g").expect("g");
2471
2472 let mut a = app_with("hi", 5);
2473 a.do_open_oil(Some(tmp.clone()));
2474 let oil_id = a.active_pane_buffer_id();
2475 let names: Vec<String> = a
2477 .editor
2478 .oil_snapshot_for(oil_id)
2479 .map(|s| {
2480 s.snapshot_entries()
2481 .iter()
2482 .map(|e| e.name.clone())
2483 .collect::<Vec<String>>()
2484 })
2485 .unwrap_or_default();
2486 assert_eq!(
2487 names,
2488 vec![
2489 "alpha.txt".to_string(),
2490 "beta.txt".into(),
2491 "gamma.txt".into()
2492 ],
2493 );
2494
2495 a.editor.cursor.line = 2;
2498 a.editor.cursor.byte = 0;
2499 oil_follow(&mut a);
2500
2501 assert_eq!(a.editor.active_buffer, BufferKind::Document);
2505 let active_path = a.editor.document.path().map(|p| p.to_path_buf());
2506 assert_eq!(
2507 active_path,
2508 Some(tmp.join("gamma.txt")),
2509 "follow should have opened the file under the App's cursor",
2510 );
2511
2512 let _ = std::fs::remove_dir_all(&tmp);
2513 }
2514
2515 #[test]
2516 fn oil_navigate_into_subdir_replaces_listing() {
2517 let tmp = std::env::temp_dir().join(format!("lattice-oil-nav-test-{}", std::process::id()));
2518 let _ = std::fs::remove_dir_all(&tmp);
2519 std::fs::create_dir_all(tmp.join("subdir")).expect("create subdir");
2520 std::fs::write(tmp.join("subdir/inner.txt"), "hi").expect("write inner");
2521
2522 let mut a = app_with("hi", 5);
2523 a.do_open_oil(Some(tmp.clone()));
2524 let oil_id = a.active_pane_buffer_id();
2525
2526 let listing_before = a
2528 .editor
2529 .buffers
2530 .document_handle(oil_id)
2531 .map(|h| h.snapshot().buffer.as_string());
2532 assert!(
2533 listing_before
2534 .as_ref()
2535 .map(|s| s.contains("subdir"))
2536 .unwrap_or(false),
2537 "expected `subdir` in initial listing: {:?}",
2538 listing_before,
2539 );
2540
2541 let snap: Vec<String> = a
2545 .editor
2546 .oil_snapshot_for(oil_id)
2547 .map(|s| {
2548 s.snapshot_entries()
2549 .iter()
2550 .map(|e| e.name.clone())
2551 .collect()
2552 })
2553 .expect("oil");
2554 let subdir_line = snap
2555 .iter()
2556 .position(|n| n == "subdir")
2557 .expect("subdir in snapshot");
2558 a.editor.cursor.line = subdir_line as u32;
2559 a.editor.cursor.byte = 0;
2560 oil_follow(&mut a);
2561
2562 let listing_after = a
2564 .editor
2565 .buffers
2566 .document_handle(oil_id)
2567 .map(|h| h.snapshot().buffer.as_string());
2568 assert!(
2569 listing_after
2570 .as_ref()
2571 .map(|s| s.contains("inner.txt"))
2572 .unwrap_or(false),
2573 "after navigate-into, expected `inner.txt`: {:?}",
2574 listing_after,
2575 );
2576
2577 let dir = a
2579 .editor
2580 .buffer_locals
2581 .get(&oil_id)
2582 .and_then(|l| l.get::<crate::modes::OilDir>())
2583 .map(|d| d.0.clone())
2584 .expect("OilDir present");
2585 assert_eq!(dir, tmp.join("subdir"));
2586
2587 let _ = std::fs::remove_dir_all(&tmp);
2588 }
2589
2590 #[test]
2596 fn oil_navigate_effect_relists_only_the_named_view() {
2597 let tmp = std::env::temp_dir().join(format!("lattice-lm2-oilnav-{}", std::process::id()));
2598 let _ = std::fs::remove_dir_all(&tmp);
2599 let dir_view = tmp.join("view");
2601 let dir_other = tmp.join("other");
2602 std::fs::create_dir_all(dir_view.join("subdir")).unwrap();
2603 std::fs::write(dir_view.join("subdir/inner.txt"), "hi").unwrap();
2604 std::fs::create_dir_all(&dir_other).unwrap();
2605 std::fs::write(dir_other.join("othermark.txt"), "o").unwrap();
2606
2607 let mut a = app_with("hi", 5);
2608 a.do_open_oil(Some(dir_other.clone()));
2610 let other = a.active_pane_buffer_id();
2611 let other_before = a
2612 .editor
2613 .buffers
2614 .document_handle(other)
2615 .map(|h| h.snapshot().buffer.as_string());
2616
2617 a.editor.do_new_tab();
2618 a.do_open_oil(Some(dir_view.clone()));
2619 let view = a.active_pane_buffer_id();
2620 assert_ne!(view, other, "two distinct oil buffers");
2621
2622 let subdir = dir_view.join("subdir");
2623 a.apply_effect(lattice_grammar::Effect::OilNavigate {
2624 view,
2625 dir: subdir.clone(),
2626 focus: None,
2627 });
2628
2629 let listing = a
2630 .editor
2631 .buffers
2632 .document_handle(view)
2633 .map(|h| h.snapshot().buffer.as_string())
2634 .unwrap_or_default();
2635 assert!(
2636 listing.contains("inner.txt"),
2637 "the navigated view re-listed to subdir: {listing:?}",
2638 );
2639 assert_eq!(
2640 a.editor.oil_dir_for(view).as_deref(),
2641 Some(subdir.as_path())
2642 );
2643
2644 let other_after = a
2645 .editor
2646 .buffers
2647 .document_handle(other)
2648 .map(|h| h.snapshot().buffer.as_string());
2649 assert_eq!(
2650 other_before, other_after,
2651 "the second oil buffer must be unchanged by navigating the first",
2652 );
2653
2654 let _ = std::fs::remove_dir_all(&tmp);
2655 }
2656
2657 #[test]
2660 fn file_tree_toggle_effect_expands_only_the_named_view() {
2661 let tmp = std::env::temp_dir().join(format!("lattice-lm2-fttoggle-{}", std::process::id()));
2662 let _ = std::fs::remove_dir_all(&tmp);
2663 let dir_view = tmp.join("view");
2665 let dir_other = tmp.join("other");
2666 std::fs::create_dir_all(dir_view.join("subdir")).unwrap();
2667 std::fs::write(dir_view.join("subdir/inner.txt"), "hi").unwrap();
2668 std::fs::create_dir_all(&dir_other).unwrap();
2669 std::fs::write(dir_other.join("othermark.txt"), "o").unwrap();
2670
2671 let mut a = app_with("hi", 5);
2672 a.do_open_file_tree(Some(dir_other.clone()));
2674 let other = a.active_pane_buffer_id();
2675 let other_before = a
2676 .editor
2677 .buffers
2678 .document_handle(other)
2679 .map(|h| h.snapshot().buffer.as_string());
2680
2681 a.editor.do_new_tab();
2682 a.do_open_file_tree(Some(dir_view.clone()));
2683 let view = a.active_pane_buffer_id();
2684 assert_ne!(view, other, "two distinct file-tree buffers");
2685
2686 let entries = a.editor.file_tree_entries_for(view).expect("tree entries");
2688 let subdir_idx = entries
2689 .iter()
2690 .position(|e| {
2691 e.path.file_name().map(|n| n == "subdir").unwrap_or(false)
2692 && matches!(
2693 e.kind,
2694 lattice_listing::file_tree::FileTreeEntryKind::Directory { .. }
2695 )
2696 })
2697 .expect("subdir row present");
2698
2699 a.apply_effect(lattice_grammar::Effect::FileTreeToggle {
2700 view,
2701 entry_index: subdir_idx as u32,
2702 });
2703
2704 let listing = a
2705 .editor
2706 .buffers
2707 .document_handle(view)
2708 .map(|h| h.snapshot().buffer.as_string())
2709 .unwrap_or_default();
2710 assert!(
2711 listing.contains("inner.txt"),
2712 "toggling subdir expands it to show inner.txt: {listing:?}",
2713 );
2714
2715 let other_after = a
2716 .editor
2717 .buffers
2718 .document_handle(other)
2719 .map(|h| h.snapshot().buffer.as_string());
2720 assert_eq!(
2721 other_before, other_after,
2722 "the second tree must be unchanged by toggling the first",
2723 );
2724
2725 let _ = std::fs::remove_dir_all(&tmp);
2726 }
2727
2728 #[tokio::test(flavor = "multi_thread")]
2734 async fn oil_ctrl_s_opens_the_file_under_cursor_in_a_split() {
2735 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
2736 let tmp = std::env::temp_dir().join(format!("lattice-lm3-ctrls-{}", std::process::id()));
2737 let _ = std::fs::remove_dir_all(&tmp);
2738 std::fs::create_dir_all(&tmp).unwrap();
2739 std::fs::write(tmp.join("file.txt"), "hi").unwrap();
2740
2741 let mut a = app_with("scratch\n", 20);
2742 a.do_open_oil(Some(tmp.clone()));
2743 assert!(
2746 settle_mode(&mut a, "oil-mode").await,
2747 "oil-mode must activate on the oil buffer",
2748 );
2749 let before_panes = a.editor.pane_tree.len();
2750 let id = a.editor.active_pane_buffer_id();
2751 let line = a
2752 .editor
2753 .oil_snapshot_for(id)
2754 .and_then(|s| {
2755 s.snapshot_entries()
2756 .iter()
2757 .position(|e| e.name == "file.txt")
2758 })
2759 .expect("file.txt row present");
2760 a.editor.cursor.line = line as u32;
2761
2762 press(
2763 &mut a,
2764 KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL),
2765 );
2766
2767 assert_eq!(
2768 a.editor.pane_tree.len(),
2769 before_panes + 1,
2770 "<C-s> opened the file in a new split",
2771 );
2772 assert_eq!(
2773 a.editor.document.path().map(|p| p.to_path_buf()),
2774 Some(tmp.join("file.txt")),
2775 "the new split shows the file under the cursor",
2776 );
2777 let _ = std::fs::remove_dir_all(&tmp);
2778 }
2779
2780 #[tokio::test(flavor = "multi_thread")]
2785 async fn file_tree_ctrl_s_opens_the_file_under_cursor_in_a_split() {
2786 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
2787 let tmp = std::env::temp_dir().join(format!("lattice-lm4-ctrls-{}", std::process::id()));
2788 let _ = std::fs::remove_dir_all(&tmp);
2789 std::fs::create_dir_all(&tmp).unwrap();
2790 std::fs::write(tmp.join("file.txt"), "hi").unwrap();
2791
2792 let mut a = app_with("scratch\n", 20);
2793 a.do_open_file_tree(Some(tmp.clone()));
2794 assert!(
2795 settle_mode(&mut a, "file-tree-mode").await,
2796 "file-tree-mode must activate on the tree buffer",
2797 );
2798 let before_panes = a.editor.pane_tree.len();
2799 let id = a.editor.active_pane_buffer_id();
2800 let line = a
2801 .editor
2802 .file_tree_entries_for(id)
2803 .and_then(|es| {
2804 es.iter()
2805 .position(|e| e.path.file_name().map(|n| n == "file.txt").unwrap_or(false))
2806 })
2807 .expect("file.txt row present");
2808 a.editor.cursor.line = line as u32;
2809
2810 press(
2811 &mut a,
2812 KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL),
2813 );
2814
2815 assert_eq!(
2816 a.editor.pane_tree.len(),
2817 before_panes + 1,
2818 "<C-s> opened the file in a new split",
2819 );
2820 assert_eq!(
2821 a.editor.document.path().map(|p| p.to_path_buf()),
2822 Some(tmp.join("file.txt")),
2823 "the new split shows the file under the cursor",
2824 );
2825 let _ = std::fs::remove_dir_all(&tmp);
2826 }
2827
2828 #[test]
2829 fn open_oil_seeds_oil_locals() {
2830 let tmp = std::env::temp_dir().join(format!("lattice-m3-2-c-3-{}", std::process::id()));
2831 let _ = std::fs::create_dir_all(&tmp);
2832
2833 let mut a = app_with("hi", 5);
2834 a.do_open_oil(Some(tmp.clone()));
2835 let oil_id = a.active_pane_buffer_id();
2836
2837 let locals = a
2838 .editor
2839 .buffer_locals
2840 .get(&oil_id)
2841 .expect("oil locals seeded");
2842 let dir = locals
2843 .get::<crate::modes::OilDir>()
2844 .expect("OilDir local present");
2845 assert_eq!(dir.0, tmp);
2846
2847 let descriptors: Vec<_> = locals.iter_descriptors().collect();
2849 let oil_descriptors: Vec<_> = descriptors
2850 .iter()
2851 .filter(|d| d.owner_mode == "oil-mode")
2852 .collect();
2853 let mut oil_names: Vec<&str> = oil_descriptors.iter().map(|d| d.name).collect();
2859 oil_names.sort_unstable();
2860 assert_eq!(oil_names, vec!["oil-mode.dir", "oil-mode.snapshot"]);
2861
2862 let _ = std::fs::remove_dir_all(&tmp);
2863 }
2864
2865 #[test]
2866 fn pane_status_label_falls_back_to_registry_name_for_synthetic_document() {
2867 let a = app_with("hi", 5);
2871 let active = a.active_pane_buffer_id();
2872 let handle = a.editor.document.as_arc();
2877 a.editor.buffers.remove(active);
2878 a.editor.buffers.insert(BufferEntry {
2879 id: active,
2880 flags: BufferFlags::default(),
2881 data: BufferData::Document(DocumentEntry { id: active, handle }),
2882 name: Some("*lsp*".to_string()),
2883 });
2884 let pane = *a.editor.pane_tree.active();
2885 let label = a.pane_status_label(&pane);
2886 assert!(
2887 label.contains("*lsp*"),
2888 "expected modeline to surface synthetic name, got `{label}`"
2889 );
2890 }
2891
2892 #[test]
2893 fn buffer_picker_accept_then_ctrl_o_walks_back_to_origin() {
2894 let mut a = app_with("alpha\nbeta\ngamma\n", 10);
2902 let origin = a.active_pane_buffer_id();
2903 a.editor.cursor = Position::new(1, 2);
2904 a.open_buffer_picker();
2905 let last = a.editor.position_history.last().expect("origin entry");
2908 assert_eq!(last.position, Position::new(1, 2));
2909 assert_eq!(last.buffer_id, origin);
2910 a.apply(Action::PickerDismiss);
2912 let history_len_before = a.editor.position_history.len();
2917 assert!(history_len_before >= 1);
2918 }
2919
2920 #[test]
2921 fn activate_buffer_pushes_position_history_so_ctrl_o_walks_back() {
2922 let mut a = app_with("alpha\nbeta\ngamma\n", 10);
2928 let initial = a.active_pane_buffer_id();
2929 a.editor.cursor = Position::new(1, 2);
2931 let lsp_id = a.editor.buffers.by_name("*lsp*").unwrap();
2933 assert_ne!(initial, lsp_id);
2934 a.activate_buffer(lsp_id);
2935 let last = a.editor.position_history.last().expect("history entry");
2937 assert_eq!(last.position, Position::new(1, 2));
2938 assert_eq!(last.buffer_id, initial);
2939 a.apply(Action::JumpHistoryBack);
2941 assert_eq!(a.active_pane_buffer_id(), initial);
2942 assert_eq!(a.editor.cursor, Position::new(1, 2));
2943 }
2944
2945 #[test]
2946 fn pane_status_label_suppresses_dirty_marker_for_synthetic_documents() {
2947 let a = app_with("hi", 5);
2955 let active = a.active_pane_buffer_id();
2956 let handle = a.editor.document.as_arc();
2957 a.editor.buffers.remove(active);
2958 a.editor.buffers.insert(BufferEntry {
2959 id: active,
2960 flags: BufferFlags::default(),
2961 data: BufferData::Document(DocumentEntry {
2962 id: active,
2963 handle: handle.clone(),
2964 }),
2965 name: Some("*lsp*".to_string()),
2966 });
2967 let snap = handle.snapshot();
2971 let last_line = crate::app::last_addressable_line(&snap.buffer);
2972 let line_len = crate::app::line_byte_len(&snap.buffer, last_line);
2973 let pos = Position::new(last_line, line_len);
2974 let _ = lattice_runtime::block_on(
2975 handle.apply_edit_batch(vec![lattice_protocol::edit::Edit::insert(pos, "x")]),
2976 );
2977 assert!(handle.dirty(), "fixture must produce a dirty Document");
2978 let pane = *a.editor.pane_tree.active();
2979 let label = a.pane_status_label(&pane);
2980 assert!(
2981 label.contains("*lsp*"),
2982 "modeline must surface synthetic name; got `{label}`"
2983 );
2984 assert!(
2985 !label.contains("[+]"),
2986 "modeline must NOT surface [+] for synthetic buffers; got `{label}`"
2987 );
2988 }
2989}