diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 9c4a4d1f50..9b94ed32a1 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -782,6 +782,7 @@ "shift-tab": "git_panel::FocusEditor", "escape": "git_panel::ToggleFocus", "ctrl-enter": "git::Commit", + "ctrl-shift-enter": "git::Amend", "alt-enter": "menu::SecondaryConfirm", "delete": ["git::RestoreFile", { "skip_prompt": false }], "backspace": ["git::RestoreFile", { "skip_prompt": false }], @@ -790,12 +791,20 @@ "ctrl-delete": ["git::RestoreFile", { "skip_prompt": false }] } }, + { + "context": "GitPanel && CommitEditor", + "use_key_equivalents": true, + "bindings": { + "escape": "git::Cancel" + } + }, { "context": "GitCommit > Editor", "bindings": { "escape": "menu::Cancel", "enter": "editor::Newline", "ctrl-enter": "git::Commit", + "ctrl-shift-enter": "git::Amend", "alt-l": "git::GenerateCommitMessage" } }, @@ -817,6 +826,7 @@ "context": "GitDiff > Editor", "bindings": { "ctrl-enter": "git::Commit", + "ctrl-shift-enter": "git::Amend", "ctrl-space": "git::StageAll", "ctrl-shift-space": "git::UnstageAll" } @@ -835,6 +845,7 @@ "shift-tab": "git_panel::FocusChanges", "enter": "editor::Newline", "ctrl-enter": "git::Commit", + "ctrl-shift-enter": "git::Amend", "alt-up": "git_panel::FocusChanges", "alt-l": "git::GenerateCommitMessage" } diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index ee24d9deb7..c5cf9e019b 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -855,17 +855,26 @@ "shift-tab": "git_panel::FocusEditor", "escape": "git_panel::ToggleFocus", "cmd-enter": "git::Commit", + "cmd-shift-enter": "git::Amend", "backspace": ["git::RestoreFile", { "skip_prompt": false }], "delete": ["git::RestoreFile", { "skip_prompt": false }], "cmd-backspace": ["git::RestoreFile", { "skip_prompt": true }], "cmd-delete": ["git::RestoreFile", { "skip_prompt": true }] } }, + { + "context": "GitPanel && CommitEditor", + "use_key_equivalents": true, + "bindings": { + "escape": "git::Cancel" + } + }, { "context": "GitDiff > Editor", "use_key_equivalents": true, "bindings": { "cmd-enter": "git::Commit", + "cmd-shift-enter": "git::Amend", "cmd-ctrl-y": "git::StageAll", "cmd-ctrl-shift-y": "git::UnstageAll" } @@ -876,6 +885,7 @@ "bindings": { "enter": "editor::Newline", "cmd-enter": "git::Commit", + "cmd-shift-enter": "git::Amend", "tab": "git_panel::FocusChanges", "shift-tab": "git_panel::FocusChanges", "alt-up": "git_panel::FocusChanges", @@ -905,6 +915,7 @@ "enter": "editor::Newline", "escape": "menu::Cancel", "cmd-enter": "git::Commit", + "cmd-shift-enter": "git::Amend", "alt-tab": "git::GenerateCommitMessage" } }, diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index 15750bfccc..1a2d28e241 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/crates/fs/src/fake_git_repo.rs @@ -5,8 +5,8 @@ use futures::future::{self, BoxFuture}; use git::{ blame::Blame, repository::{ - AskPassDelegate, Branch, CommitDetails, GitRepository, GitRepositoryCheckpoint, - PushOptions, Remote, RepoPath, ResetMode, + AskPassDelegate, Branch, CommitDetails, CommitOptions, GitRepository, + GitRepositoryCheckpoint, PushOptions, Remote, RepoPath, ResetMode, }, status::{FileStatus, GitStatus, StatusCode, TrackedStatus, UnmergedStatus}, }; @@ -365,6 +365,7 @@ impl GitRepository for FakeGitRepository { &self, _message: gpui::SharedString, _name_and_email: Option<(gpui::SharedString, gpui::SharedString)>, + _options: CommitOptions, _env: Arc>, ) -> BoxFuture> { unimplemented!() diff --git a/crates/git/src/git.rs b/crates/git/src/git.rs index 615d807c38..668d5f9ac7 100644 --- a/crates/git/src/git.rs +++ b/crates/git/src/git.rs @@ -50,6 +50,8 @@ actions!( Pull, Fetch, Commit, + Amend, + Cancel, ExpandCommitEditor, GenerateCommitMessage, Init, diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 060c14ffcc..28f0d1c910 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -74,6 +74,11 @@ impl Upstream { } } +#[derive(Clone, Copy, Default)] +pub struct CommitOptions { + pub amend: bool, +} + #[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] pub enum UpstreamTracking { /// Remote ref not present in local repository. @@ -252,6 +257,7 @@ pub trait GitRepository: Send + Sync { &self, message: SharedString, name_and_email: Option<(SharedString, SharedString)>, + options: CommitOptions, env: Arc>, ) -> BoxFuture>; @@ -368,8 +374,8 @@ impl RealGitRepository { #[derive(Clone, Debug)] pub struct GitRepositoryCheckpoint { - ref_name: String, - commit_sha: Oid, + pub ref_name: String, + pub commit_sha: Oid, } impl GitRepository for RealGitRepository { @@ -957,6 +963,7 @@ impl GitRepository for RealGitRepository { &self, message: SharedString, name_and_email: Option<(SharedString, SharedString)>, + options: CommitOptions, env: Arc>, ) -> BoxFuture> { let working_directory = self.working_directory(); @@ -969,6 +976,10 @@ impl GitRepository for RealGitRepository { .arg(&message.to_string()) .arg("--cleanup=strip"); + if options.amend { + cmd.arg("--amend"); + } + if let Some((name, email)) = name_and_email { cmd.arg("--author").arg(&format!("{name} <{email}>")); } @@ -1765,6 +1776,7 @@ mod tests { repo.commit( "Initial commit".into(), None, + CommitOptions::default(), Arc::new(checkpoint_author_envs()), ) .await @@ -1793,6 +1805,7 @@ mod tests { repo.commit( "Commit after checkpoint".into(), None, + CommitOptions::default(), Arc::new(checkpoint_author_envs()), ) .await diff --git a/crates/git_ui/src/commit_modal.rs b/crates/git_ui/src/commit_modal.rs index 16b8525f75..c90e0deade 100644 --- a/crates/git_ui/src/commit_modal.rs +++ b/crates/git_ui/src/commit_modal.rs @@ -1,8 +1,12 @@ use crate::branch_picker::{self, BranchList}; use crate::git_panel::{GitPanel, commit_message_editor}; -use git::{Commit, GenerateCommitMessage}; +use git::repository::CommitOptions; +use git::{Amend, Commit, GenerateCommitMessage}; +use language::Buffer; use panel::{panel_button, panel_editor_style, panel_filled_button}; -use ui::{KeybindingHint, PopoverMenu, PopoverMenuHandle, Tooltip, prelude::*}; +use ui::{ + ContextMenu, KeybindingHint, PopoverMenu, PopoverMenuHandle, SplitButton, Tooltip, prelude::*, +}; use editor::{Editor, EditorElement}; use gpui::*; @@ -100,6 +104,9 @@ impl CommitModal { workspace.register_action(|workspace, _: &Commit, window, cx| { CommitModal::toggle(workspace, window, cx); }); + workspace.register_action(|workspace, _: &Amend, window, cx| { + CommitModal::toggle(workspace, window, cx); + }); } pub fn toggle(workspace: &mut Workspace, window: &mut Window, cx: &mut Context) { @@ -214,23 +221,67 @@ impl CommitModal { ) } + fn render_git_commit_menu( + &self, + id: impl Into, + keybinding_target: Option, + ) -> impl IntoElement { + PopoverMenu::new(id.into()) + .trigger( + ui::ButtonLike::new_rounded_right("commit-split-button-right") + .layer(ui::ElevationIndex::ModalSurface) + .size(ui::ButtonSize::None) + .child( + div() + .px_1() + .child(Icon::new(IconName::ChevronDownSmall).size(IconSize::XSmall)), + ), + ) + .menu(move |window, cx| { + Some(ContextMenu::build(window, cx, |context_menu, _, _| { + context_menu + .when_some(keybinding_target.clone(), |el, keybinding_target| { + el.context(keybinding_target.clone()) + }) + .action("Amend...", Amend.boxed_clone()) + })) + }) + .anchor(Corner::TopRight) + } + pub fn render_footer(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let (can_commit, tooltip, commit_label, co_authors, generate_commit_message, active_repo) = - self.git_panel.update(cx, |git_panel, cx| { - let (can_commit, tooltip) = git_panel.configure_commit_button(cx); - let title = git_panel.commit_button_title(); - let co_authors = git_panel.render_co_authors(cx); - let generate_commit_message = git_panel.render_generate_commit_message_button(cx); - let active_repo = git_panel.active_repository.clone(); - ( - can_commit, - tooltip, - title, - co_authors, - generate_commit_message, - active_repo, - ) - }); + let ( + can_commit, + tooltip, + commit_label, + co_authors, + generate_commit_message, + active_repo, + is_amend_pending, + has_previous_commit, + ) = self.git_panel.update(cx, |git_panel, cx| { + let (can_commit, tooltip) = git_panel.configure_commit_button(cx); + let title = git_panel.commit_button_title(); + let co_authors = git_panel.render_co_authors(cx); + let generate_commit_message = git_panel.render_generate_commit_message_button(cx); + let active_repo = git_panel.active_repository.clone(); + let is_amend_pending = git_panel.amend_pending(); + let has_previous_commit = active_repo + .as_ref() + .and_then(|repo| repo.read(cx).branch.as_ref()) + .and_then(|branch| branch.most_recent_commit.as_ref()) + .is_some(); + ( + can_commit, + tooltip, + title, + co_authors, + generate_commit_message, + active_repo, + is_amend_pending, + has_previous_commit, + ) + }); let branch = active_repo .as_ref() @@ -277,21 +328,6 @@ impl CommitModal { None }; - let commit_button = panel_filled_button(commit_label) - .tooltip({ - let panel_editor_focus_handle = focus_handle.clone(); - move |window, cx| { - Tooltip::for_action_in(tooltip, &Commit, &panel_editor_focus_handle, window, cx) - } - }) - .disabled(!can_commit) - .on_click(cx.listener(move |this, _: &ClickEvent, window, cx| { - telemetry::event!("Git Committed", source = "Git Modal"); - this.git_panel - .update(cx, |git_panel, cx| git_panel.commit_changes(window, cx)); - cx.emit(DismissEvent); - })); - h_flex() .group("commit_editor_footer") .flex_none() @@ -324,21 +360,188 @@ impl CommitModal { .px_1() .gap_4() .children(close_kb_hint) - .child(commit_button), + .when(is_amend_pending, |this| { + let focus_handle = focus_handle.clone(); + this.child( + panel_filled_button(commit_label) + .tooltip(move |window, cx| { + if can_commit { + Tooltip::for_action_in( + tooltip, + &Amend, + &focus_handle, + window, + cx, + ) + } else { + Tooltip::simple(tooltip, cx) + } + }) + .disabled(!can_commit) + .on_click(move |_, window, cx| { + window.dispatch_action(Box::new(git::Commit), cx); + }), + ) + }) + .when(!is_amend_pending, |this| { + this.when(has_previous_commit, |this| { + this.child(SplitButton::new( + ui::ButtonLike::new_rounded_left(ElementId::Name( + format!("split-button-left-{}", commit_label).into(), + )) + .layer(ui::ElevationIndex::ModalSurface) + .size(ui::ButtonSize::Compact) + .child( + div() + .child(Label::new(commit_label).size(LabelSize::Small)) + .mr_0p5(), + ) + .on_click(move |_, window, cx| { + window.dispatch_action(Box::new(git::Commit), cx); + }) + .disabled(!can_commit) + .tooltip({ + let focus_handle = focus_handle.clone(); + move |window, cx| { + if can_commit { + Tooltip::with_meta_in( + tooltip, + Some(&git::Commit), + "git commit", + &focus_handle.clone(), + window, + cx, + ) + } else { + Tooltip::simple(tooltip, cx) + } + } + }), + self.render_git_commit_menu( + ElementId::Name( + format!("split-button-right-{}", commit_label).into(), + ), + Some(focus_handle.clone()), + ) + .into_any_element(), + )) + }) + .when(!has_previous_commit, |this| { + this.child( + panel_filled_button(commit_label) + .tooltip(move |window, cx| { + if can_commit { + Tooltip::with_meta_in( + tooltip, + Some(&git::Commit), + "git commit", + &focus_handle, + window, + cx, + ) + } else { + Tooltip::simple(tooltip, cx) + } + }) + .disabled(!can_commit) + .on_click(move |_, window, cx| { + window.dispatch_action(Box::new(git::Commit), cx); + }), + ) + }) + }), ) } fn dismiss(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context) { - cx.emit(DismissEvent); + if self.git_panel.read(cx).amend_pending() { + self.git_panel + .update(cx, |git_panel, _| git_panel.set_amend_pending(false)); + cx.notify(); + } else { + cx.emit(DismissEvent); + } + } + + pub fn commit_message_buffer(&self, cx: &App) -> Entity { + self.commit_editor + .read(cx) + .buffer() + .read(cx) + .as_singleton() + .unwrap() + .clone() } fn commit(&mut self, _: &git::Commit, window: &mut Window, cx: &mut Context) { + if self.git_panel.read(cx).amend_pending() { + return; + } telemetry::event!("Git Committed", source = "Git Modal"); - self.git_panel - .update(cx, |git_panel, cx| git_panel.commit_changes(window, cx)); + self.git_panel.update(cx, |git_panel, cx| { + git_panel.commit_changes(CommitOptions { amend: false }, window, cx) + }); cx.emit(DismissEvent); } + fn amend(&mut self, _: &git::Amend, window: &mut Window, cx: &mut Context) { + let Some(active_repository) = self.git_panel.read(cx).active_repository.as_ref() else { + return; + }; + let Some(branch) = active_repository.read(cx).branch.as_ref() else { + return; + }; + let Some(recent_sha) = branch + .most_recent_commit + .as_ref() + .map(|commit| commit.sha.to_string()) + else { + return; + }; + if self + .commit_editor + .focus_handle(cx) + .contains_focused(window, cx) + { + if !self.git_panel.read(cx).amend_pending() { + self.git_panel.update(cx, |git_panel, _| { + git_panel.set_amend_pending(true); + }); + cx.notify(); + if self.commit_editor.read(cx).is_empty(cx) { + let detail_task = self.git_panel.update(cx, |git_panel, cx| { + git_panel.load_commit_details(recent_sha, cx) + }); + cx.spawn(async move |this, cx| { + if let Ok(message) = detail_task.await.map(|detail| detail.message) { + this.update(cx, |this, cx| { + this.commit_message_buffer(cx).update(cx, |buffer, cx| { + let insert_position = buffer.anchor_before(buffer.len()); + buffer.edit( + [(insert_position..insert_position, message)], + None, + cx, + ); + }); + }) + .log_err(); + } + }) + .detach(); + } + } else { + telemetry::event!("Git Amended", source = "Git Panel"); + self.git_panel.update(cx, |git_panel, cx| { + git_panel.set_amend_pending(false); + git_panel.commit_changes(CommitOptions { amend: true }, window, cx); + }); + cx.emit(DismissEvent); + } + } else { + cx.propagate(); + } + } + fn toggle_branch_selector(&mut self, window: &mut Window, cx: &mut Context) { if self.branch_list_handle.is_focused(window, cx) { self.focus_handle(cx).focus(window) @@ -361,6 +564,7 @@ impl Render for CommitModal { .key_context("GitCommit") .on_action(cx.listener(Self::dismiss)) .on_action(cx.listener(Self::commit)) + .on_action(cx.listener(Self::amend)) .on_action(cx.listener(|this, _: &GenerateCommitMessage, _, cx| { this.git_panel.update(cx, |panel, cx| { panel.generate_commit_message(cx); diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 3b0acc4161..6a5c0fb372 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -21,11 +21,11 @@ use editor::{ use futures::StreamExt as _; use git::blame::ParsedCommitMessage; use git::repository::{ - Branch, CommitDetails, CommitSummary, DiffType, PushOptions, Remote, RemoteCommandOutput, - ResetMode, Upstream, UpstreamTracking, UpstreamTrackingStatus, + Branch, CommitDetails, CommitOptions, CommitSummary, DiffType, PushOptions, Remote, + RemoteCommandOutput, ResetMode, Upstream, UpstreamTracking, UpstreamTrackingStatus, }; use git::status::StageStatus; -use git::{Commit, ToggleStaged, repository::RepoPath, status::FileStatus}; +use git::{Amend, ToggleStaged, repository::RepoPath, status::FileStatus}; use git::{ExpandCommitEditor, RestoreTrackedFiles, StageAll, TrashUntrackedFiles, UnstageAll}; use gpui::{ Action, Animation, AnimationExt as _, Axis, ClickEvent, Corner, DismissEvent, Entity, @@ -59,8 +59,8 @@ use std::{collections::HashSet, sync::Arc, time::Duration, usize}; use strum::{IntoEnumIterator, VariantNames}; use time::OffsetDateTime; use ui::{ - Checkbox, ContextMenu, ElevationIndex, PopoverMenu, Scrollbar, ScrollbarState, Tooltip, - prelude::*, + Checkbox, ContextMenu, ElevationIndex, PopoverMenu, Scrollbar, ScrollbarState, SplitButton, + Tooltip, prelude::*, }; use util::{ResultExt, TryFutureExt, maybe}; use workspace::AppState; @@ -340,6 +340,7 @@ pub struct GitPanel { new_staged_count: usize, pending: Vec, pending_commit: Option>, + amend_pending: bool, pending_serialization: Task>, pub(crate) project: Entity, scroll_handle: UniformListScrollHandle, @@ -492,6 +493,7 @@ impl GitPanel { new_staged_count: 0, pending: Vec::new(), pending_commit: None, + amend_pending: false, pending_serialization: Task::ready(None), single_staged_entry: None, single_tracked_entry: None, @@ -1417,18 +1419,76 @@ impl GitPanel { } fn commit(&mut self, _: &git::Commit, window: &mut Window, cx: &mut Context) { + if self.amend_pending { + return; + } if self .commit_editor .focus_handle(cx) .contains_focused(window, cx) { telemetry::event!("Git Committed", source = "Git Panel"); - self.commit_changes(window, cx) + self.commit_changes(CommitOptions { amend: false }, window, cx) } else { cx.propagate(); } } + fn amend(&mut self, _: &git::Amend, window: &mut Window, cx: &mut Context) { + let Some(active_repository) = self.active_repository.as_ref() else { + return; + }; + let Some(branch) = active_repository.read(cx).branch.as_ref() else { + return; + }; + let Some(recent_sha) = branch + .most_recent_commit + .as_ref() + .map(|commit| commit.sha.to_string()) + else { + return; + }; + if self + .commit_editor + .focus_handle(cx) + .contains_focused(window, cx) + { + if !self.amend_pending { + self.amend_pending = true; + cx.notify(); + if self.commit_editor.read(cx).is_empty(cx) { + let detail_task = self.load_commit_details(recent_sha, cx); + cx.spawn(async move |this, cx| { + if let Ok(message) = detail_task.await.map(|detail| detail.message) { + this.update(cx, |this, cx| { + this.commit_message_buffer(cx).update(cx, |buffer, cx| { + let start = buffer.anchor_before(0); + let end = buffer.anchor_after(buffer.len()); + buffer.edit([(start..end, message)], None, cx); + }); + }) + .log_err(); + } + }) + .detach(); + } + } else { + telemetry::event!("Git Amended", source = "Git Panel"); + self.amend_pending = false; + self.commit_changes(CommitOptions { amend: true }, window, cx); + } + } else { + cx.propagate(); + } + } + + fn cancel(&mut self, _: &git::Cancel, _: &mut Window, cx: &mut Context) { + if self.amend_pending { + self.amend_pending = false; + cx.notify(); + } + } + fn custom_or_suggested_commit_message(&self, cx: &mut Context) -> Option { let message = self.commit_editor.read(cx).text(cx); @@ -1440,7 +1500,12 @@ impl GitPanel { .filter(|message| !message.trim().is_empty()) } - pub(crate) fn commit_changes(&mut self, window: &mut Window, cx: &mut Context) { + pub(crate) fn commit_changes( + &mut self, + options: CommitOptions, + window: &mut Window, + cx: &mut Context, + ) { let Some(active_repository) = self.active_repository.clone() else { return; }; @@ -1474,8 +1539,9 @@ impl GitPanel { let task = if self.has_staged_changes() { // Repository serializes all git operations, so we can just send a commit immediately - let commit_task = - active_repository.update(cx, |repo, cx| repo.commit(message.into(), None, cx)); + let commit_task = active_repository.update(cx, |repo, cx| { + repo.commit(message.into(), None, options, cx) + }); cx.background_spawn(async move { commit_task.await? }) } else { let changed_files = self @@ -1495,8 +1561,9 @@ impl GitPanel { active_repository.update(cx, |repo, cx| repo.stage_entries(changed_files, cx)); cx.spawn(async move |_, cx| { stage_task.await?; - let commit_task = active_repository - .update(cx, |repo, cx| repo.commit(message.into(), None, cx))?; + let commit_task = active_repository.update(cx, |repo, cx| { + repo.commit(message.into(), None, options, cx) + })?; commit_task.await? }) }; @@ -2722,6 +2789,34 @@ impl GitPanel { } } + fn render_git_commit_menu( + &self, + id: impl Into, + keybinding_target: Option, + ) -> impl IntoElement { + PopoverMenu::new(id.into()) + .trigger( + ui::ButtonLike::new_rounded_right("commit-split-button-right") + .layer(ui::ElevationIndex::ModalSurface) + .size(ui::ButtonSize::None) + .child( + div() + .px_1() + .child(Icon::new(IconName::ChevronDownSmall).size(IconSize::XSmall)), + ), + ) + .menu(move |window, cx| { + Some(ContextMenu::build(window, cx, |context_menu, _, _| { + context_menu + .when_some(keybinding_target.clone(), |el, keybinding_target| { + el.context(keybinding_target.clone()) + }) + .action("Amend...", Amend.boxed_clone()) + })) + }) + .anchor(Corner::TopRight) + } + pub fn configure_commit_button(&self, cx: &mut Context) -> (bool, &'static str) { if self.has_unstaged_conflicts() { (false, "You must resolve conflicts before committing") @@ -2739,10 +2834,18 @@ impl GitPanel { } pub fn commit_button_title(&self) -> &'static str { - if self.has_staged_changes() { - "Commit" + if self.amend_pending { + if self.has_staged_changes() { + "Amend" + } else { + "Amend Tracked" + } } else { - "Commit Tracked" + if self.has_staged_changes() { + "Commit" + } else { + "Commit Tracked" + } } } @@ -2885,6 +2988,10 @@ impl GitPanel { let editor_is_long = self.commit_editor.update(cx, |editor, cx| { editor.max_point(cx).row().0 >= MAX_PANEL_EDITOR_LINES as u32 }); + let has_previous_commit = branch + .as_ref() + .and_then(|branch| branch.most_recent_commit.as_ref()) + .is_some(); let footer = v_flex() .child(PanelRepoFooter::new(display_name, branch, Some(git_panel))) @@ -2920,32 +3027,140 @@ impl GitPanel { .unwrap_or_else(|| div().into_any_element()), ) .child( - h_flex().gap_0p5().children(enable_coauthors).child( - panel_filled_button(title) - .tooltip(move |window, cx| { - if can_commit { - Tooltip::for_action_in( - tooltip, - &Commit, - &commit_tooltip_focus_handle, - window, - cx, + h_flex() + .gap_0p5() + .children(enable_coauthors) + .when(self.amend_pending, { + |this| { + this.h_flex() + .gap_1() + .child( + panel_filled_button("Cancel") + .tooltip({ + let handle = + commit_tooltip_focus_handle.clone(); + move |window, cx| { + Tooltip::for_action_in( + "Cancel amend", + &git::Cancel, + &handle, + window, + cx, + ) + } + }) + .on_click(move |_, window, cx| { + window.dispatch_action( + Box::new(git::Cancel), + cx, + ); + }), ) - } else { - Tooltip::simple(tooltip, cx) - } + .child( + panel_filled_button(title) + .tooltip({ + let handle = + commit_tooltip_focus_handle.clone(); + move |window, cx| { + if can_commit { + Tooltip::for_action_in( + tooltip, &Amend, &handle, + window, cx, + ) + } else { + Tooltip::simple(tooltip, cx) + } + } + }) + .disabled(!can_commit || self.modal_open) + .on_click(move |_, window, cx| { + window.dispatch_action( + Box::new(git::Amend), + cx, + ); + }), + ) + } + }) + .when(!self.amend_pending, |this| { + this.when(has_previous_commit, |this| { + this.child(SplitButton::new( + ui::ButtonLike::new_rounded_left(ElementId::Name( + format!("split-button-left-{}", title).into(), + )) + .layer(ui::ElevationIndex::ModalSurface) + .size(ui::ButtonSize::Compact) + .child( + div() + .child( + Label::new(title) + .size(LabelSize::Small), + ) + .mr_0p5(), + ) + .on_click(move |_, window, cx| { + window + .dispatch_action(Box::new(git::Commit), cx); + }) + .disabled(!can_commit || self.modal_open) + .tooltip({ + let handle = + commit_tooltip_focus_handle.clone(); + move |window, cx| { + if can_commit { + Tooltip::with_meta_in( + tooltip, + Some(&git::Commit), + "git commit", + &handle.clone(), + window, + cx, + ) + } else { + Tooltip::simple(tooltip, cx) + } + } + }), + self.render_git_commit_menu( + ElementId::Name( + format!("split-button-right-{}", title) + .into(), + ), + Some(commit_tooltip_focus_handle.clone()), + ) + .into_any_element(), + )) }) - .disabled(!can_commit || self.modal_open) - .on_click({ - cx.listener(move |this, _: &ClickEvent, window, cx| { - telemetry::event!( - "Git Committed", - source = "Git Panel" - ); - this.commit_changes(window, cx) - }) - }), - ), + .when( + !has_previous_commit, + |this| { + this.child( + panel_filled_button(title) + .tooltip(move |window, cx| { + if can_commit { + Tooltip::with_meta_in( + tooltip, + Some(&git::Commit), + "git commit", + &commit_tooltip_focus_handle, + window, + cx, + ) + } else { + Tooltip::simple(tooltip, cx) + } + }) + .disabled(!can_commit || self.modal_open) + .on_click(move |_, window, cx| { + window.dispatch_action( + Box::new(git::Commit), + cx, + ); + }), + ) + }, + ) + }), ), ) .child( @@ -2994,6 +3209,17 @@ impl GitPanel { Some(footer) } + fn render_pending_amend(&self, cx: &mut Context) -> impl IntoElement { + div() + .py_2() + .px(px(8.)) + .border_color(cx.theme().colors().border) + .child( + Label::new("Your changes will modify your most recent commit. If you want to make these changes as a new commit, you can cancel the amend operation.") + .size(LabelSize::Small), + ) + } + fn render_previous_commit(&self, cx: &mut Context) -> Option { let active_repository = self.active_repository.as_ref()?; let branch = active_repository.read(cx).branch.as_ref()?; @@ -3448,7 +3674,7 @@ impl GitPanel { .into_any_element() } - fn load_commit_details( + pub fn load_commit_details( &self, sha: String, cx: &mut Context, @@ -3766,6 +3992,14 @@ impl GitPanel { fn has_write_access(&self, cx: &App) -> bool { !self.project.read(cx).is_read_only(cx) } + + pub fn amend_pending(&self) -> bool { + self.amend_pending + } + + pub fn set_amend_pending(&mut self, value: bool) { + self.amend_pending = value; + } } fn current_language_model(cx: &Context<'_, GitPanel>) -> Option> { @@ -3806,6 +4040,8 @@ impl Render for GitPanel { .when(has_write_access && !project.is_read_only(cx), |this| { this.on_action(cx.listener(Self::toggle_staged_for_selected)) .on_action(cx.listener(GitPanel::commit)) + .on_action(cx.listener(GitPanel::amend)) + .on_action(cx.listener(GitPanel::cancel)) .on_action(cx.listener(Self::stage_all)) .on_action(cx.listener(Self::unstage_all)) .on_action(cx.listener(Self::stage_selected)) @@ -3852,7 +4088,12 @@ impl Render for GitPanel { } }) .children(self.render_footer(window, cx)) - .children(self.render_previous_commit(cx)) + .when(self.amend_pending, |this| { + this.child(self.render_pending_amend(cx)) + }) + .when(!self.amend_pending, |this| { + this.children(self.render_previous_commit(cx)) + }) .into_any_element(), ) .children(self.context_menu.as_ref().map(|(menu, position, _)| { diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index 5edceb90fe..ac0a1ef859 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/crates/git_ui/src/git_ui.rs @@ -368,6 +368,7 @@ mod remote_button { }) .anchor(Corner::TopRight) } + #[allow(clippy::too_many_arguments)] fn split_button( id: SharedString, diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index b917295ec1..ddc610f755 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -21,7 +21,7 @@ use git::{ blame::Blame, parse_git_remote_url, repository::{ - Branch, CommitDetails, CommitDiff, CommitFile, DiffType, GitRepository, + Branch, CommitDetails, CommitDiff, CommitFile, CommitOptions, DiffType, GitRepository, GitRepositoryCheckpoint, PushOptions, Remote, RemoteCommandOutput, RepoPath, ResetMode, UpstreamTrackingStatus, }, @@ -1656,10 +1656,18 @@ impl GitStore { let message = SharedString::from(envelope.payload.message); let name = envelope.payload.name.map(SharedString::from); let email = envelope.payload.email.map(SharedString::from); + let options = envelope.payload.options.unwrap_or_default(); repository_handle .update(&mut cx, |repository_handle, cx| { - repository_handle.commit(message, name.zip(email), cx) + repository_handle.commit( + message, + name.zip(email), + CommitOptions { + amend: options.amend, + }, + cx, + ) })? .await??; Ok(proto::Ack {}) @@ -3248,6 +3256,7 @@ impl Repository { &mut self, message: SharedString, name_and_email: Option<(SharedString, SharedString)>, + options: CommitOptions, _cx: &mut App, ) -> oneshot::Receiver> { let id = self.id; @@ -3258,7 +3267,11 @@ impl Repository { backend, environment, .. - } => backend.commit(message, name_and_email, environment).await, + } => { + backend + .commit(message, name_and_email, options, environment) + .await + } RepositoryState::Remote { project_id, client } => { let (name, email) = name_and_email.unzip(); client @@ -3268,6 +3281,9 @@ impl Repository { message: String::from(message), name: name.map(String::from), email: email.map(String::from), + options: Some(proto::commit::CommitOptions { + amend: options.amend, + }), }) .await .context("sending commit request")?; diff --git a/crates/proto/proto/git.proto b/crates/proto/proto/git.proto index 7774a5293b..0d94bcb469 100644 --- a/crates/proto/proto/git.proto +++ b/crates/proto/proto/git.proto @@ -292,6 +292,11 @@ message Commit { optional string name = 4; optional string email = 5; string message = 6; + optional CommitOptions options = 7; + + message CommitOptions { + bool amend = 1; + } } message OpenCommitMessageBuffer { diff --git a/crates/ui/src/components/button/split_button.rs b/crates/ui/src/components/button/split_button.rs index 6ceeb88377..3d50340755 100644 --- a/crates/ui/src/components/button/split_button.rs +++ b/crates/ui/src/components/button/split_button.rs @@ -20,6 +20,12 @@ pub struct SplitButton { pub right: AnyElement, } +impl SplitButton { + pub fn new(left: ButtonLike, right: AnyElement) -> Self { + Self { left, right } + } +} + impl RenderOnce for SplitButton { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { h_flex()