1use std::sync::Arc;
9
10use lattice_picker::{
11 TransientContext, TransientGroup, TransientItem, TransientItemKind, TransientSpec,
12 TransientState, TransientValue,
13};
14use lattice_protocol::ids::CommandId;
15
16use crate::magit_global_mode::RemoteOp;
17
18#[derive(Debug, Clone, Default)]
38pub struct MagitActionIds {
39 by_name: std::collections::HashMap<String, CommandId>,
40}
41
42impl MagitActionIds {
43 const PREFIX: &'static str = "action:magit-";
46
47 pub fn resolve(registry: &lattice_grammar::CommandRegistry) -> Self {
48 let names: Vec<String> = registry
49 .names()
50 .filter(|n| n.starts_with(Self::PREFIX))
51 .map(str::to_string)
52 .collect();
53 let by_name = names
54 .into_iter()
55 .filter_map(|n| registry.id_by_name(&n).map(|id| (n, id)))
56 .collect();
57 Self { by_name }
58 }
59
60 pub fn get(&self, name: &str) -> Option<CommandId> {
61 self.by_name.get(name).copied()
62 }
63
64 pub fn is_empty(&self) -> bool {
65 self.by_name.is_empty()
66 }
67}
68
69pub struct TransientRow {
76 pub key: &'static str,
77 pub label: &'static str,
78 pub doc: &'static str,
79 pub action: &'static str,
80 pub placeholder: &'static str,
81}
82
83fn row_item(ids: &MagitActionIds, row: &TransientRow) -> TransientItem {
86 action_or_placeholder(
87 ids.get(row.action),
88 row.key,
89 row.label,
90 row.doc,
91 row.placeholder,
92 )
93}
94
95fn row_group(label: &str, ids: &MagitActionIds, rows: &'static [TransientRow]) -> TransientGroup {
98 TransientGroup {
99 label: label.into(),
100 items: rows.iter().map(|r| row_item(ids, r)).collect(),
101 }
102}
103
104#[cfg(test)]
111pub(crate) fn all_row_tables() -> &'static [(&'static str, &'static [TransientRow])] {
112 &[
113 ("branch/checkout", BRANCH_CHECKOUT_ROWS),
114 ("branch/create", BRANCH_CREATE_ROWS),
115 ("branch/do", BRANCH_DO_ROWS),
116 ("reset", RESET_ROWS),
117 ("commit", COMMIT_ROWS),
118 ("stash", STASH_ROWS),
119 ("subtree", SUBTREE_ROWS),
120 ("jump", JUMP_ROWS),
121 ("push", PUSH_ROWS),
122 ("pull", PULL_ROWS),
123 ("fetch", FETCH_ROWS),
124 ("cherry-pick", CHERRY_PICK_ROWS),
125 ("cherry-pick/sequence", CHERRY_PICK_SEQUENCE_ROWS),
126 ("revert", REVERT_ROWS),
127 ("revert/sequence", REVERT_SEQUENCE_ROWS),
128 ("merge", MERGE_ROWS),
129 ("merge/sequence", MERGE_SEQUENCE_ROWS),
130 ("tag", TAG_ROWS),
131 ("rebase/start", REBASE_START_ROWS),
132 ("rebase/sequence", REBASE_SEQUENCE_ROWS),
133 ("diff/show", DIFF_SHOW_ROWS),
139 ("log/show", LOG_SHOW_ROWS),
140 ]
141}
142
143const PUSH_ROWS: &[TransientRow] = &[
146 TransientRow {
147 key: "p",
148 label: "pushRemote",
149 doc: "Push to the configured push-remote",
150 action: "action:magit-global-push-configured",
151 placeholder: "push_configured_op",
152 },
153 TransientRow {
154 key: "u",
155 label: "@{upstream}",
156 doc: "Push to this branch's upstream — differs from pushRemote in a triangular workflow",
157 action: "action:magit-global-push-upstream",
158 placeholder: "push_upstream_op",
159 },
160 TransientRow {
161 key: "e",
162 label: "elsewhere",
163 doc: "Push to a remote you name",
164 action: "action:magit-global-push-elsewhere",
165 placeholder: "push_elsewhere_op",
166 },
167 TransientRow {
168 key: "o",
169 label: "another branch",
170 doc: "Push a branch other than HEAD",
171 action: "action:magit-global-push-other-branch",
172 placeholder: "push_other_op",
173 },
174 TransientRow {
175 key: "r",
176 label: "refspecs",
177 doc: "Push explicit refspecs",
178 action: "action:magit-global-push-refspecs",
179 placeholder: "push_refspecs_op",
180 },
181 TransientRow {
182 key: "T",
183 label: "a tag",
184 doc: "Push a single tag",
185 action: "action:magit-global-push-tag",
186 placeholder: "push_tag_op",
187 },
188 TransientRow {
189 key: "t",
190 label: "all tags",
191 doc: "Push every tag",
192 action: "action:magit-global-push-all-tags",
193 placeholder: "push_all_tags_op",
194 },
195];
196
197const PULL_ROWS: &[TransientRow] = &[
198 TransientRow {
199 key: "p",
200 label: "pushRemote",
201 doc: "Pull from the configured remote",
202 action: "action:magit-global-pull-configured",
203 placeholder: "pull_configured_op",
204 },
205 TransientRow {
206 key: "u",
207 label: "@{upstream}",
208 doc: "Pull from this branch's upstream",
209 action: "action:magit-global-pull-upstream",
210 placeholder: "pull_upstream_op",
211 },
212 TransientRow {
213 key: "e",
214 label: "elsewhere",
215 doc: "Pull from a remote you name",
216 action: "action:magit-global-pull-elsewhere",
217 placeholder: "pull_elsewhere_op",
218 },
219];
220
221const FETCH_ROWS: &[TransientRow] = &[
222 TransientRow {
223 key: "p",
224 label: "pushRemote",
225 doc: "Fetch from the configured remote",
226 action: "action:magit-global-fetch-configured",
227 placeholder: "fetch_configured_op",
228 },
229 TransientRow {
230 key: "u",
231 label: "@{upstream}",
232 doc: "Fetch this branch's upstream",
233 action: "action:magit-global-fetch-upstream",
234 placeholder: "fetch_upstream_op",
235 },
236 TransientRow {
237 key: "e",
238 label: "elsewhere",
239 doc: "Fetch from a remote you name",
240 action: "action:magit-global-fetch-elsewhere",
241 placeholder: "fetch_elsewhere_op",
242 },
243 TransientRow {
244 key: "o",
245 label: "another branch",
246 doc: "Fetch a branch you name",
247 action: "action:magit-global-fetch-other-branch",
248 placeholder: "fetch_other_op",
249 },
250 TransientRow {
251 key: "r",
252 label: "refspecs",
253 doc: "Fetch explicit refspecs",
254 action: "action:magit-global-fetch-refspecs",
255 placeholder: "fetch_refspecs_op",
256 },
257 TransientRow {
258 key: "a",
259 label: "all remotes",
260 doc: "Fetch from every configured remote",
261 action: "action:magit-global-fetch-all-remotes",
262 placeholder: "fetch_all_op",
263 },
264 TransientRow {
266 key: "m",
267 label: "submodules",
268 doc: "Fetch the superproject and its submodules",
269 action: "action:magit-global-fetch-submodules",
270 placeholder: "fetch_submodules_op",
271 },
272];
273
274const BRANCH_CHECKOUT_ROWS: &[TransientRow] = &[
281 TransientRow {
282 key: "b",
283 label: "branch/revision",
284 doc: "Check out anything git can: a branch, tag, remote ref or SHA",
285 action: "action:magit-global-branch-checkout-rev",
286 placeholder: "branch_checkout_rev_op",
287 },
288 TransientRow {
289 key: "l",
290 label: "local branch",
291 doc: "Pick a local branch and check it out",
292 action: "action:magit-global-branch-checkout",
293 placeholder: "branch_checkout_op",
294 },
295];
296
297const BRANCH_CREATE_ROWS: &[TransientRow] = &[
298 TransientRow {
299 key: "c",
300 label: "new branch and checkout",
301 doc: "Pick a base, then name a new branch and check it out",
302 action: "action:magit-global-branch-create",
303 placeholder: "branch_create_op",
304 },
305 TransientRow {
306 key: "n",
307 label: "new branch",
308 doc: "Pick a base, then name a new branch — without checking it out",
309 action: "action:magit-global-branch-create-no-checkout",
310 placeholder: "branch_create_no_checkout_op",
311 },
312];
313
314const BRANCH_DO_ROWS: &[TransientRow] = &[
315 TransientRow {
318 key: "s",
319 label: "spin-off",
320 doc: "Branch the unpushed commits and check it out",
321 action: "action:magit-global-branch-spinoff",
322 placeholder: "branch_spinoff_op",
323 },
324 TransientRow {
325 key: "S",
326 label: "spin-out",
327 doc: "Branch the unpushed commits, staying on this branch",
328 action: "action:magit-global-branch-spinout",
329 placeholder: "branch_spinout_op",
330 },
331 TransientRow {
333 key: "x",
334 label: "reset",
335 doc: "Reset the current branch to another ref (asks first)",
336 action: "action:magit-global-branch-reset",
337 placeholder: "branch_reset_op",
338 },
339 TransientRow {
340 key: "m",
341 label: "rename",
342 doc: "Pick a branch, then type its new name",
343 action: "action:magit-global-branch-rename",
344 placeholder: "branch_rename_op",
345 },
346 TransientRow {
350 key: "k",
351 label: "delete",
352 doc: "Pick a branch to delete — asks first",
353 action: "action:magit-global-branch-delete",
354 placeholder: "branch_delete_op",
355 },
356 TransientRow {
357 key: "L",
358 label: "list",
359 doc: "Open the branch list buffer",
360 action: "action:magit-global-branch",
361 placeholder: "branch_op",
362 },
363];
364
365const RESET_ROWS: &[TransientRow] = &[
366 TransientRow {
367 key: "s",
368 label: "soft",
369 doc: "Move HEAD, keep the index and working tree",
370 action: "action:magit-reset-soft",
371 placeholder: "reset_soft_op",
372 },
373 TransientRow {
374 key: "m",
375 label: "mixed",
376 doc: "Move HEAD and reset the index, keep the working tree",
377 action: "action:magit-reset-mixed",
378 placeholder: "reset_mixed_op",
379 },
380 TransientRow {
381 key: "h",
382 label: "hard",
383 doc: "Move HEAD and discard index + working-tree changes",
384 action: "action:magit-reset-hard",
385 placeholder: "reset_hard_op",
386 },
387 TransientRow {
389 key: "k",
390 label: "keep",
391 doc: "Move HEAD but refuse if that would discard uncommitted work",
392 action: "action:magit-reset-keep",
393 placeholder: "reset_keep_op",
394 },
395 TransientRow {
396 key: "i",
397 label: "index",
398 doc: "Set the index to a commit without moving HEAD or touching the working tree",
399 action: "action:magit-reset-index",
400 placeholder: "reset_index_op",
401 },
402 TransientRow {
405 key: "w",
406 label: "worktree",
407 doc: "Reset the working tree to a commit, keeping HEAD and the index",
408 action: "action:magit-reset-worktree",
409 placeholder: "reset_worktree_op",
410 },
411 TransientRow {
413 key: "f",
414 label: "a file",
415 doc: "Restore one file from a commit, leaving everything else alone",
416 action: "action:magit-global-reset-file",
417 placeholder: "reset_file_op",
418 },
419];
420
421const COMMIT_ROWS: &[TransientRow] = &[
422 TransientRow {
423 key: "c",
424 label: "commit",
425 doc: "Commit the staged changes",
426 action: "action:magit-global-commit",
427 placeholder: "commit_op",
428 },
429 TransientRow {
430 key: "a",
431 label: "amend",
432 doc: "Amend the previous commit",
433 action: "action:magit-global-amend",
434 placeholder: "amend_op",
435 },
436 TransientRow {
440 key: "f",
441 label: "fixup",
442 doc: "Record a fixup! commit for a commit you pick",
443 action: "action:magit-commit-fixup",
444 placeholder: "commit_fixup_op",
445 },
446 TransientRow {
447 key: "s",
448 label: "squash",
449 doc: "Record a squash! commit for a commit you pick",
450 action: "action:magit-commit-squash",
451 placeholder: "commit_squash_op",
452 },
453 TransientRow {
457 key: "w",
458 label: "reword",
459 doc: "Change the last commit's message, leaving the index alone",
460 action: "action:magit-global-reword",
461 placeholder: "commit_reword_op",
462 },
463 TransientRow {
465 key: "e",
466 label: "extend",
467 doc: "Add staged changes to the last commit, keeping its message",
468 action: "action:magit-global-commit-extend",
469 placeholder: "commit_extend_op",
470 },
471 TransientRow {
473 key: "A",
474 label: "augment",
475 doc: "Record a squash! for a commit, with a note you write",
476 action: "action:magit-commit-augment",
477 placeholder: "commit_augment_op",
478 },
479 TransientRow {
481 key: "F",
482 label: "instant fixup",
483 doc: "Record a fixup! and fold it in immediately",
484 action: "action:magit-commit-instant-fixup",
485 placeholder: "commit_instant_fixup_op",
486 },
487 TransientRow {
488 key: "S",
489 label: "instant squash",
490 doc: "Record a squash! and fold it in immediately",
491 action: "action:magit-commit-instant-squash",
492 placeholder: "commit_instant_squash_op",
493 },
494];
495
496const STASH_ROWS: &[TransientRow] = &[
497 TransientRow {
498 key: "z",
499 label: "stash",
500 doc: "Stash the working tree and index",
501 action: "action:magit-global-stash-create",
502 placeholder: "stash_create_op",
503 },
504 TransientRow {
505 key: "l",
506 label: "list",
507 doc: "Open the stash list buffer",
508 action: "action:magit-global-stash",
509 placeholder: "stash_op",
510 },
511 TransientRow {
513 key: "i",
514 label: "index",
515 doc: "Stash only the staged changes",
516 action: "action:magit-global-stash-staged",
517 placeholder: "stash_staged_op",
518 },
519 TransientRow {
520 key: "x",
521 label: "keeping index",
522 doc: "Stash everything but leave the index staged",
523 action: "action:magit-global-stash-keep-index",
524 placeholder: "stash_keep_index_op",
525 },
526 TransientRow {
529 key: "Z",
530 label: "snapshot",
531 doc: "Stash everything and put it straight back",
532 action: "action:magit-global-stash-snapshot",
533 placeholder: "stash_snapshot_op",
534 },
535 TransientRow {
536 key: "I",
537 label: "snapshot index",
538 doc: "Snapshot the staged changes, leaving the working tree alone",
539 action: "action:magit-global-stash-snapshot-index",
540 placeholder: "stash_snapshot_index_op",
541 },
542 TransientRow {
543 key: "W",
544 label: "snapshot worktree",
545 doc: "Snapshot the working tree, leaving the index alone",
546 action: "action:magit-global-stash-snapshot-worktree",
547 placeholder: "stash_snapshot_worktree_op",
548 },
549 TransientRow {
555 key: "a",
556 label: "apply",
557 doc: "Apply a stash, keeping it on the stack",
558 action: "action:magit-stash-apply",
559 placeholder: "stash_apply_op",
560 },
561 TransientRow {
562 key: "p",
563 label: "pop",
564 doc: "Apply a stash and drop it",
565 action: "action:magit-stash-pop",
566 placeholder: "stash_pop_op",
567 },
568 TransientRow {
569 key: "k",
570 label: "drop",
571 doc: "Delete a stash without applying it — asks first",
572 action: "action:magit-stash-drop",
573 placeholder: "stash_drop_op",
574 },
575 TransientRow {
576 key: "v",
577 label: "show",
578 doc: "Show a stash's diff",
579 action: "action:magit-stash-show",
580 placeholder: "stash_show_op",
581 },
582 TransientRow {
584 key: "b",
585 label: "branch",
586 doc: "Start a branch from a stash — for when it no longer applies to HEAD",
587 action: "action:magit-global-stash-branch",
588 placeholder: "stash_branch_op",
589 },
590];
591
592const SUBTREE_ROWS: &[TransientRow] = &[
593 TransientRow {
594 key: "a",
595 label: "add",
596 doc: "Add a repository as a subtree at a prefix",
597 action: "action:magit-global-subtree-add",
598 placeholder: "subtree_add_op",
599 },
600 TransientRow {
601 key: "m",
602 label: "merge",
603 doc: "Merge a repository into an existing subtree prefix",
604 action: "action:magit-global-subtree-merge",
605 placeholder: "subtree_merge_op",
606 },
607 TransientRow {
608 key: "f",
609 label: "pull",
610 doc: "Fetch and merge upstream changes into a subtree prefix",
611 action: "action:magit-global-subtree-pull",
612 placeholder: "subtree_pull_op",
613 },
614 TransientRow {
615 key: "p",
616 label: "push",
617 doc: "Push a subtree prefix to its upstream repository",
618 action: "action:magit-global-subtree-push",
619 placeholder: "subtree_push_op",
620 },
621 TransientRow {
622 key: "s",
623 label: "split",
624 doc: "Split a prefix into its own synthetic history",
625 action: "action:magit-global-subtree-split",
626 placeholder: "subtree_split_op",
627 },
628];
629
630fn view_open_transient(
649 title: &str,
650 ids: &MagitActionIds,
651 flags: &'static [crate::magit_global_mode::RemoteFlag],
652 rows: &'static [TransientRow],
653) -> TransientSpec {
654 let mut groups = Vec::new();
655 if !flags.is_empty() {
656 groups.push(TransientGroup {
657 label: "Arguments".into(),
658 items: flag_items_from(flags),
659 });
660 }
661 groups.push(TransientGroup {
662 label: "Show".into(),
663 items: rows.iter().map(|r| row_item(ids, r)).collect(),
664 });
665 TransientSpec {
666 title: title.into(),
667 groups,
668 preview: None,
669 footer: Some("q dismiss Esc/BS back".into()),
670 }
671}
672
673const DIFF_SHOW_ROWS: &[TransientRow] = &[
682 TransientRow {
683 key: "d",
684 label: "diff",
685 doc: "Diff the working tree against HEAD",
686 action: "action:magit-global-diff",
687 placeholder: "diff_op",
688 },
689 TransientRow {
690 key: "f",
691 label: "file",
692 doc: "Diff the file at cursor in a dedicated buffer",
693 action: "action:magit-diff-file",
694 placeholder: "diff_file",
695 },
696 TransientRow {
697 key: "v",
698 label: "side-by-side",
699 doc: "Open the file at cursor side-by-side against its baseline",
700 action: "action:magit-diff-side-by-side",
701 placeholder: "diff_side_by_side",
702 },
703 TransientRow {
712 key: "e",
713 label: "edit",
714 doc: "Edit the working-tree diff across files",
715 action: "action:magit-project-diff",
716 placeholder: "diff_project",
717 },
718];
719
720const LOG_SHOW_ROWS: &[TransientRow] = &[TransientRow {
721 key: "l",
722 label: "log",
723 doc: "Show commit history",
724 action: "action:magit-global-log",
725 placeholder: "show_log",
726}];
727
728const CHERRY_PICK_ROWS: &[TransientRow] = &[
735 TransientRow {
736 key: "A",
737 label: "pick",
738 doc: "Cherry-pick a commit onto this branch",
739 action: "action:magit-cherry-pick",
740 placeholder: "cherry_pick_op",
741 },
742 TransientRow {
745 key: "a",
746 label: "apply",
747 doc: "Apply a commit's changes without committing",
748 action: "action:magit-cherry-pick-apply",
749 placeholder: "cherry_pick_apply_op",
750 },
751 TransientRow {
754 key: "h",
755 label: "harvest",
756 doc: "Move a commit here from another branch, removing it there",
757 action: "action:magit-cherry-harvest",
758 placeholder: "cherry_harvest_op",
759 },
760 TransientRow {
761 key: "d",
762 label: "donate",
763 doc: "Move a commit to another branch, staying on this one",
764 action: "action:magit-cherry-donate",
765 placeholder: "cherry_donate_op",
766 },
767 TransientRow {
768 key: "n",
769 label: "spinout",
770 doc: "Move a commit to a new branch, staying on this one",
771 action: "action:magit-cherry-spinout",
772 placeholder: "cherry_spinout_op",
773 },
774 TransientRow {
775 key: "s",
776 label: "spinoff",
777 doc: "Move a commit to a new branch and check it out",
778 action: "action:magit-cherry-spinoff",
779 placeholder: "cherry_spinoff_op",
780 },
781];
782
783const CHERRY_PICK_SEQUENCE_ROWS: &[TransientRow] = &[
784 TransientRow {
785 key: "A",
786 label: "continue",
787 doc: "Resume the cherry-pick after resolving the conflict",
788 action: "action:magit-global-cherry-pick-continue",
789 placeholder: "cherry_pick_continue_op",
790 },
791 TransientRow {
792 key: "s",
793 label: "skip",
794 doc: "Skip the commit the cherry-pick stopped on",
795 action: "action:magit-global-cherry-pick-skip",
796 placeholder: "cherry_pick_skip_op",
797 },
798 TransientRow {
799 key: "a",
800 label: "abort",
801 doc: "Abandon the cherry-pick, restoring the branch",
802 action: "action:magit-global-cherry-pick-abort",
803 placeholder: "cherry_pick_abort_op",
804 },
805];
806
807const REVERT_ROWS: &[TransientRow] = &[
808 TransientRow {
809 key: "V",
810 label: "revert commit",
811 doc: "Revert a commit, creating an inverse commit",
812 action: "action:magit-revert",
813 placeholder: "revert_op",
814 },
815 TransientRow {
817 key: "v",
818 label: "revert changes",
819 doc: "Apply the inverse of a commit without committing",
820 action: "action:magit-revert-changes",
821 placeholder: "revert_changes_op",
822 },
823];
824
825const REVERT_SEQUENCE_ROWS: &[TransientRow] = &[
826 TransientRow {
827 key: "V",
828 label: "continue",
829 doc: "Resume the revert after resolving the conflict",
830 action: "action:magit-global-revert-continue",
831 placeholder: "revert_continue_op",
832 },
833 TransientRow {
834 key: "s",
835 label: "skip",
836 doc: "Skip the commit the revert stopped on",
837 action: "action:magit-global-revert-skip",
838 placeholder: "revert_skip_op",
839 },
840 TransientRow {
841 key: "a",
842 label: "abort",
843 doc: "Abandon the revert, restoring the branch",
844 action: "action:magit-global-revert-abort",
845 placeholder: "revert_abort_op",
846 },
847];
848
849const MERGE_ROWS: &[TransientRow] = &[
851 TransientRow {
852 key: "m",
853 label: "merge",
854 doc: "Merge a branch into the current one",
855 action: "action:magit-global-merge",
856 placeholder: "merge_op",
857 },
858 TransientRow {
859 key: "n",
860 label: "merge, don't commit",
861 doc: "Merge but stop before committing, so the result can be inspected first",
862 action: "action:magit-global-merge-no-commit",
863 placeholder: "merge_no_commit_op",
864 },
865 TransientRow {
866 key: "s",
867 label: "squash",
868 doc: "Take the branch's changes as one staged change, with no merge commit",
869 action: "action:magit-global-merge-squash",
870 placeholder: "merge_squash_op",
871 },
872 TransientRow {
874 key: "e",
875 label: "merge and edit message",
876 doc: "Merge a branch, writing the merge message yourself",
877 action: "action:magit-global-merge-edit",
878 placeholder: "merge_edit_op",
879 },
880 TransientRow {
884 key: "p",
885 label: "preview",
886 doc: "Show what merging a branch would bring in",
887 action: "action:magit-global-merge-preview",
888 placeholder: "merge_preview_op",
889 },
890 TransientRow {
893 key: "i",
894 label: "merge into",
895 doc: "Merge this branch into another, then delete this one",
896 action: "action:magit-global-merge-into",
897 placeholder: "merge_into_op",
898 },
899 TransientRow {
901 key: "a",
902 label: "absorb",
903 doc: "Merge a branch and delete it — the delete is refused if the merge did not take",
904 action: "action:magit-global-merge-absorb",
905 placeholder: "merge_absorb_op",
906 },
907];
908
909const MERGE_SEQUENCE_ROWS: &[TransientRow] = &[
928 TransientRow {
929 key: "m",
930 label: "continue",
931 doc: "Conclude the merge after resolving the conflict",
932 action: "action:magit-global-merge-continue",
933 placeholder: "merge_continue_op",
934 },
935 TransientRow {
936 key: "a",
937 label: "abort",
938 doc: "Abandon the merge, restoring the branch as it was",
939 action: "action:magit-global-merge-abort",
940 placeholder: "merge_abort_op",
941 },
942];
943
944const TAG_ROWS: &[TransientRow] = &[
946 TransientRow {
949 key: "r",
950 label: "release",
951 doc: "Create an annotated release tag (asks name and message)",
952 action: "action:magit-global-tag-release",
953 placeholder: "tag_release_op",
954 },
955 TransientRow {
957 key: "p",
958 label: "prune",
959 doc: "Drop local tags that no longer exist on the remote",
960 action: "action:magit-global-tag-prune",
961 placeholder: "tag_prune_op",
962 },
963 TransientRow {
964 key: "t",
965 label: "tag",
966 doc: "Tag HEAD with a name you type",
967 action: "action:magit-global-tag",
968 placeholder: "tag_op",
969 },
970 TransientRow {
971 key: "k",
972 label: "delete",
973 doc: "Delete a local tag — the remote copy is untouched",
974 action: "action:magit-global-tag-delete",
975 placeholder: "tag_delete_op",
976 },
977];
978
979const REBASE_START_ROWS: &[TransientRow] = &[
981 TransientRow {
984 key: "p",
985 label: "onto pushRemote",
986 doc: "Rebase this branch onto its push target",
987 action: "action:magit-global-rebase-onto-push",
988 placeholder: "rebase_onto_push_op",
989 },
990 TransientRow {
991 key: "u",
992 label: "onto @{upstream}",
993 doc: "Rebase this branch onto its upstream",
994 action: "action:magit-global-rebase-onto-upstream",
995 placeholder: "rebase_onto_upstream_op",
996 },
997 TransientRow {
998 key: "e",
999 label: "onto elsewhere",
1000 doc: "Rebase this branch onto a ref you name",
1001 action: "action:magit-global-rebase-onto-elsewhere",
1002 placeholder: "rebase_onto_elsewhere_op",
1003 },
1004 TransientRow {
1005 key: "s",
1006 label: "a subset",
1007 doc: "Replay the commits after one ref onto another",
1008 action: "action:magit-global-rebase-subset",
1009 placeholder: "rebase_subset_op",
1010 },
1011 TransientRow {
1014 key: "m",
1015 label: "edit a commit",
1016 doc: "Replay history, stopping at a commit so you can change it",
1017 action: "action:magit-rebase-edit-commit",
1018 placeholder: "rebase_edit_commit_op",
1019 },
1020 TransientRow {
1021 key: "w",
1022 label: "reword a commit",
1023 doc: "Change an older commit's message",
1024 action: "action:magit-rebase-reword-commit",
1025 placeholder: "rebase_reword_commit_op",
1026 },
1027 TransientRow {
1028 key: "k",
1029 label: "remove a commit",
1030 doc: "Replay history without a commit",
1031 action: "action:magit-rebase-remove-commit",
1032 placeholder: "rebase_remove_commit_op",
1033 },
1034 TransientRow {
1035 key: "f",
1036 label: "autosquash",
1037 doc: "Replay, folding in fixup! and squash! markers",
1038 action: "action:magit-global-rebase-autosquash",
1039 placeholder: "rebase_autosquash_op",
1040 },
1041 TransientRow {
1042 key: "i",
1043 label: "interactively",
1044 doc: "Start an interactive rebase — pick a base, then edit the todo list",
1045 action: "action:magit-global-rebase",
1046 placeholder: "rebase_op",
1047 },
1048];
1049
1050const REBASE_SEQUENCE_ROWS: &[TransientRow] = &[
1056 TransientRow {
1057 key: "r",
1058 label: "continue",
1059 doc: "Resume after amending or resolving conflicts",
1060 action: "action:magit-global-rebase-continue",
1061 placeholder: "rebase_continue_op",
1062 },
1063 TransientRow {
1064 key: "s",
1065 label: "skip",
1066 doc: "Skip the commit the rebase stopped on",
1067 action: "action:magit-global-rebase-skip",
1068 placeholder: "rebase_skip_op",
1069 },
1070 TransientRow {
1071 key: "a",
1072 label: "abort",
1073 doc: "Abandon the rebase, restoring the branch to where it started",
1074 action: "action:magit-global-rebase-abort",
1075 placeholder: "rebase_abort_op",
1076 },
1077];
1078
1079const JUMP_ROWS: &[TransientRow] = &[
1080 TransientRow {
1081 key: "s",
1082 label: "staged",
1083 doc: "Jump to the staged-changes section",
1084 action: "action:magit-jump-staged",
1085 placeholder: "jump_staged_op",
1086 },
1087 TransientRow {
1088 key: "u",
1089 label: "unstaged",
1090 doc: "Jump to the unstaged-changes section",
1091 action: "action:magit-jump-unstaged",
1092 placeholder: "jump_unstaged_op",
1093 },
1094 TransientRow {
1095 key: "n",
1096 label: "untracked",
1097 doc: "Jump to the untracked-files section",
1098 action: "action:magit-jump-untracked",
1099 placeholder: "jump_untracked_op",
1100 },
1101 TransientRow {
1102 key: "z",
1103 label: "stashes",
1104 doc: "Jump to the stashes section",
1105 action: "action:magit-jump-stashes",
1106 placeholder: "jump_stashes_op",
1107 },
1108 TransientRow {
1112 key: "m",
1113 label: "unmerged",
1114 doc: "Jump to the unmerged-into-upstream section",
1115 action: "action:magit-jump-unmerged",
1116 placeholder: "jump_unmerged_op",
1117 },
1118 TransientRow {
1119 key: "c",
1120 label: "commits",
1121 doc: "Jump to the recent-commits section",
1122 action: "action:magit-jump-commits",
1123 placeholder: "jump_commits_op",
1124 },
1125];
1126
1127fn flag_items(op: RemoteOp) -> Vec<TransientItem> {
1130 flag_items_from(op.flags)
1131}
1132
1133fn flag_items_from(flags: &'static [crate::magit_global_mode::RemoteFlag]) -> Vec<TransientItem> {
1136 use crate::magit_global_mode::RemoteArgKind;
1137 flags
1138 .iter()
1139 .map(|f| TransientItem {
1140 key: vec![f.key.to_string()],
1141 label: f.arg.to_string(),
1142 description: f.doc.to_string(),
1143 kind: match f.kind {
1144 RemoteArgKind::Flag => TransientItemKind::Flag {
1145 name: f.name.to_string(),
1146 default: false,
1147 },
1148 RemoteArgKind::Value { prompt } | RemoteArgKind::ValueJoined { prompt } => {
1151 TransientItemKind::Argument {
1152 name: f.name.to_string(),
1153 default: None,
1154 prompt: prompt.to_string(),
1155 source: None,
1161 }
1162 }
1163 },
1164 })
1165 .collect()
1166}
1167
1168fn remote_preview(op: RemoteOp) -> Box<dyn Fn(&TransientState) -> String + Send + Sync> {
1173 Box::new(move |state: &TransientState| {
1174 op.preview(&|name| match state.get(name) {
1175 Some(TransientValue::Bool(b)) => Some(b.to_string()),
1176 Some(TransientValue::String(v)) => Some(v.clone()),
1177 None => None,
1178 })
1179 })
1180}
1181
1182fn remote_op_transient(
1190 title: &str,
1191 op: RemoteOp,
1192 ids: &MagitActionIds,
1193 rows: &'static [TransientRow],
1194 config_rows: &'static [ConfigRow],
1195 workdir: &std::path::Path,
1196) -> TransientSpec {
1197 let mut groups = Vec::new();
1198 let flags = flag_items(op);
1199 if !flags.is_empty() {
1200 groups.push(TransientGroup {
1201 label: "Arguments".into(),
1202 items: flags,
1203 });
1204 }
1205 groups.push(row_group("Destination", ids, rows));
1208 groups.extend(config_group(ids, config_rows, workdir));
1210 TransientSpec {
1211 title: title.into(),
1212 groups,
1213 preview: Some(remote_preview(op)),
1214 footer: Some("q dismiss Esc/BS back".into()),
1215 }
1216}
1217
1218#[derive(Debug, Clone, Copy, Default)]
1227pub struct DispatchActionIds {
1228 pub status: Option<CommandId>,
1229 pub commit: Option<CommandId>,
1230 pub amend: Option<CommandId>,
1231 pub log: Option<CommandId>,
1232 pub diff: Option<CommandId>,
1233 pub branch: Option<CommandId>,
1234 pub branch_checkout: Option<CommandId>,
1236 pub branch_create: Option<CommandId>,
1237 pub branch_checkout_rev: Option<CommandId>,
1240 pub branch_create_no_checkout: Option<CommandId>,
1241 pub branch_rename: Option<CommandId>,
1242 pub branch_delete: Option<CommandId>,
1243 pub remote: Option<CommandId>,
1245 pub view_args: Option<CommandId>,
1247 pub submodule: Option<CommandId>,
1249 pub refs: Option<CommandId>,
1251 pub clone: Option<CommandId>,
1253 pub note_edit: Option<CommandId>,
1255 pub note_remove: Option<CommandId>,
1256 pub note_prune: Option<CommandId>,
1257 pub note_merge: Option<CommandId>,
1258 pub note_merge_commit: Option<CommandId>,
1261 pub note_merge_abort: Option<CommandId>,
1262 pub subtree_add: Option<CommandId>,
1264 pub subtree_merge: Option<CommandId>,
1265 pub subtree_pull: Option<CommandId>,
1266 pub subtree_push: Option<CommandId>,
1267 pub subtree_split: Option<CommandId>,
1268 pub am_apply: Option<CommandId>,
1271 pub am_continue: Option<CommandId>,
1272 pub am_skip: Option<CommandId>,
1273 pub am_abort: Option<CommandId>,
1274 pub format_patch: Option<CommandId>,
1275 pub cherries: Option<CommandId>,
1277 pub bisect_start: Option<CommandId>,
1280 pub bisect_good: Option<CommandId>,
1281 pub bisect_bad: Option<CommandId>,
1282 pub bisect_skip: Option<CommandId>,
1283 pub bisect_reset: Option<CommandId>,
1284 pub stash: Option<CommandId>,
1285 pub stash_create: Option<CommandId>,
1286 pub rebase: Option<CommandId>,
1287 pub fetch: Option<CommandId>,
1288 pub pull: Option<CommandId>,
1289 pub push: Option<CommandId>,
1290 pub stage_all: Option<CommandId>,
1292 pub unstage_all: Option<CommandId>,
1293 pub tag: Option<CommandId>,
1295 pub gitignore: Option<CommandId>,
1296 pub init: Option<CommandId>,
1298 pub merge: Option<CommandId>,
1299 pub apply_hunk: Option<CommandId>,
1303 pub reverse_hunk: Option<CommandId>,
1304 pub discard: Option<CommandId>,
1305 pub cherry_pick: Option<CommandId>,
1309 pub revert: Option<CommandId>,
1310 pub reset_soft: Option<CommandId>,
1311 pub reset_mixed: Option<CommandId>,
1312 pub reset_hard: Option<CommandId>,
1313 pub jump_staged: Option<CommandId>,
1315 pub jump_unstaged: Option<CommandId>,
1316 pub jump_untracked: Option<CommandId>,
1317 pub jump_stashes: Option<CommandId>,
1318 pub jump_commits: Option<CommandId>,
1319}
1320
1321fn action_or_placeholder(
1327 id: Option<CommandId>,
1328 key: &str,
1329 label: &str,
1330 description: &str,
1331 placeholder_name: &str,
1332) -> TransientItem {
1333 let kind = match id {
1334 Some(cid) => TransientItemKind::action(cid),
1335 None => TransientItemKind::Flag {
1336 name: placeholder_name.to_string(),
1337 default: false,
1338 },
1339 };
1340 TransientItem {
1341 key: vec![key.to_string()],
1342 label: label.to_string(),
1343 description: description.to_string(),
1344 kind,
1345 }
1346}
1347
1348fn variable_item(
1359 ids: &MagitActionIds,
1360 workdir: &std::path::Path,
1361 key_chord: &str,
1362 label: &str,
1363 description: &str,
1364 config_key: &str,
1365 action: &str,
1366 placeholder_name: &str,
1367) -> TransientItem {
1368 let Some(cid) = ids.get(action) else {
1369 return action_or_placeholder(None, key_chord, label, description, placeholder_name);
1370 };
1371 TransientItem {
1372 key: vec![key_chord.to_string()],
1373 label: label.to_string(),
1374 description: description.to_string(),
1375 kind: TransientItemKind::Variable {
1376 key: config_key.to_string(),
1377 value: crate::git_config::value_of(workdir, config_key),
1378 action: cid,
1379 },
1380 }
1381}
1382
1383pub(crate) struct ConfigRow {
1389 pub key: &'static str,
1390 pub label: &'static str,
1391 pub config_key: &'static str,
1392 pub action: &'static str,
1397}
1398
1399pub(crate) const BRANCH_CONFIG_ROWS: &[ConfigRow] = &[ConfigRow {
1400 key: "C",
1401 label: "rebase on pull",
1402 config_key: "pull.rebase",
1403 action: "action:magit-config-pull-rebase",
1404}];
1405
1406pub(crate) const PUSH_CONFIG_ROWS: &[ConfigRow] = &[ConfigRow {
1407 key: "C",
1408 label: "default push target",
1409 config_key: "remote.pushDefault",
1410 action: "action:magit-config-push-default",
1411}];
1412
1413pub(crate) const PULL_CONFIG_ROWS: &[ConfigRow] = &[ConfigRow {
1414 key: "C",
1415 label: "rebase on pull",
1416 config_key: "pull.rebase",
1417 action: "action:magit-config-pull-rebase",
1418}];
1419
1420pub(crate) const FETCH_CONFIG_ROWS: &[ConfigRow] = &[ConfigRow {
1421 key: "C",
1422 label: "prune on fetch",
1423 config_key: "fetch.prune",
1424 action: "action:magit-config-fetch-prune",
1425}];
1426
1427pub(crate) const TAG_CONFIG_ROWS: &[ConfigRow] = &[ConfigRow {
1428 key: "C",
1429 label: "sign tags",
1430 config_key: "tag.gpgSign",
1431 action: "action:magit-config-tag-sign",
1432}];
1433
1434pub(crate) const NOTES_CONFIG_ROWS: &[ConfigRow] = &[ConfigRow {
1435 key: "C",
1436 label: "notes ref",
1437 config_key: "core.notesRef",
1438 action: "action:magit-config-notes-ref",
1439}];
1440
1441#[cfg(test)]
1444pub(crate) fn all_config_tables() -> &'static [(&'static str, &'static [ConfigRow])] {
1445 &[
1446 ("branch", BRANCH_CONFIG_ROWS),
1447 ("push", PUSH_CONFIG_ROWS),
1448 ("pull", PULL_CONFIG_ROWS),
1449 ("fetch", FETCH_CONFIG_ROWS),
1450 ("tag", TAG_CONFIG_ROWS),
1451 ("notes", NOTES_CONFIG_ROWS),
1452 ]
1453}
1454
1455fn config_group(
1458 ids: &MagitActionIds,
1459 rows: &'static [ConfigRow],
1460 workdir: &std::path::Path,
1461) -> Option<TransientGroup> {
1462 if rows.is_empty() {
1463 return None;
1464 }
1465 Some(TransientGroup {
1466 label: "Configure".into(),
1467 items: rows
1468 .iter()
1469 .map(|r| {
1470 variable_item(
1471 ids,
1472 workdir,
1473 r.key,
1474 r.label,
1475 "Change this setting for the repository",
1476 r.config_key,
1477 r.action,
1478 "config_op",
1479 )
1480 })
1481 .collect(),
1482 })
1483}
1484
1485fn applying_changes_items(ids: &MagitActionIds, ctx: &TransientContext) -> Vec<TransientItem> {
1489 let mut items = Vec::new();
1490 if ctx.has_minor(crate::MagitCoreMode::mode_id().as_str()) {
1491 items.push(action_or_placeholder(
1492 ids.get("action:magit-apply-hunk"),
1493 "a",
1494 "apply",
1495 "Apply the hunk at cursor to the working tree",
1496 "apply_hunk",
1497 ));
1498 items.push(action_or_placeholder(
1499 ids.get("action:magit-reverse-hunk"),
1500 "-",
1501 "reverse",
1502 "Reverse the hunk at cursor out of the working tree",
1503 "reverse_hunk",
1504 ));
1505 items.push(action_or_placeholder(
1506 ids.get("action:magit-discard"),
1507 "x",
1508 "discard",
1509 "Discard the hunk or file at cursor (asks first)",
1510 "discard_at_cursor",
1511 ));
1512 }
1513 items.push(action_or_placeholder(
1514 ids.get("action:magit-global-stage-all"),
1515 "S",
1516 "stage all",
1517 "Stage every tracked modification (git add --update)",
1518 "stage_all_op",
1519 ));
1520 items.push(action_or_placeholder(
1521 ids.get("action:magit-global-unstage-all"),
1522 "U",
1523 "unstage all",
1524 "Unstage everything, keeping your working tree (git reset)",
1525 "unstage_all_op",
1526 ));
1527 items
1528}
1529
1530fn reset_transient(ids: &MagitActionIds) -> TransientSpec {
1539 TransientSpec {
1540 title: "Reset".into(),
1541 groups: vec![row_group("Reset", ids, RESET_ROWS)],
1542 preview: None,
1543 footer: Some("q dismiss Esc/BS back".into()),
1544 }
1545}
1546
1547fn branch_transient(ids: &MagitActionIds, workdir: &std::path::Path) -> TransientSpec {
1586 TransientSpec {
1587 title: "Branch".into(),
1588 groups: vec![
1589 row_group("Checkout", ids, BRANCH_CHECKOUT_ROWS),
1590 row_group("Create", ids, BRANCH_CREATE_ROWS),
1591 row_group("Do", ids, BRANCH_DO_ROWS),
1592 ]
1593 .into_iter()
1594 .chain(config_group(ids, BRANCH_CONFIG_ROWS, workdir))
1595 .collect(),
1596 preview: None,
1597 footer: Some("q dismiss Esc/BS back".into()),
1598 }
1599}
1600
1601fn notes_transient(
1619 ids: &MagitActionIds,
1620 workdir: &std::path::Path,
1621 merge_in_progress: bool,
1622) -> TransientSpec {
1623 let groups = if merge_in_progress {
1624 vec![TransientGroup {
1625 label: "Notes merge in progress".into(),
1626 items: vec![
1627 action_or_placeholder(
1628 ids.get("action:magit-global-note-merge-commit"),
1629 "c",
1630 "commit merge",
1631 "Finish the notes merge, keeping the resolved notes",
1632 "note_merge_commit_op",
1633 ),
1634 action_or_placeholder(
1635 ids.get("action:magit-global-note-merge-abort"),
1636 "a",
1637 "abort merge",
1638 "Abandon the notes merge, restoring the notes ref",
1639 "note_merge_abort_op",
1640 ),
1641 ],
1642 }]
1643 } else {
1644 vec![TransientGroup {
1645 label: "Notes".into(),
1646 items: vec![
1647 action_or_placeholder(
1648 ids.get("action:magit-global-note-edit"),
1649 "T",
1650 "edit",
1651 "Edit the note on a commit — opens an editable buffer",
1652 "note_edit_op",
1653 ),
1654 action_or_placeholder(
1655 ids.get("action:magit-global-note-remove"),
1656 "r",
1657 "remove",
1658 "Remove the note from a commit",
1659 "note_remove_op",
1660 ),
1661 action_or_placeholder(
1662 ids.get("action:magit-global-note-merge"),
1663 "m",
1664 "merge",
1665 "Merge another notes ref into this one",
1666 "note_merge_op",
1667 ),
1668 action_or_placeholder(
1669 ids.get("action:magit-global-note-prune"),
1670 "p",
1671 "prune",
1672 "Drop notes whose commit no longer exists (asks first)",
1673 "note_prune_op",
1674 ),
1675 ],
1676 }]
1677 };
1678 let groups: Vec<TransientGroup> = if merge_in_progress {
1683 groups
1684 } else {
1685 groups
1686 .into_iter()
1687 .chain(config_group(ids, NOTES_CONFIG_ROWS, workdir))
1688 .collect()
1689 };
1690 TransientSpec {
1691 title: "Notes".into(),
1692 groups,
1693 preview: None,
1694 footer: Some("q dismiss Esc/BS back".into()),
1695 }
1696}
1697
1698fn subtree_transient(ids: &MagitActionIds) -> TransientSpec {
1711 TransientSpec {
1712 title: "Subtree".into(),
1713 groups: vec![row_group("Actions", ids, SUBTREE_ROWS)],
1714 preview: None,
1715 footer: Some("q dismiss Esc/BS back".into()),
1716 }
1717}
1718
1719fn patch_transient(ids: &MagitActionIds, am_in_progress: bool) -> TransientSpec {
1729 let groups = if am_in_progress {
1730 vec![TransientGroup {
1731 label: "Patch application stopped".into(),
1732 items: vec![
1733 action_or_placeholder(
1734 ids.get("action:magit-global-am-continue"),
1735 "c",
1736 "continue",
1737 "Resume applying after resolving the conflict",
1738 "am_continue_op",
1739 ),
1740 action_or_placeholder(
1741 ids.get("action:magit-global-am-skip"),
1742 "s",
1743 "skip",
1744 "Skip the patch that would not apply",
1745 "am_skip_op",
1746 ),
1747 action_or_placeholder(
1748 ids.get("action:magit-global-am-abort"),
1749 "a",
1750 "abort",
1751 "Abandon the whole apply, restoring the branch",
1752 "am_abort_op",
1753 ),
1754 ],
1755 }]
1756 } else {
1757 vec![TransientGroup {
1758 label: "Patches".into(),
1759 items: vec![
1760 action_or_placeholder(
1761 ids.get("action:magit-global-am-apply"),
1762 "w",
1763 "apply patches",
1764 "Apply a mailbox of patches (git am)",
1765 "am_apply_op",
1766 ),
1767 action_or_placeholder(
1768 ids.get("action:magit-global-format-patch"),
1769 "W",
1770 "create patches",
1771 "Write a commit range out as .patch files",
1772 "format_patch_op",
1773 ),
1774 ],
1775 }]
1776 };
1777 TransientSpec {
1778 title: "Patches".into(),
1779 groups,
1780 preview: None,
1781 footer: Some("q dismiss Esc/BS back".into()),
1782 }
1783}
1784
1785pub fn view_arguments_transient(ids: &MagitActionIds, ctx: &TransientContext) -> TransientSpec {
1813 let (title, flags) = if ctx.is_major(crate::MagitDiffMode::mode_id().as_str()) {
1814 ("Diff arguments", crate::magit_diff_mode::DIFF_ARGS)
1815 } else if ctx.is_major(crate::MagitLogMode::mode_id().as_str()) {
1816 ("Log arguments", crate::magit_log_mode::LOG_ARGS)
1817 } else {
1818 ("Arguments", &[] as &[crate::magit_global_mode::RemoteFlag])
1819 };
1820
1821 if flags.is_empty() {
1822 return TransientSpec {
1823 title: title.into(),
1824 groups: vec![TransientGroup {
1825 label: "This buffer takes no arguments".into(),
1826 items: Vec::new(),
1827 }],
1828 preview: None,
1829 footer: Some("q dismiss".into()),
1830 };
1831 }
1832
1833 TransientSpec {
1834 title: title.into(),
1835 groups: vec![
1836 TransientGroup {
1837 label: "Arguments".into(),
1838 items: flag_items_from(flags),
1839 },
1840 TransientGroup {
1841 label: "Actions".into(),
1842 items: vec![action_or_placeholder(
1843 ids.get("action:magit-view-refresh-args"),
1844 "g",
1845 "refresh",
1846 "Re-run with these arguments",
1847 "view_args_op",
1848 )],
1849 },
1850 ],
1851 preview: None,
1852 footer: Some("q dismiss Esc/BS back".into()),
1853 }
1854}
1855
1856fn bisect_transient(ids: &MagitActionIds, in_progress: bool) -> TransientSpec {
1866 let items = if in_progress {
1867 vec![
1868 action_or_placeholder(
1869 ids.get("action:magit-global-bisect-good"),
1870 "g",
1871 "good",
1872 "Mark the revision git checked out as good",
1873 "bisect_good_op",
1874 ),
1875 action_or_placeholder(
1876 ids.get("action:magit-global-bisect-bad"),
1877 "b",
1878 "bad",
1879 "Mark the revision git checked out as bad",
1880 "bisect_bad_op",
1881 ),
1882 action_or_placeholder(
1883 ids.get("action:magit-global-bisect-skip"),
1884 "k",
1885 "skip",
1886 "Skip this revision — it cannot be tested",
1887 "bisect_skip_op",
1888 ),
1889 action_or_placeholder(
1890 ids.get("action:magit-global-bisect-reset"),
1891 "r",
1892 "reset",
1893 "End the bisect and return to where it started",
1894 "bisect_reset_op",
1895 ),
1896 ]
1897 } else {
1898 vec![action_or_placeholder(
1899 ids.get("action:magit-global-bisect-start"),
1900 "B",
1901 "start",
1902 "Start a bisect — asks for a bad then a good revision",
1903 "bisect_start_op",
1904 )]
1905 };
1906
1907 TransientSpec {
1908 title: if in_progress {
1909 "Bisect (in progress)".into()
1910 } else {
1911 "Bisect".into()
1912 },
1913 groups: vec![TransientGroup {
1914 label: "Actions".into(),
1915 items,
1916 }],
1917 preview: None,
1918 footer: Some("q dismiss Esc/BS back".into()),
1919 }
1920}
1921
1922fn status_row(ids: &MagitActionIds, ctx: &TransientContext) -> TransientItem {
1935 if ctx.is_major(crate::MagitStatusMode::mode_id().as_str()) {
1936 return TransientItem {
1937 key: vec!["s".into()],
1938 label: "jump".into(),
1939 description: "Jump to a section of this buffer".into(),
1940 kind: TransientItemKind::Submenu(Arc::new(jump_transient(ids))),
1941 };
1942 }
1943 action_or_placeholder(
1944 ids.get("action:magit-global-status"),
1945 "s",
1946 "status",
1947 "Open the status buffer",
1948 "status_op",
1949 )
1950}
1951
1952fn cherry_pick_transient(ids: &MagitActionIds, in_progress: bool) -> TransientSpec {
1961 let groups = if in_progress {
1962 vec![row_group(
1963 "Cherry-pick in progress",
1964 ids,
1965 CHERRY_PICK_SEQUENCE_ROWS,
1966 )]
1967 } else {
1968 vec![row_group("Cherry-pick", ids, CHERRY_PICK_ROWS)]
1969 };
1970 TransientSpec {
1971 title: "Cherry-pick".into(),
1972 groups,
1973 preview: None,
1974 footer: Some("q dismiss Esc/BS back".into()),
1975 }
1976}
1977
1978fn revert_transient(ids: &MagitActionIds, in_progress: bool) -> TransientSpec {
1980 let groups = if in_progress {
1981 vec![row_group("Revert in progress", ids, REVERT_SEQUENCE_ROWS)]
1982 } else {
1983 vec![row_group("Revert", ids, REVERT_ROWS)]
1984 };
1985 TransientSpec {
1986 title: "Revert".into(),
1987 groups,
1988 preview: None,
1989 footer: Some("q dismiss Esc/BS back".into()),
1990 }
1991}
1992
1993fn merge_transient(ids: &MagitActionIds, in_progress: bool) -> TransientSpec {
1994 let groups = if in_progress {
1995 vec![row_group("Merge in progress", ids, MERGE_SEQUENCE_ROWS)]
1996 } else {
1997 vec![row_group("Merge", ids, MERGE_ROWS)]
1998 };
1999 TransientSpec {
2000 title: "Merge".into(),
2001 groups,
2002 preview: None,
2003 footer: Some("q dismiss Esc/BS back".into()),
2004 }
2005}
2006
2007fn tag_transient(ids: &MagitActionIds, workdir: &std::path::Path) -> TransientSpec {
2008 TransientSpec {
2009 title: "Tag".into(),
2010 groups: vec![row_group("Tag", ids, TAG_ROWS)]
2011 .into_iter()
2012 .chain(config_group(ids, TAG_CONFIG_ROWS, workdir))
2013 .collect(),
2014 preview: None,
2015 footer: Some("q dismiss Esc/BS back".into()),
2016 }
2017}
2018
2019fn rebase_transient(ids: &MagitActionIds, in_progress: bool) -> TransientSpec {
2020 let groups = if in_progress {
2021 vec![row_group("Rebase in progress", ids, REBASE_SEQUENCE_ROWS)]
2022 } else {
2023 vec![row_group("Rebase", ids, REBASE_START_ROWS)]
2024 };
2025 TransientSpec {
2026 title: "Rebase".into(),
2027 groups,
2028 preview: None,
2029 footer: Some("q dismiss Esc/BS back".into()),
2030 }
2031}
2032
2033fn jump_transient(ids: &MagitActionIds) -> TransientSpec {
2034 TransientSpec {
2035 title: "Jump to section".into(),
2036 groups: vec![row_group("Sections", ids, JUMP_ROWS)],
2037 preview: None,
2038 footer: Some("q dismiss Esc/BS back".into()),
2039 }
2040}
2041
2042pub fn dispatch_transient(
2059 ids: &MagitActionIds,
2060 ctx: &TransientContext,
2061 workdir: &std::path::Path,
2062) -> TransientSpec {
2063 dispatch_transient_with(ids, ctx, &DispatchGates::probe_in(workdir))
2064}
2065
2066fn repo_flag(workdir: &std::path::Path, ask: impl Fn(&lattice_vcs::Repository) -> bool) -> bool {
2069 lattice_vcs::Repository::discover(workdir)
2070 .ok()
2071 .map(|repo| ask(&repo))
2072 .unwrap_or(false)
2073}
2074
2075#[derive(Debug, Clone, Default, PartialEq, Eq)]
2082pub struct DispatchGates {
2083 pub bisect: bool,
2085 pub notes_merge: bool,
2087 pub am: bool,
2089 pub rebase: bool,
2091 pub cherry_pick: bool,
2093 pub revert: bool,
2095 pub merge: bool,
2099 pub workdir: std::path::PathBuf,
2107}
2108
2109impl DispatchGates {
2110 pub fn probe_in(workdir: &std::path::Path) -> Self {
2125 let flight = lattice_vcs::Repository::discover(workdir)
2126 .ok()
2127 .and_then(|repo| lattice_vcs::InFlightOp::detect(&repo));
2128 Self {
2129 workdir: workdir.to_path_buf(),
2130 bisect: repo_flag(workdir, lattice_vcs::Bisect::in_progress),
2131 notes_merge: repo_flag(workdir, |repo| {
2132 lattice_vcs::Note::merge_in_progress(repo.gitdir())
2133 }),
2134 am: flight == Some(lattice_vcs::InFlightOp::ApplyPatch),
2135 rebase: flight == Some(lattice_vcs::InFlightOp::Rebase),
2136 cherry_pick: flight == Some(lattice_vcs::InFlightOp::CherryPick),
2137 revert: flight == Some(lattice_vcs::InFlightOp::Revert),
2138 merge: flight == Some(lattice_vcs::InFlightOp::Merge),
2139 }
2140 }
2141}
2142
2143pub struct RootMenu {
2158 pub source: &'static str,
2161 pub chord: Option<&'static str>,
2174 pub action: &'static str,
2176 pub doc: &'static str,
2178}
2179
2180pub const ROOT_MENUS: &[RootMenu] = &[
2187 RootMenu {
2188 source: "magit-menu-diff",
2189 chord: None,
2195 action: "action:magit-menu-diff",
2196 doc: "Diff menu",
2197 },
2198 RootMenu {
2199 source: "magit-menu-commit",
2200 chord: None,
2205 action: "action:magit-menu-commit",
2206 doc: "Commit menu",
2207 },
2208 RootMenu {
2209 source: "magit-menu-log",
2210 chord: None,
2212 action: "action:magit-menu-log",
2213 doc: "Log menu",
2214 },
2215 RootMenu {
2216 source: "magit-menu-cherry-pick",
2217 chord: Some("A"),
2219 action: "action:magit-menu-cherry-pick",
2220 doc: "Cherry-pick menu",
2221 },
2222 RootMenu {
2223 source: "magit-menu-revert",
2224 chord: Some("_"),
2226 action: "action:magit-menu-revert",
2227 doc: "Revert menu",
2228 },
2229 RootMenu {
2230 source: "magit-menu-reset",
2231 chord: Some("O"),
2233 action: "action:magit-menu-reset",
2234 doc: "Reset menu",
2235 },
2236 RootMenu {
2237 source: "magit-menu-bisect",
2238 chord: None,
2240 action: "action:magit-menu-bisect",
2241 doc: "Bisect menu",
2242 },
2243 RootMenu {
2244 source: "magit-menu-notes",
2245 chord: None,
2247 action: "action:magit-menu-notes",
2248 doc: "Notes menu",
2249 },
2250 RootMenu {
2251 source: "magit-menu-branch",
2252 chord: None,
2254 action: "action:magit-menu-branch",
2255 doc: "Branch menu",
2256 },
2257 RootMenu {
2258 source: "magit-menu-stash",
2259 chord: None,
2261 action: "action:magit-menu-stash",
2262 doc: "Stash menu",
2263 },
2264 RootMenu {
2265 source: "magit-menu-fetch",
2266 chord: None,
2268 action: "action:magit-menu-fetch",
2269 doc: "Fetch menu",
2270 },
2271 RootMenu {
2272 source: "magit-menu-pull",
2273 chord: None,
2275 action: "action:magit-menu-pull",
2276 doc: "Pull menu",
2277 },
2278 RootMenu {
2280 source: "magit-menu-push",
2281 chord: None,
2290 action: "action:magit-menu-push",
2291 doc: "Push menu",
2292 },
2293 RootMenu {
2294 source: "magit-menu-patches",
2295 chord: None,
2297 action: "action:magit-menu-patches",
2298 doc: "Patch (am / format-patch) menu",
2299 },
2300 RootMenu {
2301 source: "magit-menu-rebase",
2302 chord: None,
2306 action: "action:magit-menu-rebase",
2307 doc: "Rebase menu",
2308 },
2309 RootMenu {
2310 source: "magit-menu-tag",
2311 chord: None,
2313 action: "action:magit-menu-tag",
2314 doc: "Tag menu",
2315 },
2316 RootMenu {
2317 source: "magit-menu-merge",
2318 chord: None,
2320 action: "action:magit-menu-merge",
2321 doc: "Merge menu",
2322 },
2323];
2324
2325pub fn root_menu_spec(
2331 source: &str,
2332 ids: &MagitActionIds,
2333 ctx: &TransientContext,
2334 gates: &DispatchGates,
2335) -> Option<TransientSpec> {
2336 let spec = match source {
2337 "magit-menu-diff" => view_open_transient(
2338 "Diff",
2339 ids,
2340 crate::magit_diff_mode::DIFF_ARGS,
2341 DIFF_SHOW_ROWS,
2342 ),
2343 "magit-menu-commit" => commit_transient(ids),
2344 "magit-menu-log" => {
2345 view_open_transient("Log", ids, crate::magit_log_mode::LOG_ARGS, LOG_SHOW_ROWS)
2346 }
2347 "magit-menu-cherry-pick" => cherry_pick_transient(ids, gates.cherry_pick),
2348 "magit-menu-revert" => revert_transient(ids, gates.revert),
2349 "magit-menu-reset" => reset_transient(ids),
2350 "magit-menu-bisect" => bisect_transient(ids, gates.bisect),
2351 "magit-menu-notes" => notes_transient(ids, &gates.workdir, gates.notes_merge),
2352 "magit-menu-branch" => branch_transient(ids, &gates.workdir),
2353 "magit-menu-stash" => stash_transient(ids),
2354 "magit-menu-fetch" => remote_op_transient(
2355 "Fetch",
2356 RemoteOp::FETCH,
2357 ids,
2358 FETCH_ROWS,
2359 FETCH_CONFIG_ROWS,
2360 &gates.workdir,
2361 ),
2362 "magit-menu-pull" => remote_op_transient(
2363 "Pull",
2364 RemoteOp::PULL,
2365 ids,
2366 PULL_ROWS,
2367 PULL_CONFIG_ROWS,
2368 &gates.workdir,
2369 ),
2370 "magit-menu-push" => remote_op_transient(
2371 "Push",
2372 RemoteOp::PUSH,
2373 ids,
2374 PUSH_ROWS,
2375 PUSH_CONFIG_ROWS,
2376 &gates.workdir,
2377 ),
2378 "magit-menu-patches" => patch_transient(ids, gates.am),
2379 "magit-menu-rebase" => rebase_transient(ids, gates.rebase),
2380 "magit-menu-tag" => tag_transient(ids, &gates.workdir),
2381 "magit-menu-merge" => merge_transient(ids, gates.merge),
2382 _ => return None,
2383 };
2384 let _ = ctx;
2385 Some(spec)
2386}
2387
2388pub fn dispatch_transient_with(
2389 ids: &MagitActionIds,
2390 ctx: &TransientContext,
2391 gates: &DispatchGates,
2392) -> TransientSpec {
2393 let _bisect_in_progress = gates.bisect;
2394 TransientSpec {
2395 title: "Magit dispatch".into(),
2396 groups: vec![
2397 TransientGroup {
2398 label: "Working tree".into(),
2399 items: vec![
2400 status_row(ids, ctx),
2401 TransientItem {
2406 key: vec!["d".into()],
2407 label: "diff".into(),
2408 description: "Diff the working tree against HEAD".into(),
2409 kind: TransientItemKind::Submenu(Arc::new(
2410 root_menu_spec("magit-menu-diff", ids, ctx, gates)
2411 .expect("`magit-menu-diff` is in ROOT_MENUS"),
2412 )),
2413 },
2414 action_or_placeholder(
2430 ids.get("action:magit-project-diff"),
2431 "e",
2432 "edit diff",
2433 "Edit the working-tree diff across every changed file",
2434 "project_diff",
2435 ),
2436 TransientItem {
2437 key: vec!["c".into()],
2438 label: "commit".into(),
2439 description: "Commit changes".into(),
2440 kind: TransientItemKind::Submenu(Arc::new(
2441 root_menu_spec("magit-menu-commit", ids, ctx, gates)
2442 .expect("`magit-menu-commit` is in ROOT_MENUS"),
2443 )),
2444 },
2445 ],
2446 },
2447 TransientGroup {
2462 label: "Applying changes".into(),
2463 items: applying_changes_items(ids, ctx),
2464 },
2465 TransientGroup {
2466 label: "History".into(),
2467 items: vec![
2468 TransientItem {
2470 key: vec!["l".into()],
2471 label: "log".into(),
2472 description: "Show commit history".into(),
2473 kind: TransientItemKind::Submenu(Arc::new(
2474 root_menu_spec("magit-menu-log", ids, ctx, gates)
2475 .expect("`magit-menu-log` is in ROOT_MENUS"),
2476 )),
2477 },
2478 TransientItem {
2493 key: vec!["A".into()],
2494 label: "cherry-pick".into(),
2495 description: "Cherry-pick, or drive a stopped one".into(),
2496 kind: TransientItemKind::Submenu(Arc::new(
2497 root_menu_spec("magit-menu-cherry-pick", ids, ctx, gates)
2498 .expect("`magit-menu-cherry-pick` is in ROOT_MENUS"),
2499 )),
2500 },
2501 TransientItem {
2502 key: vec!["_".into()],
2503 label: "revert".into(),
2504 description: "Revert, or drive a stopped one".into(),
2505 kind: TransientItemKind::Submenu(Arc::new(
2506 root_menu_spec("magit-menu-revert", ids, ctx, gates)
2507 .expect("`magit-menu-revert` is in ROOT_MENUS"),
2508 )),
2509 },
2510 TransientItem {
2511 key: vec!["O".into()],
2512 label: "reset".into(),
2513 description: "Reset this branch to a commit".into(),
2514 kind: TransientItemKind::Submenu(Arc::new(
2515 root_menu_spec("magit-menu-reset", ids, ctx, gates)
2516 .expect("`magit-menu-reset` is in ROOT_MENUS"),
2517 )),
2518 },
2519 TransientItem {
2523 key: vec!["B".into()],
2524 label: "bisect".into(),
2525 description: "Find the commit that introduced a bug".into(),
2526 kind: TransientItemKind::Submenu(Arc::new(
2527 root_menu_spec("magit-menu-bisect", ids, ctx, gates)
2528 .expect("`magit-menu-bisect` is in ROOT_MENUS"),
2529 )),
2530 },
2531 TransientItem {
2536 key: vec!["T".into()],
2537 label: "notes".into(),
2538 description: "Edit, remove, merge or prune commit notes".into(),
2539 kind: TransientItemKind::Submenu(Arc::new(
2540 root_menu_spec("magit-menu-notes", ids, ctx, gates)
2541 .expect("`magit-menu-notes` is in ROOT_MENUS"),
2542 )),
2543 },
2544 ],
2545 },
2546 TransientGroup {
2547 label: "Branches".into(),
2548 items: vec![TransientItem {
2549 key: vec!["b".into()],
2550 label: "branch".into(),
2551 description: "Checkout, create, or list branches".into(),
2552 kind: TransientItemKind::Submenu(Arc::new(
2553 root_menu_spec("magit-menu-branch", ids, ctx, gates)
2554 .expect("`magit-menu-branch` is in ROOT_MENUS"),
2555 )),
2556 }],
2557 },
2558 TransientGroup {
2559 label: "Stashing".into(),
2560 items: vec![TransientItem {
2561 key: vec!["z".into()],
2562 label: "stash".into(),
2563 description: "Stash operations".into(),
2564 kind: TransientItemKind::Submenu(Arc::new(
2565 root_menu_spec("magit-menu-stash", ids, ctx, gates)
2566 .expect("`magit-menu-stash` is in ROOT_MENUS"),
2567 )),
2568 }],
2569 },
2570 TransientGroup {
2571 label: "Remotes".into(),
2572 items: vec![
2573 TransientItem {
2574 key: vec!["f".into()],
2575 label: "fetch".into(),
2576 description: "Fetch from the remote without merging".into(),
2577 kind: TransientItemKind::Submenu(Arc::new(
2578 root_menu_spec("magit-menu-fetch", ids, ctx, gates)
2579 .expect("`magit-menu-fetch` is in ROOT_MENUS"),
2580 )),
2581 },
2582 TransientItem {
2587 key: vec!["F".into()],
2588 label: "pull".into(),
2589 description: "Fetch + integrate from the remote".into(),
2590 kind: TransientItemKind::Submenu(Arc::new(
2591 root_menu_spec("magit-menu-pull", ids, ctx, gates)
2592 .expect("`magit-menu-pull` is in ROOT_MENUS"),
2593 )),
2594 },
2595 TransientItem {
2596 key: vec!["p".into()],
2597 label: "push".into(),
2598 description: "Push to the remote".into(),
2599 kind: TransientItemKind::Submenu(Arc::new(
2600 root_menu_spec("magit-menu-push", ids, ctx, gates)
2601 .expect("`magit-menu-push` is in ROOT_MENUS"),
2602 )),
2603 },
2604 action_or_placeholder(
2611 ids.get("action:magit-global-remote"),
2612 "M",
2613 "remote",
2614 "Manage remotes — add, rename, remove, set URL, prune",
2615 "remote_manage_op",
2616 ),
2617 action_or_placeholder(
2621 ids.get("action:magit-global-submodule"),
2622 "o",
2623 "submodule",
2624 "Manage submodules — add, update, sync, remove",
2625 "submodule_op",
2626 ),
2627 action_or_placeholder(
2630 ids.get("action:magit-global-cherries"),
2631 "Y",
2632 "cherries",
2633 "Which commits are not upstream yet, and which already are",
2634 "cherries_op",
2635 ),
2636 action_or_placeholder(
2640 ids.get("action:magit-global-refs"),
2641 "y",
2642 "refs",
2643 "Show every branch, remote-tracking branch and tag",
2644 "show_refs",
2645 ),
2646 action_or_placeholder(
2651 ids.get("action:magit-global-clone"),
2652 "C",
2653 "clone",
2654 "Clone a repository — asks for the URL, then where to put it",
2655 "clone_op",
2656 ),
2657 ],
2658 },
2659 TransientGroup {
2660 label: "Misc".into(),
2661 items: vec![
2662 TransientItem {
2665 key: vec!["\"".into()],
2666 label: "subtree".into(),
2667 description: "Add, merge, pull, push or split a subtree".into(),
2668 kind: TransientItemKind::Submenu(Arc::new(subtree_transient(ids))),
2669 },
2670 TransientItem {
2672 key: vec!["w".into()],
2673 label: "patches".into(),
2674 description: "Apply or create email patches".into(),
2675 kind: TransientItemKind::Submenu(Arc::new(
2676 root_menu_spec("magit-menu-patches", ids, ctx, gates)
2677 .expect("`magit-menu-patches` is in ROOT_MENUS"),
2678 )),
2679 },
2680 TransientItem {
2685 key: vec!["r".into()],
2686 label: "rebase".into(),
2687 description: "Rebase, or drive a stopped one".into(),
2688 kind: TransientItemKind::Submenu(Arc::new(
2689 root_menu_spec("magit-menu-rebase", ids, ctx, gates)
2690 .expect("`magit-menu-rebase` is in ROOT_MENUS"),
2691 )),
2692 },
2693 TransientItem {
2698 key: vec!["t".into()],
2699 label: "tag".into(),
2700 description: "Create or delete a tag".into(),
2701 kind: TransientItemKind::Submenu(Arc::new(
2702 root_menu_spec("magit-menu-tag", ids, ctx, gates)
2703 .expect("`magit-menu-tag` is in ROOT_MENUS"),
2704 )),
2705 },
2706 action_or_placeholder(
2707 ids.get("action:magit-global-gitignore"),
2708 "i",
2709 "gitignore",
2710 "Add a pattern to .gitignore",
2711 "gitignore_op",
2712 ),
2713 TransientItem {
2718 key: vec!["m".into()],
2719 label: "merge".into(),
2720 description: "Merge a branch into the current one".into(),
2721 kind: TransientItemKind::Submenu(Arc::new(
2722 root_menu_spec("magit-menu-merge", ids, ctx, gates)
2723 .expect("`magit-menu-merge` is in ROOT_MENUS"),
2724 )),
2725 },
2726 action_or_placeholder(
2727 ids.get("action:magit-global-init"),
2728 "I",
2729 "init",
2730 "Initialize a git repository",
2731 "init_op",
2732 ),
2733 ],
2734 },
2735 ],
2736 preview: None,
2737 footer: Some("q dismiss Esc/BS back".into()),
2738 }
2739}
2740
2741fn commit_transient(ids: &MagitActionIds) -> TransientSpec {
2746 TransientSpec {
2747 title: "Commit".into(),
2748 groups: vec![row_group("Actions", ids, COMMIT_ROWS)],
2749 preview: None,
2750 footer: Some("q dismiss Esc/BS back".into()),
2751 }
2752}
2753
2754fn stash_transient(ids: &MagitActionIds) -> TransientSpec {
2760 TransientSpec {
2761 title: "Stash".into(),
2762 groups: vec![
2763 TransientGroup {
2767 label: "Arguments".into(),
2768 items: flag_items(RemoteOp::STASH),
2769 },
2770 row_group("Actions", ids, STASH_ROWS),
2771 ],
2772 preview: Some(remote_preview(RemoteOp::STASH)),
2773 footer: Some("q dismiss Esc/BS back".into()),
2774 }
2775}
2776
2777#[derive(Debug, Clone, Copy, Default)]
2781pub struct FileDispatchActionIds {
2782 pub stage: Option<CommandId>,
2783 pub unstage: Option<CommandId>,
2784 pub discard: Option<CommandId>,
2785 pub diff: Option<CommandId>,
2786 pub log: Option<CommandId>,
2787 pub blame: Option<CommandId>,
2788 pub blame_reverse: Option<CommandId>,
2792 pub at_revision: Option<CommandId>,
2794 pub visit_live: Option<CommandId>,
2796 pub untrack: Option<CommandId>,
2798 pub delete: Option<CommandId>,
2799 pub rename: Option<CommandId>,
2800 pub checkout: Option<CommandId>,
2802 pub log_merged: Option<CommandId>,
2805 pub edit_line_commit: Option<CommandId>,
2808}
2809
2810pub fn other_file_dispatch_transient(ids: &MagitActionIds) -> TransientSpec {
2853 TransientSpec {
2854 title: "File dispatch (other file)".into(),
2855 groups: vec![
2856 TransientGroup {
2857 label: "Target".into(),
2858 items: vec![TransientItem {
2859 key: vec!["=f".into()],
2860 label: "file".into(),
2861 description: "Repo-relative path to act on".into(),
2862 kind: TransientItemKind::Argument {
2863 name: "file".to_string(),
2864 prompt: "File (repo-relative): ".to_string(),
2865 default: None,
2866 source: Some(lattice_picker::TransientArgSource::new(
2880 lattice_picker::FILE_PICK_SOURCE,
2881 )),
2882 },
2883 }],
2884 },
2885 TransientGroup {
2886 label: "Stage".into(),
2887 items: vec![
2888 action_or_placeholder(
2889 ids.get("action:magit-global-file-stage"),
2890 "s",
2891 "stage",
2892 "Stage the target file",
2893 "stage_other_file",
2894 ),
2895 action_or_placeholder(
2896 ids.get("action:magit-global-file-unstage"),
2897 "u",
2898 "unstage",
2899 "Unstage the target file",
2900 "unstage_other_file",
2901 ),
2902 action_or_placeholder(
2909 ids.get("action:magit-global-file-discard"),
2910 "x",
2911 "discard",
2912 "Discard the target file's changes (asks first)",
2913 "discard_other_file",
2914 ),
2915 ],
2916 },
2917 TransientGroup {
2918 label: "Inspect".into(),
2919 items: vec![
2920 action_or_placeholder(
2921 ids.get("action:magit-global-file-diff"),
2922 "d",
2923 "diff",
2924 "Show the target file's diff",
2925 "diff_other_file",
2926 ),
2927 action_or_placeholder(
2928 ids.get("action:magit-global-file-log"),
2929 "l",
2930 "log",
2931 "Show the target file's history",
2932 "log_other_file",
2933 ),
2934 action_or_placeholder(
2935 ids.get("action:magit-global-file-blame"),
2936 "b",
2937 "blame",
2938 "Blame the target file",
2939 "blame_other_file",
2940 ),
2941 ],
2942 },
2943 ],
2944 preview: Some(Box::new(
2948 |state: &lattice_picker::TransientState| match state.get("file") {
2949 Some(lattice_picker::TransientValue::String(p)) if !p.is_empty() => {
2950 format!("target: {p}")
2951 }
2952 _ => "target: (none set — rows act on the visited file)".to_string(),
2953 },
2954 )),
2955 footer: Some("=f set target q dismiss".into()),
2956 }
2957}
2958
2959pub fn file_dispatch_transient(ids: &MagitActionIds) -> TransientSpec {
2960 TransientSpec {
2961 title: "File dispatch".into(),
2962 groups: vec![
2963 TransientGroup {
2964 label: "Stage".into(),
2965 items: vec![
2966 action_or_placeholder(
2967 ids.get("action:magit-global-file-stage"),
2968 "s",
2969 "stage",
2970 "Stage this file",
2971 "stage_file",
2972 ),
2973 action_or_placeholder(
2974 ids.get("action:magit-global-file-unstage"),
2975 "u",
2976 "unstage",
2977 "Unstage this file",
2978 "unstage_file",
2979 ),
2980 action_or_placeholder(
2981 ids.get("action:magit-global-file-discard"),
2982 "x",
2983 "discard",
2984 "Discard this file's working-tree changes (asks first)",
2985 "discard_file",
2986 ),
2987 ],
2988 },
2989 TransientGroup {
2996 label: "File".into(),
2997 items: vec![
2998 action_or_placeholder(
2999 ids.get("action:magit-global-file-untrack"),
3000 ",x",
3001 "untrack",
3002 "Stop tracking this file, keeping it on disk",
3003 "untrack_file",
3004 ),
3005 action_or_placeholder(
3006 ids.get("action:magit-global-file-rename"),
3007 ",r",
3008 "rename",
3009 "Rename this file (asks for the new name)",
3010 "rename_file",
3011 ),
3012 action_or_placeholder(
3013 ids.get("action:magit-global-file-delete"),
3014 ",k",
3015 "delete",
3016 "Delete this file (asks first)",
3017 "delete_file",
3018 ),
3019 action_or_placeholder(
3020 ids.get("action:magit-global-file-checkout"),
3021 ",c",
3022 "checkout",
3023 "Replace this file with its content at a revision (asks, then confirms)",
3024 "checkout_file",
3025 ),
3026 ],
3027 },
3028 TransientGroup {
3029 label: "Inspect".into(),
3030 items: vec![
3031 action_or_placeholder(
3032 ids.get("action:magit-global-file-diff"),
3033 "d",
3034 "diff",
3035 "Show diff for this file",
3036 "diff_file",
3037 ),
3038 action_or_placeholder(
3039 ids.get("action:magit-global-file-log"),
3040 "l",
3041 "log",
3042 "Show commit history for this file",
3043 "log_file",
3044 ),
3045 action_or_placeholder(
3046 ids.get("action:magit-global-file-blame"),
3047 "b",
3048 "blame",
3049 "Blame this file",
3050 "blame_file",
3051 ),
3052 action_or_placeholder(
3060 ids.get("action:magit-global-file-at-revision"),
3061 "v",
3062 "view at revision",
3063 "Open this file as it was at a revision you name",
3064 "file_at_revision",
3065 ),
3066 action_or_placeholder(
3067 ids.get("action:magit-global-file-visit-live"),
3068 "V",
3069 "back to the live file",
3070 "From a file-at-revision, open the working-tree copy at the same line",
3071 "file_visit_live",
3072 ),
3073 action_or_placeholder(
3074 ids.get("action:magit-global-file-blame-reverse"),
3075 "f",
3076 "reverse blame",
3077 "For each line of this revision, the last commit it existed in",
3078 "blame_reverse_file",
3079 ),
3080 action_or_placeholder(
3085 ids.get("action:magit-global-log-merged"),
3086 "M",
3087 "merged",
3088 "Show the merge commit that brought a commit into HEAD",
3089 "log_merged",
3090 ),
3091 ],
3092 },
3093 TransientGroup {
3095 label: "More actions".into(),
3096 items: vec![action_or_placeholder(
3097 ids.get("action:magit-global-edit-line-commit"),
3098 "e",
3099 "edit line",
3100 "Start a rebase to amend the commit that wrote the line at the cursor",
3101 "edit_line_commit",
3102 )],
3103 },
3104 ],
3105 preview: None,
3106 footer: Some("q dismiss".into()),
3107 }
3108}
3109
3110#[cfg(test)]
3111mod row_table_tests {
3112 use super::*;
3113
3114 fn registry() -> lattice_grammar::CommandRegistry {
3117 let mut r = lattice_grammar::CommandRegistry::new();
3118 let _ = lattice_grammar::builtins::populate(&mut r);
3119 let _ = lattice_grammar::ex_commands::populate(&mut r);
3120 crate::register_action_commands_for_test(&mut r);
3121 r
3122 }
3123
3124 #[test]
3132 fn every_row_action_is_registered() {
3133 let reg = registry();
3134 let ids = MagitActionIds::resolve(®);
3135 assert!(!ids.is_empty(), "no magit actions resolved at all");
3136 for (table, rows) in all_row_tables() {
3137 for row in *rows {
3138 assert!(
3139 ids.get(row.action).is_some(),
3140 "{table} row `{}` ({}) references unregistered `{}`",
3141 row.key,
3142 row.label,
3143 row.action,
3144 );
3145 }
3146 }
3147 }
3148
3149 #[test]
3152 fn no_duplicate_keys_within_a_table() {
3153 for (table, rows) in all_row_tables() {
3154 let mut seen = std::collections::HashSet::new();
3155 for row in *rows {
3156 assert!(
3157 seen.insert(row.key),
3158 "{table} binds `{}` twice; the second row is unreachable",
3159 row.key,
3160 );
3161 }
3162 }
3163 }
3164
3165 #[test]
3168 fn rows_are_fully_described() {
3169 for (table, rows) in all_row_tables() {
3170 for row in *rows {
3171 assert!(!row.key.is_empty(), "{table}: empty key");
3172 assert!(!row.label.is_empty(), "{table} `{}`: empty label", row.key);
3173 assert!(!row.doc.is_empty(), "{table} `{}`: empty doc", row.key);
3174 assert!(
3175 row.action.starts_with(MagitActionIds::PREFIX),
3176 "{table} `{}`: `{}` is outside the `{}` namespace, so \
3177 `MagitActionIds::resolve` will never find it",
3178 row.key,
3179 row.action,
3180 MagitActionIds::PREFIX,
3181 );
3182 }
3183 }
3184 }
3185
3186 #[test]
3190 fn resolver_finds_actions_without_a_hand_kept_list() {
3191 let reg = registry();
3192 let ids = MagitActionIds::resolve(®);
3193 let registered = reg
3194 .names()
3195 .filter(|n| n.starts_with(MagitActionIds::PREFIX))
3196 .count();
3197 assert_eq!(
3198 ids.by_name.len(),
3199 registered,
3200 "resolve() must pick up EVERY registered magit action",
3201 );
3202 }
3203}
3204
3205#[cfg(test)]
3206mod background_task_tests {
3207 #[test]
3215 fn magit_does_not_depend_on_the_notification_crate() {
3216 let manifest = include_str!("../Cargo.toml");
3217 assert!(
3218 !manifest.contains("lattice-notify"),
3219 "magit must not depend on lattice-notify — completion is \
3220 reported by publishing `BackgroundTaskFinished`, which the \
3221 notification layer subscribes to",
3222 );
3223 }
3224
3225 #[test]
3236 fn no_repository_call_discards_its_result() {
3237 let sources = [
3238 ("magit_global_mode.rs", include_str!("magit_global_mode.rs")),
3239 ("magit_refs_mode.rs", include_str!("magit_refs_mode.rs")),
3240 ("magit_rebase_mode.rs", include_str!("magit_rebase_mode.rs")),
3241 ("magit_branch_mode.rs", include_str!("magit_branch_mode.rs")),
3242 ("magit_stash_mode.rs", include_str!("magit_stash_mode.rs")),
3243 ("actions.rs", include_str!("actions.rs")),
3244 ];
3245 for (file, src) in sources {
3246 for (n, line) in src.lines().enumerate() {
3247 let code = line.trim_start();
3248 if code.starts_with("//") {
3249 continue;
3250 }
3251 assert!(
3252 !code.starts_with("let _ = lattice_vcs::")
3253 && !code.starts_with("let _ = repo.run_git"),
3254 "{file}:{}: a repository call's result is discarded",
3255 n + 1
3256 );
3257 }
3258 }
3259 }
3260
3261 #[test]
3262 fn every_spawner_reports_completion() {
3263 let src = include_str!("magit_global_mode.rs");
3264 let delegating = ["spawn_note_remove", "spawn_note_prune"];
3267 let mut checked = 0;
3268 for (idx, _) in src.match_indices("fn spawn_") {
3269 let name: String = src[idx + 3..]
3270 .chars()
3271 .take_while(|c| c.is_alphanumeric() || *c == '_')
3272 .collect();
3273 let body_end = src[idx..]
3274 .find("\nfn ")
3275 .map(|e| idx + e)
3276 .unwrap_or(src.len());
3277 let body = &src[idx..body_end];
3278 if delegating.contains(&name.as_str()) {
3279 assert!(
3280 body.contains("spawn_git(crate::repo_scope::action_workdir(ctx), ")
3281 || body.contains("spawn_remote_op("),
3282 "{name} is listed as delegating but calls neither spawner",
3283 );
3284 } else {
3285 assert!(
3286 body.contains("finish_task"),
3287 "{name} does not report completion — a background op that \
3288 finishes invisibly is the bug MG.41g fixed",
3289 );
3290 }
3291 checked += 1;
3292 }
3293 assert!(
3294 checked >= 8,
3295 "expected to inspect every spawner, saw {checked}"
3296 );
3297 }
3298
3299 #[test]
3316 fn every_mutation_helper_in_the_crate_reports_completion() {
3317 const MUTATORS: &[(&str, &str)] = &[
3319 ("actions.rs", "spawn_mutation_and_refresh"),
3320 ("magit_branch_mode.rs", "spawn_mutation_and_refresh"),
3321 ("magit_diff_mode.rs", "spawn_mutation_and_refresh"),
3322 ("magit_stash_mode.rs", "spawn_mutation_and_refresh"),
3323 ("magit_remote_mode.rs", "spawn_remote_mutation"),
3324 ("magit_submodule_mode.rs", "spawn_submodule_mutation"),
3325 ("magit_core_mode.rs", "spawn_patch_discard"),
3326 ("magit_core_mode.rs", "spawn_hunk_apply"),
3327 ];
3328 let sources: &[(&str, &str)] = &[
3329 ("actions.rs", include_str!("actions.rs")),
3330 ("magit_branch_mode.rs", include_str!("magit_branch_mode.rs")),
3331 ("magit_diff_mode.rs", include_str!("magit_diff_mode.rs")),
3332 ("magit_stash_mode.rs", include_str!("magit_stash_mode.rs")),
3333 ("magit_remote_mode.rs", include_str!("magit_remote_mode.rs")),
3334 (
3335 "magit_submodule_mode.rs",
3336 include_str!("magit_submodule_mode.rs"),
3337 ),
3338 ("magit_core_mode.rs", include_str!("magit_core_mode.rs")),
3339 ];
3340
3341 for (file, helper) in MUTATORS {
3342 let src = sources
3343 .iter()
3344 .find(|(f, _)| f == file)
3345 .map(|(_, s)| *s)
3346 .unwrap_or_else(|| panic!("{file} is in the source table"));
3347 let idx = src
3348 .find(&format!("fn {helper}"))
3349 .unwrap_or_else(|| panic!("{file}: `{helper}` not found — renamed?"));
3350 let body_end = src[idx..]
3351 .find("\nfn ")
3352 .map(|e| idx + e)
3353 .unwrap_or(src.len());
3354 let body = &src[idx..body_end];
3355 assert!(
3356 body.contains("finish_task"),
3357 "{file}: `{helper}` mutates the repository and does not \
3358 report completion. The user presses a key, git runs, and \
3359 nothing says whether it worked — on failure the buffer \
3360 just refreshes as though it had.",
3361 );
3362 }
3363 }
3364}
3365
3366#[cfg(test)]
3367mod remote_target_tests {
3368 use crate::magit_global_mode::RemoteTarget;
3369
3370 #[test]
3374 fn configured_adds_nothing() {
3375 assert!(RemoteTarget::Configured.argv(None).is_empty());
3376 assert!(
3378 RemoteTarget::Configured
3379 .argv(Some("origin main"))
3380 .is_empty()
3381 );
3382 }
3383
3384 #[test]
3385 fn all_remotes_and_all_tags_are_flags() {
3386 assert_eq!(RemoteTarget::AllRemotes.argv(None), vec!["--all"]);
3387 assert_eq!(RemoteTarget::AllTags.argv(None), vec!["--tags"]);
3388 }
3389
3390 #[test]
3394 fn upstream_expands_to_remote_and_branch() {
3395 assert_eq!(
3396 RemoteTarget::Upstream.argv(Some("origin main")),
3397 vec!["origin", "main"],
3398 );
3399 }
3400
3401 #[test]
3405 fn an_unresolved_destination_contributes_no_argument() {
3406 for t in [RemoteTarget::Upstream, RemoteTarget::Prompted] {
3407 assert!(t.argv(None).is_empty(), "{t:?} with None");
3408 assert!(t.argv(Some("")).is_empty(), "{t:?} with empty");
3409 assert!(t.argv(Some(" ")).is_empty(), "{t:?} with blank");
3410 }
3411 }
3412
3413 #[test]
3416 fn prompted_destinations_pass_through() {
3417 assert_eq!(RemoteTarget::Prompted.argv(Some("v1.2.0")), vec!["v1.2.0"]);
3418 assert_eq!(
3419 RemoteTarget::Prompted.argv(Some("origin feature/x")),
3420 vec!["origin", "feature/x"],
3421 );
3422 }
3423}
3424
3425#[cfg(test)]
3426mod remote_flag_tests {
3427 use crate::magit_global_mode::RemoteOp;
3428 use lattice_grammar::{ArgValue, Args};
3429
3430 fn flags(op: RemoteOp, on: &[&str]) -> Vec<String> {
3431 let list: Vec<ArgValue> = op
3432 .flags
3433 .iter()
3434 .map(|f| ArgValue::Bool(on.contains(&f.name)))
3435 .collect();
3436 op.argv(&Args::List(list))
3437 }
3438
3439 #[test]
3442 fn pull_rebase_replaces_ff_only() {
3443 let argv = flags(RemoteOp::PULL, &["rebase"]);
3444 assert!(argv.contains(&"--rebase".to_string()), "{argv:?}");
3445 assert!(
3446 !argv.contains(&"--ff-only".to_string()),
3447 "--ff-only must be dropped when rebasing: {argv:?}",
3448 );
3449 }
3450
3451 #[test]
3454 fn pull_defaults_to_ff_only() {
3455 let argv = flags(RemoteOp::PULL, &[]);
3456 assert!(argv.contains(&"--ff-only".to_string()), "{argv:?}");
3457 assert!(!argv.contains(&"--rebase".to_string()), "{argv:?}");
3458 }
3459
3460 #[test]
3462 fn pull_autostash_is_independent_of_rebase() {
3463 let with_rebase = flags(RemoteOp::PULL, &["rebase", "autostash"]);
3464 assert!(with_rebase.contains(&"--autostash".to_string()));
3465 assert!(!with_rebase.contains(&"--ff-only".to_string()));
3466 let without = flags(RemoteOp::PULL, &["autostash"]);
3467 assert!(without.contains(&"--autostash".to_string()));
3468 assert!(without.contains(&"--ff-only".to_string()));
3469 }
3470
3471 #[test]
3474 fn rebase_lookup_survives_table_order() {
3475 let idx = RemoteOp::PULL
3476 .flags
3477 .iter()
3478 .position(|f| f.name == "rebase")
3479 .expect("pull has a rebase flag");
3480 let mut list: Vec<ArgValue> = RemoteOp::PULL
3482 .flags
3483 .iter()
3484 .map(|_| ArgValue::Bool(false))
3485 .collect();
3486 list[idx] = ArgValue::Bool(true);
3487 let argv = RemoteOp::PULL.argv(&Args::List(list));
3488 assert!(!argv.contains(&"--ff-only".to_string()), "{argv:?}");
3489 }
3490
3491 #[test]
3500 fn push_offers_magits_flag_set_minus_the_footgun() {
3501 let names: Vec<&str> = RemoteOp::PUSH.flags.iter().map(|f| f.name).collect();
3502 for expected in ["force-with-lease", "set-upstream", "no-verify", "dry-run"] {
3503 assert!(
3504 names.contains(&expected),
3505 "push missing `{expected}`: {names:?}"
3506 );
3507 }
3508 assert!(
3509 !names.contains(&"force"),
3510 "bare --force stays out; see force_push_uses_force_with_lease",
3511 );
3512 }
3513
3514 #[test]
3515 fn fetch_offers_tags_and_prune() {
3516 let names: Vec<&str> = RemoteOp::FETCH.flags.iter().map(|f| f.name).collect();
3517 for expected in ["tags", "prune", "all"] {
3518 assert!(
3519 names.contains(&expected),
3520 "fetch missing `{expected}`: {names:?}"
3521 );
3522 }
3523 }
3524}
3525
3526#[cfg(test)]
3527mod config_row_tests {
3528 use super::*;
3529 use lattice_grammar::CommandRegistry;
3530
3531 #[test]
3538 fn every_config_row_action_is_registered() {
3539 let mut registry = CommandRegistry::new();
3540 crate::register_action_commands(&mut registry);
3541 for (table, rows) in all_config_tables() {
3542 for row in *rows {
3543 assert!(
3544 registry.id_by_name(row.action).is_some(),
3545 "{table} row `{}` ({}) references unregistered `{}`",
3546 row.key,
3547 row.label,
3548 row.action,
3549 );
3550 }
3551 }
3552 }
3553
3554 #[test]
3560 fn every_config_row_names_a_key() {
3561 for (table, rows) in all_config_tables() {
3562 for row in *rows {
3563 assert!(
3564 !row.config_key.is_empty(),
3565 "{table} row `{}` names no config key",
3566 row.key,
3567 );
3568 assert!(
3569 row.config_key.contains('.'),
3570 "{table} row `{}` names `{}`, which is not a git-config key",
3571 row.key,
3572 row.config_key,
3573 );
3574 }
3575 }
3576 }
3577
3578 #[test]
3585 fn rows_build_without_a_prefetched_value() {
3586 let mut registry = CommandRegistry::new();
3587 crate::register_action_commands(&mut registry);
3588 let ids = MagitActionIds::resolve(®istry);
3589
3590 let group = config_group(&ids, BRANCH_CONFIG_ROWS, std::path::Path::new("/work/api"))
3591 .expect("branch has a configure row");
3592 let item = &group.items[0];
3593 match &item.kind {
3594 TransientItemKind::Variable { key, value, .. } => {
3595 assert_eq!(key, "pull.rebase");
3596 assert_eq!(*value, None, "an unread key must not report a value");
3600 }
3601 other => panic!("configure rows must be Variable, got {other:?}"),
3602 }
3603 }
3604
3605 #[test]
3607 fn unread_and_unset_render_differently() {
3608 assert_eq!(TransientItemKind::variable_display(None), "…");
3609 assert_eq!(TransientItemKind::variable_display(Some("")), "unset");
3610 assert_eq!(TransientItemKind::variable_display(Some("true")), "");
3611 }
3612}
3613
3614#[cfg(test)]
3615mod merge_and_tag_argv_tests {
3616 use crate::magit_global_mode::{
3617 merge_absorb_steps, merge_into_steps, tag_prune_argv, tag_release_argv,
3618 };
3619
3620 #[test]
3629 fn merge_into_deletes_this_branch_and_absorb_deletes_the_other() {
3630 let into = merge_into_steps("feature", "main");
3631 let deleted = into
3632 .iter()
3633 .find(|s| s.argv.first().map(String::as_str) == Some("branch"))
3634 .expect("merge-into deletes a branch");
3635 assert!(
3636 deleted.argv.contains(&"feature".to_string()),
3637 "merge-into deletes the CURRENT branch: {:?}",
3638 deleted.argv,
3639 );
3640
3641 let absorb = merge_absorb_steps("feature");
3642 let deleted = absorb
3643 .iter()
3644 .find(|s| s.argv.first().map(String::as_str) == Some("branch"))
3645 .expect("absorb deletes a branch");
3646 assert!(
3647 deleted.argv.contains(&"feature".to_string()),
3648 "absorb deletes the OTHER branch: {:?}",
3649 deleted.argv,
3650 );
3651 }
3652
3653 #[test]
3656 fn merge_into_checks_out_before_merging() {
3657 let steps = merge_into_steps("feature", "main");
3658 assert_eq!(steps[0].argv, vec!["checkout", "main"]);
3659 assert_eq!(steps[1].argv.first().map(String::as_str), Some("merge"));
3660 }
3661
3662 #[test]
3666 fn neither_direction_force_deletes() {
3667 for steps in [
3668 merge_into_steps("feature", "main"),
3669 merge_absorb_steps("feature"),
3670 ] {
3671 for step in steps {
3672 assert!(
3673 !step.argv.iter().any(|a| a == "-D"),
3674 "a force delete would destroy the branch on a failed merge: {:?}",
3675 step.argv,
3676 );
3677 }
3678 }
3679 }
3680
3681 #[test]
3685 fn a_release_tag_is_annotated() {
3686 let argv = tag_release_argv("v1.0.0", "first release");
3687 assert_eq!(argv, vec!["tag", "-a", "v1.0.0", "-m", "first release"],);
3688 }
3689
3690 #[test]
3694 fn pruning_tags_carries_prune_and_a_remote() {
3695 let argv = tag_prune_argv("origin");
3696 assert!(argv.contains(&"--prune".to_string()), "{argv:?}");
3697 assert!(argv.contains(&"--prune-tags".to_string()), "{argv:?}");
3698 assert_eq!(argv.last().map(String::as_str), Some("origin"), "{argv:?}");
3699 }
3700}
3701
3702#[cfg(test)]
3703mod merge_preview_tests {
3704 #[test]
3712 fn the_preview_range_is_symmetric_difference() {
3713 let argv = crate::magit_diff_mode::merge_preview_argv_for_test("feature");
3714 let range = argv
3715 .iter()
3716 .find(|a| a.contains("HEAD"))
3717 .expect("the range names HEAD");
3718 assert_eq!(range, "HEAD...feature");
3719 assert!(
3720 !range.contains("HEAD..feature"),
3721 "two dots would invert what the preview reports",
3722 );
3723 }
3724}
3725
3726#[cfg(test)]
3727mod rebase_argv_tests {
3728 use crate::magit_global_mode::{
3729 rebase_autosquash_argv, rebase_onto_argv, rebase_subset_argv, resolve_upstream,
3730 };
3731
3732 #[test]
3743 fn rebase_targets_are_a_single_revision() {
3744 assert_eq!(
3745 rebase_onto_argv("@{upstream}"),
3746 vec!["rebase", "@{upstream}"]
3747 );
3748 assert_eq!(rebase_onto_argv("@{push}"), vec!["rebase", "@{push}"]);
3749 for target in ["@{upstream}", "@{push}", "origin/main"] {
3750 assert_eq!(
3751 rebase_onto_argv(target).len(),
3752 2,
3753 "`{target}` must contribute exactly one token after `rebase`",
3754 );
3755 }
3756 }
3757
3758 #[test]
3766 fn the_push_side_resolution_is_not_a_single_token() {
3767 let dir = tempfile::tempdir().expect("temp dir");
3768 assert_eq!(
3769 resolve_upstream(dir.path()),
3770 None,
3771 "no upstream configured must resolve to nothing, never a partial ref",
3772 );
3773 }
3774
3775 #[test]
3779 fn subset_puts_the_new_base_before_the_upstream() {
3780 assert_eq!(
3781 rebase_subset_argv("main", "feature~3"),
3782 vec!["rebase", "--onto", "main", "feature~3"],
3783 );
3784 }
3785
3786 #[test]
3791 fn autosquash_is_interactive() {
3792 let argv = rebase_autosquash_argv("main");
3793 assert_eq!(argv, vec!["rebase", "-i", "--autosquash", "main"]);
3794 assert!(argv.iter().any(|a| a == "-i"));
3795 }
3796}
3797
3798#[cfg(test)]
3799mod commit_op_argv_tests {
3800 use crate::magit_global_mode::CommitOp;
3801
3802 #[test]
3806 fn reset_index_puts_the_dashes_after_the_commit() {
3807 assert_eq!(
3808 CommitOp::RESET_INDEX.argv("abc123"),
3809 vec!["reset", "abc123", "--"],
3810 );
3811 }
3812
3813 #[test]
3816 fn reset_keep_needs_no_confirmation() {
3817 assert_eq!(
3818 CommitOp::RESET_KEEP.argv("abc123"),
3819 vec!["reset", "--keep", "abc123"]
3820 );
3821 assert!(CommitOp::RESET_KEEP.confirm_action.is_none());
3822 assert!(CommitOp::RESET_HARD.confirm_action.is_some());
3824 }
3825
3826 #[test]
3834 fn the_no_commit_halves_stage_rather_than_commit() {
3835 assert_eq!(
3836 CommitOp::REVERT_CHANGES.argv("abc123"),
3837 vec!["revert", "--no-commit", "abc123"],
3838 );
3839 assert_eq!(
3840 CommitOp::CHERRY_PICK_APPLY.argv("abc123"),
3841 vec!["cherry-pick", "--no-commit", "abc123"],
3842 );
3843 }
3844
3845 #[test]
3848 fn each_no_commit_half_differs_from_its_committing_sibling() {
3849 assert_ne!(
3850 CommitOp::REVERT.argv("abc123"),
3851 CommitOp::REVERT_CHANGES.argv("abc123"),
3852 );
3853 assert_ne!(
3854 CommitOp::CHERRY_PICK.argv("abc123"),
3855 CommitOp::CHERRY_PICK_APPLY.argv("abc123"),
3856 );
3857 }
3858
3859 #[test]
3869 fn the_no_commit_halves_carry_no_edit_flag() {
3870 for argv in [
3871 CommitOp::REVERT_CHANGES.argv("abc123"),
3872 CommitOp::CHERRY_PICK_APPLY.argv("abc123"),
3873 ] {
3874 assert!(
3875 !argv.iter().any(|a| a == "--no-edit"),
3876 "no editor is opened when nothing is committed: {argv:?}",
3877 );
3878 }
3879 }
3880
3881 #[test]
3889 fn reset_worktree_leaves_head_and_the_index_alone() {
3890 let argv = CommitOp::RESET_WORKTREE.argv("abc123");
3891 assert_eq!(
3892 argv,
3893 vec!["restore", "--source", "abc123", "--worktree", "--", "."],
3894 );
3895 assert!(
3896 !argv.iter().any(|a| a == "reset" || a == "checkout"),
3897 "neither `reset` nor `checkout`: both touch more than the worktree",
3898 );
3899 assert!(!argv.iter().any(|a| a == "--staged"), "{argv:?}");
3902 }
3903
3904 #[test]
3907 fn reset_worktree_asks_first() {
3908 assert!(CommitOp::RESET_WORKTREE.confirm_action.is_some());
3909 }
3910
3911 #[test]
3914 fn fixup_and_squash_target_the_commit() {
3915 assert_eq!(
3916 CommitOp::COMMIT_FIXUP.argv("abc123"),
3917 vec!["commit", "--no-edit", "--fixup", "abc123"],
3918 );
3919 assert_eq!(
3920 CommitOp::COMMIT_SQUASH.argv("abc123"),
3921 vec!["commit", "--no-edit", "--squash", "abc123"],
3922 );
3923 }
3924
3925 #[test]
3928 fn ops_without_trailing_tokens_are_unaffected() {
3929 assert_eq!(
3930 CommitOp::RESET_SOFT.argv("abc"),
3931 vec!["reset", "--soft", "abc"]
3932 );
3933 assert!(CommitOp::RESET_SOFT.trailing.is_empty());
3934 }
3935}
3936
3937#[cfg(test)]
3938mod rebase_gate_tests {
3939 use super::*;
3940
3941 fn keys(spec: &TransientSpec) -> Vec<String> {
3942 spec.groups
3943 .iter()
3944 .flat_map(|g| &g.items)
3945 .flat_map(|i| i.key.clone())
3946 .collect()
3947 }
3948
3949 #[test]
3953 fn no_rebase_running_offers_only_the_way_in() {
3954 let spec = rebase_transient(&MagitActionIds::default(), false);
3955 assert_eq!(
3958 keys(&spec),
3959 vec!["p", "u", "e", "s", "m", "w", "k", "f", "i"]
3960 );
3961 }
3962
3963 #[test]
3966 fn a_stopped_rebase_offers_only_the_ways_out() {
3967 let spec = rebase_transient(&MagitActionIds::default(), true);
3968 assert_eq!(keys(&spec), vec!["r", "s", "a"]);
3969 assert!(
3970 !keys(&spec).contains(&"i".to_string()),
3971 "must not offer to start a rebase while one is stopped",
3972 );
3973 }
3974
3975 #[test]
3982 fn the_gate_is_part_of_the_probe() {
3983 let gates = DispatchGates::default();
3984 assert!(!gates.rebase, "default gates report nothing in progress");
3985 }
3986}
3987
3988#[cfg(test)]
3989mod sequencer_gate_tests {
3990 use super::*;
3991
3992 fn keys(spec: &TransientSpec) -> Vec<String> {
3993 spec.groups
3994 .iter()
3995 .flat_map(|g| &g.items)
3996 .flat_map(|i| i.key.clone())
3997 .collect()
3998 }
3999
4000 #[test]
4004 fn idle_sequencers_offer_only_the_way_in() {
4005 let ids = MagitActionIds::default();
4006 assert_eq!(keys(&merge_transient(&ids, true)), vec!["m", "a"]);
4014 let idle_merge = keys(&merge_transient(&ids, false));
4015 assert!(
4016 idle_merge.len() > 2 && !idle_merge.contains(&"continue".to_string()),
4017 "idle merge offers the ways in: {idle_merge:?}"
4018 );
4019 assert_eq!(
4020 keys(&cherry_pick_transient(&ids, false)),
4021 vec!["A", "a", "h", "d", "n", "s"]
4022 );
4023 assert_eq!(keys(&revert_transient(&ids, false)), vec!["V", "v"]);
4024 }
4025
4026 #[test]
4041 fn overloaded_keys_resolve_to_different_rows_in_each_state() {
4042 let ids = MagitActionIds::default();
4043 for (name, idle, stopped) in [
4044 (
4045 "cherry-pick",
4046 cherry_pick_transient(&ids, false),
4047 cherry_pick_transient(&ids, true),
4048 ),
4049 (
4050 "revert",
4051 revert_transient(&ids, false),
4052 revert_transient(&ids, true),
4053 ),
4054 (
4055 "merge",
4056 merge_transient(&ids, false),
4057 merge_transient(&ids, true),
4058 ),
4059 ] {
4060 let label_for = |spec: &TransientSpec, key: &str| {
4061 spec.groups
4062 .iter()
4063 .flat_map(|g| &g.items)
4064 .find(|i| i.key.iter().any(|k| k == key))
4065 .map(|i| i.label.clone())
4066 };
4067 let shared: Vec<String> = keys(&idle)
4068 .into_iter()
4069 .filter(|k| keys(&stopped).contains(k))
4070 .collect();
4071 assert!(
4075 !shared.is_empty(),
4076 "{name}: expected the two states to share at least one key",
4077 );
4078 for key in shared {
4079 assert_ne!(
4080 label_for(&idle, &key),
4081 label_for(&stopped, &key),
4082 "{name}: `{key}` resolves to the same row in both states — the gate is no longer distinguishing them",
4083 );
4084 }
4085 }
4086 }
4087
4088 #[test]
4091 fn stopped_sequencers_offer_only_the_ways_out() {
4092 let ids = MagitActionIds::default();
4093 let cp = keys(&cherry_pick_transient(&ids, true));
4094 assert_eq!(cp, vec!["A", "s", "a"]);
4095 let rv = keys(&revert_transient(&ids, true));
4096 assert_eq!(rv, vec!["V", "s", "a"]);
4097 }
4098
4099 #[test]
4106 fn each_sequence_fires_its_own_commands() {
4107 let cp: Vec<&str> = CHERRY_PICK_SEQUENCE_ROWS.iter().map(|r| r.action).collect();
4108 let rv: Vec<&str> = REVERT_SEQUENCE_ROWS.iter().map(|r| r.action).collect();
4109 assert!(cp.iter().all(|a| a.contains("cherry-pick")), "{cp:?}");
4110 assert!(rv.iter().all(|a| a.contains("revert")), "{rv:?}");
4111 assert!(
4112 cp.iter().all(|a| !rv.contains(a)),
4113 "the two sequences must not share an action: {cp:?} vs {rv:?}",
4114 );
4115 }
4116
4117 #[test]
4121 fn gates_default_to_nothing_in_progress() {
4122 let g = DispatchGates::default();
4123 assert!(!g.cherry_pick && !g.revert && !g.rebase);
4124 }
4125}
4126
4127#[cfg(test)]
4128mod sequence_step_tests {
4129 use crate::magit_global_mode::{
4130 instant_squash_steps, merge_absorb_steps, stash_snapshot_steps,
4131 };
4132
4133 #[test]
4137 fn a_snapshot_applies_rather_than_pops() {
4138 let steps = stash_snapshot_steps(&[]);
4139 assert_eq!(steps.len(), 2);
4140 assert_eq!(steps[0].argv, vec!["stash", "push"]);
4141 assert_eq!(steps[1].argv, vec!["stash", "apply"]);
4142 assert!(
4143 !steps[1].argv.contains(&"pop".to_string()),
4144 "pop would destroy the snapshot it just made",
4145 );
4146 }
4147
4148 #[test]
4150 fn snapshot_variants_pass_their_flags_to_the_push_only() {
4151 for extra in [vec!["--staged"], vec!["--keep-index"]] {
4152 let steps = stash_snapshot_steps(&extra);
4153 assert!(steps[0].argv.contains(&extra[0].to_string()));
4154 assert_eq!(
4155 steps[1].argv,
4156 vec!["stash", "apply"],
4157 "restore is unflagged"
4158 );
4159 }
4160 }
4161
4162 #[test]
4169 fn instant_squash_rebases_from_one_before_the_target() {
4170 let steps = instant_squash_steps("fixup", "abc123");
4171 assert_eq!(steps.len(), 2);
4172 assert!(steps[0].argv.contains(&"--fixup".to_string()));
4173 assert!(steps[0].argv.contains(&"abc123".to_string()));
4174 assert!(
4175 steps[1].argv.contains(&"abc123~1".to_string()),
4176 "must rebase from one before the target: {:?}",
4177 steps[1].argv,
4178 );
4179 assert!(
4180 steps[1].argv.contains(&"--autostash".to_string()),
4181 "an instant fixup is reached mid-edit; without autostash it \
4182 fails exactly when it is most wanted",
4183 );
4184 }
4185
4186 #[test]
4187 fn instant_squash_kind_selects_the_marker() {
4188 assert!(
4189 instant_squash_steps("squash", "x")[0]
4190 .argv
4191 .contains(&"--squash".to_string())
4192 );
4193 assert!(
4194 instant_squash_steps("fixup", "x")[0]
4195 .argv
4196 .contains(&"--fixup".to_string())
4197 );
4198 }
4199
4200 #[test]
4206 fn absorb_uses_a_safe_delete() {
4207 let steps = merge_absorb_steps("feature");
4208 assert_eq!(steps.len(), 2);
4209 assert!(steps[1].argv.contains(&"-d".to_string()));
4210 assert!(
4211 !steps[1].argv.contains(&"-D".to_string()),
4212 "a forced delete would destroy the branch when the merge failed",
4213 );
4214 }
4215}
4216
4217#[cfg(test)]
4218mod two_input_argv_tests {
4219 use crate::magit_global_mode::{reset_file_argv, stash_branch_argv};
4220
4221 #[test]
4230 fn resetting_a_file_checks_it_out() {
4231 let argv = reset_file_argv("abc123", "src/main.rs");
4232 assert_eq!(argv, vec!["checkout", "abc123", "--", "src/main.rs"]);
4233 assert_ne!(argv[0], "reset", "reset would not touch the working tree");
4234 }
4235
4236 #[test]
4239 fn the_path_is_separated_from_the_revision() {
4240 let argv = reset_file_argv("HEAD", "main");
4242 let dashes = argv.iter().position(|a| a == "--").expect("has --");
4243 assert!(
4244 dashes < argv.iter().rposition(|a| a == "main").unwrap(),
4245 "the path must come after `--`: {argv:?}",
4246 );
4247 }
4248
4249 #[test]
4250 fn stash_branch_takes_the_name_then_the_stash() {
4251 assert_eq!(
4252 stash_branch_argv("recover", "stash@{0}"),
4253 vec!["stash", "branch", "recover", "stash@{0}"],
4254 );
4255 }
4256}
4257
4258#[cfg(test)]
4259mod commit_intent_tests {
4260 use crate::magit_commit_mode::CommitIntent;
4261
4262 #[test]
4268 fn buffer_names_map_to_intents() {
4269 assert_eq!(
4270 CommitIntent::from_buffer_name("*magit:reword*"),
4271 CommitIntent::Reword
4272 );
4273 assert_eq!(
4274 CommitIntent::from_buffer_name("*magit:amend*"),
4275 CommitIntent::Amend
4276 );
4277 assert_eq!(
4278 CommitIntent::from_buffer_name("*magit:commit*"),
4279 CommitIntent::Create
4280 );
4281 }
4282
4283 #[test]
4285 fn replacing_intents_seed_the_prior_message() {
4286 assert!(CommitIntent::Amend.seeds_prior_message());
4287 assert!(CommitIntent::Reword.seeds_prior_message());
4288 assert!(!CommitIntent::Create.seeds_prior_message());
4289 }
4290
4291 #[test]
4297 fn reword_is_distinct_from_amend() {
4298 assert_ne!(CommitIntent::Reword, CommitIntent::Amend);
4299 }
4300
4301 #[test]
4308 fn targeted_intents_round_trip_through_the_buffer_name() {
4309 let name = CommitIntent::augment_buffer_name("lattice", "abc123");
4310 assert_eq!(
4311 CommitIntent::from_buffer_name(&name),
4312 CommitIntent::Augment {
4313 target: "abc123".to_string()
4314 }
4315 );
4316
4317 let name = CommitIntent::merge_edit_buffer_name("lattice", "feature/x");
4318 assert_eq!(
4319 CommitIntent::from_buffer_name(&name),
4320 CommitIntent::MergeEdit {
4321 branch: "feature/x".to_string()
4322 }
4323 );
4324 }
4325
4326 #[test]
4334 fn an_empty_target_does_not_produce_a_targeted_intent() {
4335 assert_eq!(
4336 CommitIntent::from_buffer_name("*magit:augment:lattice:*"),
4337 CommitIntent::Create
4338 );
4339 assert_eq!(
4340 CommitIntent::from_buffer_name("*magit:merge-edit:lattice:*"),
4341 CommitIntent::Create
4342 );
4343 }
4344
4345 #[test]
4356 fn a_target_containing_amend_stays_targeted() {
4357 assert_eq!(
4358 CommitIntent::from_buffer_name(&CommitIntent::merge_edit_buffer_name(
4359 "amend",
4360 "amend-fixes"
4361 )),
4362 CommitIntent::MergeEdit {
4363 branch: "amend-fixes".to_string()
4364 }
4365 );
4366 }
4367
4368 #[test]
4376 fn reword_a_commit_is_not_reword_head() {
4377 let name = CommitIntent::reword_commit_buffer_name("lattice", "abc123");
4378 assert_eq!(
4379 CommitIntent::from_buffer_name(&name),
4380 CommitIntent::RewordCommit {
4381 target: "abc123".to_string()
4382 },
4383 );
4384 assert_ne!(CommitIntent::from_buffer_name(&name), CommitIntent::Reword);
4385 }
4386
4387 #[test]
4393 fn reword_a_commit_seeds_from_its_own_target() {
4394 let intent = CommitIntent::RewordCommit {
4395 target: "abc123".to_string(),
4396 };
4397 assert!(intent.seeds_prior_message());
4398 assert_eq!(intent.seed_source(), Some("abc123"));
4399 assert_eq!(CommitIntent::Reword.seed_source(), None);
4401 assert_eq!(CommitIntent::Amend.seed_source(), None);
4402 }
4403
4404 #[test]
4411 fn targeted_intents_do_not_seed_a_prior_message() {
4412 assert!(
4413 !CommitIntent::Augment {
4414 target: "abc123".to_string()
4415 }
4416 .seeds_prior_message()
4417 );
4418 assert!(
4419 !CommitIntent::MergeEdit {
4420 branch: "main".to_string()
4421 }
4422 .seeds_prior_message()
4423 );
4424 }
4425}
4426
4427#[cfg(test)]
4428mod root_menu_chord_tests {
4429 use super::*;
4430
4431 #[test]
4449 fn no_root_menu_takes_a_key_vim_uses_as_a_motion() {
4450 const VIM_MOTIONS: &[(&str, &str)] = &[
4453 ("l", "right"),
4454 ("b", "back-word"),
4455 ("B", "back-WORD"),
4456 ("w", "word"),
4457 ("f", "find-char"),
4458 ("F", "find-char-back"),
4459 ("t", "till"),
4460 ("T", "till-back"),
4461 ("m", "set-mark"),
4462 ("z", "fold prefix (`zf` / `za` / `zo`)"),
4463 ];
4464 for menu in ROOT_MENUS {
4465 let Some(chord) = menu.chord else { continue };
4466 if let Some((_, meaning)) = VIM_MOTIONS.iter().find(|(k, _)| *k == chord) {
4467 panic!(
4468 "`{}` binds `{chord}`, which is vim's {meaning} — a minor \
4469 layer shadows the builtin one, so this would remove the \
4470 motion from every magit buffer. Leave it `None`; the menu \
4471 stays reachable through the dispatch.",
4472 menu.source,
4473 );
4474 }
4475 }
4476 }
4477
4478 #[test]
4482 fn the_operator_keys_are_actually_taken() {
4483 for (chord, source) in [
4484 ("A", "magit-menu-cherry-pick"),
4485 ("_", "magit-menu-revert"),
4486 ("O", "magit-menu-reset"),
4487 ] {
4488 let menu = ROOT_MENUS
4489 .iter()
4490 .find(|m| m.source == source)
4491 .unwrap_or_else(|| panic!("{source} is in ROOT_MENUS"));
4492 assert_eq!(
4493 menu.chord,
4494 Some(chord),
4495 "`{chord}` is a vim editing operator — inert in a read-only \
4496 magit buffer — so {source} is free to take it",
4497 );
4498 }
4499 }
4500
4501 #[test]
4509 fn the_diff_menu_carries_d_f_v_and_the_new_e_row() {
4510 let by_key: std::collections::HashMap<&str, &str> =
4511 DIFF_SHOW_ROWS.iter().map(|r| (r.key, r.action)).collect();
4512 assert_eq!(by_key.len(), 4, "four targets: {by_key:?}");
4513 assert_eq!(by_key.get("d"), Some(&"action:magit-global-diff"));
4514 assert_eq!(by_key.get("f"), Some(&"action:magit-diff-file"));
4515 assert_eq!(by_key.get("v"), Some(&"action:magit-diff-side-by-side"));
4516 assert_eq!(
4517 by_key.get("e"),
4518 Some(&"action:magit-project-diff"),
4519 "`e` opens the editable cross-file view"
4520 );
4521 }
4522
4523 #[test]
4525 fn every_root_menu_builds() {
4526 let ids = MagitActionIds::default();
4527 let ctx = TransientContext::default();
4528 for menu in ROOT_MENUS {
4529 assert!(
4530 root_menu_spec(menu.source, &ids, &ctx, &DispatchGates::default()).is_some(),
4531 "{} has no spec — its chord and its dispatch row would both \
4532 open nothing",
4533 menu.source,
4534 );
4535 }
4536 }
4537}