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

Conrad Irwin and Nate Butler created

Closes #ISSUE

Release Notes:

- N/A

---------

Co-authored-by: Nate Butler <iamnbutler@gmail.com>

Change summary

Cargo.lock                                |  14 
assets/keymaps/default-macos.json         |   8 
crates/editor/src/editor.rs               |  47 +++
crates/editor/src/element.rs              | 204 +++++++++++-----
crates/git_ui/src/commit_modal.rs         | 244 +++++++++++++++++++
crates/git_ui/src/git_panel.rs            | 208 ++++++++++------
crates/git_ui/src/git_ui.rs               |   4 
crates/git_ui/src/project_diff.rs         | 305 ++++++++++++++++++++++++
crates/git_ui/src/quick_commit.rs         | 307 -------------------------
crates/ui/src/components.rs               |   2 
crates/ui/src/components/content_group.rs |   4 
crates/ui/src/components/divider.rs       |  18 +
crates/ui/src/components/group.rs         |  57 ++++
crates/ui/src/components/tooltip.rs       |  18 +
crates/ui/src/prelude.rs                  |   5 
crates/welcome/src/welcome.rs             |   2 
crates/workspace/src/dock.rs              |   6 
crates/zed/src/zed.rs                     |   3 
18 files changed, 979 insertions(+), 477 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -1759,7 +1759,7 @@ dependencies = [
  "bitflags 2.8.0",
  "cexpr",
  "clang-sys",
- "itertools 0.10.5",
+ "itertools 0.12.1",
  "lazy_static",
  "lazycell",
  "log",
@@ -1782,7 +1782,7 @@ dependencies = [
  "bitflags 2.8.0",
  "cexpr",
  "clang-sys",
- "itertools 0.10.5",
+ "itertools 0.12.1",
  "log",
  "prettyplease",
  "proc-macro2",
@@ -7206,7 +7206,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34"
 dependencies = [
  "cfg-if",
- "windows-targets 0.48.5",
+ "windows-targets 0.52.6",
 ]
 
 [[package]]
@@ -10316,8 +10316,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "22505a5c94da8e3b7c2996394d1c933236c4d743e81a410bcca4e6989fc066a4"
 dependencies = [
  "bytes 1.10.0",
- "heck 0.4.1",
- "itertools 0.10.5",
+ "heck 0.5.0",
+ "itertools 0.12.1",
  "log",
  "multimap 0.10.0",
  "once_cell",
@@ -10350,7 +10350,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "81bddcdb20abf9501610992b6759a4c888aef7d1a7247ef75e2404275ac24af1"
 dependencies = [
  "anyhow",
- "itertools 0.10.5",
+ "itertools 0.12.1",
  "proc-macro2",
  "quote",
  "syn 2.0.90",
@@ -15687,7 +15687,7 @@ version = "0.1.9"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
 dependencies = [
- "windows-sys 0.48.0",
+ "windows-sys 0.59.0",
 ]
 
 [[package]]

assets/keymaps/default-macos.json 🔗

@@ -742,6 +742,14 @@
       "escape": "git_panel::ToggleFocus"
     }
   },
+  {
+    "context": "GitCommit > Editor",
+    "use_key_equivalents": true,
+    "bindings": {
+      "enter": "editor::Newline",
+      "cmd-enter": "git::Commit"
+    }
+  },
   {
     "context": "GitPanel > Editor",
     "use_key_equivalents": true,

crates/editor/src/editor.rs 🔗

@@ -12769,7 +12769,7 @@ impl Editor {
         self.toggle_diff_hunks_in_ranges(ranges, cx);
     }
 
-    fn diff_hunks_in_ranges<'a>(
+    pub fn diff_hunks_in_ranges<'a>(
         &'a self,
         ranges: &'a [Range<Anchor>],
         buffer: &'a MultiBufferSnapshot,
@@ -12814,9 +12814,7 @@ impl Editor {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        let head = self.selections.newest_anchor().head();
-        self.stage_or_unstage_diff_hunks(true, &[head..head], cx);
-        self.go_to_next_hunk(&Default::default(), window, cx);
+        self.do_stage_or_unstage_and_next(true, window, cx);
     }
 
     pub fn unstage_and_next(
@@ -12825,9 +12823,7 @@ impl Editor {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        let head = self.selections.newest_anchor().head();
-        self.stage_or_unstage_diff_hunks(false, &[head..head], cx);
-        self.go_to_next_hunk(&Default::default(), window, cx);
+        self.do_stage_or_unstage_and_next(false, window, cx);
     }
 
     pub fn stage_or_unstage_diff_hunks(
@@ -12849,6 +12845,43 @@ impl Editor {
         }
     }
 
+    fn do_stage_or_unstage_and_next(
+        &mut self,
+        stage: bool,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let mut ranges = self.selections.disjoint_anchor_ranges().collect::<Vec<_>>();
+        if ranges.iter().any(|range| range.start != range.end) {
+            self.stage_or_unstage_diff_hunks(stage, &ranges[..], cx);
+            return;
+        }
+
+        if !self.buffer().read(cx).is_singleton() {
+            if let Some((excerpt_id, buffer, range)) = self.active_excerpt(cx) {
+                ranges = vec![multi_buffer::Anchor::range_in_buffer(
+                    excerpt_id,
+                    buffer.read(cx).remote_id(),
+                    range,
+                )];
+                self.stage_or_unstage_diff_hunks(stage, &ranges[..], cx);
+                let snapshot = self.buffer().read(cx).snapshot(cx);
+                let mut point = ranges.last().unwrap().end.to_point(&snapshot);
+                if point.row < snapshot.max_row().0 {
+                    point.row += 1;
+                    point.column = 0;
+                    point = snapshot.clip_point(point, Bias::Right);
+                    self.change_selections(Some(Autoscroll::top_relative(6)), window, cx, |s| {
+                        s.select_ranges([point..point]);
+                    })
+                }
+                return;
+            }
+        }
+        self.stage_or_unstage_diff_hunks(stage, &ranges[..], cx);
+        self.go_to_next_hunk(&Default::default(), window, cx);
+    }
+
     fn do_stage_or_unstage(
         project: &Entity<Project>,
         stage: bool,

crates/editor/src/element.rs 🔗

@@ -4343,21 +4343,26 @@ impl EditorElement {
                     let y = display_row_range.start.as_f32() * line_height
                         + text_hitbox.bounds.top()
                         - scroll_pixel_position.y;
-                    let x = text_hitbox.bounds.right() - px(100.);
 
                     let mut element = diff_hunk_controls(
                         display_row_range.start.0,
+                        status,
                         multi_buffer_range.clone(),
                         line_height,
                         &editor,
                         cx,
                     );
-                    element.prepaint_as_root(
-                        gpui::Point::new(x, y),
-                        size(px(100.0), line_height).into(),
-                        window,
-                        cx,
-                    );
+                    let size =
+                        element.layout_as_root(size(px(100.0), line_height).into(), window, cx);
+
+                    let x = text_hitbox.bounds.right()
+                        - self.style.scrollbar_width
+                        - px(10.)
+                        - size.width;
+
+                    window.with_absolute_element_offset(gpui::Point::new(x, y), |window| {
+                        element.prepaint(window, cx)
+                    });
                     controls.push(element);
                 }
             }
@@ -7750,7 +7755,7 @@ impl Element for EditorElement {
                         editor.last_position_map = Some(position_map.clone())
                     });
 
-                    let hunk_controls = self.layout_diff_hunk_controls(
+                    let diff_hunk_controls = self.layout_diff_hunk_controls(
                         start_row..end_row,
                         &row_infos,
                         &text_hitbox,
@@ -7790,7 +7795,7 @@ impl Element for EditorElement {
                         visible_cursors,
                         selections,
                         inline_completion_popover,
-                        diff_hunk_controls: hunk_controls,
+                        diff_hunk_controls,
                         mouse_context_menu,
                         test_indicators,
                         code_actions_indicator,
@@ -9117,6 +9122,7 @@ mod tests {
 
 fn diff_hunk_controls(
     row: u32,
+    status: &DiffHunkStatus,
     hunk_range: Range<Anchor>,
     line_height: Pixels,
     editor: &Entity<Editor>,
@@ -9133,62 +9139,66 @@ fn diff_hunk_controls(
         .rounded_b_lg()
         .bg(cx.theme().colors().editor_background)
         .gap_1()
+        .when(status.secondary == DiffHunkSecondaryStatus::None, |el| {
+            el.child(
+                Button::new("unstage", "Unstage")
+                    .tooltip({
+                        let focus_handle = editor.focus_handle(cx);
+                        move |window, cx| {
+                            Tooltip::for_action_in(
+                                "Unstage Hunk",
+                                &::git::ToggleStaged,
+                                &focus_handle,
+                                window,
+                                cx,
+                            )
+                        }
+                    })
+                    .on_click({
+                        let editor = editor.clone();
+                        move |_event, _, cx| {
+                            editor.update(cx, |editor, cx| {
+                                editor.stage_or_unstage_diff_hunks(
+                                    false,
+                                    &[hunk_range.start..hunk_range.start],
+                                    cx,
+                                );
+                            });
+                        }
+                    }),
+            )
+        })
+        .when(status.secondary != DiffHunkSecondaryStatus::None, |el| {
+            el.child(
+                Button::new("stage", "Stage")
+                    .tooltip({
+                        let focus_handle = editor.focus_handle(cx);
+                        move |window, cx| {
+                            Tooltip::for_action_in(
+                                "Stage Hunk",
+                                &::git::ToggleStaged,
+                                &focus_handle,
+                                window,
+                                cx,
+                            )
+                        }
+                    })
+                    .on_click({
+                        let editor = editor.clone();
+                        move |_event, _, cx| {
+                            editor.update(cx, |editor, cx| {
+                                editor.stage_or_unstage_diff_hunks(
+                                    true,
+                                    &[hunk_range.start..hunk_range.start],
+                                    cx,
+                                );
+                            });
+                        }
+                    }),
+            )
+        })
         .child(
-            IconButton::new(("next-hunk", row as u64), IconName::ArrowDown)
-                .shape(IconButtonShape::Square)
-                .icon_size(IconSize::Small)
-                // .disabled(!has_multiple_hunks)
-                .tooltip({
-                    let focus_handle = editor.focus_handle(cx);
-                    move |window, cx| {
-                        Tooltip::for_action_in("Next Hunk", &GoToHunk, &focus_handle, window, cx)
-                    }
-                })
-                .on_click({
-                    let editor = editor.clone();
-                    move |_event, window, cx| {
-                        editor.update(cx, |editor, cx| {
-                            let snapshot = editor.snapshot(window, cx);
-                            let position = hunk_range.end.to_point(&snapshot.buffer_snapshot);
-                            editor.go_to_hunk_after_position(&snapshot, position, window, cx);
-                            editor.expand_selected_diff_hunks(cx);
-                        });
-                    }
-                }),
-        )
-        .child(
-            IconButton::new(("prev-hunk", row as u64), IconName::ArrowUp)
-                .shape(IconButtonShape::Square)
-                .icon_size(IconSize::Small)
-                // .disabled(!has_multiple_hunks)
-                .tooltip({
-                    let focus_handle = editor.focus_handle(cx);
-                    move |window, cx| {
-                        Tooltip::for_action_in(
-                            "Previous Hunk",
-                            &GoToPrevHunk,
-                            &focus_handle,
-                            window,
-                            cx,
-                        )
-                    }
-                })
-                .on_click({
-                    let editor = editor.clone();
-                    move |_event, window, cx| {
-                        editor.update(cx, |editor, cx| {
-                            let snapshot = editor.snapshot(window, cx);
-                            let point = hunk_range.start.to_point(&snapshot.buffer_snapshot);
-                            editor.go_to_hunk_before_position(&snapshot, point, window, cx);
-                            editor.expand_selected_diff_hunks(cx);
-                        });
-                    }
-                }),
-        )
-        .child(
-            IconButton::new("discard", IconName::Undo)
-                .shape(IconButtonShape::Square)
-                .icon_size(IconSize::Small)
+            Button::new("discard", "Restore")
                 .tooltip({
                     let focus_handle = editor.focus_handle(cx);
                     move |window, cx| {
@@ -9212,5 +9222,71 @@ fn diff_hunk_controls(
                     }
                 }),
         )
+        .when(
+            !editor.read(cx).buffer().read(cx).all_diff_hunks_expanded(),
+            |el| {
+                el.child(
+                    IconButton::new(("next-hunk", row as u64), IconName::ArrowDown)
+                        .shape(IconButtonShape::Square)
+                        .icon_size(IconSize::Small)
+                        // .disabled(!has_multiple_hunks)
+                        .tooltip({
+                            let focus_handle = editor.focus_handle(cx);
+                            move |window, cx| {
+                                Tooltip::for_action_in(
+                                    "Next Hunk",
+                                    &GoToHunk,
+                                    &focus_handle,
+                                    window,
+                                    cx,
+                                )
+                            }
+                        })
+                        .on_click({
+                            let editor = editor.clone();
+                            move |_event, window, cx| {
+                                editor.update(cx, |editor, cx| {
+                                    let snapshot = editor.snapshot(window, cx);
+                                    let position =
+                                        hunk_range.end.to_point(&snapshot.buffer_snapshot);
+                                    editor
+                                        .go_to_hunk_after_position(&snapshot, position, window, cx);
+                                    editor.expand_selected_diff_hunks(cx);
+                                });
+                            }
+                        }),
+                )
+                .child(
+                    IconButton::new(("prev-hunk", row as u64), IconName::ArrowUp)
+                        .shape(IconButtonShape::Square)
+                        .icon_size(IconSize::Small)
+                        // .disabled(!has_multiple_hunks)
+                        .tooltip({
+                            let focus_handle = editor.focus_handle(cx);
+                            move |window, cx| {
+                                Tooltip::for_action_in(
+                                    "Previous Hunk",
+                                    &GoToPrevHunk,
+                                    &focus_handle,
+                                    window,
+                                    cx,
+                                )
+                            }
+                        })
+                        .on_click({
+                            let editor = editor.clone();
+                            move |_event, window, cx| {
+                                editor.update(cx, |editor, cx| {
+                                    let snapshot = editor.snapshot(window, cx);
+                                    let point =
+                                        hunk_range.start.to_point(&snapshot.buffer_snapshot);
+                                    editor.go_to_hunk_before_position(&snapshot, point, window, cx);
+                                    editor.expand_selected_diff_hunks(cx);
+                                });
+                            }
+                        }),
+                )
+            },
+        )
         .into_any_element()
 }

crates/git_ui/src/commit_modal.rs 🔗

@@ -0,0 +1,244 @@
+#![allow(unused, dead_code)]
+
+use crate::git_panel::{commit_message_editor, GitPanel};
+use crate::repository_selector::RepositorySelector;
+use anyhow::Result;
+use git::Commit;
+use language::Buffer;
+use panel::{panel_editor_container, panel_editor_style, panel_filled_button, panel_icon_button};
+use settings::Settings;
+use theme::ThemeSettings;
+use ui::{prelude::*, Tooltip};
+
+use editor::{Editor, EditorElement, EditorMode, MultiBuffer};
+use gpui::*;
+use project::git::Repository;
+use project::{Fs, Project};
+use std::sync::Arc;
+use workspace::dock::{Dock, DockPosition, PanelHandle};
+use workspace::{ModalView, Workspace};
+
+pub fn init(cx: &mut App) {
+    cx.observe_new(|workspace: &mut Workspace, window, cx| {
+        let Some(window) = window else {
+            return;
+        };
+        CommitModal::register(workspace, window, cx)
+    })
+    .detach();
+}
+
+pub struct CommitModal {
+    git_panel: Entity<GitPanel>,
+    commit_editor: Entity<Editor>,
+    restore_dock: RestoreDock,
+}
+
+impl Focusable for CommitModal {
+    fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
+        self.commit_editor.focus_handle(cx)
+    }
+}
+
+impl EventEmitter<DismissEvent> for CommitModal {}
+impl ModalView for CommitModal {
+    fn on_before_dismiss(
+        &mut self,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> workspace::DismissDecision {
+        self.git_panel.update(cx, |git_panel, cx| {
+            git_panel.set_modal_open(false, cx);
+        });
+        self.restore_dock.dock.update(cx, |dock, cx| {
+            if let Some(active_index) = self.restore_dock.active_index {
+                dock.activate_panel(active_index, window, cx)
+            }
+            dock.set_open(self.restore_dock.is_open, window, cx)
+        });
+        workspace::DismissDecision::Dismiss(true)
+    }
+}
+
+struct RestoreDock {
+    dock: WeakEntity<Dock>,
+    is_open: bool,
+    active_index: Option<usize>,
+}
+
+impl CommitModal {
+    pub fn register(workspace: &mut Workspace, _: &mut Window, cx: &mut Context<Workspace>) {
+        workspace.register_action(|workspace, _: &Commit, window, cx| {
+            let Some(git_panel) = workspace.panel::<GitPanel>(cx) else {
+                return;
+            };
+
+            let (can_commit, conflict) = git_panel.update(cx, |git_panel, cx| {
+                let can_commit = git_panel.can_commit();
+                let conflict = git_panel.has_unstaged_conflicts();
+                (can_commit, conflict)
+            });
+            if !can_commit {
+                let message = if conflict {
+                    "There are still conflicts. You must stage these before committing."
+                } else {
+                    "No changes to commit."
+                };
+                let prompt = window.prompt(PromptLevel::Warning, message, None, &["Ok"], cx);
+                cx.spawn(|_, _| async move {
+                    prompt.await.ok();
+                })
+                .detach();
+            }
+
+            let dock = workspace.dock_at_position(git_panel.position(window, cx));
+            let is_open = dock.read(cx).is_open();
+            let active_index = dock.read(cx).active_panel_index();
+            let dock = dock.downgrade();
+            let restore_dock_position = RestoreDock {
+                dock,
+                is_open,
+                active_index,
+            };
+            workspace.open_panel::<GitPanel>(window, cx);
+            workspace.toggle_modal(window, cx, move |window, cx| {
+                CommitModal::new(git_panel, restore_dock_position, window, cx)
+            })
+        });
+    }
+
+    fn new(
+        git_panel: Entity<GitPanel>,
+        restore_dock: RestoreDock,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Self {
+        let panel = git_panel.read(cx);
+
+        let commit_editor = git_panel.update(cx, |git_panel, cx| {
+            git_panel.set_modal_open(true, cx);
+            let buffer = git_panel.commit_message_buffer(cx).clone();
+            let project = git_panel.project.clone();
+            cx.new(|cx| commit_message_editor(buffer, project.clone(), false, window, cx))
+        });
+
+        Self {
+            git_panel,
+            commit_editor,
+            restore_dock,
+        }
+    }
+
+    pub fn render_commit_editor(
+        &self,
+        name_and_email: Option<(SharedString, SharedString)>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> impl IntoElement {
+        let editor = self.commit_editor.clone();
+
+        let panel_editor_style = panel_editor_style(true, window, cx);
+
+        let settings = ThemeSettings::get_global(cx);
+        let line_height = relative(settings.buffer_line_height.value())
+            .to_pixels(settings.buffer_font_size.into(), window.rem_size());
+
+        v_flex()
+            .justify_between()
+            .relative()
+            .w_full()
+            .h_full()
+            .pt_2()
+            .bg(cx.theme().colors().editor_background)
+            .child(EditorElement::new(&self.commit_editor, panel_editor_style))
+            .child(self.render_footer(window, cx))
+    }
+
+    pub fn render_footer(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let (branch, tooltip, title, co_authors) = self.git_panel.update(cx, |git_panel, cx| {
+            let branch = git_panel
+                .active_repository
+                .as_ref()
+                .and_then(|repo| repo.read(cx).branch().map(|b| b.name.clone()))
+                .unwrap_or_else(|| "<no branch>".into());
+            let tooltip = if git_panel.has_staged_changes() {
+                "Commit staged changes"
+            } else {
+                "Commit changes to tracked files"
+            };
+            let title = if git_panel.has_staged_changes() {
+                "Commit"
+            } else {
+                "Commit All"
+            };
+            let co_authors = git_panel.render_co_authors(cx);
+            (branch, tooltip, title, co_authors)
+        });
+
+        let branch_selector = Button::new("branch-selector", branch)
+            .color(Color::Muted)
+            .style(ButtonStyle::Subtle)
+            .icon(IconName::GitBranch)
+            .icon_size(IconSize::Small)
+            .icon_color(Color::Muted)
+            .size(ButtonSize::Compact)
+            .icon_position(IconPosition::Start)
+            .tooltip(Tooltip::for_action_title(
+                "Switch Branch",
+                &zed_actions::git::Branch,
+            ))
+            .on_click(cx.listener(|_, _, window, cx| {
+                window.dispatch_action(zed_actions::git::Branch.boxed_clone(), cx);
+            }))
+            .style(ButtonStyle::Transparent);
+        h_flex()
+            .w_full()
+            .justify_between()
+            .child(branch_selector)
+            .child(
+                h_flex().children(co_authors).child(
+                    panel_filled_button(title)
+                        .tooltip(Tooltip::for_action_title(tooltip, &git::Commit))
+                        .on_click(cx.listener(|this, _, window, cx| {
+                            this.commit(&Default::default(), window, cx);
+                        })),
+                ),
+            )
+    }
+
+    fn dismiss(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
+        cx.emit(DismissEvent);
+    }
+    fn commit(&mut self, _: &git::Commit, window: &mut Window, cx: &mut Context<Self>) {
+        self.git_panel
+            .update(cx, |git_panel, cx| git_panel.commit_changes(window, cx));
+        cx.emit(DismissEvent);
+    }
+}
+
+impl Render for CommitModal {
+    fn render(&mut self, window: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement {
+        v_flex()
+            .id("commit-modal")
+            .key_context("GitCommit")
+            .elevation_3(cx)
+            .on_action(cx.listener(Self::dismiss))
+            .on_action(cx.listener(Self::commit))
+            .relative()
+            .bg(cx.theme().colors().editor_background)
+            .rounded(px(16.))
+            .border_1()
+            .border_color(cx.theme().colors().border)
+            .py_2()
+            .px_4()
+            .w(px(480.))
+            .min_h(rems(18.))
+            .flex_1()
+            .overflow_hidden()
+            .child(
+                v_flex()
+                    .flex_1()
+                    .child(self.render_commit_editor(None, window, cx)),
+            )
+    }
+}

crates/git_ui/src/git_panel.rs 🔗

@@ -1,4 +1,5 @@
 use crate::git_panel_settings::StatusStyle;
+use crate::project_diff::Diff;
 use crate::repository_selector::RepositorySelectorPopoverMenu;
 use crate::{
     git_panel_settings::GitPanelSettings, git_status_icon, repository_selector::RepositorySelector,
@@ -78,16 +79,16 @@ pub fn init(cx: &mut App) {
                 workspace.toggle_panel_focus::<GitPanel>(window, cx);
             });
 
-            workspace.register_action(|workspace, _: &Commit, window, cx| {
-                workspace.open_panel::<GitPanel>(window, cx);
-                if let Some(git_panel) = workspace.panel::<GitPanel>(cx) {
-                    git_panel
-                        .read(cx)
-                        .commit_editor
-                        .focus_handle(cx)
-                        .focus(window);
-                }
-            });
+            // workspace.register_action(|workspace, _: &Commit, window, cx| {
+            //     workspace.open_panel::<GitPanel>(window, cx);
+            //     if let Some(git_panel) = workspace.panel::<GitPanel>(cx) {
+            //         git_panel
+            //             .read(cx)
+            //             .commit_editor
+            //             .focus_handle(cx)
+            //             .focus(window);
+            //     }
+            // });
         },
     )
     .detach();
@@ -174,7 +175,7 @@ struct PendingOperation {
 }
 
 pub struct GitPanel {
-    active_repository: Option<Entity<Repository>>,
+    pub(crate) active_repository: Option<Entity<Repository>>,
     commit_editor: Entity<Editor>,
     conflicted_count: usize,
     conflicted_staged_count: usize,
@@ -190,7 +191,7 @@ pub struct GitPanel {
     pending: Vec<PendingOperation>,
     pending_commit: Option<Task<()>>,
     pending_serialization: Task<Option<()>>,
-    project: Entity<Project>,
+    pub(crate) project: Entity<Project>,
     repository_selector: Entity<RepositorySelector>,
     scroll_handle: UniformListScrollHandle,
     scrollbar_state: ScrollbarState,
@@ -202,17 +203,20 @@ pub struct GitPanel {
     width: Option<Pixels>,
     workspace: WeakEntity<Workspace>,
     context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
+    modal_open: bool,
 }
 
-fn commit_message_editor(
+pub(crate) fn commit_message_editor(
     commit_message_buffer: Entity<Buffer>,
     project: Entity<Project>,
+    in_panel: bool,
     window: &mut Window,
     cx: &mut Context<'_, Editor>,
 ) -> Editor {
     let buffer = cx.new(|cx| MultiBuffer::singleton(commit_message_buffer, cx));
+    let max_lines = if in_panel { 6 } else { 18 };
     let mut commit_editor = Editor::new(
-        EditorMode::AutoHeight { max_lines: 6 },
+        EditorMode::AutoHeight { max_lines },
         buffer,
         None,
         false,
@@ -251,8 +255,9 @@ impl GitPanel {
             // just to let us render a placeholder editor.
             // Once the active git repo is set, this buffer will be replaced.
             let temporary_buffer = cx.new(|cx| Buffer::local("", cx));
-            let commit_editor =
-                cx.new(|cx| commit_message_editor(temporary_buffer, project.clone(), window, cx));
+            let commit_editor = cx.new(|cx| {
+                commit_message_editor(temporary_buffer, project.clone(), true, window, cx)
+            });
             commit_editor.update(cx, |editor, cx| {
                 editor.clear(window, cx);
             });
@@ -309,6 +314,7 @@ impl GitPanel {
                 width: Some(px(360.)),
                 context_menu: None,
                 workspace,
+                modal_open: false,
             };
             git_panel.schedule_update(false, window, cx);
             git_panel.show_scrollbar = git_panel.should_show_scrollbar(cx);
@@ -351,6 +357,11 @@ impl GitPanel {
         );
     }
 
+    pub(crate) fn set_modal_open(&mut self, open: bool, cx: &mut Context<Self>) {
+        self.modal_open = open;
+        cx.notify();
+    }
+
     fn dispatch_context(&self, window: &mut Window, cx: &Context<Self>) -> KeyContext {
         let mut dispatch_context = KeyContext::new_with_defaults();
         dispatch_context.add("GitPanel");
@@ -592,7 +603,6 @@ impl GitPanel {
                 })
                 .ok()
         });
-        self.focus_handle.focus(window);
     }
 
     fn open_file(
@@ -998,6 +1008,16 @@ impl GitPanel {
         .detach();
     }
 
+    pub fn commit_message_buffer(&self, cx: &App) -> Entity<Buffer> {
+        self.commit_editor
+            .read(cx)
+            .buffer()
+            .read(cx)
+            .as_singleton()
+            .unwrap()
+            .clone()
+    }
+
     fn toggle_staged_for_selected(
         &mut self,
         _: &git::ToggleStaged,
@@ -1022,7 +1042,7 @@ impl GitPanel {
         self.commit_changes(window, cx)
     }
 
-    fn commit_changes(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+    pub(crate) fn commit_changes(&mut self, window: &mut Window, cx: &mut Context<Self>) {
         let Some(active_repository) = self.active_repository.clone() else {
             return;
         };
@@ -1288,7 +1308,7 @@ impl GitPanel {
                     != Some(&buffer)
                 {
                     git_panel.commit_editor = cx.new(|cx| {
-                        commit_message_editor(buffer, git_panel.project.clone(), window, cx)
+                        commit_message_editor(buffer, git_panel.project.clone(), true, window, cx)
                     });
                 }
             })
@@ -1476,12 +1496,18 @@ impl GitPanel {
         entry.is_staged
     }
 
-    fn has_staged_changes(&self) -> bool {
+    pub(crate) fn has_staged_changes(&self) -> bool {
         self.tracked_staged_count > 0
             || self.new_staged_count > 0
             || self.conflicted_staged_count > 0
     }
 
+    pub(crate) fn has_unstaged_changes(&self) -> bool {
+        self.tracked_count > self.tracked_staged_count
+            || self.new_count > self.new_staged_count
+            || self.conflicted_count > self.conflicted_staged_count
+    }
+
     fn has_conflicts(&self) -> bool {
         self.conflicted_count > 0
     }
@@ -1490,7 +1516,7 @@ impl GitPanel {
         self.tracked_count > 0
     }
 
-    fn has_unstaged_conflicts(&self) -> bool {
+    pub fn has_unstaged_conflicts(&self) -> bool {
         self.conflicted_count > 0 && self.conflicted_count != self.conflicted_staged_count
     }
 
@@ -1564,7 +1590,17 @@ impl GitPanel {
                             .size(LabelSize::Small)
                             .color(Color::Muted),
                     )
-                    .child(self.render_repository_selector(cx)),
+                    .child(self.render_repository_selector(cx))
+                    .child(div().flex_grow())
+                    .child(
+                        Button::new("diff", "+/-")
+                            .tooltip(Tooltip::for_action_title("Open diff", &Diff))
+                            .on_click(|_, _, cx| {
+                                cx.defer(|cx| {
+                                    cx.dispatch_action(&Diff);
+                                })
+                            }),
+                    ),
             )
         } else {
             None
@@ -1587,21 +1623,64 @@ impl GitPanel {
         )
     }
 
+    pub fn can_commit(&self) -> bool {
+        (self.has_staged_changes() || self.has_tracked_changes()) && !self.has_unstaged_conflicts()
+    }
+
+    pub fn can_stage_all(&self) -> bool {
+        self.has_unstaged_changes()
+    }
+
+    pub fn can_unstage_all(&self) -> bool {
+        self.has_staged_changes()
+    }
+
+    pub(crate) fn render_co_authors(&self, cx: &Context<Self>) -> Option<AnyElement> {
+        let potential_co_authors = self.potential_co_authors(cx);
+        if potential_co_authors.is_empty() {
+            None
+        } else {
+            Some(
+                IconButton::new("co-authors", IconName::Person)
+                    .icon_color(Color::Disabled)
+                    .selected_icon_color(Color::Selected)
+                    .toggle_state(self.add_coauthors)
+                    .tooltip(move |_, cx| {
+                        let title = format!(
+                            "Add co-authored-by:{}{}",
+                            if potential_co_authors.len() == 1 {
+                                ""
+                            } else {
+                                "\n"
+                            },
+                            potential_co_authors
+                                .iter()
+                                .map(|(name, email)| format!(" {} <{}>", name, email))
+                                .join("\n")
+                        );
+                        Tooltip::simple(title, cx)
+                    })
+                    .on_click(cx.listener(|this, _, _, cx| {
+                        this.add_coauthors = !this.add_coauthors;
+                        cx.notify();
+                    }))
+                    .into_any_element(),
+            )
+        }
+    }
+
     pub fn render_commit_editor(
         &self,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> impl IntoElement {
         let editor = self.commit_editor.clone();
-        let can_commit = (self.has_staged_changes() || self.has_tracked_changes())
+        let can_commit = self.can_commit()
             && self.pending_commit.is_none()
             && !editor.read(cx).is_empty(cx)
-            && !self.has_unstaged_conflicts()
             && self.has_write_access(cx);
-
-        // let can_commit_all =
-        //     !self.commit_pending && self.can_commit_all && !editor.read(cx).is_empty(cx);
         let panel_editor_style = panel_editor_style(true, window, cx);
+        let enable_coauthors = self.render_co_authors(cx);
 
         let editor_focus_handle = editor.read(cx).focus_handle(cx).clone();
 
@@ -1627,37 +1706,6 @@ impl GitPanel {
                 cx.listener(move |this, _: &ClickEvent, window, cx| this.commit_changes(window, cx))
             });
 
-        let potential_co_authors = self.potential_co_authors(cx);
-        let enable_coauthors = if potential_co_authors.is_empty() {
-            None
-        } else {
-            Some(
-                IconButton::new("co-authors", IconName::Person)
-                    .icon_color(Color::Disabled)
-                    .selected_icon_color(Color::Selected)
-                    .toggle_state(self.add_coauthors)
-                    .tooltip(move |_, cx| {
-                        let title = format!(
-                            "Add co-authored-by:{}{}",
-                            if potential_co_authors.len() == 1 {
-                                ""
-                            } else {
-                                "\n"
-                            },
-                            potential_co_authors
-                                .iter()
-                                .map(|(name, email)| format!(" {} <{}>", name, email))
-                                .join("\n")
-                        );
-                        Tooltip::simple(title, cx)
-                    })
-                    .on_click(cx.listener(|this, _, _, cx| {
-                        this.add_coauthors = !this.add_coauthors;
-                        cx.notify();
-                    })),
-            )
-        };
-
         let branch = self
             .active_repository
             .as_ref()
@@ -1698,26 +1746,28 @@ impl GitPanel {
             .on_click(cx.listener(move |_, _: &ClickEvent, window, _cx| {
                 window.focus(&editor_focus_handle);
             }))
-            .child(EditorElement::new(&self.commit_editor, panel_editor_style))
-            .child(
-                h_flex()
-                    .absolute()
-                    .bottom_0()
-                    .left_2()
-                    .h(footer_size)
-                    .flex_none()
-                    .child(branch_selector),
-            )
-            .child(
-                h_flex()
-                    .absolute()
-                    .bottom_0()
-                    .right_2()
-                    .h(footer_size)
-                    .flex_none()
-                    .children(enable_coauthors)
-                    .child(commit_button),
-            )
+            .when(!self.modal_open, |el| {
+                el.child(EditorElement::new(&self.commit_editor, panel_editor_style))
+                    .child(
+                        h_flex()
+                            .absolute()
+                            .bottom_0()
+                            .left_2()
+                            .h(footer_size)
+                            .flex_none()
+                            .child(branch_selector),
+                    )
+                    .child(
+                        h_flex()
+                            .absolute()
+                            .bottom_0()
+                            .right_2()
+                            .h(footer_size)
+                            .flex_none()
+                            .children(enable_coauthors)
+                            .child(commit_button),
+                    )
+            })
     }
 
     fn render_previous_commit(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
@@ -1892,8 +1942,8 @@ impl GitPanel {
         Some(
             h_flex()
                 .id("start-slot")
+                .text_lg()
                 .child(checkbox)
-                .child(git_status_icon(entry.status_entry()?.status, cx))
                 .on_mouse_down(MouseButton::Left, |_, _, cx| {
                     // prevent the list item active state triggering when toggling checkbox
                     cx.stop_propagation();

crates/git_ui/src/git_ui.rs 🔗

@@ -6,17 +6,17 @@ use project_diff::ProjectDiff;
 use ui::{ActiveTheme, Color, Icon, IconName, IntoElement};
 
 pub mod branch_picker;
+mod commit_modal;
 pub mod git_panel;
 mod git_panel_settings;
 pub mod project_diff;
-// mod quick_commit;
 pub mod repository_selector;
 
 pub fn init(cx: &mut App) {
     GitPanelSettings::register(cx);
     branch_picker::init(cx);
     cx.observe_new(ProjectDiff::register).detach();
-    // quick_commit::init(cx);
+    commit_modal::init(cx);
 }
 
 // TODO: Add updated status colors to theme

crates/git_ui/src/project_diff.rs 🔗

@@ -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);
+                            })),
+                    ),
+            )
+    }
+}

crates/git_ui/src/quick_commit.rs 🔗

@@ -1,307 +0,0 @@
-#![allow(unused, dead_code)]
-
-use crate::repository_selector::RepositorySelector;
-use anyhow::Result;
-use git::{CommitAllChanges, CommitChanges};
-use language::Buffer;
-use panel::{panel_editor_container, panel_editor_style, panel_filled_button, panel_icon_button};
-use ui::{prelude::*, Tooltip};
-
-use editor::{Editor, EditorElement, EditorMode, MultiBuffer};
-use gpui::*;
-use project::git::Repository;
-use project::{Fs, Project};
-use std::sync::Arc;
-use workspace::{ModalView, Workspace};
-
-actions!(
-    git,
-    [QuickCommitWithMessage, QuickCommitStaged, QuickCommitAll]
-);
-
-pub fn init(cx: &mut App) {
-    cx.observe_new(|workspace: &mut Workspace, window, cx| {
-        let Some(window) = window else {
-            return;
-        };
-        QuickCommitModal::register(workspace, window, cx)
-    })
-    .detach();
-}
-
-fn commit_message_editor(
-    commit_message_buffer: Option<Entity<Buffer>>,
-    window: &mut Window,
-    cx: &mut Context<'_, Editor>,
-) -> Editor {
-    let mut commit_editor = if let Some(commit_message_buffer) = commit_message_buffer {
-        let buffer = cx.new(|cx| MultiBuffer::singleton(commit_message_buffer, cx));
-        Editor::new(
-            EditorMode::AutoHeight { max_lines: 10 },
-            buffer,
-            None,
-            false,
-            window,
-            cx,
-        )
-    } else {
-        Editor::auto_height(10, window, cx)
-    };
-    commit_editor.set_use_autoclose(false);
-    commit_editor.set_show_gutter(false, cx);
-    commit_editor.set_show_wrap_guides(false, cx);
-    commit_editor.set_show_indent_guides(false, cx);
-    commit_editor.set_placeholder_text("Enter commit message", cx);
-    commit_editor
-}
-
-pub struct QuickCommitModal {
-    focus_handle: FocusHandle,
-    fs: Arc<dyn Fs>,
-    project: Entity<Project>,
-    active_repository: Option<Entity<Repository>>,
-    repository_selector: Entity<RepositorySelector>,
-    commit_editor: Entity<Editor>,
-    width: Option<Pixels>,
-    commit_task: Task<Result<()>>,
-    commit_pending: bool,
-    can_commit: bool,
-    can_commit_all: bool,
-    enable_auto_coauthors: bool,
-}
-
-impl Focusable for QuickCommitModal {
-    fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
-        self.focus_handle.clone()
-    }
-}
-
-impl EventEmitter<DismissEvent> for QuickCommitModal {}
-impl ModalView for QuickCommitModal {}
-
-impl QuickCommitModal {
-    pub fn register(workspace: &mut Workspace, _: &mut Window, cx: &mut Context<Workspace>) {
-        workspace.register_action(|workspace, _: &QuickCommitWithMessage, window, cx| {
-            let project = workspace.project().clone();
-            let fs = workspace.app_state().fs.clone();
-
-            workspace.toggle_modal(window, cx, move |window, cx| {
-                QuickCommitModal::new(project, fs, window, None, cx)
-            });
-        });
-    }
-
-    pub fn new(
-        project: Entity<Project>,
-        fs: Arc<dyn Fs>,
-        window: &mut Window,
-        commit_message_buffer: Option<Entity<Buffer>>,
-        cx: &mut Context<Self>,
-    ) -> Self {
-        let git_store = project.read(cx).git_store().clone();
-        let active_repository = project.read(cx).active_repository(cx);
-
-        let focus_handle = cx.focus_handle();
-
-        let commit_editor = cx.new(|cx| commit_message_editor(commit_message_buffer, window, cx));
-        commit_editor.update(cx, |editor, cx| {
-            editor.clear(window, cx);
-        });
-
-        let repository_selector = cx.new(|cx| RepositorySelector::new(project.clone(), window, cx));
-
-        Self {
-            focus_handle,
-            fs,
-            project,
-            active_repository,
-            repository_selector,
-            commit_editor,
-            width: None,
-            commit_task: Task::ready(Ok(())),
-            commit_pending: false,
-            can_commit: false,
-            can_commit_all: false,
-            enable_auto_coauthors: true,
-        }
-    }
-
-    pub fn render_header(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
-        let all_repositories = self
-            .project
-            .read(cx)
-            .git_store()
-            .read(cx)
-            .all_repositories();
-        let entry_count = self
-            .active_repository
-            .as_ref()
-            .map_or(0, |repo| repo.read(cx).entry_count());
-
-        let changes_string = match entry_count {
-            0 => "No changes".to_string(),
-            1 => "1 change".to_string(),
-            n => format!("{} changes", n),
-        };
-
-        div().absolute().top_0().right_0().child(
-            panel_icon_button("open_change_list", IconName::PanelRight)
-                .disabled(true)
-                .tooltip(Tooltip::text("Changes list coming soon!")),
-        )
-    }
-
-    pub fn render_commit_editor(
-        &self,
-        name_and_email: Option<(SharedString, SharedString)>,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) -> impl IntoElement {
-        let editor = self.commit_editor.clone();
-        let can_commit = !self.commit_pending && self.can_commit && !editor.read(cx).is_empty(cx);
-        let editor_focus_handle = editor.read(cx).focus_handle(cx).clone();
-
-        let focus_handle_1 = self.focus_handle(cx).clone();
-        let focus_handle_2 = self.focus_handle(cx).clone();
-
-        let panel_editor_style = panel_editor_style(true, window, cx);
-
-        let commit_staged_button = panel_filled_button("Commit")
-            .tooltip(move |window, cx| {
-                let focus_handle = focus_handle_1.clone();
-                Tooltip::for_action_in(
-                    "Commit all staged changes",
-                    &CommitChanges,
-                    &focus_handle,
-                    window,
-                    cx,
-                )
-            })
-            .when(!can_commit, |this| {
-                this.disabled(true).style(ButtonStyle::Transparent)
-            });
-        // .on_click({
-        //     let name_and_email = name_and_email.clone();
-        //     cx.listener(move |this, _: &ClickEvent, window, cx| {
-        //         this.commit_changes(&CommitChanges, name_and_email.clone(), window, cx)
-        //     })
-        // });
-
-        let commit_all_button = panel_filled_button("Commit All")
-            .tooltip(move |window, cx| {
-                let focus_handle = focus_handle_2.clone();
-                Tooltip::for_action_in(
-                    "Commit all changes, including unstaged changes",
-                    &CommitAllChanges,
-                    &focus_handle,
-                    window,
-                    cx,
-                )
-            })
-            .when(!can_commit, |this| {
-                this.disabled(true).style(ButtonStyle::Transparent)
-            });
-        // .on_click({
-        //     let name_and_email = name_and_email.clone();
-        //     cx.listener(move |this, _: &ClickEvent, window, cx| {
-        //         this.commit_tracked_changes(
-        //             &CommitAllChanges,
-        //             name_and_email.clone(),
-        //             window,
-        //             cx,
-        //         )
-        //     })
-        // });
-
-        let co_author_button = panel_icon_button("add-co-author", IconName::UserGroup)
-            .icon_color(if self.enable_auto_coauthors {
-                Color::Muted
-            } else {
-                Color::Accent
-            })
-            .icon_size(IconSize::Small)
-            .toggle_state(self.enable_auto_coauthors)
-            // .on_click({
-            //     cx.listener(move |this, _: &ClickEvent, _, cx| {
-            //         this.toggle_auto_coauthors(cx);
-            //     })
-            // })
-            .tooltip(move |window, cx| {
-                Tooltip::with_meta(
-                    "Toggle automatic co-authors",
-                    None,
-                    "Automatically adds current collaborators",
-                    window,
-                    cx,
-                )
-            });
-
-        panel_editor_container(window, cx)
-            .id("commit-editor-container")
-            .relative()
-            .w_full()
-            .border_t_1()
-            .border_color(cx.theme().colors().border)
-            .h(px(140.))
-            .bg(cx.theme().colors().editor_background)
-            .on_click(cx.listener(move |_, _: &ClickEvent, window, _cx| {
-                window.focus(&editor_focus_handle);
-            }))
-            .child(EditorElement::new(&self.commit_editor, panel_editor_style))
-            .child(div().flex_1())
-            .child(
-                h_flex()
-                    .items_center()
-                    .h_8()
-                    .justify_between()
-                    .gap_1()
-                    .child(co_author_button)
-                    .child(commit_all_button)
-                    .child(commit_staged_button),
-            )
-    }
-
-    pub fn render_footer(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
-        h_flex()
-            .w_full()
-            .justify_between()
-            .child(h_flex().child("cmd+esc clear message"))
-            .child(
-                h_flex()
-                    .child(panel_filled_button("Commit"))
-                    .child(panel_filled_button("Commit All")),
-            )
-    }
-
-    fn dismiss(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
-        cx.emit(DismissEvent);
-    }
-}
-
-impl Render for QuickCommitModal {
-    fn render(&mut self, window: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement {
-        v_flex()
-            .id("quick-commit-modal")
-            .key_context("QuickCommit")
-            .on_action(cx.listener(Self::dismiss))
-            .relative()
-            .bg(cx.theme().colors().elevated_surface_background)
-            .rounded(px(16.))
-            .border_1()
-            .border_color(cx.theme().colors().border)
-            .py_2()
-            .px_4()
-            .w(self.width.unwrap_or(px(640.)))
-            .h(px(450.))
-            .flex_1()
-            .overflow_hidden()
-            .child(self.render_header(window, cx))
-            .child(
-                v_flex()
-                    .flex_1()
-                    // TODO: pass name_and_email
-                    .child(self.render_commit_editor(None, window, cx)),
-            )
-            .child(self.render_footer(window, cx))
-    }
-}

crates/ui/src/components.rs 🔗

@@ -6,6 +6,7 @@ mod disclosure;
 mod divider;
 mod dropdown_menu;
 mod facepile;
+mod group;
 mod icon;
 mod image;
 mod indent_guides;
@@ -42,6 +43,7 @@ pub use disclosure::*;
 pub use divider::*;
 pub use dropdown_menu::*;
 pub use facepile::*;
+pub use group::*;
 pub use icon::*;
 pub use image::*;
 pub use indent_guides::*;

crates/ui/src/components/content_group.rs 🔗

@@ -11,14 +11,14 @@ pub fn content_group() -> ContentGroup {
 /// A [ContentGroup] that vertically stacks its children.
 ///
 /// This is a convenience function that simply combines [`ContentGroup`] and [`v_flex`](crate::v_flex).
-pub fn v_group() -> ContentGroup {
+pub fn v_container() -> ContentGroup {
     content_group().v_flex()
 }
 
 /// Creates a new horizontal [ContentGroup].
 ///
 /// This is a convenience function that simply combines [`ContentGroup`] and [`h_flex`](crate::h_flex).
-pub fn h_group() -> ContentGroup {
+pub fn h_container() -> ContentGroup {
     content_group().h_flex()
 }
 

crates/ui/src/components/divider.rs 🔗

@@ -3,6 +3,24 @@ use gpui::{Hsla, IntoElement};
 
 use crate::prelude::*;
 
+pub fn divider() -> Divider {
+    Divider {
+        style: DividerStyle::Solid,
+        direction: DividerDirection::Horizontal,
+        color: DividerColor::default(),
+        inset: false,
+    }
+}
+
+pub fn vertical_divider() -> Divider {
+    Divider {
+        style: DividerStyle::Solid,
+        direction: DividerDirection::Vertical,
+        color: DividerColor::default(),
+        inset: false,
+    }
+}
+
 #[derive(Clone, Copy, PartialEq)]
 enum DividerStyle {
     Solid,

crates/ui/src/components/group.rs 🔗

@@ -0,0 +1,57 @@
+use gpui::{div, prelude::*, Div};
+
+/// Creates a horizontal group with tight, consistent spacing.
+///
+/// xs: ~2px @16px/rem
+pub fn h_group_sm() -> Div {
+    div().flex().gap_0p5()
+}
+
+/// Creates a horizontal group with consistent spacing.
+///
+/// s: ~4px @16px/rem
+pub fn h_group() -> Div {
+    div().flex().gap_1()
+}
+
+/// Creates a horizontal group with consistent spacing.
+///
+/// m: ~6px @16px/rem
+pub fn h_group_lg() -> Div {
+    div().flex().gap_1p5()
+}
+
+/// Creates a horizontal group with consistent spacing.
+///
+/// l: ~8px @16px/rem
+pub fn h_group_xl() -> Div {
+    div().flex().gap_2()
+}
+
+/// Creates a vertical group with tight, consistent spacing.
+///
+/// xs: ~2px @16px/rem
+pub fn v_group_sm() -> Div {
+    div().flex().flex_col().gap_0p5()
+}
+
+/// Creates a vertical group with consistent spacing.
+///
+/// s: ~4px @16px/rem
+pub fn v_group() -> Div {
+    div().flex().flex_col().gap_1()
+}
+
+/// Creates a vertical group with consistent spacing.
+///
+/// m: ~6px @16px/rem
+pub fn v_group_lg() -> Div {
+    div().flex().flex_col().gap_1p5()
+}
+
+/// Creates a vertical group with consistent spacing.
+///
+/// l: ~8px @16px/rem
+pub fn v_group_xl() -> Div {
+    div().flex().flex_col().gap_2()
+}

crates/ui/src/components/tooltip.rs 🔗

@@ -52,6 +52,24 @@ impl Tooltip {
         }
     }
 
+    pub fn for_action_title_in(
+        title: impl Into<SharedString>,
+        action: &dyn Action,
+        focus_handle: &FocusHandle,
+    ) -> impl Fn(&mut Window, &mut App) -> AnyView {
+        let title = title.into();
+        let action = action.boxed_clone();
+        let focus_handle = focus_handle.clone();
+        move |window, cx| {
+            cx.new(|cx| Self {
+                title: title.clone(),
+                meta: None,
+                key_binding: KeyBinding::for_action_in(action.as_ref(), &focus_handle, window, cx),
+            })
+            .into()
+        }
+    }
+
     pub fn for_action(
         title: impl Into<SharedString>,
         action: &dyn Action,

crates/ui/src/prelude.rs 🔗

@@ -18,7 +18,10 @@ pub use crate::traits::styled_ext::*;
 pub use crate::traits::toggleable::*;
 pub use crate::traits::visible_on_hover::*;
 pub use crate::DynamicSpacing;
-pub use crate::{h_flex, h_group, v_flex, v_group};
+pub use crate::{h_container, h_flex, v_container, v_flex};
+pub use crate::{
+    h_group, h_group_lg, h_group_sm, h_group_xl, v_group, v_group_lg, v_group_sm, v_group_xl,
+};
 pub use crate::{Button, ButtonSize, ButtonStyle, IconButton, SelectableButton};
 pub use crate::{ButtonCommon, Color};
 pub use crate::{Headline, HeadlineSize};

crates/workspace/src/dock.rs 🔗

@@ -349,7 +349,11 @@ impl Dock {
             .and_then(|index| self.panel_entries.get(index))
     }
 
-    pub(crate) fn set_open(&mut self, open: bool, window: &mut Window, cx: &mut Context<Self>) {
+    pub fn active_panel_index(&self) -> Option<usize> {
+        self.active_panel_index
+    }
+
+    pub fn set_open(&mut self, open: bool, window: &mut Window, cx: &mut Context<Self>) {
         if open != self.is_open {
             self.is_open = open;
             if let Some(active_panel) = self.active_panel_entry() {

crates/zed/src/zed.rs 🔗

@@ -22,6 +22,7 @@ use editor::ProposedChangesEditorToolbar;
 use editor::{scroll::Autoscroll, Editor, MultiBuffer};
 use feature_flags::{FeatureFlagAppExt, FeatureFlagViewExt, GitUiFeatureFlag};
 use futures::{channel::mpsc, select_biased, StreamExt};
+use git_ui::project_diff::ProjectDiffToolbar;
 use gpui::{
     actions, point, px, Action, App, AppContext as _, AsyncApp, Context, DismissEvent, Element,
     Entity, Focusable, KeyBinding, MenuItem, ParentElement, PathPromptOptions, PromptLevel,
@@ -927,6 +928,8 @@ fn initialize_pane(
             toolbar.add_item(syntax_tree_item, window, cx);
             let migration_banner = cx.new(|cx| MigrationBanner::new(workspace, cx));
             toolbar.add_item(migration_banner, window, cx);
+            let project_diff_toolbar = cx.new(|cx| ProjectDiffToolbar::new(workspace, cx));
+            toolbar.add_item(project_diff_toolbar, window, cx);
         })
     });
 }