1use lattice_grammar::ModalState;
41
42use super::{App, CompletionState, EchoLevel};
43
44pub(super) struct MissingArgPrompt {
48 pub(super) prefill: String,
52 pub(super) kind: lattice_grammar::ArgKind,
56 pub(super) prompt: String,
59}
60
61impl App {
62 pub(super) fn do_command_line_describe_under_cursor(&mut self) {
74 if !matches!(self.ad().modal, ModalState::Command) {
75 return;
76 }
77 let line = self.command_line();
78 let cursor = line.len();
79 let alias_resolver = |short: &str| {
80 crate::excommand::aliases()
81 .get(short)
82 .map(|s| (*s).to_string())
83 };
84 let reg = self.registry();
88 let slot = lattice_completion::current_slot(&line, cursor, ®, &alias_resolver);
89
90 let word = slot.prefix();
91 let canonical = if word.is_empty() {
92 None
93 } else {
94 alias_resolver(word).or_else(|| reg.id_by_name(word).and(Some(word.to_string())))
95 };
96
97 if let Some(name) = canonical
98 && reg.id_by_name(&name).is_some()
99 {
100 self.do_describe_command(&name, None);
101 return;
102 }
103
104 match &slot {
105 lattice_completion::CommandLineSlot::Arg {
106 command_name,
107 arg_spec,
108 ..
109 } => {
110 let anchor = format!("arg:{}", arg_spec.name);
111 self.do_describe_command(command_name, Some(&anchor));
112 }
113 lattice_completion::CommandLineSlot::CommandName { prefix, .. } => {
114 if prefix.is_empty() {
115 self.set_message(
116 EchoLevel::Info,
117 "type a command name then C-h for its help".to_string(),
118 );
119 } else {
120 self.set_message(EchoLevel::Error, format!("no command named `{prefix}`"));
121 }
122 }
123 _ => {
124 self.set_message(
125 EchoLevel::Info,
126 "no command-line context for `C-h`".to_string(),
127 );
128 }
129 }
130 }
131
132 pub(super) fn do_command_line_complete_or_advance(&mut self) {
137 if !matches!(self.ad().modal, ModalState::Command) {
138 return;
139 }
140 let advanced = self.mutate_editor_with(|e| {
146 if let Some(state) = e.completion_state.as_mut() {
147 if !state.candidates.is_empty() {
148 state.selected = (state.selected + 1) % state.candidates.len();
149 }
150 true
151 } else {
152 false
153 }
154 });
155 if advanced {
156 return;
157 }
158 self.open_completion_popup();
159 }
160
161 pub(super) fn try_resolve_missing_arg_prompt(&self) -> Option<MissingArgPrompt> {
190 let line_owned = self.command_line();
191 let line = line_owned.trim();
192 if line.is_empty() {
193 return None;
194 }
195 let (raw_cmd, rest) = match line.find(char::is_whitespace) {
200 Some(i) => (&line[..i], line[i..].trim()),
201 None => (line, ""),
202 };
203 if !rest.is_empty() {
204 return None;
206 }
207 let cmd = raw_cmd.strip_suffix('!').unwrap_or(raw_cmd);
208 let reg = self.registry();
212 let canonical = reg.id_by_name(cmd).or_else(|| {
213 crate::excommand::aliases()
214 .get(cmd)
215 .copied()
216 .and_then(|c| reg.id_by_name(c))
217 })?;
218 let spec = reg.ex_command_spec(canonical)?;
219 if matches!(
223 spec.surface_form,
224 lattice_grammar::SurfaceForm::Delimiter { .. }
225 ) {
226 return None;
227 }
228 let first = spec.args_schema.first()?;
229 if !matches!(first.default, lattice_grammar::ArgDefault::Required) {
230 return None;
233 }
234 let prompt = if first.prompt.is_empty() {
235 format!("{}:", first.name)
236 } else {
237 first.prompt.to_string()
238 };
239 Some(MissingArgPrompt {
240 prefill: format!("{raw_cmd} "),
244 kind: first.kind,
245 prompt,
246 })
247 }
248
249 pub fn chord_capture_active(&self) -> bool {
255 if !matches!(self.ad().modal, ModalState::Command) {
261 return false;
262 }
263 let line = self.command_line();
264 let line = line.as_str();
265 let alias_resolver = |short: &str| {
266 crate::excommand::aliases()
267 .get(short)
268 .map(|s| (*s).to_string())
269 };
270 let reg = self.registry();
272 let slot = lattice_completion::current_slot(line, line.len(), ®, &alias_resolver);
273 matches!(
274 &slot,
275 lattice_completion::CommandLineSlot::Arg { arg_spec, .. }
276 if arg_spec.kind == lattice_grammar::ArgKind::Chord
277 )
278 }
279
280 pub(super) fn open_completion_popup(&mut self) {
295 self.mutate_editor(|e| e.open_completion_popup());
297 }
298
299 pub(super) fn refresh_completion_popup(&mut self) {
302 self.mutate_editor(|e| e.refresh_completion_popup());
304 }
305
306 pub(super) fn compute_completion_state(
317 &self,
318 ) -> Result<CompletionState, CompletionComputeError> {
319 self.read_editor(move |e| e.compute_completion_state())
320 }
321}
322
323pub(super) use lattice_host::dispatch::CompletionComputeError;
328
329#[cfg(test)]
334use lattice_host::dispatch::prefer_aliases_for_command_candidates;
335
336#[cfg(test)]
341mod tests {
342 use super::*;
343 use crate::app::test_helpers::{
344 app_in_command_mode, app_with, invoke_motion, press, press_chars, submit_ex, unique_tempdir,
345 };
346 use crate::app::*;
347
348 #[test]
356 fn mb1_command_line_supports_midline_editing() {
357 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
358 let mut a = app_with("hello\n", 10);
359 a.apply(Action::EnterCommandLine);
360 assert!(
361 a.editor.command_line_active(),
362 "`:` must focus the *command-line* buffer"
363 );
364 press_chars(&mut a, "eabc");
365 assert_eq!(a.editor.command_line(), "eabc");
366 press(&mut a, KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
369 press(&mut a, KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
370 press_chars(&mut a, "X");
371 assert_eq!(
372 a.editor.command_line(),
373 "eaXbc",
374 "insert must land at the cursor, not the end of the line"
375 );
376 press(
379 &mut a,
380 KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL),
381 );
382 press_chars(&mut a, "Y");
383 assert_eq!(a.editor.command_line(), "eaYXbc");
384 }
385
386 #[test]
389 fn mb1_command_line_cancel_restores_prior_buffer() {
390 let mut a = app_with("hello\n", 10);
391 let before = a.editor.document_buffer_id;
392 a.apply(Action::EnterCommandLine);
393 press_chars(&mut a, "e foo");
394 assert!(a.editor.command_line_active());
395 a.apply(Action::CommandLineCancel);
396 assert!(
397 !a.editor.command_line_active(),
398 "cancel must close the `:` line"
399 );
400 assert_eq!(
401 a.editor.document_buffer_id, before,
402 "cancel must restore the prior editing buffer"
403 );
404 }
405
406 #[test]
409 fn mb1_command_line_history_walk_seeds_the_buffer() {
410 let mut a = app_with("hello\n", 10);
411 a.editor.command_history.push("edit foo".to_string());
412 a.apply(Action::EnterCommandLine);
413 a.apply(Action::CommandLineHistoryPrev);
414 assert_eq!(
415 a.editor.command_line(),
416 "edit foo",
417 "`<C-p>` seeds the `:` buffer from history"
418 );
419 }
420
421 #[test]
428 fn mb2_toggle_expand_flips_state_and_modal_preserving_text() {
429 let mut a = app_with("hello\n", 10);
430 a.apply(Action::EnterCommandLine);
431 press_chars(&mut a, "e foo");
432 assert!(!a.editor.command_line_expanded());
433 assert!(matches!(a.editor.modal, ModalState::Command));
434
435 a.apply(Action::CommandLineToggleExpand);
436 assert!(a.editor.command_line_expanded(), "expand sets tier-2");
437 assert!(
438 matches!(a.editor.modal, ModalState::Insert),
439 "expand drops into full-modal Insert on the band"
440 );
441 assert_eq!(a.editor.command_line(), "e foo", "text survives expand");
442
443 a.apply(Action::CommandLineToggleExpand);
444 assert!(!a.editor.command_line_expanded(), "collapse clears tier-2");
445 assert!(
446 matches!(a.editor.modal, ModalState::Command),
447 "collapse returns to the readline line for review"
448 );
449 assert_eq!(a.editor.command_line(), "e foo", "text survives collapse");
450 }
451
452 #[test]
456 fn mb2_colon_is_noop_while_command_line_open() {
457 let mut a = app_with("hello\n", 10);
458 a.apply(Action::EnterCommandLine);
459 press_chars(&mut a, "abc");
460 let buf = a.editor.document_buffer_id;
461 a.apply(Action::EnterCommandLine);
462 assert!(a.editor.command_line_active());
463 assert_eq!(a.editor.command_line(), "abc", "text preserved, not reset");
464 assert_eq!(
465 a.editor.document_buffer_id, buf,
466 "same `*command-line*` buffer, not a nested one"
467 );
468 }
469
470 #[test]
473 fn mb2_expanded_cr_inserts_newline_not_submit() {
474 let mut a = app_with("hello\n", 10);
475 a.apply(Action::EnterCommandLine);
476 press_chars(&mut a, "e foo");
477 a.apply(Action::CommandLineToggleExpand); a.apply(Action::CommandLineSubmit); press_chars(&mut a, "bar");
480 assert!(
481 a.editor.command_line_active(),
482 "expanded `<CR>` must not submit / close the command line"
483 );
484 assert!(
485 a.editor.document.text().starts_with("e foo\nbar"),
486 "expanded `<CR>` inserts a newline (multi-line): {:?}",
487 a.editor.document.text()
488 );
489 assert_eq!(
490 a.editor.command_line(),
491 "e foo",
492 "`command_line()` reads the first line"
493 );
494 }
495
496 #[test]
499 fn mb2_expanded_esc_from_insert_goes_to_normal() {
500 let mut a = app_with("hello\n", 10);
501 a.apply(Action::EnterCommandLine);
502 a.apply(Action::CommandLineToggleExpand);
503 assert!(matches!(a.editor.modal, ModalState::Insert));
504 a.apply(Action::CommandLineCancel); assert!(
506 a.editor.command_line_active(),
507 "`<Esc>` in the band must not cancel the command line"
508 );
509 assert!(
510 matches!(a.editor.modal, ModalState::Normal),
511 "`<Esc>` in the expanded band drops to Normal for full modal"
512 );
513 }
514
515 #[test]
519 fn mb2_expanded_full_text_exposes_multiline_for_the_band() {
520 let mut a = app_with("hello\n", 10);
521 a.apply(Action::EnterCommandLine);
522 press_chars(&mut a, "e foo");
523 a.apply(Action::CommandLineToggleExpand);
524 a.apply(Action::CommandLineSubmit); press_chars(&mut a, "bar");
526 assert!(a.editor.command_line_expanded());
527 assert_eq!(a.editor.command_line(), "e foo");
528 assert!(
529 a.editor.command_line_full_text().starts_with("e foo\nbar"),
530 "band reads the full multi-line text: {:?}",
531 a.editor.command_line_full_text()
532 );
533 }
534
535 #[test]
540 fn mb2e_expand_height_option_drives_band_rows() {
541 use lattice_config::{CommandLineExpandHeight, ExpandHeight};
542 let a = app_with("hello\n", 40);
543 assert_eq!(a.command_line_expand_height(), ExpandHeight::Half);
545 assert_eq!(a.command_line_expand_height().rows(40), 20);
546
547 let _ = a
549 .editor
550 .config
551 .set_typed::<CommandLineExpandHeight>(ExpandHeight::Full);
552 assert_eq!(a.command_line_expand_height(), ExpandHeight::Full);
553 assert_eq!(a.command_line_expand_height().rows(40), 38);
554
555 let _ = a
557 .editor
558 .config
559 .set_typed::<CommandLineExpandHeight>(ExpandHeight::Fixed(7));
560 assert_eq!(a.command_line_expand_height().rows(40), 7);
561 }
562
563 #[test]
572 fn mb3_q_colon_picker_loads_editable_line_then_cr_runs() {
573 let mut a = app_with("hello\n", 10);
574 submit_ex(&mut a, "set number");
576 submit_ex(&mut a, "set wrap");
577 assert!(!a.editor.command_line_active());
578
579 press_chars(&mut a, "q:");
582 let p = a
583 .editor
584 .picker
585 .as_ref()
586 .expect("`q:` must open the history picker");
587 assert_eq!(p.source_id.as_deref(), Some("history"));
588 assert!(p.candidates[0].raw.display.contains("set wrap"));
590
591 for c in "number".chars() {
593 a.apply(Action::PickerAppend(c));
594 }
595 a.apply(Action::PickerAccept);
596
597 assert!(a.editor.picker.is_none());
599 assert!(
600 a.editor.command_line_active(),
601 "accept must open the `:` line"
602 );
603 assert_eq!(a.editor.command_line(), "set number");
604 assert_eq!(
606 a.editor.command_history,
607 vec!["set number".to_string(), "set wrap".to_string()]
608 );
609
610 a.apply(Action::CommandLineSubmit);
612 assert!(!a.editor.command_line_active());
613 }
614
615 #[test]
617 fn mb3_history_ex_command_opens_picker() {
618 let mut a = app_with("hello\n", 10);
619 submit_ex(&mut a, "write");
620 submit_ex(&mut a, "history");
621 assert_eq!(
622 a.editor.picker.as_ref().unwrap().source_id.as_deref(),
623 Some("history")
624 );
625 }
626
627 #[test]
630 fn mb3_q_colon_empty_history_is_graceful() {
631 let mut a = app_with("hello\n", 10);
632 assert!(a.editor.command_history.is_empty());
633 press_chars(&mut a, "q:");
634 assert!(
635 a.editor.picker.is_none(),
636 "empty history must not open a picker"
637 );
638 assert!(!a.editor.command_line_active());
639 }
640
641 #[test]
645 fn mb3_q_colon_from_expanded_band_seeds_filter() {
646 let mut a = app_with("hello\n", 10);
647 submit_ex(&mut a, "set number");
648 submit_ex(&mut a, "write");
649 a.apply(Action::EnterCommandLine);
651 press_chars(&mut a, "set");
652 a.apply(Action::CommandLineToggleExpand);
653 a.apply(Action::CommandLineCancel); assert!(a.editor.command_line_expanded());
655 assert!(matches!(a.editor.modal, ModalState::Normal));
656
657 press_chars(&mut a, "q:");
660 let p = a
661 .editor
662 .picker
663 .as_ref()
664 .expect("`q:` in the band must open the history picker");
665 assert_eq!(
666 p.query, "set",
667 "filter seeded from the in-progress `:` text"
668 );
669 assert_eq!(p.candidates.len(), 1);
670 assert!(p.candidates[0].raw.display.contains("set number"));
671 }
672
673 #[test]
682 fn mb4_typing_known_command_populates_decorations() {
683 use lattice_cells::style::Style;
684 let mut a = app_with("hello\n", 10);
685 a.apply(Action::EnterCommandLine);
686 press_chars(&mut a, "write foo");
687 let d = a
688 .editor
689 .command_line_decorations
690 .as_ref()
691 .expect("decorations produced on edit");
692 assert_eq!(
693 d.spans[0].style,
694 Style::Keyword,
695 "command word is a keyword"
696 );
697 assert!(d.error.is_none(), "known command has no error");
698 assert!(d.param_hint.is_some(), "`:write <file>` offers a hint");
699 }
700
701 #[test]
704 fn mb4_unknown_command_sets_live_error() {
705 let mut a = app_with("hello\n", 10);
706 a.apply(Action::EnterCommandLine);
707 press_chars(&mut a, "frobnicate ");
708 let d = a
709 .editor
710 .command_line_decorations
711 .as_ref()
712 .expect("decorations");
713 assert_eq!(
714 d.error.as_deref(),
715 Some("unknown command: frobnicate"),
716 "committed unknown command flags an error"
717 );
718 }
719
720 #[test]
723 fn mb4_cancel_clears_decorations() {
724 let mut a = app_with("hello\n", 10);
725 a.apply(Action::EnterCommandLine);
726 press_chars(&mut a, "write");
727 assert!(a.editor.command_line_decorations.is_some());
728 a.apply(Action::CommandLineCancel);
729 assert!(a.editor.command_line_decorations.is_none());
730 }
731
732 #[test]
733 fn prefer_aliases_rewrites_canonical_to_alias() {
734 use lattice_completion::{
735 CandidateData, CandidateKind, MatchScore, RawCandidate, RenderedCandidate,
736 };
737 use lattice_grammar::source::SourceLocation;
738 let mut candidates = vec![RenderedCandidate {
739 raw: RawCandidate {
740 insert_text: None,
741 text: "ex:describe-command".into(),
742 display: "ex:describe-command".into(),
743 kind: CandidateKind::Command,
744 data: CandidateData::Command {
745 name: "ex:describe-command".into(),
746 doc: "doc".into(),
747 kind_label: "ex-command".into(),
748 source: SourceLocation::synthetic("test"),
749 },
750 source: None,
751 accept_action: None,
752 annotations: Vec::new(),
753 display_spans: Vec::new(),
754 },
755 score: MatchScore::PERFECT,
756 match_ranges: vec![],
757 annotations: vec![],
758 }];
759 prefer_aliases_for_command_candidates(&mut candidates, "descri");
760 assert_eq!(candidates[0].raw.text, "describe-command");
761 assert_eq!(candidates[0].raw.display, "describe-command");
762 assert!(!candidates[0].match_ranges.is_empty());
764 }
765
766 #[test]
767 #[allow(clippy::single_range_in_vec_init)]
768 fn prefer_aliases_leaves_non_command_candidates_alone() {
769 use lattice_completion::{
770 CandidateData, CandidateKind, MatchScore, RawCandidate, RenderedCandidate,
771 };
772 let mut candidates = vec![RenderedCandidate {
773 raw: RawCandidate {
774 insert_text: None,
775 text: "/tmp/foo.rs".into(),
776 display: "foo.rs".into(),
777 kind: CandidateKind::File,
778 data: CandidateData::File {
779 path: "/tmp/foo.rs".into(),
780 is_dir: false,
781 size: None,
782 },
783 source: None,
784 accept_action: None,
785 annotations: Vec::new(),
786 display_spans: Vec::new(),
787 },
788 score: MatchScore::PERFECT,
789 match_ranges: vec![0..3],
790 annotations: vec![],
791 }];
792 prefer_aliases_for_command_candidates(&mut candidates, "tmp");
793 assert_eq!(candidates[0].raw.text, "/tmp/foo.rs");
795 }
796
797 #[test]
798 fn prefer_aliases_keeps_canonical_when_alias_does_not_match_query() {
799 use lattice_completion::{
800 CandidateData, CandidateKind, MatchScore, RawCandidate, RenderedCandidate,
801 };
802 use lattice_grammar::source::SourceLocation;
803 let mut candidates = vec![RenderedCandidate {
807 raw: RawCandidate {
808 insert_text: None,
809 text: "next-error".into(),
810 display: "next-error".into(),
811 kind: CandidateKind::Command,
812 data: CandidateData::Command {
813 name: "next-error".into(),
814 doc: "jump to next error".into(),
815 kind_label: "ex-command".into(),
816 source: SourceLocation::synthetic("test"),
817 },
818 source: None,
819 accept_action: None,
820 annotations: Vec::new(),
821 display_spans: Vec::new(),
822 },
823 score: MatchScore::PREFIX,
824 match_ranges: Vec::from([0..5]),
825 annotations: vec![],
826 }];
827 prefer_aliases_for_command_candidates(&mut candidates, "next-");
828 assert_eq!(candidates[0].raw.text, "next-error");
831 assert_eq!(candidates[0].raw.display, "next-error");
832 assert_eq!(candidates[0].match_ranges, vec![0..5]);
834 }
835
836 #[test]
837 fn enter_command_line_clears_buffer_and_sets_modal() {
838 let mut a = app_with("abc", 10);
839 a.editor.last_message = Some(EchoMessage {
844 text: "stale".into(),
845 level: EchoLevel::Info,
846 });
847 a.apply(Action::EnterCommandLine);
848 assert_eq!(a.editor.modal, ModalState::Command);
849 assert_eq!(a.editor.command_line(), "");
850 assert!(a.editor.last_message.is_none());
851 }
852
853 #[test]
854 fn command_line_append_pushes_chars() {
855 let mut a = app_with("", 10);
856 a.apply(Action::EnterCommandLine);
857 a.apply(Action::CommandLineAppend('w'));
858 a.apply(Action::CommandLineAppend('q'));
859 assert_eq!(a.editor.command_line(), "wq");
860 }
861
862 #[test]
863 fn command_line_backspace_pops_chars() {
864 let mut a = app_with("", 10);
865 a.apply(Action::EnterCommandLine);
866 a.apply(Action::CommandLineAppend('w'));
867 a.apply(Action::CommandLineAppend('q'));
868 a.apply(Action::CommandLineBackspace);
869 assert_eq!(a.editor.command_line(), "w");
870 }
871
872 #[test]
873 fn command_line_backspace_on_empty_exits_command_modal() {
874 let mut a = app_with("", 10);
875 a.apply(Action::EnterCommandLine);
876 a.apply(Action::CommandLineBackspace);
877 assert_eq!(a.editor.modal, ModalState::Normal);
878 }
879
880 #[test]
881 fn command_line_cancel_clears_and_returns_to_normal() {
882 let mut a = app_with("", 10);
883 a.apply(Action::EnterCommandLine);
884 a.apply(Action::CommandLineAppend('w'));
885 a.apply(Action::CommandLineCancel);
886 assert_eq!(a.editor.modal, ModalState::Normal);
887 assert_eq!(a.editor.command_line(), "");
888 }
889
890 #[test]
891 fn submit_q_on_clean_buffer_quits() {
892 let mut a = app_with("hello", 10);
893 a.apply(Action::EnterCommandLine);
894 for c in "q".chars() {
895 a.apply(Action::CommandLineAppend(c));
896 }
897 a.apply(Action::CommandLineSubmit);
898 assert!(a.editor.should_quit);
899 assert_eq!(a.editor.modal, ModalState::Normal);
900 }
901
902 #[test]
903 fn submit_q_on_dirty_buffer_refuses() {
904 let mut a = app_with("hello", 10);
905 a.apply(Action::EnterMode(ModalState::Insert));
906 a.apply(Action::Insert("X".into()));
907 a.apply(Action::EnterMode(ModalState::Normal));
908 assert!(a.editor.document.dirty());
909
910 a.apply(Action::EnterCommandLine);
911 a.apply(Action::CommandLineAppend('q'));
912 a.apply(Action::CommandLineSubmit);
913 assert!(!a.editor.should_quit);
914 let msg = a.editor.last_message.as_ref().unwrap();
915 assert_eq!(msg.level, EchoLevel::Error);
916 assert!(msg.text.contains("no write since last change"));
917 }
918
919 #[test]
920 fn submit_q_bang_quits_even_when_dirty() {
921 let mut a = app_with("hello", 10);
922 a.apply(Action::EnterMode(ModalState::Insert));
923 a.apply(Action::Insert("X".into()));
924 a.apply(Action::EnterMode(ModalState::Normal));
925 a.apply(Action::EnterCommandLine);
926 for c in "q!".chars() {
927 a.apply(Action::CommandLineAppend(c));
928 }
929 a.apply(Action::CommandLineSubmit);
930 assert!(a.editor.should_quit);
931 }
932
933 #[test]
934 fn submit_w_without_path_errors() {
935 let mut a = app_with("hello", 10);
936 a.apply(Action::EnterCommandLine);
937 a.apply(Action::CommandLineAppend('w'));
938 a.apply(Action::CommandLineSubmit);
939 let msg = a.editor.last_message.as_ref().unwrap();
940 assert_eq!(msg.level, EchoLevel::Error);
941 assert!(msg.text.contains("no file name"));
942 }
943
944 #[test]
945 fn submit_w_with_path_writes_and_clears_dirty() {
946 let dir = unique_tempdir();
947 let path = dir.join("out.txt");
948 let mut a = App::new(Document::from_text("hello"));
949 a.set_viewport_height(10);
950 a.apply(invoke_motion(a.editor.builtins.line_end));
952 a.apply(Action::EnterMode(ModalState::Insert));
953 a.apply(Action::Insert("!".into()));
954 a.apply(Action::EnterMode(ModalState::Normal));
955 assert!(a.editor.document.dirty());
956
957 a.apply(Action::EnterCommandLine);
958 for c in format!("w {}", path.display()).chars() {
959 a.apply(Action::CommandLineAppend(c));
960 }
961 a.apply(Action::CommandLineSubmit);
962
963 assert!(!a.editor.document.dirty());
964 let msg = a.editor.last_message.as_ref().unwrap();
965 assert_eq!(msg.level, EchoLevel::Info);
966 assert!(msg.text.contains("written"));
967 assert_eq!(std::fs::read_to_string(&path).unwrap(), "hello!");
968 std::fs::remove_dir_all(&dir).ok();
969 }
970
971 #[test]
972 fn submit_wq_writes_then_quits() {
973 let dir = unique_tempdir();
974 let path = dir.join("out.txt");
975 std::fs::write(&path, "first").unwrap();
976
977 let mut a = App::new(Document::open(&path).unwrap());
978 a.set_viewport_height(10);
979 a.apply(Action::EnterMode(ModalState::Insert));
980 a.apply(Action::Insert("X".into()));
981 a.apply(Action::EnterMode(ModalState::Normal));
982
983 a.apply(Action::EnterCommandLine);
984 for c in "wq".chars() {
985 a.apply(Action::CommandLineAppend(c));
986 }
987 a.apply(Action::CommandLineSubmit);
988
989 assert!(a.editor.should_quit);
990 let on_disk = std::fs::read_to_string(&path).unwrap();
991 assert!(on_disk.starts_with("X"));
992 std::fs::remove_dir_all(&dir).ok();
993 }
994
995 #[test]
996 fn submit_unknown_command_surfaces_error() {
997 let mut a = app_with("hello", 10);
998 a.apply(Action::EnterCommandLine);
999 for c in "frobnicate".chars() {
1000 a.apply(Action::CommandLineAppend(c));
1001 }
1002 a.apply(Action::CommandLineSubmit);
1003 let msg = a.editor.last_message.as_ref().unwrap();
1004 assert_eq!(msg.level, EchoLevel::Error);
1005 assert!(msg.text.contains("frobnicate"));
1006 }
1007
1008 #[test]
1009 fn submit_pushes_command_into_history() {
1010 let mut a = app_with("hello", 10);
1011 submit_ex(&mut a, "set number");
1012 assert_eq!(a.editor.command_history, vec!["set number".to_string()]);
1013 }
1014
1015 #[test]
1016 fn submit_dedupes_consecutive_identical_history() {
1017 let mut a = app_with("hello", 10);
1018 submit_ex(&mut a, "set number");
1019 submit_ex(&mut a, "set number");
1020 assert_eq!(a.editor.command_history.len(), 1);
1021 }
1022
1023 #[test]
1024 fn empty_submit_does_not_push_history() {
1025 let mut a = app_with("hello", 10);
1026 a.apply(Action::EnterCommandLine);
1027 a.apply(Action::CommandLineSubmit);
1028 assert!(a.editor.command_history.is_empty());
1029 }
1030
1031 #[test]
1032 fn up_in_command_walks_to_most_recent_history() {
1033 let mut a = app_with("hello", 10);
1034 submit_ex(&mut a, "set number");
1035 submit_ex(&mut a, "set nonumber");
1036 a.apply(Action::EnterCommandLine);
1037 a.apply(Action::CommandLineHistoryPrev);
1038 assert_eq!(a.editor.command_line(), "set nonumber");
1039 }
1040
1041 #[test]
1042 fn up_then_up_walks_to_older() {
1043 let mut a = app_with("hello", 10);
1044 submit_ex(&mut a, "set number");
1045 submit_ex(&mut a, "set nonumber");
1046 a.apply(Action::EnterCommandLine);
1047 a.apply(Action::CommandLineHistoryPrev);
1048 a.apply(Action::CommandLineHistoryPrev);
1049 assert_eq!(a.editor.command_line(), "set number");
1050 }
1051
1052 #[test]
1053 fn down_returns_to_in_progress_typed_text() {
1054 let mut a = app_with("hello", 10);
1055 submit_ex(&mut a, "set number");
1056 a.apply(Action::EnterCommandLine);
1057 for c in "se".chars() {
1058 a.apply(Action::CommandLineAppend(c));
1059 }
1060 a.apply(Action::CommandLineHistoryPrev);
1062 assert_eq!(a.editor.command_line(), "set number");
1063 a.apply(Action::CommandLineHistoryNext);
1065 assert_eq!(a.editor.command_line(), "se");
1066 }
1067
1068 #[test]
1069 fn history_navigation_with_no_history_is_no_op() {
1070 let mut a = app_with("hello", 10);
1071 a.apply(Action::EnterCommandLine);
1072 a.apply(Action::CommandLineAppend('w'));
1073 a.apply(Action::CommandLineHistoryPrev);
1074 assert_eq!(a.editor.command_line(), "w");
1075 }
1076
1077 #[test]
1078 fn history_persists_across_command_sessions() {
1079 let mut a = app_with("hello", 10);
1080 submit_ex(&mut a, "set number");
1081 a.apply(Action::EnterCommandLine);
1083 a.apply(Action::CommandLineHistoryPrev);
1084 assert_eq!(a.editor.command_line(), "set number");
1085 }
1086
1087 #[test]
1095 fn typing_desc_then_tab_opens_completion_popup() {
1096 let mut a = app_with("xx", 10);
1097 a.apply(Action::EnterCommandLine);
1099 assert_eq!(a.editor.modal, ModalState::Command);
1100 for c in "desc".chars() {
1102 a.apply(Action::CommandLineAppend(c));
1103 }
1104 assert_eq!(a.editor.command_line(), "desc");
1105 a.apply(Action::CommandLineCompleteOrAdvance);
1108 let state = a
1109 .editor
1110 .completion_state
1111 .as_ref()
1112 .expect("popup should open after :desc<Tab>");
1113 assert!(
1114 state
1115 .candidates
1116 .iter()
1117 .any(|c| c.raw.text == "describe-command"),
1118 "candidates: {:?}",
1119 state
1120 .candidates
1121 .iter()
1122 .map(|c| &c.raw.text)
1123 .collect::<Vec<_>>(),
1124 );
1125 }
1126
1127 #[test]
1132 fn describe_mode_arg_tab_lists_mode_names() {
1133 let mut a = app_in_command_mode("describe-mode ");
1134 a.apply(Action::CommandLineCompleteOrAdvance);
1135 let state = a
1136 .editor
1137 .completion_state
1138 .as_ref()
1139 .expect("popup should open at the `:describe-mode ` arg slot");
1140 assert!(
1141 state.candidates.iter().any(|c| c.raw.text == "text-mode"),
1142 "candidates: {:?}",
1143 state
1144 .candidates
1145 .iter()
1146 .map(|c| &c.raw.text)
1147 .collect::<Vec<_>>(),
1148 );
1149 }
1150
1151 #[test]
1158 fn tab_on_complete_command_name_steps_into_the_arg_slot() {
1159 let mut a = app_in_command_mode("describe-mode");
1160 a.apply(Action::CommandLineCompleteOrAdvance);
1161 assert_eq!(
1162 a.editor.command_line(),
1163 "describe-mode ",
1164 "Tab on a complete command name must advance into its arg slot"
1165 );
1166 let state = a
1167 .editor
1168 .completion_state
1169 .as_ref()
1170 .expect("stepping into the arg slot must open that arg's popup");
1171 assert!(
1172 state.candidates.iter().any(|c| c.raw.text == "text-mode"),
1173 "candidates: {:?}",
1174 state
1175 .candidates
1176 .iter()
1177 .map(|c| &c.raw.text)
1178 .collect::<Vec<_>>(),
1179 );
1180 }
1181
1182 #[test]
1186 fn tab_on_argless_command_does_not_append_a_space() {
1187 let mut a = app_in_command_mode("list-modes");
1188 a.apply(Action::CommandLineCompleteOrAdvance);
1189 assert_eq!(a.editor.command_line(), "list-modes");
1190 }
1191
1192 #[test]
1194 fn describe_mode_arg_tab_filters_on_prefix() {
1195 let mut a = app_in_command_mode("describe-mode ma");
1196 a.apply(Action::CommandLineCompleteOrAdvance);
1197 let state = a
1198 .editor
1199 .completion_state
1200 .as_ref()
1201 .expect("popup should open at the `:describe-mode ma` arg slot");
1202 assert!(
1203 state
1204 .candidates
1205 .iter()
1206 .any(|c| c.raw.text.starts_with("magit-")),
1207 "candidates: {:?}",
1208 state
1209 .candidates
1210 .iter()
1211 .map(|c| &c.raw.text)
1212 .collect::<Vec<_>>(),
1213 );
1214 }
1215
1216 #[test]
1217 fn tab_in_command_mode_opens_completion_popup() {
1218 let mut a = app_in_command_mode("descri");
1219 a.apply(Action::CommandLineCompleteOrAdvance);
1220 let state = a
1221 .editor
1222 .completion_state
1223 .as_ref()
1224 .expect("popup should open");
1225 assert!(
1231 state
1232 .candidates
1233 .iter()
1234 .any(|c| c.raw.text == "describe-command")
1235 );
1236 assert!(
1237 state
1238 .candidates
1239 .iter()
1240 .any(|c| c.raw.text == "describe-buffer")
1241 );
1242 assert_eq!(state.selected, 0);
1243 }
1244
1245 #[test]
1246 fn shift_tab_walks_back_through_candidates() {
1247 let mut a = app_in_command_mode("descri");
1248 a.apply(Action::CommandLineCompleteOrAdvance);
1249 a.apply(Action::CommandLineCompleteOrAdvance);
1250 a.apply(Action::CommandLineCompleteOrAdvance);
1251 a.apply(Action::CommandLineCompletePrev);
1252 assert_eq!(a.editor.completion_state.as_ref().unwrap().selected, 1);
1253 }
1254
1255 #[test]
1256 fn chord_capture_active_only_when_in_chord_arg_slot() {
1257 let mut a = app_with("xx", 10);
1262 a.editor.modal = ModalState::Command;
1263 a.editor.publish_render_state();
1264 a.editor.set_command_line_text("");
1266 a.editor.publish_render_state();
1267 assert!(!a.chord_capture_active());
1268 a.editor.set_command_line_text("describe-key");
1270 a.editor.publish_render_state();
1271 assert!(!a.chord_capture_active());
1272 a.editor.set_command_line_text("describe-key ");
1275 a.editor.publish_render_state();
1276 assert!(a.chord_capture_active());
1277 a.editor.set_command_line_text("describe-command ");
1280 a.editor.publish_render_state();
1281 assert!(!a.chord_capture_active());
1282 a.editor.modal = ModalState::Normal;
1284 a.editor.publish_render_state();
1285 a.editor.set_command_line_text("describe-key ");
1286 a.editor.publish_render_state();
1287 assert!(!a.chord_capture_active());
1288 }
1289
1290 #[test]
1291 fn chord_capture_active_for_canonical_command_name() {
1292 let mut a = app_with("xx", 10);
1296 a.editor.modal = ModalState::Command;
1297 a.editor.publish_render_state();
1298 a.editor.set_command_line_text("ex:describe-key ");
1299 a.editor.publish_render_state();
1300 assert!(a.chord_capture_active());
1301 }
1302
1303 #[test]
1304 fn empty_submit_of_describe_key_arms_chord_prompt() {
1305 let mut a = app_in_command_mode("describe-key");
1309 a.apply(Action::CommandLineSubmit);
1310 assert_eq!(a.editor.command_line(), "describe-key ");
1311 assert!(a.editor.auto_submit_after_chord);
1312 assert!(matches!(a.editor.modal, ModalState::Command));
1313 }
1314
1315 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1318
1319 #[test]
1327 fn enter_terminates_a_chord_capture_that_is_also_a_prefix() {
1328 let mut a = app_in_command_mode("describe-key");
1329 a.apply(Action::CommandLineSubmit);
1330 assert!(a.editor.auto_submit_after_chord, "precondition: armed");
1331
1332 press(
1333 &mut a,
1334 KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE),
1335 );
1336 assert_eq!(
1337 a.editor.command_line(),
1338 "describe-key y",
1339 "`y` alone does not submit — `yy` and `yiw` exist, so the capture \
1340 is right to wait"
1341 );
1342
1343 press(&mut a, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
1344 assert!(
1345 !a.editor
1346 .last_message
1347 .as_ref()
1348 .map(|m| m.text.contains("y<CR>"))
1349 .unwrap_or(false),
1350 "the terminator must not become part of the chord, got {:?}",
1351 a.editor.last_message
1352 );
1353 }
1354
1355 #[test]
1359 fn enter_as_the_first_captured_key_still_describes_enter() {
1360 let mut a = app_in_command_mode("describe-key");
1361 a.apply(Action::CommandLineSubmit);
1362 press(&mut a, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
1363 assert!(
1364 !a.editor.auto_submit_after_chord,
1365 "the capture completed rather than waiting"
1366 );
1367 }
1368
1369 #[test]
1372 fn a_complete_multi_key_chord_still_needs_no_terminator() {
1373 let mut a = app_in_command_mode("describe-key");
1374 a.apply(Action::CommandLineSubmit);
1375 press(
1376 &mut a,
1377 KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE),
1378 );
1379 press(
1380 &mut a,
1381 KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE),
1382 );
1383 assert!(
1384 !a.editor.command_line().contains("<CR>"),
1385 "no terminator was needed: {:?}",
1386 a.editor.command_line()
1387 );
1388 }
1389
1390 #[test]
1391 fn empty_submit_of_canonical_describe_key_arms_chord_prompt() {
1392 let mut a = app_in_command_mode("ex:describe-key");
1395 a.apply(Action::CommandLineSubmit);
1396 assert_eq!(a.editor.command_line(), "ex:describe-key ");
1397 assert!(a.editor.auto_submit_after_chord);
1398 }
1399
1400 #[test]
1401 fn empty_submit_of_describe_command_arms_prompt_without_chord_capture() {
1402 let mut a = app_in_command_mode("describe-command");
1408 a.apply(Action::CommandLineSubmit);
1409 assert!(matches!(a.editor.modal, ModalState::Command));
1410 assert!(!a.editor.auto_submit_after_chord);
1411 assert_eq!(a.editor.command_line(), "describe-command ");
1413 assert!(a.editor.last_message.is_some());
1415 }
1416
1417 #[test]
1418 fn empty_submit_of_optional_arg_command_does_not_arm_prompt() {
1419 let mut a = app_in_command_mode("w");
1423 a.apply(Action::CommandLineSubmit);
1424 assert!(matches!(a.editor.modal, ModalState::Normal));
1427 assert!(!a.editor.auto_submit_after_chord);
1428 }
1429
1430 #[test]
1431 fn missing_arg_prompt_preserves_user_alias() {
1432 let mut a = app_in_command_mode("apropos");
1436 a.apply(Action::CommandLineSubmit);
1437 assert_eq!(a.editor.command_line(), "apropos ");
1438 assert!(matches!(a.editor.modal, ModalState::Command));
1439 }
1440
1441 #[test]
1442 fn submit_with_arg_supplied_takes_normal_path() {
1443 let mut a = app_in_command_mode("describe-key j");
1446 a.apply(Action::CommandLineSubmit);
1447 assert!(!a.editor.auto_submit_after_chord);
1448 assert!(matches!(a.editor.modal, ModalState::Normal));
1449 assert!(a.editor.popup_buffer.is_some());
1450 }
1451
1452 #[test]
1453 fn ctrl_h_on_known_command_describes_it_directly() {
1454 let mut a = app_in_command_mode("describe-command");
1457 a.apply(Action::CommandLineDescribeUnderCursor);
1458 let h = a.popup_help().expect("help should open");
1459 assert!(h.title.contains("ex:describe-command"));
1460 }
1461
1462 #[test]
1463 fn ctrl_h_on_arg_describes_parent_command_at_arg_anchor() {
1464 let mut a = app_in_command_mode("describe-command moti");
1469 a.apply(Action::CommandLineDescribeUnderCursor);
1470 let h = a.popup_help().expect("help should open");
1471 assert!(h.title.contains("ex:describe-command"));
1472 let scroll = h.scroll;
1473 let anchors = a.popup_help_anchors().expect("help anchors seeded");
1475 let arg_anchor = anchors.iter().find(|a| a.name == "arg:name").unwrap();
1476 assert_eq!(scroll, arg_anchor.line as usize);
1477 }
1478
1479 #[test]
1480 fn ctrl_h_on_arg_value_that_is_a_known_command_describes_it() {
1481 let mut a = app_in_command_mode("describe-command motion:line-down");
1484 a.apply(Action::CommandLineDescribeUnderCursor);
1485 let h = a.popup_help().expect("help should open");
1486 assert!(h.title.contains("motion:line-down"));
1487 }
1488
1489 #[test]
1490 fn ctrl_h_on_unknown_word_emits_error_message() {
1491 let mut a = app_in_command_mode("no-such-command");
1492 a.apply(Action::CommandLineDescribeUnderCursor);
1493 assert!(a.editor.popup_buffer.is_none());
1494 let msg = a.editor.last_message.as_ref().unwrap();
1495 assert_eq!(msg.level, EchoLevel::Error);
1496 }
1497
1498 #[test]
1499 fn cmdline_completion_includes_lsp_subcommand_aliases() {
1500 let mut a = app_in_command_mode("lsp-");
1508 a.apply(Action::CommandLineCompleteOrAdvance);
1509 let state = a
1510 .editor
1511 .completion_state
1512 .as_ref()
1513 .expect("popup should open");
1514 let texts: Vec<&str> = state
1515 .candidates
1516 .iter()
1517 .map(|c| c.raw.text.as_str())
1518 .collect();
1519 for needle in [
1520 "lsp-trace",
1521 "lsp-status",
1522 "lsp-restart",
1523 "lsp-log",
1524 "lsp-log-level",
1525 "lsp-log-clear",
1526 ] {
1527 assert!(
1528 texts.contains(&needle),
1529 "completion should include `{needle}` -- got {:?}",
1530 texts
1531 );
1532 }
1533 }
1534}
1535
1536#[cfg(test)]
1550mod prompt_line {
1551 use crate::app::test_helpers::{app_with, press};
1552 use crate::app::{Action, App};
1553 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1554 use lattice_grammar::ModalState;
1555
1556 fn app_with_prompt() -> App {
1557 let mut a = app_with("hello\n", 10);
1558 a.editor.open_prompt_line(
1559 "Test prompt: ".to_string(),
1560 String::new(),
1561 "action:no-such-action".to_string(),
1562 Some("*magit:test-prompt*".to_string()),
1563 );
1564 a.editor.publish_render_state();
1570 assert!(
1571 matches!(a.editor.modal, ModalState::Prompt),
1572 "the prompt must actually be open before the key is pressed"
1573 );
1574 a
1575 }
1576
1577 #[test]
1579 fn esc_cancels_an_open_prompt() {
1580 let mut a = app_with_prompt();
1581 press(&mut a, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
1582 assert!(
1583 !matches!(a.editor.modal, ModalState::Prompt),
1584 "`<Esc>` must cancel the prompt — with no arm in translate's \
1585 modal table there was NO way out of this state"
1586 );
1587 }
1588
1589 #[test]
1592 fn ctrl_c_cancels_an_open_prompt() {
1593 let mut a = app_with_prompt();
1594 press(
1595 &mut a,
1596 KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
1597 );
1598 assert!(
1599 !matches!(a.editor.modal, ModalState::Prompt),
1600 "`<C-c>` must cancel the prompt too"
1601 );
1602 }
1603
1604 #[test]
1608 fn typing_reaches_the_prompt_buffer() {
1609 let mut a = app_with_prompt();
1610 for c in "v1.0".chars() {
1611 press(&mut a, KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
1612 a.editor.publish_render_state();
1613 }
1614 assert_eq!(
1615 a.editor.document.text().lines().next().unwrap_or_default(),
1616 "v1.0",
1617 "printable keys must reach the focused prompt buffer"
1618 );
1619 }
1620
1621 #[test]
1624 fn cr_submits_and_leaves_the_prompt() {
1625 let mut a = app_with_prompt();
1626 for c in "abc".chars() {
1627 press(&mut a, KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
1628 a.editor.publish_render_state();
1629 }
1630 press(&mut a, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
1631 assert!(
1632 !matches!(a.editor.modal, ModalState::Prompt),
1633 "`<CR>` must close the prompt even when the submit action is \
1634 unknown — otherwise a typo'd action name is another trap"
1635 );
1636 }
1637
1638 #[test]
1645 fn paste_lands_in_the_prompt_not_the_document_behind_it() {
1646 let mut a = app_with("hello\n", 10);
1647 a.editor.open_prompt_line(
1648 "Clone repository: ".to_string(),
1649 String::new(),
1650 "action:no-such-action".to_string(),
1651 Some("*magit:clone*".to_string()),
1652 );
1653 a.editor.publish_render_state();
1654 a.editor.do_paste_text("https://github.com/owner/repo.git");
1655 assert_eq!(
1656 a.editor.document.text().lines().next().unwrap_or_default(),
1657 "https://github.com/owner/repo.git",
1658 "the pasted URL must reach the prompt buffer"
1659 );
1660 }
1661
1662 #[test]
1665 fn paste_inserts_at_the_cursor_alongside_typed_text() {
1666 let mut a = app_with("hello\n", 10);
1667 a.editor.open_prompt_line(
1668 "Clone into: ".to_string(),
1669 "/tmp/".to_string(),
1670 "action:no-such-action".to_string(),
1671 None,
1672 );
1673 a.editor.publish_render_state();
1674 a.editor.do_paste_text("repo");
1675 assert_eq!(
1676 a.editor.document.text().lines().next().unwrap_or_default(),
1677 "/tmp/repo",
1678 "paste must land at the cursor, after the seeded text"
1679 );
1680 }
1681
1682 #[test]
1687 fn a_pasted_newline_does_not_split_a_one_line_prompt() {
1688 let mut a = app_with("hello\n", 10);
1689 a.editor.open_prompt_line(
1690 "Clone repository: ".to_string(),
1691 String::new(),
1692 "action:no-such-action".to_string(),
1693 None,
1694 );
1695 a.editor.publish_render_state();
1696 a.editor.do_paste_text("https://host/o/r.git\n");
1697 assert_eq!(
1698 a.editor.document.text().lines().count(),
1699 1,
1700 "a trailing newline must not add a second line: {:?}",
1701 a.editor.document.text()
1702 );
1703 assert_eq!(
1704 a.editor.document.text().lines().next().unwrap_or_default(),
1705 "https://host/o/r.git",
1706 "and the value itself survives whole"
1707 );
1708 }
1709
1710 #[test]
1720 fn every_readline_surface_flattens_a_pasted_newline() {
1721 let mut a = app_with("hello\n", 10);
1722 a.apply(Action::EnterCommandLine);
1723 a.editor.publish_render_state();
1724 a.editor.do_paste_text("e A\nB");
1725 assert_eq!(
1726 a.editor.command_line_full_text(),
1727 "e A B",
1728 "the `:` line stays one row"
1729 );
1730
1731 let mut b = app_with("hello\n", 10);
1732 b.apply(Action::EnterSearch(
1733 lattice_grammar::SearchDirection::Forward,
1734 ));
1735 b.editor.publish_render_state();
1736 b.editor.do_paste_text("A\nB");
1737 assert_eq!(
1738 b.editor.document.text().lines().count(),
1739 1,
1740 "the search line stays one row: {:?}",
1741 b.editor.document.text()
1742 );
1743
1744 let mut c = app_with("hello\n", 10);
1745 c.editor.open_prompt_line(
1746 "P: ".to_string(),
1747 String::new(),
1748 "action:no-such-action".to_string(),
1749 None,
1750 );
1751 c.editor.publish_render_state();
1752 c.editor.do_paste_text("A\nB");
1753 assert_eq!(
1754 c.editor.document.text().lines().next().unwrap_or_default(),
1755 "A B",
1756 "and so does the prompt"
1757 );
1758 }
1759
1760 #[test]
1765 fn the_expanded_band_keeps_pasted_newlines() {
1766 let mut a = app_with("hello\n", 10);
1767 a.apply(Action::EnterCommandLine);
1768 a.apply(Action::CommandLineToggleExpand);
1769 a.editor.publish_render_state();
1770 a.editor.do_paste_text("e A\nB");
1771 assert!(
1772 a.editor.command_line_full_text().contains('\n'),
1773 "the band is multi-line by design: {:?}",
1774 a.editor.command_line_full_text()
1775 );
1776 }
1777
1778 fn with_clipboard(app: &mut App, text: &str) {
1791 let cb: lattice_core::ClipboardHandle =
1792 std::sync::Arc::new(lattice_core::FakeClipboard::new());
1793 cb.write(text.to_string());
1794 if let Some(services) = std::sync::Arc::get_mut(&mut app.editor.services) {
1795 services.register(cb);
1796 }
1797 }
1798
1799 #[test]
1801 fn os_paste_inserts_the_clipboard_at_the_cursor() {
1802 let mut a = app_with("hello\n", 10);
1803 with_clipboard(&mut a, " world");
1804 a.editor.cursor = lattice_protocol::position::Position::new(0, 5);
1805 a.editor.do_paste_from_clipboard();
1806 assert_eq!(
1807 a.editor.document.text().lines().next().unwrap_or_default(),
1808 "hello world",
1809 "OS paste inserts literally at the cursor — not vim's `p` \
1810 placement, which is what every other application does with \
1811 this shortcut"
1812 );
1813 }
1814
1815 #[test]
1818 fn os_paste_reaches_an_open_prompt() {
1819 let mut a = app_with("hello\n", 10);
1820 with_clipboard(&mut a, "https://host/o/r.git");
1821 a.editor.open_prompt_line(
1822 "Clone repository: ".to_string(),
1823 String::new(),
1824 "action:no-such-action".to_string(),
1825 None,
1826 );
1827 a.editor.publish_render_state();
1828 a.editor.do_paste_from_clipboard();
1829 assert_eq!(
1830 a.editor.document.text().lines().next().unwrap_or_default(),
1831 "https://host/o/r.git",
1832 "the prompt, not the document behind it"
1833 );
1834 }
1835
1836 #[test]
1839 fn os_paste_into_a_readline_surface_is_flattened() {
1840 let mut a = app_with("hello\n", 10);
1841 with_clipboard(&mut a, "A\nB");
1842 a.apply(Action::EnterCommandLine);
1843 a.editor.publish_render_state();
1844 a.editor.do_paste_from_clipboard();
1845 assert_eq!(
1846 a.editor.command_line_full_text(),
1847 "A B",
1848 "the `:` line stays one row"
1849 );
1850 }
1851
1852 #[test]
1856 fn an_empty_clipboard_does_nothing() {
1857 let mut a = app_with("hello\n", 10);
1858 let before = a.editor.document.text();
1859 a.editor.do_paste_from_clipboard();
1860 assert_eq!(a.editor.document.text(), before);
1861 }
1862
1863 #[test]
1867 fn seeded_text_can_be_edited() {
1868 let mut a = app_with("hello\n", 10);
1869 a.editor.open_prompt_line(
1870 "Clone into: ".to_string(),
1871 "/tmp/repo".to_string(),
1872 "action:no-such-action".to_string(),
1873 None,
1874 );
1875 a.editor.publish_render_state();
1876 press(
1877 &mut a,
1878 KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE),
1879 );
1880 press(
1881 &mut a,
1882 KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE),
1883 );
1884 assert_eq!(
1885 a.editor.document.text().lines().next().unwrap_or_default(),
1886 "/tmp/rep2",
1887 "the pre-filled value must be editable, not read-only"
1888 );
1889 }
1890}