New commit review flow in project diff view (#25229)

Closes #ISSUE

Release Notes:

- N/A

---------

Co-authored-by: Nate Butler <iamnbutler@gmail.com>
This commit is contained in:
Conrad Irwin 2025-02-20 23:52:34 -07:00 committed by GitHub
parent 6b9397c380
commit 4871d3c9e7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 982 additions and 480 deletions

View file

@ -1,25 +1,32 @@
use std::any::{Any, TypeId};
use ::git::UnstageAndNext;
use anyhow::Result;
use buffer_diff::BufferDiff;
use buffer_diff::{BufferDiff, DiffHunkSecondaryStatus};
use collections::HashSet;
use editor::{scroll::Autoscroll, Editor, EditorEvent, ToPoint};
use editor::{
actions::{GoToHunk, GoToPrevHunk},
scroll::Autoscroll,
Editor, EditorEvent, ToPoint,
};
use feature_flags::FeatureFlagViewExt;
use futures::StreamExt;
use git::{Commit, StageAll, StageAndNext, ToggleStaged, UnstageAll};
use gpui::{
actions, AnyElement, AnyView, App, AppContext as _, AsyncWindowContext, Entity, EventEmitter,
FocusHandle, Focusable, Render, Subscription, Task, WeakEntity,
actions, Action, AnyElement, AnyView, App, AppContext as _, AsyncWindowContext, Entity,
EventEmitter, FocusHandle, Focusable, Render, Subscription, Task, WeakEntity,
};
use language::{Anchor, Buffer, Capability, OffsetRangeExt, Point};
use multi_buffer::{MultiBuffer, PathKey};
use project::{git::GitStore, Project, ProjectPath};
use theme::ActiveTheme;
use ui::prelude::*;
use ui::{prelude::*, vertical_divider, Tooltip};
use util::ResultExt as _;
use workspace::{
item::{BreadcrumbText, Item, ItemEvent, ItemHandle, TabContentParams},
searchable::SearchableItemHandle,
ItemNavHistory, SerializableItem, ToolbarItemLocation, Workspace,
ItemNavHistory, SerializableItem, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView,
Workspace,
};
use crate::git_panel::{GitPanel, GitPanelAddon, GitStatusEntry};
@ -197,6 +204,69 @@ impl ProjectDiff {
}
}
fn button_states(&self, cx: &App) -> ButtonStates {
let editor = self.editor.read(cx);
let snapshot = self.multibuffer.read(cx).snapshot(cx);
let prev_next = snapshot.diff_hunks().skip(1).next().is_some();
let mut selection = true;
let mut ranges = editor
.selections
.disjoint_anchor_ranges()
.collect::<Vec<_>>();
if !ranges.iter().any(|range| range.start != range.end) {
selection = false;
if let Some((excerpt_id, buffer, range)) = self.editor.read(cx).active_excerpt(cx) {
ranges = vec![multi_buffer::Anchor::range_in_buffer(
excerpt_id,
buffer.read(cx).remote_id(),
range,
)];
} else {
ranges = Vec::default();
}
}
let mut has_staged_hunks = false;
let mut has_unstaged_hunks = false;
for hunk in editor.diff_hunks_in_ranges(&ranges, &snapshot) {
match hunk.secondary_status {
DiffHunkSecondaryStatus::HasSecondaryHunk => {
has_unstaged_hunks = true;
}
DiffHunkSecondaryStatus::OverlapsWithSecondaryHunk => {
has_staged_hunks = true;
has_unstaged_hunks = true;
}
DiffHunkSecondaryStatus::None => {
has_staged_hunks = true;
}
}
}
let mut commit = false;
let mut stage_all = false;
let mut unstage_all = false;
self.workspace
.read_with(cx, |workspace, cx| {
if let Some(git_panel) = workspace.panel::<GitPanel>(cx) {
let git_panel = git_panel.read(cx);
commit = git_panel.can_commit();
stage_all = git_panel.can_stage_all();
unstage_all = git_panel.can_unstage_all();
}
})
.ok();
return ButtonStates {
stage: has_unstaged_hunks,
unstage: has_staged_hunks,
prev_next,
selection,
commit,
stage_all,
unstage_all,
};
}
fn handle_editor_event(
&mut self,
editor: &Entity<Editor>,
@ -598,3 +668,226 @@ impl SerializableItem for ProjectDiff {
false
}
}
pub struct ProjectDiffToolbar {
project_diff: Option<WeakEntity<ProjectDiff>>,
workspace: WeakEntity<Workspace>,
}
impl ProjectDiffToolbar {
pub fn new(workspace: &Workspace, _: &mut Context<Self>) -> Self {
Self {
project_diff: None,
workspace: workspace.weak_handle(),
}
}
fn project_diff(&self, _: &App) -> Option<Entity<ProjectDiff>> {
self.project_diff.as_ref()?.upgrade()
}
fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut Context<Self>) {
if let Some(project_diff) = self.project_diff(cx) {
project_diff.focus_handle(cx).focus(window);
}
let action = action.boxed_clone();
cx.defer(move |cx| {
cx.dispatch_action(action.as_ref());
})
}
fn dispatch_panel_action(
&self,
action: &dyn Action,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.workspace
.read_with(cx, |workspace, cx| {
if let Some(panel) = workspace.panel::<GitPanel>(cx) {
panel.focus_handle(cx).focus(window)
}
})
.ok();
let action = action.boxed_clone();
cx.defer(move |cx| {
cx.dispatch_action(action.as_ref());
})
}
}
impl EventEmitter<ToolbarItemEvent> for ProjectDiffToolbar {}
impl ToolbarItemView for ProjectDiffToolbar {
fn set_active_pane_item(
&mut self,
active_pane_item: Option<&dyn ItemHandle>,
_: &mut Window,
cx: &mut Context<Self>,
) -> ToolbarItemLocation {
self.project_diff = active_pane_item
.and_then(|item| item.act_as::<ProjectDiff>(cx))
.map(|entity| entity.downgrade());
if self.project_diff.is_some() {
ToolbarItemLocation::PrimaryRight
} else {
ToolbarItemLocation::Hidden
}
}
fn pane_focus_update(
&mut self,
_pane_focused: bool,
_window: &mut Window,
_cx: &mut Context<Self>,
) {
}
}
struct ButtonStates {
stage: bool,
unstage: bool,
prev_next: bool,
selection: bool,
stage_all: bool,
unstage_all: bool,
commit: bool,
}
impl Render for ProjectDiffToolbar {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let Some(project_diff) = self.project_diff(cx) else {
return div();
};
let focus_handle = project_diff.focus_handle(cx);
let button_states = project_diff.read(cx).button_states(cx);
h_group_xl()
.my_neg_1()
.items_center()
.py_1()
.pl_2()
.pr_1()
.flex_wrap()
.justify_between()
.child(
h_group_sm()
.when(button_states.selection, |el| {
el.child(
Button::new("stage", "Toggle Staged")
.tooltip(Tooltip::for_action_title_in(
"Toggle Staged",
&ToggleStaged,
&focus_handle,
))
.disabled(!button_states.stage && !button_states.unstage)
.on_click(cx.listener(|this, _, window, cx| {
this.dispatch_action(&ToggleStaged, window, cx)
})),
)
})
.when(!button_states.selection, |el| {
el.child(
Button::new("stage", "Stage")
.tooltip(Tooltip::for_action_title_in(
"Stage",
&StageAndNext,
&focus_handle,
))
// don't actually disable the button so it's mashable
.color(if button_states.stage {
Color::Default
} else {
Color::Disabled
})
.on_click(cx.listener(|this, _, window, cx| {
this.dispatch_action(&StageAndNext, window, cx)
})),
)
.child(
Button::new("unstage", "Unstage")
.tooltip(Tooltip::for_action_title_in(
"Unstage",
&UnstageAndNext,
&focus_handle,
))
.color(if button_states.unstage {
Color::Default
} else {
Color::Disabled
})
.on_click(cx.listener(|this, _, window, cx| {
this.dispatch_action(&UnstageAndNext, window, cx)
})),
)
}),
)
// n.b. the only reason these arrows are here is because we don't
// support "undo" for staging so we need a way to go back.
.child(
h_group_sm()
.child(
IconButton::new("up", IconName::ArrowUp)
.shape(ui::IconButtonShape::Square)
.tooltip(Tooltip::for_action_title_in(
"Go to previous hunk",
&GoToPrevHunk,
&focus_handle,
))
.disabled(!button_states.prev_next)
.on_click(cx.listener(|this, _, window, cx| {
this.dispatch_action(&GoToPrevHunk, window, cx)
})),
)
.child(
IconButton::new("down", IconName::ArrowDown)
.shape(ui::IconButtonShape::Square)
.tooltip(Tooltip::for_action_title_in(
"Go to next hunk",
&GoToHunk,
&focus_handle,
))
.disabled(!button_states.prev_next)
.on_click(cx.listener(|this, _, window, cx| {
this.dispatch_action(&GoToHunk, window, cx)
})),
),
)
.child(vertical_divider())
.child(
h_group_sm()
.when(
button_states.unstage_all && !button_states.stage_all,
|el| {
el.child(Button::new("unstage-all", "Unstage All").on_click(
cx.listener(|this, _, window, cx| {
this.dispatch_panel_action(&UnstageAll, window, cx)
}),
))
},
)
.when(
!button_states.unstage_all || button_states.stage_all,
|el| {
el.child(
// todo make it so that changing to say "Unstaged"
// doesn't change the position.
div().child(
Button::new("stage-all", "Stage All")
.disabled(!button_states.stage_all)
.on_click(cx.listener(|this, _, window, cx| {
this.dispatch_panel_action(&StageAll, window, cx)
})),
),
)
},
)
.child(
Button::new("commit", "Commit")
.disabled(!button_states.commit)
.on_click(cx.listener(|this, _, window, cx| {
// todo this should open modal, not focus panel.
this.dispatch_action(&Commit, window, cx);
})),
),
)
}
}