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 4db87879d..d56336979 100644 --- a/crates/gitcomet-core/src/services.rs +++ b/crates/gitcomet-core/src/services.rs @@ -683,6 +683,40 @@ pub trait GitRepository: Send + Sync { } fn revert(&self, id: &CommitId) -> Result<()>; + /// 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", + ))) + } + /// 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, + _range: &str, + _source: &str, + ) -> Result> { + Err(Error::new(ErrorKind::Unsupported( + "listing a cherry-pick range is not implemented for this backend", + ))) + } + 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..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,6 +1069,180 @@ impl GixRepo { self.run_planned_rebase(entries, "HEAD", &label) } + /// 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) + } + + /// 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, + range: &str, + source: &str, + ) -> Result> { + validate_ref_like_arg(range, "range reference")?; + validate_ref_like_arg(source, "source reference")?; + + 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}" + )))); + } + } + + let range_arg = format!("{range}..{source}"); + let mut cmd = self.git_workdir_cmd(); + // 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(); + } + 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() + } + 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..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,6 +608,24 @@ impl GitRepository for GixRepo { self.revert_impl(id) } + 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) + } + + fn cherry_pick_range_commits( + &self, + range: &str, + source: &str, + ) -> Result> { + self.cherry_pick_range_commits_impl(range, source) + } + 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..4bd423ab0 100644 --- a/crates/gitcomet-git-gix/tests/cherry_pick_integration.rs +++ b/crates/gitcomet-git-gix/tests/cherry_pick_integration.rs @@ -1355,3 +1355,142 @@ fn abort_returns_active_cherry_pick_lock_error() { SequencerState::CherryPick ); } + +#[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"); +} + +#[test] +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_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"); + run_git(&repo, &["checkout", "main"]); + commit_file(&repo, "main.txt", "main\n", "main change"); + run_git(&repo, &["merge", "--no-edit", "branch_a"]); + + 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"]); + + // A non-ancestor range is rejected instead of listing diverged history. + let error = open_backend(&repo) + .cherry_pick_range_commits("branch_d", "branch_a") + .expect_err("non-ancestor range must be rejected"); + assert!(error.to_string().contains("ancestor"), "{error}"); +} 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 3c0a4a647..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,6 +276,13 @@ pub enum Effect { mainline: Option, summary: String, }, + CherryPickRangeOntoNewBranch { + repo_id: RepoId, + base: String, + range: String, + source: String, + new_branch: String, + }, 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..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,6 +512,17 @@ pub enum Msg { mainline: Option, summary: String, }, + /// 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, + }, RevertCommit { repo_id: RepoId, commit_id: CommitId, @@ -1072,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 bd65e7d09..4f0136a39 100644 --- a/crates/gitcomet-state/src/msg/repo_command_kind.rs +++ b/crates/gitcomet-state/src/msg/repo_command_kind.rs @@ -83,6 +83,15 @@ pub enum RepoCommandKind { mainline: Option, summary: String, }, + /// 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, + }, MergeAbort, CreateTag { name: String, diff --git a/crates/gitcomet-state/src/store/effects.rs b/crates/gitcomet-state/src/store/effects.rs index 3a3a65ccb..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,6 +750,24 @@ fn send_unavailable_git_effect_result( result: Err(git_unavailable_error(runtime)), }, )), + 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)), + }, + )), Effect::RevertCommit { repo_id, .. } => { send_repo_action_unavailable(repo_id, RepoActionKind::RevertCommit, runtime, &send) } @@ -1846,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) @@ -2120,6 +2159,17 @@ pub(super) fn schedule_effect( executor, repos, msg_tx, repo_id, commit_id, commit, mainline, summary, ); } + 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, + ); + } 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..1ae1aeb75 100644 --- a/crates/gitcomet-state/src/store/effects/repo_commands.rs +++ b/crates/gitcomet-state/src/store/effects/repo_commands.rs @@ -1068,6 +1068,35 @@ pub(super) fn schedule_interactive_cherry_pick( ); } +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), + ); +} + 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 0ad8468c3..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 { .. } @@ -418,6 +419,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, @@ -1027,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, @@ -1167,6 +1174,21 @@ fn reduce_inner( begin_head_changing_local_action(state, repo_id); actions_emit_effects::cherry_pick_commit(repo_id, commit_id, commit, mainline, summary) } + 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, + ) + } Msg::RevertCommit { repo_id, commit_id } => { begin_head_changing_local_action(state, repo_id); actions_emit_effects::revert_commit(repo_id, commit_id) @@ -2143,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 3cab59caa..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,6 +63,22 @@ pub(super) fn cherry_pick_commit( }] } +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, + }] +} + pub(super) fn revert_commit( repo_id: RepoId, commit_id: gitcomet_core::domain::CommitId, @@ -985,6 +1001,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 +1048,7 @@ fn command_clears_pending_force_push_lease(command: &RepoCommandKind) -> bool { | RepoCommandKind::InteractiveRebase { .. } | RepoCommandKind::InteractiveCherryPick { .. } | RepoCommandKind::CherryPick { .. } + | RepoCommandKind::CherryPickRangeOntoNewBranch { .. } | RepoCommandKind::MergeAbort ) } @@ -1075,6 +1093,12 @@ 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, @@ -1155,6 +1179,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 +1228,14 @@ 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/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 c29e498a8..a4f4c2982 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,19 @@ fn summarize_command( }; format!("Cherry-pick {} commits: {state}", entries.len()) } + 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}") + } RepoCommandKind::CherryPick { commit_id, commit, @@ -2585,6 +2599,22 @@ mod tests { "Current branch already has all the changes from the cherry-picked commit." ); + 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" + ); + 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..bbb380a0a 100644 --- a/crates/gitcomet-state/src/store/tests/actions_emit_effects.rs +++ b/crates/gitcomet-state/src/store/tests/actions_emit_effects.rs @@ -2053,6 +2053,29 @@ fn additional_routing_messages_emit_effects_and_update_counters() { }] if summary == "pick me" )); + let effects = reduce( + &mut repos, + &id_alloc, + &mut state, + 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, @@ -2160,7 +2183,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" ); @@ -4512,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/mod_helpers.rs b/crates/gitcomet-ui-gpui/src/view/mod_helpers.rs index 5df8772b4..9dcda10c0 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, @@ -4572,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 ed5f97195..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,6 +786,35 @@ impl Render for ActionBarView { }) .gitcomet_tooltip(theme, "Create branch".into()); + // 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() == 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(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(automations_invoker.clone(), cx); + if let Some(repo_id) = this.state.active_repo { + this.open_popover_for_bounds( + PopoverKind::AutomationsMenu { repo_id }, + bounds, + window, + cx, + ); + } + }) + .gitcomet_tooltip(theme, "Automation flows for this repository".into()); + div() .w_full() .h(action_bar_height) @@ -902,6 +931,7 @@ impl Render for ActionBarView { .child(push) .child(terminal) .child(create_branch) + .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 b54ca8877..4c1645b23 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; @@ -124,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); @@ -302,6 +307,19 @@ pub(in super::super) struct PopoverHost { create_branch_input: Entity, create_branch_checkout_enabled: bool, create_branch_source_target: String, + 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, + /// 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 @@ -499,6 +517,7 @@ fn popover_is_context_menu(kind: &PopoverKind) -> bool { | PopoverKind::BranchGroupMenu { .. } | PopoverKind::PinnedSectionMenu { .. } | PopoverKind::BrowseHistoryMenu { .. } + | PopoverKind::AutomationsMenu { .. } ) } @@ -832,6 +851,7 @@ pub(in super::super) fn popover_width_spec(kind: &PopoverKind) -> Option Some(DIALOG_540_WIDTH), + PopoverKind::CherryPickRangePrompt { .. } => Some(DIALOG_860_WIDTH), PopoverKind::StashDropConfirm { .. } | PopoverKind::Repo { kind: @@ -955,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 @@ -1300,6 +1321,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 +1554,18 @@ 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 +1797,17 @@ impl PopoverHost { create_branch_input, create_branch_checkout_enabled: true, create_branch_source_target: String::new(), + 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(), + cherry_pick_preview_requested: None, worktree_ref_source_target: String::new(), suppress_worktree_submit_after_ref_enter: false, suppress_popover_close_after_action: false, @@ -2177,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), .. @@ -2566,6 +2622,207 @@ impl PopoverHost { self.dismiss_inline_popover(window, cx); } + 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); + } + + /// 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; + }; + 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; + } + if self.cherry_pick_preview_requested.as_ref() == Some(&(range.clone(), source.clone())) { + return; + } + 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, + }); + } + } + fn can_submit_rename_branch(&self, cx: &mut gpui::Context) -> bool { let Some(PopoverKind::RenameBranchPrompt { name, .. }) = &self.popover else { return false; @@ -3101,6 +3358,225 @@ impl PopoverHost { .read_with(cx, |i, _| i.focus_handle()); window.focus(&focus, cx); } + PopoverKind::CherryPickRangePrompt { + prefill_source, + prefill_range, + prefill_base, + .. + } => { + 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 or tag to copy from", + window, + cx, + ); + let range_input = Self::ensure_cherry_pick_search_input( + &mut self.cherry_pick_range_search_input, + "already-merged branch or tag", + window, + cx, + ); + let base_input = Self::ensure_cherry_pick_search_input( + &mut self.cherry_pick_base_search_input, + "branch the new one starts from", + 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() { + 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() { + 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() { + 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()), + (&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); + self.refresh_cherry_pick_range_preview(cx); + } PopoverKind::RenameBranchPrompt { name, .. } => { let theme = self.theme; self.create_branch_input.update(cx, |input, cx| { @@ -4099,6 +4575,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) } @@ -4379,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 new file mode 100644 index 000000000..e1fa7e534 --- /dev/null +++ b/crates/gitcomet-ui-gpui/src/view/panels/popover/cherry_pick_range_prompt.rs @@ -0,0 +1,473 @@ +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 (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, + 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); + // 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 { + 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() + .child( + div() + .px_2() + .py_1() + .text_sm() + .text_color(theme.colors.foreground.secondary) + .child(label), + ) + .child( + div().px_2().pb_1().w_full().min_w(px(0.0)).child( + branch_picker::ref_picker_prompt( + input.clone(), + this.picker_prompt_scroll.clone(), + &built, + cx, + ) + .tooltip_host(this.tooltip_host.clone()) + .empty_text("No matching branches or tags") + .max_height(scaled_px(240.0)) + .selected_index(this.branch_picker_selected_index) + .select_on_mouse_down() + .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 { + div() + .flex() + .flex_col() + .child( + div() + .px_2() + .py_1() + .text_sm() + .text_color(theme.colors.foreground.secondary) + .child(label), + ) + .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() => { + // 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 { + 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(220.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) +} + +/// 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, + 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(860.0)) + .child(popover_title("Branch extractor")) + .child(div().border_t_1().border_color(theme.colors.stroke.default)) + .child( + div() + .px_2() + .py_2() + .text_sm() + .text_color(theme.colors.foreground.secondary) + .child( + "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, + "Copy commits from (A)", + &source_input, + |this, name, _e, window, cx| { + this.handle_cherry_pick_source_select(name, window, cx); + }, + window, + cx, + )) + .child(picker_row( + this, + theme, + "Excluding commits already in (B)", + &range_input, + |this, name, _e, window, cx| { + this.handle_cherry_pick_range_select(name, window, cx); + }, + window, + cx, + )) + .child(picker_row( + this, + theme, + "New branch starts from (D)", + &base_input, + |this, name, _e, window, cx| { + this.handle_cherry_pick_base_select(name, window, cx); + }, + window, + cx, + )) + .child(preview_section(this, theme, _repo_id, scaled_px, 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.status.warning.foreground) + .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() + .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); + }), + ), + ) +} 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 5b54373ba..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 @@ -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: "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: 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..8542b9747 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 } @@ -192,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, .. } @@ -238,6 +240,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(_), .. @@ -375,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 { .. } @@ -437,6 +459,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, @@ -778,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/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 34a2a653e..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 @@ -1792,3 +1792,294 @@ 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() == "Branch extractor…" => + { + Some((**action).clone()) + } + _ => None, + }); + + match entry { + Some(ContextMenuAction::OpenPopoverCentered { + 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 Branch extractor entry with prefilled CherryPickRangePrompt"), + } + }); +} + +#[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) + }) + }); +} + +/// 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" + ); + }); + }); + }); +}