Git improvements (#24238)

Conrad Irwin and Cole created

- **Base diffs on uncommitted changes**
- **Show added files in project diff view**
- **Fix git panel optimism**

Release Notes:

- Git: update diffs to be relative to HEAD instead of the index; to pave
the way for showing which hunks are staged

---------

Co-authored-by: Cole <cole@zed.dev>

Change summary

crates/editor/src/editor.rs                   |   8 
crates/editor/src/editor_tests.rs             |  12 
crates/editor/src/test/editor_test_context.rs |   2 
crates/git/src/repository.rs                  |   6 
crates/git_ui/src/git_panel.rs                | 279 +++++++++-----------
crates/git_ui/src/project_diff.rs             |  80 +++--
crates/git_ui/src/repository_selector.rs      |   2 
crates/multi_buffer/src/multi_buffer.rs       |  19 +
crates/multi_buffer/src/multi_buffer_tests.rs |   6 
crates/project/src/buffer_store.rs            |  37 +-
crates/project/src/git.rs                     |  21 +
11 files changed, 241 insertions(+), 231 deletions(-)

Detailed changes

crates/editor/src/editor.rs 🔗

@@ -1285,7 +1285,7 @@ impl Editor {
 
         let mut code_action_providers = Vec::new();
         if let Some(project) = project.clone() {
-            get_unstaged_changes_for_buffers(
+            get_uncommitted_changes_for_buffer(
                 &project,
                 buffer.read(cx).all_buffers(),
                 buffer.clone(),
@@ -13657,7 +13657,7 @@ impl Editor {
                 let buffer_id = buffer.read(cx).remote_id();
                 if self.buffer.read(cx).change_set_for(buffer_id).is_none() {
                     if let Some(project) = &self.project {
-                        get_unstaged_changes_for_buffers(
+                        get_uncommitted_changes_for_buffer(
                             project,
                             [buffer.clone()],
                             self.buffer.clone(),
@@ -14413,7 +14413,7 @@ impl Editor {
     }
 }
 
-fn get_unstaged_changes_for_buffers(
+fn get_uncommitted_changes_for_buffer(
     project: &Entity<Project>,
     buffers: impl IntoIterator<Item = Entity<Buffer>>,
     buffer: Entity<MultiBuffer>,
@@ -14422,7 +14422,7 @@ fn get_unstaged_changes_for_buffers(
     let mut tasks = Vec::new();
     project.update(cx, |project, cx| {
         for buffer in buffers {
-            tasks.push(project.open_unstaged_changes(buffer.clone(), cx))
+            tasks.push(project.open_uncommitted_changes(buffer.clone(), cx))
         }
     });
     cx.spawn(|mut cx| async move {

crates/editor/src/editor_tests.rs 🔗

@@ -5619,13 +5619,13 @@ async fn test_fold_function_bodies(cx: &mut gpui::TestAppContext) {
 
     let base_text = r#"
         impl A {
-            // this is an unstaged comment
+            // this is an uncommitted comment
 
             fn b() {
                 c();
             }
 
-            // this is another unstaged comment
+            // this is another uncommitted comment
 
             fn d() {
                 // e
@@ -5668,13 +5668,13 @@ async fn test_fold_function_bodies(cx: &mut gpui::TestAppContext) {
     cx.assert_state_with_diff(
         "
         ˇimpl A {
-      -     // this is an unstaged comment
+      -     // this is an uncommitted comment
 
             fn b() {
                 c();
             }
 
-      -     // this is another unstaged comment
+      -     // this is another uncommitted comment
       -
             fn d() {
                 // e
@@ -5691,13 +5691,13 @@ async fn test_fold_function_bodies(cx: &mut gpui::TestAppContext) {
 
     let expected_display_text = "
         impl A {
-            // this is an unstaged comment
+            // this is an uncommitted comment
 
             fn b() {
                 ⋯
             }
 
-            // this is another unstaged comment
+            // this is another uncommitted comment
 
             fn d() {
                 ⋯

crates/editor/src/test/editor_test_context.rs 🔗

@@ -290,7 +290,7 @@ impl EditorTestContext {
             editor.project.as_ref().unwrap().read(cx).fs().as_fake()
         });
         let path = self.update_buffer(|buffer, _| buffer.file().unwrap().path().clone());
-        fs.set_index_for_repo(
+        fs.set_head_for_repo(
             &Self::root_path().join(".git"),
             &[(path.into(), diff_base.to_string())],
         );

crates/git/src/repository.rs 🔗

@@ -265,13 +265,13 @@ impl GitRepository for RealGitRepository {
             .to_path_buf();
 
         if !paths.is_empty() {
-            let cmd = new_std_command(&self.git_binary_path)
+            let status = new_std_command(&self.git_binary_path)
                 .current_dir(&working_directory)
                 .args(["update-index", "--add", "--remove", "--"])
                 .args(paths.iter().map(|p| p.as_ref()))
                 .status()?;
-            if !cmd.success() {
-                return Err(anyhow!("Failed to stage paths: {cmd}"));
+            if !status.success() {
+                return Err(anyhow!("Failed to stage paths: {status}"));
             }
         }
         Ok(())

crates/git_ui/src/git_panel.rs 🔗

@@ -12,13 +12,11 @@ use editor::scroll::ScrollbarAutoHide;
 use editor::{Editor, EditorMode, EditorSettings, MultiBuffer, ShowScrollbar};
 use git::repository::RepoPath;
 use git::status::FileStatus;
-use git::{
-    CommitAllChanges, CommitChanges, RevertAll, StageAll, ToggleStaged, UnstageAll, COMMIT_MESSAGE,
-};
+use git::{CommitAllChanges, CommitChanges, ToggleStaged, COMMIT_MESSAGE};
 use gpui::*;
 use language::{Buffer, BufferId};
 use menu::{SelectFirst, SelectLast, SelectNext, SelectPrev};
-use project::git::{GitRepo, RepositoryHandle};
+use project::git::{GitEvent, GitRepo, RepositoryHandle};
 use project::{CreateOptions, Fs, Project, ProjectPath};
 use rpc::proto;
 use serde::{Deserialize, Serialize};
@@ -43,7 +41,6 @@ actions!(
         Close,
         ToggleFocus,
         OpenMenu,
-        OpenSelected,
         FocusEditor,
         FocusChanges,
         FillCoAuthors,
@@ -76,17 +73,17 @@ struct SerializedGitPanel {
     width: Option<Pixels>,
 }
 
-#[derive(Debug, PartialEq, Eq, Clone)]
+#[derive(Debug, PartialEq, Eq, Clone, Copy)]
 enum Section {
     Changed,
-    New,
+    Created,
 }
 
 impl Section {
     pub fn contains(&self, status: FileStatus) -> bool {
         match self {
             Section::Changed => !status.is_created(),
-            Section::New => status.is_created(),
+            Section::Created => status.is_created(),
         }
     }
 }
@@ -94,7 +91,6 @@ impl Section {
 #[derive(Debug, PartialEq, Eq, Clone)]
 struct GitHeaderEntry {
     header: Section,
-    all_staged: ToggleState,
 }
 
 impl GitHeaderEntry {
@@ -104,7 +100,7 @@ impl GitHeaderEntry {
     pub fn title(&self) -> &'static str {
         match self.header {
             Section::Changed => "Changed",
-            Section::New => "New",
+            Section::Created => "New",
         }
     }
 }
@@ -126,11 +122,18 @@ impl GitListEntry {
 
 #[derive(Debug, PartialEq, Eq, Clone)]
 pub struct GitStatusEntry {
-    depth: usize,
-    display_name: String,
-    repo_path: RepoPath,
-    status: FileStatus,
-    is_staged: Option<bool>,
+    pub(crate) depth: usize,
+    pub(crate) display_name: String,
+    pub(crate) repo_path: RepoPath,
+    pub(crate) status: FileStatus,
+    pub(crate) is_staged: Option<bool>,
+}
+
+pub struct PendingOperation {
+    finished: bool,
+    will_become_staged: bool,
+    repo_paths: HashSet<RepoPath>,
+    op_id: usize,
 }
 
 pub struct GitPanel {
@@ -152,9 +155,11 @@ pub struct GitPanel {
     entries: Vec<GitListEntry>,
     entries_by_path: collections::HashMap<RepoPath, usize>,
     width: Option<Pixels>,
-    pending: HashMap<RepoPath, bool>,
+    pending: Vec<PendingOperation>,
     commit_task: Task<Result<()>>,
     commit_pending: bool,
+    can_commit: bool,
+    can_commit_all: bool,
 }
 
 fn commit_message_buffer(
@@ -287,9 +292,12 @@ impl GitPanel {
                 &git_state,
                 window,
                 move |this, git_state, event, window, cx| match event {
-                    project::git::Event::RepositoriesUpdated => {
+                    GitEvent::FileSystemUpdated => {
+                        this.schedule_update(false, window, cx);
+                    }
+                    GitEvent::ActiveRepositoryChanged | GitEvent::GitStateUpdated => {
                         this.active_repository = git_state.read(cx).active_repository();
-                        this.schedule_update(window, cx);
+                        this.schedule_update(true, window, cx);
                     }
                 },
             )
@@ -303,7 +311,7 @@ impl GitPanel {
                 pending_serialization: Task::ready(None),
                 entries: Vec::new(),
                 entries_by_path: HashMap::default(),
-                pending: HashMap::default(),
+                pending: Vec::new(),
                 current_modifiers: window.modifiers(),
                 width: Some(px(360.)),
                 scrollbar_state: ScrollbarState::new(scroll_handle.clone())
@@ -321,8 +329,10 @@ impl GitPanel {
                 commit_editor,
                 project,
                 workspace,
+                can_commit: false,
+                can_commit_all: false,
             };
-            git_panel.schedule_update(window, cx);
+            git_panel.schedule_update(false, window, cx);
             git_panel.show_scrollbar = git_panel.should_show_scrollbar(cx);
             git_panel
         });
@@ -617,7 +627,7 @@ impl GitPanel {
                 }
             }
             GitListEntry::Header(section) => {
-                let goal_staged_state = !section.all_staged.selected();
+                let goal_staged_state = !self.header_state(section.header).selected();
                 let entries = self
                     .entries
                     .iter()
@@ -629,12 +639,17 @@ impl GitPanel {
                     .map(|status_entry| status_entry.repo_path)
                     .collect::<Vec<_>>();
 
-                (!section.all_staged.selected(), entries)
+                (goal_staged_state, entries)
             }
         };
-        for repo_path in repo_paths.iter() {
-            self.pending.insert(repo_path.clone(), stage);
-        }
+
+        let op_id = self.pending.iter().map(|p| p.op_id).max().unwrap_or(0) + 1;
+        self.pending.push(PendingOperation {
+            op_id,
+            will_become_staged: stage,
+            repo_paths: repo_paths.iter().cloned().collect(),
+            finished: false,
+        });
 
         cx.spawn({
             let repo_paths = repo_paths.clone();
@@ -647,9 +662,9 @@ impl GitPanel {
                 };
 
                 this.update(&mut cx, |this, cx| {
-                    for repo_path in repo_paths {
-                        if this.pending.get(&repo_path) == Some(&stage) {
-                            this.pending.remove(&repo_path);
+                    for pending in this.pending.iter_mut() {
+                        if pending.op_id == op_id {
+                            pending.finished = true
                         }
                     }
                     result
@@ -696,67 +711,6 @@ impl GitPanel {
         cx.emit(Event::OpenedEntry { path });
     }
 
-    fn stage_all(&mut self, _: &git::StageAll, _window: &mut Window, cx: &mut Context<Self>) {
-        let Some(active_repository) = self.active_repository.as_ref().cloned() else {
-            return;
-        };
-        let mut pending_paths = Vec::new();
-        for entry in self.entries.iter() {
-            if let Some(status_entry) = entry.status_entry() {
-                self.pending.insert(status_entry.repo_path.clone(), true);
-                pending_paths.push(status_entry.repo_path.clone());
-            }
-        }
-
-        cx.spawn(|this, mut cx| async move {
-            if let Err(e) = active_repository.stage_all().await {
-                this.update(&mut cx, |this, cx| {
-                    this.show_err_toast(e, cx);
-                })
-                .ok();
-            };
-            this.update(&mut cx, |this, _cx| {
-                for repo_path in pending_paths {
-                    this.pending.remove(&repo_path);
-                }
-            })
-        })
-        .detach();
-    }
-
-    fn unstage_all(&mut self, _: &git::UnstageAll, _window: &mut Window, cx: &mut Context<Self>) {
-        let Some(active_repository) = self.active_repository.as_ref().cloned() else {
-            return;
-        };
-        let mut pending_paths = Vec::new();
-        for entry in self.entries.iter() {
-            if let Some(status_entry) = entry.status_entry() {
-                self.pending.insert(status_entry.repo_path.clone(), false);
-                pending_paths.push(status_entry.repo_path.clone());
-            }
-        }
-
-        cx.spawn(|this, mut cx| async move {
-            if let Err(e) = active_repository.unstage_all().await {
-                this.update(&mut cx, |this, cx| {
-                    this.show_err_toast(e, cx);
-                })
-                .ok();
-            };
-            this.update(&mut cx, |this, _cx| {
-                for repo_path in pending_paths {
-                    this.pending.remove(&repo_path);
-                }
-            })
-        })
-        .detach();
-    }
-
-    fn discard_all(&mut self, _: &git::RevertAll, _window: &mut Window, _cx: &mut Context<Self>) {
-        // TODO: Implement discard all
-        println!("Discard all triggered");
-    }
-
     /// Commit all staged changes
     fn commit_changes(
         &mut self,
@@ -768,7 +722,7 @@ impl GitPanel {
         let Some(active_repository) = self.active_repository.clone() else {
             return;
         };
-        if !active_repository.can_commit(false) {
+        if !self.can_commit {
             return;
         }
         if self.commit_editor.read(cx).is_empty(cx) {
@@ -811,7 +765,7 @@ impl GitPanel {
         let Some(active_repository) = self.active_repository.clone() else {
             return;
         };
-        if !active_repository.can_commit(true) {
+        if !self.can_commit_all {
             return;
         }
         if self.commit_editor.read(cx).is_empty(cx) {
@@ -926,7 +880,12 @@ impl GitPanel {
         });
     }
 
-    fn schedule_update(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+    fn schedule_update(
+        &mut self,
+        clear_pending: bool,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
         let project = self.project.clone();
         let handle = cx.entity().downgrade();
         self.update_visible_entries_task = cx.spawn_in(window, |_, mut cx| async move {
@@ -957,6 +916,9 @@ impl GitPanel {
                 git_panel
                     .update_in(&mut cx, |git_panel, window, cx| {
                         git_panel.update_visible_entries(cx);
+                        if clear_pending {
+                            git_panel.clear_pending();
+                        }
                         git_panel.commit_editor =
                             cx.new(|cx| commit_message_editor(commit_message_buffer, window, cx));
                     })
@@ -965,6 +927,10 @@ impl GitPanel {
         });
     }
 
+    fn clear_pending(&mut self) {
+        self.pending.retain(|v| !v.finished)
+    }
+
     fn update_visible_entries(&mut self, cx: &mut Context<Self>) {
         self.entries.clear();
         self.entries_by_path.clear();
@@ -980,12 +946,11 @@ impl GitPanel {
         // First pass - collect all paths
         let path_set = HashSet::from_iter(repo.status().map(|entry| entry.repo_path));
 
-        // Second pass - create entries with proper depth calculation
-        let mut new_any_staged = false;
-        let mut new_all_staged = true;
-        let mut changed_any_staged = false;
-        let mut changed_all_staged = true;
+        let mut has_changed_checked_boxes = false;
+        let mut has_changed = false;
+        let mut has_added_checked_boxes = false;
 
+        // Second pass - create entries with proper depth calculation
         for entry in repo.status() {
             let (depth, difference) =
                 Self::calculate_depth_and_difference(&entry.repo_path, &path_set);
@@ -993,15 +958,6 @@ impl GitPanel {
             let is_new = entry.status.is_created();
             let is_staged = entry.status.is_staged();
 
-            let new_is_staged = is_staged.unwrap_or(false);
-            if is_new {
-                new_any_staged |= new_is_staged;
-                new_all_staged &= new_is_staged;
-            } else {
-                changed_any_staged |= new_is_staged;
-                changed_all_staged &= new_is_staged;
-            }
-
             let display_name = if difference > 1 {
                 // Show partial path for deeply nested files
                 entry
@@ -1030,8 +986,15 @@ impl GitPanel {
             };
 
             if is_new {
+                if entry.is_staged != Some(false) {
+                    has_added_checked_boxes = true
+                }
                 new_entries.push(entry);
             } else {
+                has_changed = true;
+                if entry.is_staged != Some(false) {
+                    has_changed_checked_boxes = true
+                }
                 changed_entries.push(entry);
             }
         }
@@ -1041,11 +1004,8 @@ impl GitPanel {
         new_entries.sort_by(|a, b| a.repo_path.cmp(&b.repo_path));
 
         if changed_entries.len() > 0 {
-            let toggle_state =
-                ToggleState::from_any_and_all(changed_any_staged, changed_all_staged);
             self.entries.push(GitListEntry::Header(GitHeaderEntry {
                 header: Section::Changed,
-                all_staged: toggle_state,
             }));
             self.entries.extend(
                 changed_entries
@@ -1054,10 +1014,8 @@ impl GitPanel {
             );
         }
         if new_entries.len() > 0 {
-            let toggle_state = ToggleState::from_any_and_all(new_any_staged, new_all_staged);
             self.entries.push(GitListEntry::Header(GitHeaderEntry {
-                header: Section::New,
-                all_staged: toggle_state,
+                header: Section::Created,
             }));
             self.entries
                 .extend(new_entries.into_iter().map(GitListEntry::GitStatusEntry));
@@ -1068,12 +1026,45 @@ impl GitPanel {
                 self.entries_by_path.insert(status_entry.repo_path, ix);
             }
         }
+        self.can_commit = has_changed_checked_boxes || has_added_checked_boxes;
+        self.can_commit_all = has_changed || has_added_checked_boxes;
 
         self.select_first_entry_if_none(cx);
 
         cx.notify();
     }
 
+    fn header_state(&self, header_type: Section) -> ToggleState {
+        let mut count = 0;
+        let mut staged_count = 0;
+        'outer: for entry in &self.entries {
+            let Some(entry) = entry.status_entry() else {
+                continue;
+            };
+            if entry.status.is_created() != (header_type == Section::Created) {
+                continue;
+            }
+            count += 1;
+            for pending in self.pending.iter().rev() {
+                if pending.repo_paths.contains(&entry.repo_path) {
+                    if pending.will_become_staged {
+                        staged_count += 1;
+                    }
+                    continue 'outer;
+                }
+            }
+            staged_count += entry.status.is_staged().unwrap_or(false) as usize;
+        }
+
+        if staged_count == 0 {
+            ToggleState::Unselected
+        } else if count == staged_count {
+            ToggleState::Selected
+        } else {
+            ToggleState::Indeterminate
+        }
+    }
+
     fn show_err_toast(&self, e: anyhow::Error, cx: &mut App) {
         let Some(workspace) = self.workspace.upgrade() else {
             return;
@@ -1089,7 +1080,6 @@ impl GitPanel {
     }
 }
 
-// GitPanel –– Render
 impl GitPanel {
     pub fn panel_button(
         &self,
@@ -1199,21 +1189,13 @@ impl GitPanel {
     pub fn render_commit_editor(
         &self,
         name_and_email: Option<(SharedString, SharedString)>,
-        can_commit: bool,
         cx: &Context<Self>,
     ) -> impl IntoElement {
         let editor = self.commit_editor.clone();
-        let can_commit = can_commit && !editor.read(cx).is_empty(cx);
+        let can_commit = !self.commit_pending && self.can_commit && !editor.read(cx).is_empty(cx);
+        let can_commit_all =
+            !self.commit_pending && self.can_commit_all && !editor.read(cx).is_empty(cx);
         let editor_focus_handle = editor.read(cx).focus_handle(cx).clone();
-        let (can_commit, can_commit_all) =
-            self.active_repository
-                .as_ref()
-                .map_or((false, false), |active_repository| {
-                    (
-                        can_commit && active_repository.can_commit(false),
-                        can_commit && active_repository.can_commit(true),
-                    )
-                });
 
         let focus_handle_1 = self.focus_handle(cx).clone();
         let focus_handle_2 = self.focus_handle(cx).clone();
@@ -1466,7 +1448,7 @@ impl GitPanel {
         has_write_access: bool,
         cx: &Context<Self>,
     ) -> AnyElement {
-        let checkbox = Checkbox::new(header.title(), header.all_staged)
+        let checkbox = Checkbox::new(header.title(), self.header_state(header.header))
             .disabled(!has_write_access)
             .fill()
             .elevation(ElevationIndex::Surface);
@@ -1510,7 +1492,14 @@ impl GitPanel {
             .map(|name| name.to_string_lossy().into_owned())
             .unwrap_or_else(|| entry.repo_path.to_string_lossy().into_owned());
 
-        let pending = self.pending.get(&entry.repo_path).copied();
+        let pending = self.pending.iter().rev().find_map(|pending| {
+            if pending.repo_paths.contains(&entry.repo_path) {
+                Some(pending.will_become_staged)
+            } else {
+                None
+            }
+        });
+
         let repo_path = entry.repo_path.clone();
         let selected = self.selected_entry == Some(ix);
         let status_style = GitPanelSettings::get_global(cx).status_style;
@@ -1559,13 +1548,19 @@ impl GitPanel {
                         window,
                         cx,
                     );
+                    cx.stop_propagation();
                 })
             });
 
         let start_slot = h_flex()
+            .id(("start-slot", ix))
             .gap(DynamicSpacing::Base04.rems(cx))
             .child(checkbox)
-            .child(git_status_icon(status, cx));
+            .child(git_status_icon(status, cx))
+            .on_mouse_down(MouseButton::Left, |_, _, cx| {
+                // prevent the list item active state triggering when toggling checkbox
+                cx.stop_propagation();
+            });
 
         let id = ElementId::Name(format!("entry_{}", display_name).into());
 
@@ -1581,27 +1576,14 @@ impl GitPanel {
                     .toggle_state(selected)
                     .disabled(!has_write_access)
                     .on_click({
-                        let repo_path = entry.repo_path.clone();
+                        let entry = entry.clone();
                         cx.listener(move |this, _, window, cx| {
                             this.selected_entry = Some(ix);
-                            window.dispatch_action(Box::new(OpenSelected), cx);
-                            cx.notify();
                             let Some(workspace) = this.workspace.upgrade() else {
                                 return;
                             };
-                            let Some(git_repo) = this.active_repository.as_ref() else {
-                                return;
-                            };
-                            let Some(path) = git_repo
-                                .repo_path_to_project_path(&repo_path)
-                                .and_then(|project_path| {
-                                    this.project.read(cx).absolute_path(&project_path, cx)
-                                })
-                            else {
-                                return;
-                            };
                             workspace.update(cx, |workspace, cx| {
-                                ProjectDiff::deploy_at(workspace, Some(path.into()), window, cx);
+                                ProjectDiff::deploy_at(workspace, Some(entry.clone()), window, cx);
                             })
                         })
                     })
@@ -1691,17 +1673,6 @@ impl Render for GitPanel {
                 this.on_action(cx.listener(|this, &ToggleStaged, window, cx| {
                     this.toggle_staged_for_selected(&ToggleStaged, window, cx)
                 }))
-                .on_action(
-                    cx.listener(|this, &StageAll, window, cx| {
-                        this.stage_all(&StageAll, window, cx)
-                    }),
-                )
-                .on_action(cx.listener(|this, &UnstageAll, window, cx| {
-                    this.unstage_all(&UnstageAll, window, cx)
-                }))
-                .on_action(cx.listener(|this, &RevertAll, window, cx| {
-                    this.discard_all(&RevertAll, window, cx)
-                }))
                 .when(can_commit, |git_panel| {
                     git_panel
                         .on_action({
@@ -1764,7 +1735,7 @@ impl Render for GitPanel {
                 self.render_empty_state(cx).into_any_element()
             })
             .child(self.render_divider(cx))
-            .child(self.render_commit_editor(name_and_email, can_commit, cx))
+            .child(self.render_commit_editor(name_and_email, cx))
     }
 }
 

crates/git_ui/src/project_diff.rs 🔗

@@ -1,8 +1,4 @@
-use std::{
-    any::{Any, TypeId},
-    path::Path,
-    sync::Arc,
-};
+use std::any::{Any, TypeId};
 
 use anyhow::Result;
 use collections::HashSet;
@@ -14,7 +10,7 @@ use gpui::{
     FocusHandle, Focusable, Render, Subscription, Task, WeakEntity,
 };
 use language::{Anchor, Buffer, Capability, OffsetRangeExt};
-use multi_buffer::MultiBuffer;
+use multi_buffer::{MultiBuffer, PathKey};
 use project::{buffer_store::BufferChangeSet, git::GitState, Project, ProjectPath};
 use theme::ActiveTheme;
 use ui::prelude::*;
@@ -25,7 +21,7 @@ use workspace::{
     ItemNavHistory, ToolbarItemLocation, Workspace,
 };
 
-use crate::git_panel::GitPanel;
+use crate::git_panel::{GitPanel, GitStatusEntry};
 
 actions!(git, [Diff]);
 
@@ -37,18 +33,21 @@ pub(crate) struct ProjectDiff {
     workspace: WeakEntity<Workspace>,
     focus_handle: FocusHandle,
     update_needed: postage::watch::Sender<()>,
-    pending_scroll: Option<Arc<Path>>,
+    pending_scroll: Option<PathKey>,
 
     _task: Task<Result<()>>,
     _subscription: Subscription,
 }
 
 struct DiffBuffer {
-    abs_path: Arc<Path>,
+    path_key: PathKey,
     buffer: Entity<Buffer>,
     change_set: Entity<BufferChangeSet>,
 }
 
+const CHANGED_NAMESPACE: &'static str = "0";
+const ADDED_NAMESPACE: &'static str = "1";
+
 impl ProjectDiff {
     pub(crate) fn register(
         _: &mut Workspace,
@@ -72,7 +71,7 @@ impl ProjectDiff {
 
     pub fn deploy_at(
         workspace: &mut Workspace,
-        path: Option<Arc<Path>>,
+        entry: Option<GitStatusEntry>,
         window: &mut Window,
         cx: &mut Context<Workspace>,
     ) {
@@ -92,9 +91,9 @@ impl ProjectDiff {
             );
             project_diff
         };
-        if let Some(path) = path {
+        if let Some(entry) = entry {
             project_diff.update(cx, |project_diff, cx| {
-                project_diff.scroll_to(path, window, cx);
+                project_diff.scroll_to(entry, window, cx);
             })
         }
     }
@@ -126,10 +125,8 @@ impl ProjectDiff {
         let git_state_subscription = cx.subscribe_in(
             &git_state,
             window,
-            move |this, _git_state, event, _window, _cx| match event {
-                project::git::Event::RepositoriesUpdated => {
-                    *this.update_needed.borrow_mut() = ();
-                }
+            move |this, _git_state, _event, _window, _cx| {
+                *this.update_needed.borrow_mut() = ();
             },
         );
 
@@ -155,15 +152,39 @@ impl ProjectDiff {
         }
     }
 
-    pub fn scroll_to(&mut self, path: Arc<Path>, window: &mut Window, cx: &mut Context<Self>) {
-        if let Some(position) = self.multibuffer.read(cx).location_for_path(&path, cx) {
+    pub fn scroll_to(
+        &mut self,
+        entry: GitStatusEntry,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let Some(git_repo) = self.git_state.read(cx).active_repository() else {
+            return;
+        };
+
+        let Some(path) = git_repo
+            .repo_path_to_project_path(&entry.repo_path)
+            .and_then(|project_path| self.project.read(cx).absolute_path(&project_path, cx))
+        else {
+            return;
+        };
+        let path_key = if entry.status.is_created() {
+            PathKey::namespaced(ADDED_NAMESPACE, &path)
+        } else {
+            PathKey::namespaced(CHANGED_NAMESPACE, &path)
+        };
+        self.scroll_to_path(path_key, window, cx)
+    }
+
+    fn scroll_to_path(&mut self, path_key: PathKey, window: &mut Window, cx: &mut Context<Self>) {
+        if let Some(position) = self.multibuffer.read(cx).location_for_path(&path_key, cx) {
             self.editor.update(cx, |editor, cx| {
                 editor.change_selections(Some(Autoscroll::focused()), window, cx, |s| {
                     s.select_ranges([position..position]);
                 })
             })
         } else {
-            self.pending_scroll = Some(path);
+            self.pending_scroll = Some(path_key);
         }
     }
 
@@ -223,9 +244,14 @@ impl ProjectDiff {
             let Some(abs_path) = self.project.read(cx).absolute_path(&project_path, cx) else {
                 continue;
             };
-            let abs_path = Arc::from(abs_path);
+            // Craft some artificial paths so that created entries will appear last.
+            let path_key = if entry.status.is_created() {
+                PathKey::namespaced(ADDED_NAMESPACE, &abs_path)
+            } else {
+                PathKey::namespaced(CHANGED_NAMESPACE, &abs_path)
+            };
 
-            previous_paths.remove(&abs_path);
+            previous_paths.remove(&path_key);
             let load_buffer = self
                 .project
                 .update(cx, |project, cx| project.open_buffer(project_path, cx));
@@ -235,11 +261,11 @@ impl ProjectDiff {
                 let buffer = load_buffer.await?;
                 let changes = project
                     .update(&mut cx, |project, cx| {
-                        project.open_unstaged_changes(buffer.clone(), cx)
+                        project.open_uncommitted_changes(buffer.clone(), cx)
                     })?
                     .await?;
                 Ok(DiffBuffer {
-                    abs_path,
+                    path_key,
                     buffer,
                     change_set: changes,
                 })
@@ -259,7 +285,7 @@ impl ProjectDiff {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        let abs_path = diff_buffer.abs_path;
+        let path_key = diff_buffer.path_key;
         let buffer = diff_buffer.buffer;
         let change_set = diff_buffer.change_set;
 
@@ -272,15 +298,15 @@ impl ProjectDiff {
 
         self.multibuffer.update(cx, |multibuffer, cx| {
             multibuffer.set_excerpts_for_path(
-                abs_path.clone(),
+                path_key.clone(),
                 buffer,
                 diff_hunk_ranges,
                 editor::DEFAULT_MULTIBUFFER_CONTEXT,
                 cx,
             );
         });
-        if self.pending_scroll.as_ref() == Some(&abs_path) {
-            self.scroll_to(abs_path, window, cx);
+        if self.pending_scroll.as_ref() == Some(&path_key) {
+            self.scroll_to_path(path_key, window, cx);
         }
     }
 

crates/git_ui/src/repository_selector.rs 🔗

@@ -49,7 +49,7 @@ impl RepositorySelector {
     fn handle_project_git_event(
         &mut self,
         git_state: &Entity<GitState>,
-        _event: &project::git::Event,
+        _event: &project::git::GitEvent,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {

crates/multi_buffer/src/multi_buffer.rs 🔗

@@ -67,7 +67,7 @@ pub struct MultiBuffer {
     /// Contains the state of the buffers being edited
     buffers: RefCell<HashMap<BufferId, BufferState>>,
     // only used by consumers using `set_excerpts_for_buffer`
-    buffers_by_path: BTreeMap<Arc<Path>, Vec<ExcerptId>>,
+    buffers_by_path: BTreeMap<PathKey, Vec<ExcerptId>>,
     diff_bases: HashMap<BufferId, ChangeSetState>,
     all_diff_hunks_expanded: bool,
     subscriptions: Topic,
@@ -143,6 +143,15 @@ impl MultiBufferDiffHunk {
     }
 }
 
+#[derive(PartialEq, Eq, Ord, PartialOrd, Clone, Hash, Debug)]
+pub struct PathKey(String);
+
+impl PathKey {
+    pub fn namespaced(namespace: &str, path: &Path) -> Self {
+        Self(format!("{}/{}", namespace, path.to_string_lossy()))
+    }
+}
+
 pub type MultiBufferPoint = Point;
 type ExcerptOffset = TypedOffset<Excerpt>;
 type ExcerptPoint = TypedPoint<Excerpt>;
@@ -1395,7 +1404,7 @@ impl MultiBuffer {
         anchor_ranges
     }
 
-    pub fn location_for_path(&self, path: &Arc<Path>, cx: &App) -> Option<Anchor> {
+    pub fn location_for_path(&self, path: &PathKey, cx: &App) -> Option<Anchor> {
         let excerpt_id = self.buffers_by_path.get(path)?.first()?;
         let snapshot = self.snapshot(cx);
         let excerpt = snapshot.excerpt(*excerpt_id)?;
@@ -1408,7 +1417,7 @@ impl MultiBuffer {
 
     pub fn set_excerpts_for_path(
         &mut self,
-        path: Arc<Path>,
+        path: PathKey,
         buffer: Entity<Buffer>,
         ranges: Vec<Range<Point>>,
         context_line_count: u32,
@@ -1517,11 +1526,11 @@ impl MultiBuffer {
         }
     }
 
-    pub fn paths(&self) -> impl Iterator<Item = Arc<Path>> + '_ {
+    pub fn paths(&self) -> impl Iterator<Item = PathKey> + '_ {
         self.buffers_by_path.keys().cloned()
     }
 
-    pub fn remove_excerpts_for_path(&mut self, path: Arc<Path>, cx: &mut Context<Self>) {
+    pub fn remove_excerpts_for_path(&mut self, path: PathKey, cx: &mut Context<Self>) {
         if let Some(to_remove) = self.buffers_by_path.remove(&path) {
             self.remove_excerpts(to_remove, cx)
         }

crates/multi_buffer/src/multi_buffer_tests.rs 🔗

@@ -6,7 +6,7 @@ use language::{Buffer, Rope};
 use parking_lot::RwLock;
 use rand::prelude::*;
 use settings::SettingsStore;
-use std::{env, path::PathBuf};
+use std::env;
 use util::test::sample_text;
 
 #[ctor::ctor]
@@ -1596,7 +1596,7 @@ fn test_set_excerpts_for_buffer(cx: &mut TestAppContext) {
             cx,
         )
     });
-    let path1: Arc<Path> = Arc::from(PathBuf::from("path1"));
+    let path1: PathKey = PathKey::namespaced("0", Path::new("/"));
     let buf2 = cx.new(|cx| {
         Buffer::local(
             indoc! {
@@ -1615,7 +1615,7 @@ fn test_set_excerpts_for_buffer(cx: &mut TestAppContext) {
             cx,
         )
     });
-    let path2: Arc<Path> = Arc::from(PathBuf::from("path2"));
+    let path2 = PathKey::namespaced("x", Path::new("/"));
 
     let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite));
     multibuffer.update(cx, |multibuffer, cx| {

crates/project/src/buffer_store.rs 🔗

@@ -149,37 +149,32 @@ impl BufferChangeSetState {
     ) -> oneshot::Receiver<()> {
         match diff_bases_change {
             DiffBasesChange::SetIndex(index) => {
-                self.index_text = index.map(|mut text| {
-                    text::LineEnding::normalize(&mut text);
-                    Arc::new(text)
-                });
+                let mut index = index.unwrap_or_default();
+                text::LineEnding::normalize(&mut index);
+                self.index_text = Some(Arc::new(index));
                 self.index_changed = true;
             }
             DiffBasesChange::SetHead(head) => {
-                self.head_text = head.map(|mut text| {
-                    text::LineEnding::normalize(&mut text);
-                    Arc::new(text)
-                });
+                let mut head = head.unwrap_or_default();
+                text::LineEnding::normalize(&mut head);
+                self.head_text = Some(Arc::new(head));
                 self.head_changed = true;
             }
-            DiffBasesChange::SetBoth(mut text) => {
-                if let Some(text) = text.as_mut() {
-                    text::LineEnding::normalize(text);
-                }
-                self.head_text = text.map(Arc::new);
+            DiffBasesChange::SetBoth(text) => {
+                let mut text = text.unwrap_or_default();
+                text::LineEnding::normalize(&mut text);
+                self.head_text = Some(Arc::new(text));
                 self.index_text = self.head_text.clone();
                 self.head_changed = true;
                 self.index_changed = true;
             }
             DiffBasesChange::SetEach { index, head } => {
-                self.index_text = index.map(|mut text| {
-                    text::LineEnding::normalize(&mut text);
-                    Arc::new(text)
-                });
-                self.head_text = head.map(|mut text| {
-                    text::LineEnding::normalize(&mut text);
-                    Arc::new(text)
-                });
+                let mut index = index.unwrap_or_default();
+                text::LineEnding::normalize(&mut index);
+                let mut head = head.unwrap_or_default();
+                text::LineEnding::normalize(&mut head);
+                self.index_text = Some(Arc::new(index));
+                self.head_text = Some(Arc::new(head));
                 self.head_changed = true;
                 self.index_changed = true;
             }

crates/project/src/git.rs 🔗

@@ -69,11 +69,13 @@ enum Message {
     Unstage(GitRepo, Vec<RepoPath>),
 }
 
-pub enum Event {
-    RepositoriesUpdated,
+pub enum GitEvent {
+    ActiveRepositoryChanged,
+    FileSystemUpdated,
+    GitStateUpdated,
 }
 
-impl EventEmitter<Event> for GitState {}
+impl EventEmitter<GitEvent> for GitState {}
 
 impl GitState {
     pub fn new(
@@ -103,7 +105,7 @@ impl GitState {
     fn on_worktree_store_event(
         &mut self,
         worktree_store: Entity<WorktreeStore>,
-        _event: &WorktreeStoreEvent,
+        event: &WorktreeStoreEvent,
         cx: &mut Context<'_, Self>,
     ) {
         // TODO inspect the event
@@ -172,7 +174,14 @@ impl GitState {
         self.repositories = new_repositories;
         self.active_index = new_active_index;
 
-        cx.emit(Event::RepositoriesUpdated);
+        match event {
+            WorktreeStoreEvent::WorktreeUpdatedGitRepositories(_) => {
+                cx.emit(GitEvent::GitStateUpdated);
+            }
+            _ => {
+                cx.emit(GitEvent::FileSystemUpdated);
+            }
+        }
     }
 
     pub fn all_repositories(&self) -> Vec<RepositoryHandle> {
@@ -314,7 +323,7 @@ impl RepositoryHandle {
                 return;
             };
             git_state.active_index = Some(index);
-            cx.emit(Event::RepositoriesUpdated);
+            cx.emit(GitEvent::ActiveRepositoryChanged);
         });
     }