From 623c2d442bc71dc18ee16a810467b35eff739b33 Mon Sep 17 00:00:00 2001 From: angeousta <132761637+angeousta@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:04:36 +0200 Subject: [PATCH 1/3] feat: add cherry-pick branch A onto B as new branch C from the action bar with explicit commit ranges Adds a Cherry-pick action to the action bar and to the branch context menu that creates a new branch C from a base D, checks it out, and cherry-picks every commit unique to a source ref A relative to an explicit range B (B..A, oldest first, merge commits skipped). The branch context menu entry prefills source/range/base from the invoking branch. --- crates/gitcomet-core/src/services.rs | 45 ++ crates/gitcomet-git-gix/src/repo/history.rs | 154 ++++++ crates/gitcomet-git-gix/src/repo/mod.rs | 23 + .../tests/cherry_pick_integration.rs | 214 ++++++++ crates/gitcomet-state/src/msg/effect.rs | 17 + crates/gitcomet-state/src/msg/message.rs | 24 + .../src/msg/repo_command_kind.rs | 20 + crates/gitcomet-state/src/store/effects.rs | 63 +++ .../src/store/effects/repo_commands.rs | 61 +++ crates/gitcomet-state/src/store/reducer.rs | 32 ++ .../src/store/reducer/actions_emit_effects.rs | 45 ++ .../gitcomet-state/src/store/reducer/util.rs | 69 +++ .../src/store/tests/actions_emit_effects.rs | 50 +- .../gitcomet-ui-gpui/src/view/mod_helpers.rs | 14 + .../src/view/panels/action_bar.rs | 62 +++ .../src/view/panels/popover.rs | 517 ++++++++++++++++++ .../popover/cherry_pick_range_prompt.rs | 371 +++++++++++++ .../panels/popover/context_menu/branch.rs | 25 + .../src/view/panels/popover/fingerprint.rs | 14 + .../src/view/panels/popover/tests/refs.rs | 102 ++++ 20 files changed, 1921 insertions(+), 1 deletion(-) create mode 100644 crates/gitcomet-ui-gpui/src/view/panels/popover/cherry_pick_range_prompt.rs diff --git a/crates/gitcomet-core/src/services.rs b/crates/gitcomet-core/src/services.rs index 4db87879d..6956e63c8 100644 --- a/crates/gitcomet-core/src/services.rs +++ b/crates/gitcomet-core/src/services.rs @@ -683,6 +683,51 @@ pub trait GitRepository: Send + Sync { } fn revert(&self, id: &CommitId) -> Result<()>; +<<<<<<< New base: Support explicit commit ranges when cherry-picking onto a new branch (#17) + /// Creates a new branch `new_branch` pointing at `base`'s tip, checks it + /// out, and cherry-picks every commit reachable from `source` but not from + /// `range` (oldest first, merge commits skipped) onto it. `range` must be + /// an ancestor of `source`. + /// + /// Errors without touching anything if `new_branch` already exists, the + /// range `range..source` is empty, or `range` is not an ancestor of + /// `source`. A cherry-pick conflict stops the sequence and leaves Git's + /// sequencer state in progress on `new_branch`, exactly like a regular + /// multi-commit cherry-pick. + fn cherry_pick_range_onto_new_branch( + &self, + _base: &str, + _range: &str, + _source: &str, + _new_branch: &str, + ) -> Result { + Err(Error::new(ErrorKind::Unsupported( + "cherry-picking a branch range onto a new branch is not implemented for this backend", + ))) + } + +||||||| Common ancestor +======= + /// Creates a new branch `new_branch` pointing at `base`'s tip, checks it + /// out, and cherry-picks every commit reachable from `source` but not from + /// `base` (oldest first, merge commits skipped) onto it. + /// + /// Errors without touching anything if `new_branch` already exists or the + /// range `base..source` is empty. A cherry-pick conflict stops the sequence + /// and leaves Git's sequencer state in progress on `new_branch`, exactly + /// like a regular multi-commit cherry-pick. + fn cherry_pick_range_onto_new_branch( + &self, + _base: &str, + _source: &str, + _new_branch: &str, + ) -> Result { + Err(Error::new(ErrorKind::Unsupported( + "cherry-picking a branch range onto a new branch is not implemented for this backend", + ))) + } + +>>>>>>> Current commit: Add cherry-pick branch A onto B as new branch C from the action bar fn stash_create(&self, message: &str, include_untracked: bool) -> Result<()>; fn stash_list(&self) -> Result>; fn stash_list_cancellable(&self, cancellation: &CancellationToken) -> Result> { diff --git a/crates/gitcomet-git-gix/src/repo/history.rs b/crates/gitcomet-git-gix/src/repo/history.rs index ed1622367..cb0be37cd 100644 --- a/crates/gitcomet-git-gix/src/repo/history.rs +++ b/crates/gitcomet-git-gix/src/repo/history.rs @@ -1069,6 +1069,160 @@ impl GixRepo { self.run_planned_rebase(entries, "HEAD", &label) } +<<<<<<< New base: Support explicit commit ranges when cherry-picking onto a new branch (#17) + /// Creates `new_branch` at `base`'s tip, checks it out, and cherry-picks + /// every commit reachable from `source` but not from `range` (oldest + /// first, merge commits skipped) onto it. Nothing is created when the + /// range is empty, `range` is not an ancestor of `source`, or + /// `new_branch` already exists (`create_branch_impl` rejects it). + pub(super) fn cherry_pick_range_onto_new_branch_impl( + &self, + base: &str, + range: &str, + source: &str, + new_branch: &str, + ) -> Result { + validate_ref_like_arg(base, "base branch name")?; + validate_ref_like_arg(range, "range reference")?; + validate_ref_like_arg(source, "source reference")?; + validate_ref_like_arg(new_branch, "new branch name")?; + + // `range` must be an ancestor of `source` for `range..source` to be a + // meaningful commit set; git's --is-ancestor exits 1 when it is not. + let mut cmd = self.git_workdir_cmd(); + cmd.arg("merge-base").arg("--is-ancestor").arg(range).arg(source); + let ancestor_label = format!("git merge-base --is-ancestor {range} {source}"); + match run_git_raw_output(cmd, &ancestor_label) { + Ok(output) if output.status.success() => {} + Ok(output) if output.status.code() == Some(1) => { + return Err(Error::new(ErrorKind::Backend(format!( + "{range} is not an ancestor of {source}; the range {range}..{source} would \ + include unrelated history — pick a range reference that is an ancestor" + )))); + } + Ok(output) => { + return Err(Error::new(ErrorKind::Backend(format!( + "failed to check whether {range} is an ancestor of {source}: {}", + bytes_to_text_preserving_utf8(&output.stderr).trim() + )))); + } + Err(e) => { + return Err(Error::new(ErrorKind::Backend(format!( + "failed to check whether {range} is an ancestor of {source}: {e}" + )))); + } + } + + // Oldest-first, merge commits skipped: the same set `git cherry-pick + // range..source` would apply, enumerated explicitly so an empty range + // is rejected before any branch or checkout is created. + let mut cmd = self.git_workdir_cmd(); + cmd.arg("rev-list") + .arg("--reverse") + .arg("--no-merges") + .arg(format!("{range}..{source}")); + let rev_list_label = format!("git rev-list --reverse --no-merges {range}..{source}"); + let output = run_git_raw_output(cmd, &rev_list_label).map_err(|e| { + Error::new(ErrorKind::Backend(format!( + "failed to list commits in {range}..{source}: {e}" + ))) + })?; + let shas: Vec = bytes_to_text_preserving_utf8(&output.stdout) + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(str::to_string) + .collect(); + if shas.is_empty() { + return Err(Error::new(ErrorKind::Backend(format!( + "no commits to cherry-pick: {source} has no commits that are not already in \ + {range}" + )))); + } + + // Create the branch from `base` (errors if it already exists) and + // move onto it before the picks, mirroring create-branch-and-checkout. + self.create_branch_impl(new_branch, &CommitId(base.into()))?; + self.checkout_branch_impl(new_branch)?; + + let mut cmd = self.git_workdir_cmd(); + cmd.arg("cherry-pick") + .arg("--no-edit") + .arg("--allow-empty") + .arg("--"); + for sha in &shas { + cmd.arg(sha); + } + let label = format!("git cherry-pick {} commits onto {new_branch}", shas.len()); + // An already-applied commit stops the whole sequence with an "empty" + // error that only `--skip` moves past, and the UI exposes no skip + // control — advance automatically so the remaining picks land. + self.run_cherry_pick_step_output(cmd, &label) + } + +||||||| Common ancestor +======= + /// Creates `new_branch` at `base`'s tip, checks it out, and cherry-picks + /// every commit reachable from `source` but not from `base` (oldest first, + /// merge commits skipped) onto it. Nothing is created when the range is + /// empty, and `create_branch_impl` already rejects an existing branch. + pub(super) fn cherry_pick_range_onto_new_branch_impl( + &self, + base: &str, + source: &str, + new_branch: &str, + ) -> Result { + validate_ref_like_arg(base, "base branch name")?; + validate_ref_like_arg(source, "source branch name")?; + validate_ref_like_arg(new_branch, "new branch name")?; + + // Oldest-first, merge commits skipped: the same set `git cherry-pick + // base..source` would apply, enumerated explicitly so an empty range + // is rejected before any branch or checkout is created. + let mut cmd = self.git_workdir_cmd(); + cmd.arg("rev-list") + .arg("--reverse") + .arg("--no-merges") + .arg(format!("{base}..{source}")); + let rev_list_label = format!("git rev-list --reverse --no-merges {base}..{source}"); + let output = run_git_raw_output(cmd, &rev_list_label).map_err(|e| { + Error::new(ErrorKind::Backend(format!( + "failed to list commits in {base}..{source}: {e}" + ))) + })?; + let shas: Vec = bytes_to_text_preserving_utf8(&output.stdout) + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(str::to_string) + .collect(); + if shas.is_empty() { + return Err(Error::new(ErrorKind::Backend(format!( + "no commits to cherry-pick: {source} has no commits that are not already in {base}" + )))); + } + + // Create the branch from `base` (errors if it already exists) and + // move onto it before the picks, mirroring create-branch-and-checkout. + self.create_branch_impl(new_branch, &CommitId(base.into()))?; + self.checkout_branch_impl(new_branch)?; + + let mut cmd = self.git_workdir_cmd(); + cmd.arg("cherry-pick") + .arg("--no-edit") + .arg("--allow-empty") + .arg("--"); + for sha in &shas { + cmd.arg(sha); + } + let label = format!("git cherry-pick {} commits onto {new_branch}", shas.len()); + // An already-applied commit stops the whole sequence with an "empty" + // error that only `--skip` moves past, and the UI exposes no skip + // control — advance automatically so the remaining picks land. + self.run_cherry_pick_step_output(cmd, &label) + } + +>>>>>>> Current commit: Add cherry-pick branch A onto B as new branch C from the action bar pub(super) fn merge_commit_message_impl(&self) -> Result> { let repo = self._repo.to_thread_local(); if repo.state() != Some(gix::state::InProgress::Merge) { diff --git a/crates/gitcomet-git-gix/src/repo/mod.rs b/crates/gitcomet-git-gix/src/repo/mod.rs index 3c48b266a..2f9f86361 100644 --- a/crates/gitcomet-git-gix/src/repo/mod.rs +++ b/crates/gitcomet-git-gix/src/repo/mod.rs @@ -607,6 +607,29 @@ impl GitRepository for GixRepo { self.revert_impl(id) } +<<<<<<< New base: Support explicit commit ranges when cherry-picking onto a new branch (#17) + fn cherry_pick_range_onto_new_branch( + &self, + base: &str, + range: &str, + source: &str, + new_branch: &str, + ) -> Result { + self.cherry_pick_range_onto_new_branch_impl(base, range, source, new_branch) + } + +||||||| Common ancestor +======= + fn cherry_pick_range_onto_new_branch( + &self, + base: &str, + source: &str, + new_branch: &str, + ) -> Result { + self.cherry_pick_range_onto_new_branch_impl(base, source, new_branch) + } + +>>>>>>> Current commit: Add cherry-pick branch A onto B as new branch C from the action bar fn stash_create(&self, message: &str, include_untracked: bool) -> Result<()> { self.stash_create_impl(message, include_untracked) } diff --git a/crates/gitcomet-git-gix/tests/cherry_pick_integration.rs b/crates/gitcomet-git-gix/tests/cherry_pick_integration.rs index ead6ff90e..4e8a14919 100644 --- a/crates/gitcomet-git-gix/tests/cherry_pick_integration.rs +++ b/crates/gitcomet-git-gix/tests/cherry_pick_integration.rs @@ -1355,3 +1355,217 @@ fn abort_returns_active_cherry_pick_lock_error() { SequencerState::CherryPick ); } +<<<<<<< New base: Support explicit commit ranges when cherry-picking onto a new branch (#17) + +#[test] +fn cherry_pick_range_onto_new_branch_creates_branch_and_applies_source_commits_in_order() { + let dir = tempfile::tempdir().expect("create tempdir"); + let repo = dir.path().join("repo"); + init_repo(&repo); + let base = commit_file(&repo, "base.txt", "base\n", "base"); + // D: the base branch C starts from (has its own commit). + run_git(&repo, &["checkout", "-b", "branch_d", &base]); + commit_file(&repo, "d.txt", "d\n", "d work"); + // A: the source branch whose commits are copied; B (range) = the base + // commit, an ancestor of A. + run_git(&repo, &["checkout", "-b", "branch_a", &base]); + commit_file(&repo, "a1.txt", "a1\n", "a one"); + commit_file(&repo, "a2.txt", "a2\n", "a two"); + // The caller sits on some other branch; the command must move to branch_c. + run_git(&repo, &["checkout", "branch_d"]); + + open_backend(&repo) + .cherry_pick_range_onto_new_branch("branch_d", &base, "branch_a", "branch_c") + .expect("cherry-pick range onto new branch"); + + assert_eq!(git_stdout(&repo, &["branch", "--show-current"]), "branch_c"); + assert_eq!( + git_stdout(&repo, &["log", "--format=%s"]), + "a two\na one\nd work\nbase" + ); + assert_eq!( + fs::read_to_string(repo.join("a1.txt")).unwrap(), + "a1\n" + ); + assert_eq!( + fs::read_to_string(repo.join("a2.txt")).unwrap(), + "a2\n" + ); +} + +#[test] +fn cherry_pick_range_onto_new_branch_skips_merge_commits() { + let dir = tempfile::tempdir().expect("create tempdir"); + let repo = dir.path().join("repo"); + init_repo(&repo); + let base = commit_file(&repo, "base.txt", "base\n", "base"); + // D: the base branch C starts from. + run_git(&repo, &["checkout", "-b", "branch_d", &base]); + commit_file(&repo, "d.txt", "d\n", "d work"); + // A: the source branch merged into main below. + run_git(&repo, &["checkout", "-b", "branch_a", &base]); + commit_file(&repo, "a1.txt", "a1\n", "a one"); + commit_file(&repo, "a2.txt", "a2\n", "a two"); + // main grows a commit and then merges branch_a in, producing a merge + // commit inside the range (B = the base commit, an ancestor of main) + // that must be skipped. + run_git(&repo, &["checkout", "main"]); + commit_file(&repo, "main.txt", "main\n", "main change"); + run_git(&repo, &["merge", "--no-edit", "branch_a"]); + + open_backend(&repo) + .cherry_pick_range_onto_new_branch("branch_d", &base, "main", "branch_c") + .expect("cherry-pick range onto new branch"); + + assert_eq!(git_stdout(&repo, &["branch", "--show-current"]), "branch_c"); + assert_eq!( + git_stdout(&repo, &["log", "--format=%s"]), + "main change\na two\na one\nd work\nbase" + ); +} + +#[test] +fn cherry_pick_range_onto_new_branch_rejects_existing_branch_and_empty_range() { + let dir = tempfile::tempdir().expect("create tempdir"); + let repo = dir.path().join("repo"); + init_repo(&repo); + let base = commit_file(&repo, "base.txt", "base\n", "base"); + run_git(&repo, &["checkout", "-b", "branch_d", &base]); + commit_file(&repo, "d.txt", "d\n", "d work"); + run_git(&repo, &["checkout", "-b", "branch_a", &base]); + commit_file(&repo, "a1.txt", "a1\n", "a one"); + run_git(&repo, &["checkout", "-b", "branch_c", "branch_d"]); + + // Branch C already exists: nothing may change. + let error = open_backend(&repo) + .cherry_pick_range_onto_new_branch("branch_d", &base, "branch_a", "branch_c") + .expect_err("existing branch must be rejected"); + assert!(error.to_string().contains("already exists"), "{error}"); + assert_eq!(git_stdout(&repo, &["branch", "--show-current"]), "branch_c"); + + // Empty range (source == range): nothing created. + run_git(&repo, &["checkout", "branch_d"]); + let error = open_backend(&repo) + .cherry_pick_range_onto_new_branch("branch_d", "branch_a", "branch_a", "branch_e") + .expect_err("empty range must be rejected"); + assert!(error.to_string().contains("no commits"), "{error}"); + assert_eq!(git_stdout(&repo, &["branch", "--list", "branch_e"]), ""); + assert_eq!(git_stdout(&repo, &["branch", "--show-current"]), "branch_d"); +} + +#[test] +fn cherry_pick_range_onto_new_branch_rejects_non_ancestor_range() { + let dir = tempfile::tempdir().expect("create tempdir"); + let repo = dir.path().join("repo"); + init_repo(&repo); + let base = commit_file(&repo, "base.txt", "base\n", "base"); + run_git(&repo, &["checkout", "-b", "branch_d", &base]); + commit_file(&repo, "d.txt", "d\n", "d work"); + run_git(&repo, &["checkout", "-b", "branch_a", &base]); + commit_file(&repo, "a1.txt", "a1\n", "a one"); + + // B = branch_d is not an ancestor of A = branch_a (they diverge from + // base): rejected before anything is created. + let error = open_backend(&repo) + .cherry_pick_range_onto_new_branch("branch_d", "branch_d", "branch_a", "branch_c") + .expect_err("non-ancestor range must be rejected"); + assert!(error.to_string().contains("ancestor"), "{error}"); + assert_eq!(git_stdout(&repo, &["branch", "--list", "branch_c"]), ""); + assert_eq!(git_stdout(&repo, &["branch", "--show-current"]), "branch_a"); +} +||||||| Common ancestor +======= + +#[test] +fn cherry_pick_range_onto_new_branch_creates_branch_and_applies_source_commits_in_order() { + let dir = tempfile::tempdir().expect("create tempdir"); + let repo = dir.path().join("repo"); + init_repo(&repo); + let base = commit_file(&repo, "base.txt", "base\n", "base"); + run_git(&repo, &["checkout", "-b", "branch_b", &base]); + commit_file(&repo, "b.txt", "b\n", "b work"); + run_git(&repo, &["checkout", "-b", "branch_a", &base]); + commit_file(&repo, "a1.txt", "a1\n", "a one"); + commit_file(&repo, "a2.txt", "a2\n", "a two"); + // The caller sits on some other branch; the command must move to branch_c. + run_git(&repo, &["checkout", "branch_b"]); + + open_backend(&repo) + .cherry_pick_range_onto_new_branch("branch_b", "branch_a", "branch_c") + .expect("cherry-pick range onto new branch"); + + assert_eq!(git_stdout(&repo, &["branch", "--show-current"]), "branch_c"); + assert_eq!( + git_stdout(&repo, &["log", "--format=%s"]), + "a two\na one\nb work\nbase" + ); + assert_eq!( + fs::read_to_string(repo.join("a1.txt")).unwrap(), + "a1\n" + ); + assert_eq!( + fs::read_to_string(repo.join("a2.txt")).unwrap(), + "a2\n" + ); +} + +#[test] +fn cherry_pick_range_onto_new_branch_skips_merge_commits() { + let dir = tempfile::tempdir().expect("create tempdir"); + let repo = dir.path().join("repo"); + init_repo(&repo); + let base = commit_file(&repo, "base.txt", "base\n", "base"); + run_git(&repo, &["checkout", "-b", "branch_b", &base]); + commit_file(&repo, "b.txt", "b\n", "b work"); + run_git(&repo, &["checkout", "-b", "branch_a", &base]); + commit_file(&repo, "a1.txt", "a1\n", "a one"); + commit_file(&repo, "a2.txt", "a2\n", "a two"); + // main grows a commit and then merges branch_a in, producing a merge + // commit inside the branch_b..main range that must be skipped. + run_git(&repo, &["checkout", "main"]); + commit_file(&repo, "main.txt", "main\n", "main change"); + run_git(&repo, &["merge", "--no-edit", "branch_a"]); + + open_backend(&repo) + .cherry_pick_range_onto_new_branch("branch_b", "main", "branch_c") + .expect("cherry-pick range onto new branch"); + + assert_eq!(git_stdout(&repo, &["branch", "--show-current"]), "branch_c"); + assert_eq!( + git_stdout(&repo, &["log", "--format=%s"]), + "main change\na two\na one\nb work\nbase" + ); +} + +#[test] +fn cherry_pick_range_onto_new_branch_rejects_existing_branch_and_empty_range() { + let dir = tempfile::tempdir().expect("create tempdir"); + let repo = dir.path().join("repo"); + init_repo(&repo); + let base = commit_file(&repo, "base.txt", "base\n", "base"); + run_git(&repo, &["checkout", "-b", "branch_b", &base]); + commit_file(&repo, "b.txt", "b\n", "b work"); + run_git(&repo, &["checkout", "-b", "branch_a", &base]); + commit_file(&repo, "a1.txt", "a1\n", "a one"); + run_git(&repo, &["checkout", "-b", "branch_c", "branch_b"]); + + // Branch C already exists: nothing may change. + let error = open_backend(&repo) + .cherry_pick_range_onto_new_branch("branch_b", "branch_a", "branch_c") + .expect_err("existing branch must be rejected"); + assert!(error.to_string().contains("already exists"), "{error}"); + assert_eq!(git_stdout(&repo, &["branch", "--show-current"]), "branch_c"); + + // Empty range (branch_a is fully contained in branch_b): nothing created. + run_git(&repo, &["checkout", "branch_b"]); + let error = open_backend(&repo) + .cherry_pick_range_onto_new_branch("branch_b", "branch_b", "branch_d") + .expect_err("empty range must be rejected"); + assert!(error.to_string().contains("no commits"), "{error}"); + assert_eq!( + git_stdout(&repo, &["branch", "--list", "branch_d"]), + "" + ); + assert_eq!(git_stdout(&repo, &["branch", "--show-current"]), "branch_b"); +} +>>>>>>> Current commit: Add cherry-pick branch A onto B as new branch C from the action bar diff --git a/crates/gitcomet-state/src/msg/effect.rs b/crates/gitcomet-state/src/msg/effect.rs index 3c0a4a647..78b03bc1d 100644 --- a/crates/gitcomet-state/src/msg/effect.rs +++ b/crates/gitcomet-state/src/msg/effect.rs @@ -271,6 +271,23 @@ pub enum Effect { mainline: Option, summary: String, }, +<<<<<<< New base: Support explicit commit ranges when cherry-picking onto a new branch (#17) + CherryPickRangeOntoNewBranch { + repo_id: RepoId, + base: String, + range: String, + source: String, + new_branch: String, + }, +||||||| Common ancestor +======= + CherryPickRangeOntoNewBranch { + repo_id: RepoId, + base: String, + source: String, + new_branch: String, + }, +>>>>>>> Current commit: Add cherry-pick branch A onto B as new branch C from the action bar RevertCommit { repo_id: RepoId, commit_id: CommitId, diff --git a/crates/gitcomet-state/src/msg/message.rs b/crates/gitcomet-state/src/msg/message.rs index cc3597aed..389ba0d83 100644 --- a/crates/gitcomet-state/src/msg/message.rs +++ b/crates/gitcomet-state/src/msg/message.rs @@ -505,6 +505,30 @@ pub enum Msg { mainline: Option, summary: String, }, +<<<<<<< New base: Support explicit commit ranges when cherry-picking onto a new branch (#17) + /// Creates a new branch `new_branch` from `base`'s tip, checks it out, + /// and cherry-picks every commit unique to `source` relative to `range` + /// (oldest first, merge commits skipped; `range` must be an ancestor of + /// `source`) onto it. + CherryPickRangeOntoNewBranch { + repo_id: RepoId, + base: String, + range: String, + source: String, + new_branch: String, + }, +||||||| Common ancestor +======= + /// Creates a new branch `new_branch` from `base`'s tip, checks it out, + /// and cherry-picks every commit unique to `source` (relative to `base`, + /// oldest first, merge commits skipped) onto it. + CherryPickRangeOntoNewBranch { + repo_id: RepoId, + base: String, + source: String, + new_branch: String, + }, +>>>>>>> Current commit: Add cherry-pick branch A onto B as new branch C from the action bar RevertCommit { repo_id: RepoId, commit_id: CommitId, diff --git a/crates/gitcomet-state/src/msg/repo_command_kind.rs b/crates/gitcomet-state/src/msg/repo_command_kind.rs index bd65e7d09..42c0eb6b1 100644 --- a/crates/gitcomet-state/src/msg/repo_command_kind.rs +++ b/crates/gitcomet-state/src/msg/repo_command_kind.rs @@ -83,6 +83,26 @@ pub enum RepoCommandKind { mainline: Option, summary: String, }, +<<<<<<< New base: Support explicit commit ranges when cherry-picking onto a new branch (#17) + /// Creates a new branch `new_branch` from `base`, checks it out, and + /// cherry-picks `range..source`'s commits (oldest first, merges skipped, + /// `range` must be an ancestor of `source`) onto it. + CherryPickRangeOntoNewBranch { + base: String, + range: String, + source: String, + new_branch: String, + }, +||||||| Common ancestor +======= + /// Creates a new branch from `base`, checks it out, and cherry-picks + /// `source..base`'s commits (oldest first, merges skipped) onto it. + CherryPickRangeOntoNewBranch { + base: String, + source: String, + new_branch: String, + }, +>>>>>>> Current commit: Add cherry-pick branch A onto B as new branch C from the action bar MergeAbort, CreateTag { name: String, diff --git a/crates/gitcomet-state/src/store/effects.rs b/crates/gitcomet-state/src/store/effects.rs index 3a3a65ccb..6ad76b6db 100644 --- a/crates/gitcomet-state/src/store/effects.rs +++ b/crates/gitcomet-state/src/store/effects.rs @@ -738,6 +738,44 @@ fn send_unavailable_git_effect_result( result: Err(git_unavailable_error(runtime)), }, )), +<<<<<<< New base: Support explicit commit ranges when cherry-picking onto a new branch (#17) + Effect::CherryPickRangeOntoNewBranch { + repo_id, + base, + range, + source, + new_branch, + } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::CherryPickRangeOntoNewBranch { + base, + range, + source, + new_branch, + }, + result: Err(git_unavailable_error(runtime)), + }, + )), +||||||| Common ancestor +======= + Effect::CherryPickRangeOntoNewBranch { + repo_id, + base, + source, + new_branch, + } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::CherryPickRangeOntoNewBranch { + base, + source, + new_branch, + }, + result: Err(git_unavailable_error(runtime)), + }, + )), +>>>>>>> Current commit: Add cherry-pick branch A onto B as new branch C from the action bar Effect::RevertCommit { repo_id, .. } => { send_repo_action_unavailable(repo_id, RepoActionKind::RevertCommit, runtime, &send) } @@ -2120,6 +2158,31 @@ pub(super) fn schedule_effect( executor, repos, msg_tx, repo_id, commit_id, commit, mainline, summary, ); } +<<<<<<< New base: Support explicit commit ranges when cherry-picking onto a new branch (#17) + Effect::CherryPickRangeOntoNewBranch { + repo_id, + base, + range, + source, + new_branch, + } => { + repo_commands::schedule_cherry_pick_range_onto_new_branch( + executor, repos, msg_tx, repo_id, base, range, source, new_branch, + ); + } +||||||| Common ancestor +======= + Effect::CherryPickRangeOntoNewBranch { + repo_id, + base, + source, + new_branch, + } => { + repo_commands::schedule_cherry_pick_range_onto_new_branch( + executor, repos, msg_tx, repo_id, base, source, new_branch, + ); + } +>>>>>>> Current commit: Add cherry-pick branch A onto B as new branch C from the action bar Effect::RevertCommit { repo_id, commit_id } => { repo_actions::schedule_revert_commit(executor, repos, msg_tx, repo_id, commit_id); } diff --git a/crates/gitcomet-state/src/store/effects/repo_commands.rs b/crates/gitcomet-state/src/store/effects/repo_commands.rs index be231646a..1db38691c 100644 --- a/crates/gitcomet-state/src/store/effects/repo_commands.rs +++ b/crates/gitcomet-state/src/store/effects/repo_commands.rs @@ -1068,6 +1068,67 @@ pub(super) fn schedule_interactive_cherry_pick( ); } +<<<<<<< New base: Support explicit commit ranges when cherry-picking onto a new branch (#17) +pub(super) fn schedule_cherry_pick_range_onto_new_branch( + executor: &TaskExecutor, + repos: &RepoMap, + msg_tx: StoreWorkerSender, + repo_id: RepoId, + base: String, + range: String, + source: String, + new_branch: String, +) { + let command_base = base.clone(); + let command_range = range.clone(); + let command_source = source.clone(); + let command_new_branch = new_branch.clone(); + schedule_repo_command( + executor, + repos, + msg_tx, + repo_id, + RepoCommandKind::CherryPickRangeOntoNewBranch { + base: command_base, + range: command_range, + source: command_source, + new_branch: command_new_branch, + }, + move |repo| { + repo.cherry_pick_range_onto_new_branch(&base, &range, &source, &new_branch) + }, + ); +} + +||||||| Common ancestor +======= +pub(super) fn schedule_cherry_pick_range_onto_new_branch( + executor: &TaskExecutor, + repos: &RepoMap, + msg_tx: StoreWorkerSender, + repo_id: RepoId, + base: String, + source: String, + new_branch: String, +) { + let command_base = base.clone(); + let command_source = source.clone(); + let command_new_branch = new_branch.clone(); + schedule_repo_command( + executor, + repos, + msg_tx, + repo_id, + RepoCommandKind::CherryPickRangeOntoNewBranch { + base: command_base, + source: command_source, + new_branch: command_new_branch, + }, + move |repo| repo.cherry_pick_range_onto_new_branch(&base, &source, &new_branch), + ); +} + +>>>>>>> Current commit: Add cherry-pick branch A onto B as new branch C from the action bar pub(super) fn schedule_cherry_pick_commit( executor: &TaskExecutor, repos: &RepoMap, diff --git a/crates/gitcomet-state/src/store/reducer.rs b/crates/gitcomet-state/src/store/reducer.rs index 0ad8468c3..dc67f4cd4 100644 --- a/crates/gitcomet-state/src/store/reducer.rs +++ b/crates/gitcomet-state/src/store/reducer.rs @@ -418,6 +418,7 @@ fn retry_msg_for_repo_command(repo_id: RepoId, command: RepoCommandKind) -> Opti // rejected as already in progress (and its effect has no auth slot). // Continue the paused sequencer with the staged auth instead. RepoCommandKind::InteractiveCherryPick { .. } => Msg::RebaseContinue { repo_id }, + RepoCommandKind::CherryPickRangeOntoNewBranch { .. } => Msg::RebaseContinue { repo_id }, RepoCommandKind::CherryPick { commit_id, commit, @@ -1167,6 +1168,37 @@ fn reduce_inner( begin_head_changing_local_action(state, repo_id); actions_emit_effects::cherry_pick_commit(repo_id, commit_id, commit, mainline, summary) } +<<<<<<< New base: Support explicit commit ranges when cherry-picking onto a new branch (#17) + Msg::CherryPickRangeOntoNewBranch { + repo_id, + base, + range, + source, + new_branch, + } => { + if let Some(repo_state) = state.repos.iter_mut().find(|r| r.id == repo_id) { + repo_state.set_detached_head_commit(None); + } + begin_head_changing_local_action(state, repo_id); + actions_emit_effects::cherry_pick_range_onto_new_branch( + repo_id, base, range, source, new_branch, + ) + } +||||||| Common ancestor +======= + Msg::CherryPickRangeOntoNewBranch { + repo_id, + base, + source, + new_branch, + } => { + if let Some(repo_state) = state.repos.iter_mut().find(|r| r.id == repo_id) { + repo_state.set_detached_head_commit(None); + } + begin_head_changing_local_action(state, repo_id); + actions_emit_effects::cherry_pick_range_onto_new_branch(repo_id, base, source, new_branch) + } +>>>>>>> Current commit: Add cherry-pick branch A onto B as new branch C from the action bar Msg::RevertCommit { repo_id, commit_id } => { begin_head_changing_local_action(state, repo_id); actions_emit_effects::revert_commit(repo_id, commit_id) diff --git a/crates/gitcomet-state/src/store/reducer/actions_emit_effects.rs b/crates/gitcomet-state/src/store/reducer/actions_emit_effects.rs index 3cab59caa..8092eeb15 100644 --- a/crates/gitcomet-state/src/store/reducer/actions_emit_effects.rs +++ b/crates/gitcomet-state/src/store/reducer/actions_emit_effects.rs @@ -63,6 +63,40 @@ pub(super) fn cherry_pick_commit( }] } +<<<<<<< New base: Support explicit commit ranges when cherry-picking onto a new branch (#17) +pub(super) fn cherry_pick_range_onto_new_branch( + repo_id: RepoId, + base: String, + range: String, + source: String, + new_branch: String, +) -> Vec { + vec![Effect::CherryPickRangeOntoNewBranch { + repo_id, + base, + range, + source, + new_branch, + }] +} + +||||||| Common ancestor +======= +pub(super) fn cherry_pick_range_onto_new_branch( + repo_id: RepoId, + base: String, + source: String, + new_branch: String, +) -> Vec { + vec![Effect::CherryPickRangeOntoNewBranch { + repo_id, + base, + source, + new_branch, + }] +} + +>>>>>>> Current commit: Add cherry-pick branch A onto B as new branch C from the action bar pub(super) fn revert_commit( repo_id: RepoId, commit_id: gitcomet_core::domain::CommitId, @@ -985,6 +1019,7 @@ fn tracks_local_actions_in_flight(command: &RepoCommandKind) -> bool { | RepoCommandKind::InteractiveRebase { .. } | RepoCommandKind::InteractiveCherryPick { .. } | RepoCommandKind::CherryPick { .. } + | RepoCommandKind::CherryPickRangeOntoNewBranch { .. } | RepoCommandKind::MergeAbort | RepoCommandKind::CreateTag { .. } | RepoCommandKind::DeleteTag { .. } @@ -1031,6 +1066,7 @@ fn command_clears_pending_force_push_lease(command: &RepoCommandKind) -> bool { | RepoCommandKind::InteractiveRebase { .. } | RepoCommandKind::InteractiveCherryPick { .. } | RepoCommandKind::CherryPick { .. } + | RepoCommandKind::CherryPickRangeOntoNewBranch { .. } | RepoCommandKind::MergeAbort ) } @@ -1075,7 +1111,11 @@ pub(super) fn repo_command_finished( RepoCommandKind::AddWorktree { .. } | RepoCommandKind::RemoveWorktree { .. } | RepoCommandKind::ForceRemoveWorktree { .. } + | RepoCommandKind::CherryPickRangeOntoNewBranch { .. } ) && result.is_ok(); + // The command creates a branch and moves HEAD onto it. + let refresh_branches = matches!(&command, RepoCommandKind::CherryPickRangeOntoNewBranch { .. }) + && result.is_ok(); let refresh_submodules = matches!( &command, RepoCommandKind::AddSubmodule { .. } @@ -1155,6 +1195,7 @@ pub(super) fn repo_command_finished( | RepoCommandKind::InteractiveRebase { .. } | RepoCommandKind::InteractiveCherryPick { .. } | RepoCommandKind::CherryPick { .. } + | RepoCommandKind::CherryPickRangeOntoNewBranch { .. } | RepoCommandKind::MergeAbort ) { repo_state.set_diff_target(None); @@ -1203,6 +1244,10 @@ pub(super) fn repo_command_finished( repo_state.set_worktrees(Loadable::Loading); extra_effects.push(Effect::LoadWorktrees { repo_id }); } + if refresh_branches && repo_state.loads_in_flight.request(RepoLoadsInFlight::BRANCHES) { + repo_state.set_branches(Loadable::NotLoaded); + extra_effects.push(Effect::LoadBranches { repo_id }); + } if command_succeeded && let Some(target) = selected_submodule_target_changed_by_command(repo_state, &command) { diff --git a/crates/gitcomet-state/src/store/reducer/util.rs b/crates/gitcomet-state/src/store/reducer/util.rs index c29e498a8..cc1ee5461 100644 --- a/crates/gitcomet-state/src/store/reducer/util.rs +++ b/crates/gitcomet-state/src/store/reducer/util.rs @@ -1140,6 +1140,7 @@ fn summarize_command( } RepoCommandKind::InteractiveCherryPick { .. } => "Cherry-pick", RepoCommandKind::CherryPick { .. } => "Cherry-pick", + RepoCommandKind::CherryPickRangeOntoNewBranch { .. } => "Cherry-pick", RepoCommandKind::MergeAbort => "Merge", RepoCommandKind::CreateTag { .. } => "Tag", RepoCommandKind::DeleteTag { .. } => "Tag", @@ -1394,6 +1395,39 @@ fn summarize_command( }; format!("Cherry-pick {} commits: {state}", entries.len()) } +<<<<<<< New base: Support explicit commit ranges when cherry-picking onto a new branch (#17) + RepoCommandKind::CherryPickRangeOntoNewBranch { + source, + base, + new_branch, + .. + } => { + let state = if sequencer_paused(output) { + "Paused at a conflict" + } else { + "Completed" + }; + format!( + "Cherry-picked {source} onto {base} as {new_branch}: {state}" + ) + } +||||||| Common ancestor +======= + RepoCommandKind::CherryPickRangeOntoNewBranch { + source, + base, + new_branch, + } => { + let state = if sequencer_paused(output) { + "Paused at a conflict" + } else { + "Completed" + }; + format!( + "Cherry-picked {source} onto {base} as {new_branch}: {state}" + ) + } +>>>>>>> Current commit: Add cherry-pick branch A onto B as new branch C from the action bar RepoCommandKind::CherryPick { commit_id, commit, @@ -2585,6 +2619,41 @@ mod tests { "Current branch already has all the changes from the cherry-picked commit." ); +<<<<<<< New base: Support explicit commit ranges when cherry-picking onto a new branch (#17) + let (_, range_summary) = summarize_command( + &RepoCommandKind::CherryPickRangeOntoNewBranch { + base: "main".into(), + range: "main".into(), + source: "feature".into(), + new_branch: "feature-copy".into(), + }, + &command_output("git cherry-pick 3 commits onto feature-copy", "", ""), + true, + None, + ); + assert_eq!( + range_summary, + "Cherry-picked feature onto main as feature-copy: Completed" + ); + +||||||| Common ancestor +======= + let (_, range_summary) = summarize_command( + &RepoCommandKind::CherryPickRangeOntoNewBranch { + base: "main".into(), + source: "feature".into(), + new_branch: "feature-copy".into(), + }, + &command_output("git cherry-pick 3 commits onto feature-copy", "", ""), + true, + None, + ); + assert_eq!( + range_summary, + "Cherry-picked feature onto main as feature-copy: Completed" + ); + +>>>>>>> Current commit: Add cherry-pick branch A onto B as new branch C from the action bar let (_, merge_abort_summary) = summarize_command( &RepoCommandKind::MergeAbort, &command_output("git merge --abort", "", ""), diff --git a/crates/gitcomet-state/src/store/tests/actions_emit_effects.rs b/crates/gitcomet-state/src/store/tests/actions_emit_effects.rs index b98f2de08..59e8e9b9d 100644 --- a/crates/gitcomet-state/src/store/tests/actions_emit_effects.rs +++ b/crates/gitcomet-state/src/store/tests/actions_emit_effects.rs @@ -2057,6 +2057,54 @@ fn additional_routing_messages_emit_effects_and_update_counters() { &mut repos, &id_alloc, &mut state, +<<<<<<< New base: Support explicit commit ranges when cherry-picking onto a new branch (#17) + Msg::CherryPickRangeOntoNewBranch { + repo_id, + base: "main".to_string(), + range: "main".to_string(), + source: "feature".to_string(), + new_branch: "feature-copy".to_string(), + }, + ); + assert!(matches!( + effects.as_slice(), + [Effect::CherryPickRangeOntoNewBranch { + repo_id: RepoId(1), + base, + range, + source, + new_branch, + }] if base == "main" && range == "main" && source == "feature" && new_branch == "feature-copy" + )); + + let effects = reduce( + &mut repos, + &id_alloc, + &mut state, +||||||| Common ancestor +======= + Msg::CherryPickRangeOntoNewBranch { + repo_id, + base: "main".to_string(), + source: "feature".to_string(), + new_branch: "feature-copy".to_string(), + }, + ); + assert!(matches!( + effects.as_slice(), + [Effect::CherryPickRangeOntoNewBranch { + repo_id: RepoId(1), + base, + source, + new_branch, + }] if base == "main" && source == "feature" && new_branch == "feature-copy" + )); + + let effects = reduce( + &mut repos, + &id_alloc, + &mut state, +>>>>>>> Current commit: Add cherry-pick branch A onto B as new branch C from the action bar Msg::CreateBranchAndCheckout { repo_id, name: "feature/new".to_string(), @@ -2160,7 +2208,7 @@ fn additional_routing_messages_emit_effects_and_update_counters() { )); assert_eq!( - state.repos[0].local_actions_in_flight, 9, + state.repos[0].local_actions_in_flight, 10, "expected begin_local_action for all routed local-action messages" ); diff --git a/crates/gitcomet-ui-gpui/src/view/mod_helpers.rs b/crates/gitcomet-ui-gpui/src/view/mod_helpers.rs index 5df8772b4..aed33346b 100644 --- a/crates/gitcomet-ui-gpui/src/view/mod_helpers.rs +++ b/crates/gitcomet-ui-gpui/src/view/mod_helpers.rs @@ -4321,6 +4321,20 @@ pub(super) enum PopoverKind { /// a value would make them compare equal. name_prefix: String, }, + /// Pick a source ref A, a range ref B, a base branch D, and a name for a + /// new branch C: C is created from D, checked out, and every commit + /// unique to A relative to B (B..A, oldest first, merges skipped) is + /// cherry-picked onto it. B must be an ancestor of A. + /// + /// The `prefill_*` fields seed the pickers when opened from a branch's + /// context menu: source = the clicked branch, range = its upstream (when + /// it has one), base = the current branch. + CherryPickRangePrompt { + repo_id: RepoId, + prefill_source: Option, + prefill_range: Option, + prefill_base: Option, + }, RenameBranchPrompt { repo_id: RepoId, name: String, diff --git a/crates/gitcomet-ui-gpui/src/view/panels/action_bar.rs b/crates/gitcomet-ui-gpui/src/view/panels/action_bar.rs index ed5f97195..e110557cd 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/action_bar.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/action_bar.rs @@ -786,6 +786,67 @@ impl Render for ActionBarView { }) .gitcomet_tooltip(theme, "Create branch".into()); +<<<<<<< New base: Support explicit commit ranges when cherry-picking onto a new branch (#17) + let cherry_pick_range_invoker: SharedString = "cherry_pick_range_btn".into(); + let cherry_pick_range_active = self + .active_context_menu_invoker + .as_ref() + .is_some_and(|id| id.as_ref() == cherry_pick_range_invoker.as_ref()); + let cherry_pick_range = components::Button::new("cherry_pick_range", "") + .start_slot(icon("icons/copy.svg", icon_primary)) + .style(components::ButtonStyle::Subtle) + .selected(cherry_pick_range_active) + .selected_bg(menu_selected_bg) + .on_click_with_bounds(theme, cx, move |this, _e, bounds, window, cx| { + this.activate_context_menu_invoker(cherry_pick_range_invoker.clone(), cx); + if let Some(repo_id) = this.state.active_repo { + this.open_popover_for_bounds( + PopoverKind::CherryPickRangePrompt { + repo_id, + prefill_source: None, + prefill_range: None, + prefill_base: None, + }, + bounds, + window, + cx, + ); + } + }) + .gitcomet_tooltip( + theme, + "Cherry-pick ref A onto a new branch C created from D (range B..A)".into(), + ); + +||||||| Common ancestor +======= + let cherry_pick_range_invoker: SharedString = "cherry_pick_range_btn".into(); + let cherry_pick_range_active = self + .active_context_menu_invoker + .as_ref() + .is_some_and(|id| id.as_ref() == cherry_pick_range_invoker.as_ref()); + let cherry_pick_range = components::Button::new("cherry_pick_range", "") + .start_slot(icon("icons/copy.svg", icon_primary)) + .style(components::ButtonStyle::Subtle) + .selected(cherry_pick_range_active) + .selected_bg(menu_selected_bg) + .on_click_with_bounds(theme, cx, move |this, _e, bounds, window, cx| { + this.activate_context_menu_invoker(cherry_pick_range_invoker.clone(), cx); + if let Some(repo_id) = this.state.active_repo { + this.open_popover_for_bounds( + PopoverKind::CherryPickRangePrompt { repo_id }, + bounds, + window, + cx, + ); + } + }) + .gitcomet_tooltip( + theme, + "Cherry-pick branch A onto branch B as a new branch C".into(), + ); + +>>>>>>> Current commit: Add cherry-pick branch A onto B as new branch C from the action bar div() .w_full() .h(action_bar_height) @@ -902,6 +963,7 @@ impl Render for ActionBarView { .child(push) .child(terminal) .child(create_branch) + .child(cherry_pick_range) .child(stash), ) } diff --git a/crates/gitcomet-ui-gpui/src/view/panels/popover.rs b/crates/gitcomet-ui-gpui/src/view/panels/popover.rs index b54ca8877..39835b4f4 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/popover.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/popover.rs @@ -8,6 +8,7 @@ mod author_filter; mod branch_picker; mod checkout_remote_branch_prompt; mod cherry_pick_commit_confirm; +mod cherry_pick_range_prompt; mod clone_repo; mod commit_prompt; pub(in super::super) mod context_menu; @@ -302,6 +303,27 @@ pub(in super::super) struct PopoverHost { create_branch_input: Entity, create_branch_checkout_enabled: bool, create_branch_source_target: String, +<<<<<<< New base: Support explicit commit ranges when cherry-picking onto a new branch (#17) + cherry_pick_source_search_input: Option>, + cherry_pick_range_search_input: Option>, + cherry_pick_base_search_input: Option>, + cherry_pick_name_input: Entity, + _cherry_pick_source_search_subscription: Option, + _cherry_pick_range_search_subscription: Option, + _cherry_pick_base_search_subscription: Option, + cherry_pick_source_target: String, + cherry_pick_range_target: String, + cherry_pick_base_target: String, +||||||| Common ancestor +======= + cherry_pick_source_search_input: Option>, + cherry_pick_base_search_input: Option>, + cherry_pick_name_input: Entity, + _cherry_pick_source_search_subscription: Option, + _cherry_pick_base_search_subscription: Option, + cherry_pick_source_target: String, + cherry_pick_base_target: String, +>>>>>>> Current commit: Add cherry-pick branch A onto B as new branch C from the action bar worktree_ref_source_target: String, suppress_worktree_submit_after_ref_enter: bool, /// Set while a row menu floating over a picker runs one of its entries. The @@ -830,6 +852,7 @@ pub(in super::super) fn popover_width_spec(kind: &PopoverKind) -> Option Some(DIALOG_420_WIDTH), PopoverKind::CreateBranchFromRefPrompt { .. } + | PopoverKind::CherryPickRangePrompt { .. } | PopoverKind::RenameBranchPrompt { .. } | PopoverKind::CheckoutRemoteBranchPrompt { .. } => Some(DIALOG_540_WIDTH), PopoverKind::StashDropConfirm { .. } @@ -1300,6 +1323,17 @@ impl PopoverHost { ) }); + let cherry_pick_name_input = cx.new(|cx| { + components::TextInput::new( + components::TextInputOptions { + placeholder: "branch-name".into(), + ..Default::default() + }, + window, + cx, + ) + }); + let stash_message_input = cx.new(|cx| { components::TextInput::new( components::TextInputOptions { @@ -1522,6 +1556,13 @@ impl PopoverHost { } }, )); + prompt_input_subscriptions.push(Self::prompt_enter_subscription( + &cherry_pick_name_input, + window, + cx, + |this| matches!(this.popover, Some(PopoverKind::CherryPickRangePrompt { .. })), + |this, window, cx| this.submit_cherry_pick_range(window, cx), + )); prompt_input_subscriptions.push(Self::prompt_enter_subscription( &stash_message_input, window, @@ -1753,6 +1794,27 @@ impl PopoverHost { create_branch_input, create_branch_checkout_enabled: true, create_branch_source_target: String::new(), +<<<<<<< New base: Support explicit commit ranges when cherry-picking onto a new branch (#17) + cherry_pick_source_search_input: None, + cherry_pick_range_search_input: None, + cherry_pick_base_search_input: None, + cherry_pick_name_input, + _cherry_pick_source_search_subscription: None, + _cherry_pick_range_search_subscription: None, + _cherry_pick_base_search_subscription: None, + cherry_pick_source_target: String::new(), + cherry_pick_range_target: String::new(), + cherry_pick_base_target: String::new(), +||||||| Common ancestor +======= + cherry_pick_source_search_input: None, + cherry_pick_base_search_input: None, + cherry_pick_name_input, + _cherry_pick_source_search_subscription: None, + _cherry_pick_base_search_subscription: None, + cherry_pick_source_target: String::new(), + cherry_pick_base_target: String::new(), +>>>>>>> Current commit: Add cherry-pick branch A onto B as new branch C from the action bar worktree_ref_source_target: String::new(), suppress_worktree_submit_after_ref_enter: false, suppress_popover_close_after_action: false, @@ -2566,6 +2628,281 @@ impl PopoverHost { self.dismiss_inline_popover(window, cx); } +<<<<<<< New base: Support explicit commit ranges when cherry-picking onto a new branch (#17) + fn ensure_cherry_pick_search_input( + slot: &mut Option>, + placeholder: &str, + window: &mut Window, + cx: &mut gpui::Context, + ) -> Entity { + if let Some(input) = slot { + return input.clone(); + } + let input = cx.new(|cx| { + components::TextInput::new( + components::TextInputOptions { + placeholder: placeholder.into(), + ..Default::default() + }, + window, + cx, + ) + }); + input.update(cx, |input, cx| { + input.set_chromeless(false, cx); + input.set_leading_icon(None, cx); + }); + *slot = Some(input.clone()); + input + } + + fn handle_cherry_pick_source_select( + &mut self, + name: String, + window: &mut Window, + cx: &mut gpui::Context, + ) { + if !matches!(self.popover, Some(PopoverKind::CherryPickRangePrompt { .. })) { + return; + } + self.cherry_pick_source_target = name; + if let Some(input) = &self.cherry_pick_source_search_input { + let theme = self.theme; + input.update(cx, |input, cx| { + input.clear_transient_key_presses(); + input.set_theme(theme, cx); + input.set_text(self.cherry_pick_source_target.clone(), cx); + cx.notify(); + }); + } + self.branch_picker_selected_index = None; + // Move on to the range picker. + if let Some(range) = &self.cherry_pick_range_search_input { + let focus = range.read_with(cx, |input, _| input.focus_handle()); + window.focus(&focus, cx); + } + cx.notify(); + } + + fn handle_cherry_pick_range_select( + &mut self, + name: String, + window: &mut Window, + cx: &mut gpui::Context, + ) { + if !matches!(self.popover, Some(PopoverKind::CherryPickRangePrompt { .. })) { + return; + } + self.cherry_pick_range_target = name; + if let Some(input) = &self.cherry_pick_range_search_input { + let theme = self.theme; + input.update(cx, |input, cx| { + input.clear_transient_key_presses(); + input.set_theme(theme, cx); + input.set_text(self.cherry_pick_range_target.clone(), cx); + cx.notify(); + }); + } + self.branch_picker_selected_index = None; + // Move on to the base picker. + if let Some(base) = &self.cherry_pick_base_search_input { + let focus = base.read_with(cx, |input, _| input.focus_handle()); + window.focus(&focus, cx); + } + cx.notify(); + } + + fn handle_cherry_pick_base_select( + &mut self, + name: String, + window: &mut Window, + cx: &mut gpui::Context, + ) { + if !matches!(self.popover, Some(PopoverKind::CherryPickRangePrompt { .. })) { + return; + } + self.cherry_pick_base_target = name; + if let Some(input) = &self.cherry_pick_base_search_input { + let theme = self.theme; + input.update(cx, |input, cx| { + input.clear_transient_key_presses(); + input.set_theme(theme, cx); + input.set_text(self.cherry_pick_base_target.clone(), cx); + cx.notify(); + }); + } + self.branch_picker_selected_index = None; + // Move on to the new branch name. + let focus = self + .cherry_pick_name_input + .read_with(cx, |input, _| input.focus_handle()); + window.focus(&focus, cx); + cx.notify(); + } + + fn cherry_pick_can_submit(&self, cx: &mut gpui::Context) -> bool { + if !matches!(self.popover, Some(PopoverKind::CherryPickRangePrompt { .. })) { + return false; + } + let source = self.cherry_pick_source_target.trim(); + let range = self.cherry_pick_range_target.trim(); + let base = self.cherry_pick_base_target.trim(); + if source.is_empty() || range.is_empty() || base.is_empty() || source == range { + return false; + } + self.cherry_pick_name_input + .read_with(cx, |input, _| !input.text().trim().is_empty()) + } + + fn submit_cherry_pick_range(&mut self, window: &mut Window, cx: &mut gpui::Context) { + let Some(PopoverKind::CherryPickRangePrompt { repo_id, .. }) = self.popover.clone() else { + return; + }; + if !self.cherry_pick_can_submit(cx) { + return; + } + let base = self.cherry_pick_base_target.trim().to_string(); + let range = self.cherry_pick_range_target.trim().to_string(); + let source = self.cherry_pick_source_target.trim().to_string(); + let new_branch = self + .cherry_pick_name_input + .read_with(cx, |input, _| input.text().trim().to_string()); + if new_branch.is_empty() { + return; + } + self.store.dispatch(Msg::CherryPickRangeOntoNewBranch { + repo_id, + base, + range, + source, + new_branch, + }); + self.dismiss_inline_popover(window, cx); + } + +||||||| Common ancestor +======= + fn ensure_cherry_pick_search_input( + slot: &mut Option>, + placeholder: &str, + window: &mut Window, + cx: &mut gpui::Context, + ) -> Entity { + if let Some(input) = slot { + return input.clone(); + } + let input = cx.new(|cx| { + components::TextInput::new( + components::TextInputOptions { + placeholder: placeholder.into(), + ..Default::default() + }, + window, + cx, + ) + }); + input.update(cx, |input, cx| { + input.set_chromeless(false, cx); + input.set_leading_icon(None, cx); + }); + *slot = Some(input.clone()); + input + } + + fn handle_cherry_pick_source_select( + &mut self, + name: String, + window: &mut Window, + cx: &mut gpui::Context, + ) { + if !matches!(self.popover, Some(PopoverKind::CherryPickRangePrompt { .. })) { + return; + } + self.cherry_pick_source_target = name; + if let Some(input) = &self.cherry_pick_source_search_input { + let theme = self.theme; + input.update(cx, |input, cx| { + input.clear_transient_key_presses(); + input.set_theme(theme, cx); + input.set_text(self.cherry_pick_source_target.clone(), cx); + cx.notify(); + }); + } + self.branch_picker_selected_index = None; + // Move on to the base picker. + if let Some(base) = &self.cherry_pick_base_search_input { + let focus = base.read_with(cx, |input, _| input.focus_handle()); + window.focus(&focus, cx); + } + cx.notify(); + } + + fn handle_cherry_pick_base_select( + &mut self, + name: String, + window: &mut Window, + cx: &mut gpui::Context, + ) { + if !matches!(self.popover, Some(PopoverKind::CherryPickRangePrompt { .. })) { + return; + } + self.cherry_pick_base_target = name; + if let Some(input) = &self.cherry_pick_base_search_input { + let theme = self.theme; + input.update(cx, |input, cx| { + input.clear_transient_key_presses(); + input.set_theme(theme, cx); + input.set_text(self.cherry_pick_base_target.clone(), cx); + cx.notify(); + }); + } + self.branch_picker_selected_index = None; + // Move on to the new branch name. + let focus = self + .cherry_pick_name_input + .read_with(cx, |input, _| input.focus_handle()); + window.focus(&focus, cx); + cx.notify(); + } + + fn cherry_pick_can_submit(&self, cx: &mut gpui::Context) -> bool { + if !matches!(self.popover, Some(PopoverKind::CherryPickRangePrompt { .. })) { + return false; + } + let source = self.cherry_pick_source_target.trim(); + let base = self.cherry_pick_base_target.trim(); + if source.is_empty() || base.is_empty() || source == base { + return false; + } + self.cherry_pick_name_input + .read_with(cx, |input, _| !input.text().trim().is_empty()) + } + + fn submit_cherry_pick_range(&mut self, window: &mut Window, cx: &mut gpui::Context) { + let Some(PopoverKind::CherryPickRangePrompt { repo_id }) = self.popover.clone() else { + return; + }; + if !self.cherry_pick_can_submit(cx) { + return; + } + let base = self.cherry_pick_base_target.trim().to_string(); + let source = self.cherry_pick_source_target.trim().to_string(); + let new_branch = self + .cherry_pick_name_input + .read_with(cx, |input, _| input.text().trim().to_string()); + if new_branch.is_empty() { + return; + } + self.store.dispatch(Msg::CherryPickRangeOntoNewBranch { + repo_id, + base, + source, + new_branch, + }); + self.dismiss_inline_popover(window, cx); + } + +>>>>>>> Current commit: Add cherry-pick branch A onto B as new branch C from the action bar fn can_submit_rename_branch(&self, cx: &mut gpui::Context) -> bool { let Some(PopoverKind::RenameBranchPrompt { name, .. }) = &self.popover else { return false; @@ -3101,6 +3438,183 @@ impl PopoverHost { .read_with(cx, |i, _| i.focus_handle()); window.focus(&focus, cx); } +<<<<<<< New base: Add cherry-pick branch A onto B as new branch C from the action bar +<<<<<<< New base: Support explicit commit ranges when cherry-picking onto a new branch (#17) + PopoverKind::CherryPickRangePrompt { .. } => { +||||||| Common ancestor + PopoverKind::CherryPickRangePrompt { .. } => { +======= + PopoverKind::CherryPickRangePrompt { + prefill_source, + prefill_range, + prefill_base, + .. + } => { +>>>>>>> Current commit: Add Cherry-pick onto new branch action to the branch context menu with prefilled + let theme = self.theme; + // D defaults to the current branch: C usually starts from + // where the user is standing. A context-menu open may + // prefill all three pickers instead. + let current_branch = self + .active_repo() + .and_then(|repo| match &repo.head_branch { + Loadable::Ready(head) => Some(head.to_string()), + _ => None, + }) + .unwrap_or_default(); + let source_prefill = prefill_source.clone().unwrap_or_default(); + let range_prefill = prefill_range.clone().unwrap_or_default(); + let base_prefill = prefill_base.clone().unwrap_or(current_branch); + self.cherry_pick_source_target = source_prefill.clone(); + self.cherry_pick_range_target = range_prefill.clone(); + self.cherry_pick_base_target = base_prefill.clone(); + let source_input = + Self::ensure_cherry_pick_search_input( + &mut self.cherry_pick_source_search_input, + "branch", + window, + cx, + ); + let range_input = Self::ensure_cherry_pick_search_input( + &mut self.cherry_pick_range_search_input, + "branch", + window, + cx, + ); + let base_input = Self::ensure_cherry_pick_search_input( + &mut self.cherry_pick_base_search_input, + "branch", + window, + cx, + ); + if self._cherry_pick_source_search_subscription.is_none() { + let input = source_input.clone(); + self._cherry_pick_source_search_subscription = Some(cx.observe( + &input, + |this, _input, cx| { + if matches!( + this.popover, + Some(PopoverKind::CherryPickRangePrompt { .. }) + ) { + cx.notify(); + } + }, + )); + } + if self._cherry_pick_range_search_subscription.is_none() { + let input = range_input.clone(); + self._cherry_pick_range_search_subscription = Some(cx.observe( + &input, + |this, _input, cx| { + if matches!( + this.popover, + Some(PopoverKind::CherryPickRangePrompt { .. }) + ) { + cx.notify(); + } + }, + )); + } + if self._cherry_pick_base_search_subscription.is_none() { + let input = base_input.clone(); + self._cherry_pick_base_search_subscription = Some(cx.observe( + &input, + |this, _input, cx| { + if matches!( + this.popover, + Some(PopoverKind::CherryPickRangePrompt { .. }) + ) { + cx.notify(); + } + }, + )); + } + for (input, text) in [ + (&source_input, source_prefill.clone()), + (&range_input, range_prefill.clone()), + (&base_input, base_prefill.clone()), + ] { + input.update(cx, |input, cx| { + input.clear_transient_key_presses(); + input.set_theme(theme, cx); + input.set_text(text, cx); + cx.notify(); + }); + } + self.cherry_pick_name_input.update(cx, |input, cx| { + input.clear_transient_key_presses(); + input.set_theme(theme, cx); + input.set_text("", cx); + cx.notify(); + }); + let focus = source_input.read_with(cx, |i, _| i.focus_handle()); + window.focus(&focus, cx); + } +||||||| Common ancestor +======= + PopoverKind::CherryPickRangePrompt { .. } => { + let theme = self.theme; + self.cherry_pick_source_target = String::new(); + self.cherry_pick_base_target = String::new(); + let source_input = + Self::ensure_cherry_pick_search_input( + &mut self.cherry_pick_source_search_input, + "branch", + window, + cx, + ); + let base_input = Self::ensure_cherry_pick_search_input( + &mut self.cherry_pick_base_search_input, + "branch", + window, + cx, + ); + if self._cherry_pick_source_search_subscription.is_none() { + let input = source_input.clone(); + self._cherry_pick_source_search_subscription = Some(cx.observe( + &input, + |this, _input, cx| { + if matches!( + this.popover, + Some(PopoverKind::CherryPickRangePrompt { .. }) + ) { + cx.notify(); + } + }, + )); + } + if self._cherry_pick_base_search_subscription.is_none() { + let input = base_input.clone(); + self._cherry_pick_base_search_subscription = Some(cx.observe( + &input, + |this, _input, cx| { + if matches!( + this.popover, + Some(PopoverKind::CherryPickRangePrompt { .. }) + ) { + cx.notify(); + } + }, + )); + } + for input in [&source_input, &base_input] { + input.update(cx, |input, cx| { + input.clear_transient_key_presses(); + input.set_theme(theme, cx); + input.set_text("", cx); + cx.notify(); + }); + } + self.cherry_pick_name_input.update(cx, |input, cx| { + input.clear_transient_key_presses(); + input.set_theme(theme, cx); + input.set_text("", cx); + cx.notify(); + }); + let focus = source_input.read_with(cx, |i, _| i.focus_handle()); + window.focus(&focus, cx); + } +>>>>>>> Current commit: Add cherry-pick branch A onto B as new branch C from the action bar PopoverKind::RenameBranchPrompt { name, .. } => { let theme = self.theme; self.create_branch_input.update(cx, |input, cx| { @@ -4099,6 +4613,9 @@ impl PopoverHost { PopoverKind::CherryPickCommitConfirm { repo_id, commit_id } => { cherry_pick_commit_confirm::panel(self, repo_id, commit_id, cx) } + PopoverKind::CherryPickRangePrompt { repo_id, .. } => { + cherry_pick_range_prompt::panel(self, repo_id, window, cx) + } PopoverKind::MergeAbortConfirm { repo_id } => { merge_abort_confirm::panel(self, repo_id, cx) } diff --git a/crates/gitcomet-ui-gpui/src/view/panels/popover/cherry_pick_range_prompt.rs b/crates/gitcomet-ui-gpui/src/view/panels/popover/cherry_pick_range_prompt.rs new file mode 100644 index 000000000..d77db75dc --- /dev/null +++ b/crates/gitcomet-ui-gpui/src/view/panels/popover/cherry_pick_range_prompt.rs @@ -0,0 +1,371 @@ +<<<<<<< New base: Support explicit commit ranges when cherry-picking onto a new branch (#17) +use super::*; + +/// One picker row for the Cherry-pick prompt: a chromeless search input that +/// expands into a branch/ref picker while focused, exactly like the create +/// branch prompt's source row. +fn picker_row( + this: &mut PopoverHost, + theme: AppTheme, + label: &'static str, + input: &Entity, + on_select: impl Fn(&mut PopoverHost, String, &ClickEvent, &mut Window, &mut gpui::Context) + + 'static, + window: &Window, + cx: &mut gpui::Context, +) -> gpui::Div { + let ui_scale_percent = super::popover_ui_scale_percent(cx); + let scaled_px = |value: f32| super::popover_scaled_px_from_percent(value, ui_scale_percent); + let is_focused = input + .read_with(cx, |input, _| input.focus_handle()) + .is_focused(window); + input.update(cx, |input, cx| { + input.set_chromeless(is_focused, cx); + input.set_leading_icon(is_focused.then_some("icons/git_branch.svg"), cx); + }); + + if is_focused { + let refs = this.active_branch_ref_picker_items(true, true); + div() + .flex() + .flex_col() + .child( + div() + .px_2() + .py_1() + .text_sm() + .text_color(theme.colors.text_muted) + .child(label), + ) + .child( + div().px_2().pb_1().w_full().min_w(px(0.0)).child( + components::BranchRefPicker::new( + input.clone(), + this.picker_prompt_scroll.clone(), + refs, + ) + .tooltip_host(this.tooltip_host.clone()) + .empty_text("No matches") + .max_height(scaled_px(240.0)) + .selected_index(this.branch_picker_selected_index) + .select_on_mouse_down() + .render(theme, ui_scale_percent, cx, on_select), + ), + ) + } else { + div() + .flex() + .flex_col() + .child( + div() + .px_2() + .py_1() + .text_sm() + .text_color(theme.colors.text_muted) + .child(label), + ) + .child(div().px_2().pb_1().w_full().min_w(px(0.0)).child(input.clone())) + } +} + +pub(super) fn panel( + this: &mut PopoverHost, + _repo_id: RepoId, + window: &Window, + cx: &mut gpui::Context, +) -> gpui::Div { + let theme = this.theme; + let scaled_px = super::popover_scaled_px_fn(cx); + let can_submit = this.cherry_pick_can_submit(cx); + let source = this.cherry_pick_source_target.trim().to_string(); + let range = this.cherry_pick_range_target.trim().to_string(); + let same_ref_hint = !source.is_empty() && source == range; + + let source_input = this + .cherry_pick_source_search_input + .clone() + .expect("cherry_pick_source_search_input must be initialized"); + let range_input = this + .cherry_pick_range_search_input + .clone() + .expect("cherry_pick_range_search_input must be initialized"); + let base_input = this + .cherry_pick_base_search_input + .clone() + .expect("cherry_pick_base_search_input must be initialized"); + + div() + .flex() + .flex_col() + .w(scaled_px(540.0)) + .child(popover_title("Cherry-pick branch")) + .child(div().border_t_1().border_color(theme.colors.border)) + .child( + div() + .px_2() + .py_1() + .text_sm() + .text_color(theme.colors.text_muted) + .child( + "Creates a new branch C from D, checks it out, and cherry-picks every commit unique to A relative to B (B..A, oldest first, merge commits skipped). B must be an ancestor of A.", + ), + ) + .child(picker_row( + this, + theme, + "Source ref (A)", + &source_input, + |this, name, _e, window, cx| { + this.handle_cherry_pick_source_select(name, window, cx); + }, + window, + cx, + )) + .child(picker_row( + this, + theme, + "Range ref (B)", + &range_input, + |this, name, _e, window, cx| { + this.handle_cherry_pick_range_select(name, window, cx); + }, + window, + cx, + )) + .child(picker_row( + this, + theme, + "Base branch (D)", + &base_input, + |this, name, _e, window, cx| { + this.handle_cherry_pick_base_select(name, window, cx); + }, + window, + cx, + )) + .child(input_label(theme, "New branch name (C)")) + .child( + div() + .px_2() + .pb_1() + .w_full() + .min_w(px(0.0)) + .child(this.cherry_pick_name_input.clone()), + ) + .when(same_ref_hint, |this| { + this.child( + div() + .px_2() + .pb_1() + .text_sm() + .text_color(theme.colors.warning) + .child("Source and range are the same — there is nothing to cherry-pick."), + ) + }) + .child(div().border_t_1().border_color(theme.colors.border)) + .child( + div() + .px_2() + .py_1() + .flex() + .items_center() + .justify_between() + .child( + cancel_button( + "cherry_pick_range_cancel", + "cherry_pick_range_cancel_hint", + theme, + ) + .on_click(theme, cx, |this, _e, window, cx| { + this.dismiss_prompt_popover(window, cx); + }), + ) + .child( + components::Button::new("cherry_pick_range_go", "Create & cherry-pick") + .style(components::ButtonStyle::Filled) + .disabled(!can_submit) + .on_click(theme, cx, |this, _e, window, cx| { + this.submit_cherry_pick_range(window, cx); + }), + ), + ) +} +||||||| +======= +use super::*; + +/// One picker row for the Cherry-pick prompt: a chromeless search input that +/// expands into a branch/ref picker while focused, exactly like the create +/// branch prompt's source row. +fn picker_row( + this: &mut PopoverHost, + theme: AppTheme, + label: &'static str, + input: &Entity, + on_select: impl Fn(&mut PopoverHost, String, &ClickEvent, &mut Window, &mut gpui::Context) + + 'static, + window: &Window, + cx: &mut gpui::Context, +) -> gpui::Div { + let ui_scale_percent = super::popover_ui_scale_percent(cx); + let scaled_px = |value: f32| super::popover_scaled_px_from_percent(value, ui_scale_percent); + let is_focused = input + .read_with(cx, |input, _| input.focus_handle()) + .is_focused(window); + input.update(cx, |input, cx| { + input.set_chromeless(is_focused, cx); + input.set_leading_icon(is_focused.then_some("icons/git_branch.svg"), cx); + }); + + if is_focused { + let refs = this.active_branch_ref_picker_items(true, true); + div() + .flex() + .flex_col() + .child( + div() + .px_2() + .py_1() + .text_sm() + .text_color(theme.colors.text_muted) + .child(label), + ) + .child( + div().px_2().pb_1().w_full().min_w(px(0.0)).child( + components::BranchRefPicker::new( + input.clone(), + this.picker_prompt_scroll.clone(), + refs, + ) + .tooltip_host(this.tooltip_host.clone()) + .empty_text("No matches") + .max_height(scaled_px(240.0)) + .selected_index(this.branch_picker_selected_index) + .select_on_mouse_down() + .render(theme, ui_scale_percent, cx, on_select), + ), + ) + } else { + div() + .flex() + .flex_col() + .child( + div() + .px_2() + .py_1() + .text_sm() + .text_color(theme.colors.text_muted) + .child(label), + ) + .child(div().px_2().pb_1().w_full().min_w(px(0.0)).child(input.clone())) + } +} + +pub(super) fn panel( + this: &mut PopoverHost, + _repo_id: RepoId, + window: &Window, + cx: &mut gpui::Context, +) -> gpui::Div { + let theme = this.theme; + let scaled_px = super::popover_scaled_px_fn(cx); + let can_submit = this.cherry_pick_can_submit(cx); + let source = this.cherry_pick_source_target.trim().to_string(); + let base = this.cherry_pick_base_target.trim().to_string(); + let same_branch_hint = !source.is_empty() && source == base; + + let source_input = this + .cherry_pick_source_search_input + .clone() + .expect("cherry_pick_source_search_input must be initialized"); + let base_input = this + .cherry_pick_base_search_input + .clone() + .expect("cherry_pick_base_search_input must be initialized"); + + div() + .flex() + .flex_col() + .w(scaled_px(540.0)) + .child(popover_title("Cherry-pick branch")) + .child(div().border_t_1().border_color(theme.colors.border)) + .child( + div() + .px_2() + .py_1() + .text_sm() + .text_color(theme.colors.text_muted) + .child( + "Creates a new branch C from B, checks it out, and cherry-picks every commit unique to A (oldest first, merge commits skipped).", + ), + ) + .child(picker_row( + this, + theme, + "Source branch (A)", + &source_input, + |this, name, _e, window, cx| { + this.handle_cherry_pick_source_select(name, window, cx); + }, + window, + cx, + )) + .child(picker_row( + this, + theme, + "Base branch (B)", + &base_input, + |this, name, _e, window, cx| { + this.handle_cherry_pick_base_select(name, window, cx); + }, + window, + cx, + )) + .child(input_label(theme, "New branch name (C)")) + .child( + div() + .px_2() + .pb_1() + .w_full() + .min_w(px(0.0)) + .child(this.cherry_pick_name_input.clone()), + ) + .when(same_branch_hint, |this| { + this.child( + div() + .px_2() + .pb_1() + .text_sm() + .text_color(theme.colors.warning) + .child("Source and base are the same — there is nothing to cherry-pick."), + ) + }) + .child(div().border_t_1().border_color(theme.colors.border)) + .child( + div() + .px_2() + .py_1() + .flex() + .items_center() + .justify_between() + .child( + cancel_button( + "cherry_pick_range_cancel", + "cherry_pick_range_cancel_hint", + theme, + ) + .on_click(theme, cx, |this, _e, window, cx| { + this.dismiss_prompt_popover(window, cx); + }), + ) + .child( + components::Button::new("cherry_pick_range_go", "Create & cherry-pick") + .style(components::ButtonStyle::Filled) + .disabled(!can_submit) + .on_click(theme, cx, |this, _e, window, cx| { + this.submit_cherry_pick_range(window, cx); + }), + ), + ) +} +>>>>>>> Current commit: Add cherry-pick branch A onto B as new branch C from the action bar diff --git a/crates/gitcomet-ui-gpui/src/view/panels/popover/context_menu/branch.rs b/crates/gitcomet-ui-gpui/src/view/panels/popover/context_menu/branch.rs index 5b54373ba..19cbab62b 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/popover/context_menu/branch.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/popover/context_menu/branch.rs @@ -103,6 +103,31 @@ pub(super) fn model( }), }); } + // Cherry-pick this branch (A) onto the current branch (D) as a new + // branch (C). The range ref (B) defaults to A's upstream when it has one, + // so the copied set is "what this branch adds". + let clicked_upstream = repo.and_then(|r| match &r.branches { + Loadable::Ready(branches) => branches + .iter() + .find(|branch| branch.name == *name) + .and_then(|branch| branch.upstream.as_ref()) + .map(|upstream| format!("{}/{}", upstream.remote, upstream.branch)), + _ => None, + }); + items.push(ContextMenuItem::Entry { + label: "Cherry-pick onto new branch…".into(), + icon: Some("icons/copy.svg".into()), + shortcut: None, + disabled: false, + action: Box::new(ContextMenuAction::OpenPopover { + kind: PopoverKind::CherryPickRangePrompt { + repo_id, + prefill_source: Some(name.clone()), + prefill_range: clicked_upstream, + prefill_base: active_branch_name.clone(), + }, + }), + }); // Comparison: mark this branch's tip, or compare it against a mark. let branch_commit_id: Option = match section { diff --git a/crates/gitcomet-ui-gpui/src/view/panels/popover/fingerprint.rs b/crates/gitcomet-ui-gpui/src/view/panels/popover/fingerprint.rs index 164ff4a13..e4864cd47 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/popover/fingerprint.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/popover/fingerprint.rs @@ -156,6 +156,7 @@ fn repo_for_popover<'a>(state: &'a AppState, popover: &PopoverKind) -> Option<&' PopoverKind::CommitPrompt { repo_id } | PopoverKind::StashPickerPrompt { repo_id, .. } | PopoverKind::CreateBranchFromRefPrompt { repo_id, .. } + | PopoverKind::CherryPickRangePrompt { repo_id, .. } | PopoverKind::RenameBranchPrompt { repo_id, .. } | PopoverKind::ResetPrompt { repo_id, .. } | PopoverKind::SquashPrompt { repo_id } @@ -210,6 +211,7 @@ fn hash_repo_for_popover(repo: &RepoState, popover: &PopoverKind, has match popover { PopoverKind::BranchPicker { .. } | PopoverKind::CreateBranchFromRefPrompt { .. } + | PopoverKind::CherryPickRangePrompt { .. } | PopoverKind::RenameBranchPrompt { .. } | PopoverKind::BranchMenu { .. } | PopoverKind::BranchSectionMenu { .. } @@ -437,6 +439,18 @@ fn hash_popover_kind(kind: &PopoverKind, hasher: &mut H) { source_selectable.hash(hasher); name_prefix.hash(hasher); } + PopoverKind::CherryPickRangePrompt { + repo_id, + prefill_source, + prefill_range, + prefill_base, + } => { + 103u8.hash(hasher); + repo_id.hash(hasher); + prefill_source.hash(hasher); + prefill_range.hash(hasher); + prefill_base.hash(hasher); + } PopoverKind::RenameBranchPrompt { repo_id, name, diff --git a/crates/gitcomet-ui-gpui/src/view/panels/popover/tests/refs.rs b/crates/gitcomet-ui-gpui/src/view/panels/popover/tests/refs.rs index 34a2a653e..3da1630cc 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/popover/tests/refs.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/popover/tests/refs.rs @@ -1792,3 +1792,105 @@ fn local_branch_menu_excludes_pull_merge_and_squash_for_current_branch( assert!(delete_disabled, "expected delete entry to be disabled"); }); } + +#[gpui::test] +fn local_branch_menu_cherry_pick_prefills_source_range_and_base(cx: &mut gpui::TestAppContext) { + let (store, events) = AppStore::new(Arc::new(TestBackend)); + let (view, cx) = + cx.add_window_view(|window, cx| GitCometView::new(store, events, None, window, cx)); + + let repo_id = RepoId(240); + let branch_name = "feature/awesome".to_string(); + let workdir = std::env::temp_dir().join(format!( + "gitcomet_ui_test_{}_branch_menu_cherry_pick", + std::process::id() + )); + + cx.update(|_window, app| { + view.update(app, |this, cx| { + let mut repo = RepoState::new_opening( + repo_id, + gitcomet_core::domain::RepoSpec { + workdir: workdir.clone(), + }, + ); + repo.head_branch = Loadable::Ready("main".to_string()); + repo.branches = Loadable::Ready(Arc::new(vec![ + gitcomet_core::domain::Branch { + name: "main".to_string(), + target: CommitId("aaaaaaaa".into()), + upstream: Some(gitcomet_core::domain::Upstream { + remote: "origin".to_string(), + branch: "main".to_string(), + }), + divergence: None, + }, + gitcomet_core::domain::Branch { + name: branch_name.clone(), + target: CommitId("bbbbbbbb".into()), + upstream: Some(gitcomet_core::domain::Upstream { + remote: "origin".to_string(), + branch: "awesome".to_string(), + }), + divergence: None, + }, + ])); + + let state = Arc::new(AppState { + repos: vec![repo], + active_repo: Some(repo_id), + ..Default::default() + }); + this.state = Arc::clone(&state); + this._ui_model + .update(cx, |model, cx| model.set_state(state, cx)); + cx.notify(); + }); + }); + + cx.update(|_window, app| { + let model = view + .update(app, |this, cx| { + this.popover_host.update(cx, |host, cx| { + host.context_menu_model( + &PopoverKind::BranchMenu { + repo_id, + section: BranchSection::Local, + name: branch_name.clone(), + }, + cx, + ) + }) + }) + .expect("expected branch context menu model"); + + let entry = model.items.iter().find_map(|item| match item { + ContextMenuItem::Entry { label, action, .. } + if label.as_ref() == "Cherry-pick onto new branch…" => + { + Some((**action).clone()) + } + _ => None, + }); + + match entry { + Some(ContextMenuAction::OpenPopover { + kind: + PopoverKind::CherryPickRangePrompt { + repo_id: rid, + prefill_source, + prefill_range, + prefill_base, + }, + }) => { + assert_eq!(rid, repo_id); + assert_eq!(prefill_source.as_deref(), Some("feature/awesome")); + assert_eq!(prefill_range.as_deref(), Some("origin/awesome")); + assert_eq!(prefill_base.as_deref(), Some("main")); + } + _ => panic!( + "expected Cherry-pick onto new branch entry with prefilled CherryPickRangePrompt" + ), + } + }); +} From 6c809f5eab15082588e94596612964ae3038691c Mon Sep 17 00:00:00 2001 From: angeousta <132761637+angeousta@users.noreply.github.com> Date: Tue, 18 Aug 2026 02:34:13 +0200 Subject: [PATCH 2/3] feat: add commit range preview to the cherry-pick dialog Shows the range..source commits that will be cherry-picked directly in the dialog before submitting, with each preview row clickable to open that commit. Also cleans up leftover local-rebase conflict markers that had ended up committed in the cherry-pick range feature's files. --- crates/gitcomet-core/src/domain.rs | 8 + crates/gitcomet-core/src/services.rs | 25 +- crates/gitcomet-git-gix/src/repo/history.rs | 132 ++--- crates/gitcomet-git-gix/src/repo/mod.rs | 22 +- .../tests/cherry_pick_integration.rs | 103 +--- crates/gitcomet-state/src/model.rs | 13 + crates/gitcomet-state/src/msg/effect.rs | 15 +- crates/gitcomet-state/src/msg/message.rs | 26 +- .../gitcomet-state/src/msg/message_debug.rs | 12 + .../src/msg/repo_command_kind.rs | 11 - crates/gitcomet-state/src/store/effects.rs | 55 +- .../src/store/effects/repo_commands.rs | 34 +- .../src/store/effects/repo_load.rs | 21 + crates/gitcomet-state/src/store/reducer.rs | 28 +- .../src/store/reducer/actions_emit_effects.rs | 30 +- .../src/store/reducer/effects.rs | 79 ++- .../gitcomet-state/src/store/reducer/util.rs | 41 +- .../src/store/tests/actions_emit_effects.rs | 132 ++++- .../src/view/panels/action_bar.rs | 30 -- .../src/view/panels/popover.rs | 471 ++++++++---------- .../popover/cherry_pick_range_prompt.rs | 380 +++++++------- .../src/view/panels/popover/fingerprint.rs | 20 +- .../src/view/panels/popover/search_inputs.rs | 4 +- .../src/view/panels/popover/tests/refs.rs | 118 +++++ 24 files changed, 940 insertions(+), 870 deletions(-) diff --git a/crates/gitcomet-core/src/domain.rs b/crates/gitcomet-core/src/domain.rs index 22ba2bdc3..6dfacd68d 100644 --- a/crates/gitcomet-core/src/domain.rs +++ b/crates/gitcomet-core/src/domain.rs @@ -70,6 +70,14 @@ pub struct RecentCommitMessage { pub message: String, } +/// A single-line summary of one commit in a ref range, used to preview the +/// commits a cherry-pick range would apply (oldest first, merges skipped). +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CommitRefSummary { + pub id: CommitId, + pub summary: Arc, +} + #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Hash)] pub enum HistoryMode { #[default] diff --git a/crates/gitcomet-core/src/services.rs b/crates/gitcomet-core/src/services.rs index 6956e63c8..d56336979 100644 --- a/crates/gitcomet-core/src/services.rs +++ b/crates/gitcomet-core/src/services.rs @@ -683,7 +683,6 @@ pub trait GitRepository: Send + Sync { } fn revert(&self, id: &CommitId) -> Result<()>; -<<<<<<< New base: Support explicit commit ranges when cherry-picking onto a new branch (#17) /// Creates a new branch `new_branch` pointing at `base`'s tip, checks it /// out, and cherry-picks every commit reachable from `source` but not from /// `range` (oldest first, merge commits skipped) onto it. `range` must be @@ -705,29 +704,19 @@ pub trait GitRepository: Send + Sync { "cherry-picking a branch range onto a new branch is not implemented for this backend", ))) } - -||||||| Common ancestor -======= - /// Creates a new branch `new_branch` pointing at `base`'s tip, checks it - /// out, and cherry-picks every commit reachable from `source` but not from - /// `base` (oldest first, merge commits skipped) onto it. - /// - /// Errors without touching anything if `new_branch` already exists or the - /// range `base..source` is empty. A cherry-pick conflict stops the sequence - /// and leaves Git's sequencer state in progress on `new_branch`, exactly - /// like a regular multi-commit cherry-pick. - fn cherry_pick_range_onto_new_branch( + /// Lists the commits `range..source` would cherry-pick (oldest first, + /// merge commits skipped) as a preview. Errors when `range` is not an + /// ancestor of `source`. + fn cherry_pick_range_commits( &self, - _base: &str, + _range: &str, _source: &str, - _new_branch: &str, - ) -> Result { + ) -> Result> { Err(Error::new(ErrorKind::Unsupported( - "cherry-picking a branch range onto a new branch is not implemented for this backend", + "listing a cherry-pick range is not implemented for this backend", ))) } ->>>>>>> Current commit: Add cherry-pick branch A onto B as new branch C from the action bar fn stash_create(&self, message: &str, include_untracked: bool) -> Result<()>; fn stash_list(&self) -> Result>; fn stash_list_cancellable(&self, cancellation: &CancellationToken) -> Result> { diff --git a/crates/gitcomet-git-gix/src/repo/history.rs b/crates/gitcomet-git-gix/src/repo/history.rs index cb0be37cd..b5b8d98a8 100644 --- a/crates/gitcomet-git-gix/src/repo/history.rs +++ b/crates/gitcomet-git-gix/src/repo/history.rs @@ -3,7 +3,7 @@ use crate::util::{ bytes_to_text_preserving_utf8, git_command_failed_error, run_git_capture, run_git_raw_output, run_git_with_output, validate_hex_commit_id, validate_ref_like_arg, }; -use gitcomet_core::domain::CommitId; +use gitcomet_core::domain::{CommitId, CommitRefSummary}; use gitcomet_core::error::{Error, ErrorKind}; use gitcomet_core::services::{ CommandOutput, InteractiveRebaseAction, InteractiveRebaseEntry, ResetMode, Result, @@ -1069,7 +1069,6 @@ impl GixRepo { self.run_planned_rebase(entries, "HEAD", &label) } -<<<<<<< New base: Support explicit commit ranges when cherry-picking onto a new branch (#17) /// Creates `new_branch` at `base`'s tip, checks it out, and cherry-picks /// every commit reachable from `source` but not from `range` (oldest /// first, merge commits skipped) onto it. Nothing is created when the @@ -1090,7 +1089,10 @@ impl GixRepo { // `range` must be an ancestor of `source` for `range..source` to be a // meaningful commit set; git's --is-ancestor exits 1 when it is not. let mut cmd = self.git_workdir_cmd(); - cmd.arg("merge-base").arg("--is-ancestor").arg(range).arg(source); + cmd.arg("merge-base") + .arg("--is-ancestor") + .arg(range) + .arg(source); let ancestor_label = format!("git merge-base --is-ancestor {range} {source}"); match run_git_raw_output(cmd, &ancestor_label) { Ok(output) if output.status.success() => {} @@ -1160,69 +1162,87 @@ impl GixRepo { self.run_cherry_pick_step_output(cmd, &label) } -||||||| Common ancestor -======= - /// Creates `new_branch` at `base`'s tip, checks it out, and cherry-picks - /// every commit reachable from `source` but not from `base` (oldest first, - /// merge commits skipped) onto it. Nothing is created when the range is - /// empty, and `create_branch_impl` already rejects an existing branch. - pub(super) fn cherry_pick_range_onto_new_branch_impl( + /// Lists `range..source` commits (oldest first, merge commits skipped) for + /// the cherry-pick preview. Rejects a `range` that is not an ancestor of + /// `source` so the preview never shows diverged history. + pub(super) fn cherry_pick_range_commits_impl( &self, - base: &str, + range: &str, source: &str, - new_branch: &str, - ) -> Result { - validate_ref_like_arg(base, "base branch name")?; - validate_ref_like_arg(source, "source branch name")?; - validate_ref_like_arg(new_branch, "new branch name")?; + ) -> Result> { + validate_ref_like_arg(range, "range reference")?; + validate_ref_like_arg(source, "source reference")?; - // Oldest-first, merge commits skipped: the same set `git cherry-pick - // base..source` would apply, enumerated explicitly so an empty range - // is rejected before any branch or checkout is created. let mut cmd = self.git_workdir_cmd(); - cmd.arg("rev-list") - .arg("--reverse") - .arg("--no-merges") - .arg(format!("{base}..{source}")); - let rev_list_label = format!("git rev-list --reverse --no-merges {base}..{source}"); - let output = run_git_raw_output(cmd, &rev_list_label).map_err(|e| { - Error::new(ErrorKind::Backend(format!( - "failed to list commits in {base}..{source}: {e}" - ))) - })?; - let shas: Vec = bytes_to_text_preserving_utf8(&output.stdout) - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - .map(str::to_string) - .collect(); - if shas.is_empty() { - return Err(Error::new(ErrorKind::Backend(format!( - "no commits to cherry-pick: {source} has no commits that are not already in {base}" - )))); + cmd.arg("merge-base") + .arg("--is-ancestor") + .arg(range) + .arg(source); + let ancestor_label = format!("git merge-base --is-ancestor {range} {source}"); + match run_git_raw_output(cmd, &ancestor_label) { + Ok(output) if output.status.success() => {} + Ok(output) if output.status.code() == Some(1) => { + return Err(Error::new(ErrorKind::Backend(format!( + "{range} is not an ancestor of {source}; the range {range}..{source} would \ + include unrelated history — pick a range reference that is an ancestor" + )))); + } + Ok(output) => { + return Err(Error::new(ErrorKind::Backend(format!( + "failed to check whether {range} is an ancestor of {source}: {}", + bytes_to_text_preserving_utf8(&output.stderr).trim() + )))); + } + Err(e) => { + return Err(Error::new(ErrorKind::Backend(format!( + "failed to check whether {range} is an ancestor of {source}: {e}" + )))); + } } - // Create the branch from `base` (errors if it already exists) and - // move onto it before the picks, mirroring create-branch-and-checkout. - self.create_branch_impl(new_branch, &CommitId(base.into()))?; - self.checkout_branch_impl(new_branch)?; - + let range_arg = format!("{range}..{source}"); let mut cmd = self.git_workdir_cmd(); - cmd.arg("cherry-pick") - .arg("--no-edit") - .arg("--allow-empty") - .arg("--"); - for sha in &shas { - cmd.arg(sha); + // NUL-framed sha + single-line subject records (`-z`), oldest first, + // merges skipped: the exact set the range cherry-pick applies. + cmd.args([ + "log", + "-z", + "--format=%H%x00%s", + "--reverse", + "--topo-order", + "--no-merges", + &range_arg, + ]); + let output = run_git_capture(cmd, &format!("git log {range_arg}"))?; + let mut fields: Vec<&str> = output.split('\0').collect(); + if fields.last() == Some(&"") { + fields.pop(); } - let label = format!("git cherry-pick {} commits onto {new_branch}", shas.len()); - // An already-applied commit stops the whole sequence with an "empty" - // error that only `--skip` moves past, and the UI exposes no skip - // control — advance automatically so the remaining picks land. - self.run_cherry_pick_step_output(cmd, &label) + if !fields.len().is_multiple_of(2) { + return Err(Error::new(ErrorKind::Backend( + "unexpected git log output while listing the cherry-pick range".to_string(), + ))); + } + fields + .chunks_exact(2) + .map(|record| { + let (sha, summary) = (record[0], record[1]); + let full_hex_id = (sha.len() == 40 || sha.len() == 64) + && sha.bytes().all(|b| b.is_ascii_hexdigit()); + if !full_hex_id { + return Err(Error::new(ErrorKind::Backend(format!( + "unexpected commit id {sha:?} in git log output while listing the \ + cherry-pick range" + )))); + } + Ok(CommitRefSummary { + id: CommitId(sha.into()), + summary: summary.into(), + }) + }) + .collect() } ->>>>>>> Current commit: Add cherry-pick branch A onto B as new branch C from the action bar pub(super) fn merge_commit_message_impl(&self) -> Result> { let repo = self._repo.to_thread_local(); if repo.state() != Some(gix::state::InProgress::Merge) { diff --git a/crates/gitcomet-git-gix/src/repo/mod.rs b/crates/gitcomet-git-gix/src/repo/mod.rs index 2f9f86361..31574d449 100644 --- a/crates/gitcomet-git-gix/src/repo/mod.rs +++ b/crates/gitcomet-git-gix/src/repo/mod.rs @@ -1,10 +1,11 @@ use crate::util::git_workdir_cmd_for as util_git_workdir_cmd_for; use gitcomet_core::conflict_session::ConflictSession; use gitcomet_core::domain::{ - Branch, Commit, CommitDetails, CommitFileChange, CommitId, Diff, DiffArea, DiffPreviewTextSide, - DiffTarget, FileDiffImage, FileDiffText, FileEntry, HistoryMode, LogCursor, LogPage, - RecentCommitMessage, RefMetadata, ReflogEntry, Remote, RemoteBranch, RemoteTag, RepoSpec, - RepoStatus, StashEntry, Submodule, SubmoduleDiffSummary, Tag, UpstreamDivergence, Worktree, + Branch, Commit, CommitDetails, CommitFileChange, CommitId, CommitRefSummary, Diff, DiffArea, + DiffPreviewTextSide, DiffTarget, FileDiffImage, FileDiffText, FileEntry, HistoryMode, + LogCursor, LogPage, RecentCommitMessage, RefMetadata, ReflogEntry, Remote, RemoteBranch, + RemoteTag, RepoSpec, RepoStatus, StashEntry, Submodule, SubmoduleDiffSummary, Tag, + UpstreamDivergence, Worktree, }; use gitcomet_core::error::{Error, ErrorKind}; use gitcomet_core::git_ops_trace::{self, GitOpTraceKind}; @@ -607,7 +608,6 @@ impl GitRepository for GixRepo { self.revert_impl(id) } -<<<<<<< New base: Support explicit commit ranges when cherry-picking onto a new branch (#17) fn cherry_pick_range_onto_new_branch( &self, base: &str, @@ -618,18 +618,14 @@ impl GitRepository for GixRepo { self.cherry_pick_range_onto_new_branch_impl(base, range, source, new_branch) } -||||||| Common ancestor -======= - fn cherry_pick_range_onto_new_branch( + fn cherry_pick_range_commits( &self, - base: &str, + range: &str, source: &str, - new_branch: &str, - ) -> Result { - self.cherry_pick_range_onto_new_branch_impl(base, source, new_branch) + ) -> Result> { + self.cherry_pick_range_commits_impl(range, source) } ->>>>>>> Current commit: Add cherry-pick branch A onto B as new branch C from the action bar fn stash_create(&self, message: &str, include_untracked: bool) -> Result<()> { self.stash_create_impl(message, include_untracked) } diff --git a/crates/gitcomet-git-gix/tests/cherry_pick_integration.rs b/crates/gitcomet-git-gix/tests/cherry_pick_integration.rs index 4e8a14919..4bd423ab0 100644 --- a/crates/gitcomet-git-gix/tests/cherry_pick_integration.rs +++ b/crates/gitcomet-git-gix/tests/cherry_pick_integration.rs @@ -1355,7 +1355,6 @@ fn abort_returns_active_cherry_pick_lock_error() { SequencerState::CherryPick ); } -<<<<<<< New base: Support explicit commit ranges when cherry-picking onto a new branch (#17) #[test] fn cherry_pick_range_onto_new_branch_creates_branch_and_applies_source_commits_in_order() { @@ -1383,14 +1382,8 @@ fn cherry_pick_range_onto_new_branch_creates_branch_and_applies_source_commits_i git_stdout(&repo, &["log", "--format=%s"]), "a two\na one\nd work\nbase" ); - assert_eq!( - fs::read_to_string(repo.join("a1.txt")).unwrap(), - "a1\n" - ); - assert_eq!( - fs::read_to_string(repo.join("a2.txt")).unwrap(), - "a2\n" - ); + assert_eq!(fs::read_to_string(repo.join("a1.txt")).unwrap(), "a1\n"); + assert_eq!(fs::read_to_string(repo.join("a2.txt")).unwrap(), "a2\n"); } #[test] @@ -1473,99 +1466,31 @@ fn cherry_pick_range_onto_new_branch_rejects_non_ancestor_range() { assert_eq!(git_stdout(&repo, &["branch", "--list", "branch_c"]), ""); assert_eq!(git_stdout(&repo, &["branch", "--show-current"]), "branch_a"); } -||||||| Common ancestor -======= #[test] -fn cherry_pick_range_onto_new_branch_creates_branch_and_applies_source_commits_in_order() { +fn cherry_pick_range_commits_lists_range_oldest_first_and_skips_merges() { let dir = tempfile::tempdir().expect("create tempdir"); let repo = dir.path().join("repo"); init_repo(&repo); let base = commit_file(&repo, "base.txt", "base\n", "base"); - run_git(&repo, &["checkout", "-b", "branch_b", &base]); - commit_file(&repo, "b.txt", "b\n", "b work"); - run_git(&repo, &["checkout", "-b", "branch_a", &base]); - commit_file(&repo, "a1.txt", "a1\n", "a one"); - commit_file(&repo, "a2.txt", "a2\n", "a two"); - // The caller sits on some other branch; the command must move to branch_c. - run_git(&repo, &["checkout", "branch_b"]); - - open_backend(&repo) - .cherry_pick_range_onto_new_branch("branch_b", "branch_a", "branch_c") - .expect("cherry-pick range onto new branch"); - - assert_eq!(git_stdout(&repo, &["branch", "--show-current"]), "branch_c"); - assert_eq!( - git_stdout(&repo, &["log", "--format=%s"]), - "a two\na one\nb work\nbase" - ); - assert_eq!( - fs::read_to_string(repo.join("a1.txt")).unwrap(), - "a1\n" - ); - assert_eq!( - fs::read_to_string(repo.join("a2.txt")).unwrap(), - "a2\n" - ); -} - -#[test] -fn cherry_pick_range_onto_new_branch_skips_merge_commits() { - let dir = tempfile::tempdir().expect("create tempdir"); - let repo = dir.path().join("repo"); - init_repo(&repo); - let base = commit_file(&repo, "base.txt", "base\n", "base"); - run_git(&repo, &["checkout", "-b", "branch_b", &base]); - commit_file(&repo, "b.txt", "b\n", "b work"); + run_git(&repo, &["checkout", "-b", "branch_d", &base]); + commit_file(&repo, "d.txt", "d\n", "d work"); run_git(&repo, &["checkout", "-b", "branch_a", &base]); commit_file(&repo, "a1.txt", "a1\n", "a one"); commit_file(&repo, "a2.txt", "a2\n", "a two"); - // main grows a commit and then merges branch_a in, producing a merge - // commit inside the branch_b..main range that must be skipped. run_git(&repo, &["checkout", "main"]); commit_file(&repo, "main.txt", "main\n", "main change"); run_git(&repo, &["merge", "--no-edit", "branch_a"]); - open_backend(&repo) - .cherry_pick_range_onto_new_branch("branch_b", "main", "branch_c") - .expect("cherry-pick range onto new branch"); - - assert_eq!(git_stdout(&repo, &["branch", "--show-current"]), "branch_c"); - assert_eq!( - git_stdout(&repo, &["log", "--format=%s"]), - "main change\na two\na one\nb work\nbase" - ); -} + let commits = open_backend(&repo) + .cherry_pick_range_commits(&base, "main") + .expect("list cherry-pick range"); + let subjects: Vec<&str> = commits.iter().map(|c| c.summary.as_ref()).collect(); + assert_eq!(subjects, ["main change", "a one", "a two"]); -#[test] -fn cherry_pick_range_onto_new_branch_rejects_existing_branch_and_empty_range() { - let dir = tempfile::tempdir().expect("create tempdir"); - let repo = dir.path().join("repo"); - init_repo(&repo); - let base = commit_file(&repo, "base.txt", "base\n", "base"); - run_git(&repo, &["checkout", "-b", "branch_b", &base]); - commit_file(&repo, "b.txt", "b\n", "b work"); - run_git(&repo, &["checkout", "-b", "branch_a", &base]); - commit_file(&repo, "a1.txt", "a1\n", "a one"); - run_git(&repo, &["checkout", "-b", "branch_c", "branch_b"]); - - // Branch C already exists: nothing may change. - let error = open_backend(&repo) - .cherry_pick_range_onto_new_branch("branch_b", "branch_a", "branch_c") - .expect_err("existing branch must be rejected"); - assert!(error.to_string().contains("already exists"), "{error}"); - assert_eq!(git_stdout(&repo, &["branch", "--show-current"]), "branch_c"); - - // Empty range (branch_a is fully contained in branch_b): nothing created. - run_git(&repo, &["checkout", "branch_b"]); + // A non-ancestor range is rejected instead of listing diverged history. let error = open_backend(&repo) - .cherry_pick_range_onto_new_branch("branch_b", "branch_b", "branch_d") - .expect_err("empty range must be rejected"); - assert!(error.to_string().contains("no commits"), "{error}"); - assert_eq!( - git_stdout(&repo, &["branch", "--list", "branch_d"]), - "" - ); - assert_eq!(git_stdout(&repo, &["branch", "--show-current"]), "branch_b"); + .cherry_pick_range_commits("branch_d", "branch_a") + .expect_err("non-ancestor range must be rejected"); + assert!(error.to_string().contains("ancestor"), "{error}"); } ->>>>>>> Current commit: Add cherry-pick branch A onto B as new branch C from the action bar diff --git a/crates/gitcomet-state/src/model.rs b/crates/gitcomet-state/src/model.rs index 33d4bf4fc..19af054c9 100644 --- a/crates/gitcomet-state/src/model.rs +++ b/crates/gitcomet-state/src/model.rs @@ -1129,6 +1129,17 @@ pub struct InteractiveCherryPickSetup { pub full_messages: Loadable<()>, } +/// The commits `range..source` would cherry-pick (oldest first, merges +/// skipped), previewed in the Cherry-pick dialog. `range`/`source` record the +/// pair the preview was computed for so a stale result for another pair can +/// be ignored. +#[derive(Clone, Debug)] +pub struct CherryPickRangePreview { + pub range: String, + pub source: String, + pub commits: Loadable>>, +} + #[derive(Clone, Debug)] pub struct RepoState { pub id: RepoId, @@ -1177,6 +1188,7 @@ pub struct RepoState { pub reflog: Loadable>, pub recent_commit_messages: Loadable>>, pub recent_commit_messages_rev: u64, + pub cherry_pick_range_preview: Option, pub rebase_in_progress: Loadable, pub sequencer_state: Loadable, pub merge_commit_message: Loadable>, @@ -1301,6 +1313,7 @@ impl RepoState { hover_commit_message: None, interactive_rebase_setup: None, interactive_cherry_pick_setup: None, + cherry_pick_range_preview: None, merge_message_rev: 0, worktrees: Loadable::NotLoaded, worktrees_rev: 0, diff --git a/crates/gitcomet-state/src/msg/effect.rs b/crates/gitcomet-state/src/msg/effect.rs index 78b03bc1d..21f9ad09d 100644 --- a/crates/gitcomet-state/src/msg/effect.rs +++ b/crates/gitcomet-state/src/msg/effect.rs @@ -99,6 +99,11 @@ pub enum Effect { limit: usize, request_rev: u64, }, + LoadCherryPickRangePreview { + repo_id: RepoId, + range: String, + source: String, + }, LoadFileHistory { repo_id: RepoId, path: PathBuf, @@ -271,7 +276,6 @@ pub enum Effect { mainline: Option, summary: String, }, -<<<<<<< New base: Support explicit commit ranges when cherry-picking onto a new branch (#17) CherryPickRangeOntoNewBranch { repo_id: RepoId, base: String, @@ -279,15 +283,6 @@ pub enum Effect { source: String, new_branch: String, }, -||||||| Common ancestor -======= - CherryPickRangeOntoNewBranch { - repo_id: RepoId, - base: String, - source: String, - new_branch: String, - }, ->>>>>>> Current commit: Add cherry-pick branch A onto B as new branch C from the action bar RevertCommit { repo_id: RepoId, commit_id: CommitId, diff --git a/crates/gitcomet-state/src/msg/message.rs b/crates/gitcomet-state/src/msg/message.rs index 389ba0d83..8c9799601 100644 --- a/crates/gitcomet-state/src/msg/message.rs +++ b/crates/gitcomet-state/src/msg/message.rs @@ -321,6 +321,13 @@ pub enum Msg { repo_id: RepoId, commit_id: CommitId, }, + /// Loads the `range..source` commit list shown as the Cherry-pick dialog + /// preview. + LoadCherryPickRangePreview { + repo_id: RepoId, + range: String, + source: String, + }, LoadFileHistory { repo_id: RepoId, path: PathBuf, @@ -505,7 +512,6 @@ pub enum Msg { mainline: Option, summary: String, }, -<<<<<<< New base: Support explicit commit ranges when cherry-picking onto a new branch (#17) /// Creates a new branch `new_branch` from `base`'s tip, checks it out, /// and cherry-picks every commit unique to `source` relative to `range` /// (oldest first, merge commits skipped; `range` must be an ancestor of @@ -517,18 +523,6 @@ pub enum Msg { source: String, new_branch: String, }, -||||||| Common ancestor -======= - /// Creates a new branch `new_branch` from `base`'s tip, checks it out, - /// and cherry-picks every commit unique to `source` (relative to `base`, - /// oldest first, merge commits skipped) onto it. - CherryPickRangeOntoNewBranch { - repo_id: RepoId, - base: String, - source: String, - new_branch: String, - }, ->>>>>>> Current commit: Add cherry-pick branch A onto B as new branch C from the action bar RevertCommit { repo_id: RepoId, commit_id: CommitId, @@ -1096,6 +1090,12 @@ pub enum InternalMsg { request_rev: u64, result: Result, Error>, }, + CherryPickRangePreviewLoaded { + repo_id: RepoId, + range: String, + source: String, + result: Result, Error>, + }, RebaseStateLoaded { repo_id: RepoId, result: Result, diff --git a/crates/gitcomet-state/src/msg/message_debug.rs b/crates/gitcomet-state/src/msg/message_debug.rs index 7bb0d9345..55ae7ca84 100644 --- a/crates/gitcomet-state/src/msg/message_debug.rs +++ b/crates/gitcomet-state/src/msg/message_debug.rs @@ -171,6 +171,18 @@ impl std::fmt::Debug for InternalMsg { .field("requested_count", &requested_ids.len()) .field("ok", &result.is_ok()) .finish(), + InternalMsg::CherryPickRangePreviewLoaded { + repo_id, + range, + source, + result, + } => f + .debug_struct("CherryPickRangePreviewLoaded") + .field("repo_id", repo_id) + .field("range", range) + .field("source", source) + .field("count", &result.as_ref().map_or(0, Vec::len)) + .finish(), InternalMsg::MergeCommitMessageLoaded { repo_id, result } => f .debug_struct("MergeCommitMessageLoaded") .field("repo_id", repo_id) diff --git a/crates/gitcomet-state/src/msg/repo_command_kind.rs b/crates/gitcomet-state/src/msg/repo_command_kind.rs index 42c0eb6b1..4f0136a39 100644 --- a/crates/gitcomet-state/src/msg/repo_command_kind.rs +++ b/crates/gitcomet-state/src/msg/repo_command_kind.rs @@ -83,7 +83,6 @@ pub enum RepoCommandKind { mainline: Option, summary: String, }, -<<<<<<< New base: Support explicit commit ranges when cherry-picking onto a new branch (#17) /// Creates a new branch `new_branch` from `base`, checks it out, and /// cherry-picks `range..source`'s commits (oldest first, merges skipped, /// `range` must be an ancestor of `source`) onto it. @@ -93,16 +92,6 @@ pub enum RepoCommandKind { source: String, new_branch: String, }, -||||||| Common ancestor -======= - /// Creates a new branch from `base`, checks it out, and cherry-picks - /// `source..base`'s commits (oldest first, merges skipped) onto it. - CherryPickRangeOntoNewBranch { - base: String, - source: String, - new_branch: String, - }, ->>>>>>> Current commit: Add cherry-pick branch A onto B as new branch C from the action bar MergeAbort, CreateTag { name: String, diff --git a/crates/gitcomet-state/src/store/effects.rs b/crates/gitcomet-state/src/store/effects.rs index 6ad76b6db..e27fb1592 100644 --- a/crates/gitcomet-state/src/store/effects.rs +++ b/crates/gitcomet-state/src/store/effects.rs @@ -357,6 +357,18 @@ fn send_unavailable_git_effect_result( result: Err(git_unavailable_error(runtime)), }, )), + Effect::LoadCherryPickRangePreview { + repo_id, + range, + source, + } => send(Msg::Internal( + crate::msg::InternalMsg::CherryPickRangePreviewLoaded { + repo_id, + range, + source, + result: Err(git_unavailable_error(runtime)), + }, + )), Effect::SaveWorktreeFile { repo_id, path, @@ -738,7 +750,6 @@ fn send_unavailable_git_effect_result( result: Err(git_unavailable_error(runtime)), }, )), -<<<<<<< New base: Support explicit commit ranges when cherry-picking onto a new branch (#17) Effect::CherryPickRangeOntoNewBranch { repo_id, base, @@ -757,25 +768,6 @@ fn send_unavailable_git_effect_result( result: Err(git_unavailable_error(runtime)), }, )), -||||||| Common ancestor -======= - Effect::CherryPickRangeOntoNewBranch { - repo_id, - base, - source, - new_branch, - } => send(Msg::Internal( - crate::msg::InternalMsg::RepoCommandFinished { - repo_id, - command: RepoCommandKind::CherryPickRangeOntoNewBranch { - base, - source, - new_branch, - }, - result: Err(git_unavailable_error(runtime)), - }, - )), ->>>>>>> Current commit: Add cherry-pick branch A onto B as new branch C from the action bar Effect::RevertCommit { repo_id, .. } => { send_repo_action_unavailable(repo_id, RepoActionKind::RevertCommit, runtime, &send) } @@ -1884,6 +1876,15 @@ pub(super) fn schedule_effect( request_rev, ); } + Effect::LoadCherryPickRangePreview { + repo_id, + range, + source, + } => { + repo_load::schedule_load_cherry_pick_range_preview( + executor, repos, msg_tx, repo_id, range, source, + ); + } Effect::LoadCommitDetails { repo_id, commit_id } => { if let Some((msg_tx, _)) = repo_load_context(thread_state, repo_task_tokens, msg_tx, repo_id) @@ -2158,7 +2159,6 @@ pub(super) fn schedule_effect( executor, repos, msg_tx, repo_id, commit_id, commit, mainline, summary, ); } -<<<<<<< New base: Support explicit commit ranges when cherry-picking onto a new branch (#17) Effect::CherryPickRangeOntoNewBranch { repo_id, base, @@ -2170,19 +2170,6 @@ pub(super) fn schedule_effect( executor, repos, msg_tx, repo_id, base, range, source, new_branch, ); } -||||||| Common ancestor -======= - Effect::CherryPickRangeOntoNewBranch { - repo_id, - base, - source, - new_branch, - } => { - repo_commands::schedule_cherry_pick_range_onto_new_branch( - executor, repos, msg_tx, repo_id, base, source, new_branch, - ); - } ->>>>>>> Current commit: Add cherry-pick branch A onto B as new branch C from the action bar Effect::RevertCommit { repo_id, commit_id } => { repo_actions::schedule_revert_commit(executor, repos, msg_tx, repo_id, commit_id); } diff --git a/crates/gitcomet-state/src/store/effects/repo_commands.rs b/crates/gitcomet-state/src/store/effects/repo_commands.rs index 1db38691c..1ae1aeb75 100644 --- a/crates/gitcomet-state/src/store/effects/repo_commands.rs +++ b/crates/gitcomet-state/src/store/effects/repo_commands.rs @@ -1068,7 +1068,6 @@ pub(super) fn schedule_interactive_cherry_pick( ); } -<<<<<<< New base: Support explicit commit ranges when cherry-picking onto a new branch (#17) pub(super) fn schedule_cherry_pick_range_onto_new_branch( executor: &TaskExecutor, repos: &RepoMap, @@ -1094,41 +1093,10 @@ pub(super) fn schedule_cherry_pick_range_onto_new_branch( source: command_source, new_branch: command_new_branch, }, - move |repo| { - repo.cherry_pick_range_onto_new_branch(&base, &range, &source, &new_branch) - }, - ); -} - -||||||| Common ancestor -======= -pub(super) fn schedule_cherry_pick_range_onto_new_branch( - executor: &TaskExecutor, - repos: &RepoMap, - msg_tx: StoreWorkerSender, - repo_id: RepoId, - base: String, - source: String, - new_branch: String, -) { - let command_base = base.clone(); - let command_source = source.clone(); - let command_new_branch = new_branch.clone(); - schedule_repo_command( - executor, - repos, - msg_tx, - repo_id, - RepoCommandKind::CherryPickRangeOntoNewBranch { - base: command_base, - source: command_source, - new_branch: command_new_branch, - }, - move |repo| repo.cherry_pick_range_onto_new_branch(&base, &source, &new_branch), + move |repo| repo.cherry_pick_range_onto_new_branch(&base, &range, &source, &new_branch), ); } ->>>>>>> Current commit: Add cherry-pick branch A onto B as new branch C from the action bar pub(super) fn schedule_cherry_pick_commit( executor: &TaskExecutor, repos: &RepoMap, diff --git a/crates/gitcomet-state/src/store/effects/repo_load.rs b/crates/gitcomet-state/src/store/effects/repo_load.rs index 64ec07d60..7582de33e 100644 --- a/crates/gitcomet-state/src/store/effects/repo_load.rs +++ b/crates/gitcomet-state/src/store/effects/repo_load.rs @@ -1788,6 +1788,27 @@ pub(super) fn schedule_load_recent_commit_messages( }); } +pub(super) fn schedule_load_cherry_pick_range_preview( + executor: &TaskExecutor, + repos: &RepoMap, + msg_tx: StoreWorkerSender, + repo_id: RepoId, + range: String, + source: String, +) { + spawn_with_repo(executor, repos, repo_id, msg_tx, move |repo, msg_tx| { + send_or_log( + &msg_tx, + Msg::Internal(crate::msg::InternalMsg::CherryPickRangePreviewLoaded { + repo_id, + range: range.clone(), + source: source.clone(), + result: repo.cherry_pick_range_commits(&range, &source), + }), + ); + }); +} + pub(super) fn schedule_load_diff( executor: &TaskExecutor, repos: &RepoMap, diff --git a/crates/gitcomet-state/src/store/reducer.rs b/crates/gitcomet-state/src/store/reducer.rs index dc67f4cd4..e982372dc 100644 --- a/crates/gitcomet-state/src/store/reducer.rs +++ b/crates/gitcomet-state/src/store/reducer.rs @@ -109,6 +109,7 @@ pub(crate) fn msg_requires_available_git(msg: &Msg) -> bool { | Msg::LoadReflog { .. } | Msg::LoadRecentCommitMessages { .. } | Msg::LoadHoverCommitMessage { .. } + | Msg::LoadCherryPickRangePreview { .. } | Msg::LoadFileHistory { .. } | Msg::LoadBlame { .. } | Msg::LoadWorktrees { .. } @@ -1028,6 +1029,11 @@ fn reduce_inner( Msg::LoadRecentCommitMessages { repo_id, limit } => { effects::load_recent_commit_messages(state, repo_id, limit) } + Msg::LoadCherryPickRangePreview { + repo_id, + range, + source, + } => effects::load_cherry_pick_range_preview(state, repo_id, range, source), Msg::LoadFileHistory { repo_id, path, @@ -1168,7 +1174,6 @@ fn reduce_inner( begin_head_changing_local_action(state, repo_id); actions_emit_effects::cherry_pick_commit(repo_id, commit_id, commit, mainline, summary) } -<<<<<<< New base: Support explicit commit ranges when cherry-picking onto a new branch (#17) Msg::CherryPickRangeOntoNewBranch { repo_id, base, @@ -1184,21 +1189,6 @@ fn reduce_inner( repo_id, base, range, source, new_branch, ) } -||||||| Common ancestor -======= - Msg::CherryPickRangeOntoNewBranch { - repo_id, - base, - source, - new_branch, - } => { - if let Some(repo_state) = state.repos.iter_mut().find(|r| r.id == repo_id) { - repo_state.set_detached_head_commit(None); - } - begin_head_changing_local_action(state, repo_id); - actions_emit_effects::cherry_pick_range_onto_new_branch(repo_id, base, source, new_branch) - } ->>>>>>> Current commit: Add cherry-pick branch A onto B as new branch C from the action bar Msg::RevertCommit { repo_id, commit_id } => { begin_head_changing_local_action(state, repo_id); actions_emit_effects::revert_commit(repo_id, commit_id) @@ -2175,6 +2165,12 @@ fn reduce_inner( request_rev, result, }) => effects::recent_commit_messages_loaded(state, repo_id, request_rev, result), + Msg::Internal(crate::msg::InternalMsg::CherryPickRangePreviewLoaded { + repo_id, + range, + source, + result, + }) => effects::cherry_pick_range_preview_loaded(state, repo_id, range, source, result), Msg::Internal(crate::msg::InternalMsg::DiffLoaded { repo_id, target, diff --git a/crates/gitcomet-state/src/store/reducer/actions_emit_effects.rs b/crates/gitcomet-state/src/store/reducer/actions_emit_effects.rs index 8092eeb15..cb6f17a2f 100644 --- a/crates/gitcomet-state/src/store/reducer/actions_emit_effects.rs +++ b/crates/gitcomet-state/src/store/reducer/actions_emit_effects.rs @@ -63,7 +63,6 @@ pub(super) fn cherry_pick_commit( }] } -<<<<<<< New base: Support explicit commit ranges when cherry-picking onto a new branch (#17) pub(super) fn cherry_pick_range_onto_new_branch( repo_id: RepoId, base: String, @@ -80,23 +79,6 @@ pub(super) fn cherry_pick_range_onto_new_branch( }] } -||||||| Common ancestor -======= -pub(super) fn cherry_pick_range_onto_new_branch( - repo_id: RepoId, - base: String, - source: String, - new_branch: String, -) -> Vec { - vec![Effect::CherryPickRangeOntoNewBranch { - repo_id, - base, - source, - new_branch, - }] -} - ->>>>>>> Current commit: Add cherry-pick branch A onto B as new branch C from the action bar pub(super) fn revert_commit( repo_id: RepoId, commit_id: gitcomet_core::domain::CommitId, @@ -1114,8 +1096,10 @@ pub(super) fn repo_command_finished( | RepoCommandKind::CherryPickRangeOntoNewBranch { .. } ) && result.is_ok(); // The command creates a branch and moves HEAD onto it. - let refresh_branches = matches!(&command, RepoCommandKind::CherryPickRangeOntoNewBranch { .. }) - && result.is_ok(); + let refresh_branches = matches!( + &command, + RepoCommandKind::CherryPickRangeOntoNewBranch { .. } + ) && result.is_ok(); let refresh_submodules = matches!( &command, RepoCommandKind::AddSubmodule { .. } @@ -1244,7 +1228,11 @@ pub(super) fn repo_command_finished( repo_state.set_worktrees(Loadable::Loading); extra_effects.push(Effect::LoadWorktrees { repo_id }); } - if refresh_branches && repo_state.loads_in_flight.request(RepoLoadsInFlight::BRANCHES) { + if refresh_branches + && repo_state + .loads_in_flight + .request(RepoLoadsInFlight::BRANCHES) + { repo_state.set_branches(Loadable::NotLoaded); extra_effects.push(Effect::LoadBranches { repo_id }); } diff --git a/crates/gitcomet-state/src/store/reducer/effects.rs b/crates/gitcomet-state/src/store/reducer/effects.rs index 5c02ccd05..82de6d76a 100644 --- a/crates/gitcomet-state/src/store/reducer/effects.rs +++ b/crates/gitcomet-state/src/store/reducer/effects.rs @@ -3,9 +3,9 @@ use super::util::{ push_notification, selected_diff_load_plan, }; use crate::model::{ - AppNotificationKind, AppState, CommitMultiSelection, ConflictFileLoadMode, DiagnosticKind, - ForeignDiffOrigin, Loadable, RangeSelection, RepoId, RepoLoadsInFlight, RepoState, - SidebarDataRequest, SidebarMode, + AppNotificationKind, AppState, CherryPickRangePreview, CommitMultiSelection, + ConflictFileLoadMode, DiagnosticKind, ForeignDiffOrigin, Loadable, RangeSelection, RepoId, + RepoLoadsInFlight, RepoState, SidebarDataRequest, SidebarMode, }; use crate::msg::{CommitSelectMode, ConflictAutosolveMode, Effect}; use gitcomet_core::conflict_session::{ @@ -13,9 +13,9 @@ use gitcomet_core::conflict_session::{ ConflictResolverStrategy, ConflictSession, reconstruct_conflict_marker_sides, }; use gitcomet_core::domain::{ - Branch, CommitDetails, CommitFileChange, CommitId, EMPTY_TREE_ID, FileEntry, FileSource, - FileStatusKind, LogPage, RecentCommitMessage, RefMetadata, ReflogEntry, Remote, RemoteBranch, - RemoteTag, RepoStatus, StashEntry, Submodule, Tag, UpstreamDivergence, Worktree, + Branch, CommitDetails, CommitFileChange, CommitId, CommitRefSummary, EMPTY_TREE_ID, FileEntry, + FileSource, FileStatusKind, LogPage, RecentCommitMessage, RefMetadata, ReflogEntry, Remote, + RemoteBranch, RemoteTag, RepoStatus, StashEntry, Submodule, Tag, UpstreamDivergence, Worktree, WorktreeDirtySummary, }; use gitcomet_core::error::Error; @@ -1546,6 +1546,73 @@ pub(super) fn recent_commit_messages_loaded( Vec::new() } +pub(super) fn load_cherry_pick_range_preview( + state: &mut AppState, + repo_id: RepoId, + range: String, + source: String, +) -> Vec { + let Some(repo_state) = state.repos.iter_mut().find(|r| r.id == repo_id) else { + return Vec::new(); + }; + if !matches!(repo_state.open, Loadable::Ready(())) { + return Vec::new(); + } + // Reuse an in-flight preview for the same pair instead of restarting it. + if repo_state + .cherry_pick_range_preview + .as_ref() + .is_some_and(|preview| { + preview.range == range + && preview.source == source + && matches!(preview.commits, Loadable::Loading) + }) + { + return Vec::new(); + } + repo_state.cherry_pick_range_preview = Some(CherryPickRangePreview { + range: range.clone(), + source: source.clone(), + commits: Loadable::Loading, + }); + vec![Effect::LoadCherryPickRangePreview { + repo_id, + range, + source, + }] +} + +pub(super) fn cherry_pick_range_preview_loaded( + state: &mut AppState, + repo_id: RepoId, + range: String, + source: String, + result: std::result::Result, Error>, +) -> Vec { + let Some(repo_state) = state.repos.iter_mut().find(|r| r.id == repo_id) else { + return Vec::new(); + }; + // Only apply the result if it still matches the requested pair: a later + // request for a different pair supersedes it. + if !repo_state + .cherry_pick_range_preview + .as_ref() + .is_some_and(|preview| preview.range == range && preview.source == source) + { + return Vec::new(); + } + let commits = match result { + Ok(commits) => Loadable::Ready(Arc::new(commits)), + Err(e) => Loadable::Error(e.to_string()), + }; + repo_state.cherry_pick_range_preview = Some(CherryPickRangePreview { + range, + source, + commits, + }); + Vec::new() +} + pub(super) fn load_file_history( state: &mut AppState, repo_id: RepoId, diff --git a/crates/gitcomet-state/src/store/reducer/util.rs b/crates/gitcomet-state/src/store/reducer/util.rs index cc1ee5461..a4f4c2982 100644 --- a/crates/gitcomet-state/src/store/reducer/util.rs +++ b/crates/gitcomet-state/src/store/reducer/util.rs @@ -1395,7 +1395,6 @@ fn summarize_command( }; format!("Cherry-pick {} commits: {state}", entries.len()) } -<<<<<<< New base: Support explicit commit ranges when cherry-picking onto a new branch (#17) RepoCommandKind::CherryPickRangeOntoNewBranch { source, base, @@ -1407,27 +1406,8 @@ fn summarize_command( } else { "Completed" }; - format!( - "Cherry-picked {source} onto {base} as {new_branch}: {state}" - ) - } -||||||| Common ancestor -======= - RepoCommandKind::CherryPickRangeOntoNewBranch { - source, - base, - new_branch, - } => { - let state = if sequencer_paused(output) { - "Paused at a conflict" - } else { - "Completed" - }; - format!( - "Cherry-picked {source} onto {base} as {new_branch}: {state}" - ) + format!("Cherry-picked {source} onto {base} as {new_branch}: {state}") } ->>>>>>> Current commit: Add cherry-pick branch A onto B as new branch C from the action bar RepoCommandKind::CherryPick { commit_id, commit, @@ -2619,7 +2599,6 @@ mod tests { "Current branch already has all the changes from the cherry-picked commit." ); -<<<<<<< New base: Support explicit commit ranges when cherry-picking onto a new branch (#17) let (_, range_summary) = summarize_command( &RepoCommandKind::CherryPickRangeOntoNewBranch { base: "main".into(), @@ -2636,24 +2615,6 @@ mod tests { "Cherry-picked feature onto main as feature-copy: Completed" ); -||||||| Common ancestor -======= - let (_, range_summary) = summarize_command( - &RepoCommandKind::CherryPickRangeOntoNewBranch { - base: "main".into(), - source: "feature".into(), - new_branch: "feature-copy".into(), - }, - &command_output("git cherry-pick 3 commits onto feature-copy", "", ""), - true, - None, - ); - assert_eq!( - range_summary, - "Cherry-picked feature onto main as feature-copy: Completed" - ); - ->>>>>>> Current commit: Add cherry-pick branch A onto B as new branch C from the action bar let (_, merge_abort_summary) = summarize_command( &RepoCommandKind::MergeAbort, &command_output("git merge --abort", "", ""), diff --git a/crates/gitcomet-state/src/store/tests/actions_emit_effects.rs b/crates/gitcomet-state/src/store/tests/actions_emit_effects.rs index 59e8e9b9d..bbb380a0a 100644 --- a/crates/gitcomet-state/src/store/tests/actions_emit_effects.rs +++ b/crates/gitcomet-state/src/store/tests/actions_emit_effects.rs @@ -2057,7 +2057,6 @@ fn additional_routing_messages_emit_effects_and_update_counters() { &mut repos, &id_alloc, &mut state, -<<<<<<< New base: Support explicit commit ranges when cherry-picking onto a new branch (#17) Msg::CherryPickRangeOntoNewBranch { repo_id, base: "main".to_string(), @@ -2081,30 +2080,6 @@ fn additional_routing_messages_emit_effects_and_update_counters() { &mut repos, &id_alloc, &mut state, -||||||| Common ancestor -======= - Msg::CherryPickRangeOntoNewBranch { - repo_id, - base: "main".to_string(), - source: "feature".to_string(), - new_branch: "feature-copy".to_string(), - }, - ); - assert!(matches!( - effects.as_slice(), - [Effect::CherryPickRangeOntoNewBranch { - repo_id: RepoId(1), - base, - source, - new_branch, - }] if base == "main" && source == "feature" && new_branch == "feature-copy" - )); - - let effects = reduce( - &mut repos, - &id_alloc, - &mut state, ->>>>>>> Current commit: Add cherry-pick branch A onto B as new branch C from the action bar Msg::CreateBranchAndCheckout { repo_id, name: "feature/new".to_string(), @@ -4560,3 +4535,110 @@ fn cherry_pick_setup_never_enables_rewording_after_partial_or_stale_message_load assert!(matches!(setup.full_messages, Loadable::Error(_))); assert_eq!(setup.entries[0].message, "subject"); } + +#[test] +fn cherry_pick_range_preview_loads_and_applies_matching_result() { + use gitcomet_core::domain::CommitRefSummary; + + let mut state = AppState::default(); + let repo_id = RepoId(1); + let mut repo = RepoState::new_opening( + repo_id, + RepoSpec { + workdir: PathBuf::from("/tmp/repo"), + }, + ); + repo.set_open(Loadable::Ready(())); + state.repos.push(repo); + + let reduce_msg = |state: &mut AppState, msg: Msg| { + reduce(&mut HashMap::default(), &AtomicU64::new(1), state, msg) + }; + + let _ = reduce_msg( + &mut state, + Msg::LoadCherryPickRangePreview { + repo_id, + range: "main".to_string(), + source: "feature".to_string(), + }, + ); + let preview = state.repos[0] + .cherry_pick_range_preview + .as_ref() + .expect("preview set"); + assert_eq!(preview.range, "main"); + assert_eq!(preview.source, "feature"); + assert!(matches!(preview.commits, Loadable::Loading)); + + // A matching result lands in state. + let _ = reduce_msg( + &mut state, + Msg::Internal(crate::msg::InternalMsg::CherryPickRangePreviewLoaded { + repo_id, + range: "main".to_string(), + source: "feature".to_string(), + result: Ok(vec![ + CommitRefSummary { + id: CommitId("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into()), + summary: "first".into(), + }, + CommitRefSummary { + id: CommitId("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".into()), + summary: "second".into(), + }, + ]), + }), + ); + let preview = state.repos[0] + .cherry_pick_range_preview + .as_ref() + .expect("preview set"); + let Loadable::Ready(commits) = &preview.commits else { + panic!("expected ready preview"); + }; + assert_eq!(commits.len(), 2); + assert_eq!(commits[1].summary.as_ref(), "second"); + + // A result for a different pair is ignored (stale request). + let _ = reduce_msg( + &mut state, + Msg::Internal(crate::msg::InternalMsg::CherryPickRangePreviewLoaded { + repo_id, + range: "old".to_string(), + source: "stale".to_string(), + result: Ok(Vec::new()), + }), + ); + let preview = state.repos[0] + .cherry_pick_range_preview + .as_ref() + .expect("preview set"); + assert!(matches!(preview.commits, Loadable::Ready(_))); + + // An error result is surfaced as Loadable::Error for the matching pair. + let _ = reduce_msg( + &mut state, + Msg::LoadCherryPickRangePreview { + repo_id, + range: "main".to_string(), + source: "other".to_string(), + }, + ); + let _ = reduce_msg( + &mut state, + Msg::Internal(crate::msg::InternalMsg::CherryPickRangePreviewLoaded { + repo_id, + range: "main".to_string(), + source: "other".to_string(), + result: Err(gitcomet_core::error::Error::new( + gitcomet_core::error::ErrorKind::Backend("not an ancestor".into()), + )), + }), + ); + let preview = state.repos[0] + .cherry_pick_range_preview + .as_ref() + .expect("preview set"); + assert!(matches!(preview.commits, Loadable::Error(ref e) if e.contains("ancestor"))); +} diff --git a/crates/gitcomet-ui-gpui/src/view/panels/action_bar.rs b/crates/gitcomet-ui-gpui/src/view/panels/action_bar.rs index e110557cd..c40e4a081 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/action_bar.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/action_bar.rs @@ -786,7 +786,6 @@ impl Render for ActionBarView { }) .gitcomet_tooltip(theme, "Create branch".into()); -<<<<<<< New base: Support explicit commit ranges when cherry-picking onto a new branch (#17) let cherry_pick_range_invoker: SharedString = "cherry_pick_range_btn".into(); let cherry_pick_range_active = self .active_context_menu_invoker @@ -818,35 +817,6 @@ impl Render for ActionBarView { "Cherry-pick ref A onto a new branch C created from D (range B..A)".into(), ); -||||||| Common ancestor -======= - let cherry_pick_range_invoker: SharedString = "cherry_pick_range_btn".into(); - let cherry_pick_range_active = self - .active_context_menu_invoker - .as_ref() - .is_some_and(|id| id.as_ref() == cherry_pick_range_invoker.as_ref()); - let cherry_pick_range = components::Button::new("cherry_pick_range", "") - .start_slot(icon("icons/copy.svg", icon_primary)) - .style(components::ButtonStyle::Subtle) - .selected(cherry_pick_range_active) - .selected_bg(menu_selected_bg) - .on_click_with_bounds(theme, cx, move |this, _e, bounds, window, cx| { - this.activate_context_menu_invoker(cherry_pick_range_invoker.clone(), cx); - if let Some(repo_id) = this.state.active_repo { - this.open_popover_for_bounds( - PopoverKind::CherryPickRangePrompt { repo_id }, - bounds, - window, - cx, - ); - } - }) - .gitcomet_tooltip( - theme, - "Cherry-pick branch A onto branch B as a new branch C".into(), - ); - ->>>>>>> Current commit: Add cherry-pick branch A onto B as new branch C from the action bar div() .w_full() .h(action_bar_height) diff --git a/crates/gitcomet-ui-gpui/src/view/panels/popover.rs b/crates/gitcomet-ui-gpui/src/view/panels/popover.rs index 39835b4f4..30c7b439c 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/popover.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/popover.rs @@ -303,7 +303,6 @@ pub(in super::super) struct PopoverHost { create_branch_input: Entity, create_branch_checkout_enabled: bool, create_branch_source_target: String, -<<<<<<< New base: Support explicit commit ranges when cherry-picking onto a new branch (#17) cherry_pick_source_search_input: Option>, cherry_pick_range_search_input: Option>, cherry_pick_base_search_input: Option>, @@ -314,16 +313,9 @@ pub(in super::super) struct PopoverHost { cherry_pick_source_target: String, cherry_pick_range_target: String, cherry_pick_base_target: String, -||||||| Common ancestor -======= - cherry_pick_source_search_input: Option>, - cherry_pick_base_search_input: Option>, - cherry_pick_name_input: Entity, - _cherry_pick_source_search_subscription: Option, - _cherry_pick_base_search_subscription: Option, - cherry_pick_source_target: String, - cherry_pick_base_target: String, ->>>>>>> Current commit: Add cherry-pick branch A onto B as new branch C from the action bar + /// The (range, source) pair whose commit preview was last requested, so + /// the dialog does not re-dispatch the load on every keystroke. + cherry_pick_preview_requested: Option<(String, String)>, worktree_ref_source_target: String, suppress_worktree_submit_after_ref_enter: bool, /// Set while a row menu floating over a picker runs one of its entries. The @@ -1560,7 +1552,12 @@ impl PopoverHost { &cherry_pick_name_input, window, cx, - |this| matches!(this.popover, Some(PopoverKind::CherryPickRangePrompt { .. })), + |this| { + matches!( + this.popover, + Some(PopoverKind::CherryPickRangePrompt { .. }) + ) + }, |this, window, cx| this.submit_cherry_pick_range(window, cx), )); prompt_input_subscriptions.push(Self::prompt_enter_subscription( @@ -1794,7 +1791,6 @@ impl PopoverHost { create_branch_input, create_branch_checkout_enabled: true, create_branch_source_target: String::new(), -<<<<<<< New base: Support explicit commit ranges when cherry-picking onto a new branch (#17) cherry_pick_source_search_input: None, cherry_pick_range_search_input: None, cherry_pick_base_search_input: None, @@ -1805,16 +1801,7 @@ impl PopoverHost { cherry_pick_source_target: String::new(), cherry_pick_range_target: String::new(), cherry_pick_base_target: String::new(), -||||||| Common ancestor -======= - cherry_pick_source_search_input: None, - cherry_pick_base_search_input: None, - cherry_pick_name_input, - _cherry_pick_source_search_subscription: None, - _cherry_pick_base_search_subscription: None, - cherry_pick_source_target: String::new(), - cherry_pick_base_target: String::new(), ->>>>>>> Current commit: Add cherry-pick branch A onto B as new branch C from the action bar + cherry_pick_preview_requested: None, worktree_ref_source_target: String::new(), suppress_worktree_submit_after_ref_enter: false, suppress_popover_close_after_action: false, @@ -2628,7 +2615,6 @@ impl PopoverHost { self.dismiss_inline_popover(window, cx); } -<<<<<<< New base: Support explicit commit ranges when cherry-picking onto a new branch (#17) fn ensure_cherry_pick_search_input( slot: &mut Option>, placeholder: &str, @@ -2662,7 +2648,10 @@ impl PopoverHost { window: &mut Window, cx: &mut gpui::Context, ) { - if !matches!(self.popover, Some(PopoverKind::CherryPickRangePrompt { .. })) { + if !matches!( + self.popover, + Some(PopoverKind::CherryPickRangePrompt { .. }) + ) { return; } self.cherry_pick_source_target = name; @@ -2690,7 +2679,10 @@ impl PopoverHost { window: &mut Window, cx: &mut gpui::Context, ) { - if !matches!(self.popover, Some(PopoverKind::CherryPickRangePrompt { .. })) { + if !matches!( + self.popover, + Some(PopoverKind::CherryPickRangePrompt { .. }) + ) { return; } self.cherry_pick_range_target = name; @@ -2718,7 +2710,10 @@ impl PopoverHost { window: &mut Window, cx: &mut gpui::Context, ) { - if !matches!(self.popover, Some(PopoverKind::CherryPickRangePrompt { .. })) { + if !matches!( + self.popover, + Some(PopoverKind::CherryPickRangePrompt { .. }) + ) { return; } self.cherry_pick_base_target = name; @@ -2741,7 +2736,10 @@ impl PopoverHost { } fn cherry_pick_can_submit(&self, cx: &mut gpui::Context) -> bool { - if !matches!(self.popover, Some(PopoverKind::CherryPickRangePrompt { .. })) { + if !matches!( + self.popover, + Some(PopoverKind::CherryPickRangePrompt { .. }) + ) { return false; } let source = self.cherry_pick_source_target.trim(); @@ -2780,129 +2778,44 @@ impl PopoverHost { self.dismiss_inline_popover(window, cx); } -||||||| Common ancestor -======= - fn ensure_cherry_pick_search_input( - slot: &mut Option>, - placeholder: &str, - window: &mut Window, - cx: &mut gpui::Context, - ) -> Entity { - if let Some(input) = slot { - return input.clone(); - } - let input = cx.new(|cx| { - components::TextInput::new( - components::TextInputOptions { - placeholder: placeholder.into(), - ..Default::default() - }, - window, - cx, - ) - }); - input.update(cx, |input, cx| { - input.set_chromeless(false, cx); - input.set_leading_icon(None, cx); - }); - *slot = Some(input.clone()); - input - } - - fn handle_cherry_pick_source_select( - &mut self, - name: String, - window: &mut Window, - cx: &mut gpui::Context, - ) { - if !matches!(self.popover, Some(PopoverKind::CherryPickRangePrompt { .. })) { - return; - } - self.cherry_pick_source_target = name; - if let Some(input) = &self.cherry_pick_source_search_input { - let theme = self.theme; - input.update(cx, |input, cx| { - input.clear_transient_key_presses(); - input.set_theme(theme, cx); - input.set_text(self.cherry_pick_source_target.clone(), cx); - cx.notify(); - }); - } - self.branch_picker_selected_index = None; - // Move on to the base picker. - if let Some(base) = &self.cherry_pick_base_search_input { - let focus = base.read_with(cx, |input, _| input.focus_handle()); - window.focus(&focus, cx); - } - cx.notify(); - } - - fn handle_cherry_pick_base_select( - &mut self, - name: String, - window: &mut Window, - cx: &mut gpui::Context, - ) { - if !matches!(self.popover, Some(PopoverKind::CherryPickRangePrompt { .. })) { - return; - } - self.cherry_pick_base_target = name; - if let Some(input) = &self.cherry_pick_base_search_input { - let theme = self.theme; - input.update(cx, |input, cx| { - input.clear_transient_key_presses(); - input.set_theme(theme, cx); - input.set_text(self.cherry_pick_base_target.clone(), cx); - cx.notify(); - }); - } - self.branch_picker_selected_index = None; - // Move on to the new branch name. - let focus = self - .cherry_pick_name_input - .read_with(cx, |input, _| input.focus_handle()); - window.focus(&focus, cx); - cx.notify(); - } - - fn cherry_pick_can_submit(&self, cx: &mut gpui::Context) -> bool { - if !matches!(self.popover, Some(PopoverKind::CherryPickRangePrompt { .. })) { - return false; - } - let source = self.cherry_pick_source_target.trim(); - let base = self.cherry_pick_base_target.trim(); - if source.is_empty() || base.is_empty() || source == base { - return false; - } - self.cherry_pick_name_input - .read_with(cx, |input, _| !input.text().trim().is_empty()) - } - - fn submit_cherry_pick_range(&mut self, window: &mut Window, cx: &mut gpui::Context) { - let Some(PopoverKind::CherryPickRangePrompt { repo_id }) = self.popover.clone() else { + /// Loads the `range..source` commit preview whenever the source/range + /// pair is complete and differs from the last requested (or already + /// loaded) pair. Called from the picker subscriptions and on popover open. + fn refresh_cherry_pick_range_preview(&mut self, _cx: &mut gpui::Context) { + let Some(PopoverKind::CherryPickRangePrompt { repo_id, .. }) = self.popover.clone() else { return; }; - if !self.cherry_pick_can_submit(cx) { + let source = self.cherry_pick_source_target.trim().to_string(); + let range = self.cherry_pick_range_target.trim().to_string(); + if source.is_empty() || range.is_empty() || source == range { return; } - let base = self.cherry_pick_base_target.trim().to_string(); - let source = self.cherry_pick_source_target.trim().to_string(); - let new_branch = self - .cherry_pick_name_input - .read_with(cx, |input, _| input.text().trim().to_string()); - if new_branch.is_empty() { + if self.cherry_pick_preview_requested.as_ref() == Some(&(range.clone(), source.clone())) { return; } - self.store.dispatch(Msg::CherryPickRangeOntoNewBranch { - repo_id, - base, - source, - new_branch, - }); - self.dismiss_inline_popover(window, cx); + self.cherry_pick_preview_requested = Some((range.clone(), source.clone())); + // A preview for this pair may already be loaded in state (e.g. the + // dialog was reopened with the same refs); avoid a pointless reload. + let already_loaded = self + .state + .repos + .iter() + .find(|r| r.id == repo_id) + .and_then(|r| r.cherry_pick_range_preview.as_ref()) + .is_some_and(|preview| { + preview.range == range + && preview.source == source + && matches!(preview.commits, Loadable::Ready(_)) + }); + if !already_loaded { + self.store.dispatch(Msg::LoadCherryPickRangePreview { + repo_id, + range, + source, + }); + } } ->>>>>>> Current commit: Add cherry-pick branch A onto B as new branch C from the action bar fn can_submit_rename_branch(&self, cx: &mut gpui::Context) -> bool { let Some(PopoverKind::RenameBranchPrompt { name, .. }) = &self.popover else { return false; @@ -3438,19 +3351,12 @@ impl PopoverHost { .read_with(cx, |i, _| i.focus_handle()); window.focus(&focus, cx); } -<<<<<<< New base: Add cherry-pick branch A onto B as new branch C from the action bar -<<<<<<< New base: Support explicit commit ranges when cherry-picking onto a new branch (#17) - PopoverKind::CherryPickRangePrompt { .. } => { -||||||| Common ancestor - PopoverKind::CherryPickRangePrompt { .. } => { -======= PopoverKind::CherryPickRangePrompt { prefill_source, prefill_range, prefill_base, .. } => { ->>>>>>> Current commit: Add Cherry-pick onto new branch action to the branch context menu with prefilled let theme = self.theme; // D defaults to the current branch: C usually starts from // where the user is standing. A context-menu open may @@ -3468,13 +3374,12 @@ impl PopoverHost { self.cherry_pick_source_target = source_prefill.clone(); self.cherry_pick_range_target = range_prefill.clone(); self.cherry_pick_base_target = base_prefill.clone(); - let source_input = - Self::ensure_cherry_pick_search_input( - &mut self.cherry_pick_source_search_input, - "branch", - window, - cx, - ); + let source_input = Self::ensure_cherry_pick_search_input( + &mut self.cherry_pick_source_search_input, + "branch", + window, + cx, + ); let range_input = Self::ensure_cherry_pick_search_input( &mut self.cherry_pick_range_search_input, "branch", @@ -3487,47 +3392,161 @@ impl PopoverHost { window, cx, ); + // Each ref row gets its own arrow-key/Enter subscription, + // scoped to its own input entity, so only one row's list + // reacts to a given keypress even though all three share + // the popover kind and the highlight index — the picker + // row that isn't focused renders as a plain text field + // instead of a list, so a stale shared index never shows. if self._cherry_pick_source_search_subscription.is_none() { - let input = source_input.clone(); - self._cherry_pick_source_search_subscription = Some(cx.observe( - &input, - |this, _input, cx| { - if matches!( - this.popover, - Some(PopoverKind::CherryPickRangePrompt { .. }) - ) { - cx.notify(); - } - }, - )); + self._cherry_pick_source_search_subscription = + Some(Self::picker_search_subscription( + &source_input, + window, + cx, + |this| { + matches!( + this.popover, + Some(PopoverKind::CherryPickRangePrompt { .. }) + ) + }, + |this| &mut this.branch_picker_selected_index, + |this, query, cx| { + // Keeps the range..source preview in step + // with every keystroke, not only Enter — + // this closure runs on each observed + // change to the input, typed or picked. + this.refresh_cherry_pick_range_preview(cx); + Some(branch_picker::ref_nav_targets( + this, + branch_picker::RefRowsSpec::source_ref(), + query, + )) + }, + |this, cx| this.close_popover(cx), + |this, sel, cx| { + let query = this + .cherry_pick_source_search_input + .as_ref() + .map(|input| input.read(cx).text().trim().to_string()) + .unwrap_or_default(); + let rows = branch_picker::ref_rows_cached( + this, + branch_picker::RefRowsSpec::source_ref(), + &query, + ); + this.scroll_picker_prompt_to_row( + &rows.items, + &rows.layout, + sel, + branch_picker::REF_PICKER_LIST_MAX_HEIGHT_PX, + cx, + ); + }, + |this, payload, query, window, cx| { + let name = payload.unwrap_or(query); + if !name.is_empty() { + this.handle_cherry_pick_source_select(name, window, cx); + } + }, + )); } if self._cherry_pick_range_search_subscription.is_none() { - let input = range_input.clone(); - self._cherry_pick_range_search_subscription = Some(cx.observe( - &input, - |this, _input, cx| { - if matches!( - this.popover, - Some(PopoverKind::CherryPickRangePrompt { .. }) - ) { - cx.notify(); - } - }, - )); + self._cherry_pick_range_search_subscription = + Some(Self::picker_search_subscription( + &range_input, + window, + cx, + |this| { + matches!( + this.popover, + Some(PopoverKind::CherryPickRangePrompt { .. }) + ) + }, + |this| &mut this.branch_picker_selected_index, + |this, query, cx| { + this.refresh_cherry_pick_range_preview(cx); + Some(branch_picker::ref_nav_targets( + this, + branch_picker::RefRowsSpec::source_ref(), + query, + )) + }, + |this, cx| this.close_popover(cx), + |this, sel, cx| { + let query = this + .cherry_pick_range_search_input + .as_ref() + .map(|input| input.read(cx).text().trim().to_string()) + .unwrap_or_default(); + let rows = branch_picker::ref_rows_cached( + this, + branch_picker::RefRowsSpec::source_ref(), + &query, + ); + this.scroll_picker_prompt_to_row( + &rows.items, + &rows.layout, + sel, + branch_picker::REF_PICKER_LIST_MAX_HEIGHT_PX, + cx, + ); + }, + |this, payload, query, window, cx| { + let name = payload.unwrap_or(query); + if !name.is_empty() { + this.handle_cherry_pick_range_select(name, window, cx); + } + }, + )); } if self._cherry_pick_base_search_subscription.is_none() { - let input = base_input.clone(); - self._cherry_pick_base_search_subscription = Some(cx.observe( - &input, - |this, _input, cx| { - if matches!( - this.popover, - Some(PopoverKind::CherryPickRangePrompt { .. }) - ) { - cx.notify(); - } - }, - )); + self._cherry_pick_base_search_subscription = + Some(Self::picker_search_subscription( + &base_input, + window, + cx, + |this| { + matches!( + this.popover, + Some(PopoverKind::CherryPickRangePrompt { .. }) + ) + }, + |this| &mut this.branch_picker_selected_index, + |this, query, _cx| { + Some(branch_picker::ref_nav_targets( + this, + branch_picker::RefRowsSpec::source_ref(), + query, + )) + }, + |this, cx| this.close_popover(cx), + |this, sel, cx| { + let query = this + .cherry_pick_base_search_input + .as_ref() + .map(|input| input.read(cx).text().trim().to_string()) + .unwrap_or_default(); + let rows = branch_picker::ref_rows_cached( + this, + branch_picker::RefRowsSpec::source_ref(), + &query, + ); + this.scroll_picker_prompt_to_row( + &rows.items, + &rows.layout, + sel, + branch_picker::REF_PICKER_LIST_MAX_HEIGHT_PX, + cx, + ); + }, + |this, payload, query, window, cx| { + let name = payload.unwrap_or(query); + if !name.is_empty() { + this.handle_cherry_pick_base_select(name, window, cx); + } + }, + )); } for (input, text) in [ (&source_input, source_prefill.clone()), @@ -3549,72 +3568,8 @@ impl PopoverHost { }); let focus = source_input.read_with(cx, |i, _| i.focus_handle()); window.focus(&focus, cx); + self.refresh_cherry_pick_range_preview(cx); } -||||||| Common ancestor -======= - PopoverKind::CherryPickRangePrompt { .. } => { - let theme = self.theme; - self.cherry_pick_source_target = String::new(); - self.cherry_pick_base_target = String::new(); - let source_input = - Self::ensure_cherry_pick_search_input( - &mut self.cherry_pick_source_search_input, - "branch", - window, - cx, - ); - let base_input = Self::ensure_cherry_pick_search_input( - &mut self.cherry_pick_base_search_input, - "branch", - window, - cx, - ); - if self._cherry_pick_source_search_subscription.is_none() { - let input = source_input.clone(); - self._cherry_pick_source_search_subscription = Some(cx.observe( - &input, - |this, _input, cx| { - if matches!( - this.popover, - Some(PopoverKind::CherryPickRangePrompt { .. }) - ) { - cx.notify(); - } - }, - )); - } - if self._cherry_pick_base_search_subscription.is_none() { - let input = base_input.clone(); - self._cherry_pick_base_search_subscription = Some(cx.observe( - &input, - |this, _input, cx| { - if matches!( - this.popover, - Some(PopoverKind::CherryPickRangePrompt { .. }) - ) { - cx.notify(); - } - }, - )); - } - for input in [&source_input, &base_input] { - input.update(cx, |input, cx| { - input.clear_transient_key_presses(); - input.set_theme(theme, cx); - input.set_text("", cx); - cx.notify(); - }); - } - self.cherry_pick_name_input.update(cx, |input, cx| { - input.clear_transient_key_presses(); - input.set_theme(theme, cx); - input.set_text("", cx); - cx.notify(); - }); - let focus = source_input.read_with(cx, |i, _| i.focus_handle()); - window.focus(&focus, cx); - } ->>>>>>> Current commit: Add cherry-pick branch A onto B as new branch C from the action bar PopoverKind::RenameBranchPrompt { name, .. } => { let theme = self.theme; self.create_branch_input.update(cx, |input, cx| { diff --git a/crates/gitcomet-ui-gpui/src/view/panels/popover/cherry_pick_range_prompt.rs b/crates/gitcomet-ui-gpui/src/view/panels/popover/cherry_pick_range_prompt.rs index d77db75dc..47450b6c2 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/popover/cherry_pick_range_prompt.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/popover/cherry_pick_range_prompt.rs @@ -1,16 +1,24 @@ -<<<<<<< New base: Support explicit commit ranges when cherry-picking onto a new branch (#17) use super::*; +use gpui::StatefulInteractiveElement; /// One picker row for the Cherry-pick prompt: a chromeless search input that /// expands into a branch/ref picker while focused, exactly like the create -/// branch prompt's source row. +/// branch prompt's source row (see `create_branch_from_ref_prompt`). Rows +/// share the picker's own row-part matcher and `rows_cache`-backed layout +/// rather than a self-contained picker component — that component was +/// retired along with the plain-string `match_branches` matcher it used. fn picker_row( this: &mut PopoverHost, theme: AppTheme, label: &'static str, input: &Entity, - on_select: impl Fn(&mut PopoverHost, String, &ClickEvent, &mut Window, &mut gpui::Context) - + 'static, + on_select: impl Fn( + &mut PopoverHost, + String, + &ClickEvent, + &mut Window, + &mut gpui::Context, + ) + 'static, window: &Window, cx: &mut gpui::Context, ) -> gpui::Div { @@ -25,7 +33,10 @@ fn picker_row( }); if is_focused { - let refs = this.active_branch_ref_picker_items(true, true); + let query = input.read(cx).text().trim().to_string(); + let built = + branch_picker::ref_rows_cached(this, branch_picker::RefRowsSpec::source_ref(), &query); + let names = std::rc::Rc::clone(&built.payloads); div() .flex() .flex_col() @@ -34,22 +45,33 @@ fn picker_row( .px_2() .py_1() .text_sm() - .text_color(theme.colors.text_muted) + .text_color(theme.colors.foreground.secondary) .child(label), ) .child( div().px_2().pb_1().w_full().min_w(px(0.0)).child( - components::BranchRefPicker::new( + branch_picker::ref_picker_prompt( input.clone(), this.picker_prompt_scroll.clone(), - refs, + &built, + cx, ) .tooltip_host(this.tooltip_host.clone()) .empty_text("No matches") .max_height(scaled_px(240.0)) .selected_index(this.branch_picker_selected_index) .select_on_mouse_down() - .render(theme, ui_scale_percent, cx, on_select), + .render( + theme, + ui_scale_percent, + cx, + move |this, ix, e, window, cx| { + let Some(name) = names.get(ix).cloned() else { + return; + }; + on_select(this, name, e, window, cx); + }, + ), ), ) } else { @@ -61,13 +83,161 @@ fn picker_row( .px_2() .py_1() .text_sm() - .text_color(theme.colors.text_muted) + .text_color(theme.colors.foreground.secondary) .child(label), ) - .child(div().px_2().pb_1().w_full().min_w(px(0.0)).child(input.clone())) + .child( + div() + .px_2() + .pb_1() + .w_full() + .min_w(px(0.0)) + .child(input.clone()), + ) } } +/// The `range..source` commit preview: count + subject list once the pair is +/// chosen, a loading row while it loads, or the backend's error (e.g. range +/// not an ancestor of source). +fn preview_section( + this: &mut PopoverHost, + theme: AppTheme, + repo_id: RepoId, + scaled_px: impl Fn(f32) -> gpui::Pixels + Copy, + cx: &mut gpui::Context, +) -> gpui::Div { + let source = this.cherry_pick_source_target.trim().to_string(); + let range = this.cherry_pick_range_target.trim().to_string(); + let incomplete = source.is_empty() || range.is_empty() || source == range; + + let preview = this + .state + .repos + .iter() + .find(|r| r.id == repo_id) + .and_then(|r| r.cherry_pick_range_preview.as_ref()) + .filter(|p| p.range == range && p.source == source); + + let body = if incomplete { + div() + .px_2() + .py_1() + .text_xs() + .text_color(theme.colors.foreground.secondary) + .child("Pick a source and a range to preview the commits to cherry-pick.") + } else { + match preview.map(|p| &p.commits) { + Some(gitcomet_state::model::Loadable::Ready(commits)) if !commits.is_empty() => { + let shown = commits.iter().take(8).collect::>(); + let more = commits.len().saturating_sub(shown.len()); + let mut rows = div().flex().flex_col().gap(scaled_px(2.0)).py_1(); + for commit in &shown { + let short = commit.id.as_ref().get(..7).unwrap_or(commit.id.as_ref()); + let commit_id = commit.id.clone(); + let row_debug_id = + format!("cherry_pick_range_preview_row_{}", commit_id.as_ref()); + rows = rows.child( + div() + .id(SharedString::from(row_debug_id.clone())) + .debug_selector(move || row_debug_id.clone()) + .flex() + .items_center() + .gap(scaled_px(6.0)) + .px_2() + .rounded_md() + .hover(move |style| style.bg(theme.hover_overlay())) + .cursor_pointer() + .on_click(cx.listener(move |this, _e: &gpui::ClickEvent, _w, cx| { + // Open the commit in the history/details + // panes, like a reflog entry click. + this.store.dispatch(Msg::SelectCommit { + repo_id, + commit_id: commit_id.clone(), + }); + this.close_popover(cx); + })) + .child( + div() + .flex_none() + .text_xs() + .font_family("ui-monospace") + .text_color(theme.colors.foreground.secondary) + .child(short.to_string()), + ) + .child( + div() + .flex_1() + .min_w(px(0.0)) + .text_xs() + .whitespace_nowrap() + .line_clamp(1) + .text_color(theme.colors.foreground.primary) + .child(commit.summary.to_string()), + ), + ); + } + div() + .flex() + .flex_col() + .child( + div() + .px_2() + .py_1() + .text_xs() + .text_color(theme.colors.foreground.secondary) + .child(if more > 0 { + format!( + "{} commits will be cherry-picked (showing first {} — click one to open it)", + commits.len(), + shown.len() + ) + } else { + format!( + "{} commits will be cherry-picked — click one to open it", + commits.len() + ) + }), + ) + .child( + div() + .id("cherry_pick_range_preview_scroll") + .max_h(scaled_px(160.0)) + .overflow_y_scroll() + .child(rows), + ) + } + Some(gitcomet_state::model::Loadable::Ready(_)) => div() + .px_2() + .py_1() + .text_xs() + .text_color(theme.colors.foreground.secondary) + .child("No commits to cherry-pick in this range."), + Some(gitcomet_state::model::Loadable::Error(error)) => div() + .px_2() + .py_1() + .text_xs() + .text_color(theme.colors.status.warning.foreground) + .child(error.clone()), + Some(gitcomet_state::model::Loadable::NotLoaded) + | Some(gitcomet_state::model::Loadable::Loading) + | None => div() + .px_2() + .py_1() + .text_xs() + .text_color(theme.colors.foreground.secondary) + .child("Loading preview…"), + } + }; + + div() + .flex() + .flex_col() + .w_full() + .child(div().border_t_1().border_color(theme.colors.stroke.default)) + .child(body) +} + pub(super) fn panel( this: &mut PopoverHost, _repo_id: RepoId, @@ -99,13 +269,13 @@ pub(super) fn panel( .flex_col() .w(scaled_px(540.0)) .child(popover_title("Cherry-pick branch")) - .child(div().border_t_1().border_color(theme.colors.border)) + .child(div().border_t_1().border_color(theme.colors.stroke.default)) .child( div() .px_2() .py_1() .text_sm() - .text_color(theme.colors.text_muted) + .text_color(theme.colors.foreground.secondary) .child( "Creates a new branch C from D, checks it out, and cherry-picks every commit unique to A relative to B (B..A, oldest first, merge commits skipped). B must be an ancestor of A.", ), @@ -143,6 +313,7 @@ pub(super) fn panel( window, cx, )) + .child(preview_section(this, theme, _repo_id, scaled_px, cx)) .child(input_label(theme, "New branch name (C)")) .child( div() @@ -158,189 +329,11 @@ pub(super) fn panel( .px_2() .pb_1() .text_sm() - .text_color(theme.colors.warning) + .text_color(theme.colors.status.warning.foreground) .child("Source and range are the same — there is nothing to cherry-pick."), ) }) - .child(div().border_t_1().border_color(theme.colors.border)) - .child( - div() - .px_2() - .py_1() - .flex() - .items_center() - .justify_between() - .child( - cancel_button( - "cherry_pick_range_cancel", - "cherry_pick_range_cancel_hint", - theme, - ) - .on_click(theme, cx, |this, _e, window, cx| { - this.dismiss_prompt_popover(window, cx); - }), - ) - .child( - components::Button::new("cherry_pick_range_go", "Create & cherry-pick") - .style(components::ButtonStyle::Filled) - .disabled(!can_submit) - .on_click(theme, cx, |this, _e, window, cx| { - this.submit_cherry_pick_range(window, cx); - }), - ), - ) -} -||||||| -======= -use super::*; - -/// One picker row for the Cherry-pick prompt: a chromeless search input that -/// expands into a branch/ref picker while focused, exactly like the create -/// branch prompt's source row. -fn picker_row( - this: &mut PopoverHost, - theme: AppTheme, - label: &'static str, - input: &Entity, - on_select: impl Fn(&mut PopoverHost, String, &ClickEvent, &mut Window, &mut gpui::Context) - + 'static, - window: &Window, - cx: &mut gpui::Context, -) -> gpui::Div { - let ui_scale_percent = super::popover_ui_scale_percent(cx); - let scaled_px = |value: f32| super::popover_scaled_px_from_percent(value, ui_scale_percent); - let is_focused = input - .read_with(cx, |input, _| input.focus_handle()) - .is_focused(window); - input.update(cx, |input, cx| { - input.set_chromeless(is_focused, cx); - input.set_leading_icon(is_focused.then_some("icons/git_branch.svg"), cx); - }); - - if is_focused { - let refs = this.active_branch_ref_picker_items(true, true); - div() - .flex() - .flex_col() - .child( - div() - .px_2() - .py_1() - .text_sm() - .text_color(theme.colors.text_muted) - .child(label), - ) - .child( - div().px_2().pb_1().w_full().min_w(px(0.0)).child( - components::BranchRefPicker::new( - input.clone(), - this.picker_prompt_scroll.clone(), - refs, - ) - .tooltip_host(this.tooltip_host.clone()) - .empty_text("No matches") - .max_height(scaled_px(240.0)) - .selected_index(this.branch_picker_selected_index) - .select_on_mouse_down() - .render(theme, ui_scale_percent, cx, on_select), - ), - ) - } else { - div() - .flex() - .flex_col() - .child( - div() - .px_2() - .py_1() - .text_sm() - .text_color(theme.colors.text_muted) - .child(label), - ) - .child(div().px_2().pb_1().w_full().min_w(px(0.0)).child(input.clone())) - } -} - -pub(super) fn panel( - this: &mut PopoverHost, - _repo_id: RepoId, - window: &Window, - cx: &mut gpui::Context, -) -> gpui::Div { - let theme = this.theme; - let scaled_px = super::popover_scaled_px_fn(cx); - let can_submit = this.cherry_pick_can_submit(cx); - let source = this.cherry_pick_source_target.trim().to_string(); - let base = this.cherry_pick_base_target.trim().to_string(); - let same_branch_hint = !source.is_empty() && source == base; - - let source_input = this - .cherry_pick_source_search_input - .clone() - .expect("cherry_pick_source_search_input must be initialized"); - let base_input = this - .cherry_pick_base_search_input - .clone() - .expect("cherry_pick_base_search_input must be initialized"); - - div() - .flex() - .flex_col() - .w(scaled_px(540.0)) - .child(popover_title("Cherry-pick branch")) - .child(div().border_t_1().border_color(theme.colors.border)) - .child( - div() - .px_2() - .py_1() - .text_sm() - .text_color(theme.colors.text_muted) - .child( - "Creates a new branch C from B, checks it out, and cherry-picks every commit unique to A (oldest first, merge commits skipped).", - ), - ) - .child(picker_row( - this, - theme, - "Source branch (A)", - &source_input, - |this, name, _e, window, cx| { - this.handle_cherry_pick_source_select(name, window, cx); - }, - window, - cx, - )) - .child(picker_row( - this, - theme, - "Base branch (B)", - &base_input, - |this, name, _e, window, cx| { - this.handle_cherry_pick_base_select(name, window, cx); - }, - window, - cx, - )) - .child(input_label(theme, "New branch name (C)")) - .child( - div() - .px_2() - .pb_1() - .w_full() - .min_w(px(0.0)) - .child(this.cherry_pick_name_input.clone()), - ) - .when(same_branch_hint, |this| { - this.child( - div() - .px_2() - .pb_1() - .text_sm() - .text_color(theme.colors.warning) - .child("Source and base are the same — there is nothing to cherry-pick."), - ) - }) - .child(div().border_t_1().border_color(theme.colors.border)) + .child(div().border_t_1().border_color(theme.colors.stroke.default)) .child( div() .px_2() @@ -368,4 +361,3 @@ pub(super) fn panel( ), ) } ->>>>>>> Current commit: Add cherry-pick branch A onto B as new branch C from the action bar diff --git a/crates/gitcomet-ui-gpui/src/view/panels/popover/fingerprint.rs b/crates/gitcomet-ui-gpui/src/view/panels/popover/fingerprint.rs index e4864cd47..c5e508546 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/popover/fingerprint.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/popover/fingerprint.rs @@ -211,7 +211,6 @@ fn hash_repo_for_popover(repo: &RepoState, popover: &PopoverKind, has match popover { PopoverKind::BranchPicker { .. } | PopoverKind::CreateBranchFromRefPrompt { .. } - | PopoverKind::CherryPickRangePrompt { .. } | PopoverKind::RenameBranchPrompt { .. } | PopoverKind::BranchMenu { .. } | PopoverKind::BranchSectionMenu { .. } @@ -240,6 +239,25 @@ fn hash_repo_for_popover(repo: &RepoState, popover: &PopoverKind, has repo.file_browser.file_browser_rev.hash(hasher); } + // The dialog re-renders when the cherry-pick range preview loads. + PopoverKind::CherryPickRangePrompt { .. } => { + repo.head_branch_rev.hash(hasher); + repo.branches_rev.hash(hasher); + repo.remote_branches_rev.hash(hasher); + repo.tags_rev.hash(hasher); + match &repo.cherry_pick_range_preview { + Some(preview) => { + preview.range.hash(hasher); + preview.source.hash(hasher); + view_fingerprint::hash_loadable_kind(&preview.commits, hasher); + if let Loadable::Ready(commits) = &preview.commits { + commits.len().hash(hasher); + } + } + None => 0u8.hash(hasher), + } + } + PopoverKind::Repo { kind: RepoPopoverKind::Remote(_), .. diff --git a/crates/gitcomet-ui-gpui/src/view/panels/popover/search_inputs.rs b/crates/gitcomet-ui-gpui/src/view/panels/popover/search_inputs.rs index 2ffc3b659..f44a3db63 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/popover/search_inputs.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/popover/search_inputs.rs @@ -51,7 +51,7 @@ impl PopoverHost { /// /// `viewport_px` must be the same height the panel gave the picker as its /// `max_height`, since that is the viewport the window was built for. - fn scroll_picker_prompt_to_row( + pub(super) fn scroll_picker_prompt_to_row( &self, items: &[components::PickerPromptItem], layout: &components::PickerPromptLayout, @@ -103,7 +103,7 @@ impl PopoverHost { /// selected index and the Enter target can't drift apart. `on_enter` /// receives the selected payload (if any) plus the raw query. #[allow(clippy::too_many_arguments)] - fn picker_search_subscription( + pub(super) fn picker_search_subscription( input: &Entity, window: &mut Window, cx: &mut gpui::Context, diff --git a/crates/gitcomet-ui-gpui/src/view/panels/popover/tests/refs.rs b/crates/gitcomet-ui-gpui/src/view/panels/popover/tests/refs.rs index 3da1630cc..71ba97880 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/popover/tests/refs.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/popover/tests/refs.rs @@ -1894,3 +1894,121 @@ fn local_branch_menu_cherry_pick_prefills_source_range_and_base(cx: &mut gpui::T } }); } + +#[gpui::test] +fn cherry_pick_range_preview_row_click_opens_commit_and_closes_popover( + cx: &mut gpui::TestAppContext, +) { + const FIRST_SHA: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const SECOND_SHA: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + let (store, events) = AppStore::new(Arc::new(TestBackend)); + let (view, cx) = + cx.add_window_view(|window, cx| GitCometView::new(store.clone(), events, None, window, cx)); + + let repo_id = RepoId(241); + let workdir = std::env::temp_dir().join(format!( + "gitcomet_ui_test_{}_cherry_pick_preview_click", + std::process::id() + )); + + let mut repo = RepoState::new_opening( + repo_id, + gitcomet_core::domain::RepoSpec { + workdir: workdir.clone(), + }, + ); + repo.head_branch = Loadable::Ready("main".to_string()); + repo.cherry_pick_range_preview = Some(gitcomet_state::model::CherryPickRangePreview { + range: "main".to_string(), + source: "feature".to_string(), + commits: Loadable::Ready(Arc::new(vec![ + gitcomet_core::domain::CommitRefSummary { + id: CommitId(FIRST_SHA.into()), + summary: "first commit".into(), + }, + gitcomet_core::domain::CommitRefSummary { + id: CommitId(SECOND_SHA.into()), + summary: "second commit".into(), + }, + ])), + }); + let state = Arc::new(AppState { + repos: vec![repo], + active_repo: Some(repo_id), + ..Default::default() + }); + store.replace_snapshot_for_test(Arc::clone(&state)); + + cx.update(|window, app| { + // Prompt popovers create TextInput entities; the test key bindings + // must be installed before those inputs are constructed. + crate::app::bind_text_input_keys_for_test(app); + let _ = window.draw(app); + }); + cx.update(|_window, app| { + view.update(app, |this, cx| { + this.state = Arc::clone(&state); + this._ui_model + .update(cx, |model, cx| model.set_state(state, cx)); + cx.notify(); + }); + }); + + cx.update(|window, app| { + view.update(app, |this, cx| { + this.popover_host.update(cx, |host, cx| { + host.open_popover_at( + PopoverKind::CherryPickRangePrompt { + repo_id, + prefill_source: Some("feature".to_string()), + prefill_range: Some("main".to_string()), + prefill_base: Some("main".to_string()), + }, + gpui::point(gpui::px(120.0), gpui::px(72.0)), + window, + cx, + ); + }); + }); + }); + cx.update(|window, app| { + let _ = window.draw(app); + }); + cx.update(|window, app| { + let _ = window.draw(app); + }); + + // The preview rows render a clickable element per commit; clicking one + // opens the commit and closes the dialog. + let _ = cx + .debug_bounds("cherry_pick_range_preview_row_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + .unwrap_or_else(|| { + panic!("expected preview row in debug bounds (debug_selector on the row)") + }); + click_debug_selector( + cx, + "cherry_pick_range_preview_row_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ); + + cx.update(|_window, app| { + let host = view.read(app).popover_host.read(app); + assert!( + host.popover.is_none(), + "expected opening a preview commit to close the popover" + ); + }); + wait_until("SelectCommit lands in the store", || { + store + .snapshot() + .repos + .iter() + .find(|r| r.id == repo_id) + .is_some_and(|r| { + r.history_state + .selected_commit + .as_ref() + .is_some_and(|c| c.as_ref() == FIRST_SHA) + }) + }); +} From 7ca6866606e52a892887d94b8a4f0aa8acc18e75 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 18 Aug 2026 10:33:42 +0200 Subject: [PATCH 3/3] address maintainer review: rename to Branch extractor, move it into a new Automations menu, fix Cancel, and redesign as a bigger dialog Responds to Havunen's review comment on the cherry-pick-range PR: - Renamed the user-facing feature to "Branch extractor" (dialog title, tooltip, menu entry). Internal Rust identifiers were left as CherryPickRangePrompt/cherry_pick_range_* since they're referenced across 23 files in gitcomet-core/state/git-gix, and a full rename was judged out of proportion for this change. - Added an "Automations" action-bar button with a dropdown menu (PopoverKind::AutomationsMenu), whose first (and currently only) entry is Branch extractor. Removed the standalone action-bar button it replaces. The menu is built from a plain entry list so future automations are new entries, not new buttons. - Fixed the Cancel button: dismiss_prompt_popover's match had no arm for CherryPickRangePrompt, so it fell through the catch-all and did nothing. It now joins the CloneRepo/CreateTagPrompt/SquashPrompt group that calls close_popover. - Reworked the popover into a centered, 860px-wide dialog (matching the existing CloneRepo modal pattern) instead of a small 540px anchored popover, giving the three ref pickers, commit preview, and new "what will be created" summary (plain-language recap + a small text diagram) real room. - Rewrote the help text in plain language instead of dense A/B/D algebra; the letters now live only next to each field's label. - Minor branch-picker tuning: the branch icon stays visible whether or not a row is focused, each of the three ref inputs got a distinct, descriptive placeholder instead of the generic "branch", and the empty state reads "No matching branches or tags". Adds a regression test for the Cancel fix and a unit test for the Automations menu model; updates the existing branch-context-menu test for the renamed entry and its new OpenPopoverCentered action. Co-Authored-By: Claude Sonnet 5 --- .../gitcomet-ui-gpui/src/view/mod_helpers.rs | 7 + .../src/view/panels/action_bar.rs | 34 +++-- .../gitcomet-ui-gpui/src/view/panels/mod.rs | 7 + .../src/view/panels/popover.rs | 20 ++- .../popover/cherry_pick_range_prompt.rs | 132 ++++++++++++++++-- .../src/view/panels/popover/context_menu.rs | 6 + .../popover/context_menu/automations.rs | 59 ++++++++ .../panels/popover/context_menu/branch.rs | 4 +- .../src/view/panels/popover/fingerprint.rs | 6 + .../src/view/panels/popover/tests/refs.rs | 81 ++++++++++- 10 files changed, 315 insertions(+), 41 deletions(-) create mode 100644 crates/gitcomet-ui-gpui/src/view/panels/popover/context_menu/automations.rs diff --git a/crates/gitcomet-ui-gpui/src/view/mod_helpers.rs b/crates/gitcomet-ui-gpui/src/view/mod_helpers.rs index aed33346b..9dcda10c0 100644 --- a/crates/gitcomet-ui-gpui/src/view/mod_helpers.rs +++ b/crates/gitcomet-ui-gpui/src/view/mod_helpers.rs @@ -4586,6 +4586,13 @@ pub(super) enum PopoverKind { BrowseHistoryMenu { repo_id: RepoId, }, + /// Dropdown behind the action bar's "Automations" button. Currently + /// lists only the Branch extractor entry (cherry-pick a commit range + /// onto a new branch), but is a menu on purpose so future automations + /// have somewhere to go without a new action-bar button each time. + AutomationsMenu { + repo_id: RepoId, + }, SubmoduleInnerDiffMenu { repo_id: RepoId, submodule_repo_path: std::path::PathBuf, diff --git a/crates/gitcomet-ui-gpui/src/view/panels/action_bar.rs b/crates/gitcomet-ui-gpui/src/view/panels/action_bar.rs index c40e4a081..fcbefaee7 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/action_bar.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/action_bar.rs @@ -786,36 +786,34 @@ impl Render for ActionBarView { }) .gitcomet_tooltip(theme, "Create branch".into()); - let cherry_pick_range_invoker: SharedString = "cherry_pick_range_btn".into(); - let cherry_pick_range_active = self + // Home for one-shot automation flows (currently just the Branch + // extractor). A dedicated menu rather than a growing row of + // standalone buttons, so adding the next automation is a new menu + // entry, not a new place in the action bar. + let automations_invoker: SharedString = "automations_btn".into(); + let automations_active = self .active_context_menu_invoker .as_ref() - .is_some_and(|id| id.as_ref() == cherry_pick_range_invoker.as_ref()); - let cherry_pick_range = components::Button::new("cherry_pick_range", "") - .start_slot(icon("icons/copy.svg", icon_primary)) + .is_some_and(|id| id.as_ref() == automations_invoker.as_ref()); + let automations_enabled = self.state.active_repo.is_some(); + let automations = components::Button::new("automations", "Automations") + .start_slot(icon("icons/cog.svg", icon_primary)) .style(components::ButtonStyle::Subtle) - .selected(cherry_pick_range_active) + .selected(automations_active) .selected_bg(menu_selected_bg) + .disabled(!automations_enabled) .on_click_with_bounds(theme, cx, move |this, _e, bounds, window, cx| { - this.activate_context_menu_invoker(cherry_pick_range_invoker.clone(), cx); + this.activate_context_menu_invoker(automations_invoker.clone(), cx); if let Some(repo_id) = this.state.active_repo { this.open_popover_for_bounds( - PopoverKind::CherryPickRangePrompt { - repo_id, - prefill_source: None, - prefill_range: None, - prefill_base: None, - }, + PopoverKind::AutomationsMenu { repo_id }, bounds, window, cx, ); } }) - .gitcomet_tooltip( - theme, - "Cherry-pick ref A onto a new branch C created from D (range B..A)".into(), - ); + .gitcomet_tooltip(theme, "Automation flows for this repository".into()); div() .w_full() @@ -933,7 +931,7 @@ impl Render for ActionBarView { .child(push) .child(terminal) .child(create_branch) - .child(cherry_pick_range) + .child(automations) .child(stash), ) } diff --git a/crates/gitcomet-ui-gpui/src/view/panels/mod.rs b/crates/gitcomet-ui-gpui/src/view/panels/mod.rs index 1cab0a211..48d30816e 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/mod.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/mod.rs @@ -353,6 +353,13 @@ pub(in crate::view) enum ContextMenuAction { OpenPopover { kind: PopoverKind, }, + /// Like [`ContextMenuAction::OpenPopover`], but opens the target as a + /// centered modal dialog instead of anchoring it to the menu that + /// triggered it. For popovers that need real room to breathe (e.g. the + /// Branch extractor dialog), rather than a small anchored dropdown. + OpenPopoverCentered { + kind: PopoverKind, + }, LoadInteractiveRebaseSetup { repo_id: RepoId, base: String, diff --git a/crates/gitcomet-ui-gpui/src/view/panels/popover.rs b/crates/gitcomet-ui-gpui/src/view/panels/popover.rs index 30c7b439c..4c1645b23 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/popover.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/popover.rs @@ -125,6 +125,10 @@ const DIALOG_440_WIDTH: PopoverWidthSpec = PopoverWidthSpec::fixed(440.0); const DIALOG_460_WIDTH: PopoverWidthSpec = PopoverWidthSpec::fixed(460.0); const DIALOG_540_WIDTH: PopoverWidthSpec = PopoverWidthSpec::fixed(540.0); const DIALOG_640_WIDTH: PopoverWidthSpec = PopoverWidthSpec::fixed(640.0); +/// The Branch extractor dialog: wide enough for its three ref pickers, commit +/// preview, and "what will be created" summary to all get real room, per the +/// maintainer's request to give this flow more space than a small popover. +const DIALOG_860_WIDTH: PopoverWidthSpec = PopoverWidthSpec::fixed(860.0); // Leaves enough room for “Open in code editor” and its three-key shortcut // badge to remain on one line on non-macOS platforms. const APP_MENU_WIDTH: PopoverWidthSpec = PopoverWidthSpec::fixed(320.0); @@ -513,6 +517,7 @@ fn popover_is_context_menu(kind: &PopoverKind) -> bool { | PopoverKind::BranchGroupMenu { .. } | PopoverKind::PinnedSectionMenu { .. } | PopoverKind::BrowseHistoryMenu { .. } + | PopoverKind::AutomationsMenu { .. } ) } @@ -844,9 +849,9 @@ pub(in super::super) fn popover_width_spec(kind: &PopoverKind) -> Option Some(DIALOG_420_WIDTH), PopoverKind::CreateBranchFromRefPrompt { .. } - | PopoverKind::CherryPickRangePrompt { .. } | PopoverKind::RenameBranchPrompt { .. } | PopoverKind::CheckoutRemoteBranchPrompt { .. } => Some(DIALOG_540_WIDTH), + PopoverKind::CherryPickRangePrompt { .. } => Some(DIALOG_860_WIDTH), PopoverKind::StashDropConfirm { .. } | PopoverKind::Repo { kind: @@ -970,7 +975,8 @@ pub(in super::super) fn popover_width_spec(kind: &PopoverKind) -> Option Some(DEFAULT_CONTEXT_MENU_WIDTH), + | PopoverKind::BrowseHistoryMenu { .. } + | PopoverKind::AutomationsMenu { .. } => Some(DEFAULT_CONTEXT_MENU_WIDTH), PopoverKind::RepoTabMenu { .. } => Some(REPO_TAB_MENU_WIDTH), PopoverKind::HistoryBranchFilter { .. } | PopoverKind::DiffContentModeSettings @@ -2226,6 +2232,7 @@ impl PopoverHost { | Some(PopoverKind::SquashPrompt { .. }) | Some(PopoverKind::CheckoutRemoteBranchPrompt { .. }) | Some(PopoverKind::PushSetUpstreamPrompt { .. }) + | Some(PopoverKind::CherryPickRangePrompt { .. }) | Some(PopoverKind::Repo { kind: RepoPopoverKind::Remote(RemotePopoverKind::AddPrompt), .. @@ -3376,19 +3383,19 @@ impl PopoverHost { self.cherry_pick_base_target = base_prefill.clone(); let source_input = Self::ensure_cherry_pick_search_input( &mut self.cherry_pick_source_search_input, - "branch", + "branch or tag to copy from", window, cx, ); let range_input = Self::ensure_cherry_pick_search_input( &mut self.cherry_pick_range_search_input, - "branch", + "already-merged branch or tag", window, cx, ); let base_input = Self::ensure_cherry_pick_search_input( &mut self.cherry_pick_base_search_input, - "branch", + "branch the new one starts from", window, cx, ); @@ -4851,6 +4858,9 @@ impl PopoverHost { PopoverKind::BrowseHistoryMenu { repo_id } => { self.context_menu_view(PopoverKind::BrowseHistoryMenu { repo_id }, cx) } + PopoverKind::AutomationsMenu { repo_id } => { + self.context_menu_view(PopoverKind::AutomationsMenu { repo_id }, cx) + } PopoverKind::SubmoduleInnerDiffMenu { repo_id, submodule_repo_path, diff --git a/crates/gitcomet-ui-gpui/src/view/panels/popover/cherry_pick_range_prompt.rs b/crates/gitcomet-ui-gpui/src/view/panels/popover/cherry_pick_range_prompt.rs index 47450b6c2..e1fa7e534 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/popover/cherry_pick_range_prompt.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/popover/cherry_pick_range_prompt.rs @@ -29,7 +29,10 @@ fn picker_row( .is_focused(window); input.update(cx, |input, cx| { input.set_chromeless(is_focused, cx); - input.set_leading_icon(is_focused.then_some("icons/git_branch.svg"), cx); + // Keep the branch icon visible even when the row isn't focused, so the + // three rows read as "pick a ref" fields at a glance instead of + // looking like plain, unlabeled text boxes. + input.set_leading_icon(Some("icons/git_branch.svg"), cx); }); if is_focused { @@ -57,7 +60,7 @@ fn picker_row( cx, ) .tooltip_host(this.tooltip_host.clone()) - .empty_text("No matches") + .empty_text("No matching branches or tags") .max_height(scaled_px(240.0)) .selected_index(this.branch_picker_selected_index) .select_on_mouse_down() @@ -129,7 +132,9 @@ fn preview_section( } else { match preview.map(|p| &p.commits) { Some(gitcomet_state::model::Loadable::Ready(commits)) if !commits.is_empty() => { - let shown = commits.iter().take(8).collect::>(); + // The wider dialog (see `panel`) has room for more rows than + // a small anchored popover would. + let shown = commits.iter().take(12).collect::>(); let more = commits.len().saturating_sub(shown.len()); let mut rows = div().flex().flex_col().gap(scaled_px(2.0)).py_1(); for commit in &shown { @@ -202,7 +207,7 @@ fn preview_section( .child( div() .id("cherry_pick_range_preview_scroll") - .max_h(scaled_px(160.0)) + .max_h(scaled_px(220.0)) .overflow_y_scroll() .child(rows), ) @@ -238,6 +243,105 @@ fn preview_section( .child(body) } +/// Plain-language readout of what submitting the form will do, plus a small +/// text diagram — the maintainer asked for a way to "visualize what is going +/// into the new branch" beyond the raw commit list in [`preview_section`]. +fn summary_section( + this: &PopoverHost, + theme: AppTheme, + repo_id: RepoId, + scaled_px: impl Fn(f32) -> gpui::Pixels + Copy, + cx: &gpui::Context, +) -> gpui::Div { + let source = this.cherry_pick_source_target.trim().to_string(); + let range = this.cherry_pick_range_target.trim().to_string(); + let base = this.cherry_pick_base_target.trim().to_string(); + let name = this + .cherry_pick_name_input + .read(cx) + .text() + .trim() + .to_string(); + + if source.is_empty() + || range.is_empty() + || base.is_empty() + || name.is_empty() + || source == range + { + return div() + .mx_2() + .my_1() + .px_2() + .py_2() + .rounded(px(theme.radii.panel)) + .border_1() + .border_color(theme.colors.stroke.subtle) + .text_xs() + .text_color(theme.colors.foreground.secondary) + .child("Fill in the fields above to preview the branch that will be created."); + } + + let commit_count = this + .state + .repos + .iter() + .find(|r| r.id == repo_id) + .and_then(|r| r.cherry_pick_range_preview.as_ref()) + .filter(|p| p.range == range && p.source == source) + .and_then(|p| match &p.commits { + gitcomet_state::model::Loadable::Ready(commits) => Some(commits.len()), + _ => None, + }); + + let count_text = match commit_count { + Some(0) => "no commits".to_string(), + Some(1) => "1 commit".to_string(), + Some(n) => format!("{n} commits"), + None => "some commits".to_string(), + }; + + let summary = format!( + "New branch \"{name}\" will be created from {base}, then {count_text} from {source} \ + (everything not already in {range}) will be copied onto it. \ + {base} and {source} are left untouched." + ); + let diagram = format!("{base} ──▶ {name} (new) ◀── {count_text} from {range}..{source}"); + + div() + .flex() + .flex_col() + .gap(scaled_px(4.0)) + .mx_2() + .my_1() + .px_2() + .py_2() + .rounded(px(theme.radii.panel)) + .bg(theme.colors.surface.raised) + .border_1() + .border_color(theme.colors.stroke.subtle) + .child( + div() + .text_xs() + .font_weight(FontWeight::BOLD) + .text_color(theme.colors.foreground.primary) + .child("What will be created"), + ) + .child( + div() + .text_xs() + .text_color(theme.colors.foreground.secondary) + .child(summary), + ) + .child( + div() + .text_xs() + .font_family("ui-monospace") + .text_color(theme.colors.foreground.secondary) + .child(diagram), + ) +} + pub(super) fn panel( this: &mut PopoverHost, _repo_id: RepoId, @@ -267,23 +371,28 @@ pub(super) fn panel( div() .flex() .flex_col() - .w(scaled_px(540.0)) - .child(popover_title("Cherry-pick branch")) + .w(scaled_px(860.0)) + .child(popover_title("Branch extractor")) .child(div().border_t_1().border_color(theme.colors.stroke.default)) .child( div() .px_2() - .py_1() + .py_2() .text_sm() .text_color(theme.colors.foreground.secondary) .child( - "Creates a new branch C from D, checks it out, and cherry-picks every commit unique to A relative to B (B..A, oldest first, merge commits skipped). B must be an ancestor of A.", + "Copies a range of commits onto a brand-new branch, without touching the \ + branches they came from. Pick the branch or tag to copy commits from, then \ + the earlier point they grew from (it needs to already contain that starting \ + point in its history) — every commit after that, oldest first and skipping \ + merges, gets copied. Finally, pick which branch the new one should start from.", ), ) + .child(div().border_t_1().border_color(theme.colors.stroke.subtle)) .child(picker_row( this, theme, - "Source ref (A)", + "Copy commits from (A)", &source_input, |this, name, _e, window, cx| { this.handle_cherry_pick_source_select(name, window, cx); @@ -294,7 +403,7 @@ pub(super) fn panel( .child(picker_row( this, theme, - "Range ref (B)", + "Excluding commits already in (B)", &range_input, |this, name, _e, window, cx| { this.handle_cherry_pick_range_select(name, window, cx); @@ -305,7 +414,7 @@ pub(super) fn panel( .child(picker_row( this, theme, - "Base branch (D)", + "New branch starts from (D)", &base_input, |this, name, _e, window, cx| { this.handle_cherry_pick_base_select(name, window, cx); @@ -333,6 +442,7 @@ pub(super) fn panel( .child("Source and range are the same — there is nothing to cherry-pick."), ) }) + .child(summary_section(this, theme, _repo_id, scaled_px, cx)) .child(div().border_t_1().border_color(theme.colors.stroke.default)) .child( div() diff --git a/crates/gitcomet-ui-gpui/src/view/panels/popover/context_menu.rs b/crates/gitcomet-ui-gpui/src/view/panels/popover/context_menu.rs index 7efaefe8c..2169d4f0f 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/popover/context_menu.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/popover/context_menu.rs @@ -1,5 +1,6 @@ use super::*; +mod automations; mod branch; mod branch_group; mod branch_section; @@ -473,6 +474,7 @@ impl PopoverHost { PopoverKind::BrowseHistoryMenu { repo_id } => { Some(browse_history::model(self, *repo_id)) } + PopoverKind::AutomationsMenu { repo_id } => Some(automations::model(*repo_id)), PopoverKind::SubmoduleInnerDiffMenu { repo_id, submodule_repo_path, @@ -1360,6 +1362,10 @@ impl PopoverHost { self.open_popover_at(kind, anchor, window, cx); return; } + ContextMenuAction::OpenPopoverCentered { kind } => { + self.open_popover_centered(kind, window, cx); + return; + } ContextMenuAction::ConflictResolverPick { target } => { self.main_pane.update(cx, |pane, cx| { pane.conflict_resolver_apply_pick_target(target, cx); diff --git a/crates/gitcomet-ui-gpui/src/view/panels/popover/context_menu/automations.rs b/crates/gitcomet-ui-gpui/src/view/panels/popover/context_menu/automations.rs new file mode 100644 index 000000000..b50358c2c --- /dev/null +++ b/crates/gitcomet-ui-gpui/src/view/panels/popover/context_menu/automations.rs @@ -0,0 +1,59 @@ +use super::*; + +/// Dropdown opened from the action bar's "Automations" button. +/// +/// Kept as a list rather than a single button so new automations have a +/// home: adding one is a new [`ContextMenuItem::Entry`] here, not a new +/// top-level action-bar button. +pub(super) fn model(repo_id: RepoId) -> ContextMenuModel { + ContextMenuModel::new(vec![ + ContextMenuItem::Header("Automations".into()), + ContextMenuItem::Separator, + ContextMenuItem::Entry { + label: "Branch extractor".into(), + icon: Some("icons/copy.svg".into()), + shortcut: None, + disabled: false, + action: Box::new(ContextMenuAction::OpenPopoverCentered { + kind: PopoverKind::CherryPickRangePrompt { + repo_id, + prefill_source: None, + prefill_range: None, + prefill_base: None, + }, + }), + }, + ]) + .with_entry_tooltips(std::collections::HashMap::from([( + 2usize, + "Copy a range of commits from one branch onto a new branch".into(), + )])) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn model_lists_branch_extractor_as_first_entry() { + let repo_id = RepoId(1); + let items = model(repo_id).items; + + assert!( + matches!(&items[0], ContextMenuItem::Header(label) if label.as_ref() == "Automations") + ); + assert!(matches!(&items[1], ContextMenuItem::Separator)); + match &items[2] { + ContextMenuItem::Entry { label, action, .. } => { + assert_eq!(label.as_ref(), "Branch extractor"); + match action.as_ref() { + ContextMenuAction::OpenPopoverCentered { + kind: PopoverKind::CherryPickRangePrompt { repo_id: rid, .. }, + } => assert_eq!(*rid, repo_id), + _ => panic!("expected OpenPopoverCentered(CherryPickRangePrompt)"), + } + } + _ => panic!("expected an entry"), + } + } +} diff --git a/crates/gitcomet-ui-gpui/src/view/panels/popover/context_menu/branch.rs b/crates/gitcomet-ui-gpui/src/view/panels/popover/context_menu/branch.rs index 19cbab62b..f595db555 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/popover/context_menu/branch.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/popover/context_menu/branch.rs @@ -115,11 +115,11 @@ pub(super) fn model( _ => None, }); items.push(ContextMenuItem::Entry { - label: "Cherry-pick onto new branch…".into(), + label: "Branch extractor…".into(), icon: Some("icons/copy.svg".into()), shortcut: None, disabled: false, - action: Box::new(ContextMenuAction::OpenPopover { + action: Box::new(ContextMenuAction::OpenPopoverCentered { kind: PopoverKind::CherryPickRangePrompt { repo_id, prefill_source: Some(name.clone()), diff --git a/crates/gitcomet-ui-gpui/src/view/panels/popover/fingerprint.rs b/crates/gitcomet-ui-gpui/src/view/panels/popover/fingerprint.rs index c5e508546..8542b9747 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/popover/fingerprint.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/popover/fingerprint.rs @@ -193,6 +193,7 @@ fn repo_for_popover<'a>(state: &'a AppState, popover: &PopoverKind) -> Option<&' | PopoverKind::FileBrowserFileMenu { repo_id, .. } | PopoverKind::FileBrowserFolderMenu { repo_id, .. } | PopoverKind::BrowseHistoryMenu { repo_id } + | PopoverKind::AutomationsMenu { repo_id } | PopoverKind::SubmoduleInnerDiffMenu { repo_id, .. } | PopoverKind::TagMenu { repo_id, .. } | PopoverKind::TerminalMenu { repo_id, .. } @@ -395,6 +396,7 @@ fn hash_repo_for_popover(repo: &RepoState, popover: &PopoverKind, has | PopoverKind::CommitFileMenu { .. } | PopoverKind::FileBrowserFileMenu { .. } | PopoverKind::BrowseHistoryMenu { .. } + | PopoverKind::AutomationsMenu { .. } | PopoverKind::SubmoduleInnerDiffMenu { .. } | PopoverKind::StatusFileMenu { .. } | PopoverKind::StageConflictMarkersConfirm { .. } @@ -810,6 +812,10 @@ fn hash_popover_kind(kind: &PopoverKind, hasher: &mut H) { 63u8.hash(hasher); repo_id.hash(hasher); } + PopoverKind::AutomationsMenu { repo_id } => { + 105u8.hash(hasher); + repo_id.hash(hasher); + } PopoverKind::SubmoduleInnerDiffMenu { repo_id, submodule_repo_path, diff --git a/crates/gitcomet-ui-gpui/src/view/panels/popover/tests/refs.rs b/crates/gitcomet-ui-gpui/src/view/panels/popover/tests/refs.rs index 71ba97880..fb295af38 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/popover/tests/refs.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/popover/tests/refs.rs @@ -1866,7 +1866,7 @@ fn local_branch_menu_cherry_pick_prefills_source_range_and_base(cx: &mut gpui::T let entry = model.items.iter().find_map(|item| match item { ContextMenuItem::Entry { label, action, .. } - if label.as_ref() == "Cherry-pick onto new branch…" => + if label.as_ref() == "Branch extractor…" => { Some((**action).clone()) } @@ -1874,7 +1874,7 @@ fn local_branch_menu_cherry_pick_prefills_source_range_and_base(cx: &mut gpui::T }); match entry { - Some(ContextMenuAction::OpenPopover { + Some(ContextMenuAction::OpenPopoverCentered { kind: PopoverKind::CherryPickRangePrompt { repo_id: rid, @@ -1888,9 +1888,7 @@ fn local_branch_menu_cherry_pick_prefills_source_range_and_base(cx: &mut gpui::T assert_eq!(prefill_range.as_deref(), Some("origin/awesome")); assert_eq!(prefill_base.as_deref(), Some("main")); } - _ => panic!( - "expected Cherry-pick onto new branch entry with prefilled CherryPickRangePrompt" - ), + _ => panic!("expected Branch extractor entry with prefilled CherryPickRangePrompt"), } }); } @@ -2012,3 +2010,76 @@ fn cherry_pick_range_preview_row_click_opens_commit_and_closes_popover( }) }); } + +/// Regression test for the Branch extractor's Cancel button: it calls +/// `dismiss_prompt_popover`, whose match previously had no arm for +/// `CherryPickRangePrompt` and silently fell through the catch-all, +/// leaving the dialog stuck open. It now belongs to the same +/// standalone-dialog group as `CloneRepo`/`CreateTagPrompt`, closed via +/// `close_popover`. +#[gpui::test] +fn cherry_pick_range_cancel_closes_popover(cx: &mut gpui::TestAppContext) { + let (store, events) = AppStore::new(Arc::new(TestBackend)); + let repo_id = RepoId(242); + let workdir = std::env::temp_dir().join(format!( + "gitcomet_ui_test_{}_cherry_pick_cancel", + std::process::id() + )); + + let mut repo = RepoState::new_opening( + repo_id, + gitcomet_core::domain::RepoSpec { + workdir: workdir.clone(), + }, + ); + repo.head_branch = Loadable::Ready("main".to_string()); + let state = Arc::new(AppState { + repos: vec![repo], + active_repo: Some(repo_id), + ..Default::default() + }); + store.replace_snapshot_for_test(Arc::clone(&state)); + + let (view, cx) = + cx.add_window_view(|window, cx| GitCometView::new(store, events, None, window, cx)); + cx.update(|_window, app| { + view.update(app, |this, cx| { + this.state = Arc::clone(&state); + this._ui_model + .update(cx, |model, cx| model.set_state(state, cx)); + cx.notify(); + }); + }); + + cx.update(|window, app| { + view.update(app, |this, cx| { + this.popover_host.update(cx, |host, cx| { + host.open_popover_at( + PopoverKind::CherryPickRangePrompt { + repo_id, + prefill_source: None, + prefill_range: None, + prefill_base: None, + }, + gpui::point(gpui::px(120.0), gpui::px(72.0)), + window, + cx, + ); + assert!( + matches!( + host.popover, + Some(PopoverKind::CherryPickRangePrompt { .. }) + ), + "expected the dialog to be open before Cancel" + ); + + host.dismiss_prompt_popover(window, cx); + + assert!( + host.popover.is_none(), + "expected Cancel to close the Branch extractor dialog" + ); + }); + }); + }); +}