diff --git a/.gitignore b/.gitignore index 55df167e..ce1329da 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ __pycache__/ *.pyc criterion .codex +.worktrees/ diff --git a/crates/gitcomet-core/src/lib.rs b/crates/gitcomet-core/src/lib.rs index 54a0a3d3..b0e0a0a0 100644 --- a/crates/gitcomet-core/src/lib.rs +++ b/crates/gitcomet-core/src/lib.rs @@ -13,4 +13,5 @@ pub mod mergetool_trace; pub mod path_utils; pub mod process; pub mod services; +pub mod squash; pub mod text_utils; diff --git a/crates/gitcomet-core/src/services.rs b/crates/gitcomet-core/src/services.rs index 35d94fa9..6191c3ba 100644 --- a/crates/gitcomet-core/src/services.rs +++ b/crates/gitcomet-core/src/services.rs @@ -141,6 +141,49 @@ pub enum RemoteUrlKind { Push, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum InteractiveRebaseAction { + Pick, + Reword, + Edit, + Squash, + Fixup, + Drop, +} + +impl InteractiveRebaseAction { + pub fn to_todo_str(self) -> &'static str { + match self { + Self::Pick => "pick", + Self::Reword => "reword", + Self::Edit => "edit", + Self::Squash => "squash", + Self::Fixup => "fixup", + Self::Drop => "drop", + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Pick => "Pick", + Self::Reword => "Reword", + Self::Edit => "Edit", + Self::Squash => "Squash", + Self::Fixup => "Fixup", + Self::Drop => "Drop", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InteractiveRebaseEntry { + pub action: InteractiveRebaseAction, + pub commit_id: String, + pub summary: String, + /// New commit message, used only when action is Reword. + pub new_message: Option, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub struct SubmoduleTrustTarget { pub submodule_path: PathBuf, @@ -511,6 +554,11 @@ pub trait GitRepository: Send + Sync { } fn checkout_commit(&self, id: &CommitId) -> Result<()>; fn cherry_pick(&self, id: &CommitId) -> Result<()>; + fn cherry_pick_with_output(&self, _id: &CommitId, _commit: bool) -> Result { + Err(Error::new(ErrorKind::Unsupported( + "git cherry-pick is not implemented for this backend", + ))) + } fn revert(&self, id: &CommitId) -> Result<()>; fn stash_create(&self, message: &str, include_untracked: bool) -> Result<()>; @@ -556,6 +604,31 @@ pub trait GitRepository: Send + Sync { "git rebase --abort is not implemented for this backend", ))) } + fn list_commits_for_interactive_rebase( + &self, + _base: &str, + ) -> Result> { + Err(Error::new(ErrorKind::Unsupported( + "listing commits for interactive rebase is not implemented for this backend", + ))) + } + fn interactive_rebase_with_output( + &self, + _base: &str, + _entries: &[InteractiveRebaseEntry], + ) -> Result { + Err(Error::new(ErrorKind::Unsupported( + "git rebase -i is not implemented for this backend", + ))) + } + fn interactive_cherry_pick_with_output( + &self, + _entries: &[InteractiveRebaseEntry], + ) -> Result { + Err(Error::new(ErrorKind::Unsupported( + "interactive cherry-pick is not implemented for this backend", + ))) + } fn merge_abort_with_output(&self) -> Result { Err(Error::new(ErrorKind::Unsupported( "git merge --abort is not implemented for this backend", @@ -768,6 +841,31 @@ pub trait GitRepository: Send + Sync { ))) } + /// Builds the default combined message for squashing the linear commit + /// range `oldest..=head`: the oldest commit's full message first, younger + /// messages appended as paragraphs. + fn squash_message_preview(&self, _oldest: &CommitId, _head: &CommitId) -> Result { + Err(Error::new(ErrorKind::Unsupported( + "squashing commits is not implemented for this backend", + ))) + } + + /// Squashes the linear first-parent range `oldest..=expected_head` (which + /// must end at the current HEAD) into a single commit carrying `message`, + /// preserving the oldest commit's author. Must not touch the worktree or + /// index, and must fail without changing refs when HEAD no longer equals + /// `expected_head`. + fn squash_commits_with_output( + &self, + _oldest: &CommitId, + _expected_head: &CommitId, + _message: &str, + ) -> Result { + Err(Error::new(ErrorKind::Unsupported( + "squashing commits is not implemented for this backend", + ))) + } + fn reset_with_output(&self, _target: &str, _mode: ResetMode) -> Result { Err(Error::new(ErrorKind::Unsupported( "git reset is not implemented for this backend", diff --git a/crates/gitcomet-core/src/squash.rs b/crates/gitcomet-core/src/squash.rs new file mode 100644 index 00000000..4c157d4a --- /dev/null +++ b/crates/gitcomet-core/src/squash.rs @@ -0,0 +1,359 @@ +//! Eligibility rules and message construction for squashing a contiguous +//! range of history commits into one. + +use crate::domain::{Commit, CommitId}; +use std::collections::{HashMap, HashSet}; + +/// A validated squash of `commit_count` commits in a linear first-parent +/// chain reachable from HEAD. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SquashPlan { + /// The repo's actual HEAD when the plan was computed. Equals `head` when + /// the selection ends at HEAD (commit-tree path); differs from `head` + /// for intermediate ranges (rebase path). + pub actual_head: CommitId, + /// Youngest selected commit. + pub head: CommitId, + /// Oldest selected commit; its message becomes the squash subject. + pub oldest: CommitId, + /// Parent of the oldest selected commit; becomes the squash commit's + /// parent, and the rebase base when the range does not end at HEAD. + pub oldest_parent: CommitId, + pub commit_count: usize, + /// Selected commits in log order (youngest first). + pub ordered_ids: Vec, +} + +/// Validates a selection against the loaded log page (`commits`) and the +/// current HEAD id. Returns a plan only when all criteria hold: +/// +/// 1. more than one distinct commit is selected, +/// 2. every selected commit is present in the loaded page (selections spanning +/// unloaded pages are rejected), +/// 3. the selected commits lie on a single first-parent chain reachable from +/// HEAD — each commit in the chain, selected or not, must have exactly one +/// parent — and the oldest selected commit has a parent (is not the root). +/// +/// The range may end at HEAD (commit-tree path) or sit anywhere in the +/// middle of the chain (rebase path). Validation follows the first-parent +/// chain via id lookup rather than page position, so it is unaffected by +/// rows the visible history interleaves into the page (e.g. stash-helper +/// commits) which the selection excludes. +pub fn squash_eligibility( + commits: &[Commit], + selected: &[CommitId], + actual_head: &CommitId, +) -> Option { + if selected.len() < 2 { + return None; + } + + let selected_set: HashSet<&CommitId> = selected.iter().collect(); + if selected_set.len() != selected.len() { + return None; + } + + // Build a full id → commit lookup for the whole page so we can walk + // through both selected and non-selected commits. + let all_by_id: HashMap<&CommitId, &Commit> = commits.iter().map(|c| (&c.id, c)).collect(); + + // Every selected commit must be present in the loaded page. + if selected_set.iter().any(|id| !all_by_id.contains_key(id)) { + return None; + } + + // Walk first parents from actual HEAD, collecting selected commits in + // encounter order. Non-selected commits preceding the range are + // pass-through; gaps within the selected range are rejected so the + // squashed set is always contiguous on the chain. + let mut ordered_ids = Vec::with_capacity(selected.len()); + let mut current: &CommitId = actual_head; + let mut collected = 0; + let mut inside_selection = false; + let mut gap_within_selection = false; + + loop { + let commit = all_by_id.get(current)?; + + if commit.parent_ids.len() != 1 { + return None; + } + + if selected_set.contains(current) { + if gap_within_selection { + return None; + } + inside_selection = true; + ordered_ids.push(current.clone()); + collected += 1; + + if collected == selected.len() { + return Some(SquashPlan { + actual_head: actual_head.clone(), + head: ordered_ids[0].clone(), + oldest: current.clone(), + oldest_parent: commit.parent_ids[0].clone(), + commit_count: selected.len(), + ordered_ids, + }); + } + } else if inside_selection { + gap_within_selection = true; + } + + current = &commit.parent_ids[0]; + } +} + +/// Splits a commit message into a single-line subject and the remaining body, +/// following git's convention: the subject is the first line and the body is +/// everything after the first line break, with a single leading blank-line +/// separator consumed. Keeping this in core means the UI never has to re-parse +/// the message format. +pub fn split_subject_body(message: &str) -> (String, String) { + match message.split_once('\n') { + Some((subject, rest)) => ( + subject.to_string(), + rest.strip_prefix('\n').unwrap_or(rest).to_string(), + ), + None => (message.to_string(), String::new()), + } +} + +/// Builds the combined squash message: the oldest commit's full message is the +/// subject/body, and each younger message is appended as its own paragraph, +/// oldest to youngest. +pub fn build_squash_message(messages_oldest_first: &[String]) -> String { + let mut out = String::new(); + for message in messages_oldest_first { + let trimmed = message.trim(); + if trimmed.is_empty() { + continue; + } + if !out.is_empty() { + out.push_str("\n\n"); + } + out.push_str(trimmed); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + use std::time::{Duration, SystemTime}; + + fn id(s: &str) -> CommitId { + CommitId(Arc::from(s)) + } + + fn commit(sha: &str, parents: &[&str], age: u64) -> Commit { + Commit { + id: id(sha), + parent_ids: parents.iter().map(|p| id(p)).collect(), + summary: Arc::from(sha), + author: Arc::from("author"), + time: SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000 - age), + } + } + + /// d (HEAD) -> c -> b -> a -> root + fn linear_log() -> Vec { + vec![ + commit("d", &["c"], 0), + commit("c", &["b"], 10), + commit("b", &["a"], 20), + commit("a", &["root"], 30), + commit("root", &[], 40), + ] + } + + #[test] + fn eligible_range_ending_at_head_returns_plan() { + let log = linear_log(); + let plan = + squash_eligibility(&log, &[id("c"), id("d"), id("b")], &id("d")).expect("eligible"); + assert_eq!(plan.actual_head, id("d")); + assert_eq!(plan.head, id("d")); + assert_eq!(plan.oldest, id("b")); + assert_eq!(plan.oldest_parent, id("a")); + assert_eq!(plan.commit_count, 3); + assert_eq!(plan.ordered_ids, vec![id("d"), id("c"), id("b")]); + } + + #[test] + fn intermediate_range_in_linear_chain_is_eligible() { + let log = linear_log(); + let plan = squash_eligibility(&log, &[id("c"), id("b")], &id("d")).expect("eligible"); + assert_eq!(plan.actual_head, id("d")); + assert_eq!(plan.head, id("c")); + assert_eq!(plan.oldest, id("b")); + assert_eq!(plan.oldest_parent, id("a")); + assert_eq!(plan.commit_count, 2); + assert_eq!(plan.ordered_ids, vec![id("c"), id("b")]); + } + + #[test] + fn single_selection_is_rejected() { + let log = linear_log(); + assert!(squash_eligibility(&log, &[id("d")], &id("d")).is_none()); + } + + #[test] + fn selection_outside_loaded_page_is_rejected() { + let log = linear_log(); + assert!(squash_eligibility(&log, &[id("d"), id("missing")], &id("d")).is_none()); + } + + #[test] + fn non_contiguous_selection_is_rejected() { + let log = linear_log(); + assert!(squash_eligibility(&log, &[id("d"), id("b")], &id("d")).is_none()); + } + + #[test] + fn selection_unreachable_from_head_is_rejected() { + let log = linear_log(); + assert!(squash_eligibility(&log, &[id("d"), id("c")], &id("other")).is_none()); + } + + #[test] + fn intermediate_range_rejected_when_merge_blocks_chain() { + // A merge commit sits between HEAD and the selected range, so the + // first-parent walk can't continue past it. + let log = vec![ + commit("d", &["m1", "m2"], 0), + commit("m1", &["c"], 5), + commit("m2", &["x"], 6), + commit("c", &["b"], 10), + commit("b", &["a"], 20), + commit("a", &[], 30), + ]; + assert!(squash_eligibility(&log, &[id("c"), id("b")], &id("d")).is_none()); + } + + #[test] + fn merge_commit_in_range_is_rejected() { + let log = vec![ + commit("d", &["c", "x"], 0), + commit("c", &["b"], 10), + commit("b", &["a"], 20), + commit("a", &[], 30), + ]; + assert!(squash_eligibility(&log, &[id("d"), id("c")], &id("d")).is_none()); + } + + #[test] + fn parent_chain_gap_is_rejected() { + // Contiguous in log order but "c" is not "d"'s parent (interleaved branch). + let log = vec![ + commit("d", &["b"], 0), + commit("c", &["a"], 10), + commit("b", &["a"], 20), + commit("a", &[], 30), + ]; + assert!(squash_eligibility(&log, &[id("d"), id("c")], &id("d")).is_none()); + } + + #[test] + fn root_as_oldest_is_rejected() { + let log = linear_log(); + assert!( + squash_eligibility( + &log, + &[id("d"), id("c"), id("b"), id("a"), id("root")], + &id("d"), + ) + .is_none() + ); + } + + #[test] + fn duplicate_selected_ids_are_rejected() { + let log = linear_log(); + assert!(squash_eligibility(&log, &[id("d"), id("d")], &id("d")).is_none()); + } + + #[test] + fn message_combines_oldest_first_with_paragraph_breaks() { + let messages = vec![ + "Oldest subject\n\nOldest body\n".to_string(), + "Middle change".to_string(), + "Newest change\n".to_string(), + ]; + assert_eq!( + build_squash_message(&messages), + "Oldest subject\n\nOldest body\n\nMiddle change\n\nNewest change" + ); + } + + #[test] + fn message_skips_empty_entries() { + let messages = vec![ + "Subject".to_string(), + " \n".to_string(), + "Tail".to_string(), + ]; + assert_eq!(build_squash_message(&messages), "Subject\n\nTail"); + } + + #[test] + fn interleaved_stash_helper_does_not_break_eligibility() { + // A stash-helper commit is sorted into the page between real commits, + // but is not part of the branch's first-parent chain and is excluded + // from the selection. The range d,c,b stays eligible. + let log = vec![ + commit("d", &["c"], 0), + commit("c", &["b"], 10), + commit("stash", &["a", "idx"], 15), + commit("b", &["a"], 20), + commit("a", &["root"], 30), + commit("root", &[], 40), + ]; + let plan = + squash_eligibility(&log, &[id("d"), id("c"), id("b")], &id("d")).expect("eligible"); + assert_eq!(plan.actual_head, id("d")); + assert_eq!(plan.head, id("d")); + assert_eq!(plan.oldest, id("b")); + assert_eq!(plan.oldest_parent, id("a")); + assert_eq!(plan.commit_count, 3); + assert_eq!(plan.ordered_ids, vec![id("d"), id("c"), id("b")]); + } + + #[test] + fn selection_off_the_head_chain_is_rejected() { + // "x" is in the page and count matches, but is not on d's first-parent + // chain, so the walk from d never reaches it. + let log = vec![ + commit("d", &["c"], 0), + commit("x", &["c"], 5), + commit("c", &["b"], 10), + commit("b", &["a"], 20), + commit("a", &[], 30), + ]; + assert!(squash_eligibility(&log, &[id("d"), id("c"), id("x")], &id("d")).is_none()); + } + + #[test] + fn split_subject_body_handles_conventional_message() { + let (subject, body) = split_subject_body("Fix parser\n\nHandle CRLF endings\n"); + assert_eq!(subject, "Fix parser"); + assert_eq!(body, "Handle CRLF endings\n"); + } + + #[test] + fn split_subject_body_keeps_single_line_wrap_out_of_subject() { + // A single newline (no blank line) must not land inside the subject. + let (subject, body) = split_subject_body("Fix parser\nhandle CRLF"); + assert_eq!(subject, "Fix parser"); + assert_eq!(body, "handle CRLF"); + } + + #[test] + fn split_subject_body_without_newline() { + let (subject, body) = split_subject_body("Only a subject"); + assert_eq!(subject, "Only a subject"); + assert_eq!(body, ""); + } +} diff --git a/crates/gitcomet-git-gix/src/repo/history.rs b/crates/gitcomet-git-gix/src/repo/history.rs index 60d64cb0..30ac3ace 100644 --- a/crates/gitcomet-git-gix/src/repo/history.rs +++ b/crates/gitcomet-git-gix/src/repo/history.rs @@ -1,7 +1,16 @@ use super::GixRepo; -use crate::util::{run_git_with_output, validate_ref_like_arg}; +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::error::{Error, ErrorKind}; -use gitcomet_core::services::{CommandOutput, ResetMode, Result}; +use gitcomet_core::services::{ + CommandOutput, InteractiveRebaseAction, InteractiveRebaseEntry, ResetMode, Result, +}; +use std::fs; +use std::path::PathBuf; +use std::process::Command; /// Returns the HEAD commit id, or `None` when HEAD is unborn / empty. pub(super) fn gix_head_id_or_none(repo: &gix::Repository) -> Result> { @@ -13,6 +22,114 @@ pub(super) fn gix_head_id_or_none(repo: &gix::Repository) -> Result(repo: &'r gix::Repository, spec: &str) -> Result> { + repo.rev_parse_single(spec) + .map_err(|e| Error::new(ErrorKind::Backend(format!("gix rev-parse {spec}: {e}"))))? + .object() + .map_err(|e| Error::new(ErrorKind::Backend(format!("gix commit object {spec}: {e}"))))? + .peel_to_commit() + .map_err(|e| Error::new(ErrorKind::Backend(format!("gix peel commit {spec}: {e}")))) +} + +/// Walks first parents from `head` down to `oldest` (inclusive), requiring a +/// strictly linear chain: every commit in the range, including `oldest`, must +/// have exactly one parent. Returns the chain (youngest first, hex ids) and +/// `oldest`'s parent id. +fn first_parent_chain_to( + repo: &gix::Repository, + head: &CommitId, + oldest: &CommitId, +) -> Result<(Vec, String)> { + let oldest_hex = oldest.as_ref().to_ascii_lowercase(); + let mut current = head.as_ref().to_ascii_lowercase(); + let mut chain = Vec::new(); + + loop { + let commit = peel_commit(repo, ¤t)?; + let mut parents = commit.parent_ids(); + let (Some(parent), None) = (parents.next(), parents.next()) else { + return Err(Error::new(ErrorKind::Backend(format!( + "squash: commit {current} does not have exactly one parent" + )))); + }; + let parent = parent.detach().to_string(); + chain.push(current.clone()); + + if current == oldest_hex { + return Ok((chain, parent)); + } + if chain.len() >= MAX_SQUASH_CHAIN { + return Err(Error::new(ErrorKind::Backend(format!( + "squash: {oldest_hex} is not a first-parent ancestor of {}", + head.as_ref() + )))); + } + current = parent; + } +} + +/// The oldest squashed commit's author, formatted for `GIT_AUTHOR_*` env vars +/// (`GIT_AUTHOR_DATE` in git's raw ` <±HHMM>` format). +fn commit_author_env(repo: &gix::Repository, spec: &str) -> Result<(String, String, String)> { + let commit = peel_commit(repo, spec)?; + let author = commit + .author() + .map_err(|e| Error::new(ErrorKind::Backend(format!("gix author {spec}: {e}"))))?; + let time = author + .time() + .map_err(|e| Error::new(ErrorKind::Backend(format!("gix author time {spec}: {e}"))))?; + let sign = if time.offset < 0 { '-' } else { '+' }; + let offset_abs = time.offset.unsigned_abs(); + let date = format!( + "{} {}{:02}{:02}", + time.seconds, + sign, + offset_abs / 3600, + (offset_abs % 3600) / 60 + ); + Ok((author.name.to_string(), author.email.to_string(), date)) +} + +fn commit_message(repo: &gix::Repository, spec: &str) -> Result { + let commit = peel_commit(repo, spec)?; + Ok( + bytes_to_text_preserving_utf8(commit.message_raw_sloppy().as_ref()) + .trim_end() + .to_string(), + ) +} + +fn append_command_output(acc: &mut CommandOutput, output: CommandOutput) { + if !acc.stdout.is_empty() && !output.stdout.is_empty() { + acc.stdout.push('\n'); + } + acc.stdout.push_str(&output.stdout); + if !acc.stderr.is_empty() && !output.stderr.is_empty() { + acc.stderr.push('\n'); + } + acc.stderr.push_str(&output.stderr); + acc.exit_code = output.exit_code; +} + +const CHERRY_PICK_ALREADY_APPLIED_SENTINEL: &str = "GITCOMET_CHERRY_PICK_ALREADY_APPLIED"; + +fn is_empty_cherry_pick_output(output: &std::process::Output) -> bool { + let combined = format!( + "{}\n{}", + bytes_to_text_preserving_utf8(&output.stdout), + bytes_to_text_preserving_utf8(&output.stderr) + ) + .to_ascii_lowercase(); + + (combined.contains("previous cherry-pick is now empty") + || combined.contains("cherry-pick is now empty")) + && combined.contains("nothing to commit") +} + impl GixRepo { pub(super) fn reset_with_output_impl( &self, @@ -33,6 +150,108 @@ impl GixRepo { run_git_with_output(cmd, &label) } + pub(super) fn squash_message_preview_impl( + &self, + oldest: &CommitId, + head: &CommitId, + ) -> Result { + validate_hex_commit_id(oldest)?; + validate_hex_commit_id(head)?; + + let repo = self._repo.to_thread_local(); + let (chain, _oldest_parent) = first_parent_chain_to(&repo, head, oldest)?; + let mut messages = Vec::with_capacity(chain.len()); + for spec in &chain { + let commit = peel_commit(&repo, spec)?; + messages.push( + bytes_to_text_preserving_utf8(commit.message_raw_sloppy().as_ref()) + .trim_end() + .to_string(), + ); + } + messages.reverse(); + Ok(gitcomet_core::squash::build_squash_message(&messages)) + } + + pub(super) fn squash_commits_with_output_impl( + &self, + oldest: &CommitId, + expected_head: &CommitId, + message: &str, + ) -> Result { + validate_hex_commit_id(oldest)?; + validate_hex_commit_id(expected_head)?; + if message.trim().is_empty() { + return Err(Error::new(ErrorKind::Backend( + "squash: commit message must not be empty".to_string(), + ))); + } + + // Re-validate against live repo state: the selection was made from a + // possibly stale log snapshot. + let repo = self.reopen_repo()?; + let head = gix_head_id_or_none(&repo)? + .ok_or_else(|| Error::new(ErrorKind::Backend("squash: HEAD is unborn".to_string())))?; + if !head + .to_string() + .eq_ignore_ascii_case(expected_head.as_ref()) + { + return Err(Error::new(ErrorKind::Backend( + "squash aborted: HEAD moved since the squash was prepared".to_string(), + ))); + } + let (chain, oldest_parent) = first_parent_chain_to(&repo, expected_head, oldest)?; + let count = chain.len(); + if count < 2 { + return Err(Error::new(ErrorKind::Backend( + "squash: needs at least two commits".to_string(), + ))); + } + + let (author_name, author_email, author_date) = commit_author_env(&repo, oldest.as_ref())?; + + // Respect commit.gpgsign: `git commit-tree` never signs unless asked, + // so without this a signed-commit repo would get an unsigned squash + // commit and later have the push rejected. + let sign = repo + .config_snapshot() + .boolean("commit.gpgsign") + .unwrap_or(false); + + // The squash commit reuses HEAD's tree with the range's base as its + // parent, so the worktree and index are never touched. + let mut cmd = self.git_workdir_cmd(); + cmd.arg("commit-tree"); + if sign { + cmd.arg("-S"); + } + cmd.arg(format!("{}^{{tree}}", expected_head.as_ref())) + .arg("-p") + .arg(&oldest_parent) + .arg("-m") + .arg(message) + .env("GIT_AUTHOR_NAME", author_name) + .env("GIT_AUTHOR_EMAIL", author_email) + .env("GIT_AUTHOR_DATE", author_date); + let new_sha = run_git_capture(cmd, "git commit-tree")?.trim().to_string(); + if new_sha.is_empty() { + return Err(Error::new(ErrorKind::Backend( + "squash: git commit-tree produced no commit id".to_string(), + ))); + } + + // Atomic compare-and-swap on HEAD (dereferences to the branch ref when + // attached): fails without side effects if HEAD moved concurrently. + let mut cmd = self.git_workdir_cmd(); + cmd.arg("update-ref") + .arg("-m") + .arg(format!("squash: {count} commits")) + .arg("HEAD") + .arg(&new_sha) + .arg(expected_head.as_ref()); + run_git_with_output(cmd, &format!("git update-ref HEAD {new_sha}")) + } + pub(super) fn rebase_with_output_impl(&self, onto: &str) -> Result { validate_ref_like_arg(onto, "rebase target")?; @@ -41,10 +260,102 @@ impl GixRepo { run_git_with_output(cmd, &format!("git rebase {onto}")) } + pub(super) fn cherry_pick_with_output_impl( + &self, + id: &CommitId, + commit: bool, + ) -> Result { + validate_hex_commit_id(id)?; + + let mut cmd = self.git_workdir_cmd(); + cmd.arg("cherry-pick"); + if !commit { + cmd.arg("--no-commit"); + } + cmd.arg("--").arg(id.as_ref()); + let label = if commit { + format!("git cherry-pick {}", id.as_ref()) + } else { + format!("git cherry-pick --no-commit {}", id.as_ref()) + }; + + let output = run_git_raw_output(cmd, &label) + .map_err(|e| Error::new(ErrorKind::Backend(format!("failed to run {label}: {e}"))))?; + if output.status.success() { + return Ok(CommandOutput { + command: label, + stdout: bytes_to_text_preserving_utf8(&output.stdout), + stderr: bytes_to_text_preserving_utf8(&output.stderr), + exit_code: output.status.code(), + }); + } + + if is_empty_cherry_pick_output(&output) { + if self.rebase_in_progress_impl()? { + let mut abort = self.git_workdir_cmd(); + abort.arg("cherry-pick").arg("--abort"); + run_git_with_output(abort, "git cherry-pick --abort")?; + } + return Ok(CommandOutput { + command: label, + stdout: CHERRY_PICK_ALREADY_APPLIED_SENTINEL.to_string(), + stderr: bytes_to_text_preserving_utf8(&output.stderr), + exit_code: Some(0), + }); + } + + Err(git_command_failed_error(&label, output)) + } + pub(super) fn rebase_continue_with_output_impl(&self) -> Result { let mut cmd = self.git_workdir_cmd(); cmd.arg("rebase").arg("--continue"); - run_git_with_output(cmd, "git rebase --continue") + match self.run_rebase_step_output(cmd, "git rebase --continue") { + Ok(output) => Ok(output), + Err(rebase_error) => { + let mut cherry_pick_cmd = self.git_workdir_cmd(); + cherry_pick_cmd.arg("cherry-pick").arg("--continue"); + match self + .run_cherry_pick_step_output(cherry_pick_cmd, "git cherry-pick --continue") + { + Ok(output) => Ok(output), + Err(_) => Err(rebase_error), + } + } + } + } + + /// Run a rebase step (`rebase -i` / `rebase --continue`). A non-zero exit + /// that leaves the rebase *still in progress* means git paused at the next + /// conflict — a normal outcome, not a failure. Report it as success (with + /// the captured output) so the UI treats it as "paused at conflict": it + /// clears the loading state, reloads status, and surfaces the new conflict, + /// instead of showing a hard error and getting stuck on a spinner. + fn run_rebase_step_output(&self, cmd: Command, label: &str) -> Result { + self.run_sequence_step_output(cmd, label, Self::rebase_state_in_progress_impl) + } + + fn run_cherry_pick_step_output(&self, cmd: Command, label: &str) -> Result { + self.run_sequence_step_output(cmd, label, Self::cherry_pick_in_progress_impl) + } + + fn run_sequence_step_output( + &self, + cmd: Command, + label: &str, + in_progress_after_failure: fn(&Self) -> Result, + ) -> Result { + let output = run_git_raw_output(cmd, label)?; + if output.status.success() || in_progress_after_failure(self)? { + Ok(CommandOutput { + command: label.to_string(), + stdout: bytes_to_text_preserving_utf8(&output.stdout), + stderr: bytes_to_text_preserving_utf8(&output.stderr), + exit_code: output.status.code(), + }) + } else { + Err(git_command_failed_error(label, output)) + } } pub(super) fn rebase_abort_with_output_impl(&self) -> Result { @@ -53,6 +364,12 @@ impl GixRepo { match run_git_with_output(cmd, "git rebase --abort") { Ok(output) => Ok(output), Err(rebase_error) => { + let mut cherry_pick_cmd = self.git_workdir_cmd(); + cherry_pick_cmd.arg("cherry-pick").arg("--abort"); + if let Ok(output) = run_git_with_output(cherry_pick_cmd, "git cherry-pick --abort") + { + return Ok(output); + } // `git am` uses its own sequencer state. Falling back here allows a // single "abort in-progress operation" UI action to handle both rebase // and patch-apply flows. @@ -73,6 +390,21 @@ impl GixRepo { } pub(super) fn rebase_in_progress_impl(&self) -> Result { + let repo = self._repo.to_thread_local(); + Ok(matches!( + repo.state(), + Some( + gix::state::InProgress::Rebase + | gix::state::InProgress::RebaseInteractive + | gix::state::InProgress::ApplyMailbox + | gix::state::InProgress::ApplyMailboxRebase + | gix::state::InProgress::CherryPick + | gix::state::InProgress::CherryPickSequence + ) + )) + } + + fn rebase_state_in_progress_impl(&self) -> Result { let repo = self._repo.to_thread_local(); Ok(matches!( repo.state(), @@ -85,6 +417,221 @@ impl GixRepo { )) } + fn cherry_pick_in_progress_impl(&self) -> Result { + let repo = self._repo.to_thread_local(); + Ok(matches!( + repo.state(), + Some(gix::state::InProgress::CherryPick | gix::state::InProgress::CherryPickSequence) + )) + } + + pub(super) fn list_commits_for_interactive_rebase_impl( + &self, + base: &str, + ) -> Result> { + validate_ref_like_arg(base, "interactive rebase base")?; + + let range = format!("{base}..HEAD"); + let mut cmd = self.git_workdir_cmd(); + cmd.args(["log", "--format=%H %s", "--reverse", &range]); + let output = run_git_capture(cmd, &format!("git log {range}"))?; + + let entries = output + .lines() + .filter(|l| !l.trim().is_empty()) + .map(|line| { + let (sha, summary) = line.split_once(' ').unwrap_or((line, "")); + InteractiveRebaseEntry { + action: InteractiveRebaseAction::Pick, + commit_id: sha.to_string(), + summary: summary.to_string(), + new_message: None, + } + }) + .collect(); + + Ok(entries) + } + + pub(super) fn interactive_rebase_with_output_impl( + &self, + base: &str, + entries: &[InteractiveRebaseEntry], + ) -> Result { + validate_ref_like_arg(base, "interactive rebase base")?; + + let scripts = RebaseScripts::create(entries, self.git_dir_path()).map_err(|e| { + Error::new(ErrorKind::Backend(format!( + "failed to create rebase scripts: {e}" + ))) + })?; + + let mut cmd = self.git_workdir_cmd(); + cmd.env("GIT_SEQUENCE_EDITOR", &scripts.seq_editor_path); + if let Some(ref msg_editor) = scripts.msg_editor_path { + cmd.env("GIT_EDITOR", msg_editor); + cmd.env("GITCOMET_MSGS_DIR", &scripts.msgs_dir); + let repo = self._repo.to_thread_local(); + cmd.env("GITCOMET_GIT_DIR", repo.path()); + } + cmd.env("GITCOMET_TODO_FILE", &scripts.todo_path); + cmd.args(["rebase", "-i", "--", base]); + + let label = format!("git rebase -i {base}"); + self.run_rebase_step_output(cmd, &label) + } + + pub(super) fn interactive_cherry_pick_with_output_impl( + &self, + entries: &[InteractiveRebaseEntry], + ) -> Result { + if entries.is_empty() { + return Err(Error::new(ErrorKind::Backend( + "cherry-pick: no commits selected".to_string(), + ))); + } + for entry in entries { + validate_hex_commit_id(&CommitId(entry.commit_id.clone().into()))?; + if entry.action == InteractiveRebaseAction::Edit { + return Err(Error::new(ErrorKind::Backend( + "cherry-pick: edit action is not supported".to_string(), + ))); + } + } + + let pure_pick = entries + .iter() + .all(|entry| entry.action == InteractiveRebaseAction::Pick); + if pure_pick { + let mut cmd = self.git_workdir_cmd(); + cmd.arg("cherry-pick").arg("--no-edit").arg("--"); + for entry in entries { + cmd.arg(&entry.commit_id); + } + let label = format!("git cherry-pick {} commits", entries.len()); + return run_git_with_output(cmd, &label); + } + + let repo = self._repo.to_thread_local(); + let mut output = CommandOutput { + command: format!("git cherry-pick --interactive {} commits", entries.len()), + stdout: String::new(), + stderr: String::new(), + exit_code: Some(0), + }; + + let mut pending_messages: Vec = Vec::new(); + let mut pending_author: Option<(String, String, String)> = None; + + let commit_pending_group = |this: &Self, + output: &mut CommandOutput, + pending_messages: &mut Vec, + pending_author: &mut Option<(String, String, String)>| + -> Result<()> { + if pending_messages.is_empty() { + return Ok(()); + } + let message = if pending_messages.len() == 1 { + pending_messages[0].clone() + } else { + gitcomet_core::squash::build_squash_message(pending_messages) + }; + if message.trim().is_empty() { + return Err(Error::new(ErrorKind::Backend( + "cherry-pick: commit message must not be empty".to_string(), + ))); + } + let Some((author_name, author_email, author_date)) = pending_author.take() else { + return Err(Error::new(ErrorKind::Backend( + "cherry-pick: missing author for pending commit".to_string(), + ))); + }; + let mut cmd = this.git_workdir_cmd(); + cmd.arg("commit") + .arg("-m") + .arg(&message) + .env("GIT_AUTHOR_NAME", author_name) + .env("GIT_AUTHOR_EMAIL", author_email) + .env("GIT_AUTHOR_DATE", author_date); + let commit_output = run_git_with_output(cmd, "git commit")?; + append_command_output(output, commit_output); + pending_messages.clear(); + Ok(()) + }; + + for entry in entries { + match entry.action { + InteractiveRebaseAction::Drop => continue, + InteractiveRebaseAction::Pick | InteractiveRebaseAction::Reword => { + commit_pending_group( + self, + &mut output, + &mut pending_messages, + &mut pending_author, + )?; + let mut cmd = self.git_workdir_cmd(); + cmd.arg("cherry-pick") + .arg("--no-commit") + .arg("--") + .arg(&entry.commit_id); + let pick_output = run_git_with_output( + cmd, + &format!("git cherry-pick --no-commit {}", entry.commit_id), + )?; + append_command_output(&mut output, pick_output); + pending_author = Some(commit_author_env(&repo, &entry.commit_id)?); + let message = if entry.action == InteractiveRebaseAction::Reword { + entry + .new_message + .clone() + .filter(|message| !message.trim().is_empty()) + .ok_or_else(|| { + Error::new(ErrorKind::Backend( + "cherry-pick: reword message must not be empty".to_string(), + )) + })? + } else { + commit_message(&repo, &entry.commit_id)? + }; + pending_messages.push(message); + } + InteractiveRebaseAction::Squash | InteractiveRebaseAction::Fixup => { + if pending_messages.is_empty() { + return Err(Error::new(ErrorKind::Backend( + "cherry-pick: squash needs a previous picked commit".to_string(), + ))); + } + let mut cmd = self.git_workdir_cmd(); + cmd.arg("cherry-pick") + .arg("--no-commit") + .arg("--") + .arg(&entry.commit_id); + let pick_output = run_git_with_output( + cmd, + &format!("git cherry-pick --no-commit {}", entry.commit_id), + )?; + append_command_output(&mut output, pick_output); + if entry.action == InteractiveRebaseAction::Squash { + pending_messages.push(commit_message(&repo, &entry.commit_id)?); + } + } + InteractiveRebaseAction::Edit => unreachable!("validated above"), + } + } + commit_pending_group( + self, + &mut output, + &mut pending_messages, + &mut pending_author, + )?; + + Ok(output) + } + + fn git_dir_path(&self) -> PathBuf { + self._repo.to_thread_local().path().to_owned() + } + pub(super) fn merge_commit_message_impl(&self) -> Result> { let repo = self._repo.to_thread_local(); if repo.state() != Some(gix::state::InProgress::Merge) { @@ -124,3 +671,126 @@ impl GixRepo { } } } + +struct RebaseScripts { + _dir: tempfile::TempDir, + seq_editor_path: PathBuf, + todo_path: PathBuf, + msg_editor_path: Option, + msgs_dir: PathBuf, +} + +impl RebaseScripts { + fn create(entries: &[InteractiveRebaseEntry], git_dir: PathBuf) -> std::io::Result { + let dir = tempfile::tempdir()?; + + let todo_content = build_todo_content(entries); + let todo_path = dir.path().join("git-rebase-todo"); + fs::write(&todo_path, &todo_content)?; + + #[cfg(windows)] + let seq_editor_name = "gitcomet-seq-editor.cmd"; + #[cfg(not(windows))] + let seq_editor_name = "gitcomet-seq-editor.sh"; + let seq_editor_path = dir.path().join(seq_editor_name); + fs::write(&seq_editor_path, seq_editor_script_contents())?; + #[cfg(unix)] + make_executable(&seq_editor_path)?; + + let msgs_dir = dir.path().join("msgs"); + let mut msg_editor_path = None; + let has_reword = entries + .iter() + .any(|e| e.action == InteractiveRebaseAction::Reword); + if has_reword { + fs::create_dir_all(&msgs_dir)?; + for entry in entries { + if entry.action == InteractiveRebaseAction::Reword + && let Some(ref msg) = entry.new_message + { + let msg_file = msgs_dir.join(&entry.commit_id); + fs::write(msg_file, msg.as_bytes())?; + } + } + + #[cfg(windows)] + let msg_editor_name = "gitcomet-msg-editor.cmd"; + #[cfg(not(windows))] + let msg_editor_name = "gitcomet-msg-editor.sh"; + let path = dir.path().join(msg_editor_name); + let script = msg_editor_script_contents(&git_dir); + fs::write(&path, script.as_bytes())?; + #[cfg(unix)] + make_executable(&path)?; + msg_editor_path = Some(path); + } + + Ok(Self { + _dir: dir, + seq_editor_path, + todo_path, + msg_editor_path, + msgs_dir, + }) + } +} + +fn build_todo_content(entries: &[InteractiveRebaseEntry]) -> String { + entries + .iter() + .map(|e| { + let safe_summary = e.summary.replace('\n', " "); + format!( + "{} {} {}\n", + e.action.to_todo_str(), + e.commit_id, + safe_summary + ) + }) + .collect() +} + +#[cfg(unix)] +fn make_executable(path: &PathBuf) -> std::io::Result<()> { + use std::os::unix::fs::PermissionsExt as _; + let mut permissions = fs::metadata(path)?.permissions(); + permissions.set_mode(0o700); + fs::set_permissions(path, permissions) +} + +#[cfg(windows)] +fn seq_editor_script_contents() -> &'static [u8] { + b"@echo off\ncopy /Y \"%GITCOMET_TODO_FILE%\" \"%~1\" >nul\n" +} + +#[cfg(not(windows))] +fn seq_editor_script_contents() -> &'static [u8] { + b"#!/bin/sh\ncp \"$GITCOMET_TODO_FILE\" \"$1\"\n" +} + +#[cfg(windows)] +fn msg_editor_script_contents(_git_dir: &PathBuf) -> String { + r#"@echo off +set "REBASE_HEAD_FILE=%GITCOMET_GIT_DIR%\REBASE_HEAD" +if not exist "%REBASE_HEAD_FILE%" exit /b 0 +set /p sha=<"%REBASE_HEAD_FILE%" +set "msg_file=%GITCOMET_MSGS_DIR%\%sha%" +if not exist "%msg_file%" exit /b 0 +copy /Y "%msg_file%" "%~1" >nul +"# + .to_string() +} + +#[cfg(not(windows))] +fn msg_editor_script_contents(_git_dir: &PathBuf) -> String { + r#"#!/bin/sh +if [ -f "$GITCOMET_GIT_DIR/REBASE_HEAD" ]; then + sha=$(cat "$GITCOMET_GIT_DIR/REBASE_HEAD") + msg_file="$GITCOMET_MSGS_DIR/$sha" + if [ -f "$msg_file" ]; then + cp "$msg_file" "$1" + fi +fi +"# + .to_string() +} diff --git a/crates/gitcomet-git-gix/src/repo/mod.rs b/crates/gitcomet-git-gix/src/repo/mod.rs index fff6e2e7..8d94d7ba 100644 --- a/crates/gitcomet-git-gix/src/repo/mod.rs +++ b/crates/gitcomet-git-gix/src/repo/mod.rs @@ -10,8 +10,8 @@ use gitcomet_core::error::{Error, ErrorKind}; use gitcomet_core::git_ops_trace::{self, GitOpTraceKind}; use gitcomet_core::services::{ BlameLine, CancellationToken, CommandOutput, CommitOperationOutcome, ConflictFileStages, - ConflictSide, ForcePushLease, GitRepository, MergetoolResult, PullMode, RemoteUrlKind, - ResetMode, Result, SafePushAfterCommitContext, SafePushAfterCommitDecision, + ConflictSide, ForcePushLease, GitRepository, InteractiveRebaseEntry, MergetoolResult, PullMode, + RemoteUrlKind, ResetMode, Result, SafePushAfterCommitContext, SafePushAfterCommitDecision, SafePushAfterCommitTarget, SubmoduleTrustDecision, SubmoduleTrustTarget, }; use std::path::{Path, PathBuf}; @@ -448,6 +448,19 @@ impl GitRepository for GixRepo { self.squash_ref_with_output_impl(reference) } + fn squash_message_preview(&self, oldest: &CommitId, head: &CommitId) -> Result { + self.squash_message_preview_impl(oldest, head) + } + + fn squash_commits_with_output( + &self, + oldest: &CommitId, + expected_head: &CommitId, + message: &str, + ) -> Result { + self.squash_commits_with_output_impl(oldest, expected_head, message) + } + fn diff_unified(&self, target: &DiffTarget) -> Result { let _scope = git_ops_trace::scope(GitOpTraceKind::Diff); self.diff_unified_impl(target) @@ -519,6 +532,10 @@ impl GitRepository for GixRepo { self.cherry_pick_impl(id) } + fn cherry_pick_with_output(&self, id: &CommitId, commit: bool) -> Result { + self.cherry_pick_with_output_impl(id, commit) + } + fn revert(&self, id: &CommitId) -> Result<()> { self.revert_impl(id) } @@ -647,6 +664,28 @@ impl GitRepository for GixRepo { self.rebase_abort_with_output_impl() } + fn list_commits_for_interactive_rebase( + &self, + base: &str, + ) -> Result> { + self.list_commits_for_interactive_rebase_impl(base) + } + + fn interactive_rebase_with_output( + &self, + base: &str, + entries: &[InteractiveRebaseEntry], + ) -> Result { + self.interactive_rebase_with_output_impl(base, entries) + } + + fn interactive_cherry_pick_with_output( + &self, + entries: &[InteractiveRebaseEntry], + ) -> Result { + self.interactive_cherry_pick_with_output_impl(entries) + } + fn merge_abort_with_output(&self) -> Result { self.merge_abort_with_output_impl() } diff --git a/crates/gitcomet-git-gix/tests/cherry_pick_integration.rs b/crates/gitcomet-git-gix/tests/cherry_pick_integration.rs new file mode 100644 index 00000000..ae15612b --- /dev/null +++ b/crates/gitcomet-git-gix/tests/cherry_pick_integration.rs @@ -0,0 +1,199 @@ +use gitcomet_core::domain::CommitId; +use gitcomet_core::services::{GitBackend, GitRepository}; +use gitcomet_git_gix::GixBackend; +#[path = "support/test_git_env.rs"] +mod test_git_env; +use std::fs; +use std::path::Path; +use std::process::Command; +use std::sync::Arc; + +fn run_git(repo: &Path, args: &[&str]) { + let mut cmd = Command::new("git"); + test_git_env::apply(&mut cmd); + let status = cmd + .arg("-C") + .arg(repo) + .args(args) + .env("GIT_TERMINAL_PROMPT", "0") + .env("GIT_EDITOR", "true") + .env("EDITOR", "true") + .env("VISUAL", "true") + .status() + .expect("git command to run"); + assert!(status.success(), "git {:?} failed", args); +} + +fn git_output(repo: &Path, args: &[&str]) -> std::process::Output { + let mut cmd = Command::new("git"); + test_git_env::apply(&mut cmd); + cmd.arg("-C") + .arg(repo) + .args(args) + .env("GIT_TERMINAL_PROMPT", "0") + .env("GIT_EDITOR", "true") + .env("EDITOR", "true") + .env("VISUAL", "true") + .output() + .expect("git command to run") +} + +fn git_stdout(repo: &Path, args: &[&str]) -> String { + let output = git_output(repo, args); + assert!(output.status.success(), "git {:?} failed", args); + String::from_utf8(output.stdout).unwrap().trim().to_string() +} + +fn init_repo(repo: &Path) { + fs::create_dir_all(repo).expect("create repo directory"); + run_git(repo, &["init", "-b", "main"]); + run_git(repo, &["config", "user.email", "you@example.com"]); + run_git(repo, &["config", "user.name", "You"]); +} + +fn commit_file(repo: &Path, name: &str, content: &str, message: &str) -> String { + fs::write(repo.join(name), content).expect("write file"); + run_git(repo, &["add", "."]); + run_git( + repo, + &["-c", "commit.gpgsign=false", "commit", "-m", message], + ); + git_stdout(repo, &["rev-parse", "HEAD"]) +} + +fn commit_id(sha: &str) -> CommitId { + CommitId(sha.into()) +} + +fn open_backend(repo: &Path) -> Arc { + GixBackend.open(repo).expect("open repository") +} + +#[test] +fn single_cherry_pick_with_commit_creates_commit() { + let dir = tempfile::tempdir().expect("create tempdir"); + let repo = dir.path().join("repo"); + init_repo(&repo); + commit_file(&repo, "base.txt", "base\n", "base"); + run_git(&repo, &["checkout", "-b", "feature"]); + let picked = commit_file(&repo, "feature.txt", "feature\n", "feature change"); + run_git(&repo, &["checkout", "main"]); + commit_file(&repo, "main.txt", "main\n", "main change"); + let before_count = git_stdout(&repo, &["rev-list", "--count", "HEAD"]); + + let output = open_backend(&repo) + .cherry_pick_with_output(&commit_id(&picked), true) + .expect("cherry-pick"); + + assert_eq!(output.exit_code, Some(0)); + assert_eq!( + git_stdout(&repo, &["rev-list", "--count", "HEAD"]), + (before_count.parse::().unwrap() + 1).to_string() + ); + assert_eq!( + git_stdout(&repo, &["log", "-1", "--format=%s"]), + "feature change" + ); + assert_eq!( + fs::read_to_string(repo.join("feature.txt")).unwrap(), + "feature\n" + ); +} + +#[test] +fn single_cherry_pick_without_commit_applies_index_and_worktree_only() { + let dir = tempfile::tempdir().expect("create tempdir"); + let repo = dir.path().join("repo"); + init_repo(&repo); + commit_file(&repo, "base.txt", "base\n", "base"); + run_git(&repo, &["checkout", "-b", "feature"]); + let picked = commit_file(&repo, "feature.txt", "feature\n", "feature change"); + run_git(&repo, &["checkout", "main"]); + let before_head = git_stdout(&repo, &["rev-parse", "HEAD"]); + + let output = open_backend(&repo) + .cherry_pick_with_output(&commit_id(&picked), false) + .expect("cherry-pick --no-commit"); + + assert_eq!(output.exit_code, Some(0)); + assert_eq!(git_stdout(&repo, &["rev-parse", "HEAD"]), before_head); + assert_eq!( + git_stdout(&repo, &["status", "--porcelain"]), + "A feature.txt" + ); + assert_eq!( + fs::read_to_string(repo.join("feature.txt")).unwrap(), + "feature\n" + ); +} + +#[test] +fn already_applied_cherry_pick_is_successful_noop_and_cleans_state() { + let dir = tempfile::tempdir().expect("create tempdir"); + let repo = dir.path().join("repo"); + init_repo(&repo); + commit_file(&repo, "file.txt", "old\n", "base"); + run_git(&repo, &["checkout", "-b", "feature"]); + let picked = commit_file(&repo, "file.txt", "new\n", "same change"); + run_git(&repo, &["checkout", "main"]); + commit_file(&repo, "file.txt", "new\n", "same change independently"); + let before_head = git_stdout(&repo, &["rev-parse", "HEAD"]); + + let output = open_backend(&repo) + .cherry_pick_with_output(&commit_id(&picked), true) + .expect("already-applied cherry-pick"); + + assert_eq!(output.exit_code, Some(0)); + assert!( + output + .stdout + .contains("GITCOMET_CHERRY_PICK_ALREADY_APPLIED") + ); + assert_eq!(git_stdout(&repo, &["rev-parse", "HEAD"]), before_head); + assert_eq!(git_stdout(&repo, &["status", "--porcelain"]), ""); + assert!(!open_backend(&repo).rebase_in_progress().unwrap()); + assert!( + !git_output(&repo, &["rev-parse", "-q", "--verify", "CHERRY_PICK_HEAD"]) + .status + .success() + ); +} + +#[test] +fn continue_falls_back_to_cherry_pick_continue_when_cherry_pick_is_paused() { + let dir = tempfile::tempdir().expect("create tempdir"); + let repo = dir.path().join("repo"); + init_repo(&repo); + commit_file(&repo, "file.txt", "base\n", "base"); + + run_git(&repo, &["checkout", "-b", "feature"]); + let picked = commit_file(&repo, "file.txt", "feature\n", "feature change"); + + run_git(&repo, &["checkout", "main"]); + commit_file(&repo, "file.txt", "main\n", "main change"); + + let conflict = git_output(&repo, &["cherry-pick", &picked]); + assert!( + !conflict.status.success(), + "cherry-pick should pause at a conflict" + ); + assert!(open_backend(&repo).rebase_in_progress().unwrap()); + + fs::write(repo.join("file.txt"), "resolved\n").expect("resolve conflict"); + run_git(&repo, &["add", "file.txt"]); + + let output = open_backend(&repo) + .rebase_continue_with_output() + .expect("continue paused cherry-pick"); + assert_eq!(output.command, "git cherry-pick --continue"); + assert!(!open_backend(&repo).rebase_in_progress().unwrap()); + assert!( + !git_output(&repo, &["rev-parse", "-q", "--verify", "CHERRY_PICK_HEAD"]) + .status + .success() + ); + assert_eq!( + fs::read_to_string(repo.join("file.txt")).unwrap(), + "resolved\n" + ); +} diff --git a/crates/gitcomet-git-gix/tests/squash_integration.rs b/crates/gitcomet-git-gix/tests/squash_integration.rs new file mode 100644 index 00000000..f1ac303d --- /dev/null +++ b/crates/gitcomet-git-gix/tests/squash_integration.rs @@ -0,0 +1,335 @@ +use gitcomet_core::domain::CommitId; +use gitcomet_core::services::{GitBackend, GitRepository}; +use gitcomet_git_gix::GixBackend; +#[path = "support/test_git_env.rs"] +mod test_git_env; +use std::fs; +use std::path::Path; +use std::process::Command; +use std::sync::Arc; + +fn run_git(repo: &Path, args: &[&str]) { + run_git_with_env(repo, args, &[]); +} + +fn run_git_with_env(repo: &Path, args: &[&str], envs: &[(&str, &str)]) { + let mut cmd = Command::new("git"); + test_git_env::apply(&mut cmd); + let cmd = cmd + .arg("-C") + .arg(repo) + .args(args) + .env("GIT_TERMINAL_PROMPT", "0") + .env("GIT_EDITOR", "true") + .env("EDITOR", "true") + .env("VISUAL", "true"); + for (key, value) in envs { + cmd.env(key, value); + } + let status = cmd.status().expect("git command to run"); + assert!(status.success(), "git {:?} failed", args); +} + +fn git_stdout(repo: &Path, args: &[&str]) -> String { + let mut cmd = Command::new("git"); + test_git_env::apply(&mut cmd); + let output = cmd + .arg("-C") + .arg(repo) + .args(args) + .env("GIT_TERMINAL_PROMPT", "0") + .output() + .expect("git command to run"); + assert!(output.status.success(), "git {:?} failed", args); + String::from_utf8(output.stdout).unwrap().trim().to_string() +} + +fn init_repo(repo: &Path) { + fs::create_dir_all(repo).expect("create repo directory"); + run_git(repo, &["init"]); + run_git(repo, &["config", "user.email", "you@example.com"]); + run_git(repo, &["config", "user.name", "You"]); +} + +fn commit_file(repo: &Path, name: &str, content: &str, message: &str) { + commit_file_with_env(repo, name, content, message, &[]); +} + +fn commit_file_with_env( + repo: &Path, + name: &str, + content: &str, + message: &str, + envs: &[(&str, &str)], +) { + fs::write(repo.join(name), content).expect("write file"); + run_git(repo, &["add", "."]); + run_git_with_env( + repo, + &["-c", "commit.gpgsign=false", "commit", "-m", message], + envs, + ); +} + +fn rev_parse(repo: &Path, spec: &str) -> String { + git_stdout(repo, &["rev-parse", spec]) +} + +fn commit_id(sha: &str) -> CommitId { + CommitId(sha.into()) +} + +fn open_backend(repo: &Path) -> Arc { + GixBackend.open(repo).expect("open repository") +} + +/// root <- a <- b <- c <- d(HEAD); returns (root, a, b, c, d) shas. +fn linear_repo(repo: &Path) -> (String, String, String, String, String) { + init_repo(repo); + commit_file(repo, "file.txt", "root\n", "Root commit"); + let root = rev_parse(repo, "HEAD"); + commit_file(repo, "file.txt", "a\n", "Commit A"); + let a = rev_parse(repo, "HEAD"); + commit_file_with_env( + repo, + "file.txt", + "b\n", + "Commit B\n\nBody of B", + &[ + ("GIT_AUTHOR_NAME", "Oldest Author"), + ("GIT_AUTHOR_EMAIL", "oldest@example.com"), + ("GIT_AUTHOR_DATE", "1600000000 +0230"), + ], + ); + let b = rev_parse(repo, "HEAD"); + commit_file(repo, "file.txt", "c\n", "Commit C"); + let c = rev_parse(repo, "HEAD"); + commit_file(repo, "file.txt", "d\n", "Commit D"); + let d = rev_parse(repo, "HEAD"); + (root, a, b, c, d) +} + +#[test] +fn squash_replaces_linear_range_with_single_commit() { + let dir = tempfile::tempdir().expect("create tempdir"); + let repo = dir.path().join("repo"); + let (_root, a, b, _c, d) = linear_repo(&repo); + let tree_before = rev_parse(&repo, "HEAD^{tree}"); + + let backend = open_backend(&repo); + backend + .squash_commits_with_output( + &commit_id(&b), + &commit_id(&d), + "Squashed message\n\nDetails", + ) + .expect("squash commits"); + + let head = rev_parse(&repo, "HEAD"); + assert_ne!(head, d, "HEAD must move to the squash commit"); + assert_eq!(rev_parse(&repo, "HEAD^{tree}"), tree_before); + assert_eq!(rev_parse(&repo, "HEAD^"), a); + assert_eq!( + git_stdout(&repo, &["log", "-1", "--format=%B"]), + "Squashed message\n\nDetails" + ); + // Author is preserved from the oldest squashed commit; committer is fresh. + assert_eq!( + git_stdout(&repo, &["log", "-1", "--format=%an|%ae|%ad", "--date=raw"]), + "Oldest Author|oldest@example.com|1600000000 +0230" + ); + assert_eq!(git_stdout(&repo, &["log", "-1", "--format=%cn"]), "You"); + // Exactly root, a, squash remain. + assert_eq!( + git_stdout(&repo, &["rev-list", "--count", "HEAD"]), + "3".to_string() + ); + // The reflog records the squash. + assert!( + git_stdout(&repo, &["reflog", "-1"]).contains("squash: 3 commits"), + "reflog should mention the squash" + ); +} + +#[test] +fn squash_leaves_dirty_worktree_and_index_untouched() { + let dir = tempfile::tempdir().expect("create tempdir"); + let repo = dir.path().join("repo"); + let (_root, _a, b, _c, d) = linear_repo(&repo); + + fs::write(repo.join("staged.txt"), "staged\n").expect("write staged file"); + run_git(&repo, &["add", "staged.txt"]); + fs::write(repo.join("file.txt"), "dirty\n").expect("dirty the worktree"); + + let backend = open_backend(&repo); + backend + .squash_commits_with_output(&commit_id(&b), &commit_id(&d), "Squash") + .expect("squash commits"); + + // Note: git_stdout trims the output, which strips porcelain's leading + // space from the first line; match without the column prefix. + let status = git_stdout(&repo, &["status", "--porcelain"]); + assert!( + status.contains("A staged.txt"), + "staged file kept: {status}" + ); + assert!(status.contains("M file.txt"), "dirty file kept: {status}"); + assert_eq!( + fs::read_to_string(repo.join("file.txt")).unwrap(), + "dirty\n" + ); +} + +#[test] +fn squash_works_on_detached_head_and_moves_branch_when_attached() { + let dir = tempfile::tempdir().expect("create tempdir"); + let repo = dir.path().join("repo"); + let (_root, _a, b, _c, d) = linear_repo(&repo); + let branch = git_stdout(&repo, &["branch", "--show-current"]); + + // Attached: the branch ref moves. + let backend = open_backend(&repo); + backend + .squash_commits_with_output(&commit_id(&b), &commit_id(&d), "Attached squash") + .expect("squash commits"); + let squashed_head = rev_parse(&repo, "HEAD"); + assert_eq!( + rev_parse(&repo, &format!("refs/heads/{branch}")), + squashed_head + ); + + // Detached: HEAD itself moves, the branch stays. + commit_file(&repo, "file.txt", "e\n", "Commit E"); + let e = rev_parse(&repo, "HEAD"); + commit_file(&repo, "file.txt", "f\n", "Commit F"); + let f = rev_parse(&repo, "HEAD"); + let branch_tip = rev_parse(&repo, &format!("refs/heads/{branch}")); + run_git(&repo, &["checkout", "--detach"]); + backend + .squash_commits_with_output(&commit_id(&e), &commit_id(&f), "Detached squash") + .expect("squash on detached HEAD"); + assert_eq!( + rev_parse(&repo, "HEAD^"), + squashed_head, + "squash parent is the pre-range tip" + ); + assert_eq!( + rev_parse(&repo, &format!("refs/heads/{branch}")), + branch_tip, + "branch must not move while detached" + ); +} + +#[test] +fn squash_with_stale_expected_head_fails_without_changing_refs() { + let dir = tempfile::tempdir().expect("create tempdir"); + let repo = dir.path().join("repo"); + let (_root, _a, b, c, d) = linear_repo(&repo); + + let backend = open_backend(&repo); + // Pretend the squash was prepared when `c` was HEAD. + let err = backend + .squash_commits_with_output(&commit_id(&b), &commit_id(&c), "Stale") + .expect_err("stale expected head must fail"); + assert!( + err.to_string().contains("HEAD moved"), + "unexpected error: {err}" + ); + assert_eq!(rev_parse(&repo, "HEAD"), d, "refs must be unchanged"); +} + +#[test] +fn squash_rejects_merge_commit_in_range() { + let dir = tempfile::tempdir().expect("create tempdir"); + let repo = dir.path().join("repo"); + init_repo(&repo); + commit_file(&repo, "base.txt", "base\n", "Base"); + let base = rev_parse(&repo, "HEAD"); + run_git(&repo, &["checkout", "-b", "feature"]); + commit_file(&repo, "feature.txt", "feature\n", "Feature"); + run_git(&repo, &["checkout", "-"]); + commit_file(&repo, "main.txt", "main\n", "Main work"); + let main_work = rev_parse(&repo, "HEAD"); + run_git( + &repo, + &[ + "-c", + "commit.gpgsign=false", + "merge", + "--no-ff", + "-m", + "Merge feature", + "feature", + ], + ); + let merge = rev_parse(&repo, "HEAD"); + + let backend = open_backend(&repo); + let err = backend + .squash_commits_with_output(&commit_id(&main_work), &commit_id(&merge), "Nope") + .expect_err("merge commit in range must fail"); + assert!( + err.to_string().contains("exactly one parent"), + "unexpected error: {err}" + ); + assert_eq!(rev_parse(&repo, "HEAD"), merge); + let _ = base; +} + +#[test] +fn squash_rejects_root_commit_as_oldest() { + let dir = tempfile::tempdir().expect("create tempdir"); + let repo = dir.path().join("repo"); + init_repo(&repo); + commit_file(&repo, "file.txt", "root\n", "Root"); + let root = rev_parse(&repo, "HEAD"); + commit_file(&repo, "file.txt", "next\n", "Next"); + let next = rev_parse(&repo, "HEAD"); + + let backend = open_backend(&repo); + let err = backend + .squash_commits_with_output(&commit_id(&root), &commit_id(&next), "Nope") + .expect_err("root commit as oldest must fail"); + assert!( + err.to_string().contains("exactly one parent"), + "unexpected error: {err}" + ); + assert_eq!(rev_parse(&repo, "HEAD"), next); +} + +#[test] +fn squash_rejects_non_ancestor_oldest() { + let dir = tempfile::tempdir().expect("create tempdir"); + let repo = dir.path().join("repo"); + init_repo(&repo); + commit_file(&repo, "file.txt", "root\n", "Root"); + run_git(&repo, &["checkout", "-b", "side"]); + commit_file(&repo, "side.txt", "side\n", "Side"); + let side = rev_parse(&repo, "HEAD"); + run_git(&repo, &["checkout", "-"]); + commit_file(&repo, "file.txt", "main\n", "Main"); + let head = rev_parse(&repo, "HEAD"); + + let backend = open_backend(&repo); + let err = backend + .squash_commits_with_output(&commit_id(&side), &commit_id(&head), "Nope") + .expect_err("non-ancestor oldest must fail"); + assert!( + err.to_string().contains("exactly one parent"), + "unexpected error: {err}" + ); + assert_eq!(rev_parse(&repo, "HEAD"), head); +} + +#[test] +fn squash_message_preview_combines_messages_oldest_first() { + let dir = tempfile::tempdir().expect("create tempdir"); + let repo = dir.path().join("repo"); + let (_root, _a, b, _c, d) = linear_repo(&repo); + + let backend = open_backend(&repo); + let preview = backend + .squash_message_preview(&commit_id(&b), &commit_id(&d)) + .expect("build message preview"); + assert_eq!(preview, "Commit B\n\nBody of B\n\nCommit C\n\nCommit D"); +} diff --git a/crates/gitcomet-git-gix/tests/status_integration.rs b/crates/gitcomet-git-gix/tests/status_integration.rs index 98bf298b..aaa1fa53 100644 --- a/crates/gitcomet-git-gix/tests/status_integration.rs +++ b/crates/gitcomet-git-gix/tests/status_integration.rs @@ -4,8 +4,8 @@ use gitcomet_core::domain::{ FileDiffText, FileDiffTextSource, FileStatusKind, }; use gitcomet_core::error::{Error, ErrorKind, GitFailureId}; -use gitcomet_core::services::ConflictSide; use gitcomet_core::services::GitBackend; +use gitcomet_core::services::{ConflictSide, InteractiveRebaseAction, InteractiveRebaseEntry}; use gitcomet_git_gix::GixBackend; use std::fs; use std::io::Write; @@ -5094,6 +5094,63 @@ fn rebase_continue_without_in_progress_rebase_returns_error() { assert!(opened.rebase_continue_with_output().is_err()); } +#[test] +fn rebase_continue_paused_at_next_conflict_is_ok() { + if !require_git_shell_for_status_integration_tests() { + return; + } + let dir = tempfile::tempdir().unwrap(); + let repo = dir.path(); + + run_git(repo, &["init"]); + run_git(repo, &["config", "user.email", "you@example.com"]); + run_git(repo, &["config", "user.name", "You"]); + run_git(repo, &["config", "commit.gpgsign", "false"]); + + write(repo, "f.txt", "v0\n"); + run_git(repo, &["add", "f.txt"]); + run_git(repo, &["commit", "-m", "base"]); + let default_branch = run_git_output(repo, &["rev-parse", "--abbrev-ref", "HEAD"]) + .trim() + .to_string(); + + // Two feature commits, each of which will conflict when rebased onto a + // divergent `onto` commit. + run_git(repo, &["checkout", "-b", "feature"]); + write(repo, "f.txt", "A\n"); + run_git(repo, &["commit", "-am", "A"]); + write(repo, "f.txt", "B\n"); + run_git(repo, &["commit", "-am", "B"]); + + run_git(repo, &["checkout", &default_branch]); + write(repo, "f.txt", "onto\n"); + run_git(repo, &["commit", "-am", "onto"]); + + // Start rebasing `feature` onto the divergent branch: pauses at A's conflict. + run_git(repo, &["checkout", "feature"]); + run_git_expect_failure(repo, &["rebase", &default_branch]); + + // Resolve the first conflict. + write(repo, "f.txt", "resolved-A\n"); + run_git(repo, &["add", "f.txt"]); + + let backend = GixBackend; + let opened = backend.open(repo).unwrap(); + + // Continuing applies B, which conflicts again. This pauses the rebase at the + // next conflict — a normal outcome, not a failure — so it must be Ok and the + // rebase must still be in progress (regression test for the stuck-spinner bug). + let result = opened.rebase_continue_with_output(); + assert!( + result.is_ok(), + "rebase --continue that pauses at the next conflict should be Ok, got {result:?}" + ); + assert!( + opened.rebase_in_progress().unwrap(), + "rebase should still be in progress after pausing at the next conflict" + ); +} + #[test] fn rebase_abort_falls_back_to_git_am_abort() { if !require_git_shell_for_status_integration_tests() { @@ -5959,6 +6016,68 @@ fn cherry_pick_applies_commit_onto_current_branch() { assert!(status.unstaged.is_empty()); } +#[test] +fn interactive_cherry_pick_applies_multiple_commits_in_order() { + if !require_git_shell_for_status_integration_tests() { + return; + } + let dir = tempfile::tempdir().unwrap(); + let repo = dir.path(); + + run_git(repo, &["init", "-b", "main"]); + run_git(repo, &["config", "user.email", "you@example.com"]); + run_git(repo, &["config", "user.name", "You"]); + run_git(repo, &["config", "commit.gpgsign", "false"]); + + write(repo, "base.txt", "base\n"); + run_git(repo, &["add", "base.txt"]); + run_git( + repo, + &["-c", "commit.gpgsign=false", "commit", "-m", "base"], + ); + + run_git(repo, &["checkout", "-b", "feature"]); + write(repo, "one.txt", "one\n"); + run_git(repo, &["add", "one.txt"]); + run_git( + repo, + &["-c", "commit.gpgsign=false", "commit", "-m", "feature one"], + ); + let one_sha = run_git_output(repo, &["rev-parse", "HEAD"]); + write(repo, "two.txt", "two\n"); + run_git(repo, &["add", "two.txt"]); + run_git( + repo, + &["-c", "commit.gpgsign=false", "commit", "-m", "feature two"], + ); + let two_sha = run_git_output(repo, &["rev-parse", "HEAD"]); + run_git(repo, &["checkout", "main"]); + + let backend = GixBackend; + let opened = backend.open(repo).unwrap(); + opened + .interactive_cherry_pick_with_output(&[ + InteractiveRebaseEntry { + action: InteractiveRebaseAction::Pick, + commit_id: one_sha, + summary: "feature one".to_string(), + new_message: None, + }, + InteractiveRebaseEntry { + action: InteractiveRebaseAction::Pick, + commit_id: two_sha, + summary: "feature two".to_string(), + new_message: None, + }, + ]) + .unwrap(); + + assert_eq!(fs::read_to_string(repo.join("one.txt")).unwrap(), "one\n"); + assert_eq!(fs::read_to_string(repo.join("two.txt")).unwrap(), "two\n"); + let subjects = run_git_output(repo, &["log", "--format=%s", "-2"]); + assert_eq!(subjects, "feature two\nfeature one"); +} + #[test] fn create_and_delete_local_tag() { if !require_git_shell_for_status_integration_tests() { diff --git a/crates/gitcomet-git/src/noop_backend.rs b/crates/gitcomet-git/src/noop_backend.rs index f4217062..46143079 100644 --- a/crates/gitcomet-git/src/noop_backend.rs +++ b/crates/gitcomet-git/src/noop_backend.rs @@ -250,6 +250,7 @@ mod tests { assert_unsupported(repo.delete_branch_force("feature")); assert_unsupported(repo.checkout_remote_branch("origin", "main", "feature")); assert_unsupported(repo.commit_amend("message")); + assert_unsupported(repo.cherry_pick_with_output(&commit, true)); assert_unsupported(repo.rebase_with_output("main")); assert_unsupported(repo.rebase_continue_with_output()); assert_unsupported(repo.rebase_abort_with_output()); diff --git a/crates/gitcomet-state/src/model.rs b/crates/gitcomet-state/src/model.rs index c3f8c70e..2e154ce4 100644 --- a/crates/gitcomet-state/src/model.rs +++ b/crates/gitcomet-state/src/model.rs @@ -7,7 +7,8 @@ use gitcomet_core::conflict_session::{ use gitcomet_core::domain::*; use gitcomet_core::process::GitRuntimeState; use gitcomet_core::services::{ - BlameLine, ForcePushLease, SafePushAfterCommitContext, SubmoduleTrustTarget, + BlameLine, ForcePushLease, InteractiveRebaseEntry, SafePushAfterCommitContext, + SubmoduleTrustTarget, }; use serde::{Deserialize, Serialize}; use std::collections::{HashSet, VecDeque}; @@ -654,6 +655,14 @@ pub struct HistoryState { pub selected_commit_rev: u64, pub commit_details: Loadable>, pub commit_details_rev: u64, + pub multi_selection: CommitMultiSelection, + pub squash_preview: Loadable, + pub squash_preview_rev: u64, + /// The `(oldest, head)` range whose message preview is currently being + /// loaded. Lets a returning preview result be accepted even if the squash + /// plan is transiently invalid (e.g. HEAD momentarily unresolved during a + /// concurrent reload), as long as the range still matches what was asked. + pub squash_preview_pending: Option<(CommitId, CommitId)>, } impl Default for HistoryState { @@ -673,10 +682,48 @@ impl Default for HistoryState { selected_commit_rev: 0, commit_details: Loadable::NotLoaded, commit_details_rev: 0, + multi_selection: CommitMultiSelection::default(), + squash_preview: Loadable::NotLoaded, + squash_preview_rev: 0, + squash_preview_pending: None, } } } +/// Multi-selected commits in the history view. `commits` always mirrors the +/// selection (a plain single-select stores one id here); only `len() > 1` +/// switches the UI into multi-selection presentation. The anchor is the +/// origin for shift-click ranges; `anchor_index`/`anchor_log_rev` are a +/// resolution hint trusted only while the log revision is unchanged. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct CommitMultiSelection { + pub commits: Vec, + pub anchor: Option, + pub anchor_index: Option, + pub anchor_log_rev: Option, +} + +impl CommitMultiSelection { + pub fn is_multi(&self) -> bool { + self.commits.len() > 1 + } + + pub fn contains(&self, id: &CommitId) -> bool { + self.commits.iter().any(|c| c == id) + } +} + +/// Backend-built default message for the squash confirmation prompt. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SquashPreview { + pub oldest: CommitId, + pub head: CommitId, + /// Single-line subject, split from the combined message by core. + pub subject: String, + /// Message body (everything after the subject line), possibly empty. + pub body: String, +} + #[derive(Clone, Debug)] pub struct DiffState { pub diff_target: Option, @@ -797,6 +844,18 @@ fn mix_status_cache_revs(values: [u64; 2]) -> u64 { acc } +#[derive(Clone, Debug)] +pub struct InteractiveRebaseSetup { + pub base: String, + pub entries: Loadable>, +} + +#[derive(Clone, Debug)] +pub struct InteractiveCherryPickSetup { + pub entries: Vec, + pub source_colors: Vec<(String, u8)>, +} + #[derive(Clone, Debug)] pub struct RepoState { pub id: RepoId, @@ -847,6 +906,8 @@ pub struct RepoState { pub recent_commit_messages_rev: u64, pub rebase_in_progress: Loadable, pub merge_commit_message: Loadable>, + pub interactive_rebase_setup: Option, + pub interactive_cherry_pick_setup: Option, pub merge_message_rev: u64, pub worktrees: Loadable>>, pub worktrees_rev: u64, @@ -934,6 +995,8 @@ impl RepoState { recent_commit_messages_rev: 0, rebase_in_progress: Loadable::NotLoaded, merge_commit_message: Loadable::NotLoaded, + interactive_rebase_setup: None, + interactive_cherry_pick_setup: None, merge_message_rev: 0, worktrees: Loadable::NotLoaded, worktrees_rev: 0, @@ -1293,11 +1356,45 @@ impl RepoState { } pub(crate) fn set_selected_commit(&mut self, v: Option) { + if v.is_none() { + // Clearing the selection always dissolves any multi-selection too; + // every clear site (scope change, repo switch, diff selection) + // relies on this. + self.history_state.multi_selection = CommitMultiSelection::default(); + } self.history_state.selected_commit = v; self.history_state.selected_commit_rev = self.history_state.selected_commit_rev.wrapping_add(1); } + pub(crate) fn set_commit_multi_selection(&mut self, v: CommitMultiSelection) { + if self.history_state.multi_selection == v { + return; + } + self.history_state.multi_selection = v; + self.history_state.selected_commit_rev = + self.history_state.selected_commit_rev.wrapping_add(1); + } + + pub(crate) fn set_squash_preview(&mut self, v: Loadable) { + self.history_state.squash_preview = v; + self.history_state.squash_preview_rev = + self.history_state.squash_preview_rev.wrapping_add(1); + } + + /// Resolves the commit HEAD points at: the current branch's target when + /// attached, else the detached HEAD commit. + pub fn head_commit_id(&self) -> Option { + if let Loadable::Ready(head_branch) = &self.head_branch + && head_branch != "HEAD" + && let Loadable::Ready(branches) = &self.branches + && let Some(branch) = branches.iter().find(|b| b.name == *head_branch) + { + return Some(branch.target.clone()); + } + self.detached_head_commit.clone() + } + pub(crate) fn set_commit_details(&mut self, v: Loadable>) { self.history_state.commit_details = v; self.history_state.commit_details_rev = diff --git a/crates/gitcomet-state/src/msg.rs b/crates/gitcomet-state/src/msg.rs index fe117cea..bf408106 100644 --- a/crates/gitcomet-state/src/msg.rs +++ b/crates/gitcomet-state/src/msg.rs @@ -9,8 +9,9 @@ mod store_event; pub use effect::Effect; pub use message::{ - ConflictAutosolveMode, ConflictAutosolveStats, ConflictBulkChoice, ConflictRegionChoice, - ConflictRegionResolutionUpdate, InternalMsg, Msg, RepoActionKind, RepoWatchDegradedReason, + CommitSelectMode, ConflictAutosolveMode, ConflictAutosolveStats, ConflictBulkChoice, + ConflictRegionChoice, ConflictRegionResolutionUpdate, InternalMsg, Msg, RepoActionKind, + RepoWatchDegradedReason, }; pub use repo_command_kind::RepoCommandKind; pub use repo_external_change::RepoExternalChange; diff --git a/crates/gitcomet-state/src/msg/effect.rs b/crates/gitcomet-state/src/msg/effect.rs index cc0f1294..c325a3c6 100644 --- a/crates/gitcomet-state/src/msg/effect.rs +++ b/crates/gitcomet-state/src/msg/effect.rs @@ -2,8 +2,8 @@ use crate::model::{ConflictFileLoadMode, RepoId}; use gitcomet_core::auth::StagedGitAuth; use gitcomet_core::domain::*; use gitcomet_core::services::{ - ConflictSide, ForcePushLease, PullMode, RemoteUrlKind, ResetMode, SafePushAfterCommitContext, - SafePushAfterCommitTarget, SubmoduleTrustTarget, + ConflictSide, ForcePushLease, InteractiveRebaseEntry, PullMode, RemoteUrlKind, ResetMode, + SafePushAfterCommitContext, SafePushAfterCommitTarget, SubmoduleTrustTarget, }; use std::path::PathBuf; @@ -121,6 +121,23 @@ pub enum Effect { repo_id: RepoId, commit_id: CommitId, }, + LoadSquashMessagePreview { + repo_id: RepoId, + oldest: CommitId, + head: CommitId, + }, + LoadSquashRebaseSetup { + repo_id: RepoId, + base: CommitId, + /// The repo HEAD the plan was validated against. Re-checked once the + /// live `base..HEAD` list loads, so a HEAD move during the async gap + /// cancels the squash instead of rewriting an unintended range. + actual_head: CommitId, + selected_ids: Vec, + reword_id: CommitId, + message: String, + count: usize, + }, OpenFileAtCommitParent { repo_id: RepoId, commit_id: CommitId, @@ -205,6 +222,8 @@ pub enum Effect { CherryPickCommit { repo_id: RepoId, commit_id: CommitId, + commit: bool, + summary: String, }, RevertCommit { repo_id: RepoId, @@ -430,6 +449,13 @@ pub enum Effect { target: String, mode: ResetMode, }, + SquashCommits { + repo_id: RepoId, + oldest: CommitId, + expected_head: CommitId, + message: String, + count: usize, + }, Rebase { repo_id: RepoId, onto: String, @@ -440,6 +466,22 @@ pub enum Effect { RebaseAbort { repo_id: RepoId, }, + LoadInteractiveRebaseSetup { + repo_id: RepoId, + base: String, + }, + InteractiveRebase { + repo_id: RepoId, + base: String, + entries: Vec, + /// True for the user-opened editor; false for automated todo-list + /// rebases (e.g. squashing history without HEAD). + interactive: bool, + }, // entries held here so the effect dispatcher can pass them to the scheduler + InteractiveCherryPick { + repo_id: RepoId, + entries: Vec, + }, MergeAbort { repo_id: RepoId, }, diff --git a/crates/gitcomet-state/src/msg/message.rs b/crates/gitcomet-state/src/msg/message.rs index f4bb9078..e55425e5 100644 --- a/crates/gitcomet-state/src/msg/message.rs +++ b/crates/gitcomet-state/src/msg/message.rs @@ -7,9 +7,9 @@ use gitcomet_core::error::Error; use gitcomet_core::process::GitRuntimeState; use gitcomet_core::services::GitRepository; use gitcomet_core::services::{ - CommandOutput, CommitOperationOutcome, ConflictSide, ForcePushLease, PullMode, RemoteUrlKind, - ResetMode, SafePushAfterCommitContext, SafePushAfterCommitDecision, SafePushAfterCommitTarget, - SubmoduleTrustDecision, SubmoduleTrustTarget, + CommandOutput, CommitOperationOutcome, ConflictSide, ForcePushLease, InteractiveRebaseEntry, + PullMode, RemoteUrlKind, ResetMode, SafePushAfterCommitContext, SafePushAfterCommitDecision, + SafePushAfterCommitTarget, SubmoduleTrustDecision, SubmoduleTrustTarget, }; use std::path::PathBuf; use std::sync::Arc; @@ -41,6 +41,22 @@ pub enum RepoActionKind { DropStash, } +/// How a history-row click mutates the commit selection. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CommitSelectMode { + /// Plain click: collapse to the clicked commit. + Single, + /// Ctrl/Cmd click: add or remove the clicked commit. + Toggle, + /// Shift click: select the range between the anchor and the clicked commit. + Range, + /// Move focus to the clicked commit while preserving an existing + /// multi-selection that already contains it (used by right-click so the + /// details pane follows the menu target); collapses to the clicked commit + /// when it is not part of the selection. + PreserveIfSelected, +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ConflictAutosolveMode { Safe, @@ -178,6 +194,15 @@ pub enum Msg { repo_id: RepoId, commit_id: CommitId, }, + /// Modifier-aware history selection. `visible_order` (the visible commit + /// ids in log order) is only provided for `Range` clicks. + SelectCommitMulti { + repo_id: RepoId, + commit_id: CommitId, + mode: CommitSelectMode, + clicked_index: Option, + visible_order: Option>, + }, ClearCommitSelection { repo_id: RepoId, }, @@ -347,6 +372,8 @@ pub enum Msg { CherryPickCommit { repo_id: RepoId, commit_id: CommitId, + commit: bool, + summary: String, }, RevertCommit { repo_id: RepoId, @@ -552,6 +579,21 @@ pub enum Msg { target: String, mode: ResetMode, }, + /// Builds the squash message preview for the current multi-selection so + /// the squash prompt can prefill its message input. + PrepareSquash { + repo_id: RepoId, + }, + /// Squashes the linear range `oldest..=expected_head` into one commit. + /// The reducer re-validates the range against the current selection and + /// log before emitting the effect. + SquashCommits { + repo_id: RepoId, + oldest: CommitId, + expected_head: CommitId, + message: String, + count: usize, + }, Rebase { repo_id: RepoId, onto: String, @@ -562,6 +604,30 @@ pub enum Msg { RebaseAbort { repo_id: RepoId, }, + LoadInteractiveRebaseSetup { + repo_id: RepoId, + base: String, + }, + OpenInteractiveCherryPickSetup { + repo_id: RepoId, + entries: Vec, + source_colors: Vec<(String, u8)>, + }, + InteractiveRebase { + repo_id: RepoId, + base: String, + entries: Vec, + }, + InteractiveCherryPick { + repo_id: RepoId, + entries: Vec, + }, + CancelInteractiveRebaseSetup { + repo_id: RepoId, + }, + CancelInteractiveCherryPickSetup { + repo_id: RepoId, + }, MergeAbort { repo_id: RepoId, }, @@ -772,6 +838,11 @@ pub enum InternalMsg { repo_id: RepoId, result: Result, }, + InteractiveRebaseSetupLoaded { + repo_id: RepoId, + base: String, + result: Result, Error>, + }, MergeCommitMessageLoaded { repo_id: RepoId, result: Result, Error>, @@ -829,6 +900,22 @@ pub enum InternalMsg { commit_id: CommitId, result: Result, }, + SquashMessagePreviewLoaded { + repo_id: RepoId, + oldest: CommitId, + head: CommitId, + result: Result, + }, + SquashRebaseSetupLoaded { + repo_id: RepoId, + base: String, + actual_head: CommitId, + selected_ids: Vec, + reword_id: CommitId, + message: String, + count: usize, + result: Result, Error>, + }, DiffLoaded { repo_id: RepoId, target: DiffTarget, diff --git a/crates/gitcomet-state/src/msg/message_debug.rs b/crates/gitcomet-state/src/msg/message_debug.rs index 009320e5..084dd207 100644 --- a/crates/gitcomet-state/src/msg/message_debug.rs +++ b/crates/gitcomet-state/src/msg/message_debug.rs @@ -137,6 +137,16 @@ impl std::fmt::Debug for InternalMsg { .field("repo_id", repo_id) .field("result", result) .finish(), + InternalMsg::InteractiveRebaseSetupLoaded { + repo_id, + base, + result, + } => f + .debug_struct("InteractiveRebaseSetupLoaded") + .field("repo_id", repo_id) + .field("base", base) + .field("ok", &result.is_ok()) + .finish(), InternalMsg::MergeCommitMessageLoaded { repo_id, result } => f .debug_struct("MergeCommitMessageLoaded") .field("repo_id", repo_id) @@ -239,6 +249,36 @@ impl std::fmt::Debug for InternalMsg { .field("commit_id", commit_id) .field("result", result) .finish(), + InternalMsg::SquashMessagePreviewLoaded { + repo_id, + oldest, + head, + result, + } => f + .debug_struct("SquashMessagePreviewLoaded") + .field("repo_id", repo_id) + .field("oldest", oldest) + .field("head", head) + .field("result", result) + .finish(), + InternalMsg::SquashRebaseSetupLoaded { + repo_id, + base, + actual_head, + selected_ids, + reword_id, + count, + .. + } => f + .debug_struct("SquashRebaseSetupLoaded") + .field("repo_id", repo_id) + .field("base", &base) + .field("actual_head", &actual_head) + .field("selected_ids", &selected_ids) + .field("reword_id", &reword_id) + .field("count", &count) + .field("result", &"") + .finish(), InternalMsg::DiffLoaded { repo_id, target, diff --git a/crates/gitcomet-state/src/msg/repo_command_kind.rs b/crates/gitcomet-state/src/msg/repo_command_kind.rs index 4705a136..09c809fe 100644 --- a/crates/gitcomet-state/src/msg/repo_command_kind.rs +++ b/crates/gitcomet-state/src/msg/repo_command_kind.rs @@ -1,7 +1,7 @@ use gitcomet_core::domain::CommitId; use gitcomet_core::services::{ - ConflictSide, ForcePushLease, PullMode, RemoteUrlKind, ResetMode, SafePushAfterCommitTarget, - SubmoduleTrustTarget, + ConflictSide, ForcePushLease, InteractiveRebaseEntry, PullMode, RemoteUrlKind, ResetMode, + SafePushAfterCommitTarget, SubmoduleTrustTarget, }; use std::path::PathBuf; @@ -51,11 +51,32 @@ pub enum RepoCommandKind { mode: ResetMode, target: String, }, + SquashCommits { + oldest: CommitId, + expected_head: CommitId, + message: String, + count: usize, + }, Rebase { onto: String, }, RebaseContinue, RebaseAbort, + InteractiveRebase { + base: String, + /// True when the interactive-rebase editor was opened by the user; + /// false for automated todo-list rebases (e.g. squashing history that + /// doesn't include HEAD), which report as a plain "Rebase". + interactive: bool, + }, + InteractiveCherryPick { + entries: Vec, + }, + CherryPick { + commit_id: CommitId, + commit: bool, + summary: String, + }, MergeAbort, CreateTag { name: String, diff --git a/crates/gitcomet-state/src/store/effects.rs b/crates/gitcomet-state/src/store/effects.rs index c4c8a2f9..29710974 100644 --- a/crates/gitcomet-state/src/store/effects.rs +++ b/crates/gitcomet-state/src/store/effects.rs @@ -409,6 +409,38 @@ fn send_unavailable_git_effect_result( result: Err(git_unavailable_error(runtime)), }, )), + Effect::LoadSquashMessagePreview { + repo_id, + oldest, + head, + } => send(Msg::Internal( + crate::msg::InternalMsg::SquashMessagePreviewLoaded { + repo_id, + oldest, + head, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::LoadSquashRebaseSetup { + repo_id, + base, + actual_head, + selected_ids, + reword_id, + message, + count, + } => send(Msg::Internal( + crate::msg::InternalMsg::SquashRebaseSetupLoaded { + repo_id, + base: base.as_ref().to_string(), + actual_head, + selected_ids, + reword_id, + message, + count, + result: Err(git_unavailable_error(runtime)), + }, + )), Effect::OpenFileAtCommitParent { .. } | Effect::OpenFileAtCommit { .. } => { // No git backend available; nothing to resolve. } @@ -592,9 +624,22 @@ fn send_unavailable_git_effect_result( Effect::CheckoutCommit { repo_id, .. } => { send_repo_action_unavailable(repo_id, RepoActionKind::CheckoutCommit, runtime, &send) } - Effect::CherryPickCommit { repo_id, .. } => { - send_repo_action_unavailable(repo_id, RepoActionKind::CherryPickCommit, runtime, &send) - } + Effect::CherryPickCommit { + repo_id, + commit_id, + commit, + summary, + } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::CherryPick { + commit_id, + commit, + summary, + }, + result: Err(git_unavailable_error(runtime)), + }, + )), Effect::RevertCommit { repo_id, .. } => { send_repo_action_unavailable(repo_id, RepoActionKind::RevertCommit, runtime, &send) } @@ -964,6 +1009,24 @@ fn send_unavailable_git_effect_result( result: Err(git_unavailable_error(runtime)), }, )), + Effect::SquashCommits { + repo_id, + oldest, + expected_head, + message, + count, + } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::SquashCommits { + oldest, + expected_head, + message, + count, + }, + result: Err(git_unavailable_error(runtime)), + }, + )), Effect::Rebase { repo_id, onto } => send(Msg::Internal( crate::msg::InternalMsg::RepoCommandFinished { repo_id, @@ -985,6 +1048,32 @@ fn send_unavailable_git_effect_result( result: Err(git_unavailable_error(runtime)), }, )), + Effect::LoadInteractiveRebaseSetup { repo_id, base } => send(Msg::Internal( + crate::msg::InternalMsg::InteractiveRebaseSetupLoaded { + repo_id, + base, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::InteractiveRebase { + repo_id, + base, + entries: _, + interactive, + } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::InteractiveRebase { base, interactive }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::InteractiveCherryPick { repo_id, entries } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::InteractiveCherryPick { entries }, + result: Err(git_unavailable_error(runtime)), + }, + )), Effect::MergeAbort { repo_id } => send(Msg::Internal( crate::msg::InternalMsg::RepoCommandFinished { repo_id, @@ -1577,6 +1666,47 @@ pub(super) fn schedule_effect( ); } } + Effect::LoadSquashMessagePreview { + repo_id, + oldest, + head, + } => { + if let Some((msg_tx, _)) = + repo_load_context(thread_state, repo_task_tokens, msg_tx, repo_id) + { + repo_load::schedule_load_squash_message_preview( + executor, repos, msg_tx, repo_id, oldest, head, + ); + } + } + Effect::LoadSquashRebaseSetup { + repo_id, + base, + actual_head, + selected_ids, + reword_id, + message, + count, + } => { + if let Some((msg_tx, _)) = + repo_load_context(thread_state, repo_task_tokens, msg_tx, repo_id) + { + repo_load::schedule_load_squash_rebase_setup( + executor, + repos, + msg_tx, + repo_id, + repo_load::SquashRebaseSetupRequest { + base, + actual_head, + selected_ids, + reword_id, + message, + count, + }, + ); + } + } Effect::OpenFileAtCommitParent { repo_id, commit_id, @@ -1758,8 +1888,15 @@ pub(super) fn schedule_effect( Effect::CheckoutCommit { repo_id, commit_id } => { repo_actions::schedule_checkout_commit(executor, repos, msg_tx, repo_id, commit_id); } - Effect::CherryPickCommit { repo_id, commit_id } => { - repo_actions::schedule_cherry_pick_commit(executor, repos, msg_tx, repo_id, commit_id); + Effect::CherryPickCommit { + repo_id, + commit_id, + commit, + summary, + } => { + repo_commands::schedule_cherry_pick_commit( + executor, repos, msg_tx, repo_id, commit_id, commit, summary, + ); } Effect::RevertCommit { repo_id, commit_id } => { repo_actions::schedule_revert_commit(executor, repos, msg_tx, repo_id, commit_id); @@ -2047,6 +2184,22 @@ pub(super) fn schedule_effect( target, mode, } => repo_commands::schedule_reset(executor, repos, msg_tx, repo_id, target, mode), + Effect::SquashCommits { + repo_id, + oldest, + expected_head, + message, + count, + } => repo_commands::schedule_squash_commits( + executor, + repos, + msg_tx, + repo_id, + oldest, + expected_head, + message, + count, + ), Effect::Rebase { repo_id, onto } => { repo_commands::schedule_rebase(executor, repos, msg_tx, repo_id, onto) } @@ -2056,6 +2209,30 @@ pub(super) fn schedule_effect( Effect::RebaseAbort { repo_id } => { repo_commands::schedule_rebase_abort(executor, repos, msg_tx, repo_id) } + Effect::LoadInteractiveRebaseSetup { repo_id, base } => { + repo_load::schedule_load_interactive_rebase_setup( + executor, repos, msg_tx, repo_id, base, + ); + } + Effect::InteractiveRebase { + repo_id, + base, + entries, + interactive, + } => repo_commands::schedule_interactive_rebase( + executor, + repos, + msg_tx, + repo_id, + base, + entries, + interactive, + ), + Effect::InteractiveCherryPick { repo_id, entries } => { + repo_commands::schedule_interactive_cherry_pick( + executor, repos, msg_tx, repo_id, entries, + ) + } Effect::MergeAbort { repo_id } => { repo_commands::schedule_merge_abort(executor, repos, msg_tx, repo_id) } diff --git a/crates/gitcomet-state/src/store/effects/repo_actions.rs b/crates/gitcomet-state/src/store/effects/repo_actions.rs index 8b51b0be..7ea1d728 100644 --- a/crates/gitcomet-state/src/store/effects/repo_actions.rs +++ b/crates/gitcomet-state/src/store/effects/repo_actions.rs @@ -186,23 +186,6 @@ pub(super) fn schedule_checkout_commit( ); } -pub(super) fn schedule_cherry_pick_commit( - executor: &TaskExecutor, - repos: &RepoMap, - msg_tx: StoreWorkerSender, - repo_id: RepoId, - commit_id: gitcomet_core::domain::CommitId, -) { - schedule_repo_action( - executor, - repos, - msg_tx, - repo_id, - RepoActionKind::CherryPickCommit, - move |repo| repo.cherry_pick(&commit_id), - ); -} - pub(super) fn schedule_revert_commit( executor: &TaskExecutor, repos: &RepoMap, diff --git a/crates/gitcomet-state/src/store/effects/repo_commands.rs b/crates/gitcomet-state/src/store/effects/repo_commands.rs index d679f4ff..c90075c6 100644 --- a/crates/gitcomet-state/src/store/effects/repo_commands.rs +++ b/crates/gitcomet-state/src/store/effects/repo_commands.rs @@ -4,8 +4,9 @@ use gitcomet_core::auth::{ }; use gitcomet_core::error::{Error, ErrorKind}; use gitcomet_core::services::{ - CommandOutput, ConflictSide, ForcePushLease, GitRepository, PullMode, RemoteUrlKind, ResetMode, - SafePushAfterCommitContext, SafePushAfterCommitTarget, SubmoduleTrustTarget, + CommandOutput, ConflictSide, ForcePushLease, GitRepository, InteractiveRebaseEntry, PullMode, + RemoteUrlKind, ResetMode, SafePushAfterCommitContext, SafePushAfterCommitTarget, + SubmoduleTrustTarget, }; use std::path::{Component, Path, PathBuf}; use std::sync::Arc; @@ -857,6 +858,27 @@ pub(super) fn schedule_reset( ); } +pub(super) fn schedule_squash_commits( + executor: &TaskExecutor, + repos: &RepoMap, + msg_tx: StoreWorkerSender, + repo_id: RepoId, + oldest: gitcomet_core::domain::CommitId, + expected_head: gitcomet_core::domain::CommitId, + message: String, + count: usize, +) { + let command = RepoCommandKind::SquashCommits { + oldest: oldest.clone(), + expected_head: expected_head.clone(), + message: message.clone(), + count, + }; + schedule_repo_command(executor, repos, msg_tx, repo_id, command, move |repo| { + repo.squash_commits_with_output(&oldest, &expected_head, &message) + }); +} + pub(super) fn schedule_rebase( executor: &TaskExecutor, repos: &RepoMap, @@ -907,6 +929,73 @@ pub(super) fn schedule_rebase_abort( ); } +pub(super) fn schedule_interactive_rebase( + executor: &TaskExecutor, + repos: &RepoMap, + msg_tx: StoreWorkerSender, + repo_id: RepoId, + base: String, + entries: Vec, + interactive: bool, +) { + let base_for_cmd = base.clone(); + schedule_repo_command( + executor, + repos, + msg_tx, + repo_id, + RepoCommandKind::InteractiveRebase { + base: base_for_cmd, + interactive, + }, + move |repo| repo.interactive_rebase_with_output(&base, &entries), + ); +} + +pub(super) fn schedule_interactive_cherry_pick( + executor: &TaskExecutor, + repos: &RepoMap, + msg_tx: StoreWorkerSender, + repo_id: RepoId, + entries: Vec, +) { + let command_entries = entries.clone(); + schedule_repo_command( + executor, + repos, + msg_tx, + repo_id, + RepoCommandKind::InteractiveCherryPick { + entries: command_entries, + }, + move |repo| repo.interactive_cherry_pick_with_output(&entries), + ); +} + +pub(super) fn schedule_cherry_pick_commit( + executor: &TaskExecutor, + repos: &RepoMap, + msg_tx: StoreWorkerSender, + repo_id: RepoId, + commit_id: gitcomet_core::domain::CommitId, + commit: bool, + summary: String, +) { + let command_commit_id = commit_id.clone(); + schedule_repo_command( + executor, + repos, + msg_tx, + repo_id, + RepoCommandKind::CherryPick { + commit_id: command_commit_id, + commit, + summary, + }, + move |repo| repo.cherry_pick_with_output(&commit_id, commit), + ); +} + pub(super) fn schedule_merge_abort( 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 0e0d7f15..bf9aabee 100644 --- a/crates/gitcomet-state/src/store/effects/repo_load.rs +++ b/crates/gitcomet-state/src/store/effects/repo_load.rs @@ -1112,6 +1112,73 @@ pub(super) fn schedule_load_commit_details( }); } +pub(super) fn schedule_load_squash_message_preview( + executor: &TaskExecutor, + repos: &RepoMap, + msg_tx: StoreWorkerSender, + repo_id: RepoId, + oldest: gitcomet_core::domain::CommitId, + head: gitcomet_core::domain::CommitId, +) { + spawn_with_repo(executor, repos, repo_id, msg_tx, move |repo, msg_tx| { + send_or_log( + &msg_tx, + Msg::Internal(crate::msg::InternalMsg::SquashMessagePreviewLoaded { + repo_id, + oldest: oldest.clone(), + head: head.clone(), + result: repo.squash_message_preview(&oldest, &head), + }), + ); + }); +} + +/// Payload for scheduling a squash-via-rebase setup load. Bundled so the +/// scheduler stays within the argument-count budget and the fields travel +/// together into the resulting `SquashRebaseSetupLoaded` message. +pub(super) struct SquashRebaseSetupRequest { + pub base: gitcomet_core::domain::CommitId, + pub actual_head: gitcomet_core::domain::CommitId, + pub selected_ids: Vec, + pub reword_id: gitcomet_core::domain::CommitId, + pub message: String, + pub count: usize, +} + +pub(super) fn schedule_load_squash_rebase_setup( + executor: &TaskExecutor, + repos: &RepoMap, + msg_tx: StoreWorkerSender, + repo_id: RepoId, + request: SquashRebaseSetupRequest, +) { + let SquashRebaseSetupRequest { + base, + actual_head, + selected_ids, + reword_id, + message, + count, + } = request; + let base_str = base.as_ref().to_string(); + spawn_with_repo(executor, repos, repo_id, msg_tx, move |repo, msg_tx| { + let result = repo.list_commits_for_interactive_rebase(&base_str); + send_or_log( + &msg_tx, + Msg::Internal(crate::msg::InternalMsg::SquashRebaseSetupLoaded { + repo_id, + base: base_str, + actual_head, + selected_ids, + reword_id, + message, + count, + result, + }), + ); + }); +} + pub(super) fn schedule_open_file_at_commit( executor: &TaskExecutor, repos: &RepoMap, @@ -1545,3 +1612,42 @@ pub(super) fn schedule_load_selected_diff( ); } } + +pub(super) fn schedule_load_interactive_rebase_setup( + executor: &TaskExecutor, + repos: &RepoMap, + msg_tx: StoreWorkerSender, + repo_id: RepoId, + base: String, +) { + let base_for_call = base.clone(); + let base_for_err = base.clone(); + spawn_detached_with_repo_or_else( + executor, + "load-interactive-rebase-setup", + repos, + repo_id, + msg_tx, + move |repo, msg_tx| { + let result = repo.list_commits_for_interactive_rebase(&base_for_call); + send_or_log( + &msg_tx, + Msg::Internal(crate::msg::InternalMsg::InteractiveRebaseSetupLoaded { + repo_id, + base, + result, + }), + ); + }, + move |msg_tx| { + send_or_log( + &msg_tx, + Msg::Internal(crate::msg::InternalMsg::InteractiveRebaseSetupLoaded { + repo_id, + base: base_for_err, + result: Err(missing_repo_error(repo_id)), + }), + ); + }, + ); +} diff --git a/crates/gitcomet-state/src/store/reducer.rs b/crates/gitcomet-state/src/store/reducer.rs index 67a7bef4..2cb6541a 100644 --- a/crates/gitcomet-state/src/store/reducer.rs +++ b/crates/gitcomet-state/src/store/reducer.rs @@ -168,9 +168,13 @@ pub(crate) fn msg_requires_available_git(msg: &Msg) -> bool { | Msg::UnsetUpstreamBranch { .. } | Msg::DeleteRemoteBranch { .. } | Msg::Reset { .. } + | Msg::PrepareSquash { .. } + | Msg::SquashCommits { .. } | Msg::Rebase { .. } | Msg::RebaseContinue { .. } | Msg::RebaseAbort { .. } + | Msg::InteractiveRebase { .. } + | Msg::InteractiveCherryPick { .. } | Msg::MergeAbort { .. } | Msg::CreateTag { .. } | Msg::DeleteTag { .. } @@ -373,9 +377,34 @@ fn retry_msg_for_repo_command(repo_id: RepoId, command: RepoCommandKind) -> Opti target, mode, }, + RepoCommandKind::SquashCommits { + oldest, + expected_head, + message, + count, + } => Msg::SquashCommits { + repo_id, + oldest, + expected_head, + message, + count, + }, RepoCommandKind::Rebase { onto } => Msg::Rebase { repo_id, onto }, RepoCommandKind::RebaseContinue => Msg::RebaseContinue { repo_id }, RepoCommandKind::RebaseAbort => Msg::RebaseAbort { repo_id }, + RepoCommandKind::InteractiveCherryPick { entries } => { + Msg::InteractiveCherryPick { repo_id, entries } + } + RepoCommandKind::CherryPick { + commit_id, + commit, + summary, + } => Msg::CherryPickCommit { + repo_id, + commit_id, + commit, + summary, + }, RepoCommandKind::MergeAbort => Msg::MergeAbort { repo_id }, RepoCommandKind::CreateTag { name, @@ -473,7 +502,8 @@ fn retry_msg_for_repo_command(repo_id: RepoId, command: RepoCommandKind) -> Opti RepoCommandKind::SaveWorktreeFile { .. } | RepoCommandKind::StageHunk | RepoCommandKind::UnstageHunk - | RepoCommandKind::ApplyWorktreePatch { .. } => return None, + | RepoCommandKind::ApplyWorktreePatch { .. } + | RepoCommandKind::InteractiveRebase { .. } => return None, }) } @@ -830,6 +860,20 @@ fn reduce_inner( Msg::SelectCommit { repo_id, commit_id } => { effects::select_commit(state, repo_id, commit_id) } + Msg::SelectCommitMulti { + repo_id, + commit_id, + mode, + clicked_index, + visible_order, + } => effects::select_commit_multi( + state, + repo_id, + commit_id, + mode, + clicked_index, + visible_order, + ), Msg::ClearCommitSelection { repo_id } => effects::clear_commit_selection(state, repo_id), Msg::SelectDiff { repo_id, target } => diff_selection::select_diff(state, repo_id, target), Msg::OpenInlineSubmoduleDiff { @@ -979,9 +1023,14 @@ fn reduce_inner( begin_head_changing_local_action(state, repo_id); actions_emit_effects::checkout_commit(repo_id, commit_id) } - Msg::CherryPickCommit { repo_id, commit_id } => { + Msg::CherryPickCommit { + repo_id, + commit_id, + commit, + summary, + } => { begin_head_changing_local_action(state, repo_id); - actions_emit_effects::cherry_pick_commit(repo_id, commit_id) + actions_emit_effects::cherry_pick_commit(repo_id, commit_id, commit, summary) } Msg::RevertCommit { repo_id, commit_id } => { begin_head_changing_local_action(state, repo_id); @@ -1314,6 +1363,21 @@ fn reduce_inner( begin_local_action(state, repo_id); actions_emit_effects::reset(repo_id, target, mode) } + Msg::PrepareSquash { repo_id } => effects::prepare_squash(state, repo_id), + Msg::SquashCommits { + repo_id, + oldest, + expected_head, + message, + count, + } => actions_emit_effects::squash_commits( + state, + repo_id, + oldest, + expected_head, + message, + count, + ), Msg::Rebase { repo_id, onto } => { begin_local_action(state, repo_id); actions_emit_effects::rebase(repo_id, onto) @@ -1326,6 +1390,37 @@ fn reduce_inner( begin_local_action(state, repo_id); actions_emit_effects::rebase_abort(repo_id) } + Msg::LoadInteractiveRebaseSetup { repo_id, base } => { + actions_emit_effects::load_interactive_rebase_setup(state, repo_id, base) + } + Msg::OpenInteractiveCherryPickSetup { + repo_id, + entries, + source_colors, + } => actions_emit_effects::open_interactive_cherry_pick_setup( + state, + repo_id, + entries, + source_colors, + ), + Msg::InteractiveRebase { + repo_id, + base, + entries, + } => { + begin_local_action(state, repo_id); + actions_emit_effects::interactive_rebase(repo_id, base, entries) + } + Msg::InteractiveCherryPick { repo_id, entries } => { + begin_local_action(state, repo_id); + actions_emit_effects::interactive_cherry_pick(repo_id, entries) + } + Msg::CancelInteractiveRebaseSetup { repo_id } => { + actions_emit_effects::cancel_interactive_rebase_setup(state, repo_id) + } + Msg::CancelInteractiveCherryPickSetup { repo_id } => { + actions_emit_effects::cancel_interactive_cherry_pick_setup(state, repo_id) + } Msg::MergeAbort { repo_id } => { begin_local_action(state, repo_id); actions_emit_effects::merge_abort(repo_id) @@ -1556,6 +1651,11 @@ fn reduce_inner( Msg::Internal(crate::msg::InternalMsg::RebaseStateLoaded { repo_id, result }) => { external_and_history::rebase_state_loaded(state, repo_id, result) } + Msg::Internal(crate::msg::InternalMsg::InteractiveRebaseSetupLoaded { + repo_id, + base, + result, + }) => external_and_history::interactive_rebase_setup_loaded(state, repo_id, base, result), Msg::Internal(crate::msg::InternalMsg::MergeCommitMessageLoaded { repo_id, result }) => { external_and_history::merge_commit_message_loaded(state, repo_id, result) } @@ -1684,6 +1784,32 @@ fn reduce_inner( commit_id, result, }) => effects::commit_details_loaded(state, repo_id, commit_id, result), + Msg::Internal(crate::msg::InternalMsg::SquashMessagePreviewLoaded { + repo_id, + oldest, + head, + result, + }) => effects::squash_message_preview_loaded(state, repo_id, oldest, head, result), + Msg::Internal(crate::msg::InternalMsg::SquashRebaseSetupLoaded { + repo_id, + base, + actual_head, + selected_ids, + reword_id, + message, + count, + result, + }) => effects::squash_rebase_setup_loaded( + state, + repo_id, + base, + actual_head, + selected_ids, + reword_id, + message, + count, + result, + ), Msg::Internal(crate::msg::InternalMsg::RecentCommitMessagesLoaded { repo_id, request_rev, 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 49bc4aa5..2e399e1e 100644 --- a/crates/gitcomet-state/src/store/reducer/actions_emit_effects.rs +++ b/crates/gitcomet-state/src/store/reducer/actions_emit_effects.rs @@ -4,14 +4,18 @@ use super::util::{ refresh_full_effects, refresh_primary_effects, selected_conflict_target, selected_diff_load_plan, start_conflict_target_reload, start_current_conflict_target_reload, }; -use crate::model::{AppState, Loadable, RepoId, RepoLoadsInFlight, RepoState}; +use crate::model::{ + AppState, InteractiveCherryPickSetup, InteractiveRebaseSetup, Loadable, RepoId, + RepoLoadsInFlight, RepoState, +}; use crate::msg::{Effect, RepoCommandKind, RepoPathList}; use gitcomet_core::auth::StagedGitAuth; use gitcomet_core::conflict_session::{ConflictRegionResolution, ConflictResolverStrategy}; use gitcomet_core::domain::{DiffTarget, FileConflictKind}; use gitcomet_core::error::Error; use gitcomet_core::services::{ - CommandOutput, GitRepository, PullMode, RemoteUrlKind, ResetMode, SafePushAfterCommitTarget, + CommandOutput, GitRepository, InteractiveRebaseEntry, PullMode, RemoteUrlKind, ResetMode, + SafePushAfterCommitTarget, }; use rustc_hash::FxHashMap as HashMap; use std::path::PathBuf; @@ -45,8 +49,15 @@ pub(super) fn checkout_commit( pub(super) fn cherry_pick_commit( repo_id: RepoId, commit_id: gitcomet_core::domain::CommitId, + commit: bool, + summary: String, ) -> Vec { - vec![Effect::CherryPickCommit { repo_id, commit_id }] + vec![Effect::CherryPickCommit { + repo_id, + commit_id, + commit, + summary, + }] } pub(super) fn revert_commit( @@ -468,6 +479,60 @@ pub(super) fn reset(repo_id: RepoId, target: String, mode: ResetMode) -> Vec Vec { + // Re-validate against the current selection and log: both may have + // changed between opening the prompt and confirming. + let plan = state + .repos + .iter() + .find(|r| r.id == repo_id) + .and_then(super::effects::squash_plan_for_repo); + let still_valid = plan + .as_ref() + .is_some_and(|p| p.oldest == oldest && p.head == expected_head); + if !still_valid || message.trim().is_empty() { + super::util::push_notification( + state, + crate::model::AppNotificationKind::Warning, + "Squash cancelled: the selected commits are no longer squashable.".to_string(), + ); + return Vec::new(); + } + let plan = plan.unwrap(); + + // Range ends at HEAD: use the fast commit-tree + update-ref path that + // does not touch the worktree or index. + if plan.head == plan.actual_head { + super::begin_local_action(state, repo_id); + return vec![Effect::SquashCommits { + repo_id, + oldest, + expected_head, + message, + count, + }]; + } + + // Intermediate range: load the full commit list from base..HEAD so we + // can build a rebase todo that squashes only the selected commits. + vec![Effect::LoadSquashRebaseSetup { + repo_id, + base: plan.oldest_parent, + actual_head: plan.actual_head, + selected_ids: plan.ordered_ids, + reword_id: oldest, + message, + count, + }] +} + pub(super) fn rebase(repo_id: RepoId, onto: String) -> Vec { vec![Effect::Rebase { repo_id, onto }] } @@ -484,6 +549,76 @@ pub(super) fn merge_abort(repo_id: RepoId) -> Vec { vec![Effect::MergeAbort { repo_id }] } +pub(super) fn load_interactive_rebase_setup( + state: &mut AppState, + repo_id: RepoId, + base: String, +) -> Vec { + if let Some(repo_state) = state.repos.iter_mut().find(|r| r.id == repo_id) { + repo_state.interactive_rebase_setup = Some(InteractiveRebaseSetup { + base: base.clone(), + entries: Loadable::Loading, + }); + } + vec![Effect::LoadInteractiveRebaseSetup { repo_id, base }] +} + +pub(super) fn interactive_rebase( + repo_id: RepoId, + base: String, + entries: Vec, +) -> Vec { + vec![Effect::InteractiveRebase { + repo_id, + base, + entries, + interactive: true, + }] +} + +pub(super) fn open_interactive_cherry_pick_setup( + state: &mut AppState, + repo_id: RepoId, + entries: Vec, + source_colors: Vec<(String, u8)>, +) -> Vec { + if let Some(repo_state) = state.repos.iter_mut().find(|r| r.id == repo_id) { + repo_state.interactive_rebase_setup = None; + repo_state.interactive_cherry_pick_setup = Some(InteractiveCherryPickSetup { + entries, + source_colors, + }); + } + vec![] +} + +pub(super) fn interactive_cherry_pick( + repo_id: RepoId, + entries: Vec, +) -> Vec { + vec![Effect::InteractiveCherryPick { repo_id, entries }] +} + +pub(super) fn cancel_interactive_rebase_setup( + state: &mut AppState, + repo_id: RepoId, +) -> Vec { + if let Some(repo_state) = state.repos.iter_mut().find(|r| r.id == repo_id) { + repo_state.interactive_rebase_setup = None; + } + vec![] +} + +pub(super) fn cancel_interactive_cherry_pick_setup( + state: &mut AppState, + repo_id: RepoId, +) -> Vec { + if let Some(repo_state) = state.repos.iter_mut().find(|r| r.id == repo_id) { + repo_state.interactive_cherry_pick_setup = None; + } + vec![] +} + pub(super) fn create_tag( repo_id: RepoId, name: String, @@ -786,9 +921,13 @@ fn tracks_local_actions_in_flight(command: &RepoCommandKind) -> bool { RepoCommandKind::MergeRef { .. } | RepoCommandKind::SquashRef { .. } | RepoCommandKind::Reset { .. } + | RepoCommandKind::SquashCommits { .. } | RepoCommandKind::Rebase { .. } | RepoCommandKind::RebaseContinue | RepoCommandKind::RebaseAbort + | RepoCommandKind::InteractiveRebase { .. } + | RepoCommandKind::InteractiveCherryPick { .. } + | RepoCommandKind::CherryPick { .. } | RepoCommandKind::MergeAbort | RepoCommandKind::CreateTag { .. } | RepoCommandKind::DeleteTag { .. } @@ -831,6 +970,9 @@ fn command_clears_pending_force_push_lease(command: &RepoCommandKind) -> bool { | RepoCommandKind::Rebase { .. } | RepoCommandKind::RebaseContinue | RepoCommandKind::RebaseAbort + | RepoCommandKind::InteractiveRebase { .. } + | RepoCommandKind::InteractiveCherryPick { .. } + | RepoCommandKind::CherryPick { .. } | RepoCommandKind::MergeAbort ) } @@ -947,9 +1089,13 @@ pub(super) fn repo_command_finished( if matches!( &command, RepoCommandKind::Reset { .. } + | RepoCommandKind::SquashCommits { .. } | RepoCommandKind::Rebase { .. } | RepoCommandKind::RebaseContinue | RepoCommandKind::RebaseAbort + | RepoCommandKind::InteractiveRebase { .. } + | RepoCommandKind::InteractiveCherryPick { .. } + | RepoCommandKind::CherryPick { .. } | RepoCommandKind::MergeAbort ) { repo_state.set_diff_target(None); @@ -961,6 +1107,19 @@ pub(super) fn repo_command_finished( repo_state.diff_state.diff_file_image = Loadable::NotLoaded; repo_state.bump_diff_state_rev(); } + if matches!( + &command, + RepoCommandKind::SquashCommits { .. } + | RepoCommandKind::InteractiveRebase { .. } + | RepoCommandKind::InteractiveCherryPick { .. } + ) { + // The squashed/rebased commits may no longer exist; clear the + // selection and the prompt's preview. + repo_state.set_selected_commit(None); + repo_state.set_commit_details(Loadable::NotLoaded); + repo_state.history_state.squash_preview_pending = None; + repo_state.set_squash_preview(Loadable::NotLoaded); + } push_command_log(repo_state, true, &command, &output, None); } Err(e) => { diff --git a/crates/gitcomet-state/src/store/reducer/effects.rs b/crates/gitcomet-state/src/store/reducer/effects.rs index 11e7b8e7..631be9f5 100644 --- a/crates/gitcomet-state/src/store/reducer/effects.rs +++ b/crates/gitcomet-state/src/store/reducer/effects.rs @@ -1,12 +1,12 @@ use super::util::{ EffectAccumulator, apply_selected_diff_load_plan_state, diff_reload_effects, push_diagnostic, - selected_diff_load_plan, + push_notification, selected_diff_load_plan, }; use crate::model::{ - AppState, ConflictFileLoadMode, DiagnosticKind, Loadable, RepoId, RepoLoadsInFlight, RepoState, - SidebarDataRequest, SidebarMode, + AppNotificationKind, AppState, ConflictFileLoadMode, DiagnosticKind, Loadable, RepoId, + RepoLoadsInFlight, RepoState, SidebarDataRequest, SidebarMode, }; -use crate::msg::Effect; +use crate::msg::{CommitSelectMode, Effect}; use gitcomet_core::conflict_session::{ConflictPayload, ConflictSession}; use gitcomet_core::domain::{ Branch, CommitDetails, CommitId, FileEntry, FileSource, FileStatusKind, LogPage, @@ -14,6 +14,8 @@ use gitcomet_core::domain::{ Submodule, Tag, UpstreamDivergence, Worktree, }; use gitcomet_core::error::Error; +use gitcomet_core::services::{InteractiveRebaseAction, InteractiveRebaseEntry}; +use std::collections::HashSet; use std::path::PathBuf; use std::sync::Arc; @@ -212,11 +214,139 @@ pub(super) fn select_commit( state: &mut AppState, repo_id: RepoId, commit_id: CommitId, +) -> Vec { + select_commit_multi( + state, + repo_id, + commit_id, + CommitSelectMode::Single, + None, + None, + ) +} + +pub(super) fn select_commit_multi( + state: &mut AppState, + repo_id: RepoId, + commit_id: CommitId, + mode: CommitSelectMode, + clicked_index: Option, + visible_order: Option>, ) -> Vec { let Some(repo_state) = state.repos.iter_mut().find(|r| r.id == repo_id) else { return Vec::new(); }; + let log_rev = repo_state.history_state.log_rev; + let mut sel = repo_state.history_state.multi_selection.clone(); + + let focus = match mode { + CommitSelectMode::Single => { + collapse_multi_selection_to(&mut sel, commit_id.clone(), clicked_index, log_rev); + commit_id + } + CommitSelectMode::Toggle => { + if let Some(ix) = sel.commits.iter().position(|c| *c == commit_id) { + sel.commits.remove(ix); + let Some(focus) = sel.commits.last().cloned() else { + // Toggled the last commit away: clear the selection + // entirely (also dissolves the multi-selection). + repo_state.set_selected_commit(None); + repo_state.set_commit_details(Loadable::NotLoaded); + return Vec::new(); + }; + focus + } else { + sel.commits.push(commit_id.clone()); + sel.anchor = Some(commit_id.clone()); + sel.anchor_index = clicked_index; + sel.anchor_log_rev = Some(log_rev); + commit_id + } + } + CommitSelectMode::Range => { + let entries = visible_order.as_deref().unwrap_or(&[]); + let clicked_ix = commit_selection_entry_index(entries, &commit_id, clicked_index); + match clicked_ix { + None => { + collapse_multi_selection_to( + &mut sel, + commit_id.clone(), + clicked_index, + log_rev, + ); + } + Some(clicked_ix) => { + let anchor_ix = sel + .anchor + .as_ref() + .and_then(|anchor| { + let trusted_hint = sel + .anchor_index + .filter(|_| sel.anchor_log_rev == Some(log_rev)); + commit_selection_entry_index(entries, anchor, trusted_hint) + }) + .unwrap_or(clicked_ix); + let (a, b) = if anchor_ix <= clicked_ix { + (anchor_ix, clicked_ix) + } else { + (clicked_ix, anchor_ix) + }; + sel.commits = entries[a..=b].to_vec(); + if sel.anchor.is_none() { + sel.anchor = Some(commit_id.clone()); + } + sel.anchor_index = Some(anchor_ix); + sel.anchor_log_rev = Some(log_rev); + } + } + commit_id + } + CommitSelectMode::PreserveIfSelected => { + // Keep an existing multi-selection intact when the clicked commit + // is already part of it — only the focus moves. Otherwise collapse + // to the clicked commit like a plain click. + if !sel.commits.contains(&commit_id) { + collapse_multi_selection_to(&mut sel, commit_id.clone(), clicked_index, log_rev); + } + commit_id + } + }; + + repo_state.set_commit_multi_selection(sel); + select_commit_and_load_details(repo_state, repo_id, focus) +} + +fn collapse_multi_selection_to( + sel: &mut crate::model::CommitMultiSelection, + commit_id: CommitId, + clicked_index: Option, + log_rev: u64, +) { + sel.commits.clear(); + sel.commits.push(commit_id.clone()); + sel.anchor = Some(commit_id); + sel.anchor_index = clicked_index; + sel.anchor_log_rev = Some(log_rev); +} + +/// Resolves `target`'s index in `entries`, preferring the index hint when it +/// still points at the target. +fn commit_selection_entry_index( + entries: &[CommitId], + target: &CommitId, + index_hint: Option, +) -> Option { + index_hint + .filter(|&ix| entries.get(ix) == Some(target)) + .or_else(|| entries.iter().position(|id| id == target)) +} + +pub(super) fn select_commit_and_load_details( + repo_state: &mut RepoState, + repo_id: RepoId, + commit_id: CommitId, +) -> Vec { if repo_state.history_state.selected_commit.as_ref() == Some(&commit_id) { return Vec::new(); } @@ -1094,6 +1224,159 @@ pub(super) fn reflog_loaded( effects } +/// Validates the current multi-selection against the loaded log and HEAD. +/// This is the single reducer-side gate for every squash entry point. +pub(super) fn squash_plan_for_repo( + repo_state: &RepoState, +) -> Option { + let Loadable::Ready(page) = &repo_state.log else { + return None; + }; + let head = repo_state.head_commit_id()?; + gitcomet_core::squash::squash_eligibility( + &page.commits, + &repo_state.history_state.multi_selection.commits, + &head, + ) +} + +pub(super) fn prepare_squash(state: &mut AppState, repo_id: RepoId) -> Vec { + let Some(repo_state) = state.repos.iter_mut().find(|r| r.id == repo_id) else { + return Vec::new(); + }; + let Some(plan) = squash_plan_for_repo(repo_state) else { + repo_state.history_state.squash_preview_pending = None; + repo_state.set_squash_preview(Loadable::NotLoaded); + return Vec::new(); + }; + + repo_state.history_state.squash_preview_pending = + Some((plan.oldest.clone(), plan.head.clone())); + repo_state.set_squash_preview(Loadable::Loading); + vec![Effect::LoadSquashMessagePreview { + repo_id, + oldest: plan.oldest, + head: plan.head, + }] +} + +pub(super) fn squash_message_preview_loaded( + state: &mut AppState, + repo_id: RepoId, + oldest: CommitId, + head: CommitId, + result: std::result::Result, +) -> Vec { + if let Some(repo_state) = state.repos.iter_mut().find(|r| r.id == repo_id) { + // Accept the result only if it still matches the range we last asked + // for. Keying off the recorded request (not the live plan) means a + // transiently-invalid plan — e.g. HEAD momentarily unresolved during a + // concurrent reload — does not drop the result and strand the preview + // on Loading forever. + let matches_request = repo_state.history_state.squash_preview_pending.as_ref() + == Some(&(oldest.clone(), head.clone())); + if matches_request { + repo_state.history_state.squash_preview_pending = None; + let value = match result { + Ok(message) => { + let (subject, body) = gitcomet_core::squash::split_subject_body(&message); + Loadable::Ready(crate::model::SquashPreview { + oldest, + head, + subject, + body, + }) + } + Err(e) => { + push_diagnostic(repo_state, DiagnosticKind::Error, e.to_string()); + Loadable::Error(e.to_string()) + } + }; + repo_state.set_squash_preview(value); + } + } + Vec::new() +} + +pub(super) fn squash_rebase_setup_loaded( + state: &mut AppState, + repo_id: RepoId, + base: String, + actual_head: CommitId, + selected_ids: Vec, + reword_id: CommitId, + message: String, + count: usize, + result: std::result::Result, Error>, +) -> Vec { + let Some(repo_state) = state.repos.iter_mut().find(|r| r.id == repo_id) else { + return Vec::new(); + }; + + let entries = match result { + Ok(entries) => entries, + Err(e) => { + push_diagnostic(repo_state, DiagnosticKind::Error, e.to_string()); + push_notification( + state, + AppNotificationKind::Error, + format!("Failed to load commits for squash rebase: {e}"), + ); + return Vec::new(); + } + }; + + let selected_strs: HashSet<&str> = selected_ids.iter().map(|id| id.as_ref()).collect(); + + // The list loaded asynchronously, so re-validate it against the plan the + // user confirmed before rewriting history. `git log --reverse base..HEAD` + // yields commits oldest-first, so the last entry is the live HEAD. + let head_unchanged = entries + .last() + .is_some_and(|e| e.commit_id == actual_head.as_ref()); + + let mut matched = 0usize; + let mut reword_found = false; + let todo: Vec = entries + .into_iter() + .map(|mut entry| { + if entry.commit_id == reword_id.as_ref() { + entry.action = InteractiveRebaseAction::Reword; + entry.new_message = Some(message.clone()); + reword_found = true; + matched += 1; + } else if selected_strs.contains(entry.commit_id.as_str()) { + entry.action = InteractiveRebaseAction::Fixup; + matched += 1; + } + entry + }) + .collect(); + + // Every selected commit must appear exactly once in the live range and the + // oldest must have become the reword anchor; otherwise HEAD moved or the + // range drifted between confirmation and now, and rewriting would either be + // a silent no-op or touch the wrong commits. `matched == count` also + // catches a selection count that disagrees with what was actually planned. + if !head_unchanged || !reword_found || matched != count { + push_notification( + state, + AppNotificationKind::Warning, + "Squash cancelled: the selected commits are no longer squashable.".to_string(), + ); + return Vec::new(); + } + + super::begin_local_action(state, repo_id); + vec![Effect::InteractiveRebase { + repo_id, + base, + entries: todo, + // Automated squash rebase — no editor window; reports as "Rebase". + interactive: false, + }] +} + pub(super) fn commit_details_loaded( state: &mut AppState, repo_id: RepoId, @@ -1890,6 +2173,316 @@ mod tests { )); } + fn multi_selection( + state: &mut AppState, + repo_id: RepoId, + ) -> crate::model::CommitMultiSelection { + repo_mut(state, repo_id) + .history_state + .multi_selection + .clone() + } + + #[test] + fn toggle_click_adds_and_removes_commits() { + let repo_id = RepoId(1); + let mut state = new_state_with_repo(repo_id); + let a = CommitId("a".into()); + let b = CommitId("b".into()); + + select_commit(&mut state, repo_id, a.clone()); + select_commit_multi( + &mut state, + repo_id, + b.clone(), + CommitSelectMode::Toggle, + Some(1), + None, + ); + let sel = multi_selection(&mut state, repo_id); + assert_eq!(sel.commits, vec![a.clone(), b.clone()]); + assert_eq!(sel.anchor.as_ref(), Some(&b)); + assert_eq!( + repo_mut(&mut state, repo_id).history_state.selected_commit, + Some(b.clone()) + ); + + // Toggling a selected commit removes it; focus falls back to the last + // remaining commit. + select_commit_multi( + &mut state, + repo_id, + b.clone(), + CommitSelectMode::Toggle, + Some(1), + None, + ); + let sel = multi_selection(&mut state, repo_id); + assert_eq!(sel.commits, vec![a.clone()]); + assert_eq!( + repo_mut(&mut state, repo_id).history_state.selected_commit, + Some(a.clone()) + ); + + // Toggling the last commit away clears the whole selection. + select_commit_multi( + &mut state, + repo_id, + a, + CommitSelectMode::Toggle, + Some(0), + None, + ); + let repo = repo_mut(&mut state, repo_id); + assert!(repo.history_state.selected_commit.is_none()); + assert!(repo.history_state.multi_selection.commits.is_empty()); + } + + #[test] + fn preserve_if_selected_moves_focus_without_collapsing() { + let repo_id = RepoId(1); + let mut state = new_state_with_repo(repo_id); + let a = CommitId("a".into()); + let b = CommitId("b".into()); + let c = CommitId("c".into()); + + select_commit(&mut state, repo_id, a.clone()); + select_commit_multi( + &mut state, + repo_id, + b.clone(), + CommitSelectMode::Toggle, + Some(1), + None, + ); + assert_eq!( + repo_mut(&mut state, repo_id).history_state.selected_commit, + Some(b.clone()) + ); + + // Right-click a commit already in the selection: the set is preserved, + // only the focus moves. + select_commit_multi( + &mut state, + repo_id, + a.clone(), + CommitSelectMode::PreserveIfSelected, + None, + None, + ); + let sel = multi_selection(&mut state, repo_id); + assert_eq!(sel.commits, vec![a.clone(), b.clone()]); + assert_eq!( + repo_mut(&mut state, repo_id).history_state.selected_commit, + Some(a.clone()) + ); + + // Right-click a commit outside the selection: collapse to it. + select_commit_multi( + &mut state, + repo_id, + c.clone(), + CommitSelectMode::PreserveIfSelected, + None, + None, + ); + let sel = multi_selection(&mut state, repo_id); + assert_eq!(sel.commits, vec![c.clone()]); + assert_eq!( + repo_mut(&mut state, repo_id).history_state.selected_commit, + Some(c) + ); + } + + #[test] + fn squash_preview_accepted_by_pending_request_even_when_plan_invalid() { + let repo_id = RepoId(1); + let mut state = new_state_with_repo(repo_id); + let oldest = CommitId("old".into()); + let head = CommitId("head".into()); + // A request is in flight but the plan is transiently invalid (no Ready + // log here). The returning result must still be accepted rather than + // stranding the preview on Loading forever. + { + let repo = repo_mut(&mut state, repo_id); + repo.history_state.squash_preview_pending = Some((oldest.clone(), head.clone())); + repo.set_squash_preview(Loadable::Loading); + } + let effects = squash_message_preview_loaded( + &mut state, + repo_id, + oldest.clone(), + head.clone(), + Ok("Subject line\n\nBody text".to_string()), + ); + assert!(effects.is_empty()); + let repo = repo_mut(&mut state, repo_id); + match &repo.history_state.squash_preview { + Loadable::Ready(preview) => { + assert_eq!(preview.subject, "Subject line"); + assert_eq!(preview.body, "Body text"); + assert_eq!(preview.oldest, oldest); + assert_eq!(preview.head, head); + } + other => panic!("expected Ready preview, got {other:?}"), + } + assert!(repo.history_state.squash_preview_pending.is_none()); + } + + #[test] + fn squash_preview_dropped_when_request_range_differs() { + let repo_id = RepoId(1); + let mut state = new_state_with_repo(repo_id); + { + let repo = repo_mut(&mut state, repo_id); + repo.history_state.squash_preview_pending = + Some((CommitId("new_old".into()), CommitId("new_head".into()))); + repo.set_squash_preview(Loadable::Loading); + } + // A stale result for a range we are no longer waiting on is ignored. + squash_message_preview_loaded( + &mut state, + repo_id, + CommitId("old".into()), + CommitId("head".into()), + Ok("stale".to_string()), + ); + let repo = repo_mut(&mut state, repo_id); + assert!(matches!( + repo.history_state.squash_preview, + Loadable::Loading + )); + assert!(repo.history_state.squash_preview_pending.is_some()); + } + + #[test] + fn shift_click_selects_range_from_anchor_in_both_directions() { + let repo_id = RepoId(1); + let mut state = new_state_with_repo(repo_id); + let ids: Vec = ["a", "b", "c", "d"] + .iter() + .map(|s| CommitId((*s).into())) + .collect(); + + select_commit(&mut state, repo_id, ids[1].clone()); + select_commit_multi( + &mut state, + repo_id, + ids[3].clone(), + CommitSelectMode::Range, + Some(3), + Some(ids.clone()), + ); + let sel = multi_selection(&mut state, repo_id); + assert_eq!(sel.commits, ids[1..=3].to_vec()); + assert_eq!(sel.anchor.as_ref(), Some(&ids[1])); + + // Extending upward from the same anchor replaces the range. + select_commit_multi( + &mut state, + repo_id, + ids[0].clone(), + CommitSelectMode::Range, + Some(0), + Some(ids.clone()), + ); + let sel = multi_selection(&mut state, repo_id); + assert_eq!(sel.commits, ids[0..=1].to_vec()); + } + + #[test] + fn shift_click_ignores_stale_anchor_index_hint() { + let repo_id = RepoId(1); + let mut state = new_state_with_repo(repo_id); + let ids: Vec = ["a", "b", "c", "d"] + .iter() + .map(|s| CommitId((*s).into())) + .collect(); + + select_commit(&mut state, repo_id, ids[0].clone()); + { + // Simulate a log reload shifting rows: the anchor hint index now + // points elsewhere and the stored log rev no longer matches. + let repo = repo_mut(&mut state, repo_id); + let mut sel = repo.history_state.multi_selection.clone(); + sel.anchor_index = Some(3); + sel.anchor_log_rev = Some(repo.history_state.log_rev.wrapping_add(1)); + repo.set_commit_multi_selection(sel); + } + select_commit_multi( + &mut state, + repo_id, + ids[2].clone(), + CommitSelectMode::Range, + Some(2), + Some(ids.clone()), + ); + // The anchor is re-resolved by id, so the range is a..=c, not c..=d. + let sel = multi_selection(&mut state, repo_id); + assert_eq!(sel.commits, ids[0..=2].to_vec()); + } + + #[test] + fn plain_click_collapses_multi_selection() { + let repo_id = RepoId(1); + let mut state = new_state_with_repo(repo_id); + let a = CommitId("a".into()); + let b = CommitId("b".into()); + + select_commit(&mut state, repo_id, a.clone()); + select_commit_multi( + &mut state, + repo_id, + b.clone(), + CommitSelectMode::Toggle, + None, + None, + ); + assert_eq!(multi_selection(&mut state, repo_id).commits.len(), 2); + + select_commit(&mut state, repo_id, a.clone()); + let sel = multi_selection(&mut state, repo_id); + assert_eq!(sel.commits, vec![a.clone()]); + assert_eq!(sel.anchor.as_ref(), Some(&a)); + } + + #[test] + fn range_click_without_entries_falls_back_to_single() { + let repo_id = RepoId(1); + let mut state = new_state_with_repo(repo_id); + let a = CommitId("a".into()); + let b = CommitId("b".into()); + + select_commit(&mut state, repo_id, a); + select_commit_multi( + &mut state, + repo_id, + b.clone(), + CommitSelectMode::Range, + None, + None, + ); + let sel = multi_selection(&mut state, repo_id); + assert_eq!(sel.commits, vec![b]); + } + + #[test] + fn clearing_selection_dissolves_multi_selection() { + let repo_id = RepoId(1); + let mut state = new_state_with_repo(repo_id); + let a = CommitId("a".into()); + let b = CommitId("b".into()); + + select_commit(&mut state, repo_id, a); + select_commit_multi(&mut state, repo_id, b, CommitSelectMode::Toggle, None, None); + assert_eq!(multi_selection(&mut state, repo_id).commits.len(), 2); + + clear_commit_selection(&mut state, repo_id); + let repo = repo_mut(&mut state, repo_id); + assert!(repo.history_state.multi_selection.commits.is_empty()); + assert!(repo.history_state.multi_selection.anchor.is_none()); + } + #[test] fn loaded_handlers_reschedule_when_pending() { let repo_id = RepoId(1); diff --git a/crates/gitcomet-state/src/store/reducer/external_and_history.rs b/crates/gitcomet-state/src/store/reducer/external_and_history.rs index 6002f482..5954845c 100644 --- a/crates/gitcomet-state/src/store/reducer/external_and_history.rs +++ b/crates/gitcomet-state/src/store/reducer/external_and_history.rs @@ -1,5 +1,5 @@ use super::actions_emit_effects::invalidate_loaded_blame; -use super::effects::append_ensure_sidebar_data_effects; +use super::effects::{append_ensure_sidebar_data_effects, select_commit_and_load_details}; use super::repo_management::{ append_cancel_repo_loads_effect_for_repo, append_selected_history_reload_effects, selected_history_reloads_for_activation, @@ -10,10 +10,11 @@ use super::util::{ push_diagnostic, refresh_full_effects, refresh_primary_effects, selected_conflict_target, start_conflict_target_reload, start_current_conflict_target_reload, }; -use crate::model::{AppState, DiagnosticKind, Loadable, RepoLoadsInFlight}; +use crate::model::{AppState, DiagnosticKind, InteractiveRebaseSetup, Loadable, RepoLoadsInFlight}; use crate::msg::{Effect, RepoActionKind, RepoExternalChange}; use gitcomet_core::domain::{DiffArea, DiffTarget, LogCursor, LogPage, LogScope}; use gitcomet_core::error::Error; +use gitcomet_core::services::InteractiveRebaseEntry; use std::sync::Arc; const LARGE_HISTORY_APPEND_LEN_THRESHOLD: usize = 4_096; @@ -298,6 +299,34 @@ pub(super) fn rebase_state_loaded( effects } +pub(super) fn interactive_rebase_setup_loaded( + state: &mut AppState, + repo_id: crate::model::RepoId, + base: String, + result: std::result::Result, Error>, +) -> Vec { + if let Some(repo_state) = state.repos.iter_mut().find(|r| r.id == repo_id) { + // Discard stale results: only write if setup is still active for this + // exact base. Guards against a cancelled setup being revived, or a + // result for commit X clobbering a newer load already in flight for Y. + if repo_state + .interactive_rebase_setup + .as_ref() + .is_some_and(|s| s.base == base) + { + let entries = match result { + Ok(v) => Loadable::Ready(v), + Err(e) => { + push_diagnostic(repo_state, DiagnosticKind::Error, e.to_string()); + Loadable::Error(e.to_string()) + } + }; + repo_state.interactive_rebase_setup = Some(InteractiveRebaseSetup { base, entries }); + } + } + vec![] +} + pub(super) fn merge_commit_message_loaded( state: &mut AppState, repo_id: crate::model::RepoId, @@ -386,6 +415,51 @@ pub(super) fn log_loaded( repo_state.set_detached_head_commit(page.commits.first().map(|c| c.id.clone())); } + // Reconcile the commit multi-selection against the reloaded page: drop + // ids that no longer exist, and drop the anchor index hint since row + // indices may have shifted. + if !repo_state.history_state.multi_selection.commits.is_empty() + && let Loadable::Ready(page) = &repo_state.log + { + let mut next = repo_state.history_state.multi_selection.clone(); + next.commits + .retain(|id| page.commits.iter().any(|c| c.id == *id)); + if let Some(anchor) = &next.anchor + && !page.commits.iter().any(|c| c.id == *anchor) + { + next.anchor = None; + } + next.anchor_index = None; + next.anchor_log_rev = None; + + // The focused commit (which drives the details pane) may itself + // have vanished — an external amend/rebase can replace exactly the + // focused commit. Re-point focus at a surviving selected commit so + // the details pane never trails a commit that no longer exists. + let focus_gone = repo_state + .history_state + .selected_commit + .as_ref() + .is_some_and(|id| !page.commits.iter().any(|c| c.id == *id)); + let refocus = focus_gone.then(|| next.commits.last().cloned()).flatten(); + + repo_state.set_commit_multi_selection(next); + + if focus_gone { + match refocus { + Some(commit_id) => { + effects.extend(select_commit_and_load_details( + repo_state, repo_id, commit_id, + )); + } + None => { + repo_state.set_selected_commit(None); + repo_state.set_commit_details(Loadable::NotLoaded); + } + } + } + } + if is_load_more { repo_state.set_log_loading_more(false); } diff --git a/crates/gitcomet-state/src/store/reducer/util.rs b/crates/gitcomet-state/src/store/reducer/util.rs index db2b9189..d84a7873 100644 --- a/crates/gitcomet-state/src/store/reducer/util.rs +++ b/crates/gitcomet-state/src/store/reducer/util.rs @@ -1002,9 +1002,19 @@ fn summarize_command( RepoCommandKind::PushTag { .. } => "Push tag", RepoCommandKind::DeleteRemoteTag { .. } => "Delete remote tag", RepoCommandKind::Reset { .. } => "Reset", + RepoCommandKind::SquashCommits { .. } => "Squash", RepoCommandKind::Rebase { .. } => "Rebase", RepoCommandKind::RebaseContinue => "Rebase", RepoCommandKind::RebaseAbort => "Rebase", + RepoCommandKind::InteractiveRebase { interactive, .. } => { + if *interactive { + "Interactive rebase" + } else { + "Rebase" + } + } + RepoCommandKind::InteractiveCherryPick { .. } => "Cherry-pick", + RepoCommandKind::CherryPick { .. } => "Cherry-pick", RepoCommandKind::MergeAbort => "Merge", RepoCommandKind::CreateTag { .. } => "Tag", RepoCommandKind::DeleteTag { .. } => "Tag", @@ -1202,9 +1212,44 @@ fn summarize_command( }; format!("Reset (--{mode}) {target}: Completed") } + RepoCommandKind::SquashCommits { count, .. } => { + format!("Squash {count} commits: Completed") + } RepoCommandKind::Rebase { onto } => format!("Rebase onto {onto}: Completed"), RepoCommandKind::RebaseContinue => "Rebase: Continued".to_string(), RepoCommandKind::RebaseAbort => "Rebase: Aborted".to_string(), + RepoCommandKind::InteractiveRebase { base, interactive } => { + if *interactive { + format!("Interactive rebase onto {base}: Completed") + } else { + format!("Rebase onto {base}: Completed") + } + } + RepoCommandKind::InteractiveCherryPick { entries } => { + format!("Cherry-pick {} commits: Completed", entries.len()) + } + RepoCommandKind::CherryPick { + commit_id, + commit, + summary, + } => { + if output + .stdout + .contains("GITCOMET_CHERRY_PICK_ALREADY_APPLIED") + { + "Current branch already has all the changes from the cherry-picked commit." + .to_string() + } else { + let sha = commit_id.as_ref(); + let short = sha.get(0..7).unwrap_or(sha); + let summary = summary.lines().next().unwrap_or("").trim(); + if *commit { + format!("Cherry-picked {short}: {summary}") + } else { + format!("Cherry-picked {short} without committing: {summary}") + } + } + } RepoCommandKind::MergeAbort => "Merge: Aborted".to_string(), RepoCommandKind::CreateTag { name, target, .. } => { format!("Tag {name} → {target}: Created") @@ -1903,6 +1948,13 @@ mod tests { ), (RepoCommandKind::RebaseContinue, "Rebase"), (RepoCommandKind::RebaseAbort, "Rebase"), + ( + RepoCommandKind::InteractiveRebase { + base: "HEAD~3".into(), + interactive: true, + }, + "Interactive rebase", + ), (RepoCommandKind::MergeAbort, "Merge"), ( RepoCommandKind::CreateTag { @@ -2162,6 +2214,79 @@ mod tests { ); assert_eq!(rebase_abort_summary, "Rebase: Aborted"); + let (_, interactive_rebase_summary) = summarize_command( + &RepoCommandKind::InteractiveRebase { + base: "HEAD~3".into(), + interactive: true, + }, + &command_output("git rebase -i HEAD~3", "", ""), + true, + None, + ); + assert_eq!( + interactive_rebase_summary, + "Interactive rebase onto HEAD~3: Completed" + ); + + // An automated squash rebase (no editor window) reports as "Rebase". + let (_, squash_rebase_summary) = summarize_command( + &RepoCommandKind::InteractiveRebase { + base: "HEAD~3".into(), + interactive: false, + }, + &command_output("git rebase -i HEAD~3", "", ""), + true, + None, + ); + assert_eq!(squash_rebase_summary, "Rebase onto HEAD~3: Completed"); + + let commit_id = CommitId("abcdef1234567890".into()); + let (_, cherry_pick_summary) = summarize_command( + &RepoCommandKind::CherryPick { + commit_id: commit_id.clone(), + commit: true, + summary: "fix parser\n\nbody".into(), + }, + &command_output("git cherry-pick abcdef1", "", ""), + true, + None, + ); + assert_eq!(cherry_pick_summary, "Cherry-picked abcdef1: fix parser"); + + let (_, cherry_pick_no_commit_summary) = summarize_command( + &RepoCommandKind::CherryPick { + commit_id: commit_id.clone(), + commit: false, + summary: "fix parser".into(), + }, + &command_output("git cherry-pick --no-commit abcdef1", "", ""), + true, + None, + ); + assert_eq!( + cherry_pick_no_commit_summary, + "Cherry-picked abcdef1 without committing: fix parser" + ); + + let (_, cherry_pick_already_applied_summary) = summarize_command( + &RepoCommandKind::CherryPick { + commit_id, + commit: true, + summary: "fix parser".into(), + }, + &command_output( + "git cherry-pick abcdef1", + "GITCOMET_CHERRY_PICK_ALREADY_APPLIED", + "", + ), + true, + None, + ); + assert_eq!( + cherry_pick_already_applied_summary, + "Current branch already has all the changes from the cherry-picked commit." + ); + 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 049270c6..a2f38651 100644 --- a/crates/gitcomet-state/src/store/tests/actions_emit_effects.rs +++ b/crates/gitcomet-state/src/store/tests/actions_emit_effects.rs @@ -2017,14 +2017,18 @@ fn additional_routing_messages_emit_effects_and_update_counters() { Msg::CherryPickCommit { repo_id, commit_id: CommitId("deadbeef".into()), + commit: true, + summary: "pick me".into(), }, ); assert!(matches!( effects.as_slice(), [Effect::CherryPickCommit { repo_id: RepoId(1), + commit: true, + summary, .. - }] + }] if summary == "pick me" )); let effects = reduce( @@ -3638,6 +3642,8 @@ fn cherry_pick_clears_recent_messages_from_previous_head() { Msg::CherryPickCommit { repo_id, commit_id: CommitId("3333333333333333333333333333333333333333".into()), + commit: true, + summary: "pick me".into(), }, ); @@ -3648,6 +3654,37 @@ fn cherry_pick_clears_recent_messages_from_previous_head() { assert_eq!(state.repos[0].pending_force_push_lease, None); } +#[test] +fn interactive_cherry_pick_finished_clears_stale_force_push_lease() { + let mut repos: HashMap> = HashMap::default(); + let id_alloc = AtomicU64::new(1); + let mut state = AppState::default(); + let repo_id = RepoId(1); + state + .repos + .push(repo_with_head_dependent_cached_state(repo_id)); + + reduce( + &mut repos, + &id_alloc, + &mut state, + Msg::Internal(crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::InteractiveCherryPick { + entries: vec![gitcomet_core::services::InteractiveRebaseEntry { + action: gitcomet_core::services::InteractiveRebaseAction::Pick, + commit_id: "3333333333333333333333333333333333333333".to_string(), + summary: "pick me".to_string(), + new_message: None, + }], + }, + result: Ok(CommandOutput::empty_success("git cherry-pick")), + }), + ); + + assert_eq!(state.repos[0].pending_force_push_lease, None); +} + #[test] fn head_changing_repo_action_finish_invalidates_data_loaded_while_in_flight() { let mut repos: HashMap> = HashMap::default(); diff --git a/crates/gitcomet-state/src/store/tests/effects.rs b/crates/gitcomet-state/src/store/tests/effects.rs index 365b8843..51c80f04 100644 --- a/crates/gitcomet-state/src/store/tests/effects.rs +++ b/crates/gitcomet-state/src/store/tests/effects.rs @@ -4916,6 +4916,8 @@ fn schedule_effect_dispatches_many_variants_with_repo_present() { Effect::CherryPickCommit { repo_id, commit_id: commit_id.clone(), + commit: true, + summary: "pick me".into(), }, 1, ), diff --git a/crates/gitcomet-state/src/store/tests/external_and_history.rs b/crates/gitcomet-state/src/store/tests/external_and_history.rs index acae0a02..c6638cf6 100644 --- a/crates/gitcomet-state/src/store/tests/external_and_history.rs +++ b/crates/gitcomet-state/src/store/tests/external_and_history.rs @@ -1613,6 +1613,58 @@ fn log_loaded_appends_when_loading_more() { assert_eq!(page.next_cursor, None); } +#[test] +fn log_loaded_reconciles_commit_multi_selection() { + let mut repos: HashMap> = HashMap::default(); + let id_alloc = AtomicU64::new(1); + let mut state = AppState::default(); + state.repos.push(RepoState::new_opening( + RepoId(1), + RepoSpec { + workdir: PathBuf::from("/tmp/repo"), + }, + )); + state.active_repo = Some(RepoId(1)); + + let commit = |id: &str| Commit { + id: CommitId(id.into()), + parent_ids: gitcomet_core::domain::CommitParentIds::new(), + summary: "s".into(), + author: "a".into(), + time: SystemTime::UNIX_EPOCH, + }; + + let repo_state = &mut state.repos[0]; + repo_state.history_state.history_scope = LogScope::CurrentBranch; + repo_state.history_state.multi_selection = crate::model::CommitMultiSelection { + commits: vec![CommitId("kept".into()), CommitId("gone".into())], + anchor: Some(CommitId("gone".into())), + anchor_index: Some(1), + anchor_log_rev: Some(repo_state.history_state.log_rev), + }; + + let _effects = reduce( + &mut repos, + &id_alloc, + &mut state, + Msg::Internal(crate::msg::InternalMsg::LogLoaded { + repo_id: RepoId(1), + scope: LogScope::CurrentBranch, + cursor: None, + result: Ok(LogPage { + commits: vec![commit("kept"), commit("other")], + next_cursor: None, + }), + }), + ); + + let sel = &state.repos[0].history_state.multi_selection; + assert_eq!(sel.commits, vec![CommitId("kept".into())]); + assert_eq!(sel.anchor, None); + assert_eq!(sel.anchor_index, None); + assert_eq!(sel.anchor_log_rev, None); +} + #[test] fn log_loaded_appends_when_loading_more_re_shares_history_log_arc() { let mut repos: HashMap> = HashMap::default(); diff --git a/crates/gitcomet-ui-gpui/assets/icons/squash_arrow.svg b/crates/gitcomet-ui-gpui/assets/icons/squash_arrow.svg new file mode 100644 index 00000000..6954ffe3 --- /dev/null +++ b/crates/gitcomet-ui-gpui/assets/icons/squash_arrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/gitcomet-ui-gpui/build.rs b/crates/gitcomet-ui-gpui/build.rs index fb31b7a7..575734ec 100644 --- a/crates/gitcomet-ui-gpui/build.rs +++ b/crates/gitcomet-ui-gpui/build.rs @@ -41,16 +41,20 @@ fn rasterize_svg_png(svg_bytes: &[u8], target_width_px: f32, max_edge_px: f32) - pixmap.encode_png().ok() } -/// Generate a `file_icon_bytes` lookup and `FILE_ICON_ASSETS` list embedding -/// every `assets/icons/file_icons/*.svg` via `include_bytes!`, so the file -/// browser's icons are served by the manual asset registry in `assets.rs` -/// without hand-maintaining ~100 entries. -fn generate_file_icon_assets(manifest_dir: &std::path::Path, out_dir: &std::path::Path) { - let dir = manifest_dir.join("assets/icons/file_icons"); +/// Generate a `` lookup and `` list embedding every +/// `*.svg` in `dir` via `include_bytes!` under the `` asset +/// path, so the icons are served by the asset registry in `assets.rs` +/// without hand-maintaining the entries. +fn generate_svg_dir_assets( + dir: &std::path::Path, + asset_prefix: &str, + fn_name: &str, + list_name: &str, +) -> String { println!("cargo:rerun-if-changed={}", dir.display()); - let mut names: Vec = fs::read_dir(&dir) - .expect("read file_icons dir") + let mut names: Vec = fs::read_dir(dir) + .expect("read svg asset dir") .filter_map(|entry| { let name = entry.ok()?.file_name().into_string().ok()?; name.ends_with(".svg").then_some(name) @@ -58,22 +62,36 @@ fn generate_file_icon_assets(manifest_dir: &std::path::Path, out_dir: &std::path .collect(); names.sort(); - let mut generated = String::from( - "fn file_icon_bytes(path: &str) -> Option<&'static [u8]> {\n match path {\n", - ); + let mut generated = + format!("fn {fn_name}(path: &str) -> Option<&'static [u8]> {{\n match path {{\n"); for name in &names { let abs = dir.join(name); generated.push_str(&format!( - " \"icons/file_icons/{name}\" => Some(include_bytes!({abs:?}).as_slice()),\n" + " \"{asset_prefix}/{name}\" => Some(include_bytes!({abs:?}).as_slice()),\n" )); } - generated.push_str(" _ => None,\n }\n}\n\nconst FILE_ICON_ASSETS: &[&str] = &[\n"); + generated.push_str(&format!( + " _ => None,\n }}\n}}\n\nconst {list_name}: &[&str] = &[\n" + )); for name in &names { - generated.push_str(&format!(" \"icons/file_icons/{name}\",\n")); + generated.push_str(&format!(" \"{asset_prefix}/{name}\",\n")); } generated.push_str("];\n"); + generated +} + +fn generate_icon_assets(manifest_dir: &std::path::Path, out_dir: &std::path::Path) { + let icons_dir = manifest_dir.join("assets/icons"); + let mut generated = generate_svg_dir_assets(&icons_dir, "icons", "icon_bytes", "ICON_ASSETS"); + generated.push('\n'); + generated.push_str(&generate_svg_dir_assets( + &icons_dir.join("file_icons"), + "icons/file_icons", + "file_icon_bytes", + "FILE_ICON_ASSETS", + )); - fs::write(out_dir.join("file_icons_assets.rs"), generated).expect("write file_icons_assets.rs"); + fs::write(out_dir.join("icons_assets.rs"), generated).expect("write icons_assets.rs"); } fn main() { @@ -84,7 +102,7 @@ fn main() { let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR missing")); let out_dir = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR missing")); - generate_file_icon_assets(&manifest_dir, &out_dir); + generate_icon_assets(&manifest_dir, &out_dir); let svg_path = manifest_dir.join("../../assets/splash_backdrop.svg"); let out_path = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR missing")).join("splash_backdrop.png"); diff --git a/crates/gitcomet-ui-gpui/src/app.rs b/crates/gitcomet-ui-gpui/src/app.rs index 2e2383ff..e780cb2c 100644 --- a/crates/gitcomet-ui-gpui/src/app.rs +++ b/crates/gitcomet-ui-gpui/src/app.rs @@ -2070,14 +2070,7 @@ mod tests { #[gpui::test] fn text_input_command_shortcuts_trigger_undo_and_redo(cx: &mut gpui::TestAppContext) { let (input, cx) = cx.add_window_view(|window, cx| { - crate::kit::TextInput::new( - crate::kit::TextInputOptions { - multiline: false, - ..Default::default() - }, - window, - cx, - ) + crate::kit::TextInput::new(crate::kit::TextInputOptions::default(), window, cx) }); cx.update(|window, app| { @@ -2110,14 +2103,7 @@ mod tests { #[gpui::test] fn text_input_control_redo_shortcut_triggers_redo(cx: &mut gpui::TestAppContext) { let (input, cx) = cx.add_window_view(|window, cx| { - crate::kit::TextInput::new( - crate::kit::TextInputOptions { - multiline: false, - ..Default::default() - }, - window, - cx, - ) + crate::kit::TextInput::new(crate::kit::TextInputOptions::default(), window, cx) }); cx.update(|window, app| { diff --git a/crates/gitcomet-ui-gpui/src/assets.rs b/crates/gitcomet-ui-gpui/src/assets.rs index bb488f12..3b4e0855 100644 --- a/crates/gitcomet-ui-gpui/src/assets.rs +++ b/crates/gitcomet-ui-gpui/src/assets.rs @@ -1,9 +1,10 @@ use gpui::{AssetSource, Result, SharedString}; use std::borrow::Cow; -// `file_icon_bytes()` and `FILE_ICON_ASSETS`, generated by `build.rs` from +// `icon_bytes()`/`ICON_ASSETS` and `file_icon_bytes()`/`FILE_ICON_ASSETS`, +// generated by `build.rs` from `assets/icons/*.svg` and // `assets/icons/file_icons/*.svg`. -include!(concat!(env!("OUT_DIR"), "/file_icons_assets.rs")); +include!(concat!(env!("OUT_DIR"), "/icons_assets.rs")); pub struct GitCometAssets; @@ -19,96 +20,9 @@ impl GitCometAssets { "gitcomet_logo.svg" => Some(Cow::Borrowed(include_bytes!( "../../../assets/gitcomet_logo.svg" ))), - "icons/arrow_down.svg" => Some(Cow::Borrowed(include_bytes!( - "../assets/icons/arrow_down.svg" - ))), - "icons/arrow_up.svg" => Some(Cow::Borrowed(include_bytes!( - "../assets/icons/arrow_up.svg" - ))), - "icons/spinner.svg" => { - Some(Cow::Borrowed(include_bytes!("../assets/icons/spinner.svg"))) - } - "icons/stash.svg" => Some(Cow::Borrowed(include_bytes!("../assets/icons/stash.svg"))), - "icons/box.svg" => Some(Cow::Borrowed(include_bytes!("../assets/icons/box.svg"))), - "icons/check.svg" => Some(Cow::Borrowed(include_bytes!("../assets/icons/check.svg"))), - "icons/chevron_down.svg" => Some(Cow::Borrowed(include_bytes!( - "../assets/icons/chevron_down.svg" - ))), - "icons/plus.svg" => Some(Cow::Borrowed(include_bytes!("../assets/icons/plus.svg"))), - "icons/minus.svg" => Some(Cow::Borrowed(include_bytes!("../assets/icons/minus.svg"))), - "icons/question.svg" => Some(Cow::Borrowed(include_bytes!( - "../assets/icons/question.svg" - ))), - "icons/warning.svg" => { - Some(Cow::Borrowed(include_bytes!("../assets/icons/warning.svg"))) - } - "icons/swap.svg" => Some(Cow::Borrowed(include_bytes!("../assets/icons/swap.svg"))), - "icons/open_external.svg" => Some(Cow::Borrowed(include_bytes!( - "../assets/icons/open_external.svg" - ))), - "icons/file.svg" => Some(Cow::Borrowed(include_bytes!("../assets/icons/file.svg"))), - "icons/copy.svg" => Some(Cow::Borrowed(include_bytes!("../assets/icons/copy.svg"))), - "icons/refresh.svg" => { - Some(Cow::Borrowed(include_bytes!("../assets/icons/refresh.svg"))) - } - "icons/history.svg" => { - Some(Cow::Borrowed(include_bytes!("../assets/icons/history.svg"))) - } - "icons/undo.svg" => Some(Cow::Borrowed(include_bytes!("../assets/icons/undo.svg"))), - "icons/tag.svg" => Some(Cow::Borrowed(include_bytes!("../assets/icons/tag.svg"))), - "icons/terminal.svg" => Some(Cow::Borrowed(include_bytes!( - "../assets/icons/terminal.svg" - ))), - "icons/trash.svg" => Some(Cow::Borrowed(include_bytes!("../assets/icons/trash.svg"))), - "icons/broom.svg" => Some(Cow::Borrowed(include_bytes!("../assets/icons/broom.svg"))), - "icons/infinity.svg" => Some(Cow::Borrowed(include_bytes!( - "../assets/icons/infinity.svg" - ))), - "icons/arrow_left.svg" => Some(Cow::Borrowed(include_bytes!( - "../assets/icons/arrow_left.svg" - ))), - "icons/arrow_right.svg" => Some(Cow::Borrowed(include_bytes!( - "../assets/icons/arrow_right.svg" - ))), - "icons/link.svg" => Some(Cow::Borrowed(include_bytes!("../assets/icons/link.svg"))), - "icons/line_break.svg" => Some(Cow::Borrowed(include_bytes!( - "../assets/icons/line_break.svg" - ))), - "icons/unlink.svg" => Some(Cow::Borrowed(include_bytes!("../assets/icons/unlink.svg"))), - "icons/cloud.svg" => Some(Cow::Borrowed(include_bytes!("../assets/icons/cloud.svg"))), - "icons/cog.svg" => Some(Cow::Borrowed(include_bytes!("../assets/icons/cog.svg"))), - "icons/computer.svg" => Some(Cow::Borrowed(include_bytes!( - "../assets/icons/computer.svg" - ))), - "icons/folder.svg" => Some(Cow::Borrowed(include_bytes!("../assets/icons/folder.svg"))), - "icons/generic_minimize.svg" => Some(Cow::Borrowed(include_bytes!( - "../assets/icons/generic_minimize.svg" - ))), - "icons/generic_maximize.svg" => Some(Cow::Borrowed(include_bytes!( - "../assets/icons/generic_maximize.svg" - ))), - "icons/generic_restore.svg" => Some(Cow::Borrowed(include_bytes!( - "../assets/icons/generic_restore.svg" - ))), - "icons/generic_close.svg" => Some(Cow::Borrowed(include_bytes!( - "../assets/icons/generic_close.svg" - ))), - "icons/repo_tab_close.svg" => Some(Cow::Borrowed(include_bytes!( - "../assets/icons/repo_tab_close.svg" - ))), - "icons/git_branch.svg" => Some(Cow::Borrowed(include_bytes!( - "../assets/icons/git_branch.svg" - ))), - "icons/git_worktree.svg" => Some(Cow::Borrowed(include_bytes!( - "../assets/icons/git_worktree.svg" - ))), - "icons/gitcomet_mark.svg" => Some(Cow::Borrowed(include_bytes!( - "../assets/icons/gitcomet_mark.svg" - ))), - "icons/menu.svg" => Some(Cow::Borrowed(include_bytes!("../assets/icons/menu.svg"))), - "icons/pencil.svg" => Some(Cow::Borrowed(include_bytes!("../assets/icons/pencil.svg"))), - "icons/zoom.svg" => Some(Cow::Borrowed(include_bytes!("../assets/icons/zoom.svg"))), - other => file_icon_bytes(other).map(Cow::Borrowed), + other => icon_bytes(other) + .or_else(|| file_icon_bytes(other)) + .map(Cow::Borrowed), } } @@ -120,52 +34,11 @@ impl GitCometAssets { "gitcomet_logo.svg".into(), "icons".into(), ], - "icons" => vec![ - "icons/arrow_down.svg".into(), - "icons/arrow_up.svg".into(), - "icons/spinner.svg".into(), - "icons/stash.svg".into(), - "icons/box.svg".into(), - "icons/check.svg".into(), - "icons/chevron_down.svg".into(), - "icons/plus.svg".into(), - "icons/minus.svg".into(), - "icons/question.svg".into(), - "icons/warning.svg".into(), - "icons/swap.svg".into(), - "icons/open_external.svg".into(), - "icons/file.svg".into(), - "icons/copy.svg".into(), - "icons/refresh.svg".into(), - "icons/history.svg".into(), - "icons/undo.svg".into(), - "icons/tag.svg".into(), - "icons/terminal.svg".into(), - "icons/trash.svg".into(), - "icons/broom.svg".into(), - "icons/infinity.svg".into(), - "icons/arrow_left.svg".into(), - "icons/arrow_right.svg".into(), - "icons/link.svg".into(), - "icons/line_break.svg".into(), - "icons/unlink.svg".into(), - "icons/cloud.svg".into(), - "icons/cog.svg".into(), - "icons/computer.svg".into(), - "icons/folder.svg".into(), - "icons/generic_minimize.svg".into(), - "icons/generic_maximize.svg".into(), - "icons/generic_restore.svg".into(), - "icons/generic_close.svg".into(), - "icons/repo_tab_close.svg".into(), - "icons/git_branch.svg".into(), - "icons/git_worktree.svg".into(), - "icons/gitcomet_mark.svg".into(), - "icons/menu.svg".into(), - "icons/pencil.svg".into(), - "icons/zoom.svg".into(), - "icons/file_icons".into(), - ], + "icons" => ICON_ASSETS + .iter() + .map(|path| SharedString::from(*path)) + .chain(std::iter::once("icons/file_icons".into())) + .collect(), "icons/file_icons" => FILE_ICON_ASSETS .iter() .map(|path| SharedString::from(*path)) diff --git a/crates/gitcomet-ui-gpui/src/kit/scrollbar.rs b/crates/gitcomet-ui-gpui/src/kit/scrollbar.rs index 939369d9..15abdf0c 100644 --- a/crates/gitcomet-ui-gpui/src/kit/scrollbar.rs +++ b/crates/gitcomet-ui-gpui/src/kit/scrollbar.rs @@ -1,7 +1,7 @@ use crate::theme::AppTheme; use gpui::prelude::*; use gpui::{ - Bounds, CursorStyle, DispatchPhase, ElementId, Hitbox, HitboxBehavior, MouseButton, + Bounds, CursorStyle, DispatchPhase, ElementId, Hitbox, HitboxBehavior, ListState, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, ScrollHandle, UniformListScrollHandle, canvas, div, fill, point, px, size, }; @@ -129,6 +129,38 @@ impl ScrollbarDriver for UniformListScrollHandle { fn drag_ended(&self, _axis: ScrollbarAxis) {} } +impl ScrollbarDriver for ListState { + fn max_offset(&self, axis: ScrollbarAxis) -> Pixels { + match axis { + // `list` virtualizes vertically only. + ScrollbarAxis::Vertical => self.max_offset_for_scrollbar().y.max(px(0.0)), + ScrollbarAxis::Horizontal => px(0.0), + } + } + + fn raw_offset(&self, axis: ScrollbarAxis) -> Pixels { + match axis { + // Negative y = scrolled down, matching the driver's sign convention. + ScrollbarAxis::Vertical => self.scroll_px_offset_for_scrollbar().y, + ScrollbarAxis::Horizontal => px(0.0), + } + } + + fn set_axis_offset(&self, axis: ScrollbarAxis, offset: Pixels) { + if axis == ScrollbarAxis::Vertical { + self.set_offset_from_scrollbar(point(px(0.0), offset)); + } + } + + fn drag_started(&self, _axis: ScrollbarAxis) { + self.scrollbar_drag_started(); + } + + fn drag_ended(&self, _axis: ScrollbarAxis) { + self.scrollbar_drag_ended(); + } +} + // --------------------------------------------------------------------------- // Scrollbar component // --------------------------------------------------------------------------- diff --git a/crates/gitcomet-ui-gpui/src/view/command_palette.rs b/crates/gitcomet-ui-gpui/src/view/command_palette.rs index 35a82546..e8781df5 100644 --- a/crates/gitcomet-ui-gpui/src/view/command_palette.rs +++ b/crates/gitcomet-ui-gpui/src/view/command_palette.rs @@ -341,8 +341,6 @@ pub(crate) const COMMANDS: &[CommandEntry] = &[ // TODO: "delete-remote-branch" - Delete Remote Branch // TODO: "merge" - Merge Branch/Ref // TODO: "rebase" - Rebase Onto - // TODO: "rebase-continue" - Continue Rebase (Merge/Rebase) - // TODO: "rebase-abort" - Abort Rebase (Merge/Rebase) // TODO: "delete-tag" - Delete Tag // TODO: "remove-remote" - Remove Remote // TODO: "edit-remote-url" - Edit Remote URL diff --git a/crates/gitcomet-ui-gpui/src/view/components/button.rs b/crates/gitcomet-ui-gpui/src/view/components/button.rs index 9315c4fe..61f2f363 100644 --- a/crates/gitcomet-ui-gpui/src/view/components/button.rs +++ b/crates/gitcomet-ui-gpui/src/view/components/button.rs @@ -29,6 +29,7 @@ pub struct Button { selected_bg: Option, borderless: bool, suppress_hover_border: bool, + no_focus: bool, focus_handle: Option, start_slot: Option, end_slot: Option, @@ -46,6 +47,7 @@ impl Button { selected_bg: None, borderless: false, suppress_hover_border: false, + no_focus: false, focus_handle: None, start_slot: None, end_slot: None, @@ -73,6 +75,11 @@ impl Button { self } + pub fn no_focus(mut self) -> Self { + self.no_focus = true; + self + } + pub fn focus_handle(mut self, focus_handle: FocusHandle) -> Self { self.focus_handle = Some(focus_handle); self @@ -160,6 +167,7 @@ impl Button { selected_bg, borderless, suppress_hover_border, + no_focus, focus_handle, start_slot, end_slot, @@ -340,7 +348,7 @@ impl Button { if let Some(focus_handle) = focus_handle { let focus_handle = focus_handle.tab_stop(!disabled); base = base.track_focus(&focus_handle); - } else { + } else if !no_focus { base = base.tab_index(0); } diff --git a/crates/gitcomet-ui-gpui/src/view/components/commit_sha_hover_menu.rs b/crates/gitcomet-ui-gpui/src/view/components/commit_sha_hover_menu.rs index cc3fe8fa..f96430fa 100644 --- a/crates/gitcomet-ui-gpui/src/view/components/commit_sha_hover_menu.rs +++ b/crates/gitcomet-ui-gpui/src/view/components/commit_sha_hover_menu.rs @@ -421,19 +421,15 @@ impl CommitShaHoverMenu { if self.allow_navigate { let navigate_selector = format!("{}_navigate", self.id); entries = entries.child( - super::context_menu_entry( + super::ContextMenuEntry::new( ( ElementId::from("commit_sha_hover_menu_navigate"), self.id.clone(), ), - self.theme, - self.ui_scale, - false, - false, - Some("icons/link.svg".into()), "Navigate", - None, ) + .icon(super::ContextMenuIconSlot::Icon("icons/link.svg".into())) + .render(self.theme, self.ui_scale, cx) .debug_selector(move || navigate_selector.clone()) .on_mouse_down( MouseButton::Left, @@ -443,19 +439,15 @@ impl CommitShaHoverMenu { } let browse_selector = format!("{}_browse", self.id); entries = entries.child( - super::context_menu_entry( + super::ContextMenuEntry::new( ( ElementId::from("commit_sha_hover_menu_browse"), self.id.clone(), ), - self.theme, - self.ui_scale, - false, - false, - Some("icons/history.svg".into()), "Browse repository at this point", - None, ) + .icon(super::ContextMenuIconSlot::Icon("icons/history.svg".into())) + .render(self.theme, self.ui_scale, cx) .debug_selector(move || browse_selector.clone()) .on_mouse_down( MouseButton::Left, diff --git a/crates/gitcomet-ui-gpui/src/view/components/context_menu.rs b/crates/gitcomet-ui-gpui/src/view/components/context_menu.rs index 36c1b543..eaadda51 100644 --- a/crates/gitcomet-ui-gpui/src/view/components/context_menu.rs +++ b/crates/gitcomet-ui-gpui/src/view/components/context_menu.rs @@ -76,6 +76,19 @@ impl From<&str> for ContextMenuText { } } +/// What occupies the fixed-width icon slot at the start of a menu entry. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub enum ContextMenuIconSlot { + /// No icon and no reserved space; the label starts at the row edge. + #[default] + None, + /// Keep the icon column empty so the label stays aligned with sibling + /// entries that carry an icon. + Reserved, + /// An icon name or `icons/*.svg` path resolved via `context_menu_icon_path`. + Icon(SharedString), +} + pub fn context_menu(theme: AppTheme, content: impl IntoElement) -> Div { div() .w_full() @@ -99,8 +112,6 @@ pub fn context_menu_header( let title = title.into(); let max_lines = title.resolved_max_lines(1); div() - .w_full() - .self_stretch() .px(scaled_px(8.0)) .py(scaled_px(4.0)) .text_xs() @@ -129,8 +140,6 @@ pub fn context_menu_label( let text = text.into(); let max_lines = text.resolved_max_lines(2); div() - .w_full() - .self_stretch() .px(scaled_px(8.0)) .pb(scaled_px(4.0)) .text_sm() @@ -151,29 +160,91 @@ pub fn context_menu_separator(theme: AppTheme, ui_scale: impl Into) -> let ui_scale = ui_scale.into(); let scaled_px = |value| ui_scale.px(value); div() - .w_full() - .self_stretch() .my(scaled_px(2.0)) .border_t_1() .border_color(theme.colors.border) } -pub fn context_menu_entry( - id: impl Into, - theme: AppTheme, - ui_scale: impl Into, +pub struct ContextMenuEntry { + id: ElementId, + label: ContextMenuText, + icon: ContextMenuIconSlot, + shortcut: Option, selected: bool, disabled: bool, - icon: Option, - label: impl Into, - shortcut: Option, + tooltip_host: Option>, +} + +impl ContextMenuEntry { + pub fn new(id: impl Into, label: impl Into) -> Self { + Self { + id: id.into(), + label: label.into(), + icon: ContextMenuIconSlot::None, + shortcut: None, + selected: false, + disabled: false, + tooltip_host: None, + } + } + + pub fn icon(mut self, icon: ContextMenuIconSlot) -> Self { + self.icon = icon; + self + } + + pub fn shortcut(mut self, shortcut: Option) -> Self { + self.shortcut = shortcut; + self + } + + pub fn selected(mut self, selected: bool) -> Self { + self.selected = selected; + self + } + + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + pub fn tooltip_host(mut self, tooltip_host: WeakEntity) -> Self { + self.tooltip_host = Some(tooltip_host); + self + } + + pub fn render( + self, + theme: AppTheme, + ui_scale: impl Into, + cx: &gpui::Context, + ) -> Stateful
{ + context_menu_entry(self, theme, ui_scale, cx) + } +} + +fn context_menu_entry( + entry: ContextMenuEntry, + theme: AppTheme, + ui_scale: impl Into, + cx: &gpui::Context, ) -> Stateful
{ + let ContextMenuEntry { + id, + label, + icon, + shortcut, + selected, + disabled, + tooltip_host, + } = entry; let ui_scale = ui_scale.into(); let scaled_px = |value| ui_scale.px(value); - let label: SharedString = label.into(); - let icon_path = icon - .as_ref() - .and_then(|icon| context_menu_icon_path(icon.as_ref(), label.as_ref())); + let max_lines = label.resolved_max_lines(2); + let icon_path = match &icon { + ContextMenuIconSlot::Icon(name) => context_menu_icon_path(name.as_ref(), label.as_ref()), + ContextMenuIconSlot::Reserved | ContextMenuIconSlot::None => None, + }; let icon_color = context_menu_icon_color(theme, disabled, label.as_ref(), icon_path); let text_color = if disabled { theme.colors.text_muted @@ -183,15 +254,13 @@ pub fn context_menu_entry( let mut row = div() .id(id) - .h(control_height_md(ui_scale)) - .w_full() - .min_w_full() - .self_stretch() + .min_h(control_height_md(ui_scale)) + .py(scaled_px(4.0)) .px(scaled_px(8.0)) .flex() .items_center() .justify_between() - .gap(scaled_px(8.0)) + .gap(scaled_px(20.0)) .rounded(px(theme.radii.row)) .text_color(text_color) .when(selected, |s| s.bg(theme.colors.hover)) @@ -207,20 +276,23 @@ pub fn context_menu_entry( .gap(scaled_px(8.0)) .flex_1() .min_w(px(0.0)) - .child( - div() - .w(scaled_px(16.0)) - .flex() - .items_center() - .justify_center() - .when_some(icon_path, |this, path| { - this.child(crate::view::icons::svg_icon( - path, - icon_color, - scaled_px(13.0), - )) - }), - ) + .overflow_hidden() + .when(!matches!(icon, ContextMenuIconSlot::None), |row| { + row.child( + div() + .w(scaled_px(16.0)) + .flex() + .items_center() + .justify_center() + .when_some(icon_path, |this, path| { + this.child(crate::view::icons::svg_icon( + path, + icon_color, + scaled_px(13.0), + )) + }), + ) + }) .child( div() .flex_1() @@ -228,8 +300,15 @@ pub fn context_menu_entry( .text_sm() .line_height(scaled_px(18.0)) .text_color(text_color) - .line_clamp(1) - .child(label), + .when(max_lines == 1, |s| s.whitespace_nowrap().overflow_hidden()) + .when(max_lines > 1, |s| s.line_clamp(max_lines)) + .child(context_menu_text_content( + label, + tooltip_host, + cx, + max_lines, + text_color, + )), ), ); @@ -325,6 +404,7 @@ fn context_menu_icon_path(icon: &str, label: &str) -> Option<&'static str> { "icons/box.svg" => Some("icons/box.svg"), "icons/menu.svg" => Some("icons/menu.svg"), "icons/swap.svg" => Some("icons/swap.svg"), + "icons/squash_arrow.svg" => Some("icons/squash_arrow.svg"), "icons/arrow_right.svg" => Some("icons/arrow_right.svg"), "icons/infinity.svg" => Some("icons/infinity.svg"), "icons/arrow_left.svg" => Some("icons/arrow_left.svg"), @@ -439,6 +519,7 @@ mod tests { "icons/box.svg", "icons/menu.svg", "icons/swap.svg", + "icons/squash_arrow.svg", "icons/arrow_right.svg", "icons/infinity.svg", "icons/arrow_left.svg", @@ -519,6 +600,7 @@ mod tests { "icons/box.svg", "icons/infinity.svg", "icons/swap.svg", + "icons/squash_arrow.svg", "icons/arrow_right.svg", "icons/arrow_left.svg", "icons/pencil.svg", diff --git a/crates/gitcomet-ui-gpui/src/view/components/mod.rs b/crates/gitcomet-ui-gpui/src/view/components/mod.rs index f61a0cc9..af5c7ea4 100644 --- a/crates/gitcomet-ui-gpui/src/view/components/mod.rs +++ b/crates/gitcomet-ui-gpui/src/view/components/mod.rs @@ -17,8 +17,8 @@ pub use containers::{empty_state, split_columns_header}; #[cfg(test)] pub use containers::{panel, pill}; pub use context_menu::{ - ContextMenuText, context_menu, context_menu_entry, context_menu_header, context_menu_label, - context_menu_separator, + ContextMenuEntry, ContextMenuIconSlot, ContextMenuText, context_menu, context_menu_header, + context_menu_label, context_menu_separator, }; pub use diff_stat::diff_stat; pub use picker_prompt::{PickerPrompt, PickerPromptItem, PickerPromptItemPart}; diff --git a/crates/gitcomet-ui-gpui/src/view/mod.rs b/crates/gitcomet-ui-gpui/src/view/mod.rs index 15d6202d..1e595d12 100644 --- a/crates/gitcomet-ui-gpui/src/view/mod.rs +++ b/crates/gitcomet-ui-gpui/src/view/mod.rs @@ -1254,12 +1254,6 @@ impl GitCometView { "rebase" => { // TODO: Implement rebase onto } - "rebase-continue" => { - // TODO: Continue rebase - } - "rebase-abort" => { - // TODO: Abort rebase - } "create-tag" => { if let Some(repo_id) = self.active_repo_id() && let Some(window) = window diff --git a/crates/gitcomet-ui-gpui/src/view/mod_helpers.rs b/crates/gitcomet-ui-gpui/src/view/mod_helpers.rs index d13e4206..cc5bd985 100644 --- a/crates/gitcomet-ui-gpui/src/view/mod_helpers.rs +++ b/crates/gitcomet-ui-gpui/src/view/mod_helpers.rs @@ -1,5 +1,6 @@ use super::*; use gitcomet_core::path_utils::canonicalize_or_original; +use gitcomet_core::services::InteractiveRebaseAction; type AlacrittyTermLock = super::terminal_alacritty::AlacrittyTermLock; @@ -2473,6 +2474,28 @@ pub(super) enum StashPickerPurpose { Drop, } +/// Auto-squash strategy: which commit in each identical-message group survives, +/// the others being folded (fixup) into it. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum AutosquashMode { + /// Fold each duplicate group into its newest (top) commit. + ToTop, + /// Only merge duplicates that are already adjacent in the list. + Neighbor, + /// Fold each duplicate group into its oldest (bottom) commit. + ToBottom, +} + +impl AutosquashMode { + pub(super) fn label(self) -> &'static str { + match self { + AutosquashMode::ToTop => "To Top Commit", + AutosquashMode::Neighbor => "Neighboring Commit", + AutosquashMode::ToBottom => "To Bottom Commit", + } + } +} + #[derive(Clone, Debug, Eq, PartialEq)] pub(super) enum PopoverKind { RepoPicker, @@ -2514,6 +2537,9 @@ pub(super) enum PopoverKind { target: String, mode: ResetMode, }, + SquashPrompt { + repo_id: RepoId, + }, CreateTagPrompt { repo_id: RepoId, target: String, @@ -2533,6 +2559,10 @@ pub(super) enum PopoverKind { ForcePushConfirm { repo_id: RepoId, }, + CherryPickCommitConfirm { + repo_id: RepoId, + commit_id: CommitId, + }, MergeAbortConfirm { repo_id: RepoId, }, @@ -2665,6 +2695,21 @@ pub(super) enum PopoverKind { DiffContentModeSettings, ChangeTrackingSettings, UiScalePicker, + RebaseOntoConfirm { + repo_id: RepoId, + onto: String, + }, + RebaseReword { + ix: usize, + original_action: InteractiveRebaseAction, + original_message: String, + }, + InteractiveRebaseActionMenu { + ix: usize, + can_squash: bool, + can_drop: bool, + }, + InteractiveRebaseAutosquashMenu, } #[derive(Clone, Debug, Eq, PartialEq)] 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 6c74bbc1..604a08ff 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/action_bar.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/action_bar.rs @@ -311,6 +311,8 @@ impl Render for ActionBarView { let is_rebase_or_apply_in_progress = self .active_repo() .is_some_and(|r| matches!(&r.rebase_in_progress, Loadable::Ready(true))); + let rebase_has_unstaged_conflicts = + self.active_repo().is_some_and(|r| r.has_unstaged_conflicts); let (pull_count, push_count) = self .active_repo() @@ -843,6 +845,25 @@ impl Render for ActionBarView { ); } }), + ) + .child( + components::Button::new("continue_rebase_or_apply", "Continue") + .style(components::ButtonStyle::Outlined) + .disabled(rebase_has_unstaged_conflicts) + .on_click(theme, cx, |this, _e, _w, _cx| { + if let Some(repo_id) = this.active_repo_id() { + this.store + .dispatch(Msg::RebaseContinue { repo_id }); + } + }) + .gitcomet_tooltip( + theme, + if rebase_has_unstaged_conflicts { + "Resolve all conflicts before continuing".into() + } else { + "Continue the in-progress rebase or apply".into() + }, + ), ), ) }), diff --git a/crates/gitcomet-ui-gpui/src/view/panels/layout.rs b/crates/gitcomet-ui-gpui/src/view/panels/layout.rs index 72e715dd..75d9e0ce 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/layout.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/layout.rs @@ -16,6 +16,8 @@ fn commit_allowed(is_merge_active: bool, staged_count: usize) -> bool { staged_count > 0 || is_merge_active } +const MULTI_COMMIT_ROW_HEIGHT_PX: f32 = 44.0; + fn commit_details_selectable_row(theme: AppTheme, key: &'static str, value: AnyElement) -> Div { div() .flex() @@ -664,6 +666,216 @@ impl DetailsPaneView { }); } + /// Selected commits resolved against the loaded log page, in log order + /// (youngest first). Ids missing from the page are skipped. + fn multi_selected_commits_in_log_order(repo: &RepoState) -> Vec { + let selection = &repo.history_state.multi_selection; + let Loadable::Ready(page) = &repo.log else { + return Vec::new(); + }; + page.commits + .iter() + .filter(|commit| selection.contains(&commit.id)) + .cloned() + .collect() + } + + pub(in super::super) fn render_multi_commit_rows( + this: &mut Self, + range: Range, + _window: &mut Window, + cx: &mut gpui::Context, + ) -> Vec { + let _ = cx; + let Some(repo) = this.active_repo() else { + return Vec::new(); + }; + let commits = Self::multi_selected_commits_in_log_order(repo); + let theme = this.theme; + let ui_scale_percent = this.ui_scale_percent; + let scaled_px = + |value: f32| crate::ui_scale::design_px_from_percent(value, ui_scale_percent); + let now = std::time::SystemTime::now(); + + range + .filter_map(|ix| commits.get(ix).map(|commit| (ix, commit))) + .map(|(ix, commit)| { + let short_sha: SharedString = commit + .id + .as_ref() + .get(0..8) + .unwrap_or(commit.id.as_ref()) + .to_string() + .into(); + let summary: SharedString = commit.summary.to_string().into(); + let unix_secs = commit + .time + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + let when: SharedString = format!( + "{} · {}", + commit.author, + crate::view::date_time::format_relative_time(unix_secs, now) + ) + .into(); + + div() + .id(("commit_multi_row", ix)) + .debug_selector(move || format!("commit_multi_row_{ix}")) + .h(scaled_px(MULTI_COMMIT_ROW_HEIGHT_PX)) + .w_full() + .flex() + .flex_col() + .justify_center() + .gap(scaled_px(2.0)) + .px(scaled_px(8.0)) + .border_b_1() + .border_color(theme.colors.border) + .child( + div() + .flex() + .items_center() + .gap(scaled_px(8.0)) + .min_w(px(0.0)) + .child( + div() + .text_sm() + .font_family(crate::view::UI_MONOSPACE_FONT_FAMILY) + .text_color(theme.colors.text_muted) + .child(short_sha), + ) + .child( + div() + .flex_1() + .min_w(px(0.0)) + .text_sm() + .line_clamp(1) + .child(summary), + ), + ) + .child( + div() + .text_xs() + .text_color(theme.colors.text_muted) + .line_clamp(1) + .child(when), + ) + .into_any_element() + }) + .collect() + } + + fn multi_commit_details_view( + &mut self, + repo_id: RepoId, + count: usize, + cx: &mut gpui::Context, + ) -> AnyElement { + let theme = self.theme; + let ui_scale = self.ui_scale(); + + let header = div() + .flex() + .items_center() + .justify_between() + .h(components::control_height_md(ui_scale)) + .px_2() + .bg(theme.colors.surface_bg_elevated) + .border_b_1() + .border_color(theme.colors.border) + .child( + div() + .flex_1() + .min_w(px(0.0)) + .text_sm() + .font_weight(FontWeight::BOLD) + .line_clamp(1) + .child(SharedString::from(format!("{count} commits selected"))), + ) + .child( + components::Button::new("commit_details_close", "") + .start_slot(svg_icon( + "icons/generic_close.svg", + theme.colors.text_muted, + px(12.0), + )) + .style(components::ButtonStyle::Transparent) + .on_click(theme, cx, |this, _e, _w, cx| { + if let Some(repo_id) = this.active_repo_id() { + this.store.dispatch(Msg::ClearCommitSelection { repo_id }); + } + cx.notify(); + }) + .gitcomet_tooltip(theme, "Close commit details".into()), + ); + + let list = uniform_list( + ("commit_multi_list", repo_id.0), + count, + cx.processor(Self::render_multi_commit_rows), + ) + .w_full() + .h_full() + .min_h(px(0.0)) + .track_scroll(&self.commit_multi_scroll); + let list = restrict_scroll_to_vertical_axis(list); + let scrollbar_gutter = components::Scrollbar::visible_gutter( + self.commit_multi_scroll.clone(), + components::ScrollbarAxis::Vertical, + ); + + let body = div() + .id(("commit_multi_container", repo_id.0)) + .relative() + .flex() + .flex_col() + .flex_1() + .h_full() + .min_h(px(0.0)) + .w_full() + .overflow_hidden() + .child( + div() + .w_full() + .flex_1() + .h_full() + .min_h(px(0.0)) + .pr(scrollbar_gutter) + .child(list), + ) + .child( + components::Scrollbar::new( + ("commit_multi_scrollbar", repo_id.0), + self.commit_multi_scroll.clone(), + ) + .render(theme), + ); + + div() + .id("commit_details_container") + .relative() + .flex() + .flex_col() + .flex_1() + .h_full() + .min_h(px(0.0)) + .child(header) + .child( + div() + .id("commit_details_body_container") + .relative() + .flex() + .flex_col() + .flex_1() + .h_full() + .min_h(px(0.0)) + .p_2() + .child(body), + ) + .into_any_element() + } + pub(in super::super) fn commit_details_view( &mut self, cx: &mut gpui::Context, @@ -677,6 +889,16 @@ impl DetailsPaneView { .active_repo() .and_then(|repo| repo.history_state.selected_commit.clone()); + let multi_count = self + .active_repo() + .filter(|repo| repo.history_state.multi_selection.is_multi()) + .map(Self::multi_selected_commits_in_log_order) + .filter(|commits| commits.len() > 1) + .map(|commits| commits.len()); + if let (Some(repo_id), Some(count)) = (active_repo_id, multi_count) { + return self.multi_commit_details_view(repo_id, count, cx); + } + if let (Some(repo_id), Some(selected_id)) = (active_repo_id, selected_id) { let show_delayed_loading = self.commit_details_delay.as_ref().is_some_and(|s| { s.repo_id == repo_id && s.commit_id == selected_id && s.show_loading diff --git a/crates/gitcomet-ui-gpui/src/view/panels/mod.rs b/crates/gitcomet-ui-gpui/src/view/panels/mod.rs index b93f3495..2859e467 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/mod.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/mod.rs @@ -1,4 +1,5 @@ use super::*; +use gitcomet_core::services::InteractiveRebaseAction; const COMMIT_DETAILS_MESSAGE_MAX_HEIGHT_PX: f32 = 240.0; const COMMIT_MESSAGE_INPUT_MAX_HEIGHT_PX: f32 = 200.0; @@ -70,6 +71,10 @@ pub(in crate::view) enum ContextMenuAction { repo_id: RepoId, commit_id: CommitId, }, + /// Opens the squash confirmation prompt for the current multi-selection. + SquashSelectedCommits { + repo_id: RepoId, + }, CheckoutBranch { repo_id: RepoId, name: String, @@ -201,6 +206,22 @@ pub(in crate::view) enum ContextMenuAction { OpenPopover { kind: PopoverKind, }, + LoadInteractiveRebaseSetup { + repo_id: RepoId, + base: String, + }, + OpenInteractiveCherryPickSetup { + repo_id: RepoId, + entries: Vec, + source_colors: Vec<(String, u8)>, + }, + SetInteractiveRebaseAction { + ix: usize, + action: InteractiveRebaseAction, + }, + SetInteractiveRebaseAutosquashMode { + mode: AutosquashMode, + }, ConflictResolverPick { target: ResolverPickTarget, }, diff --git a/crates/gitcomet-ui-gpui/src/view/panels/popover.rs b/crates/gitcomet-ui-gpui/src/view/panels/popover.rs index 086991a6..32cadd96 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/popover.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/popover.rs @@ -1,8 +1,10 @@ use super::*; +use gitcomet_core::services::InteractiveRebaseAction; mod app_menu; mod branch_picker; mod checkout_remote_branch_prompt; +mod cherry_pick_commit_confirm; mod clone_repo; mod commit_prompt; mod conflict_save_stage_confirm; @@ -20,6 +22,7 @@ mod merge_abort_confirm; mod picker_nav; mod pull_reconcile_prompt; mod push_set_upstream_prompt; +mod rebase_onto_confirm; mod recent_repo_picker; mod remote_add_prompt; mod remote_edit_url_prompt; @@ -27,6 +30,7 @@ mod remote_remove_confirm; mod repo_picker; mod reset_prompt; mod search_inputs; +mod squash_prompt; mod stash_drop_confirm; mod stash_picker_prompt; mod stash_prompt; @@ -87,7 +91,10 @@ impl PopoverWidthSpec { } const DEFAULT_CONTEXT_MENU_WIDTH: PopoverWidthSpec = PopoverWidthSpec::range(260.0, 180.0, 380.0); +const COMMIT_MENU_WIDTH: PopoverWidthSpec = PopoverWidthSpec::range(320.0, 260.0, 480.0); const NARROW_CONTEXT_MENU_WIDTH: PopoverWidthSpec = PopoverWidthSpec::range(220.0, 160.0, 220.0); +const REBASE_ACTION_MENU_WIDTH: PopoverWidthSpec = PopoverWidthSpec::fixed(110.0); +const REBASE_AUTOSQUASH_MENU_WIDTH: PopoverWidthSpec = PopoverWidthSpec::fixed(190.0); const CHANGE_TRACKING_MENU_WIDTH: PopoverWidthSpec = PopoverWidthSpec::range(220.0, 220.0, 320.0); const DIFF_ACTION_MENU_WIDTH: PopoverWidthSpec = PopoverWidthSpec::range(240.0, 200.0, 320.0); const DIFF_EDITOR_MENU_WIDTH: PopoverWidthSpec = PopoverWidthSpec::range(260.0, 200.0, 340.0); @@ -136,6 +143,8 @@ pub(in super::super) struct PopoverHost { _file_history_search_input_subscription: Option, _create_branch_input_subscription: gpui::Subscription, _stash_message_input_subscription: gpui::Subscription, + _squash_message_input_subscription: gpui::Subscription, + _squash_description_input_subscription: gpui::Subscription, _submodule_ref_input_subscription: gpui::Subscription, notify_fingerprint: u64, root_view: WeakEntity, @@ -172,6 +181,17 @@ pub(in super::super) struct PopoverHost { create_tag_input: Entity, create_tag_message_input: Entity, create_tag_message_scroll: ScrollHandle, + squash_message_input: Entity, + squash_description_input: Entity, + squash_description_scroll: ScrollHandle, + /// The `(oldest, head)` range the squash prompt's message inputs were last + /// prefilled for. Prevents re-prefilling the same range (so a user who + /// clears the fields keeps them cleared) and, together with the empty-input + /// check, prevents clobbering text the user typed while the preview loaded. + squash_prompt_prefilled_range: Option<( + gitcomet_core::domain::CommitId, + gitcomet_core::domain::CommitId, + )>, remote_name_input: Entity, remote_url_input: Entity, remote_url_edit_input: Entity, @@ -201,12 +221,15 @@ pub(in super::super) struct PopoverHost { clone_repo_submit_focus_handle: FocusHandle, create_tag_cancel_focus_handle: FocusHandle, create_tag_submit_focus_handle: FocusHandle, + squash_cancel_focus_handle: FocusHandle, + squash_submit_focus_handle: FocusHandle, remote_add_cancel_focus_handle: FocusHandle, remote_add_submit_focus_handle: FocusHandle, remote_edit_cancel_focus_handle: FocusHandle, remote_edit_submit_focus_handle: FocusHandle, push_upstream_cancel_focus_handle: FocusHandle, push_upstream_submit_focus_handle: FocusHandle, + rebase_onto_submit_focus_handle: FocusHandle, worktree_browse_focus_handle: FocusHandle, worktree_cancel_focus_handle: FocusHandle, worktree_submit_focus_handle: FocusHandle, @@ -224,6 +247,8 @@ pub(in super::super) struct PopoverHost { submodule_name_input: Entity, submodule_add_advanced_expanded: bool, submodule_force_enabled: bool, + rebase_reword_input: Entity, + rebase_reword_description_input: Entity, } pub(in super::super) fn popover_ui_scale(cx: &mut gpui::Context) -> ui_scale::UiScale { @@ -292,6 +317,8 @@ fn popover_is_context_menu(kind: &PopoverKind) -> bool { | PopoverKind::PreviousCommitMessagesMenu { .. } | PopoverKind::RepoTabMenu { .. } | PopoverKind::DiffActionMenu + | PopoverKind::InteractiveRebaseActionMenu { .. } + | PopoverKind::InteractiveRebaseAutosquashMenu | PopoverKind::HistoryBranchFilter { .. } | PopoverKind::DiffContentModeSettings | PopoverKind::ChangeTrackingSettings @@ -337,7 +364,10 @@ fn popover_is_confirm_dialog(kind: &PopoverKind) -> bool { kind, PopoverKind::StashDropConfirm { .. } | PopoverKind::ForcePushConfirm { .. } + | PopoverKind::CherryPickCommitConfirm { .. } | PopoverKind::MergeAbortConfirm { .. } + | PopoverKind::RebaseOntoConfirm { .. } + | PopoverKind::RebaseReword { .. } | PopoverKind::ConflictSaveStageConfirm { .. } | PopoverKind::ForceDeleteBranchConfirm { .. } | PopoverKind::ForceRemoveWorktreeConfirm { .. } @@ -374,6 +404,19 @@ pub(super) fn hotkey_hint( .child(label) } +/// Shared Cancel button for confirm dialogs and prompt popovers: consistent +/// label, outlined style, and "Esc" hint. Attach the dismiss handler with +/// `.on_click(...)` at the call site. +pub(super) fn cancel_button( + id: &'static str, + hint_debug_selector: &'static str, + theme: AppTheme, +) -> components::Button { + components::Button::new(id, "Cancel") + .separated_end_slot(hotkey_hint(theme, hint_debug_selector, "Esc")) + .style(components::ButtonStyle::Outlined) +} + fn popover_anchor_corner(kind: &PopoverKind) -> Anchor { match kind { PopoverKind::PullPicker @@ -417,11 +460,14 @@ fn popover_anchor_corner(kind: &PopoverKind) -> Anchor { } | PopoverKind::PushSetUpstreamPrompt { .. } | PopoverKind::ForcePushConfirm { .. } + | PopoverKind::CherryPickCommitConfirm { .. } | PopoverKind::MergeAbortConfirm { .. } | PopoverKind::ConflictSaveStageConfirm { .. } | PopoverKind::ForceDeleteBranchConfirm { .. } | PopoverKind::ForceRemoveWorktreeConfirm { .. } | PopoverKind::PullReconcilePrompt { .. } + | PopoverKind::RebaseOntoConfirm { .. } + | PopoverKind::RebaseReword { .. } | PopoverKind::CommitOptionsMenu { .. } | PopoverKind::PreviousCommitMessagesMenu { .. } | PopoverKind::RepoTabMenu { .. } @@ -443,7 +489,8 @@ pub(in super::super) fn popover_width_spec(kind: &PopoverKind) -> Option Some(DIALOG_420_WIDTH), + | PopoverKind::CreateTagPrompt { .. } + | PopoverKind::SquashPrompt { .. } => Some(DIALOG_420_WIDTH), PopoverKind::CreateBranchFromRefPrompt { .. } | PopoverKind::CheckoutRemoteBranchPrompt { .. } => Some(DIALOG_540_WIDTH), PopoverKind::StashDropConfirm { .. } @@ -467,7 +514,9 @@ pub(in super::super) fn popover_width_spec(kind: &PopoverKind) -> Option Some(DIALOG_420_WIDTH), PopoverKind::PushSetUpstreamPrompt { .. } => Some(DIALOG_320_WIDTH), - PopoverKind::ResetPrompt { .. } => Some(DIALOG_380_WIDTH), + PopoverKind::ResetPrompt { .. } + | PopoverKind::RebaseOntoConfirm { .. } + | PopoverKind::CherryPickCommitConfirm { .. } => Some(DIALOG_380_WIDTH), PopoverKind::MergeAbortConfirm { .. } | PopoverKind::ConflictSaveStageConfirm { .. } => { Some(DIALOG_360_WIDTH) } @@ -514,12 +563,12 @@ pub(in super::super) fn popover_width_spec(kind: &PopoverKind) -> Option Some(DIALOG_440_WIDTH), PopoverKind::TerminalMenu { .. } => Some(DEFAULT_CONTEXT_MENU_WIDTH), PopoverKind::DiffActionMenu => Some(DIFF_ACTION_MENU_WIDTH), + PopoverKind::CommitMenu { .. } => Some(COMMIT_MENU_WIDTH), PopoverKind::PullPicker | PopoverKind::PushPicker | PopoverKind::CommitOptionsMenu { .. } | PopoverKind::PreviousCommitMessagesMenu { .. } | PopoverKind::RepoTabMenu { .. } - | PopoverKind::CommitMenu { .. } | PopoverKind::TagMenu { .. } | PopoverKind::TagRefMenu { .. } | PopoverKind::StatusFileMenu { .. } @@ -557,6 +606,9 @@ pub(in super::super) fn popover_width_spec(kind: &PopoverKind) -> Option Some(CONFLICT_CHUNK_MENU_WIDTH), PopoverKind::ConflictResolverOutputMenu { .. } => Some(CONFLICT_OUTPUT_MENU_WIDTH), PopoverKind::StashMenu { .. } => Some(STASH_MENU_WIDTH), + PopoverKind::RebaseReword { .. } => Some(DIALOG_440_WIDTH), + PopoverKind::InteractiveRebaseActionMenu { .. } => Some(REBASE_ACTION_MENU_WIDTH), + PopoverKind::InteractiveRebaseAutosquashMenu => Some(REBASE_AUTOSQUASH_MENU_WIDTH), } } @@ -660,6 +712,11 @@ impl PopoverHost { let subscription = cx.observe(&ui_model, |this, model, cx| { this.state = Arc::clone(&model.read(cx).state); + // Prefill the squash prompt from the message preview when it lands, + // rather than in the render path, so the generated message never + // clobbers text the user typed while it was loading. + this.sync_squash_prompt_prefill(cx); + let Some(popover) = this.popover.as_ref() else { return; }; @@ -766,6 +823,34 @@ impl PopoverHost { input }); + let squash_message_input = cx.new(|cx| { + components::TextInput::new( + components::TextInputOptions { + placeholder: "Commit message".into(), + ..Default::default() + }, + window, + cx, + ) + }); + + let squash_description_scroll = ScrollHandle::new(); + let squash_description_input = cx.new(|cx| { + let mut input = components::TextInput::new( + components::TextInputOptions { + placeholder: "Description (optional)".into(), + multiline: true, + soft_wrap: true, + min_lines: 4, + ..Default::default() + }, + window, + cx, + ); + input.set_vertical_scroll_handle(Some(squash_description_scroll.clone())); + input + }); + let remote_name_input = cx.new(|cx| { components::TextInput::new( components::TextInputOptions { @@ -875,6 +960,36 @@ impl PopoverHost { cx.notify(); }); + // The subject input re-renders the host on every keystroke so the + // Squash button's disabled state (driven by whether the message is + // empty) stays current, and submits on Enter. + let squash_message_input_subscription = + cx.observe(&squash_message_input, |this, input, cx| { + let enter_pressed = input.update(cx, |input, _| input.take_enter_pressed()); + let _ = input.update(cx, |input, _| input.take_escape_pressed()); + + if !matches!(this.popover, Some(PopoverKind::SquashPrompt { .. })) { + return; + } + + if enter_pressed { + this.submit_squash(cx); + return; + } + + cx.notify(); + }); + + // The multiline description input only needs to re-render the host (it + // does not affect the button state, and Enter inserts a newline). + let squash_description_input_subscription = + cx.observe(&squash_description_input, |this, _input, cx| { + if !matches!(this.popover, Some(PopoverKind::SquashPrompt { .. })) { + return; + } + cx.notify(); + }); + let commit_prompt_message_scroll = ScrollHandle::new(); let commit_prompt_message_input = cx.new(|cx| { let mut input = components::TextInput::new( @@ -1034,6 +1149,8 @@ impl PopoverHost { let clone_repo_submit_focus_handle = cx.focus_handle().tab_index(0).tab_stop(true); let create_tag_cancel_focus_handle = cx.focus_handle().tab_index(0).tab_stop(true); let create_tag_submit_focus_handle = cx.focus_handle().tab_index(0).tab_stop(true); + let squash_cancel_focus_handle = cx.focus_handle().tab_index(0).tab_stop(true); + let squash_submit_focus_handle = cx.focus_handle().tab_index(0).tab_stop(true); let create_tag_annotated_focus_handle = cx.focus_handle().tab_index(0).tab_stop(true); let remote_add_cancel_focus_handle = cx.focus_handle().tab_index(0).tab_stop(true); let remote_add_submit_focus_handle = cx.focus_handle().tab_index(0).tab_stop(true); @@ -1041,6 +1158,7 @@ impl PopoverHost { let remote_edit_submit_focus_handle = cx.focus_handle().tab_index(0).tab_stop(true); let push_upstream_cancel_focus_handle = cx.focus_handle().tab_index(0).tab_stop(true); let push_upstream_submit_focus_handle = cx.focus_handle().tab_index(0).tab_stop(true); + let rebase_onto_submit_focus_handle = cx.focus_handle().tab_index(0).tab_stop(true); let worktree_browse_focus_handle = cx.focus_handle().tab_index(0).tab_stop(true); let worktree_cancel_focus_handle = cx.focus_handle().tab_index(0).tab_stop(true); let worktree_submit_focus_handle = cx.focus_handle().tab_index(0).tab_stop(true); @@ -1078,6 +1196,8 @@ impl PopoverHost { _stash_picker_search_input_subscription: None, _create_branch_input_subscription: create_branch_input_subscription, _stash_message_input_subscription: stash_message_input_subscription, + _squash_message_input_subscription: squash_message_input_subscription, + _squash_description_input_subscription: squash_description_input_subscription, _submodule_ref_input_subscription: submodule_ref_input_subscription, notify_fingerprint: 0, root_view, @@ -1111,6 +1231,10 @@ impl PopoverHost { create_tag_input, create_tag_message_input, create_tag_message_scroll, + squash_message_input, + squash_description_input, + squash_description_scroll, + squash_prompt_prefilled_range: None, remote_name_input, remote_url_input, remote_url_edit_input, @@ -1139,12 +1263,15 @@ impl PopoverHost { clone_repo_submit_focus_handle, create_tag_cancel_focus_handle, create_tag_submit_focus_handle, + squash_cancel_focus_handle, + squash_submit_focus_handle, remote_add_cancel_focus_handle, remote_add_submit_focus_handle, remote_edit_cancel_focus_handle, remote_edit_submit_focus_handle, push_upstream_cancel_focus_handle, push_upstream_submit_focus_handle, + rebase_onto_submit_focus_handle, worktree_browse_focus_handle, worktree_cancel_focus_handle, worktree_submit_focus_handle, @@ -1162,6 +1289,29 @@ impl PopoverHost { submodule_name_input, submodule_add_advanced_expanded: false, submodule_force_enabled: false, + rebase_reword_input: cx.new(|cx| { + components::TextInput::new( + components::TextInputOptions { + placeholder: "Commit subject".into(), + ..Default::default() + }, + window, + cx, + ) + }), + rebase_reword_description_input: cx.new(|cx| { + components::TextInput::new( + components::TextInputOptions { + placeholder: "Description (optional)".into(), + multiline: true, + soft_wrap: true, + min_lines: 4, + ..Default::default() + }, + window, + cx, + ) + }), } } @@ -1176,6 +1326,10 @@ impl PopoverHost { .update(cx, |input, cx| input.set_theme(theme, cx)); self.create_tag_input .update(cx, |input, cx| input.set_theme(theme, cx)); + self.squash_message_input + .update(cx, |input, cx| input.set_theme(theme, cx)); + self.squash_description_input + .update(cx, |input, cx| input.set_theme(theme, cx)); self.remote_name_input .update(cx, |input, cx| input.set_theme(theme, cx)); self.remote_url_input @@ -1250,6 +1404,110 @@ impl PopoverHost { cx.notify(); } + /// Validates the repo's current multi-selection against its loaded log and + /// HEAD, returning a squash plan when the selection is eligible. Shared by + /// the squash prompt's render, prefill, and submit paths so they always + /// agree on the range. + pub(in super::super) fn squash_plan_for_repo_id( + &self, + repo_id: RepoId, + ) -> Option { + let repo = self.state.repos.iter().find(|r| r.id == repo_id)?; + let Loadable::Ready(page) = &repo.log else { + return None; + }; + let head = repo.head_commit_id()?; + gitcomet_core::squash::squash_eligibility( + &page.commits, + &repo.history_state.multi_selection.commits, + &head, + ) + } + + /// Populates the squash prompt's inputs from the loaded message preview. + /// Only fires when the preview matches the live plan's range (never a stale + /// preview from an earlier selection) and only while both inputs are still + /// empty for a range not yet prefilled (never over the user's own text). + fn sync_squash_prompt_prefill(&mut self, cx: &mut gpui::Context) { + let Some(PopoverKind::SquashPrompt { repo_id }) = self.popover else { + return; + }; + let Some(plan) = self.squash_plan_for_repo_id(repo_id) else { + return; + }; + let repo = self.state.repos.iter().find(|r| r.id == repo_id); + let Some(Loadable::Ready(preview)) = repo.map(|repo| &repo.history_state.squash_preview) + else { + return; + }; + // The preview must belong to the range currently planned, not a leftover + // from a previous prompt whose PrepareSquash dispatch has not landed yet. + if preview.oldest != plan.oldest || preview.head != plan.head { + return; + } + let range = (plan.oldest.clone(), plan.head.clone()); + if self.squash_prompt_prefilled_range.as_ref() == Some(&range) { + return; + } + // Empty inputs mean the user has not typed anything for this range yet; + // if they had, we must not overwrite it. + let inputs_empty = self + .squash_message_input + .read_with(cx, |input, _| input.text().is_empty()) + && self + .squash_description_input + .read_with(cx, |input, _| input.text().is_empty()); + if !inputs_empty { + return; + } + + let subject = preview.subject.clone(); + let body = preview.body.clone(); + self.squash_prompt_prefilled_range = Some(range); + self.squash_message_input.update(cx, |input, cx| { + input.set_text(subject, cx); + cx.notify(); + }); + self.squash_description_input.update(cx, |input, cx| { + input.set_text(body, cx); + cx.notify(); + }); + } + + /// Reads the squash prompt inputs, builds the final message, and dispatches + /// the squash against the live plan. No-ops if the selection is no longer + /// eligible or the subject is empty. + fn submit_squash(&mut self, cx: &mut gpui::Context) { + let Some(PopoverKind::SquashPrompt { repo_id }) = self.popover else { + return; + }; + let Some(plan) = self.squash_plan_for_repo_id(repo_id) else { + return; + }; + let subject = self + .squash_message_input + .read_with(cx, |input, _| input.text().trim().to_string()); + if subject.is_empty() { + return; + } + let body = self + .squash_description_input + .read_with(cx, |input, _| input.text().to_string()); + let message = if body.trim().is_empty() { + subject + } else { + format!("{subject}\n\n{}", body.trim_end()) + }; + self.store.dispatch(Msg::SquashCommits { + repo_id, + oldest: plan.oldest, + expected_head: plan.head, + message, + count: plan.commit_count, + }); + self.close_popover(cx); + } + pub(in super::super) fn close_popover_and_restore_focus( &mut self, window: &mut Window, @@ -1285,6 +1543,7 @@ impl PopoverHost { | Some(PopoverKind::CommitPrompt { .. }) | Some(PopoverKind::CloneRepo) | Some(PopoverKind::CreateTagPrompt { .. }) + | Some(PopoverKind::SquashPrompt { .. }) | Some(PopoverKind::PushSetUpstreamPrompt { .. }) | Some(PopoverKind::Repo { kind: RepoPopoverKind::Remote(RemotePopoverKind::AddPrompt), @@ -1379,6 +1638,7 @@ impl PopoverHost { Some(PopoverKind::CloneRepo) | Some(PopoverKind::RecentRepositoryPicker) | Some(PopoverKind::CreateTagPrompt { .. }) + | Some(PopoverKind::SquashPrompt { .. }) | Some(PopoverKind::CheckoutRemoteBranchPrompt { .. }) | Some(PopoverKind::PushSetUpstreamPrompt { .. }) | Some(PopoverKind::Repo { @@ -1981,6 +2241,30 @@ impl PopoverHost { .read_with(cx, |i, _| i.focus_handle()); window.focus(&focus, cx); } + PopoverKind::SquashPrompt { .. } => { + let theme = self.theme; + self.squash_prompt_prefilled_range = None; + self.squash_message_input.update(cx, |input, cx| { + input.clear_transient_key_presses(); + input.set_theme(theme, cx); + input.set_text("", cx); + cx.notify(); + }); + self.squash_description_input.update(cx, |input, cx| { + input.clear_transient_key_presses(); + input.set_theme(theme, cx); + input.set_text("", cx); + cx.notify(); + }); + // The preview may already be Ready (e.g. reopening the same + // range); prefill immediately rather than waiting for the + // next model update. + self.sync_squash_prompt_prefill(cx); + let focus = self + .squash_message_input + .read_with(cx, |i, _| i.focus_handle()); + window.focus(&focus, cx); + } PopoverKind::CreateTagPrompt { .. } => { let theme = self.theme; self.create_tag_annotated = @@ -2171,6 +2455,39 @@ impl PopoverHost { .read_with(cx, |i, _| i.focus_handle()); window.focus(&focus, cx); } + PopoverKind::RebaseReword { + ix: _, + original_action: _, + original_message, + } => { + let theme = self.theme; + let (subject, body) = original_message + .split_once("\n\n") + .map(|(s, b)| (s.to_owned(), b.to_owned())) + .unwrap_or_else(|| (original_message.clone(), String::new())); + self.rebase_reword_input.update(cx, |input, cx| { + input.clear_transient_key_presses(); + input.set_theme(theme, cx); + input.set_text(subject, cx); + cx.notify(); + }); + self.rebase_reword_description_input + .update(cx, |input, cx| { + input.clear_transient_key_presses(); + input.set_theme(theme, cx); + input.set_text(body, cx); + cx.notify(); + }); + let focus = self + .rebase_reword_input + .read_with(cx, |i, _| i.focus_handle()); + window.focus(&focus, cx); + } + PopoverKind::RebaseOntoConfirm { .. } => { + // Focus the primary (Rebase) button so Enter confirms and + // Tab/Esc still reach Cancel. + window.focus(&self.rebase_onto_submit_focus_handle, cx); + } k if popover_is_confirm_dialog(k) => { window.focus(&self.prompt_tab_group_focus_handle, cx); } @@ -2538,6 +2855,7 @@ impl PopoverHost { target, mode, } => reset_prompt::panel(self, repo_id, target, mode, cx), + PopoverKind::SquashPrompt { repo_id } => squash_prompt::panel(self, repo_id, cx), PopoverKind::CreateTagPrompt { repo_id, target } => { create_tag_prompt::panel(self, repo_id, target, cx) } @@ -2618,6 +2936,9 @@ impl PopoverHost { PopoverKind::ForcePushConfirm { repo_id } => { force_push_confirm::panel(self, repo_id, cx) } + PopoverKind::CherryPickCommitConfirm { repo_id, commit_id } => { + cherry_pick_commit_confirm::panel(self, repo_id, commit_id, cx) + } PopoverKind::MergeAbortConfirm { repo_id } => { merge_abort_confirm::panel(self, repo_id, cx) } @@ -2840,6 +3161,150 @@ impl PopoverHost { cx, ), PopoverKind::AppMenu => app_menu::panel(self, cx), + PopoverKind::RebaseOntoConfirm { repo_id, onto } => { + rebase_onto_confirm::panel(self, repo_id, onto, cx) + } + PopoverKind::InteractiveRebaseActionMenu { .. } + | PopoverKind::InteractiveRebaseAutosquashMenu => { + self.context_menu_view(kind.clone(), cx) + } + PopoverKind::RebaseReword { + ix, + original_action, + original_message: _, + } => { + let theme = self.theme; + let submit_button_id = "reword_save"; + let main_pane = self.main_pane.clone(); + let submit = cx.listener(move |this, _: &gpui::ClickEvent, window, cx| { + let subject = this + .rebase_reword_input + .read_with(cx, |input, _| input.text().to_string()); + let body = this + .rebase_reword_description_input + .read_with(cx, |input, _| input.text().to_string()); + let new_message = if body.trim().is_empty() { + subject.clone() + } else { + format!("{subject}\n\n{body}") + }; + main_pane.update(cx, |pane, cx| { + if subject.is_empty() { + // Empty subject → discard any previous override and revert + // the action. Use set_rebase_action so side-effects + // (squash-target cleanup, notify) are handled consistently. + if let Some(entry) = pane + .active_irebase_mut() + .and_then(|st| st.entries.get_mut(ix)) + { + entry.new_message = None; + } + pane.set_rebase_action(ix, original_action, cx); + } else if let Some(entry) = pane + .active_irebase_mut() + .and_then(|st| st.entries.get_mut(ix)) + { + entry.action = InteractiveRebaseAction::Reword; + entry.new_message = Some(new_message); + cx.notify(); + } + }); + this.close_popover_and_restore_focus(window, cx); + }); + let cancel = cx.listener(move |this, _: &gpui::ClickEvent, window, cx| { + this.main_pane.update(cx, |pane, cx| { + pane.set_rebase_action(ix, original_action, cx); + }); + this.close_popover_and_restore_focus(window, cx); + }); + + div() + .flex() + .flex_col() + .w(scaled_px(440.0)) + .child( + div() + .px_2() + .py_1() + .text_sm() + .font_weight(FontWeight::BOLD) + .child("Reword commit message"), + ) + .child(div().border_t_1().border_color(theme.colors.border)) + .child( + div() + .px_2() + .pt_2() + .pb_1() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_xs() + .text_color(theme.colors.text_muted) + .child("Commit message"), + ) + .child(self.rebase_reword_input.clone()), + ) + .child( + div() + .px_2() + .pt_1() + .pb_2() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_xs() + .text_color(theme.colors.text_muted) + .child("Description"), + ) + .child( + div() + .w_full() + .min_w(px(0.0)) + .child(self.rebase_reword_description_input.clone()), + ), + ) + .child( + div() + .px_2() + .pb_1() + .text_xs() + .text_color(theme.colors.text_muted) + .child( + "Clear the message and save to keep the original commit message.", + ), + ) + .child(div().border_t_1().border_color(theme.colors.border)) + .child( + div() + .px_2() + .py_1() + .flex() + .items_center() + .justify_between() + .child( + components::Button::new("reword_cancel", "Cancel") + .separated_end_slot(hotkey_hint( + theme, + "reword_cancel_hint", + "Esc", + )) + .style(components::ButtonStyle::Outlined) + .render(theme, ui_scale_percent) + .on_click(cancel), + ) + .child( + components::Button::new(submit_button_id, "Save message") + .style(components::ButtonStyle::Filled) + .render(theme, ui_scale_percent) + .on_click(submit), + ), + ) + } PopoverKind::TerminalShutdownConfirm(prompt) => { terminal_shutdown_confirm::panel(self, prompt, cx) } diff --git a/crates/gitcomet-ui-gpui/src/view/panels/popover/branch_picker.rs b/crates/gitcomet-ui-gpui/src/view/panels/popover/branch_picker.rs index 235ea279..69ca70f5 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/popover/branch_picker.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/popover/branch_picker.rs @@ -2,8 +2,10 @@ use super::*; pub(super) fn panel(this: &mut PopoverHost, cx: &mut gpui::Context) -> gpui::Div { let theme = this.theme; - let ui_scale_percent = super::popover_ui_scale_percent(cx); + let ui_scale = super::popover_ui_scale(cx); + let ui_scale_percent = ui_scale.percent(); let scaled_px = |value: f32| super::popover_scaled_px_from_percent(value, ui_scale_percent); + let width = super::PICKER_WIDTH; let is_delete = matches!( this.popover, Some(PopoverKind::BranchPicker { @@ -19,8 +21,8 @@ pub(super) fn panel(this: &mut PopoverHost, cx: &mut gpui::Context) let mut menu = div() .flex() .flex_col() - .min_w(scaled_px(420.0)) - .max_w(scaled_px(820.0)) + .min_w(width.min_px(ui_scale)) + .max_w(width.max_px(ui_scale)) .child( div() .px_2() @@ -74,16 +76,16 @@ pub(super) fn panel(this: &mut PopoverHost, cx: &mut gpui::Context) let name = branch.name.clone(); let label: SharedString = name.clone().into(); menu = menu.child( - components::context_menu_entry( + components::ContextMenuEntry::new( ("branch_item", ix), - theme, - ui_scale_percent, - false, - false, - None, - label, - None, + components::ContextMenuText::new(label) + .max_lines(1) + .tooltip_mode( + components::TruncatedTextTooltipMode::FullTextIfTruncated, + ), ) + .tooltip_host(this.tooltip_host.clone()) + .render(theme, ui_scale_percent, cx) .on_click(cx.listener( move |this, _e: &ClickEvent, _w, cx| { this.handle_inline_branch_picker_select( @@ -109,9 +111,9 @@ pub(super) fn panel(this: &mut PopoverHost, cx: &mut gpui::Context) } } - components::context_menu(theme, menu) - .w(scaled_px(420.0)) - .max_w(scaled_px(820.0)) + // Fixed width: PickerPrompt rows size with `w_full`, which does not + // stretch under fit-content parents. + components::context_menu(theme, menu).w(width.preferred_px(ui_scale)) } fn branch_picker_status_panel( diff --git a/crates/gitcomet-ui-gpui/src/view/panels/popover/cherry_pick_commit_confirm.rs b/crates/gitcomet-ui-gpui/src/view/panels/popover/cherry_pick_commit_confirm.rs new file mode 100644 index 00000000..20201b70 --- /dev/null +++ b/crates/gitcomet-ui-gpui/src/view/panels/popover/cherry_pick_commit_confirm.rs @@ -0,0 +1,113 @@ +use super::*; + +pub(super) fn panel( + this: &mut PopoverHost, + repo_id: RepoId, + commit_id: CommitId, + cx: &mut gpui::Context, +) -> gpui::Div { + let theme = this.theme; + let sha = commit_id.as_ref(); + let short = sha.get(0..7).unwrap_or(sha).to_string(); + let summary = this + .state + .repos + .iter() + .find(|repo| repo.id == repo_id) + .and_then(|repo| match &repo.log { + Loadable::Ready(page) => page + .commits + .iter() + .find(|commit| commit.id == commit_id) + .map(|commit| commit.summary.to_string()), + _ => None, + }) + .unwrap_or_default(); + 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 dispatch = + move |this: &mut PopoverHost, commit_now: bool, cx: &mut gpui::Context| { + this.store.dispatch(Msg::CherryPickCommit { + repo_id, + commit_id: commit_id.clone(), + commit: commit_now, + summary: summary.clone(), + }); + this.popover = None; + this.popover_anchor = None; + cx.notify(); + }; + + div() + .flex() + .flex_col() + .min_w(scaled_px(380.0)) + .child( + div() + .px_2() + .py_1() + .text_sm() + .font_weight(FontWeight::BOLD) + .child("Cherry-pick commit?"), + ) + .child(div().border_t_1().border_color(theme.colors.border)) + .child( + div() + .px_2() + .py_1() + .text_sm() + .text_color(theme.colors.text_muted) + .child(format!("Apply {short} to the current branch?")), + ) + .child( + div() + .px_2() + .pb_1() + .text_xs() + .text_color(theme.colors.text_muted) + .child("Commit the cherry-picked change immediately?"), + ) + .child(div().border_t_1().border_color(theme.colors.border)) + .child( + div() + .px_2() + .py_1() + .flex() + .items_center() + .justify_between() + .child( + super::cancel_button( + "cherry_pick_commit_cancel", + "cherry_pick_commit_cancel_hint", + theme, + ) + .on_click(theme, cx, |this, _e, _w, cx| { + this.popover = None; + this.popover_anchor = None; + cx.notify(); + }), + ) + .child( + div() + .flex() + .items_center() + .gap_1() + .child( + components::Button::new("cherry_pick_commit_no", "No") + .style(components::ButtonStyle::Outlined) + .on_click(theme, cx, { + let dispatch = dispatch.clone(); + move |this, _e, _w, cx| dispatch(this, false, cx) + }), + ) + .child( + components::Button::new("cherry_pick_commit_yes", "Yes") + .style(components::ButtonStyle::Filled) + .on_click(theme, cx, move |this, _e, _w, cx| { + dispatch(this, true, 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 fca5600c..2deccdcd 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 @@ -384,6 +384,24 @@ impl PopoverHost { PopoverKind::DiffContentModeSettings => Some(diff_content_mode_settings::model(self)), PopoverKind::ChangeTrackingSettings => Some(change_tracking_settings::model(self)), PopoverKind::UiScalePicker => Some(ui_scale_picker::model(cx)), + PopoverKind::InteractiveRebaseActionMenu { + ix, + can_squash, + can_drop, + } => { + let is_squash_target = self + .main_pane + .read_with(cx, |pane, _| pane.active_entry_is_squash_target(*ix)); + Some(interactive_rebase_action_menu_model( + *ix, + *can_squash, + *can_drop, + is_squash_target, + )) + } + PopoverKind::InteractiveRebaseAutosquashMenu => { + Some(interactive_rebase_autosquash_menu_model()) + } PopoverKind::TerminalMenu { repo_id, context } => { Some(terminal::model(*repo_id, *context, cx)) } @@ -578,13 +596,51 @@ impl PopoverHost { .dispatch(Msg::CheckoutCommit { repo_id, commit_id }); } ContextMenuAction::CherryPickCommit { repo_id, commit_id } => { - self.store - .dispatch(Msg::CherryPickCommit { repo_id, commit_id }); + let anchor = self + .popover_anchor + .as_ref() + .map(|anchor| match anchor { + PopoverAnchor::Point(point) => *point, + PopoverAnchor::Bounds(bounds) => bounds.bottom_right(), + PopoverAnchor::Centered => point(px(64.0), px(64.0)), + }) + .unwrap_or_else(|| point(px(64.0), px(64.0))); + self.open_popover_at( + PopoverKind::CherryPickCommitConfirm { repo_id, commit_id }, + anchor, + window, + cx, + ); + return; } ContextMenuAction::RevertCommit { repo_id, commit_id } => { self.store .dispatch(Msg::RevertCommit { repo_id, commit_id }); } + ContextMenuAction::SquashSelectedCommits { repo_id } => { + // PrepareSquash and the eventual SquashCommits are both + // discarded silently when the git runtime is unavailable, which + // would leave the prompt stuck on "Building combined message…". + // Don't open it in that state. + if !self.state.git_runtime.is_available() { + self.close_popover(cx); + return; + } + // Kick off the combined-message preview, then swap the menu + // for the confirmation prompt. + self.store.dispatch(Msg::PrepareSquash { repo_id }); + let anchor = self + .popover_anchor + .as_ref() + .map(|anchor| match anchor { + PopoverAnchor::Point(point) => *point, + PopoverAnchor::Bounds(bounds) => bounds.bottom_right(), + PopoverAnchor::Centered => point(px(64.0), px(64.0)), + }) + .unwrap_or_else(|| point(px(64.0), px(64.0))); + self.open_popover_at(PopoverKind::SquashPrompt { repo_id }, anchor, window, cx); + return; + } ContextMenuAction::CheckoutBranch { repo_id, name } => { self.store.dispatch(Msg::CheckoutBranch { repo_id, name }); } @@ -874,6 +930,72 @@ impl PopoverHost { crate::app::set_app_ui_scale_percent(cx, percent); }); } + ContextMenuAction::LoadInteractiveRebaseSetup { repo_id, base } => { + self.store + .dispatch(Msg::LoadInteractiveRebaseSetup { repo_id, base }); + } + ContextMenuAction::OpenInteractiveCherryPickSetup { + repo_id, + entries, + source_colors, + } => { + self.store.dispatch(Msg::OpenInteractiveCherryPickSetup { + repo_id, + entries, + source_colors, + }); + } + ContextMenuAction::SetInteractiveRebaseAction { ix, action } => { + let root_view = self.root_view.clone(); + let was_reword = action == InteractiveRebaseAction::Reword; + let reword_state = if was_reword { + self.main_pane.read_with(cx, |pane, _| { + pane.active_irebase() + .and_then(|st| st.entries.get(ix)) + .map(|e| { + let msg = e.new_message.as_ref().unwrap_or(&e.summary).clone(); + (e.action, msg) + }) + }) + } else { + None + }; + self.main_pane.update(cx, |pane, cx| { + pane.set_rebase_action(ix, action, cx); + }); + if let Some((original_action, msg)) = reword_state { + let wh = window.window_handle(); + cx.defer(move |cx| { + let _ = wh.update(cx, |_, window, cx| { + let _ = root_view.update(cx, |root, cx| { + root.open_popover_centered( + PopoverKind::RebaseReword { + ix, + original_action, + original_message: msg, + }, + window, + cx, + ); + }); + }); + }); + } + } + ContextMenuAction::SetInteractiveRebaseAutosquashMode { mode } => { + let applied = self + .main_pane + .update(cx, |pane, cx| pane.apply_autosquash_mode(mode, cx)); + if !applied { + self.push_toast( + components::ToastKind::Warning, + "No automatic squashable commits found. Auto Squash searches for \ + commits with identical messages and amend-commits them." + .to_string(), + cx, + ); + } + } ContextMenuAction::OpenPopover { kind } => { let anchor = self .popover_anchor @@ -1180,66 +1302,77 @@ impl PopoverHost { .filter(|&ix| model.is_selectable(ix)) .or_else(|| model.first_selectable()); - components::context_menu( - theme, - div() - .w_full() - .min_w_full() - .flex() - .flex_col() - .items_stretch() - .track_focus(&focus) - .key_context("ContextMenu") - .on_mouse_down( - MouseButton::Left, - cx.listener(|this, _e: &MouseDownEvent, window, cx| { - window.focus(&this.context_menu_focus_handle, cx); - }), - ) - .on_key_down( - cx.listener(move |this, e: &gpui::KeyDownEvent, window, cx| { - let key = e.keystroke.key.as_str(); - let mods = e.keystroke.modifiers; - if mods.control || mods.platform || mods.alt || mods.function { - return; - } + // Keep labels aligned across entries when only some of them (e.g. the + // checked option) carry an icon; icon-less menus stay compact. + let reserve_icon_column = model + .items + .iter() + .any(|item| matches!(item, ContextMenuItem::Entry { icon: Some(_), .. })); - match key { - "escape" => { - cx.stop_propagation(); - this.close_popover_and_restore_focus(window, cx); - } - "up" => { - cx.stop_propagation(); - let next = model_for_keys - .next_selectable(this.context_menu_selected_ix, -1); - this.context_menu_selected_ix = next; - cx.notify(); - } - "down" => { - cx.stop_propagation(); - let next = model_for_keys - .next_selectable(this.context_menu_selected_ix, 1); - this.context_menu_selected_ix = next; - cx.notify(); - } - "home" => { - cx.stop_propagation(); - this.context_menu_selected_ix = model_for_keys.first_selectable(); - cx.notify(); - } - "end" => { - cx.stop_propagation(); - this.context_menu_selected_ix = model_for_keys.last_selectable(); - cx.notify(); - } - "enter" => { - let Some(ix) = context_menu_activate_entry_ix( - &model_for_keys, - this.context_menu_selected_ix, - ) else { - return; - }; + div() + .flex() + .flex_col() + .items_stretch() + .text_color(theme.colors.text) + .min_w(width.min_px(ui_scale)) + .max_w(width.max_px(ui_scale)) + .track_focus(&focus) + .key_context("ContextMenu") + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _e: &MouseDownEvent, window, cx| { + window.focus(&this.context_menu_focus_handle, cx); + }), + ) + .on_key_down( + cx.listener(move |this, e: &gpui::KeyDownEvent, window, cx| { + let key = e.keystroke.key.as_str(); + let mods = e.keystroke.modifiers; + if mods.control || mods.platform || mods.alt || mods.function { + return; + } + + match key { + "escape" => { + cx.stop_propagation(); + this.close_popover_and_restore_focus(window, cx); + } + "up" => { + cx.stop_propagation(); + let next = + model_for_keys.next_selectable(this.context_menu_selected_ix, -1); + this.context_menu_selected_ix = next; + cx.notify(); + } + "down" => { + cx.stop_propagation(); + let next = + model_for_keys.next_selectable(this.context_menu_selected_ix, 1); + this.context_menu_selected_ix = next; + cx.notify(); + } + "home" => { + cx.stop_propagation(); + this.context_menu_selected_ix = model_for_keys.first_selectable(); + cx.notify(); + } + "end" => { + cx.stop_propagation(); + this.context_menu_selected_ix = model_for_keys.last_selectable(); + cx.notify(); + } + "enter" => { + let Some(ix) = context_menu_activate_entry_ix( + &model_for_keys, + this.context_menu_selected_ix, + ) else { + return; + }; + cx.stop_propagation(); + this.context_menu_activate_model_entry(&model_for_keys, ix, window, cx); + } + _ => { + if let Some(ix) = context_menu_shortcut_entry_ix(&model_for_keys, key) { cx.stop_propagation(); this.context_menu_activate_model_entry( &model_for_keys, @@ -1248,138 +1381,199 @@ impl PopoverHost { cx, ); } - _ => { - if let Some(ix) = - context_menu_shortcut_entry_ix(&model_for_keys, key) - { + } + } + }), + ) + .children(model.items.into_iter().enumerate().map(move |(ix, item)| { + match item { + ContextMenuItem::Separator => { + components::context_menu_separator(theme, ui_scale) + .id(("context_menu_sep", ix)) + .into_any_element() + } + ContextMenuItem::Header(title) => components::context_menu_header( + theme, + ui_scale, + title, + Some(tooltip_host.clone()), + cx, + ) + .id(("context_menu_header", ix)) + .into_any_element(), + ContextMenuItem::Label(text) => components::context_menu_label( + theme, + ui_scale, + text, + Some(tooltip_host.clone()), + cx, + ) + .id(("context_menu_label", ix)) + .into_any_element(), + ContextMenuItem::Entry { + label, + icon, + shortcut, + disabled, + action, + } => { + let selected = selected_for_render == Some(ix); + let debug_selector = context_menu_entry_debug_selector(label.as_ref()); + let tooltip_text = entry_tooltips + .get(&ix) + .cloned() + .or_else(|| context_menu_entry_tooltip(action.as_ref())); + let tooltip_host_for_move = tooltip_host.clone(); + let tooltip_text_for_move = tooltip_text.clone(); + let tooltip_host_for_hover = tooltip_host.clone(); + let activate_on_left_release = model_for_mouse.clone(); + let activate_on_right_release = model_for_mouse.clone(); + let icon_slot = match icon { + Some(icon) => components::ContextMenuIconSlot::Icon(icon), + None if reserve_icon_column => { + components::ContextMenuIconSlot::Reserved + } + None => components::ContextMenuIconSlot::None, + }; + let row = + components::ContextMenuEntry::new(("context_menu_entry", ix), label) + .icon(icon_slot) + .shortcut(shortcut) + .selected(selected) + .disabled(disabled) + .tooltip_host(tooltip_host.clone()) + .render(theme, ui_scale, cx) + .debug_selector(move || debug_selector.clone()); + + row.on_mouse_move(cx.listener( + move |this, event: &MouseMoveEvent, _w, cx| { + this.context_menu_selected_ix = Some(ix); + if let Some(tooltip_text) = tooltip_text_for_move.as_ref() { + let _ = tooltip_host_for_move.update(cx, |host, cx| { + host.on_mouse_moved(event.position, cx); + host.set_tooltip_text_if_changed( + Some(tooltip_text.clone()), + cx, + ); + }); + } + cx.notify(); + }, + )) + .on_hover(cx.listener(move |this, hovering: &bool, _w, cx| { + if *hovering { + this.context_menu_selected_ix = Some(ix); + cx.notify(); + } else if let Some(tooltip_text) = tooltip_text.as_ref() { + let _ = tooltip_host_for_hover.update(cx, |host, cx| { + host.clear_tooltip_if_matches(tooltip_text, cx); + }); + } + })) + .when(!disabled, |row| { + row.on_mouse_up( + MouseButton::Left, + cx.listener(move |this, _e: &MouseUpEvent, window, cx| { cx.stop_propagation(); this.context_menu_activate_model_entry( - &model_for_keys, + &activate_on_left_release, ix, window, cx, ); - } - } - } - }), - ) - .children(model.items.into_iter().enumerate().map(move |(ix, item)| { - match item { - ContextMenuItem::Separator => { - components::context_menu_separator(theme, ui_scale) - .id(("context_menu_sep", ix)) - .into_any_element() - } - ContextMenuItem::Header(title) => components::context_menu_header( - theme, - ui_scale, - title, - Some(tooltip_host.clone()), - cx, - ) - .id(("context_menu_header", ix)) - .into_any_element(), - ContextMenuItem::Label(text) => components::context_menu_label( - theme, - ui_scale, - text, - Some(tooltip_host.clone()), - cx, - ) - .id(("context_menu_label", ix)) - .into_any_element(), - ContextMenuItem::Entry { - label, - icon, - shortcut, - disabled, - action, - } => { - let selected = selected_for_render == Some(ix); - let debug_selector = context_menu_entry_debug_selector(label.as_ref()); - let tooltip_text = entry_tooltips - .get(&ix) - .cloned() - .or_else(|| context_menu_entry_tooltip(action.as_ref())); - let tooltip_host_for_move = tooltip_host.clone(); - let tooltip_text_for_move = tooltip_text.clone(); - let tooltip_host_for_hover = tooltip_host.clone(); - let activate_on_left_release = model_for_mouse.clone(); - let activate_on_right_release = model_for_mouse.clone(); - let row = components::context_menu_entry( - ("context_menu_entry", ix), - theme, - ui_scale, - selected, - disabled, - icon, - label, - shortcut, + }), ) - .debug_selector(move || debug_selector.clone()); - - row.on_mouse_move(cx.listener( - move |this, event: &MouseMoveEvent, _w, cx| { - this.context_menu_selected_ix = Some(ix); - if let Some(tooltip_text) = tooltip_text_for_move.as_ref() { - let _ = tooltip_host_for_move.update(cx, |host, cx| { - host.on_mouse_moved(event.position, cx); - host.set_tooltip_text_if_changed( - Some(tooltip_text.clone()), - cx, - ); - }); - } - cx.notify(); - }, - )) - .on_hover(cx.listener(move |this, hovering: &bool, _w, cx| { - if *hovering { - this.context_menu_selected_ix = Some(ix); - cx.notify(); - } else if let Some(tooltip_text) = tooltip_text.as_ref() { - let _ = tooltip_host_for_hover.update(cx, |host, cx| { - host.clear_tooltip_if_matches(tooltip_text, cx); - }); - } - })) - .when(!disabled, |row| { - row.on_mouse_up( - MouseButton::Left, - cx.listener(move |this, _e: &MouseUpEvent, window, cx| { - cx.stop_propagation(); - this.context_menu_activate_model_entry( - &activate_on_left_release, - ix, - window, - cx, - ); - }), - ) - .on_mouse_up( - MouseButton::Right, - cx.listener(move |this, _e: &MouseUpEvent, window, cx| { - cx.stop_propagation(); - this.context_menu_activate_model_entry( - &activate_on_right_release, - ix, - window, - cx, - ); - }), - ) - }) - .into_any_element() - } + .on_mouse_up( + MouseButton::Right, + cx.listener(move |this, _e: &MouseUpEvent, window, cx| { + cx.stop_propagation(); + this.context_menu_activate_model_entry( + &activate_on_right_release, + ix, + window, + cx, + ); + }), + ) + }) + .into_any_element() } - })) - .into_any_element(), - ) - .w(width.preferred_px(ui_scale)) - .min_w(width.min_px(ui_scale)) - .max_w(width.max_px(ui_scale)) + } + })) + } +} + +fn interactive_rebase_action_menu_model( + ix: usize, + can_squash: bool, + can_drop: bool, + is_squash_target: bool, +) -> ContextMenuModel { + let mut items = vec![ + ContextMenuItem::Entry { + label: "pick".into(), + icon: None, + shortcut: None, + // A commit that a later commit squashes into cannot go back to a + // plain pick — it must keep receiving the squashed changes. + disabled: is_squash_target, + action: Box::new(ContextMenuAction::SetInteractiveRebaseAction { + ix, + action: InteractiveRebaseAction::Pick, + }), + }, + ContextMenuItem::Entry { + label: "reword".into(), + icon: None, + shortcut: None, + disabled: false, + action: Box::new(ContextMenuAction::SetInteractiveRebaseAction { + ix, + action: InteractiveRebaseAction::Reword, + }), + }, + ContextMenuItem::Entry { + label: "drop".into(), + icon: None, + shortcut: None, + disabled: !can_drop, + action: Box::new(ContextMenuAction::SetInteractiveRebaseAction { + ix, + action: InteractiveRebaseAction::Drop, + }), + }, + ]; + if can_squash { + items.push(ContextMenuItem::Entry { + label: "squash".into(), + icon: None, + shortcut: None, + disabled: false, + action: Box::new(ContextMenuAction::SetInteractiveRebaseAction { + ix, + action: InteractiveRebaseAction::Squash, + }), + }); } + ContextMenuModel::new(items) +} + +fn interactive_rebase_autosquash_menu_model() -> ContextMenuModel { + // Auto Squash is a one-shot action: pick a strategy and it folds the + // duplicate-message commits, no persisted on/off state to display. + let entry = |mode: AutosquashMode| ContextMenuItem::Entry { + label: mode.label().into(), + icon: None, + shortcut: None, + disabled: false, + action: Box::new(ContextMenuAction::SetInteractiveRebaseAutosquashMode { mode }), + }; + ContextMenuModel::new(vec![ + ContextMenuItem::Header("Auto Squash".into()), + entry(AutosquashMode::ToTop), + entry(AutosquashMode::Neighbor), + entry(AutosquashMode::ToBottom), + ]) } #[cfg(test)] 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 40bb022a..de9a3ec3 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 @@ -37,6 +37,10 @@ pub(super) fn model( let is_current_branch = active_branch_name .as_ref() .is_some_and(|branch| branch == name); + // Name the branch being moved rather than the opaque "HEAD". + let current_branch_label = active_branch_name + .clone() + .unwrap_or_else(|| "HEAD".to_string()); items.push(ContextMenuItem::Entry { label: "Checkout".into(), @@ -113,6 +117,18 @@ pub(super) fn model( reference: name.clone(), }), }); + items.push(ContextMenuItem::Entry { + label: format!("Rebase {current_branch_label} onto {name}").into(), + icon: Some("icons/arrow_up.svg".into()), + shortcut: Some("B".into()), + disabled: false, + action: Box::new(ContextMenuAction::OpenPopover { + kind: PopoverKind::RebaseOntoConfirm { + repo_id, + onto: name.clone(), + }, + }), + }); } items.push(ContextMenuItem::Entry { label: "Delete branch".into(), @@ -160,6 +176,18 @@ pub(super) fn model( reference: name.clone(), }), }); + items.push(ContextMenuItem::Entry { + label: format!("Rebase {current_branch_label} onto {name}").into(), + icon: Some("icons/arrow_up.svg".into()), + shortcut: Some("B".into()), + disabled: false, + action: Box::new(ContextMenuAction::OpenPopover { + kind: PopoverKind::RebaseOntoConfirm { + repo_id, + onto: name.clone(), + }, + }), + }); items.push(ContextMenuItem::Separator); items.push(ContextMenuItem::Entry { label: "Delete remote branch…".into(), diff --git a/crates/gitcomet-ui-gpui/src/view/panels/popover/context_menu/commit.rs b/crates/gitcomet-ui-gpui/src/view/panels/popover/context_menu/commit.rs index 1a96934d..320eec3f 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/popover/context_menu/commit.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/popover/context_menu/commit.rs @@ -1,4 +1,75 @@ use super::*; +use gitcomet_core::services::{InteractiveRebaseAction, InteractiveRebaseEntry}; + +fn multi_cherry_pick_plan( + this: &PopoverHost, + repo_id: RepoId, + commit_id: &CommitId, +) -> Option<(Vec, Vec<(String, u8)>)> { + let repo = this.active_repo().filter(|repo| repo.id == repo_id)?; + let selection = &repo.history_state.multi_selection; + if !(selection.is_multi() && selection.contains(commit_id)) { + return None; + } + let Loadable::Ready(page) = &repo.log else { + return None; + }; + + let branches = match &repo.branches { + Loadable::Ready(branches) => Some(branches), + _ => None, + }; + let remote_branches = match &repo.remote_branches { + Loadable::Ready(branches) => Some(branches), + _ => None, + }; + let branch_heads = branches + .into_iter() + .flat_map(|branches| branches.iter().map(|branch| branch.target.as_ref())) + .chain( + remote_branches + .into_iter() + .flat_map(|branches| branches.iter().map(|branch| branch.target.as_ref())), + ) + .collect::>(); + let head_target = repo.head_commit_id(); + let graph_rows = crate::view::history_graph::compute_graph( + &page.commits, + this.theme, + branch_heads, + head_target.as_ref().map(|head| head.as_ref()), + ); + + let mut selected = page + .commits + .iter() + .enumerate() + .filter(|(_, commit)| selection.contains(&commit.id)) + .collect::>(); + if selected.len() < 2 { + return None; + } + selected.reverse(); + + let mut entries = Vec::with_capacity(selected.len()); + let mut source_colors = Vec::with_capacity(selected.len()); + for (page_ix, commit) in selected { + entries.push(InteractiveRebaseEntry { + action: InteractiveRebaseAction::Pick, + commit_id: commit.id.as_ref().to_string(), + summary: commit.summary.to_string(), + new_message: None, + }); + let color_ix = graph_rows + .get(page_ix) + .and_then(|row| row.lanes_now.get(usize::from(row.node_col))) + .map(|lane| lane.color_ix) + .unwrap_or(0); + source_colors.push((commit.id.as_ref().to_string(), color_ix)); + } + + Some((entries, source_colors)) +} pub(super) fn model(this: &PopoverHost, repo_id: RepoId, commit_id: &CommitId) -> ContextMenuModel { let sha = commit_id.as_ref().to_string(); @@ -16,11 +87,89 @@ pub(super) fn model(this: &PopoverHost, repo_id: RepoId, commit_id: &CommitId) - }) .unwrap_or_default(); - let mut items = vec![ContextMenuItem::Header(format!("Commit {short}").into())]; + let branch_names: Vec = this + .active_repo() + .and_then(|r| match &r.branches { + Loadable::Ready(branches) => Some( + branches + .iter() + .filter(|b| b.target == *commit_id) + .map(|b| b.name.clone()) + .collect(), + ), + _ => None, + }) + .unwrap_or_default(); + + let header_text: SharedString = match branch_names.as_slice() { + [] => format!("Commit {short}").into(), + [name] => name.clone().into(), + names => names.join(", ").into(), + }; + let mut items = vec![ContextMenuItem::Header( + components::ContextMenuText::new(header_text).max_lines(2), + )]; if !commit_summary.is_empty() { - items.push(ContextMenuItem::Label(commit_summary.into())); + items.push(ContextMenuItem::Label( + components::ContextMenuText::new(commit_summary).max_lines(4), + )); } items.push(ContextMenuItem::Separator); + let multi_cherry_pick_plan = multi_cherry_pick_plan(this, repo_id, commit_id); + let has_multi_cherry_pick = multi_cherry_pick_plan.is_some(); + let cherry_pick_disabled = this + .active_repo() + .filter(|repo| repo.id == repo_id) + .is_some_and(|repo| { + repo.local_actions_in_flight > 0 + || matches!(repo.rebase_in_progress, Loadable::Ready(true)) + || matches!(&repo.merge_commit_message, Loadable::Ready(Some(_))) + }); + + // "Squash N commits" appears only when the right-clicked commit is part + // of the active multi-selection and the whole selection passes the squash + // criteria (contiguous linear first-parent chain, non-root base). The + // range may end at HEAD or sit anywhere in the chain. + let squash_plan = this + .active_repo() + .filter(|repo| repo.id == repo_id) + .and_then(|repo| { + let selection = &repo.history_state.multi_selection; + if !(selection.is_multi() && selection.contains(commit_id)) { + return None; + } + let Loadable::Ready(page) = &repo.log else { + return None; + }; + let head = repo.head_commit_id()?; + gitcomet_core::squash::squash_eligibility(&page.commits, &selection.commits, &head) + }); + if let Some(plan) = squash_plan { + let label = format!("Squash {} commits", plan.commit_count).into(); + items.push(ContextMenuItem::Entry { + label, + icon: Some("icons/git_commit.svg".into()), + shortcut: None, + disabled: false, + action: Box::new(ContextMenuAction::SquashSelectedCommits { repo_id }), + }); + items.push(ContextMenuItem::Separator); + } + if let Some((entries, source_colors)) = multi_cherry_pick_plan { + let label = format!("Cherry-pick {} commits…", entries.len()).into(); + items.push(ContextMenuItem::Entry { + label, + icon: Some("icons/arrow_up.svg".into()), + shortcut: Some("P".into()), + disabled: cherry_pick_disabled, + action: Box::new(ContextMenuAction::OpenInteractiveCherryPickSetup { + repo_id, + entries, + source_colors, + }), + }); + items.push(ContextMenuItem::Separator); + } items.push(ContextMenuItem::Entry { label: "Open diff".into(), icon: Some("icons/open_external.svg".into()), @@ -76,16 +225,18 @@ pub(super) fn model(this: &PopoverHost, repo_id: RepoId, commit_id: &CommitId) - commit_id: commit_id.clone(), }), }); - items.push(ContextMenuItem::Entry { - label: "Cherry-pick".into(), - icon: Some("icons/arrow_up.svg".into()), - shortcut: Some("P".into()), - disabled: false, - action: Box::new(ContextMenuAction::CherryPickCommit { - repo_id, - commit_id: commit_id.clone(), - }), - }); + if !has_multi_cherry_pick { + items.push(ContextMenuItem::Entry { + label: "Cherry-pick".into(), + icon: Some("icons/arrow_up.svg".into()), + shortcut: Some("P".into()), + disabled: cherry_pick_disabled, + action: Box::new(ContextMenuAction::CherryPickCommit { + repo_id, + commit_id: commit_id.clone(), + }), + }); + } items.push(ContextMenuItem::Entry { label: "Revert".into(), icon: Some("icons/undo.svg".into()), @@ -96,6 +247,57 @@ pub(super) fn model(this: &PopoverHost, repo_id: RepoId, commit_id: &CommitId) - commit_id: commit_id.clone(), }), }); + let current_branch: SharedString = this + .active_repo() + .and_then(|r| match &r.head_branch { + Loadable::Ready(head) if !head.is_empty() && head != "HEAD" => { + Some(head.as_str().into()) + } + _ => None, + }) + .unwrap_or_else(|| short.clone()); + + // Rebasing the current branch onto the commit it already points to is a + // no-op (plain rebase) or produces an empty `HEAD..HEAD` todo list + // (interactive), so skip both entries on the HEAD commit. The topmost + // commit is still editable via an interactive rebase from the commit below. + let is_head_commit = this + .active_repo() + .filter(|repo| repo.id == repo_id) + .and_then(|repo| repo.head_commit_id()) + .is_some_and(|head| head == *commit_id); + if !is_head_commit { + // Prefer a branch name at the target commit; fall back to the abbreviated + // sha for the label and the full sha for the actual rebase target. + let target_label: SharedString = branch_names + .first() + .map(|s| s.as_str()) + .unwrap_or(&short) + .into(); + let onto_ref = branch_names.first().cloned().unwrap_or_else(|| sha.clone()); + items.push(ContextMenuItem::Entry { + label: format!("Rebase {current_branch} onto {target_label}").into(), + icon: Some("icons/arrow_up.svg".into()), + shortcut: Some("B".into()), + disabled: false, + action: Box::new(ContextMenuAction::OpenPopover { + kind: PopoverKind::RebaseOntoConfirm { + repo_id, + onto: onto_ref, + }, + }), + }); + items.push(ContextMenuItem::Entry { + label: format!("Interactive rebase {current_branch} onto {target_label}").into(), + icon: Some("icons/refresh.svg".into()), + shortcut: Some("I".into()), + disabled: false, + action: Box::new(ContextMenuAction::LoadInteractiveRebaseSetup { + repo_id, + base: sha.clone(), + }), + }); + } items.push(ContextMenuItem::Separator); for (label, icon, mode) in [ diff --git a/crates/gitcomet-ui-gpui/src/view/panels/popover/file_history.rs b/crates/gitcomet-ui-gpui/src/view/panels/popover/file_history.rs index 4df31055..e2e1ae5c 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/popover/file_history.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/popover/file_history.rs @@ -27,7 +27,9 @@ pub(super) fn panel( cx: &mut gpui::Context, ) -> gpui::Div { let theme = this.theme; - let ui_scale_percent = super::popover_ui_scale_percent(cx); + let ui_scale = super::popover_ui_scale(cx); + let ui_scale_percent = ui_scale.percent(); + let width = super::LARGE_PICKER_WIDTH; let scaled_px = |value: f32| super::popover_scaled_px_from_percent(value, ui_scale_percent); let repo = this.state.repos.iter().find(|r| r.id == repo_id); // The commit the viewer currently shows this file at, so its row can be @@ -162,8 +164,7 @@ pub(super) fn panel( div() .flex() .flex_col() - .w(scaled_px(520.0)) - .max_w(scaled_px(820.0)) + .w(width.preferred_px(ui_scale)) .child(header) .child(div().border_t_1().border_color(theme.colors.border)) .child(body), 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 a473bf6c..284d5523 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/popover/fingerprint.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/popover/fingerprint.rs @@ -136,6 +136,9 @@ fn repo_for_popover<'a>(state: &'a AppState, popover: &PopoverKind) -> Option<&' | PopoverKind::PullPicker | PopoverKind::PushPicker | PopoverKind::AppMenu + | PopoverKind::RebaseReword { .. } + | PopoverKind::InteractiveRebaseActionMenu { .. } + | PopoverKind::InteractiveRebaseAutosquashMenu | PopoverKind::TerminalShutdownConfirm(_) | PopoverKind::ConflictResolverInputRowMenu { .. } | PopoverKind::ConflictResolverChunkMenu { .. } @@ -146,6 +149,7 @@ fn repo_for_popover<'a>(state: &'a AppState, popover: &PopoverKind) -> Option<&' | PopoverKind::StashPickerPrompt { repo_id, .. } | PopoverKind::CreateBranchFromRefPrompt { repo_id, .. } | PopoverKind::ResetPrompt { repo_id, .. } + | PopoverKind::SquashPrompt { repo_id } | PopoverKind::CheckoutRemoteBranchPrompt { repo_id, .. } | PopoverKind::StashDropConfirm { repo_id, .. } | PopoverKind::StashMenu { repo_id, .. } @@ -155,12 +159,14 @@ fn repo_for_popover<'a>(state: &'a AppState, popover: &PopoverKind) -> Option<&' | PopoverKind::FileHistory { repo_id, .. } | PopoverKind::PushSetUpstreamPrompt { repo_id, .. } | PopoverKind::ForcePushConfirm { repo_id } + | PopoverKind::CherryPickCommitConfirm { repo_id, .. } | PopoverKind::MergeAbortConfirm { repo_id } | PopoverKind::ConflictSaveStageConfirm { repo_id, .. } | PopoverKind::ForceDeleteBranchConfirm { repo_id, .. } | PopoverKind::ForceRemoveWorktreeConfirm { repo_id, .. } | PopoverKind::DiscardChangesConfirm { repo_id, .. } | PopoverKind::PullReconcilePrompt { repo_id } + | PopoverKind::RebaseOntoConfirm { repo_id, .. } | PopoverKind::CommitOptionsMenu { repo_id } | PopoverKind::PreviousCommitMessagesMenu { repo_id } | PopoverKind::DiffHunkMenu { repo_id, .. } @@ -290,6 +296,16 @@ fn hash_repo_for_popover(repo: &RepoState, popover: &PopoverKind, has repo.branches_rev.hash(hasher); } + // The squash prompt tracks the message preview plus everything that + // can invalidate the selection's eligibility while it is open. + PopoverKind::SquashPrompt { .. } => { + repo.history_state.squash_preview_rev.hash(hasher); + repo.history_state.selected_commit_rev.hash(hasher); + repo.history_state.log_rev.hash(hasher); + repo.head_branch_rev.hash(hasher); + repo.branches_rev.hash(hasher); + } + PopoverKind::TagMenu { .. } | PopoverKind::TagRefMenu { .. } => { repo.tags_rev.hash(hasher); repo.remotes_rev.hash(hasher); @@ -297,7 +313,12 @@ fn hash_repo_for_popover(repo: &RepoState, popover: &PopoverKind, has } // Most prompt-style popovers don't require live state updates. - PopoverKind::MergeAbortConfirm { .. } + PopoverKind::InteractiveRebaseActionMenu { .. } + | PopoverKind::InteractiveRebaseAutosquashMenu + | PopoverKind::RebaseReword { .. } + | PopoverKind::RebaseOntoConfirm { .. } + | PopoverKind::CherryPickCommitConfirm { .. } + | PopoverKind::MergeAbortConfirm { .. } | PopoverKind::ConflictSaveStageConfirm { .. } | PopoverKind::ResetPrompt { .. } | PopoverKind::CheckoutRemoteBranchPrompt { .. } @@ -417,6 +438,10 @@ fn hash_popover_kind(kind: &PopoverKind, hasher: &mut H) { target.hash(hasher); hash_reset_mode(*mode, hasher); } + PopoverKind::SquashPrompt { repo_id } => { + 75u8.hash(hasher); + repo_id.hash(hasher); + } PopoverKind::CreateTagPrompt { repo_id, target } => { 8u8.hash(hasher); repo_id.hash(hasher); @@ -440,6 +465,11 @@ fn hash_popover_kind(kind: &PopoverKind, hasher: &mut H) { 31u8.hash(hasher); repo_id.hash(hasher); } + PopoverKind::CherryPickCommitConfirm { repo_id, commit_id } => { + 76u8.hash(hasher); + repo_id.hash(hasher); + commit_id.hash(hasher); + } PopoverKind::ForceDeleteBranchConfirm { repo_id, name } => { 32u8.hash(hasher); repo_id.hash(hasher); @@ -649,6 +679,34 @@ fn hash_popover_kind(kind: &PopoverKind, hasher: &mut H) { repo_id.hash(hasher); (*purpose as u8).hash(hasher); } + PopoverKind::RebaseOntoConfirm { repo_id, onto } => { + 75u8.hash(hasher); + repo_id.hash(hasher); + onto.hash(hasher); + } + PopoverKind::RebaseReword { + ix, + original_action, + original_message, + } => { + 77u8.hash(hasher); + ix.hash(hasher); + (*original_action as u8).hash(hasher); + original_message.hash(hasher); + } + PopoverKind::InteractiveRebaseActionMenu { + ix, + can_squash, + can_drop, + } => { + 78u8.hash(hasher); + ix.hash(hasher); + can_squash.hash(hasher); + can_drop.hash(hasher); + } + PopoverKind::InteractiveRebaseAutosquashMenu => { + 79u8.hash(hasher); + } } } diff --git a/crates/gitcomet-ui-gpui/src/view/panels/popover/rebase_onto_confirm.rs b/crates/gitcomet-ui-gpui/src/view/panels/popover/rebase_onto_confirm.rs new file mode 100644 index 00000000..cd849f63 --- /dev/null +++ b/crates/gitcomet-ui-gpui/src/view/panels/popover/rebase_onto_confirm.rs @@ -0,0 +1,90 @@ +use super::*; + +pub(super) fn panel( + this: &mut PopoverHost, + repo_id: RepoId, + onto: String, + cx: &mut gpui::Context, +) -> gpui::Div { + let theme = this.theme; + 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); + + // Name the branch being moved rather than the opaque "HEAD". + let current_branch = this + .state + .repos + .iter() + .find(|r| r.id == repo_id) + .and_then(|r| match &r.head_branch { + Loadable::Ready(head) if !head.is_empty() && head != "HEAD" => Some(head.clone()), + _ => None, + }) + .unwrap_or_else(|| "HEAD".to_string()); + + div() + .flex() + .flex_col() + .min_w(scaled_px(380.0)) + .child( + div() + .px_2() + .py_1() + .text_sm() + .font_weight(FontWeight::BOLD) + .child("Rebase"), + ) + .child(div().border_t_1().border_color(theme.colors.border)) + .child( + div() + .px_2() + .py_1() + .text_sm() + .text_color(theme.colors.text_muted) + .child(format!("Rebase {current_branch} onto {onto}")), + ) + .child( + div() + .px_2() + .pb_1() + .text_xs() + .text_color(theme.colors.text_muted) + .child("This rewrites commit history. Avoid rebasing commits already pushed to a shared branch."), + ) + .child(div().border_t_1().border_color(theme.colors.border)) + .child( + div() + .px_2() + .py_1() + .flex() + .items_center() + .justify_between() + .child( + super::cancel_button("rebase_onto_cancel", "rebase_onto_cancel_hint", theme) + .on_click(theme, cx, |this, _e, _w, cx| { + this.popover = None; + this.popover_anchor = None; + cx.notify(); + }), + ) + .child( + components::Button::new("rebase_onto_go", "Rebase") + .focus_handle(this.rebase_onto_submit_focus_handle.clone()) + .separated_end_slot(super::hotkey_hint( + theme, + "rebase_onto_go_hint", + "Enter", + )) + .style(components::ButtonStyle::Filled) + .on_click(theme, cx, move |this, _e, _w, cx| { + this.store.dispatch(Msg::Rebase { + repo_id, + onto: onto.clone(), + }); + this.popover = None; + this.popover_anchor = None; + cx.notify(); + }), + ), + ) +} diff --git a/crates/gitcomet-ui-gpui/src/view/panels/popover/recent_repo_picker.rs b/crates/gitcomet-ui-gpui/src/view/panels/popover/recent_repo_picker.rs index 63cd6e0c..eb1897a2 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/popover/recent_repo_picker.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/popover/recent_repo_picker.rs @@ -27,8 +27,10 @@ fn recent_repo_picker_item(path: &std::path::Path) -> components::PickerPromptIt pub(super) fn panel(this: &mut PopoverHost, cx: &mut gpui::Context) -> gpui::Div { let theme = this.theme; - let ui_scale_percent = super::popover_ui_scale_percent(cx); + let ui_scale = super::popover_ui_scale(cx); + let ui_scale_percent = ui_scale.percent(); let scaled_px = |value: f32| super::popover_scaled_px_from_percent(value, ui_scale_percent); + let width = super::RECENT_PICKER_WIDTH; let recent_repos = this.recent_repo_picker_cached_repos.clone(); let labels = recent_repos .iter() @@ -61,29 +63,26 @@ pub(super) fn panel(this: &mut PopoverHost, cx: &mut gpui::Context) }, ), ) - .w(scaled_px(480.0)) - .max_w(scaled_px(860.0)) + // Fixed width: PickerPrompt rows size with `w_full`, which does not + // stretch under fit-content parents. + .w(width.preferred_px(ui_scale)) } else { let mut menu = div() .flex() .flex_col() - .min_w(scaled_px(480.0)) - .max_w(scaled_px(860.0)); + .min_w(width.min_px(ui_scale)) + .max_w(width.max_px(ui_scale)); for (ix, label) in labels.into_iter().enumerate() { let Some(path) = recent_repos.get(ix).cloned() else { continue; }; menu = menu.child( - components::context_menu_entry( + components::ContextMenuEntry::new( ("recent_repo_item", ix), - theme, - ui_scale_percent, - false, - false, - None, - label, - None, + components::ContextMenuText::path_single_line(label), ) + .tooltip_host(this.tooltip_host.clone()) + .render(theme, ui_scale_percent, cx) .on_click(cx.listener( move |this, _event: &ClickEvent, _window, cx| { select_recent_repository(this, path.clone(), cx); diff --git a/crates/gitcomet-ui-gpui/src/view/panels/popover/repo_picker.rs b/crates/gitcomet-ui-gpui/src/view/panels/popover/repo_picker.rs index 9bb6cb19..247d5afe 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/popover/repo_picker.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/popover/repo_picker.rs @@ -3,8 +3,10 @@ use super::*; pub(super) fn panel(this: &mut PopoverHost, cx: &mut gpui::Context) -> gpui::Div { let theme = this.theme; - let ui_scale_percent = super::popover_ui_scale_percent(cx); + let ui_scale = super::popover_ui_scale(cx); + let ui_scale_percent = ui_scale.percent(); let scaled_px = |value: f32| super::popover_scaled_px_from_percent(value, ui_scale_percent); + let width = super::PICKER_WIDTH; if let Some(search) = this.repo_picker_search_input.clone() { let repo_ids = this.state.repos.iter().map(|r| r.id).collect::>(); @@ -35,28 +37,25 @@ pub(super) fn panel(this: &mut PopoverHost, cx: &mut gpui::Context) this.close_popover(cx); }), ) - .w(scaled_px(420.0)) - .max_w(scaled_px(820.0)) + // Fixed width: PickerPrompt rows size with `w_full`, which does not + // stretch under fit-content parents. + .w(width.preferred_px(ui_scale)) } else { let mut menu = div() .flex() .flex_col() - .min_w(scaled_px(420.0)) - .max_w(scaled_px(820.0)); + .min_w(width.min_px(ui_scale)) + .max_w(width.max_px(ui_scale)); for repo in this.state.repos.iter() { let id = repo.id; let label = path_display::path_display_shared(&repo.spec.workdir); menu = menu.child( - components::context_menu_entry( + components::ContextMenuEntry::new( ("repo_item", id.0), - theme, - ui_scale_percent, - false, - false, - None, - label.clone(), - None, + components::ContextMenuText::path_single_line(label.clone()), ) + .tooltip_host(this.tooltip_host.clone()) + .render(theme, ui_scale_percent, cx) .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| { this.store.dispatch(Msg::SetActiveRepo { repo_id: id }); this.popover = None; diff --git a/crates/gitcomet-ui-gpui/src/view/panels/popover/squash_prompt.rs b/crates/gitcomet-ui-gpui/src/view/panels/popover/squash_prompt.rs new file mode 100644 index 00000000..526a84a9 --- /dev/null +++ b/crates/gitcomet-ui-gpui/src/view/panels/popover/squash_prompt.rs @@ -0,0 +1,208 @@ +use super::*; + +fn short_sha(id: &gitcomet_core::domain::CommitId) -> &str { + id.as_ref().get(0..8).unwrap_or(id.as_ref()) +} + +pub(super) fn panel( + this: &mut PopoverHost, + repo_id: RepoId, + cx: &mut gpui::Context, +) -> gpui::Div { + let theme = this.theme; + 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); + + // Recompute eligibility from live state each render: the selection or the + // log may have changed while the prompt is open. Prefill of the inputs is + // handled outside the render path (see `sync_squash_prompt_prefill`). + let repo = this.state.repos.iter().find(|r| r.id == repo_id); + let plan = this.squash_plan_for_repo_id(repo_id); + let preview = repo + .map(|repo| repo.history_state.squash_preview.clone()) + .unwrap_or(Loadable::NotLoaded); + + let cancel_button = |this: &PopoverHost, cx: &mut gpui::Context| { + super::cancel_button("squash_cancel", "squash_cancel_hint", theme) + .focus_handle(this.squash_cancel_focus_handle.clone()) + .on_click(theme, cx, |this, _e, window, cx| { + this.dismiss_prompt_popover(window, cx); + }) + }; + + let Some(plan) = plan else { + return div() + .flex() + .flex_col() + .w(scaled_px(420.0)) + .child( + div() + .px_2() + .py_1() + .text_sm() + .font_weight(FontWeight::BOLD) + .child("Squash commits"), + ) + .child(div().border_t_1().border_color(theme.colors.border)) + .child( + div() + .px_2() + .py_1() + .text_sm() + .text_color(theme.colors.text_muted) + .child("The selected commits are no longer squashable."), + ) + .child(div().border_t_1().border_color(theme.colors.border)) + .child( + div() + .px_2() + .py_1() + .flex() + .items_center() + .justify_end() + .child(cancel_button(this, cx)), + ); + }; + + let message_empty = this + .squash_message_input + .read_with(cx, |input, _| input.text().trim().is_empty()); + + let summary = if plan.head == plan.actual_head { + format!( + "{}..{} → one commit on HEAD", + short_sha(&plan.oldest), + short_sha(&plan.head) + ) + } else { + format!( + "{}..{} → one commit · rewriting commits above", + short_sha(&plan.oldest), + short_sha(&plan.head) + ) + }; + let message_hint: Option = match &preview { + Loadable::Loading | Loadable::NotLoaded => Some("Building combined message…".into()), + Loadable::Error(e) => Some(format!("Could not build the combined message: {e}").into()), + Loadable::Ready(_) => None, + }; + + let description_scroll = this.squash_description_scroll.clone(); + let count = plan.commit_count; + + div() + .flex() + .flex_col() + .w(scaled_px(420.0)) + .child( + div() + .px_2() + .py_1() + .text_sm() + .font_weight(FontWeight::BOLD) + .child(format!("Squash {count} commits")), + ) + .child(div().border_t_1().border_color(theme.colors.border)) + .child( + div() + .px_2() + .py_1() + .text_sm() + .text_color(theme.colors.text_muted) + .child(summary), + ) + .child( + div() + .px_2() + .pt_1() + .pb_1() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_xs() + .text_color(theme.colors.text_muted) + .child("Commit message"), + ) + .child( + div() + .w_full() + .min_w(px(0.0)) + .child(this.squash_message_input.clone()), + ), + ) + .child( + div() + .px_2() + .pt_1() + .pb_2() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_xs() + .text_color(theme.colors.text_muted) + .child("Description"), + ) + .child( + div() + .w_full() + .min_w(px(0.0)) + .child( + restrict_scroll_to_vertical_axis( + div() + .id("squash_description_scroll_surface") + .relative() + .w_full() + .min_w(px(0.0)) + .max_h(scaled_px(180.0)) + .pr(components::Scrollbar::visible_gutter( + description_scroll.clone(), + components::ScrollbarAxis::Vertical, + )) + .overflow_y_scroll() + .track_scroll(&description_scroll), + ) + .child(this.squash_description_input.clone()), + ) + .child( + components::Scrollbar::new( + "squash_description_scrollbar", + description_scroll, + ) + .render(theme), + ), + ), + ) + .when_some(message_hint, |el, hint| { + el.child( + div() + .px_2() + .pb_1() + .text_xs() + .text_color(theme.colors.text_muted) + .child(hint), + ) + }) + .child(div().border_t_1().border_color(theme.colors.border)) + .child( + div() + .px_2() + .py_1() + .flex() + .items_center() + .justify_between() + .child(cancel_button(this, cx)) + .child( + components::Button::new("squash_go", "Squash") + .focus_handle(this.squash_submit_focus_handle.clone()) + .style(components::ButtonStyle::Filled) + .disabled(message_empty) + .on_click(theme, cx, move |this, _e, _w, cx| { + this.submit_squash(cx); + }), + ), + ) +} diff --git a/crates/gitcomet-ui-gpui/src/view/panels/popover/submodule_open_picker.rs b/crates/gitcomet-ui-gpui/src/view/panels/popover/submodule_open_picker.rs index d441a50e..d76d1d57 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/popover/submodule_open_picker.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/popover/submodule_open_picker.rs @@ -6,7 +6,9 @@ pub(super) fn panel( cx: &mut gpui::Context, ) -> gpui::Div { let theme = this.theme; - let ui_scale_percent = super::popover_ui_scale_percent(cx); + let ui_scale = super::popover_ui_scale(cx); + let ui_scale_percent = ui_scale.percent(); + let width = super::LARGE_PICKER_WIDTH; let scaled_px = |value: f32| super::popover_scaled_px_from_percent(value, ui_scale_percent); if let Some(repo) = this.state.repos.iter().find(|r| r.id == repo_id) { @@ -62,8 +64,7 @@ pub(super) fn panel( this.close_popover(cx); }), ) - .w(scaled_px(520.0)) - .max_w(scaled_px(820.0)) + .w(width.preferred_px(ui_scale)) } else { components::context_menu_label( theme, diff --git a/crates/gitcomet-ui-gpui/src/view/panels/popover/submodule_remove_picker.rs b/crates/gitcomet-ui-gpui/src/view/panels/popover/submodule_remove_picker.rs index 5bfd961a..852bf626 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/popover/submodule_remove_picker.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/popover/submodule_remove_picker.rs @@ -6,7 +6,9 @@ pub(super) fn panel( cx: &mut gpui::Context, ) -> gpui::Div { let theme = this.theme; - let ui_scale_percent = super::popover_ui_scale_percent(cx); + let ui_scale = super::popover_ui_scale(cx); + let ui_scale_percent = ui_scale.percent(); + let width = super::LARGE_PICKER_WIDTH; let scaled_px = |value: f32| super::popover_scaled_px_from_percent(value, ui_scale_percent); if let Some(repo) = this.state.repos.iter().find(|r| r.id == repo_id) { @@ -73,8 +75,7 @@ pub(super) fn panel( }, ), ) - .w(scaled_px(520.0)) - .max_w(scaled_px(820.0)) + .w(width.preferred_px(ui_scale)) } else { components::context_menu_label( theme, diff --git a/crates/gitcomet-ui-gpui/src/view/panels/popover/tests/file_actions.rs b/crates/gitcomet-ui-gpui/src/view/panels/popover/tests/file_actions.rs index 5b49ae4a..21a01411 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/popover/tests/file_actions.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/popover/tests/file_actions.rs @@ -19,6 +19,31 @@ fn context_menu_entry_disabled(model: &ContextMenuModel, label: &str) -> bool { .unwrap_or_else(|| panic!("expected `{label}` context menu entry")) } +fn commit_menu_test_repo(repo_id: RepoId, commit_id: &CommitId) -> RepoState { + let workdir = std::env::temp_dir().join(format!( + "gitcomet_ui_test_{}_commit_menu", + std::process::id() + )); + let mut repo = RepoState::new_opening(repo_id, gitcomet_core::domain::RepoSpec { workdir }); + repo.log = Loadable::Ready( + gitcomet_core::domain::LogPage { + commits: vec![gitcomet_core::domain::Commit { + id: commit_id.clone(), + parent_ids: gitcomet_core::domain::CommitParentIds::new(), + summary: "Hello".into(), + author: "Alice".into(), + time: SystemTime::UNIX_EPOCH, + }], + next_cursor: None, + } + .into(), + ); + repo.tags = Loadable::Ready(Arc::new(vec![])); + repo.rebase_in_progress = Loadable::Ready(false); + repo.merge_commit_message = Loadable::Ready(None); + repo +} + #[gpui::test] fn commit_menu_has_add_tag_entry(cx: &mut gpui::TestAppContext) { let (store, events) = AppStore::new(Arc::new(TestBackend)); @@ -27,40 +52,11 @@ fn commit_menu_has_add_tag_entry(cx: &mut gpui::TestAppContext) { let repo_id = RepoId(1); let commit_id = CommitId("deadbeefdeadbeef".into()); - let workdir = std::env::temp_dir().join(format!( - "gitcomet_ui_test_{}_commit_menu_tag", - 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.log = Loadable::Ready( - gitcomet_core::domain::LogPage { - commits: vec![gitcomet_core::domain::Commit { - id: commit_id.clone(), - parent_ids: gitcomet_core::domain::CommitParentIds::new(), - summary: "Hello".into(), - author: "Alice".into(), - time: SystemTime::UNIX_EPOCH, - }], - next_cursor: None, - } - .into(), - ); - repo.tags = Loadable::Ready(Arc::new(vec![])); - - this.state = Arc::new(AppState { - repos: vec![repo], - active_repo: Some(repo_id), - ..Default::default() - }); - cx.notify(); + let repo = commit_menu_test_repo(repo_id, &commit_id); + push_test_state(this, app_state_with_repo(repo, repo_id), cx); }); }); @@ -103,6 +99,83 @@ fn commit_menu_has_add_tag_entry(cx: &mut gpui::TestAppContext) { }); } +#[gpui::test] +fn commit_menu_cherry_pick_action_opens_confirm_popover(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(1); + let commit_id = CommitId("deadbeefdeadbeef".into()); + + cx.update(|_window, app| { + view.update(app, |this, cx| { + let repo = commit_menu_test_repo(repo_id, &commit_id); + push_test_state(this, app_state_with_repo(repo, repo_id), cx); + }); + }); + + cx.update(|window, app| { + view.update(app, |this, cx| { + this.popover_host.update(cx, |host, cx| { + host.context_menu_activate_action( + ContextMenuAction::CherryPickCommit { + repo_id, + commit_id: commit_id.clone(), + }, + window, + cx, + ); + assert_eq!( + host.popover_kind_for_tests(), + Some(PopoverKind::CherryPickCommitConfirm { + repo_id, + commit_id: commit_id.clone() + }) + ); + }); + }); + }); +} + +#[gpui::test] +fn commit_menu_disables_cherry_pick_when_local_operation_in_progress( + 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(1); + let commit_id = CommitId("deadbeefdeadbeef".into()); + + cx.update(|_window, app| { + view.update(app, |this, cx| { + let mut repo = commit_menu_test_repo(repo_id, &commit_id); + repo.local_actions_in_flight = 1; + push_test_state(this, app_state_with_repo(repo, repo_id), cx); + }); + }); + + cx.update(|_window, app| { + let model = view + .update(app, |this, cx| { + this.popover_host.update(cx, |host, cx| { + host.context_menu_model( + &PopoverKind::CommitMenu { + repo_id, + commit_id: commit_id.clone(), + }, + cx, + ) + }) + }) + .expect("expected commit context menu model"); + + assert!(context_menu_entry_disabled(&model, "Cherry-pick")); + }); +} + #[gpui::test] fn commit_file_menu_has_open_file_entries(cx: &mut gpui::TestAppContext) { let (store, events) = AppStore::new(Arc::new(TestBackend)); diff --git a/crates/gitcomet-ui-gpui/src/view/panels/popover/worktree_open_picker.rs b/crates/gitcomet-ui-gpui/src/view/panels/popover/worktree_open_picker.rs index 84bccf79..ee6cdfbc 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/popover/worktree_open_picker.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/popover/worktree_open_picker.rs @@ -33,7 +33,9 @@ pub(super) fn panel( cx: &mut gpui::Context, ) -> gpui::Div { let theme = this.theme; - let ui_scale_percent = super::popover_ui_scale_percent(cx); + let ui_scale = super::popover_ui_scale(cx); + let ui_scale_percent = ui_scale.percent(); + let width = super::LARGE_PICKER_WIDTH; let scaled_px = |value: f32| super::popover_scaled_px_from_percent(value, ui_scale_percent); if let Some(repo) = this.state.repos.iter().find(|r| r.id == repo_id) { @@ -89,8 +91,7 @@ pub(super) fn panel( this.close_popover(cx); }), ) - .w(scaled_px(520.0)) - .max_w(scaled_px(820.0)) + .w(width.preferred_px(ui_scale)) } else { components::context_menu_label( theme, diff --git a/crates/gitcomet-ui-gpui/src/view/panels/popover/worktree_remove_picker.rs b/crates/gitcomet-ui-gpui/src/view/panels/popover/worktree_remove_picker.rs index 32ad2117..35fa05cb 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/popover/worktree_remove_picker.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/popover/worktree_remove_picker.rs @@ -6,7 +6,9 @@ pub(super) fn panel( cx: &mut gpui::Context, ) -> gpui::Div { let theme = this.theme; - let ui_scale_percent = super::popover_ui_scale_percent(cx); + let ui_scale = super::popover_ui_scale(cx); + let ui_scale_percent = ui_scale.percent(); + let width = super::LARGE_PICKER_WIDTH; let scaled_px = |value: f32| super::popover_scaled_px_from_percent(value, ui_scale_percent); if let Some(repo) = this.state.repos.iter().find(|r| r.id == repo_id) { @@ -82,8 +84,7 @@ pub(super) fn panel( }, ), ) - .w(scaled_px(520.0)) - .max_w(scaled_px(820.0)) + .w(width.preferred_px(ui_scale)) } else { components::context_menu_label( theme, diff --git a/crates/gitcomet-ui-gpui/src/view/panels/tests/shortcuts.rs b/crates/gitcomet-ui-gpui/src/view/panels/tests/shortcuts.rs index 7d111888..29553155 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/tests/shortcuts.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/tests/shortcuts.rs @@ -514,10 +514,17 @@ fn open_popover_for_test( fn set_ui_scale_percent_for_test( cx: &mut gpui::VisualTestContext, - _view: &gpui::Entity, + view: &gpui::Entity, percent: u32, ) { - cx.update(|_window, app| { + // Apply to the test window through the view first: `set_app_ui_scale_percent` + // reaches open windows via `WindowHandle::update`, which silently fails here + // because the test window is already borrowed by this `cx.update`, leaving the + // window rem size (and thus text scaling) untouched. + cx.update(|window, app| { + view.update(app, |view, cx| { + view.apply_ui_scale_percent(percent, window, cx); + }); crate::app::set_app_ui_scale_percent(app, percent); }); } @@ -995,7 +1002,7 @@ fn repo_operation_context_menu_shortcuts_match_expected_actions(cx: &mut gpui::T }, ) }); - assert_declared_shortcuts(&local_branch_model, &["P", "M", "S"]); + assert_declared_shortcuts(&local_branch_model, &["P", "M", "S", "B"]); assert_shortcut_action!( local_branch_model, "Enter", @@ -1026,6 +1033,13 @@ fn repo_operation_context_menu_shortcuts_match_expected_actions(cx: &mut gpui::T reference } if *rid == repo_id && reference == "feature" ); + assert_shortcut_action!( + local_branch_model, + "B", + ContextMenuAction::OpenPopover { + kind: PopoverKind::RebaseOntoConfirm { repo_id: rid, onto } + } if *rid == repo_id && onto == "feature" + ); let remote_branch_name = "origin/feature".to_string(); let remote_branch_model = cx.update(|_window, app| { @@ -1039,7 +1053,7 @@ fn repo_operation_context_menu_shortcuts_match_expected_actions(cx: &mut gpui::T }, ) }); - assert_declared_shortcuts(&remote_branch_model, &["P", "M", "S", "F"]); + assert_declared_shortcuts(&remote_branch_model, &["P", "M", "S", "B", "F"]); assert_shortcut_action!( remote_branch_model, "Enter", @@ -1076,6 +1090,13 @@ fn repo_operation_context_menu_shortcuts_match_expected_actions(cx: &mut gpui::T reference } if *rid == repo_id && reference == "origin/feature" ); + assert_shortcut_action!( + remote_branch_model, + "B", + ContextMenuAction::OpenPopover { + kind: PopoverKind::RebaseOntoConfirm { repo_id: rid, onto } + } if *rid == repo_id && onto == "origin/feature" + ); assert_shortcut_action!( remote_branch_model, "F", @@ -1201,7 +1222,7 @@ fn file_and_diff_context_menu_shortcuts_match_expected_actions(cx: &mut gpui::Te }, ) }); - assert_declared_shortcuts(&commit_model, &["T", "D", "P", "R"]); + assert_declared_shortcuts(&commit_model, &["T", "D", "P", "R", "B", "I"]); assert_shortcut_action!( commit_model, "Enter", @@ -1244,6 +1265,19 @@ fn file_and_diff_context_menu_shortcuts_match_expected_actions(cx: &mut gpui::Te commit_id: cid } if *rid == repo_id && cid == &commit_id ); + assert_shortcut_action!( + commit_model, + "B", + ContextMenuAction::OpenPopover { + kind: PopoverKind::RebaseOntoConfirm { repo_id: rid, onto } + } if *rid == repo_id && onto == commit_id.as_ref() + ); + assert_shortcut_action!( + commit_model, + "I", + ContextMenuAction::LoadInteractiveRebaseSetup { repo_id: rid, base } + if *rid == repo_id && base == commit_id.as_ref() + ); let commit_file_model = cx.update(|_window, app| { context_menu_model_for( diff --git a/crates/gitcomet-ui-gpui/src/view/panes/details.rs b/crates/gitcomet-ui-gpui/src/view/panes/details.rs index effff992..844cdc72 100644 --- a/crates/gitcomet-ui-gpui/src/view/panes/details.rs +++ b/crates/gitcomet-ui-gpui/src/view/panes/details.rs @@ -37,6 +37,7 @@ pub(in super::super) struct DetailsPaneView { pub(in super::super) unstaged_scroll: UniformListScrollHandle, pub(in super::super) staged_scroll: UniformListScrollHandle, pub(in super::super) commit_files_scroll: UniformListScrollHandle, + pub(in super::super) commit_multi_scroll: UniformListScrollHandle, pub(in super::super) commit_message_scroll: ScrollHandle, pub(in super::super) commit_scroll: ScrollHandle, @@ -381,6 +382,7 @@ impl DetailsPaneView { unstaged_scroll: UniformListScrollHandle::default(), staged_scroll: UniformListScrollHandle::default(), commit_files_scroll: UniformListScrollHandle::default(), + commit_multi_scroll: UniformListScrollHandle::default(), commit_message_scroll, commit_scroll: ScrollHandle::new(), commit_message_input, @@ -773,9 +775,18 @@ impl DetailsPaneView { }) }); + let prev_multi_commits = prev_active_repo_id.and_then(|repo_id| { + self.state + .repos + .iter() + .find(|r| r.id == repo_id) + .map(|r| r.history_state.multi_selection.commits.clone()) + }); + let next_repo_id = next.active_repo; let next_repo = next_repo_id.and_then(|id| next.repos.iter().find(|r| r.id == id)); let next_selected_commit = next_repo.and_then(|r| r.history_state.selected_commit.clone()); + let next_multi_commits = next_repo.map(|r| r.history_state.multi_selection.commits.clone()); let next_merge_message = next_repo.and_then(|r| match &r.merge_commit_message { Loadable::Ready(Some(message)) => Some(message.clone()), _ => None, @@ -890,6 +901,11 @@ impl DetailsPaneView { .scroll_to_item_strict(0, gpui::ScrollStrategy::Top); } + if switched_repo || prev_multi_commits != next_multi_commits { + self.commit_multi_scroll + .scroll_to_item_strict(0, gpui::ScrollStrategy::Top); + } + let merge_started = match (prev_active_repo_id, next_repo_id) { (Some(prev), Some(next)) if prev == next => { prev_merge_message.is_none() && next_merge_message.is_some() diff --git a/crates/gitcomet-ui-gpui/src/view/panes/history.rs b/crates/gitcomet-ui-gpui/src/view/panes/history.rs index 0a8052d3..9e920da8 100644 --- a/crates/gitcomet-ui-gpui/src/view/panes/history.rs +++ b/crates/gitcomet-ui-gpui/src/view/panes/history.rs @@ -1075,6 +1075,29 @@ impl HistoryView { self.state.repos.iter().find(|r| r.id == repo_id) } + /// Visible commit ids in log order for shift-click range selection. + /// Hidden rows (stash helper commits) are excluded, matching what the + /// user sees. + pub(in super::super) fn visible_commit_ids_for_repo( + &self, + repo_id: RepoId, + ) -> Option> { + let repo = self.state.repos.iter().find(|r| r.id == repo_id)?; + let page = Self::display_log_page_for_repo(repo)?; + let cache = self + .history_cache + .as_ref() + .filter(|cache| cache.base.request.repo_id == repo_id)?; + Some( + cache + .base + .visible_indices + .iter() + .filter_map(|ix| page.commits.get(ix).map(|c| c.id.clone())) + .collect(), + ) + } + pub(in crate::view) fn show_history_refs_hover( &mut self, repo_id: RepoId, diff --git a/crates/gitcomet-ui-gpui/src/view/panes/main.rs b/crates/gitcomet-ui-gpui/src/view/panes/main.rs index 441301d9..2abdae40 100644 --- a/crates/gitcomet-ui-gpui/src/view/panes/main.rs +++ b/crates/gitcomet-ui-gpui/src/view/panes/main.rs @@ -9,6 +9,7 @@ pub(in crate::view) mod diff_cache; pub(in crate::view) mod diff_search; mod diff_text; mod helpers; +mod interactive_rebase; mod preview; #[cfg(feature = "benchmarks")] @@ -81,6 +82,9 @@ impl Render for MainPaneView { .active_repo() .and_then(|r| r.diff_state.diff_target.as_ref()) .is_some(); + let in_rebase = self.active_repo().is_some_and(|r| { + r.interactive_rebase_setup.is_some() || r.interactive_cherry_pick_setup.is_some() + }); // Keep blame in sync with the displayed file/revision while annotate is // on; the request is a no-op when the target is unchanged. Render must not // force a retry — a persistent error would re-dispatch every frame. @@ -89,6 +93,8 @@ impl Render for MainPaneView { } let inner = if show_diff { self.diff_view(window, cx).into_any_element() + } else if in_rebase { + self.interactive_rebase_view(window, cx).into_any_element() } else { self.history_view.clone().into_any_element() }; diff --git a/crates/gitcomet-ui-gpui/src/view/panes/main/core_impl.rs b/crates/gitcomet-ui-gpui/src/view/panes/main/core_impl.rs index 599e8be7..72e58fe7 100644 --- a/crates/gitcomet-ui-gpui/src/view/panes/main/core_impl.rs +++ b/crates/gitcomet-ui-gpui/src/view/panes/main/core_impl.rs @@ -730,6 +730,69 @@ fn blame_path_rev_for_target( } impl MainPaneView { + pub(in crate::view) fn sync_interactive_commit_editor_states(&mut self) { + let repos_with_setup: Vec = self + .state + .repos + .iter() + .filter(|r| { + r.interactive_rebase_setup.is_some() || r.interactive_cherry_pick_setup.is_some() + }) + .map(|r| r.id) + .collect(); + self.interactive_rebase_states + .retain(|repo_id, _| repos_with_setup.contains(repo_id)); + for repo in self.state.repos.iter() { + if let Some(setup) = repo.interactive_rebase_setup.as_ref() { + let Loadable::Ready(entries) = &setup.entries else { + continue; + }; + let replace = self + .interactive_rebase_states + .get(&repo.id) + .is_none_or(|st| { + st.mode != ICommitEditorMode::Rebase || st.original_entries != *entries + }); + if replace { + self.interactive_rebase_states.insert( + repo.id, + IRebaseViewState { + mode: ICommitEditorMode::Rebase, + entries: entries.clone(), + original_entries: entries.clone(), + ..Default::default() + }, + ); + } + } else if let Some(setup) = repo.interactive_cherry_pick_setup.as_ref() { + let source_colors = setup + .source_colors + .iter() + .cloned() + .collect::>(); + let replace = self + .interactive_rebase_states + .get(&repo.id) + .is_none_or(|st| { + st.mode != ICommitEditorMode::CherryPick + || st.original_entries != setup.entries + }); + if replace { + self.interactive_rebase_states.insert( + repo.id, + IRebaseViewState { + mode: ICommitEditorMode::CherryPick, + entries: setup.entries.clone(), + original_entries: setup.entries.clone(), + source_colors, + ..Default::default() + }, + ); + } + } + } + } + pub(super) fn notify_fingerprint_for(state: &AppState) -> u64 { use std::hash::{Hash, Hasher}; @@ -795,6 +858,24 @@ impl MainPaneView { // The historical-browse purple frame keys off the file browser source. repo.file_browser.file_browser_rev.hash(&mut hasher); + match &repo.interactive_rebase_setup { + Some(setup) => { + 1u8.hash(&mut hasher); + setup.base.hash(&mut hasher); + match &setup.entries { + Loadable::NotLoaded => 0u8.hash(&mut hasher), + Loadable::Loading => 1u8.hash(&mut hasher), + Loadable::Ready(_) => 2u8.hash(&mut hasher), + Loadable::Error(err) => { + 3u8.hash(&mut hasher); + err.hash(&mut hasher); + } + } + } + None => { + 0u8.hash(&mut hasher); + } + } // Blame/annotate data — when blame loads for the first time or changes // target, the annotation sidebar needs to repaint. repo.history_state.blame_path.hash(&mut hasher); @@ -1375,6 +1456,7 @@ impl MainPaneView { conflict_resolved_preview_gutter_last_synced_y: [px(0.0); 2], worktree_preview_scroll: UniformListScrollHandle::default(), path_display_cache: std::cell::RefCell::new(path_display::PathDisplayCache::default()), + interactive_rebase_states: HashMap::default(), }; pane.set_theme(theme, cx); @@ -3817,6 +3899,12 @@ impl MainPaneView { self.ensure_rendered_patch_diff_cache(cx); + // Sync per-repo interactive commit editing state. Each repo with a setup + // gets its own `IRebaseViewState`, populated once its entries become Ready + // and kept (with local edits) across repo-tab switches. State for repos + // whose setup is gone (cancelled, started, repo closed) is dropped. + self.sync_interactive_commit_editor_states(); + // History caches are now managed by HistoryView. } diff --git a/crates/gitcomet-ui-gpui/src/view/panes/main/helpers.rs b/crates/gitcomet-ui-gpui/src/view/panes/main/helpers.rs index c0a78bda..f929bb40 100644 --- a/crates/gitcomet-ui-gpui/src/view/panes/main/helpers.rs +++ b/crates/gitcomet-ui-gpui/src/view/panes/main/helpers.rs @@ -2588,6 +2588,52 @@ pub(crate) struct MainPaneView { pub(in crate::view) worktree_preview_scroll: UniformListScrollHandle, pub(super) path_display_cache: std::cell::RefCell, + + /// Per-repo interactive rebase editing state, keyed by repo id so that + /// setups open in several repo tabs at once stay independent. Entries are + /// populated when a repo's setup becomes Ready and dropped when its setup + /// goes away (see `apply_state`). + pub(in crate::view) interactive_rebase_states: HashMap, +} + +/// View-local editing state for one repo's interactive rebase setup. +#[derive(Default)] +pub(in crate::view) struct IRebaseViewState { + pub(in crate::view) mode: ICommitEditorMode, + pub(in crate::view) entries: Vec, + pub(in crate::view) original_entries: Vec, + pub(in crate::view) source_colors: std::collections::HashMap, + /// Active auto-squash strategy, or None when auto-squash is off. + pub(in crate::view) autosquash_mode: Option, + /// Commits folded away by auto-squash, keyed by the surviving commit id. + /// Each survivor's `entries` row displays these ids; they are re-expanded + /// into `fixup` todo entries when the rebase starts. + pub(in crate::view) folded: + std::collections::HashMap>, + pub(in crate::view) drag_state: Option, + /// Variable-height virtualized list state, lazily created on first render + /// (`ListState` has no `Default`). Kept in sync with `entries`/`folded` via + /// `list_sig` (remeasure on same-count content change, reset on count change). + pub(in crate::view) scroll: Option, + /// (content-hash, item-count) the `scroll` ListState was last synced to. + pub(in crate::view) list_sig: (u64, usize), + /// (ix_a, ix_b, version) — the two data-indices swapped by ▲/▼; drives fade-in animation. + pub(in crate::view) reorder_anim: Option<(usize, usize, u32)>, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(in crate::view) enum ICommitEditorMode { + #[default] + Rebase, + CherryPick, +} + +#[derive(Clone, Copy, Debug)] +pub(in crate::view) struct IRebaseDragState { + pub(in crate::view) from_ix: usize, + pub(in crate::view) to_ix: usize, + /// Drop-target position in display order (0..=entry_count). + pub(in crate::view) display_pos: usize, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] diff --git a/crates/gitcomet-ui-gpui/src/view/panes/main/interactive_rebase.rs b/crates/gitcomet-ui-gpui/src/view/panes/main/interactive_rebase.rs new file mode 100644 index 00000000..5d4c2221 --- /dev/null +++ b/crates/gitcomet-ui-gpui/src/view/panes/main/interactive_rebase.rs @@ -0,0 +1,1630 @@ +use super::super::super::*; +use super::helpers::{ICommitEditorMode, IRebaseDragState, IRebaseViewState}; +use gitcomet_core::services::{InteractiveRebaseAction, InteractiveRebaseEntry}; +use std::{cell::RefCell, rc::Rc}; + +const ACTION_BTN_W: f32 = 76.0; + +fn squash_target(entries: &[InteractiveRebaseEntry], k: usize) -> Option { + (0..k) + .rev() + .find(|&j| entries[j].action != InteractiveRebaseAction::Drop) +} + +/// Whether any squash/fixup entry currently folds into the entry at `ix`. +fn entry_is_squash_target(entries: &[InteractiveRebaseEntry], ix: usize) -> bool { + (0..entries.len()).any(|k| { + matches!( + entries[k].action, + InteractiveRebaseAction::Squash | InteractiveRebaseAction::Fixup + ) && squash_target(entries, k) == Some(ix) + }) +} + +/// Subject used to group commits for auto-squash. Strips leading `fixup! ` / +/// `squash! ` / `amend! ` prefixes (git's autosquash convention, which SmartGit +/// follows) so a `fixup! foo` commit groups with `foo`. +fn autosquash_group_key(summary: &str) -> &str { + let mut s = summary; + loop { + match s + .strip_prefix("fixup! ") + .or_else(|| s.strip_prefix("squash! ")) + .or_else(|| s.strip_prefix("amend! ")) + { + Some(rest) => s = rest, + None => return s, + } + } +} + +/// Whether a subject carries an auto-squash prefix — such a commit is not kept +/// as the survivor when an unprefixed sibling exists, so the folded commit's +/// message doesn't leak into the result. +fn is_autosquash_prefixed(summary: &str) -> bool { + autosquash_group_key(summary) != summary +} + +/// The commit ids folded into each survivor for a given auto-squash `mode`. +/// Groups commits by their [`autosquash_group_key`] (so `fixup!`/`squash!` +/// commits fold into their target); the surviving commit per group is chosen by +/// the mode, preferring an unprefixed commit so its message is kept. Empty +/// summaries are never eligible. `entries` are ordered oldest-first (index 0 = +/// oldest). +/// +/// Returns `folded_into[i] = Some(survivor_index)` for every commit that is +/// folded away, and `None` for survivors and untouched commits. +fn autosquash_folds( + entries: &[InteractiveRebaseEntry], + mode: AutosquashMode, +) -> Vec> { + let n = entries.len(); + let mut folded_into: Vec> = vec![None; n]; + // Pick a group's survivor: prefer an unprefixed member (so its message is + // kept over a `fixup!`/`squash!` one), then apply the mode's position rule. + let choose_survivor = |candidates: &[usize]| -> usize { + let unprefixed: Vec = candidates + .iter() + .copied() + .filter(|&i| !is_autosquash_prefixed(entries[i].summary.as_str())) + .collect(); + let pool: &[usize] = if unprefixed.is_empty() { + candidates + } else { + &unprefixed + }; + match mode { + // Highest index = newest commit; lowest = oldest. + AutosquashMode::ToTop => *pool.iter().max().unwrap(), + _ => *pool.iter().min().unwrap(), + } + }; + match mode { + AutosquashMode::ToTop | AutosquashMode::ToBottom => { + // Group indices by normalized summary, preserving first-seen order. + let mut order: Vec<&str> = Vec::new(); + let mut groups: std::collections::HashMap<&str, Vec> = + std::collections::HashMap::new(); + for (i, e) in entries.iter().enumerate() { + let key = autosquash_group_key(e.summary.as_str()); + if key.trim().is_empty() { + continue; + } + groups + .entry(key) + .or_insert_with(|| { + order.push(key); + Vec::new() + }) + .push(i); + } + for key in order { + let indices = &groups[key]; + if indices.len() < 2 { + continue; + } + let survivor = choose_survivor(indices); + for &i in indices { + if i != survivor { + folded_into[i] = Some(survivor); + } + } + } + } + AutosquashMode::Neighbor => { + // Collapse each maximal run of adjacent commits with the same + // normalized summary into one survivor (an unprefixed member if any, + // else the run's oldest). + let mut i = 0; + while i < n { + let key = autosquash_group_key(entries[i].summary.as_str()); + if key.trim().is_empty() { + i += 1; + continue; + } + let mut j = i + 1; + while j < n && autosquash_group_key(entries[j].summary.as_str()) == key { + j += 1; + } + let run: Vec = (i..j).collect(); + if run.len() >= 2 { + let survivor = choose_survivor(&run); + for &k in &run { + if k != survivor { + folded_into[k] = Some(survivor); + } + } + } + i = j; + } + } + } + folded_into +} + +/// Applies `mode` to `original`, producing the collapsed entry list (one row +/// per surviving/untouched commit) plus the survivor-id → folded-fixup map. +/// The folded map is empty when nothing was eligible. +fn compute_autosquash( + original: &[InteractiveRebaseEntry], + mode: AutosquashMode, +) -> ( + Vec, + std::collections::HashMap>, +) { + let folded_into = autosquash_folds(original, mode); + let mut collapsed = Vec::with_capacity(original.len()); + let mut folded: std::collections::HashMap> = + std::collections::HashMap::new(); + for (i, e) in original.iter().enumerate() { + match folded_into[i] { + Some(survivor) => { + let mut fixup = e.clone(); + fixup.action = InteractiveRebaseAction::Fixup; + fixup.new_message = None; + folded + .entry(original[survivor].commit_id.clone()) + .or_default() + .push(fixup); + } + None => collapsed.push(e.clone()), + } + } + (collapsed, folded) +} + +/// Re-expands the collapsed editing list into the todo the rebase executor +/// consumes: each survivor is followed by its folded commits as `fixup` entries. +/// A dropped survivor takes its folded commits with it. +fn expand_folded( + entries: &[InteractiveRebaseEntry], + folded: &std::collections::HashMap>, +) -> Vec { + let mut out = Vec::with_capacity(entries.len()); + for e in entries { + out.push(e.clone()); + if e.action != InteractiveRebaseAction::Drop + && let Some(fixups) = folded.get(&e.commit_id) + { + out.extend(fixups.iter().cloned()); + } + } + out +} + +fn validate_squash_entries(entries: &mut [InteractiveRebaseEntry]) { + // First pass: a squash/fixup with no surviving target above it becomes a + // plain pick. + for k in 0..entries.len() { + if !matches!( + entries[k].action, + InteractiveRebaseAction::Squash | InteractiveRebaseAction::Fixup + ) { + continue; + } + let has_target = (0..k) + .rev() + .any(|j| entries[j].action != InteractiveRebaseAction::Drop); + if !has_target { + entries[k].action = InteractiveRebaseAction::Pick; + } + } + // Second pass: an entry auto-promoted to Reword solely to absorb a squash + // reverts to Pick once nothing folds into it and the user never typed a + // replacement message. Runs after the squash pass so a squash that just + // reverted to Pick correctly strands its former target. Mirrors the + // targeted cleanup in `set_rebase_action` for the reorder paths (▲/▼, drag) + // which previously only ran the squash pass. + for k in 0..entries.len() { + if entries[k].action == InteractiveRebaseAction::Reword + && entries[k].new_message.is_none() + && !entry_is_squash_target(entries, k) + { + entries[k].action = InteractiveRebaseAction::Pick; + } + } +} + +fn non_drop_count(entries: &[InteractiveRebaseEntry]) -> usize { + entries + .iter() + .filter(|e| e.action != InteractiveRebaseAction::Drop) + .count() +} + +fn action_short_label(action: InteractiveRebaseAction) -> &'static str { + match action { + InteractiveRebaseAction::Pick => "pick", + InteractiveRebaseAction::Reword => "reword", + InteractiveRebaseAction::Drop => "drop", + InteractiveRebaseAction::Squash => "squash", + InteractiveRebaseAction::Fixup => "fixup", + InteractiveRebaseAction::Edit => "edit", + } +} + +#[derive(Clone, Copy, Debug)] +struct IRebaseDragValue { + ix: usize, +} + +fn data_ix_for_display(st: &IRebaseViewState, display_pos: usize) -> usize { + match st.mode { + ICommitEditorMode::Rebase => st.entries.len() - 1 - display_pos, + ICommitEditorMode::CherryPick => display_pos, + } +} + +fn display_pos_for_data_ix(st: &IRebaseViewState, ix: usize) -> usize { + match st.mode { + ICommitEditorMode::Rebase => st.entries.len() - 1 - ix, + ICommitEditorMode::CherryPick => ix, + } +} + +fn insertion_data_ix_for_display( + st: &IRebaseViewState, + from_ix: usize, + display_pos: usize, +) -> usize { + let source_dp = display_pos_for_data_ix(st, from_ix); + match st.mode { + ICommitEditorMode::Rebase => { + if display_pos <= source_dp { + st.entries.len() - 1 - display_pos + } else { + st.entries.len() - display_pos + } + } + ICommitEditorMode::CherryPick => { + if display_pos <= source_dp { + display_pos + } else { + display_pos.saturating_sub(1) + } + } + } +} + +/// Content signature of the rebase list: changes whenever a row's height +/// could change (reorder, action change, or the folded-into map). Drives the +/// `ListState` remeasure/reset in `interactive_rebase_view`. +fn irebase_list_sig(st: &IRebaseViewState) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = rustc_hash::FxHasher::default(); + for e in &st.entries { + e.commit_id.hash(&mut h); + (e.action as u8).hash(&mut h); + } + // Folded groups add a header line (taller row); key on survivor + count. + let mut folded: Vec<(&String, usize)> = st.folded.iter().map(|(k, v)| (k, v.len())).collect(); + folded.sort(); + folded.hash(&mut h); + h.finish() +} + +/// Floating preview shown under the cursor while dragging a rebase entry, so +/// the in-list row can stay put (dimmed) with nothing painted over neighbours. +struct IRebaseDragPreview { + theme: AppTheme, + ui_scale_percent: u32, + action: InteractiveRebaseAction, + sha: String, + summary: String, + row_h: f32, +} + +impl Render for IRebaseDragPreview { + fn render(&mut self, _window: &mut Window, _cx: &mut gpui::Context) -> impl IntoElement { + let theme = self.theme; + let action_btn_w = px(ACTION_BTN_W * self.ui_scale_percent as f32 / 100.0); + let is_squash_like = matches!( + self.action, + InteractiveRebaseAction::Squash | InteractiveRebaseAction::Fixup + ); + let outlined_border = with_alpha( + theme.colors.text_muted, + if theme.is_dark { 0.38 } else { 0.28 }, + ); + div() + .h(px(self.row_h)) + .w(px(440.0 * self.ui_scale_percent as f32 / 100.0)) + .flex() + .items_center() + .gap_1() + .px_2() + .py_0p5() + .rounded(px(theme.radii.row)) + .bg(theme.colors.surface_bg_elevated) + .border_1() + .border_color(with_alpha(theme.colors.accent, 0.6)) + .child( + div() + .text_xs() + .text_color(theme.colors.text_muted) + .child("⠿"), + ) + .when(is_squash_like, |d| { + d.child(div().flex_shrink_0().flex().items_center().child( + crate::view::icons::svg_icon( + "icons/squash_arrow.svg", + with_alpha(theme.colors.accent, 0.7), + px(14.0), + ), + )) + }) + .child( + div() + .flex_shrink_0() + .w(action_btn_w) + .flex() + .items_center() + .justify_center() + .rounded(px(theme.radii.row)) + .border_1() + .border_color(outlined_border) + .text_sm() + .text_color(theme.colors.text) + .child(format!("{} ▾", action_short_label(self.action))), + ) + .child( + div() + .flex_shrink_0() + .text_xs() + .text_color(theme.colors.text_muted) + .font_family("monospace") + .child(self.sha.clone()), + ) + .child( + div() + .flex_1() + .text_sm() + .text_color(theme.colors.text) + .overflow_x_hidden() + .whitespace_nowrap() + .child(self.summary.clone()), + ) + } +} + +impl MainPaneView { + /// The active repo's interactive rebase editing state, if a setup is open. + pub(in crate::view) fn active_irebase(&self) -> Option<&IRebaseViewState> { + self.interactive_rebase_states.get(&self.active_repo_id()?) + } + + pub(in crate::view) fn active_irebase_mut(&mut self) -> Option<&mut IRebaseViewState> { + let repo_id = self.active_repo_id()?; + self.interactive_rebase_states.get_mut(&repo_id) + } + + /// Whether a later commit squashes into the active-setup entry at `ix`. + pub(in crate::view) fn active_entry_is_squash_target(&self, ix: usize) -> bool { + self.active_irebase() + .is_some_and(|st| entry_is_squash_target(&st.entries, ix)) + } + + /// Applies an auto-squash `mode` to the active setup as a one-shot action, + /// recomputing from the original commit list. Returns `false` when no + /// commits were eligible — the caller then surfaces a toast and the state is + /// left unchanged. + pub(in crate::view) fn apply_autosquash_mode( + &mut self, + mode: AutosquashMode, + cx: &mut gpui::Context, + ) -> bool { + let Some(st) = self.active_irebase_mut() else { + return true; + }; + let (collapsed, folded) = compute_autosquash(&st.original_entries, mode); + if folded.is_empty() { + return false; + } + st.autosquash_mode = Some(mode); + st.entries = collapsed; + st.folded = folded; + cx.notify(); + true + } + + pub(in crate::view) fn set_rebase_action( + &mut self, + ix: usize, + action: InteractiveRebaseAction, + cx: &mut gpui::Context, + ) { + let Some(st) = self.active_irebase_mut() else { + return; + }; + if ix >= st.entries.len() { + return; + } + + // Prevent dropping the last non-dropped commit. + if action == InteractiveRebaseAction::Drop { + let current = st.entries[ix].action; + if current != InteractiveRebaseAction::Drop && non_drop_count(&st.entries) <= 1 { + return; + } + } + + let old_action = st.entries[ix].action; + // Capture the former squash target before we change the action. + let former_squash_target = if matches!( + old_action, + InteractiveRebaseAction::Squash | InteractiveRebaseAction::Fixup + ) { + squash_target(&st.entries, ix) + } else { + None + }; + + st.entries[ix].action = action; + + if action == InteractiveRebaseAction::Squash { + // Auto-set the new target to Reword so the combined message can be written. + if let Some(j) = squash_target(&st.entries, ix) + && st.entries[j].action == InteractiveRebaseAction::Pick + { + st.entries[j].action = InteractiveRebaseAction::Reword; + } + } else if let Some(j) = former_squash_target { + // Was Squash/Fixup, now it isn't. If the former target is Reword and nothing + // else is squashing into it, revert it back to Pick. + if st.entries[j].action == InteractiveRebaseAction::Reword { + let still_targeted = (0..st.entries.len()).any(|k| { + matches!( + st.entries[k].action, + InteractiveRebaseAction::Squash | InteractiveRebaseAction::Fixup + ) && squash_target(&st.entries, k) == Some(j) + }); + if !still_targeted && st.entries[j].new_message.is_none() { + st.entries[j].action = InteractiveRebaseAction::Pick; + } + } + } + + if action == InteractiveRebaseAction::Drop { + validate_squash_entries(&mut st.entries); + } + + cx.notify(); + } + + /// Commit the pending drag reorder. Shared by every way a drag can end + /// (drop on the list, drop outside it, mouse released out of the window) + /// so the paths cannot diverge. Returns true if there was a drag to end. + fn commit_interactive_rebase_drag(&mut self) -> bool { + let Some(st) = self.active_irebase_mut() else { + return false; + }; + let Some(state) = st.drag_state.take() else { + return false; + }; + if state.from_ix != state.to_ix + && state.from_ix < st.entries.len() + && state.to_ix < st.entries.len() + { + let entry = st.entries.remove(state.from_ix); + st.entries.insert(state.to_ix, entry); + validate_squash_entries(&mut st.entries); + // Fade the landed row in at its new position. + let ver = st.reorder_anim.map(|(_, _, v)| v + 1).unwrap_or(0); + st.reorder_anim = Some((state.to_ix, state.to_ix, ver)); + } + true + } + + /// Update the drop target from a drag-move over the virtualized list. + /// Uses per-item bounds (rows are variable height) and auto-scrolls near + /// the viewport edges. + fn irebase_drag_move( + &mut self, + e: &gpui::DragMoveEvent, + repo_id: RepoId, + cx: &mut gpui::Context, + ) { + let from_ix = e.drag(cx).ix; + let Some(st) = self.interactive_rebase_states.get_mut(&repo_id) else { + return; + }; + let entry_count = st.entries.len(); + if entry_count == 0 { + return; + } + let Some(list) = st.scroll.clone() else { + return; + }; + + // Auto-scroll while the pointer is near the viewport edges. + let vp_h = e.bounds.size.height; + let pointer_vp = e.event.position.y - e.bounds.origin.y; + let edge = px(28.0); + if pointer_vp < edge { + list.scroll_by(-(edge - pointer_vp)); + cx.notify(); + } else if pointer_vp > vp_h - edge { + list.scroll_by(pointer_vp - (vp_h - edge)); + cx.notify(); + } + + // Insertion position in display order (0..=entry_count): the first + // laid-out row whose vertical midpoint is below the pointer; past the + // last row means "append at the bottom". + let pointer_y = e.event.position.y; + let mut display_pos = entry_count; + for dp in 0..entry_count { + if let Some(b) = list.bounds_for_item(dp) + && pointer_y < b.origin.y + b.size.height / 2.0 + { + display_pos = dp; + break; + } + } + + let to_ix = insertion_data_ix_for_display(st, from_ix, display_pos); + let already = st + .drag_state + .is_some_and(|s| s.from_ix == from_ix && s.display_pos == display_pos); + if !already { + st.drag_state = Some(IRebaseDragState { + from_ix, + to_ix, + display_pos, + }); + cx.notify(); + } + } + + /// Render one rebase row for the virtualized list. `display_pos` is the + /// list index (newest-first); the data index is `entry_count-1-display_pos`. + fn render_irebase_row( + &mut self, + display_pos: usize, + repo_id: RepoId, + cx: &mut gpui::Context, + ) -> gpui::AnyElement { + let theme = self.theme; + let ui_scale_percent = ui_scale::current(cx).percent; + let selected_commit_id = self + .active_repo() + .and_then(|r| r.history_state.selected_commit.as_ref()) + .map(|c| c.0.as_ref().to_owned()); + let Some(st) = self.interactive_rebase_states.get(&repo_id) else { + return div().into_any_element(); + }; + let entry_count = st.entries.len(); + if display_pos >= entry_count { + return div().into_any_element(); + } + let ix = data_ix_for_display(st, display_pos); + let reorder_anim = st.reorder_anim; + let drag_state = st.drag_state; + let drag_from_ix = drag_state.map(|s| s.from_ix).unwrap_or(usize::MAX); + let insertion_pos = drag_state.map(|s| s.display_pos); + let is_drag_source = ix == drag_from_ix; + let is_bottom = display_pos + 1 >= entry_count; + // Insertion indicator: a line above this row when it is the drop + // target, or below it when dropping past the last row. Absolutely + // positioned so it never changes row height (no remeasure churn). + let show_top_line = insertion_pos == Some(display_pos); + let show_bottom_line = is_bottom && insertion_pos == Some(entry_count); + + let action = st.entries[ix].action; + let sha = st.entries[ix] + .commit_id + .get(..8) + .unwrap_or(&st.entries[ix].commit_id) + .to_string(); + let summary = st.entries[ix] + .new_message + .as_deref() + .and_then(|m| m.lines().next()) + .unwrap_or(&st.entries[ix].summary) + .to_owned(); + let source_color = (st.mode == ICommitEditorMode::CherryPick) + .then(|| { + st.source_colors + .get(&st.entries[ix].commit_id) + .copied() + .unwrap_or(0) + }) + .map(|color_ix| crate::view::history_graph::lane_color(theme, color_ix)); + let is_selected = selected_commit_id + .as_deref() + .is_some_and(|s| s == st.entries[ix].commit_id); + let is_squash_like = matches!( + action, + InteractiveRebaseAction::Squash | InteractiveRebaseAction::Fixup + ); + let is_dropped = action == InteractiveRebaseAction::Drop; + let autosquash_active = + st.mode == ICommitEditorMode::Rebase && st.autosquash_mode.is_some(); + let is_autosquash_eligible = + st.mode == ICommitEditorMode::Rebase && !autosquash_active && { + let key = autosquash_group_key(st.entries[ix].summary.as_str()); + !key.trim().is_empty() + && st + .entries + .iter() + .filter(|e| autosquash_group_key(e.summary.as_str()) == key) + .count() + > 1 + }; + let folded_shas: Vec = if st.mode == ICommitEditorMode::Rebase { + st.folded + .get(&st.entries[ix].commit_id) + .map(|folds| { + folds + .iter() + .map(|f| f.commit_id.get(..8).unwrap_or(&f.commit_id).to_string()) + .collect() + }) + .unwrap_or_default() + } else { + Vec::new() + }; + + let btn_bounds: Rc>>> = + Rc::new(RefCell::new(None)); + let btn_bounds_prepaint = Rc::clone(&btn_bounds); + let action_btn_w = px(ACTION_BTN_W * ui_scale_percent as f32 / 100.0); + let action_label = format!("{} ▾", action_short_label(action)); + + let inner_btn = components::Button::new(format!("action_{ix}"), action_label) + .style(components::ButtonStyle::Outlined) + .render(theme, ui_scale_percent) + .w(action_btn_w) + .flex_shrink_0() + .on_click(cx.listener(move |this, _e, window, cx| { + let bounds = (*btn_bounds.borrow()).unwrap_or(gpui::Bounds { + origin: gpui::point(px(0.0), px(0.0)), + size: gpui::size(px(0.0), px(0.0)), + }); + let Some(st) = this.interactive_rebase_states.get(&repo_id) else { + return; + }; + let nd = non_drop_count(&st.entries); + let current_action = st.entries.get(ix).map(|e| e.action); + let can_drop = current_action == Some(InteractiveRebaseAction::Drop) || nd > 1; + let can_squash = ix > 0; + let wh = window.window_handle(); + let root = this.root_view.clone(); + cx.defer(move |cx| { + let _ = wh.update(cx, |_, window, cx| { + let _ = root.update(cx, |root, cx| { + root.open_popover_for_bounds( + PopoverKind::InteractiveRebaseActionMenu { + ix, + can_squash, + can_drop, + }, + bounds, + window, + cx, + ); + }); + }); + }); + })); + + let action_btn = div() + .on_children_prepainted(move |children_bounds, _w, _cx| { + if let Some(b) = children_bounds.first() { + *btn_bounds_prepaint.borrow_mut() = Some(*b); + } + }) + .child(inner_btn) + .id(format!("action_w_{ix}")); + + let up_btn = components::Button::new(format!("up_{ix}"), "▲") + .style(components::ButtonStyle::Subtle) + .no_focus() + .disabled(display_pos == 0) + .render(theme, ui_scale_percent) + .on_click(cx.listener(move |this, _e: &gpui::ClickEvent, _w, cx| { + let Some(st) = this.interactive_rebase_states.get_mut(&repo_id) else { + return; + }; + let entry_display_pos = display_pos_for_data_ix(st, ix); + if entry_display_pos > 0 { + let swap_ix = data_ix_for_display(st, entry_display_pos - 1); + st.entries.swap(ix, swap_ix); + validate_squash_entries(&mut st.entries); + let ver = st.reorder_anim.map(|(_, _, v)| v + 1).unwrap_or(0); + st.reorder_anim = Some((ix, swap_ix, ver)); + } + cx.notify(); + })); + + let down_btn = components::Button::new(format!("down_{ix}"), "▼") + .style(components::ButtonStyle::Subtle) + .no_focus() + .disabled(display_pos + 1 >= entry_count) + .render(theme, ui_scale_percent) + .on_click(cx.listener(move |this, _e: &gpui::ClickEvent, _w, cx| { + let Some(st) = this.interactive_rebase_states.get_mut(&repo_id) else { + return; + }; + let len = st.entries.len(); + let entry_display_pos = display_pos_for_data_ix(st, ix); + if entry_display_pos + 1 < len { + let swap_ix = data_ix_for_display(st, entry_display_pos + 1); + st.entries.swap(ix, swap_ix); + validate_squash_entries(&mut st.entries); + let ver = st.reorder_anim.map(|(_, _, v)| v + 1).unwrap_or(0); + st.reorder_anim = Some((ix, swap_ix, ver)); + } + cx.notify(); + })); + + let drag_val = IRebaseDragValue { ix }; + let pf_action = action; + let pf_sha = sha.clone(); + let pf_summary = summary.clone(); + let preview_row_h = 28.0 * ui_scale_percent as f32 / 100.0; + let gripper = div() + .id(("gripper", ix)) + .cursor(gpui::CursorStyle::PointingHand) + .text_xs() + .text_color(theme.colors.text_muted) + .child("⠿") + .on_drag(drag_val, move |_drag, _offset, _window, cx| { + cx.new(|_cx| IRebaseDragPreview { + theme, + ui_scale_percent, + action: pf_action, + sha: pf_sha.clone(), + summary: pf_summary.clone(), + row_h: preview_row_h, + }) + }); + + let commit_id_val = CommitId(st.entries[ix].commit_id.clone().into()); + let accent = theme.colors.accent; + let row_div = div() + .id(("irebase_row", ix)) + .relative() + .w_full() + .flex() + .flex_col() + .px_2() + .py_0p5() + .rounded(px(theme.radii.row)) + .when(!is_drag_source && is_selected, |d| { + d.bg(theme.colors.active) + }) + .when(!is_drag_source && !is_selected, |d| { + d.hover(move |s| s.bg(theme.colors.hover)) + }) + .when(is_dropped, |d| d.opacity(0.5)) + .when(show_top_line, |d| { + d.child( + div() + .absolute() + .top_0() + .left_0() + .right_0() + .h(px(2.0)) + .bg(accent), + ) + }) + .when(show_bottom_line, |d| { + d.child( + div() + .absolute() + .bottom_0() + .left_0() + .right_0() + .h(px(2.0)) + .bg(accent), + ) + }) + // The folded-commit list sits above the survivor row. + .when(!folded_shas.is_empty(), |d| { + d.child( + div() + .pl(px(20.0)) + .flex() + .items_center() + .gap_1() + .text_xs() + .text_color(theme.colors.text_muted) + .child(crate::view::icons::svg_icon( + "icons/squash_arrow.svg", + with_alpha(theme.colors.accent, 0.7), + px(12.0), + )) + .child(format!("squashed {}", folded_shas.join(", "))), + ) + }) + .child( + div() + .flex() + .items_center() + .gap_1() + .child(gripper) + .when(is_squash_like, |d| { + d.child(div().flex_shrink_0().flex().items_center().child( + crate::view::icons::svg_icon( + "icons/squash_arrow.svg", + with_alpha(theme.colors.accent, 0.7), + px(14.0), + ), + )) + }) + .child(action_btn) + .when_some(source_color, |d, color| { + d.child( + div() + .flex_shrink_0() + .w(px(4.0)) + .h(px(22.0)) + .rounded(px(2.0)) + .bg(color), + ) + }) + .child( + div() + .flex_shrink_0() + .text_xs() + .text_color(theme.colors.text_muted) + .font_family("monospace") + .child(sha.clone()), + ) + .child( + div() + .flex_1() + .text_sm() + .text_color(theme.colors.text) + .when(is_autosquash_eligible, |d| { + d.text_color(theme.colors.accent) + }) + .overflow_x_hidden() + .whitespace_nowrap() + .when(is_dropped, |d| d.line_through()) + .child(summary), + ) + .child( + div() + .flex() + .flex_shrink_0() + .gap_0p5() + .child(up_btn) + .child(down_btn), + ), + ) + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(move |this, _e: &gpui::MouseDownEvent, _w, cx| { + this.store.dispatch(Msg::SelectCommit { + repo_id, + commit_id: commit_id_val.clone(), + }); + cx.notify(); + }), + ) + .on_mouse_up( + gpui::MouseButton::Right, + cx.listener(move |this, e: &gpui::MouseUpEvent, window, cx| { + cx.stop_propagation(); + let Some(st) = this.interactive_rebase_states.get(&repo_id) else { + return; + }; + let nd = non_drop_count(&st.entries); + let current_action = st.entries.get(ix).map(|e| e.action); + let can_drop = current_action == Some(InteractiveRebaseAction::Drop) || nd > 1; + let can_squash = ix > 0; + let wh = window.window_handle(); + let root = this.root_view.clone(); + let pos = e.position; + cx.defer(move |cx| { + let _ = wh.update(cx, |_, window, cx| { + let _ = root.update(cx, |root, cx| { + root.open_popover_at( + PopoverKind::InteractiveRebaseActionMenu { + ix, + can_squash, + can_drop, + }, + pos, + window, + cx, + ); + }); + }); + }); + }), + ); + + if is_drag_source { + row_div + .with_animation( + ("irebase_source_dim", ix), + Animation::new(Duration::from_millis(120)).with_easing(gpui::ease_out_quint()), + |d, delta| d.opacity(1.0 - 0.6 * delta), + ) + .into_any_element() + } else if let Some((aix, bix, ver)) = reorder_anim { + if ix == aix || ix == bix { + row_div + .with_animation( + format!("reorder_{ix}_{ver}"), + Animation::new(Duration::from_millis(200)) + .with_easing(gpui::ease_out_quint()), + |d, delta| d.opacity(delta), + ) + .into_any_element() + } else { + row_div.into_any_element() + } + } else { + row_div.into_any_element() + } + } + + pub(in crate::view) fn interactive_rebase_view( + &mut self, + _window: &mut Window, + cx: &mut gpui::Context, + ) -> gpui::Div { + let theme = self.theme; + let ui_scale_percent = ui_scale::current(cx).percent; + self.sync_interactive_commit_editor_states(); + + // Sync the variable-height virtualized list state before borrowing the + // repo below (this needs `&mut self`; the match on `repo` holds `self` + // immutably for its whole body). + if let Some(repo_id) = self.active_repo_id() + && self.interactive_rebase_states.contains_key(&repo_id) + { + let sig = irebase_list_sig(&self.interactive_rebase_states[&repo_id]); + let count = self.interactive_rebase_states[&repo_id].entries.len(); + let st = self + .interactive_rebase_states + .get_mut(&repo_id) + .expect("checked contains_key"); + if let Some(ls) = &st.scroll { + if st.list_sig != (sig, count) { + if st.list_sig.1 == count { + ls.remeasure(); + } else { + ls.reset(count); + } + st.list_sig = (sig, count); + } + } else { + st.scroll = Some(gpui::ListState::new( + count, + gpui::ListAlignment::Top, + px(400.0), + )); + st.list_sig = (sig, count); + } + } + + let Some(repo) = self.active_repo() else { + return div().child("No active repo"); + }; + let repo_id = repo.id; + let (editor_mode, base, header_title, header_detail, loading_state) = + if let Some(setup) = repo.interactive_rebase_setup.as_ref() { + let base = setup.base.clone(); + // Only abbreviate full 40-char SHAs; leave branch names intact. + let base_short: SharedString = + if base.len() > 16 && base.chars().all(|c| c.is_ascii_hexdigit()) { + base.get(..8).unwrap_or(&base).to_string().into() + } else { + base.clone().into() + }; + // Prefer a branch name that points at the base commit; fall back to the + // abbreviated sha. + let base_display: SharedString = match &repo.branches { + Loadable::Ready(branches) => branches + .iter() + .find(|b| b.target.as_ref() == base.as_str()) + .map(|b| SharedString::from(b.name.clone())) + .unwrap_or_else(|| base_short.clone()), + _ => base_short.clone(), + }; + ( + ICommitEditorMode::Rebase, + Some(base), + SharedString::from("Interactive Rebase"), + SharedString::from(format!("onto {base_display}")), + setup.entries.clone(), + ) + } else if let Some(setup) = repo.interactive_cherry_pick_setup.as_ref() { + let count = setup.entries.len(); + ( + ICommitEditorMode::CherryPick, + None, + SharedString::from("Cherry-pick"), + SharedString::from(format!("{count} commits")), + Loadable::Ready(setup.entries.clone()), + ) + } else { + return div().child("No interactive commit setup"); + }; + let entry_content: gpui::AnyElement = match &loading_state { + Loadable::NotLoaded => div() + .px_2() + .py_2() + .text_sm() + .text_color(theme.colors.text_muted) + .child("Preparing…") + .into_any_element(), + Loadable::Loading => div() + .px_2() + .py_2() + .text_sm() + .text_color(theme.colors.text_muted) + .child("Loading commits…") + .into_any_element(), + Loadable::Error(e) => div() + .px_2() + .py_2() + .text_sm() + .text_color(theme.colors.text_muted) + .child(format!("Error: {e}")) + .into_any_element(), + // The map entry is populated by `apply_state` on the same state + // application that made the entries Ready; guard anyway. + // Nothing to rebase (base is already at HEAD): the editing UI is + // useless here, so show a plain message instead of an empty list. + // The footer hides its rebase options in the same empty case. + Loadable::Ready(_) + if self + .interactive_rebase_states + .get(&repo_id) + .is_some_and(|st| st.entries.is_empty()) => + { + div() + .flex_1() + .flex() + .items_center() + .justify_center() + .px_2() + .py_2() + .text_sm() + .text_color(theme.colors.text_muted) + .child(match editor_mode { + ICommitEditorMode::Rebase => "No commits to rebase.".to_string(), + ICommitEditorMode::CherryPick => "No commits to cherry-pick.".to_string(), + }) + .into_any_element() + } + Loadable::Ready(_) if self.interactive_rebase_states.contains_key(&repo_id) => { + // ListState was created/synced at the top of this fn. + let scroll = self.interactive_rebase_states[&repo_id] + .scroll + .clone() + .expect("synced above"); + + let list_el = gpui::list( + scroll.clone(), + cx.processor(move |this, ix: usize, _window, cx| { + this.render_irebase_row(ix, repo_id, cx) + }), + ) + .size_full(); + + let scrollbar_gutter = components::Scrollbar::visible_gutter( + scroll.clone(), + components::ScrollbarAxis::Vertical, + ); + + // `list` (unlike `uniform_list`) is not interactive, so the drag + // handlers live on the wrapping div. + let list_wrap = div() + .id("irebase_list_wrap") + .flex() + .flex_col() + .flex_1() + .min_h(px(0.0)) + .pr(scrollbar_gutter) + .on_drag_move(cx.listener( + move |this, e: &gpui::DragMoveEvent, _w, cx| { + this.irebase_drag_move(e, repo_id, cx); + }, + )) + .can_drop(move |dragged, _window, _cx| { + dragged.downcast_ref::().is_some() + }) + .on_drop(cx.listener(move |this, _drag: &IRebaseDragValue, _w, cx| { + this.commit_interactive_rebase_drag(); + cx.notify(); + })) + .child(list_el); + + div() + .relative() + .flex() + .flex_col() + .flex_1() + .min_h(px(0.0)) + .overflow_hidden() + .child(list_wrap) + .child( + components::Scrollbar::new("irebase_scrollbar", scroll.clone()) + .render(theme), + ) + .into_any_element() + } + // Ready, but apply_state has not populated the editing state yet. + Loadable::Ready(_) => div() + .px_2() + .py_2() + .text_sm() + .text_color(theme.colors.text_muted) + .child("Loading commits…") + .into_any_element(), + }; + + let (is_modified, entries_empty) = self + .interactive_rebase_states + .get(&repo_id) + .map(|st| (st.entries != st.original_entries, st.entries.is_empty())) + .unwrap_or((false, true)); + + div() + .flex() + .flex_col() + .size_full() + // Safety net: end the drag for drops that land outside the scroll container + // (e.g. releasing the mouse above the list when dragging the topmost item). + // Commits at the last previewed position, same as dropping on the list. + .can_drop(|dragged, _, _| dragged.downcast_ref::().is_some()) + .on_drop(cx.listener(|this, _: &IRebaseDragValue, _, cx| { + if this.commit_interactive_rebase_drag() { + cx.notify(); + } + })) + .on_mouse_up_out( + MouseButton::Left, + cx.listener(|this, _e, _w, cx| { + if this.commit_interactive_rebase_drag() { + cx.notify(); + } + }), + ) + .child( + div() + .px_2() + .py_1() + .flex() + .items_center() + .justify_between() + .child( + div() + .text_sm() + .font_weight(FontWeight::BOLD) + .child(header_title), + ) + .child( + div() + .text_xs() + .text_color(theme.colors.text_muted) + .child(header_detail), + ), + ) + .child(div().border_t_1().border_color(theme.colors.border)) + .child(entry_content) + .child(div().border_t_1().border_color(theme.colors.border)) + .child( + div() + .px_2() + .py_1() + .flex() + .items_center() + .justify_between() + .child( + div() + .flex() + .items_center() + .gap_1() + .when( + !entries_empty && editor_mode == ICommitEditorMode::Rebase, + |left| { + left.child( + div() + .text_xs() + .text_color(theme.colors.text_muted) + .child("Auto Squash"), + ) + .child( + components::Button::new( + "irebase_autosquash", + "Auto Squash ▾", + ) + .style(components::ButtonStyle::Outlined) + .on_click_with_bounds( + theme, + cx, + move |this, _e, bounds, window, cx| { + let wh = window.window_handle(); + let root = this.root_view.clone(); + cx.defer(move |cx| { + let _ = wh.update(cx, |_, window, cx| { + let _ = root.update(cx, |root, cx| { + root.open_popover_for_bounds( + PopoverKind::InteractiveRebaseAutosquashMenu, + bounds, + window, + cx, + ); + }); + }); + }); + }, + ), + ) + }, + ), + ) + .child( + div() + .flex() + .gap_1() + .when(!entries_empty, |row| row.child( + components::Button::new("irebase_reset", "Reset All") + .style(components::ButtonStyle::Outlined) + .disabled(!is_modified) + .render(theme, ui_scale_percent) + .on_click(cx.listener( + move |this, _e: &gpui::ClickEvent, _w, cx| { + let Some(st) = + this.interactive_rebase_states.get_mut(&repo_id) + else { + return; + }; + st.entries = st.original_entries.clone(); + st.folded.clear(); + st.autosquash_mode = None; + cx.notify(); + }, + )), + )) + .child( + components::Button::new("irebase_cancel", "Cancel") + .style(components::ButtonStyle::Outlined) + .render(theme, ui_scale_percent) + .on_click(cx.listener( + move |this, _e: &gpui::ClickEvent, _w, cx| { + this.store.dispatch( + Msg::CancelInteractiveRebaseSetup { repo_id }, + ); + if editor_mode == ICommitEditorMode::CherryPick { + this.store.dispatch( + Msg::CancelInteractiveCherryPickSetup { + repo_id, + }, + ); + } + this.interactive_rebase_states.remove(&repo_id); + cx.notify(); + }, + )), + ) + .when(!entries_empty, |row| row.child( + components::Button::new( + "irebase_start", + match editor_mode { + ICommitEditorMode::Rebase => "Start Rebase", + ICommitEditorMode::CherryPick => "Start Cherry-pick", + }, + ) + .style(components::ButtonStyle::Filled) + .disabled( + entries_empty + || !matches!(loading_state, Loadable::Ready(_)), + ) + .render(theme, ui_scale_percent) + .on_click(cx.listener( + move |this, _e: &gpui::ClickEvent, _w, cx| { + let Some(st) = + this.interactive_rebase_states.get_mut(&repo_id) + else { + return; + }; + if st.entries.is_empty() { + return; + } + let entries = if editor_mode + == ICommitEditorMode::Rebase + { + expand_folded(&st.entries, &st.folded) + } else { + st.entries.clone() + }; + match editor_mode { + ICommitEditorMode::Rebase => { + if let Some(base) = base.clone() { + this.store.dispatch( + Msg::InteractiveRebase { + repo_id, + base, + entries, + }, + ); + this.store.dispatch( + Msg::CancelInteractiveRebaseSetup { + repo_id, + }, + ); + } + } + ICommitEditorMode::CherryPick => { + this.store.dispatch( + Msg::InteractiveCherryPick { + repo_id, + entries, + }, + ); + this.store.dispatch( + Msg::CancelInteractiveCherryPickSetup { + repo_id, + }, + ); + this.interactive_rebase_states.remove(&repo_id); + } + } + cx.notify(); + }, + )), + )), + ), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry( + action: InteractiveRebaseAction, + id: &str, + new_message: Option<&str>, + ) -> InteractiveRebaseEntry { + InteractiveRebaseEntry { + action, + commit_id: id.to_string(), + summary: format!("summary {id}"), + new_message: new_message.map(|s| s.to_string()), + } + } + + #[test] + fn squash_folds_into_preceding_entry() { + // Data index 0 is the oldest commit; a squash at a higher index folds + // into the nearest non-drop entry below it. + let entries = vec![ + entry(InteractiveRebaseAction::Pick, "a", None), + entry(InteractiveRebaseAction::Squash, "b", None), + ]; + assert!(entry_is_squash_target(&entries, 0)); + assert!(!entry_is_squash_target(&entries, 1)); + } + + #[test] + fn stranded_auto_reword_reverts_to_pick() { + // A Reword with no user message and nothing squashing into it — the + // state left behind when a squash is reordered away from its target + // (finding #5) — reverts to Pick. + let mut entries = vec![ + entry(InteractiveRebaseAction::Pick, "a", None), + entry(InteractiveRebaseAction::Reword, "b", None), + ]; + validate_squash_entries(&mut entries); + assert_eq!(entries[1].action, InteractiveRebaseAction::Pick); + } + + #[test] + fn deliberate_reword_is_preserved() { + // A Reword the user actually typed a message for is never downgraded. + let mut entries = vec![ + entry(InteractiveRebaseAction::Pick, "a", None), + entry(InteractiveRebaseAction::Reword, "b", Some("new subject")), + ]; + validate_squash_entries(&mut entries); + assert_eq!(entries[1].action, InteractiveRebaseAction::Reword); + } + + #[test] + fn reword_kept_while_still_a_squash_target() { + // The auto-promoted Reword target stays Reword as long as a squash + // continues to fold into it. + let mut entries = vec![ + entry(InteractiveRebaseAction::Reword, "a", None), + entry(InteractiveRebaseAction::Squash, "b", None), + ]; + validate_squash_entries(&mut entries); + assert_eq!(entries[0].action, InteractiveRebaseAction::Reword); + assert_eq!(entries[1].action, InteractiveRebaseAction::Squash); + } + + #[test] + fn squash_without_target_reverts_to_pick() { + // A squash at the bottom (nothing below to fold into) becomes a pick, + // and the now-stranded reword above it also reverts. + let mut entries = vec![ + entry(InteractiveRebaseAction::Squash, "a", None), + entry(InteractiveRebaseAction::Reword, "b", None), + ]; + validate_squash_entries(&mut entries); + assert_eq!(entries[0].action, InteractiveRebaseAction::Pick); + assert_eq!(entries[1].action, InteractiveRebaseAction::Pick); + } + + // Commit with an explicit summary, for auto-squash grouping tests. + // Order is oldest-first (index 0 = oldest), matching the entries vector. + fn sc(id: &str, summary: &str) -> InteractiveRebaseEntry { + InteractiveRebaseEntry { + action: InteractiveRebaseAction::Pick, + commit_id: id.to_string(), + summary: summary.to_string(), + new_message: None, + } + } + + #[test] + fn autosquash_to_bottom_folds_into_oldest() { + // oldest→newest: B "fix", C "wip", D "fix", F "fix" + let original = vec![ + sc("B", "fix"), + sc("C", "wip"), + sc("D", "fix"), + sc("F", "fix"), + ]; + let (collapsed, folded) = compute_autosquash(&original, AutosquashMode::ToBottom); + // Only B (oldest "fix") and C survive. + let ids: Vec<&str> = collapsed.iter().map(|e| e.commit_id.as_str()).collect(); + assert_eq!(ids, vec!["B", "C"]); + let into_b = &folded["B"]; + assert_eq!( + into_b + .iter() + .map(|e| e.commit_id.as_str()) + .collect::>(), + vec!["D", "F"] + ); + assert!( + into_b + .iter() + .all(|e| e.action == InteractiveRebaseAction::Fixup) + ); + } + + #[test] + fn autosquash_fixup_prefix_folds_into_target() { + // `fixup! add feature` groups with `add feature` even though the exact + // subjects differ; the unprefixed commit survives (so its clean message + // is kept) even under ToTop, which would otherwise keep the newest. + let original = vec![ + sc("A", "add feature"), + sc("B", "unrelated"), + sc("C", "fixup! add feature"), + ]; + let (collapsed, folded) = compute_autosquash(&original, AutosquashMode::ToTop); + let ids: Vec<&str> = collapsed.iter().map(|e| e.commit_id.as_str()).collect(); + assert_eq!(ids, vec!["A", "B"]); + assert_eq!( + folded["A"] + .iter() + .map(|e| e.commit_id.as_str()) + .collect::>(), + vec!["C"] + ); + assert!( + folded["A"] + .iter() + .all(|e| e.action == InteractiveRebaseAction::Fixup) + ); + } + + #[test] + fn autosquash_neighbor_folds_adjacent_fixup_prefix() { + let original = vec![ + sc("A", "add feature"), + sc("B", "fixup! add feature"), + sc("C", "unrelated"), + ]; + let (collapsed, folded) = compute_autosquash(&original, AutosquashMode::Neighbor); + let ids: Vec<&str> = collapsed.iter().map(|e| e.commit_id.as_str()).collect(); + assert_eq!(ids, vec!["A", "C"]); + assert_eq!( + folded["A"] + .iter() + .map(|e| e.commit_id.as_str()) + .collect::>(), + vec!["B"] + ); + } + + #[test] + fn autosquash_to_top_folds_into_newest() { + let original = vec![ + sc("B", "fix"), + sc("C", "wip"), + sc("D", "fix"), + sc("F", "fix"), + ]; + let (collapsed, folded) = compute_autosquash(&original, AutosquashMode::ToTop); + // F (newest "fix") and C survive; F keeps its slot (after C). + let ids: Vec<&str> = collapsed.iter().map(|e| e.commit_id.as_str()).collect(); + assert_eq!(ids, vec!["C", "F"]); + assert_eq!( + folded["F"] + .iter() + .map(|e| e.commit_id.as_str()) + .collect::>(), + vec!["B", "D"] + ); + } + + #[test] + fn autosquash_neighbor_only_merges_adjacent() { + // Two "fix" are adjacent (D,E); a separate "fix" (B) is not. + let original = vec![ + sc("B", "fix"), + sc("C", "wip"), + sc("D", "fix"), + sc("E", "fix"), + ]; + let (collapsed, folded) = compute_autosquash(&original, AutosquashMode::Neighbor); + // B stays (not adjacent to another "fix"); D survives its run, E folds in. + let ids: Vec<&str> = collapsed.iter().map(|e| e.commit_id.as_str()).collect(); + assert_eq!(ids, vec!["B", "C", "D"]); + assert_eq!( + folded["D"] + .iter() + .map(|e| e.commit_id.as_str()) + .collect::>(), + vec!["E"] + ); + assert!(!folded.contains_key("B")); + } + + #[test] + fn autosquash_no_duplicates_yields_empty_fold() { + let original = vec![sc("A", "one"), sc("B", "two"), sc("C", "three")]; + let (_, folded) = compute_autosquash(&original, AutosquashMode::ToBottom); + assert!(folded.is_empty()); + } + + #[test] + fn expand_folded_reinserts_fixups_after_survivor() { + let original = vec![sc("B", "fix"), sc("C", "wip"), sc("D", "fix")]; + let (collapsed, folded) = compute_autosquash(&original, AutosquashMode::ToBottom); + let expanded = expand_folded(&collapsed, &folded); + let seq: Vec<(&str, InteractiveRebaseAction)> = expanded + .iter() + .map(|e| (e.commit_id.as_str(), e.action)) + .collect(); + assert_eq!( + seq, + vec![ + ("B", InteractiveRebaseAction::Pick), + ("D", InteractiveRebaseAction::Fixup), + ("C", InteractiveRebaseAction::Pick), + ] + ); + } + + #[test] + fn expand_folded_drops_fixups_with_dropped_survivor() { + let original = vec![sc("B", "fix"), sc("D", "fix")]; + let (mut collapsed, folded) = compute_autosquash(&original, AutosquashMode::ToBottom); + // Drop the survivor B; its folded commit D should not be emitted. + collapsed[0].action = InteractiveRebaseAction::Drop; + let expanded = expand_folded(&collapsed, &folded); + assert_eq!( + expanded + .iter() + .map(|e| e.commit_id.as_str()) + .collect::>(), + vec!["B"] + ); + } +} diff --git a/crates/gitcomet-ui-gpui/src/view/rows/history.rs b/crates/gitcomet-ui-gpui/src/view/rows/history.rs index fbda4d05..f9454a05 100644 --- a/crates/gitcomet-ui-gpui/src/view/rows/history.rs +++ b/crates/gitcomet-ui-gpui/src/view/rows/history.rs @@ -9,6 +9,7 @@ use crate::view::markdown_preview::{ }; use crate::view::panes::main::diff_search::DiffSearchMatcher; use crate::view::perf::{self, ViewPerfRenderLane, ViewPerfSpan}; +use gitcomet_state::msg::CommitSelectMode; use rustc_hash::FxHasher; #[derive(Clone)] @@ -1780,7 +1781,9 @@ impl HistoryView { let decoration_row_vm = cache.decorations.row_vms.get(visible_ix)?; let connect_from_top_col = (show_working_tree_summary_row && visible_ix == 0).then_some(0); - let selected = repo.history_state.selected_commit.as_ref() == Some(&commit.id); + let selected = repo.history_state.selected_commit.as_ref() == Some(&commit.id) + || repo.history_state.multi_selection.is_multi() + && repo.history_state.multi_selection.contains(&commit.id); let selected_branch_entry_text = this.selected_branch_entry_text_for_history_row( repo.id, base_row_vm.is_head, @@ -1988,10 +1991,24 @@ fn history_table_row( .child(commit_row) .on_mouse_up( MouseButton::Left, - cx.listener(move |this, _e: &MouseUpEvent, _w, cx| { - this.store.dispatch(Msg::SelectCommit { + cx.listener(move |this, e: &MouseUpEvent, _w, cx| { + let modifiers = e.modifiers; + let mode = if modifiers.shift { + CommitSelectMode::Range + } else if modifiers.secondary() || modifiers.control || modifiers.platform { + CommitSelectMode::Toggle + } else { + CommitSelectMode::Single + }; + let visible_order = (mode == CommitSelectMode::Range) + .then(|| this.visible_commit_ids_for_repo(repo_id)) + .flatten(); + this.store.dispatch(Msg::SelectCommitMulti { repo_id, commit_id: commit_id.clone(), + mode, + clicked_index: Some(graph_row_ix), + visible_order, }); cx.notify(); }), diff --git a/crates/gitcomet-ui-gpui/src/view/rows/history_canvas.rs b/crates/gitcomet-ui-gpui/src/view/rows/history_canvas.rs index 1c0b7f23..fb07e4fe 100644 --- a/crates/gitcomet-ui-gpui/src/view/rows/history_canvas.rs +++ b/crates/gitcomet-ui-gpui/src/view/rows/history_canvas.rs @@ -1,4 +1,5 @@ use super::*; +use gitcomet_state::msg::CommitSelectMode; use gpui::{ Bounds, ContentMask, CursorStyle, DispatchPhase, HitboxBehavior, MouseButton, TruncateFrom, fill, point, px, size, @@ -670,9 +671,17 @@ pub(super) fn history_commit_row_canvas( .and_then(|ix| tag_names.get(ix)) .map(|tag| tag.as_ref().to_string()); view.update(cx, |this, cx| { - this.store.dispatch(Msg::SelectCommit { + // Right-clicking inside an active multi-selection must + // not collapse it — the menu acts on the whole set — but + // focus must still move to the clicked commit so the + // details pane matches the menu target. Outside the + // selection this collapses to the clicked commit. + this.store.dispatch(Msg::SelectCommitMulti { repo_id, commit_id: commit_id.clone(), + mode: CommitSelectMode::PreserveIfSelected, + clicked_index: None, + visible_order: None, }); let context_menu_invoker: SharedString = format!("history_commit_menu_{}_{}", repo_id.0, commit_id.as_ref())