1use std::path::PathBuf;
13use std::sync::Arc;
14
15use lattice_completion::{CandidateKind, RawCandidate};
16use lattice_picker::{
17 PickerAcceptOutcome, PickerContext, PickerInitResult, PickerSourceGenerator, PickerSourceSpec,
18 RoutingPayload, SourceResult,
19};
20use lattice_vcs::{Branch, RefKind, Reference, Remote, Repository};
21
22#[derive(Clone, Default)]
39pub struct RepoLens {
40 store: Option<lattice_mode::BufferStoreHandle>,
41 scopes: Option<crate::repo_scope::RepoScopesHandle>,
42}
43
44impl RepoLens {
45 pub fn new(
46 store: lattice_mode::BufferStoreHandle,
47 scopes: crate::repo_scope::RepoScopesHandle,
48 ) -> Self {
49 Self {
50 store: Some(store),
51 scopes: Some(scopes),
52 }
53 }
54
55 pub fn workdir(&self, ctx: &PickerContext<'_>) -> std::path::PathBuf {
62 if let (Some(store), Some(scopes)) = (&self.store, &self.scopes) {
63 let buffer = lattice_core::BufferId(ctx.active_buffer.buffer_id);
64 if let Some(workdir) = crate::repo_scope::active_workdir(store, scopes, buffer) {
65 return workdir;
66 }
67 }
68 ctx.active_buffer
69 .path
70 .and_then(|p| crate::workdir::workdir_for_file(p).map(|(workdir, _rel)| workdir))
71 .or_else(crate::workdir::magit_workdir)
72 .unwrap_or_default()
73 }
74}
75
76pub struct BranchPickBaseSource {
77 spec: PickerSourceSpec,
78 repo: RepoLens,
79}
80
81impl BranchPickBaseSource {
82 pub fn new(repo: RepoLens) -> Self {
83 Self {
84 repo,
85 spec: PickerSourceSpec::no_args(
91 "magit-branch-pick-base",
92 "Pick an existing branch as the base for a new branch (magit branch-create wizard).",
93 ).with_rooted(true).with_help_topic(MAGIT_PICKER_HELP),
94 }
95 }
96}
97
98impl Default for BranchPickBaseSource {
99 fn default() -> Self {
100 Self::new(RepoLens::default())
101 }
102}
103
104impl PickerSourceGenerator for BranchPickBaseSource {
105 fn spec(&self) -> &PickerSourceSpec {
106 &self.spec
107 }
108
109 fn init(&self, ctx: &PickerContext<'_>, _args: &[String]) -> SourceResult<PickerInitResult> {
110 let workdir = self.repo.workdir(ctx);
113 Ok(PickerInitResult::Future(Box::pin(async move {
114 let branches = tokio::task::spawn_blocking(move || {
115 let repo = Repository::discover(&workdir)
116 .map_err(|e| format!("magit-branch-pick-base: repo discover failed: {e}"))?;
117 Branch::list(&repo).map_err(|e| format!("magit-branch-pick-base: {e}"))
118 })
119 .await
120 .map_err(|e| format!("magit-branch-pick-base: join error: {e}"))??;
121 Ok(branches
122 .into_iter()
123 .map(|name| {
124 let cand = RawCandidate::plain(name.clone(), CandidateKind::Plain);
125 (cand, RoutingPayload::BranchBase { name })
126 })
127 .collect())
128 })))
129 }
130
131 fn accept(
132 &self,
133 _ctx: &PickerContext<'_>,
134 routing: &RoutingPayload,
135 ) -> SourceResult<PickerAcceptOutcome> {
136 match routing {
137 RoutingPayload::BranchBase { name } => Ok(branch_create_prompt_outcome(name)),
138 other => Err(format!(
139 "magit-branch-pick-base: unexpected routing payload {other:?}"
140 )),
141 }
142 }
143}
144
145pub struct BranchCheckoutSource {
152 spec: PickerSourceSpec,
153 repo: RepoLens,
154}
155
156impl BranchCheckoutSource {
157 pub fn new(repo: RepoLens) -> Self {
158 Self {
159 repo,
160 spec: PickerSourceSpec::no_args(
161 BRANCH_CHECKOUT_SOURCE,
162 "Pick a branch and check it out.",
163 )
164 .with_rooted(true)
165 .with_help_topic(MAGIT_PICKER_HELP),
166 }
167 }
168}
169
170impl Default for BranchCheckoutSource {
171 fn default() -> Self {
172 Self::new(RepoLens::default())
173 }
174}
175
176pub const BRANCH_CHECKOUT_SOURCE: &str = "magit-branch-checkout-pick";
177
178impl PickerSourceGenerator for BranchCheckoutSource {
179 fn spec(&self) -> &PickerSourceSpec {
180 &self.spec
181 }
182
183 fn init(&self, ctx: &PickerContext<'_>, args: &[String]) -> SourceResult<PickerInitResult> {
184 BranchPickBaseSource::new(self.repo.clone()).init(ctx, args)
188 }
189
190 fn accept(
191 &self,
192 _ctx: &PickerContext<'_>,
193 routing: &RoutingPayload,
194 ) -> SourceResult<PickerAcceptOutcome> {
195 match routing {
196 RoutingPayload::BranchBase { name } => Ok(branch_checkout_outcome(name)),
197 other => Err(format!(
198 "{BRANCH_CHECKOUT_SOURCE}: unexpected routing payload {other:?}"
199 )),
200 }
201 }
202}
203
204fn branch_checkout_outcome(name: &str) -> PickerAcceptOutcome {
210 PickerAcceptOutcome::InvokeCommand {
211 id: "magit-checkout".to_string(),
212 args: lattice_grammar::Args::String(name.to_string()),
213 }
214}
215
216fn branch_create_prompt_outcome(base: &str) -> PickerAcceptOutcome {
221 PickerAcceptOutcome::OpenPrompt {
222 prompt: format!("New branch name (from {base}):"),
223 initial: String::new(),
224 on_submit_action: "action:magit-branch-create-finish".to_string(),
225 buffer_name: Some(format!(
226 "{}{base}*",
227 crate::magit_global_mode::BRANCH_CREATE_PROMPT_PREFIX
228 )),
229 }
230}
231
232pub struct BranchCreateNoCheckoutSource {
249 spec: PickerSourceSpec,
250 repo: RepoLens,
251}
252
253impl BranchCreateNoCheckoutSource {
254 pub fn new(repo: RepoLens) -> Self {
255 Self {
256 repo,
257 spec: PickerSourceSpec::no_args(
258 BRANCH_CREATE_NO_CHECKOUT_SOURCE,
259 "Pick a base, then name a new branch — without checking it out.",
260 )
261 .with_rooted(true)
262 .with_help_topic(MAGIT_PICKER_HELP),
263 }
264 }
265}
266
267impl Default for BranchCreateNoCheckoutSource {
268 fn default() -> Self {
269 Self::new(RepoLens::default())
270 }
271}
272
273pub const BRANCH_CREATE_NO_CHECKOUT_SOURCE: &str = "magit-branch-create-no-checkout-pick";
274
275impl PickerSourceGenerator for BranchCreateNoCheckoutSource {
276 fn spec(&self) -> &PickerSourceSpec {
277 &self.spec
278 }
279
280 fn init(&self, ctx: &PickerContext<'_>, args: &[String]) -> SourceResult<PickerInitResult> {
281 BranchPickBaseSource::new(self.repo.clone()).init(ctx, args)
282 }
283
284 fn accept(
285 &self,
286 _ctx: &PickerContext<'_>,
287 routing: &RoutingPayload,
288 ) -> SourceResult<PickerAcceptOutcome> {
289 match routing {
290 RoutingPayload::BranchBase { name } => Ok(branch_create_no_checkout_outcome(name)),
291 other => Err(format!(
292 "{BRANCH_CREATE_NO_CHECKOUT_SOURCE}: unexpected routing payload {other:?}"
293 )),
294 }
295 }
296}
297
298fn branch_create_no_checkout_outcome(base: &str) -> PickerAcceptOutcome {
299 PickerAcceptOutcome::OpenPrompt {
300 prompt: format!("New branch name (from {base}, no checkout):"),
301 initial: String::new(),
302 on_submit_action: "action:magit-branch-create-no-checkout-finish".to_string(),
303 buffer_name: Some(format!(
304 "{}{base}*",
305 crate::magit_global_mode::BRANCH_CREATE_NO_CHECKOUT_PROMPT_PREFIX
306 )),
307 }
308}
309
310pub struct BranchRenameSource {
316 spec: PickerSourceSpec,
317 repo: RepoLens,
318}
319
320impl BranchRenameSource {
321 pub fn new(repo: RepoLens) -> Self {
322 Self {
323 repo,
324 spec: PickerSourceSpec::no_args(
325 BRANCH_RENAME_SOURCE,
326 "Pick a branch, then type its new name.",
327 )
328 .with_rooted(true)
329 .with_help_topic(MAGIT_PICKER_HELP),
330 }
331 }
332}
333
334impl Default for BranchRenameSource {
335 fn default() -> Self {
336 Self::new(RepoLens::default())
337 }
338}
339
340pub const BRANCH_RENAME_SOURCE: &str = "magit-branch-rename-pick";
341
342impl PickerSourceGenerator for BranchRenameSource {
343 fn spec(&self) -> &PickerSourceSpec {
344 &self.spec
345 }
346
347 fn init(&self, ctx: &PickerContext<'_>, args: &[String]) -> SourceResult<PickerInitResult> {
348 BranchPickBaseSource::new(self.repo.clone()).init(ctx, args)
349 }
350
351 fn accept(
352 &self,
353 _ctx: &PickerContext<'_>,
354 routing: &RoutingPayload,
355 ) -> SourceResult<PickerAcceptOutcome> {
356 match routing {
357 RoutingPayload::BranchBase { name } => Ok(branch_rename_outcome(name)),
358 other => Err(format!(
359 "{BRANCH_RENAME_SOURCE}: unexpected routing payload {other:?}"
360 )),
361 }
362 }
363}
364
365fn branch_rename_outcome(old: &str) -> PickerAcceptOutcome {
369 PickerAcceptOutcome::OpenPrompt {
370 prompt: format!("Rename {old} to:"),
371 initial: old.to_string(),
372 on_submit_action: "action:magit-branch-rename-finish".to_string(),
373 buffer_name: Some(format!(
374 "{}{old}*",
375 crate::magit_global_mode::BRANCH_RENAME_PROMPT_PREFIX
376 )),
377 }
378}
379
380pub struct BranchDeleteSource {
388 spec: PickerSourceSpec,
389 repo: RepoLens,
390}
391
392impl BranchDeleteSource {
393 pub fn new(repo: RepoLens) -> Self {
394 Self {
395 repo,
396 spec: PickerSourceSpec::no_args(
397 BRANCH_DELETE_SOURCE,
398 "Pick a branch to delete — asks before deleting.",
399 )
400 .with_rooted(true)
401 .with_help_topic(MAGIT_PICKER_HELP),
402 }
403 }
404}
405
406impl Default for BranchDeleteSource {
407 fn default() -> Self {
408 Self::new(RepoLens::default())
409 }
410}
411
412pub const BRANCH_DELETE_SOURCE: &str = "magit-branch-delete-pick";
413
414impl PickerSourceGenerator for BranchDeleteSource {
415 fn spec(&self) -> &PickerSourceSpec {
416 &self.spec
417 }
418
419 fn init(&self, ctx: &PickerContext<'_>, args: &[String]) -> SourceResult<PickerInitResult> {
420 BranchPickBaseSource::new(self.repo.clone()).init(ctx, args)
421 }
422
423 fn accept(
424 &self,
425 _ctx: &PickerContext<'_>,
426 routing: &RoutingPayload,
427 ) -> SourceResult<PickerAcceptOutcome> {
428 match routing {
429 RoutingPayload::BranchBase { name } => Ok(branch_delete_outcome(name)),
430 other => Err(format!(
431 "{BRANCH_DELETE_SOURCE}: unexpected routing payload {other:?}"
432 )),
433 }
434 }
435}
436
437fn branch_delete_outcome(name: &str) -> PickerAcceptOutcome {
438 PickerAcceptOutcome::InvokeCommand {
439 id: "magit-branch-delete".to_string(),
440 args: lattice_grammar::Args::String(name.to_string()),
441 }
442}
443
444pub(crate) fn picked_line(command: &str, value: &str) -> String {
457 match command.find("{}") {
458 Some(_) => command.replacen("{}", value, 1),
459 None => format!("{command} {value}"),
460 }
461}
462
463pub fn register(
476 picker_registry: &mut lattice_picker::PickerRegistry,
477 config: Option<Arc<lattice_config::ConfigRegistry>>,
478 repo: RepoLens,
479) {
480 picker_registry.register_generator(Arc::new(BranchPickBaseSource::new(repo.clone())));
481 picker_registry.register_generator(Arc::new(BranchCheckoutSource::new(repo.clone())));
482 picker_registry.register_generator(Arc::new(BranchCreateNoCheckoutSource::new(repo.clone())));
484 picker_registry.register_generator(Arc::new(BranchRenameSource::new(repo.clone())));
485 picker_registry.register_generator(Arc::new(BranchDeleteSource::new(repo.clone())));
486 picker_registry.register_generator(Arc::new(CommitPickSource::new(repo.clone())));
487 picker_registry.register_generator(Arc::new(StashPickSource::new(repo.clone())));
490 picker_registry.register_generator(Arc::new(BranchPickSource::new(repo.clone())));
494 picker_registry.register_generator(Arc::new(RefPickSource::new(repo.clone(), RefScope::Tags)));
496 picker_registry.register_generator(Arc::new(RefPickSource::new(
497 repo.clone(),
498 RefScope::Remotes,
499 )));
500 picker_registry.register_generator(Arc::new(RefPickSource::new(
501 repo.clone(),
502 RefScope::AllRefs,
503 )));
504 picker_registry.register_generator(Arc::new(RefPickSource::with_config(
507 repo,
508 RefScope::Revisions,
509 config,
510 )));
511}
512
513pub struct CommitPickSource {
532 spec: PickerSourceSpec,
533 repo: RepoLens,
534}
535
536const MAGIT_PICKER_HELP: &str = "picker-magit";
555
556fn takes_ex_command(id: &'static str, doc: &'static str, noun: &'static str) -> PickerSourceSpec {
557 PickerSourceSpec {
558 create_label: None,
559 id: std::borrow::Cow::Borrowed(id),
560 doc: std::borrow::Cow::Borrowed(doc),
561 args_schema: vec![lattice_grammar::ArgSpec::required(
562 "command",
563 lattice_grammar::ArgKind::String,
564 noun,
565 )],
566 args_hint: std::borrow::Cow::Borrowed("<magit ex-command>"),
567 live: false,
568 delete_command: None,
575 help_topic: Some(std::borrow::Cow::Borrowed(MAGIT_PICKER_HELP)),
576 rooted: true,
582 }
583}
584
585impl CommitPickSource {
586 pub fn new(repo: RepoLens) -> Self {
587 Self {
588 repo,
589 spec: takes_ex_command(
590 COMMIT_PICK_SOURCE,
591 "Pick a commit, then run the named magit ex-command on it.",
592 "magit ex-command to run on the picked commit",
593 ),
594 }
595 }
596}
597
598impl Default for CommitPickSource {
599 fn default() -> Self {
600 Self::new(RepoLens::default())
601 }
602}
603
604pub const COMMIT_PICK_SOURCE: &str = "magit-commit";
608
609const COMMIT_PICK_LIMIT: usize = 200;
615
616impl PickerSourceGenerator for CommitPickSource {
617 fn spec(&self) -> &PickerSourceSpec {
618 &self.spec
619 }
620
621 fn init(&self, ctx: &PickerContext<'_>, args: &[String]) -> SourceResult<PickerInitResult> {
622 let workdir = self.repo.workdir(ctx);
623 let command = args
624 .first()
625 .filter(|c| !c.is_empty())
626 .ok_or_else(|| {
627 format!("{COMMIT_PICK_SOURCE}: needs the ex-command to run on the picked commit")
628 })?
629 .clone();
630 Ok(PickerInitResult::Future(Box::pin(async move {
631 let rows = tokio::task::spawn_blocking(move || recent_commits(workdir))
632 .await
633 .map_err(|e| format!("{COMMIT_PICK_SOURCE}: join error: {e}"))??;
634 Ok(rows
635 .into_iter()
636 .map(|(sha, display)| {
637 let cand = RawCandidate::plain(display, CandidateKind::Plain);
638 (
639 cand,
640 RoutingPayload::InvokeCommand {
646 id: picked_line(&command, &sha),
647 args: lattice_grammar::args::Args::None,
648 },
649 )
650 })
651 .collect())
652 })))
653 }
654
655 fn accept(
656 &self,
657 _ctx: &PickerContext<'_>,
658 routing: &RoutingPayload,
659 ) -> SourceResult<PickerAcceptOutcome> {
660 match routing {
661 RoutingPayload::InvokeCommand { id, args } => Ok(PickerAcceptOutcome::InvokeCommand {
662 id: id.clone(),
663 args: args.clone(),
664 }),
665 other => Err(format!(
666 "{COMMIT_PICK_SOURCE}: unexpected routing payload {other:?}"
667 )),
668 }
669 }
670}
671
672pub struct StashPickSource {
687 spec: PickerSourceSpec,
688 repo: RepoLens,
689}
690
691impl StashPickSource {
692 pub fn new(repo: RepoLens) -> Self {
693 Self {
694 repo,
695 spec: takes_ex_command(
696 STASH_PICK_SOURCE,
697 "Pick a stash, then run the named magit ex-command on it.",
698 "magit ex-command to run on the picked stash",
699 ),
700 }
701 }
702}
703
704impl Default for StashPickSource {
705 fn default() -> Self {
706 Self::new(RepoLens::default())
707 }
708}
709
710pub const STASH_PICK_SOURCE: &str = "magit-stash-pick";
711
712impl PickerSourceGenerator for StashPickSource {
713 fn spec(&self) -> &PickerSourceSpec {
714 &self.spec
715 }
716
717 fn init(&self, ctx: &PickerContext<'_>, args: &[String]) -> SourceResult<PickerInitResult> {
718 let workdir = self.repo.workdir(ctx);
721 let command = args
722 .first()
723 .filter(|c| !c.is_empty())
724 .ok_or_else(|| {
725 format!("{STASH_PICK_SOURCE}: needs the ex-command to run on the picked stash")
726 })?
727 .clone();
728 Ok(PickerInitResult::Future(Box::pin(async move {
729 let entries = tokio::task::spawn_blocking(move || {
730 let repo = Repository::discover(&workdir)
731 .map_err(|e| format!("{STASH_PICK_SOURCE}: repo discover failed: {e}"))?;
732 lattice_vcs::Stash::list(&repo).map_err(|e| format!("{STASH_PICK_SOURCE}: {e}"))
733 })
734 .await
735 .map_err(|e| format!("{STASH_PICK_SOURCE}: join error: {e}"))??;
736 Ok(entries
737 .into_iter()
738 .map(|entry| {
739 let cand = RawCandidate::plain(
740 format!("stash@{{{}}} {}", entry.index, entry.message),
741 CandidateKind::Plain,
742 );
743 (
744 cand,
745 RoutingPayload::InvokeCommand {
746 id: picked_line(&command, &entry.index.to_string()),
747 args: lattice_grammar::args::Args::None,
748 },
749 )
750 })
751 .collect())
752 })))
753 }
754
755 fn accept(
756 &self,
757 _ctx: &PickerContext<'_>,
758 routing: &RoutingPayload,
759 ) -> SourceResult<PickerAcceptOutcome> {
760 match routing {
761 RoutingPayload::InvokeCommand { id, args } => Ok(PickerAcceptOutcome::InvokeCommand {
762 id: id.clone(),
763 args: args.clone(),
764 }),
765 other => Err(format!(
766 "{STASH_PICK_SOURCE}: unexpected routing payload {other:?}"
767 )),
768 }
769 }
770}
771
772fn recent_commits(workdir: std::path::PathBuf) -> Result<Vec<(String, String)>, String> {
779 let out = std::process::Command::new("git")
780 .args([
781 "log",
782 &format!("-n{COMMIT_PICK_LIMIT}"),
783 "--format=%H%x00%h %s",
784 ])
785 .current_dir(&workdir)
789 .output()
790 .map_err(|e| format!("{COMMIT_PICK_SOURCE}: {e}"))?;
791 if !out.status.success() {
792 return Err(format!("{COMMIT_PICK_SOURCE}: git log failed"));
793 }
794 Ok(parse_commit_rows(&String::from_utf8_lossy(&out.stdout)))
795}
796
797pub(crate) fn parse_commit_rows(raw: &str) -> Vec<(String, String)> {
803 raw.lines()
804 .filter_map(|line| {
805 let (sha, display) = line.split_once('\0')?;
806 (!sha.is_empty() && !display.is_empty()).then(|| (sha.to_string(), display.to_string()))
807 })
808 .collect()
809}
810
811#[cfg(test)]
812mod commit_pick {
813 use super::*;
814
815 #[test]
816 fn spec_id_matches_the_name_every_open_effect_uses() {
817 let source = CommitPickSource::new(RepoLens::default());
818 assert_eq!(source.spec().id, COMMIT_PICK_SOURCE);
819 assert_eq!(
820 source.spec().args_schema.len(),
821 1,
822 "the ex-command to run is the source's one argument"
823 );
824 }
825
826 #[test]
833 fn a_picked_commit_becomes_the_ex_line_that_acts_on_it() {
834 let rows = parse_commit_rows("abc123def\0abc123d fix the thing\n");
835 assert_eq!(rows.len(), 1);
836 let (sha, display) = &rows[0];
837 assert_eq!(sha, "abc123def", "the FULL sha, not the abbreviation");
838 assert_eq!(display, "abc123d fix the thing");
839 assert_eq!(
840 format!("magit-cherry-pick {sha}"),
841 "magit-cherry-pick abc123def"
842 );
843 }
844
845 #[test]
848 fn rows_keep_the_sha_and_the_display_on_their_own_sides() {
849 let rows = parse_commit_rows("1111111111\x001111111 first\n2222222222\x002222222 second\n");
852 assert_eq!(rows[0].0, "1111111111");
853 assert!(rows[0].1.ends_with("first"));
854 assert_eq!(rows[1].0, "2222222222");
855 assert!(rows[1].1.ends_with("second"));
856 }
857
858 #[test]
859 fn malformed_rows_are_dropped_rather_than_half_parsed() {
860 assert!(parse_commit_rows("no-nul-here\n").is_empty());
861 assert!(parse_commit_rows("\0only-display\n").is_empty());
862 assert!(parse_commit_rows("only-sha\0\n").is_empty());
863 }
864
865 #[test]
870 fn every_commit_op_names_a_distinct_ex_command() {
871 use crate::magit_global_mode::CommitOp;
872 let ops = [
873 CommitOp::CHERRY_PICK,
874 CommitOp::REVERT,
875 CommitOp::RESET_SOFT,
876 CommitOp::RESET_MIXED,
877 CommitOp::RESET_HARD,
878 ];
879 let names: Vec<&str> = ops.iter().map(|o| o.ex_command).collect();
880 assert_eq!(
881 names,
882 [
883 "magit-cherry-pick",
884 "magit-revert",
885 "magit-reset-soft",
886 "magit-reset-mixed",
887 "magit-reset-hard"
888 ]
889 );
890 let unique: std::collections::HashSet<_> = names.iter().collect();
891 assert_eq!(unique.len(), names.len(), "one command per op");
892 }
893}
894
895#[cfg(test)]
896mod tests {
897 use super::*;
898
899 #[test]
915 fn magit_registers_exactly_the_sources_its_rows_open() {
916 let mut registry = lattice_picker::PickerRegistry::new();
917 register(&mut registry, None, RepoLens::default());
918 let mut ids: Vec<&str> = registry.ids().collect();
919 ids.sort_unstable();
920
921 assert_eq!(
922 ids,
923 vec![
924 BRANCH_PICK_SOURCE, BRANCH_CHECKOUT_SOURCE, BRANCH_CREATE_NO_CHECKOUT_SOURCE, BRANCH_DELETE_SOURCE, "magit-branch-pick-base", BRANCH_RENAME_SOURCE, COMMIT_PICK_SOURCE, REF_PICK_SOURCE, REMOTE_PICK_SOURCE, REVISION_PICK_SOURCE, STASH_PICK_SOURCE, TAG_PICK_SOURCE, ],
938 "magit's registered picker sources changed — update this list \
939 together with `register`, and check every `Effect::OpenPicker` \
940 that names one"
941 );
942 }
943
944 fn empty_picker_ctx(buffer: &lattice_core::Buffer) -> lattice_picker::PickerContext<'_> {
949 lattice_picker::PickerContext {
950 active_buffer: lattice_picker::ActiveBufferSnapshot {
951 buffer_id: 0,
952 path: None,
953 language: None,
954 cursor: lattice_protocol::position::Position::new(0, 0),
955 selection: None,
956 buffer,
957 syntax_symbols: Vec::new(),
958 syntax_highlights: Vec::new(),
959 },
960 workspace_root: std::path::PathBuf::from("."),
961 recent_files: &[],
962 position_history: Vec::new(),
963 buffers: Vec::new(),
964 marks: Vec::new(),
965 registers: Vec::new(),
966 yank_ring: Vec::new(),
967 active_modes: Vec::new(),
968 command_history: Vec::new(),
969 search_history: Vec::new(),
970 pane_buffer_history: Vec::new(),
971 }
972 }
973
974 #[test]
988 fn a_source_that_needs_an_ex_command_declares_it() {
989 let mut registry = lattice_picker::PickerRegistry::new();
990 register(&mut registry, None, RepoLens::default());
991 let buffer = lattice_core::Buffer::empty();
992 let ctx = empty_picker_ctx(&buffer);
993 let ids: Vec<String> = registry.ids().map(str::to_string).collect();
994 for id in ids {
995 let id = id.as_str();
996 let source = registry.generator(id).expect("just enumerated");
997 let declares = source
998 .spec()
999 .args_schema
1000 .first()
1001 .is_some_and(|a| matches!(a.default, lattice_grammar::ArgDefault::Required));
1002 let refuses = source.init(&ctx, &[]).is_err();
1003 assert_eq!(
1004 declares, refuses,
1005 "`{id}`: spec declares a required arg = {declares}, but \
1006 init with no args refuses = {refuses}. These must agree — \
1007 a source that needs an ex-command has to advertise it so \
1008 `:picker {id} <Tab>` can complete one."
1009 );
1010 if declares {
1011 assert!(
1012 !source.spec().args_hint.is_empty(),
1013 "`{id}` declares a required arg but shows no args_hint, \
1014 so the command line has nothing to display while the \
1015 user types it"
1016 );
1017 }
1018 }
1019 }
1020
1021 #[test]
1029 fn the_revision_scope_spans_refs_and_commits_and_the_others_do_not() {
1030 use super::{RefPickSource, RefScope};
1031 let ids: Vec<String> = [
1033 RefScope::Tags,
1034 RefScope::Remotes,
1035 RefScope::AllRefs,
1036 RefScope::Revisions,
1037 ]
1038 .into_iter()
1039 .map(|sc| {
1040 RefPickSource::new(RepoLens::default(), sc)
1041 .spec()
1042 .id
1043 .to_string()
1044 })
1045 .collect();
1046 let mut sorted = ids.clone();
1047 sorted.sort_unstable();
1048 sorted.dedup();
1049 assert_eq!(
1050 sorted.len(),
1051 ids.len(),
1052 "each scope registers under its own id: {ids:?}"
1053 );
1054 assert_eq!(
1055 RefPickSource::new(RepoLens::default(), RefScope::Revisions)
1056 .spec()
1057 .id,
1058 super::REVISION_PICK_SOURCE
1059 );
1060 assert_ne!(super::REVISION_PICK_SOURCE, super::COMMIT_PICK_SOURCE);
1064 }
1065
1066 #[test]
1074 fn a_placeholder_places_the_pick_and_absence_appends_it() {
1075 assert_eq!(
1076 super::picked_line("magit-find-file {} src/main.rs", "abc123"),
1077 "magit-find-file abc123 src/main.rs",
1078 "the pick goes where the placeholder is"
1079 );
1080 assert_eq!(
1081 super::picked_line("magit-checkout", "main"),
1082 "magit-checkout main",
1083 "no placeholder — appended, which is what every branch row does"
1084 );
1085 assert_eq!(
1088 super::picked_line("cmd {} {}", "x"),
1089 "cmd x {}",
1090 "one pick fills one slot"
1091 );
1092 }
1093
1094 #[test]
1103 fn only_the_find_file_line_is_previewable() {
1104 use super::find_file_preview_target;
1105 let (rev, path) =
1106 find_file_preview_target("magit-find-file abc123 src/main.rs").expect("previewable");
1107 assert_eq!(rev, "abc123");
1108 assert_eq!(path, std::path::Path::new("src/main.rs"));
1109
1110 assert!(
1113 find_file_preview_target("magit-checkout main").is_none(),
1114 "a checkout is an action, not a question about a file"
1115 );
1116 assert!(
1117 find_file_preview_target("magit-file-checkout abc123 src/main.rs").is_none(),
1118 "file-checkout OVERWRITES that path — previewing it reads as a promise"
1119 );
1120 assert!(find_file_preview_target("magit-find-file abc123").is_none());
1122 assert!(find_file_preview_target("magit-find-file ").is_none());
1123 assert!(find_file_preview_target("magit-find-files x y").is_none());
1124 }
1125
1126 #[test]
1129 fn a_path_with_spaces_is_kept_whole() {
1130 let (rev, path) = super::find_file_preview_target("magit-find-file HEAD my dir/a b.rs")
1131 .expect("previewable");
1132 assert_eq!(rev, "HEAD");
1133 assert_eq!(path, std::path::Path::new("my dir/a b.rs"));
1134 }
1135
1136 #[test]
1140 fn the_option_gates_the_window_and_the_fetch_together() {
1141 use super::{RefPickSource, RefScope};
1142 use lattice_picker::PickerSourceGenerator;
1143
1144 let config = std::sync::Arc::new(lattice_config::ConfigRegistry::new());
1145 config.init_from_linkme();
1148 let src = RefPickSource::with_config(
1149 RepoLens::default(),
1150 RefScope::Revisions,
1151 Some(std::sync::Arc::clone(&config)),
1152 );
1153 assert!(
1154 src.preview_debounce().is_some(),
1155 "on by default ⇒ the settle window is declared"
1156 );
1157
1158 config
1159 .set_typed::<crate::options::MagitRevisionPreview>(false)
1160 .expect("option is registered");
1161 assert!(
1162 src.preview_debounce().is_none(),
1163 "off ⇒ no window, so no timer per selection move either"
1164 );
1165 }
1166
1167 #[test]
1170 fn only_the_revision_scope_declares_a_settle_window() {
1171 use super::{RefPickSource, RefScope};
1172 use lattice_picker::PickerSourceGenerator;
1173 for scope in [RefScope::Tags, RefScope::Remotes, RefScope::AllRefs] {
1174 assert!(
1175 RefPickSource::new(RepoLens::default(), scope)
1176 .preview_debounce()
1177 .is_none(),
1178 "{scope:?} has nothing to preview"
1179 );
1180 }
1181 }
1182
1183 #[test]
1184 fn spec_id_matches_the_effect_openpicker_source_name() {
1185 let source = BranchPickBaseSource::new(RepoLens::default());
1189 assert_eq!(source.spec().id, "magit-branch-pick-base");
1190 assert!(source.spec().args_schema.is_empty());
1191 }
1192
1193 #[test]
1205 fn every_branch_prompt_name_round_trips_to_its_reader() {
1206 use lattice_picker::PickerAcceptOutcome as O;
1207
1208 for branch in ["feature/foo", "release-1.2", "main"] {
1211 let cases: Vec<(&str, PickerAcceptOutcome, &str)> = vec![
1212 (
1213 "create",
1214 branch_create_prompt_outcome(branch),
1215 crate::magit_global_mode::BRANCH_CREATE_PROMPT_PREFIX,
1216 ),
1217 (
1218 "create-no-checkout",
1219 branch_create_no_checkout_outcome(branch),
1220 crate::magit_global_mode::BRANCH_CREATE_NO_CHECKOUT_PROMPT_PREFIX,
1221 ),
1222 (
1223 "rename",
1224 branch_rename_outcome(branch),
1225 crate::magit_global_mode::BRANCH_RENAME_PROMPT_PREFIX,
1226 ),
1227 ];
1228 for (what, outcome, prefix) in cases {
1229 let O::OpenPrompt { buffer_name, .. } = outcome else {
1230 panic!("{what} must open a prompt");
1231 };
1232 let name = buffer_name.unwrap_or_else(|| panic!("{what} must name its buffer"));
1233 assert_eq!(
1234 crate::magit_global_mode::branch_from_prompt_buffer_name_for_test(
1235 &name, prefix
1236 ),
1237 Some(branch.to_string()),
1238 "{what}'s prompt-buffer name `{name}` must read back as `{branch}`"
1239 );
1240 }
1241 }
1242 }
1243
1244 #[test]
1253 fn the_branch_prompt_prefixes_cannot_match_each_others_names() {
1254 use crate::magit_global_mode as g;
1255 let prefixes = [
1256 g::BRANCH_CREATE_PROMPT_PREFIX,
1257 g::BRANCH_CREATE_NO_CHECKOUT_PROMPT_PREFIX,
1258 g::BRANCH_RENAME_PROMPT_PREFIX,
1259 ];
1260 for writer in prefixes {
1261 let name = format!("{writer}topic*");
1262 for reader in prefixes {
1263 let parsed = g::branch_from_prompt_buffer_name_for_test(&name, reader);
1264 if writer == reader {
1265 assert_eq!(parsed, Some("topic".to_string()));
1266 } else {
1267 assert_eq!(
1268 parsed, None,
1269 "`{reader}` must not match a name written by `{writer}`"
1270 );
1271 }
1272 }
1273 }
1274 }
1275
1276 #[test]
1277 fn branch_delete_asks_through_the_ex_command_rather_than_deleting() {
1278 use lattice_picker::PickerAcceptOutcome as O;
1279 let O::InvokeCommand { id, args } = branch_delete_outcome("feature/foo") else {
1280 panic!("delete must route through a command");
1281 };
1282 assert_eq!(
1283 id, "magit-branch-delete",
1284 "the ex-command is what raises the MG.12 confirm; routing anywhere \
1285 else would delete without asking"
1286 );
1287 assert_eq!(args, lattice_grammar::Args::String("feature/foo".into()));
1288 }
1289
1290 #[test]
1293 fn branch_rename_prefills_the_current_name() {
1294 use lattice_picker::PickerAcceptOutcome as O;
1295 let O::OpenPrompt {
1296 prompt, initial, ..
1297 } = branch_rename_outcome("feature/foo")
1298 else {
1299 panic!("rename must open a prompt");
1300 };
1301 assert_eq!(initial, "feature/foo");
1302 assert!(
1303 prompt.contains("feature/foo"),
1304 "the prompt names its target"
1305 );
1306 }
1307
1308 #[test]
1309 fn branch_create_prompt_outcome_names_the_base_in_the_label_and_buffer_name() {
1310 let outcome = branch_create_prompt_outcome("feature/foo");
1311 match outcome {
1312 PickerAcceptOutcome::OpenPrompt {
1313 prompt,
1314 initial,
1315 on_submit_action,
1316 buffer_name,
1317 } => {
1318 assert!(prompt.contains("feature/foo"));
1319 assert_eq!(initial, "");
1320 assert_eq!(on_submit_action, "action:magit-branch-create-finish");
1321 assert_eq!(
1322 buffer_name.as_deref(),
1323 Some("*magit:branch-create-from:feature/foo*")
1324 );
1325 }
1326 other => panic!("expected OpenPrompt, got {other:?}"),
1327 }
1328 }
1329}
1330
1331pub struct BranchPickSource {
1353 spec: PickerSourceSpec,
1354 repo: RepoLens,
1355}
1356
1357pub const BRANCH_PICK_SOURCE: &str = "magit-branch";
1358
1359impl BranchPickSource {
1360 pub fn new(repo: RepoLens) -> Self {
1361 Self {
1362 repo,
1363 spec: takes_ex_command(
1364 BRANCH_PICK_SOURCE,
1365 "Pick a branch, then run the named magit ex-command on it.",
1366 "magit ex-command to run on the picked branch",
1367 ),
1368 }
1369 }
1370}
1371
1372impl Default for BranchPickSource {
1373 fn default() -> Self {
1374 Self::new(RepoLens::default())
1375 }
1376}
1377
1378impl PickerSourceGenerator for BranchPickSource {
1379 fn spec(&self) -> &PickerSourceSpec {
1380 &self.spec
1381 }
1382
1383 fn init(&self, ctx: &PickerContext<'_>, args: &[String]) -> SourceResult<PickerInitResult> {
1384 let workdir = self.repo.workdir(ctx);
1387 let command = args
1388 .first()
1389 .filter(|c| !c.is_empty())
1390 .ok_or_else(|| {
1391 format!("{BRANCH_PICK_SOURCE}: needs the ex-command to run on the picked branch")
1392 })?
1393 .clone();
1394 Ok(PickerInitResult::Future(Box::pin(async move {
1395 let branches = tokio::task::spawn_blocking(move || {
1396 let repo = Repository::discover(&workdir)
1397 .map_err(|e| format!("{BRANCH_PICK_SOURCE}: repo discover failed: {e}"))?;
1398 Branch::list(&repo).map_err(|e| format!("{BRANCH_PICK_SOURCE}: {e}"))
1399 })
1400 .await
1401 .map_err(|e| format!("{BRANCH_PICK_SOURCE}: join error: {e}"))??;
1402 Ok(branches
1403 .into_iter()
1404 .map(|name| {
1405 let cand = RawCandidate::plain(name.clone(), CandidateKind::Plain);
1406 (
1407 cand,
1408 RoutingPayload::InvokeCommand {
1409 id: picked_line(&command, &name),
1410 args: lattice_grammar::Args::None,
1411 },
1412 )
1413 })
1414 .collect())
1415 })))
1416 }
1417
1418 fn accept(
1419 &self,
1420 _ctx: &PickerContext<'_>,
1421 routing: &RoutingPayload,
1422 ) -> SourceResult<PickerAcceptOutcome> {
1423 match routing {
1424 RoutingPayload::InvokeCommand { id, args } => Ok(PickerAcceptOutcome::InvokeCommand {
1425 id: id.clone(),
1426 args: args.clone(),
1427 }),
1428 other => Err(format!(
1429 "{BRANCH_PICK_SOURCE}: unexpected routing payload {other:?}"
1430 )),
1431 }
1432 }
1433}
1434
1435pub struct RefPickSource {
1447 spec: PickerSourceSpec,
1448 repo: RepoLens,
1449 which: RefScope,
1450 config: Option<Arc<lattice_config::ConfigRegistry>>,
1456}
1457
1458#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1460pub enum RefScope {
1461 Tags,
1463 Remotes,
1466 AllRefs,
1469 Revisions,
1481}
1482
1483pub const TAG_PICK_SOURCE: &str = "magit-tag";
1484pub const REMOTE_PICK_SOURCE: &str = "magit-remote";
1485pub const REF_PICK_SOURCE: &str = "magit-ref";
1486pub const REVISION_PICK_SOURCE: &str = "magit-revision";
1487
1488impl RefPickSource {
1489 pub fn new(repo: RepoLens, which: RefScope) -> Self {
1490 Self::with_config(repo, which, None)
1491 }
1492
1493 pub fn with_config(
1494 repo: RepoLens,
1495 which: RefScope,
1496 config: Option<Arc<lattice_config::ConfigRegistry>>,
1497 ) -> Self {
1498 let (id, doc, noun) = match which {
1499 RefScope::Tags => (
1500 TAG_PICK_SOURCE,
1501 "Pick a tag, then run the named magit ex-command on it.",
1502 "magit ex-command to run on the picked tag",
1503 ),
1504 RefScope::Remotes => (
1505 REMOTE_PICK_SOURCE,
1506 "Pick a remote, then run the named magit ex-command on it.",
1507 "magit ex-command to run on the picked remote",
1508 ),
1509 RefScope::AllRefs => (
1510 REF_PICK_SOURCE,
1511 "Pick a ref, then run the named magit ex-command on it.",
1512 "magit ex-command to run on the picked ref",
1513 ),
1514 RefScope::Revisions => (
1515 REVISION_PICK_SOURCE,
1516 "Pick a revision — a branch, tag or recent commit — then run \
1517 the named magit ex-command on it.",
1518 "magit ex-command to run on the picked revision",
1519 ),
1520 };
1521 Self {
1522 repo,
1523 spec: takes_ex_command(id, doc, noun),
1524 which,
1525 config,
1526 }
1527 }
1528
1529 fn id(&self) -> &'static str {
1530 match self.which {
1531 RefScope::Tags => TAG_PICK_SOURCE,
1532 RefScope::Remotes => REMOTE_PICK_SOURCE,
1533 RefScope::AllRefs => REF_PICK_SOURCE,
1534 RefScope::Revisions => REVISION_PICK_SOURCE,
1535 }
1536 }
1537
1538 fn preview_enabled(&self) -> bool {
1541 self.config
1542 .as_ref()
1543 .and_then(|c| c.get_typed::<crate::options::MagitRevisionPreview>())
1544 .map(|v| *v)
1545 .unwrap_or(true)
1546 }
1547}
1548
1549fn find_file_preview_target(line: &str) -> Option<(String, PathBuf)> {
1561 let rest = line.strip_prefix("magit-find-file ")?;
1562 let (rev, path) = rest.trim().split_once(char::is_whitespace)?;
1563 let path = path.trim();
1564 (!rev.is_empty() && !path.is_empty()).then(|| (rev.to_string(), PathBuf::from(path)))
1565}
1566
1567impl PickerSourceGenerator for RefPickSource {
1568 fn spec(&self) -> &PickerSourceSpec {
1569 &self.spec
1570 }
1571
1572 fn init(&self, ctx: &PickerContext<'_>, args: &[String]) -> SourceResult<PickerInitResult> {
1573 let workdir = self.repo.workdir(ctx);
1576 let id = self.id();
1577 let which = self.which;
1578 let command = args
1579 .first()
1580 .filter(|c| !c.is_empty())
1581 .ok_or_else(|| format!("{id}: needs the ex-command to run on the pick"))?
1582 .clone();
1583 Ok(PickerInitResult::Future(Box::pin(async move {
1584 let names = tokio::task::spawn_blocking(move || {
1589 let repo = Repository::discover(&workdir)
1590 .map_err(|e| format!("{id}: repo discover failed: {e}"))?;
1591 Ok::<Vec<(String, String)>, String>(match which {
1592 RefScope::Remotes => Remote::list(&repo)
1593 .map_err(|e| format!("{id}: {e}"))?
1594 .into_iter()
1595 .map(|r| (r.name.clone(), r.name))
1596 .collect(),
1597 RefScope::Tags => Reference::list(&repo)
1598 .map_err(|e| format!("{id}: {e}"))?
1599 .into_iter()
1600 .filter(|r| r.kind == RefKind::Tag)
1601 .map(|r| (r.name.clone(), r.name))
1602 .collect(),
1603 RefScope::AllRefs => Reference::list(&repo)
1604 .map_err(|e| format!("{id}: {e}"))?
1605 .into_iter()
1606 .map(|r| (r.name.clone(), r.name))
1607 .collect(),
1608 RefScope::Revisions => {
1612 let mut out: Vec<(String, String)> = Reference::list(&repo)
1613 .map_err(|e| format!("{id}: {e}"))?
1614 .into_iter()
1615 .map(|r| (r.name.clone(), r.name))
1616 .collect();
1617 out.extend(
1618 recent_commits(workdir.clone())?
1619 .into_iter()
1620 .map(|(sha, display)| (display, sha)),
1621 );
1622 out
1623 }
1624 })
1625 })
1626 .await
1627 .map_err(|e| format!("{id}: join error: {e}"))??;
1628 Ok(names
1629 .into_iter()
1630 .map(|(display, value)| {
1631 let cand = RawCandidate::plain(display, CandidateKind::Plain);
1632 (
1633 cand,
1634 RoutingPayload::InvokeCommand {
1635 id: picked_line(&command, &value),
1636 args: lattice_grammar::Args::None,
1637 },
1638 )
1639 })
1640 .collect())
1641 })))
1642 }
1643
1644 fn accept(
1645 &self,
1646 _ctx: &PickerContext<'_>,
1647 routing: &RoutingPayload,
1648 ) -> SourceResult<PickerAcceptOutcome> {
1649 match routing {
1650 RoutingPayload::InvokeCommand { id, args } => Ok(PickerAcceptOutcome::InvokeCommand {
1651 id: id.clone(),
1652 args: args.clone(),
1653 }),
1654 other => Err(format!(
1655 "{}: unexpected routing payload {other:?}",
1656 self.id()
1657 )),
1658 }
1659 }
1660
1661 fn preview(
1675 &self,
1676 ctx: &PickerContext<'_>,
1677 routing: &RoutingPayload,
1678 ) -> Option<lattice_picker::PickerPreviewOutcome> {
1679 if self.which != RefScope::Revisions || !self.preview_enabled() {
1680 return None;
1681 }
1682 let RoutingPayload::InvokeCommand { id, .. } = routing else {
1683 return None;
1684 };
1685 let (rev, path) = find_file_preview_target(id)?;
1686 let workdir = self.repo.workdir(ctx);
1690 let text = crate::magit_file_revision_mode::preview_blob(&workdir, &rev, &path)?;
1691 Some(lattice_picker::PickerPreviewOutcome::Buffer {
1692 name: crate::magit_file_revision_mode::blob_buffer_name(
1693 &crate::workdir::repo_label(&workdir),
1694 &rev,
1695 &path,
1696 ),
1697 text,
1698 syntax_path: Some(path),
1699 })
1700 }
1701
1702 fn preview_debounce(&self) -> Option<std::time::Duration> {
1713 (self.which == RefScope::Revisions && self.preview_enabled())
1714 .then(|| std::time::Duration::from_millis(150))
1715 }
1716}