git: Git Panel UI, continued (#22960)

Nate Butler , Cole Miller , and Cole Miller created

TODO:

- [ ] Investigate incorrect hit target for `stage all` button
- [ ] Add top level context menu
- [ ] Add entry context menus
- [x] Show paths in list view
- [ ] For now, `enter` can just open the file
- [ ] 🐞: Hover deadzone in list caused by scrollbar
- [x] 🐞: Incorrect status/nothing shown when multiple worktrees are
added

---

This PR continues work on the feature flagged git panel.

Changes:
- Defines and wires up git panel actions & keybindings
- Re-scopes some actions from `git_ui` -> `git`.
- General git actions (StageAll, CommitChanges, ...) are scoped to
`git`.
- Git panel specific actions (Close, FocusCommitEditor, ...) are scoped
to `git_panel.
- Staging actions & UI are now connected to git!
- Unify more reusable git status into the GitState global over being
tied to the panel directly.
- Uses the new git status codepaths instead of filtering all workspace
entries

Release Notes:

- N/A

---------

Co-authored-by: Cole Miller <53574922+cole-miller@users.noreply.github.com>
Co-authored-by: Cole Miller <cole@zed.dev>

Change summary

Cargo.lock                                                    |   2 
assets/keymaps/default-macos.json                             |  32 
crates/collab/src/tests/random_project_collaboration_tests.rs |   2 
crates/editor/src/git/project_diff.rs                         |   3 
crates/git/src/repository.rs                                  |  73 
crates/git/src/status.rs                                      |  69 
crates/git_ui/Cargo.toml                                      |   2 
crates/git_ui/TODO.md                                         |  45 
crates/git_ui/src/git_panel.rs                                | 974 ++--
crates/git_ui/src/git_ui.rs                                   | 248 +
crates/ui/src/components/button/button_like.rs                |   1 
crates/worktree/src/worktree.rs                               |  89 
crates/worktree/src/worktree_tests.rs                         |  36 
13 files changed, 904 insertions(+), 672 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -5113,6 +5113,7 @@ dependencies = [
  "collections",
  "db",
  "editor",
+ "futures 0.3.31",
  "git",
  "gpui",
  "language",
@@ -5123,6 +5124,7 @@ dependencies = [
  "serde_derive",
  "serde_json",
  "settings",
+ "sum_tree",
  "theme",
  "ui",
  "util",

assets/keymaps/default-macos.json 🔗

@@ -682,6 +682,38 @@
       "space": "project_panel::Open"
     }
   },
+  {
+    "context": "GitPanel && !CommitEditor",
+    "use_key_equivalents": true,
+    "bindings": {
+      "escape": "git_panel::Close"
+    }
+  },
+  {
+    "context": "GitPanel && ChangesList",
+    "use_key_equivalents": true,
+    "bindings": {
+      "up": "menu::SelectPrev",
+      "down": "menu::SelectNext",
+      "cmd-up": "menu::SelectFirst",
+      "cmd-down": "menu::SelectLast",
+      "enter": "menu::Confirm",
+      "space": "git::ToggleStaged",
+      "cmd-shift-space": "git::StageAll",
+      "ctrl-shift-space": "git::UnstageAll",
+      "alt-down": "git_panel::FocusEditor"
+    }
+  },
+  {
+    "context": "GitPanel && CommitEditor > Editor",
+    "use_key_equivalents": true,
+    "bindings": {
+      "alt-up": "git_panel::FocusChanges",
+      "escape": "git_panel::FocusChanges",
+      "cmd-enter": "git::CommitChanges",
+      "cmd-alt-enter": "git::CommitAllChanges"
+    }
+  },
   {
     "context": "CollabPanel && not_editing",
     "use_key_equivalents": true,

crates/collab/src/tests/random_project_collaboration_tests.rs 🔗

@@ -1221,7 +1221,7 @@ impl RandomizedTest for ProjectCollaborationTest {
                                         id,
                                         guest_project.remote_id(),
                                     );
-                                    assert_eq!(guest_snapshot.repositories().collect::<Vec<_>>(), host_snapshot.repositories().collect::<Vec<_>>(),
+                                    assert_eq!(guest_snapshot.repositories().iter().collect::<Vec<_>>(), host_snapshot.repositories().iter().collect::<Vec<_>>(),
                                         "{} has different repositories than the host for worktree {:?} and project {:?}",
                                         client.username,
                                         host_snapshot.abs_path(),

crates/editor/src/git/project_diff.rs 🔗

@@ -197,9 +197,10 @@ impl ProjectDiffEditor {
                         let snapshot = worktree.read(cx).snapshot();
                         let applicable_entries = snapshot
                             .repositories()
+                            .iter()
                             .flat_map(|entry| {
                                 entry.status().map(|git_entry| {
-                                    (git_entry.status, entry.join(git_entry.repo_path))
+                                    (git_entry.combined_status(), entry.join(git_entry.repo_path))
                                 })
                             })
                             .filter_map(|(status, path)| {

crates/git/src/repository.rs 🔗

@@ -1,6 +1,7 @@
+use crate::status::GitStatusPair;
 use crate::GitHostingProviderRegistry;
 use crate::{blame::Blame, status::GitStatus};
-use anyhow::{Context, Result};
+use anyhow::{anyhow, Context, Result};
 use collections::{HashMap, HashSet};
 use git2::BranchType;
 use gpui::SharedString;
@@ -15,6 +16,7 @@ use std::{
     sync::Arc,
 };
 use sum_tree::MapSeekTarget;
+use util::command::new_std_command;
 use util::ResultExt;
 
 #[derive(Clone, Debug, Hash, PartialEq)]
@@ -51,6 +53,8 @@ pub trait GitRepository: Send + Sync {
 
     /// Returns the path to the repository, typically the `.git` folder.
     fn dot_git_dir(&self) -> PathBuf;
+
+    fn update_index(&self, stage: &[RepoPath], unstage: &[RepoPath]) -> Result<()>;
 }
 
 impl std::fmt::Debug for dyn GitRepository {
@@ -152,7 +156,7 @@ impl GitRepository for RealGitRepository {
             Ok(_) => Ok(true),
             Err(e) => match e.code() {
                 git2::ErrorCode::NotFound => Ok(false),
-                _ => Err(anyhow::anyhow!(e)),
+                _ => Err(anyhow!(e)),
             },
         }
     }
@@ -196,7 +200,7 @@ impl GitRepository for RealGitRepository {
         repo.set_head(
             revision
                 .name()
-                .ok_or_else(|| anyhow::anyhow!("Branch name could not be retrieved"))?,
+                .ok_or_else(|| anyhow!("Branch name could not be retrieved"))?,
         )?;
         Ok(())
     }
@@ -228,6 +232,36 @@ impl GitRepository for RealGitRepository {
             self.hosting_provider_registry.clone(),
         )
     }
+
+    fn update_index(&self, stage: &[RepoPath], unstage: &[RepoPath]) -> Result<()> {
+        let working_directory = self
+            .repository
+            .lock()
+            .workdir()
+            .context("failed to read git work directory")?
+            .to_path_buf();
+        if !stage.is_empty() {
+            let add = new_std_command(&self.git_binary_path)
+                .current_dir(&working_directory)
+                .args(["add", "--"])
+                .args(stage.iter().map(|p| p.as_ref()))
+                .status()?;
+            if !add.success() {
+                return Err(anyhow!("Failed to stage files: {add}"));
+            }
+        }
+        if !unstage.is_empty() {
+            let rm = new_std_command(&self.git_binary_path)
+                .current_dir(&working_directory)
+                .args(["restore", "--staged", "--"])
+                .args(unstage.iter().map(|p| p.as_ref()))
+                .status()?;
+            if !rm.success() {
+                return Err(anyhow!("Failed to unstage files: {rm}"));
+            }
+        }
+        Ok(())
+    }
 }
 
 #[derive(Debug, Clone)]
@@ -298,18 +332,24 @@ impl GitRepository for FakeGitRepository {
         let mut entries = state
             .worktree_statuses
             .iter()
-            .filter_map(|(repo_path, status)| {
+            .filter_map(|(repo_path, status_worktree)| {
                 if path_prefixes
                     .iter()
                     .any(|path_prefix| repo_path.0.starts_with(path_prefix))
                 {
-                    Some((repo_path.to_owned(), *status))
+                    Some((
+                        repo_path.to_owned(),
+                        GitStatusPair {
+                            index_status: None,
+                            worktree_status: Some(*status_worktree),
+                        },
+                    ))
                 } else {
                     None
                 }
             })
             .collect::<Vec<_>>();
-        entries.sort_unstable_by(|a, b| a.0.cmp(&b.0));
+        entries.sort_unstable_by(|(a, _), (b, _)| a.cmp(&b));
 
         Ok(GitStatus {
             entries: entries.into(),
@@ -363,6 +403,10 @@ impl GitRepository for FakeGitRepository {
             .with_context(|| format!("failed to get blame for {:?}", path))
             .cloned()
     }
+
+    fn update_index(&self, _stage: &[RepoPath], _unstage: &[RepoPath]) -> Result<()> {
+        unimplemented!()
+    }
 }
 
 fn check_path_to_repo_path_errors(relative_file_path: &Path) -> Result<()> {
@@ -398,6 +442,7 @@ fn check_path_to_repo_path_errors(relative_file_path: &Path) -> Result<()> {
 pub enum GitFileStatus {
     Added,
     Modified,
+    // TODO conflicts should be represented by the GitStatusPair
     Conflict,
     Deleted,
     Untracked,
@@ -426,6 +471,16 @@ impl GitFileStatus {
             _ => None,
         }
     }
+
+    pub fn from_byte(byte: u8) -> Option<Self> {
+        match byte {
+            b'M' => Some(GitFileStatus::Modified),
+            b'A' => Some(GitFileStatus::Added),
+            b'D' => Some(GitFileStatus::Deleted),
+            b'?' => Some(GitFileStatus::Untracked),
+            _ => None,
+        }
+    }
 }
 
 pub static WORK_DIRECTORY_REPO_PATH: LazyLock<RepoPath> =
@@ -453,6 +508,12 @@ impl RepoPath {
     }
 }
 
+impl std::fmt::Display for RepoPath {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        self.0.to_string_lossy().fmt(f)
+    }
+}
+
 impl From<&Path> for RepoPath {
     fn from(value: &Path) -> Self {
         RepoPath::new(value.into())

crates/git/src/status.rs 🔗

@@ -2,9 +2,33 @@ use crate::repository::{GitFileStatus, RepoPath};
 use anyhow::{anyhow, Result};
 use std::{path::Path, process::Stdio, sync::Arc};
 
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct GitStatusPair {
+    // Not both `None`.
+    pub index_status: Option<GitFileStatus>,
+    pub worktree_status: Option<GitFileStatus>,
+}
+
+impl GitStatusPair {
+    pub fn is_staged(&self) -> Option<bool> {
+        match (self.index_status, self.worktree_status) {
+            (Some(_), None) => Some(true),
+            (None, Some(_)) => Some(false),
+            (Some(GitFileStatus::Untracked), Some(GitFileStatus::Untracked)) => Some(false),
+            (Some(_), Some(_)) => None,
+            (None, None) => unreachable!(),
+        }
+    }
+
+    // TODO reconsider uses of this
+    pub fn combined(&self) -> GitFileStatus {
+        self.index_status.or(self.worktree_status).unwrap()
+    }
+}
+
 #[derive(Clone)]
 pub struct GitStatus {
-    pub entries: Arc<[(RepoPath, GitFileStatus)]>,
+    pub entries: Arc<[(RepoPath, GitStatusPair)]>,
 }
 
 impl GitStatus {
@@ -20,6 +44,7 @@ impl GitStatus {
                 "status",
                 "--porcelain=v1",
                 "--untracked-files=all",
+                "--no-renames",
                 "-z",
             ])
             .args(path_prefixes.iter().map(|path_prefix| {
@@ -47,36 +72,32 @@ impl GitStatus {
         let mut entries = stdout
             .split('\0')
             .filter_map(|entry| {
-                if entry.is_char_boundary(3) {
-                    let (status, path) = entry.split_at(3);
-                    let status = status.trim();
-                    Some((
-                        RepoPath(Path::new(path).into()),
-                        match status {
-                            "A" => GitFileStatus::Added,
-                            "M" => GitFileStatus::Modified,
-                            "D" => GitFileStatus::Deleted,
-                            "??" => GitFileStatus::Untracked,
-                            _ => return None,
-                        },
-                    ))
-                } else {
-                    None
+                let sep = entry.get(2..3)?;
+                if sep != " " {
+                    return None;
+                };
+                let path = &entry[3..];
+                let status = entry[0..2].as_bytes();
+                let index_status = GitFileStatus::from_byte(status[0]);
+                let worktree_status = GitFileStatus::from_byte(status[1]);
+                if (index_status, worktree_status) == (None, None) {
+                    return None;
                 }
+                let path = RepoPath(Path::new(path).into());
+                Some((
+                    path,
+                    GitStatusPair {
+                        index_status,
+                        worktree_status,
+                    },
+                ))
             })
             .collect::<Vec<_>>();
-        entries.sort_unstable_by(|a, b| a.0.cmp(&b.0));
+        entries.sort_unstable_by(|(a, _), (b, _)| a.cmp(&b));
         Ok(Self {
             entries: entries.into(),
         })
     }
-
-    pub fn get(&self, path: &Path) -> Option<GitFileStatus> {
-        self.entries
-            .binary_search_by(|(repo_path, _)| repo_path.0.as_ref().cmp(path))
-            .ok()
-            .map(|index| self.entries[index].1)
-    }
 }
 
 impl Default for GitStatus {

crates/git_ui/Cargo.toml 🔗

@@ -17,6 +17,7 @@ anyhow.workspace = true
 collections.workspace = true
 db.workspace = true
 editor.workspace = true
+futures.workspace = true
 git.workspace = true
 gpui.workspace = true
 language.workspace = true
@@ -27,6 +28,7 @@ serde.workspace = true
 serde_derive.workspace = true
 serde_json.workspace = true
 settings.workspace = true
+sum_tree.workspace = true
 theme.workspace = true
 ui.workspace = true
 util.workspace = true

crates/git_ui/TODO.md 🔗

@@ -1,45 +0,0 @@
-### General
-
-- [x] Disable staging and committing actions for read-only projects
-
-### List
-
-- [x] Add uniform list
-- [x] Git status item
-- [ ] Directory item
-- [x] Scrollbar
-- [ ] Add indent size setting
-- [ ] Add tree settings
-
-### List Items
-
-- [x] Checkbox for staging
-- [x] Git status icon
-- [ ] Context menu
-  - [ ] Discard Changes
-  - ---
-  - [ ] Ignore
-  - [ ] Ignore directory
-  - ---
-  - [ ] Copy path
-  - [ ] Copy relative path
-  - ---
-  - [ ] Reveal in Finder
-
-### Commit Editor
-
-- [ ] Add commit editor
-- [ ] Add commit message placeholder & add commit message to store
-- [ ] Add a way to get the current collaborators & automatically add them to the commit message as co-authors
-- [ ] Add action to clear commit message
-- [x] Swap commit button between "Commit" and "Commit All" based on modifier key
-
-### Component Updates
-
-- [ ] ChangedLineCount (new)
-  - takes `lines_added: usize, lines_removed: usize`, returns a added/removed badge
-- [x] GitStatusIcon (new)
-- [ ] Checkbox
-  - update checkbox design
-- [ ] ScrollIndicator
-  - shows a gradient overlay when more content is available to be scrolled

crates/git_ui/src/git_panel.rs 🔗

@@ -1,31 +1,21 @@
+use crate::{first_repository_in_project, first_worktree_repository};
 use crate::{
-    git_status_icon, settings::GitPanelSettings, CommitAllChanges, CommitStagedChanges, GitState,
-    RevertAll, StageAll, UnstageAll,
+    git_status_icon, settings::GitPanelSettings, CommitAllChanges, CommitChanges, GitState,
+    GitViewMode, RevertAll, StageAll, ToggleStaged, UnstageAll,
 };
 use anyhow::{Context as _, Result};
 use db::kvp::KEY_VALUE_STORE;
 use editor::Editor;
-use git::{
-    diff::DiffHunk,
-    repository::{GitFileStatus, RepoPath},
-};
+use git::repository::{GitFileStatus, RepoPath};
+use git::status::GitStatusPair;
 use gpui::*;
 use language::Buffer;
-use menu::{SelectNext, SelectPrev};
-use project::{EntryKind, Fs, Project, ProjectEntryId, WorktreeId};
+use menu::{SelectFirst, SelectLast, SelectNext, SelectPrev};
+use project::{Fs, Project};
 use serde::{Deserialize, Serialize};
 use settings::Settings as _;
-use std::{
-    cell::OnceCell,
-    collections::HashSet,
-    ffi::OsStr,
-    ops::{Deref, Range},
-    path::PathBuf,
-    rc::Rc,
-    sync::Arc,
-    time::Duration,
-    usize,
-};
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::{collections::HashSet, ops::Range, path::PathBuf, sync::Arc, time::Duration, usize};
 use theme::ThemeSettings;
 use ui::{
     prelude::*, Checkbox, Divider, DividerColor, ElevationIndex, Scrollbar, ScrollbarState, Tooltip,
@@ -35,9 +25,18 @@ use workspace::{
     dock::{DockPosition, Panel, PanelEvent},
     Workspace,
 };
-use worktree::StatusEntry;
 
-actions!(git_panel, [ToggleFocus, OpenEntryMenu]);
+actions!(
+    git_panel,
+    [
+        Close,
+        ToggleFocus,
+        OpenMenu,
+        OpenSelected,
+        FocusEditor,
+        FocusChanges
+    ]
+);
 
 const GIT_PANEL_KEY: &str = "GitPanel";
 
@@ -59,35 +58,21 @@ pub enum Event {
     Focus,
 }
 
-#[derive(Default, Debug, PartialEq, Eq, Clone)]
-pub enum ViewMode {
-    #[default]
-    List,
-    Tree,
+#[derive(Serialize, Deserialize)]
+struct SerializedGitPanel {
+    width: Option<Pixels>,
 }
 
-pub struct GitStatusEntry {}
-
 #[derive(Debug, PartialEq, Eq, Clone)]
-struct EntryDetails {
-    filename: String,
-    display_name: String,
-    path: RepoPath,
-    kind: EntryKind,
+pub struct GitListEntry {
     depth: usize,
-    is_expanded: bool,
-    status: Option<GitFileStatus>,
-    hunks: Rc<OnceCell<Vec<DiffHunk>>>,
-    index: usize,
-}
-
-#[derive(Serialize, Deserialize)]
-struct SerializedGitPanel {
-    width: Option<Pixels>,
+    display_name: String,
+    repo_path: RepoPath,
+    status: GitStatusPair,
+    toggle_state: ToggleState,
 }
 
 pub struct GitPanel {
-    // workspace: WeakView<Workspace>,
     current_modifiers: Modifiers,
     focus_handle: FocusHandle,
     fs: Arc<dyn Fs>,
@@ -96,53 +81,25 @@ pub struct GitPanel {
     project: Model<Project>,
     scroll_handle: UniformListScrollHandle,
     scrollbar_state: ScrollbarState,
-    selected_item: Option<usize>,
-    view_mode: ViewMode,
+    selected_entry: Option<usize>,
     show_scrollbar: bool,
-    // TODO Reintroduce expanded directories, once we're deriving directories from paths
-    // expanded_dir_ids: HashMap<WorktreeId, Vec<ProjectEntryId>>,
+    rebuild_requested: Arc<AtomicBool>,
     git_state: Model<GitState>,
     commit_editor: View<Editor>,
-    // The entries that are currently shown in the panel, aka
-    // not hidden by folding or such
-    visible_entries: Vec<WorktreeEntries>,
+    /// The visible entries in the list, accounting for folding & expanded state.
+    ///
+    /// At this point it doesn't matter what repository the entry belongs to,
+    /// as only one repositories' entries are visible in the list at a time.
+    visible_entries: Vec<GitListEntry>,
     width: Option<Pixels>,
-    // git_diff_editor: Option<View<Editor>>,
-    // git_diff_editor_updates: Task<()>,
     reveal_in_editor: Task<()>,
 }
 
-#[derive(Debug, Clone)]
-struct WorktreeEntries {
-    worktree_id: WorktreeId,
-    // TODO support multiple repositories per worktree
-    // work_directory: worktree::WorkDirectory,
-    visible_entries: Vec<GitPanelEntry>,
-    paths: Rc<OnceCell<HashSet<RepoPath>>>,
-}
-
-#[derive(Debug, Clone)]
-struct GitPanelEntry {
-    entry: worktree::StatusEntry,
-    hunks: Rc<OnceCell<Vec<DiffHunk>>>,
-}
-
-impl Deref for GitPanelEntry {
-    type Target = worktree::StatusEntry;
-
-    fn deref(&self) -> &Self::Target {
-        &self.entry
-    }
-}
-
-impl WorktreeEntries {
-    fn paths(&self) -> &HashSet<RepoPath> {
-        self.paths.get_or_init(|| {
-            self.visible_entries
-                .iter()
-                .map(|e| (e.entry.repo_path.clone()))
-                .collect()
-        })
+fn status_to_toggle_state(status: &GitStatusPair) -> ToggleState {
+    match status.is_staged() {
+        Some(true) => ToggleState::Selected,
+        Some(false) => ToggleState::Unselected,
+        None => ToggleState::Indeterminate,
     }
 }
 
@@ -155,12 +112,14 @@ impl GitPanel {
     }
 
     pub fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> View<Self> {
-        let git_state = GitState::get_global(cx);
-
         let fs = workspace.app_state().fs.clone();
-        // let weak_workspace = workspace.weak_handle();
         let project = workspace.project().clone();
         let language_registry = workspace.app_state().languages.clone();
+        let git_state = GitState::get_global(cx);
+        let current_commit_message = {
+            let state = git_state.read(cx);
+            state.commit_message.clone()
+        };
 
         let git_panel = cx.new_view(|cx: &mut ViewContext<Self>| {
             let focus_handle = cx.focus_handle();
@@ -169,36 +128,103 @@ impl GitPanel {
                 this.hide_scrollbar(cx);
             })
             .detach();
-            cx.subscribe(&project, |this, _, event, cx| match event {
-                project::Event::WorktreeRemoved(_id) => {
-                    // this.expanded_dir_ids.remove(id);
-                    this.update_visible_entries(None, None, cx);
-                    cx.notify();
-                }
-                project::Event::WorktreeOrderChanged => {
-                    this.update_visible_entries(None, None, cx);
-                    cx.notify();
-                }
-                project::Event::WorktreeUpdatedEntries(id, _)
-                | project::Event::WorktreeAdded(id)
-                | project::Event::WorktreeUpdatedGitRepositories(id) => {
-                    this.update_visible_entries(Some(*id), None, cx);
-                    cx.notify();
-                }
-                project::Event::Closed => {
-                    // this.git_diff_editor_updates = Task::ready(());
-                    this.reveal_in_editor = Task::ready(());
-                    // this.expanded_dir_ids.clear();
-                    this.visible_entries.clear();
-                    // this.git_diff_editor = None;
-                }
-                _ => {}
+            cx.subscribe(&project, move |this, project, event, cx| {
+                use project::Event;
+
+                let first_worktree_id = project.read(cx).worktrees(cx).next().map(|worktree| {
+                    let snapshot = worktree.read(cx).snapshot();
+                    snapshot.id()
+                });
+                let first_repo_in_project = first_repository_in_project(&project, cx);
+
+                // TODO: Don't get another git_state here
+                // was running into a borrow issue
+                let git_state = GitState::get_global(cx);
+
+                match event {
+                    project::Event::WorktreeRemoved(id) => {
+                        git_state.update(cx, |state, _| {
+                            state.all_repositories.remove(id);
+                            let Some((worktree_id, _, _)) = state.active_repository.as_ref() else {
+                                return;
+                            };
+                            if worktree_id == id {
+                                state.active_repository = first_repo_in_project;
+                                this.schedule_update();
+                            }
+                        });
+                    }
+                    project::Event::WorktreeOrderChanged => {
+                        // activate the new first worktree if the first was moved
+                        let Some(first_id) = first_worktree_id else {
+                            return;
+                        };
+                        git_state.update(cx, |state, _| {
+                            if !state
+                                .active_repository
+                                .as_ref()
+                                .is_some_and(|(id, _, _)| id == &first_id)
+                            {
+                                state.active_repository = first_repo_in_project;
+                                this.schedule_update();
+                            }
+                        });
+                    }
+                    Event::WorktreeAdded(id) => {
+                        git_state.update(cx, |state, cx| {
+                            let Some(worktree) = project.read(cx).worktree_for_id(*id, cx) else {
+                                return;
+                            };
+                            let snapshot = worktree.read(cx).snapshot();
+                            state
+                                .all_repositories
+                                .insert(*id, snapshot.repositories().clone());
+                        });
+                        let Some(first_id) = first_worktree_id else {
+                            return;
+                        };
+                        git_state.update(cx, |state, _| {
+                            if !state
+                                .active_repository
+                                .as_ref()
+                                .is_some_and(|(id, _, _)| id == &first_id)
+                            {
+                                state.active_repository = first_repo_in_project;
+                                this.schedule_update();
+                            }
+                        });
+                    }
+                    project::Event::WorktreeUpdatedEntries(id, _) => {
+                        git_state.update(cx, |state, _| {
+                            if state
+                                .active_repository
+                                .as_ref()
+                                .is_some_and(|(active_id, _, _)| active_id == id)
+                            {
+                                state.active_repository = first_repo_in_project;
+                                this.schedule_update();
+                            }
+                        });
+                    }
+                    project::Event::WorktreeUpdatedGitRepositories(_) => {
+                        let Some(first) = first_repo_in_project else {
+                            return;
+                        };
+                        git_state.update(cx, |state, _| {
+                            state.active_repository = Some(first);
+                            this.schedule_update();
+                        });
+                    }
+                    project::Event::Closed => {
+                        this.reveal_in_editor = Task::ready(());
+                        this.visible_entries.clear();
+                        // TODO cancel/clear task?
+                    }
+                    _ => {}
+                };
             })
             .detach();
 
-            let state = git_state.read(cx);
-            let current_commit_message = state.commit_message.clone();
-
             let commit_editor = cx.new_view(|cx| {
                 let theme = ThemeSettings::get_global(cx);
 
@@ -220,7 +246,6 @@ impl GitPanel {
                 } else {
                     commit_editor.set_text("", cx);
                 }
-                // commit_editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
                 commit_editor.set_use_autoclose(false);
                 commit_editor.set_show_gutter(false, cx);
                 commit_editor.set_show_wrap_guides(false, cx);
@@ -250,29 +275,59 @@ impl GitPanel {
 
             let scroll_handle = UniformListScrollHandle::new();
 
+            git_state.update(cx, |state, cx| {
+                let mut visible_worktrees = project.read(cx).visible_worktrees(cx);
+                let Some(first_worktree) = visible_worktrees.next() else {
+                    return;
+                };
+                drop(visible_worktrees);
+                let snapshot = first_worktree.read(cx).snapshot();
+
+                if let Some((repo, git_repo)) =
+                    first_worktree_repository(&project, snapshot.id(), cx)
+                {
+                    state.activate_repository(snapshot.id(), repo, git_repo);
+                }
+            });
+
+            let rebuild_requested = Arc::new(AtomicBool::new(false));
+            let flag = rebuild_requested.clone();
+            let handle = cx.view().downgrade();
+            cx.spawn(|_, mut cx| async move {
+                loop {
+                    cx.background_executor().timer(UPDATE_DEBOUNCE).await;
+                    if flag.load(Ordering::Relaxed) {
+                        if let Some(this) = handle.upgrade() {
+                            this.update(&mut cx, |this, cx| {
+                                this.update_visible_entries(cx);
+                            })
+                            .ok();
+                        }
+                        flag.store(false, Ordering::Relaxed);
+                    }
+                }
+            })
+            .detach();
+
             let mut git_panel = Self {
-                // workspace: weak_workspace,
                 focus_handle: cx.focus_handle(),
                 fs,
                 pending_serialization: Task::ready(None),
                 visible_entries: Vec::new(),
                 current_modifiers: cx.modifiers(),
-                // expanded_dir_ids: Default::default(),
                 width: Some(px(360.)),
                 scrollbar_state: ScrollbarState::new(scroll_handle.clone()).parent_view(cx.view()),
                 scroll_handle,
-                selected_item: None,
-                view_mode: ViewMode::default(),
+                selected_entry: None,
                 show_scrollbar: !Self::should_autohide_scrollbar(cx),
                 hide_scrollbar_task: None,
-                // git_diff_editor: Some(diff_display_editor(cx)),
-                // git_diff_editor_updates: Task::ready(()),
+                rebuild_requested,
                 commit_editor,
                 git_state,
                 reveal_in_editor: Task::ready(()),
                 project,
             };
-            git_panel.update_visible_entries(None, None, cx);
+            git_panel.schedule_update();
             git_panel
         });
 
@@ -280,6 +335,7 @@ impl GitPanel {
     }
 
     fn serialize(&mut self, cx: &mut ViewContext<Self>) {
+        // TODO: we can store stage status here
         let width = self.width;
         self.pending_serialization = cx.background_executor().spawn(
             async move {
@@ -295,14 +351,31 @@ impl GitPanel {
         );
     }
 
-    fn dispatch_context(&self) -> KeyContext {
+    fn dispatch_context(&self, cx: &ViewContext<Self>) -> KeyContext {
         let mut dispatch_context = KeyContext::new_with_defaults();
         dispatch_context.add("GitPanel");
-        dispatch_context.add("menu");
+
+        if self.is_focused(cx) {
+            dispatch_context.add("menu");
+            dispatch_context.add("ChangesList");
+        }
+
+        if self.commit_editor.read(cx).is_focused(cx) {
+            dispatch_context.add("CommitEditor");
+        }
 
         dispatch_context
     }
 
+    fn is_focused(&self, cx: &ViewContext<Self>) -> bool {
+        cx.focused()
+            .map_or(false, |focused| self.focus_handle == focused)
+    }
+
+    fn close_panel(&mut self, _: &Close, cx: &mut ViewContext<Self>) {
+        cx.emit(PanelEvent::Close);
+    }
+
     fn focus_in(&mut self, cx: &mut ViewContext<Self>) {
         if !self.focus_handle.contains_focused(cx) {
             cx.emit(Event::Focus);
@@ -347,119 +420,195 @@ impl GitPanel {
     }
 
     fn calculate_depth_and_difference(
-        entry: &StatusEntry,
-        visible_worktree_entries: &HashSet<RepoPath>,
+        repo_path: &RepoPath,
+        visible_entries: &HashSet<RepoPath>,
     ) -> (usize, usize) {
-        let (depth, difference) = entry
-            .repo_path
-            .ancestors()
-            .skip(1) // Skip the entry itself
-            .find_map(|ancestor| {
-                if let Some(parent_entry) = visible_worktree_entries.get(ancestor) {
-                    let entry_path_components_count = entry.repo_path.components().count();
-                    let parent_path_components_count = parent_entry.components().count();
-                    let difference = entry_path_components_count - parent_path_components_count;
-                    let depth = parent_entry
-                        .ancestors()
-                        .skip(1)
-                        .filter(|ancestor| visible_worktree_entries.contains(*ancestor))
-                        .count();
-                    Some((depth + 1, difference))
-                } else {
-                    None
-                }
-            })
-            .unwrap_or((0, 0));
+        let ancestors = repo_path.ancestors().skip(1);
+        for ancestor in ancestors {
+            if let Some(parent_entry) = visible_entries.get(ancestor) {
+                let entry_component_count = repo_path.components().count();
+                let parent_component_count = parent_entry.components().count();
+
+                let difference = entry_component_count - parent_component_count;
+
+                let parent_depth = parent_entry
+                    .ancestors()
+                    .skip(1) // Skip the parent itself
+                    .filter(|ancestor| visible_entries.contains(*ancestor))
+                    .count();
 
-        (depth, difference)
+                return (parent_depth + 1, difference);
+            }
+        }
+
+        (0, 0)
     }
 
-    fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
-        let item_count = self
-            .visible_entries
-            .iter()
-            .map(|worktree_entries| worktree_entries.visible_entries.len())
-            .sum::<usize>();
+    fn scroll_to_selected_entry(&mut self, cx: &mut ViewContext<Self>) {
+        if let Some(selected_entry) = self.selected_entry {
+            self.scroll_handle
+                .scroll_to_item(selected_entry, ScrollStrategy::Center);
+        }
+
+        cx.notify();
+    }
+
+    fn select_first(&mut self, _: &SelectFirst, cx: &mut ViewContext<Self>) {
+        if self.visible_entries.first().is_some() {
+            self.selected_entry = Some(0);
+            self.scroll_to_selected_entry(cx);
+        }
+    }
+
+    fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
+        let item_count = self.visible_entries.len();
         if item_count == 0 {
             return;
         }
-        let selection = match self.selected_item {
-            Some(i) => {
-                if i < item_count - 1 {
-                    self.selected_item = Some(i + 1);
-                    i + 1
-                } else {
-                    self.selected_item = Some(0);
-                    0
-                }
-            }
-            None => {
-                self.selected_item = Some(0);
-                0
-            }
-        };
-        self.scroll_handle
-            .scroll_to_item(selection, ScrollStrategy::Center);
 
-        let mut hunks = None;
-        self.for_each_visible_entry(selection..selection + 1, cx, |_, entry, _| {
-            hunks = Some(entry.hunks.clone());
-        });
-        if let Some(hunks) = hunks {
-            self.reveal_entry_in_git_editor(hunks, false, Some(UPDATE_DEBOUNCE), cx);
+        if let Some(selected_entry) = self.selected_entry {
+            let new_selected_entry = if selected_entry > 0 {
+                selected_entry - 1
+            } else {
+                self.selected_entry = Some(item_count - 1);
+                item_count - 1
+            };
+
+            self.selected_entry = Some(new_selected_entry);
+
+            self.scroll_to_selected_entry(cx);
         }
 
         cx.notify();
     }
 
-    fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
-        let item_count = self
-            .visible_entries
-            .iter()
-            .map(|worktree_entries| worktree_entries.visible_entries.len())
-            .sum::<usize>();
+    fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
+        let item_count = self.visible_entries.len();
         if item_count == 0 {
             return;
         }
-        let selection = match self.selected_item {
-            Some(i) => {
-                if i > 0 {
-                    self.selected_item = Some(i - 1);
-                    i - 1
-                } else {
-                    self.selected_item = Some(item_count - 1);
-                    item_count - 1
-                }
-            }
-            None => {
-                self.selected_item = Some(0);
-                0
-            }
-        };
-        self.scroll_handle
-            .scroll_to_item(selection, ScrollStrategy::Center);
 
-        let mut hunks = None;
-        self.for_each_visible_entry(selection..selection + 1, cx, |_, entry, _| {
-            hunks = Some(entry.hunks.clone());
+        if let Some(selected_entry) = self.selected_entry {
+            let new_selected_entry = if selected_entry < item_count - 1 {
+                selected_entry + 1
+            } else {
+                selected_entry
+            };
+
+            self.selected_entry = Some(new_selected_entry);
+
+            self.scroll_to_selected_entry(cx);
+        }
+
+        cx.notify();
+    }
+
+    fn select_last(&mut self, _: &SelectLast, cx: &mut ViewContext<Self>) {
+        if self.visible_entries.last().is_some() {
+            self.selected_entry = Some(self.visible_entries.len() - 1);
+            self.scroll_to_selected_entry(cx);
+        }
+    }
+
+    fn focus_editor(&mut self, _: &FocusEditor, cx: &mut ViewContext<Self>) {
+        self.commit_editor.update(cx, |editor, cx| {
+            editor.focus(cx);
         });
-        if let Some(hunks) = hunks {
-            self.reveal_entry_in_git_editor(hunks, false, Some(UPDATE_DEBOUNCE), cx);
+        cx.notify();
+    }
+
+    fn select_first_entry(&mut self, cx: &mut ViewContext<Self>) {
+        if !self.no_entries() && self.selected_entry.is_none() {
+            self.selected_entry = Some(0);
+            self.scroll_to_selected_entry(cx);
+            cx.notify();
         }
+    }
+
+    fn focus_changes_list(&mut self, _: &FocusChanges, cx: &mut ViewContext<Self>) {
+        self.select_first_entry(cx);
 
+        cx.focus_self();
         cx.notify();
     }
-}
 
-impl GitPanel {
-    fn stage_all(&mut self, _: &StageAll, _cx: &mut ViewContext<Self>) {
-        // TODO: Implement stage all
-        println!("Stage all triggered");
+    fn get_selected_entry(&self) -> Option<&GitListEntry> {
+        self.selected_entry
+            .and_then(|i| self.visible_entries.get(i))
+    }
+
+    fn toggle_staged_for_entry(&self, entry: &GitListEntry, cx: &mut ViewContext<Self>) {
+        self.git_state
+            .clone()
+            .update(cx, |state, _| match entry.status.is_staged() {
+                Some(true) | None => state.unstage_entry(entry.repo_path.clone()),
+                Some(false) => state.stage_entry(entry.repo_path.clone()),
+            });
+        cx.notify();
+    }
+
+    fn toggle_staged_for_selected(&mut self, _: &ToggleStaged, cx: &mut ViewContext<Self>) {
+        if let Some(selected_entry) = self.get_selected_entry() {
+            self.toggle_staged_for_entry(&selected_entry, cx);
+        }
+    }
+
+    fn open_selected(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
+        println!("Open Selected triggered!");
+        let selected_entry = self.selected_entry;
+
+        if let Some(entry) = selected_entry.and_then(|i| self.visible_entries.get(i)) {
+            self.open_entry(entry);
+
+            cx.notify();
+        }
+    }
+
+    fn open_entry(&self, entry: &GitListEntry) {
+        // TODO: Open entry or entry's changes.
+        println!("Open {} triggered!", entry.repo_path);
+
+        // cx.emit(project_panel::Event::OpenedEntry {
+        //     entry_id,
+        //     focus_opened_item,
+        //     allow_preview,
+        // });
+        //
+        // workspace
+        // .open_path_preview(
+        //     ProjectPath {
+        //         worktree_id,
+        //         path: file_path.clone(),
+        //     },
+        //     None,
+        //     focus_opened_item,
+        //     allow_preview,
+        //     cx,
+        // )
+        // .detach_and_prompt_err("Failed to open file", cx, move |e, _| {
+        //     match e.error_code() {
+        //         ErrorCode::Disconnected => if is_via_ssh {
+        //             Some("Disconnected from SSH host".to_string())
+        //         } else {
+        //             Some("Disconnected from remote project".to_string())
+        //         },
+        //         ErrorCode::UnsharedItem => Some(format!(
+        //             "{} is not shared by the host. This could be because it has been marked as `private`",
+        //             file_path.display()
+        //         )),
+        //         _ => None,
+        //     }
+        // });
     }
 
-    fn unstage_all(&mut self, _: &UnstageAll, _cx: &mut ViewContext<Self>) {
-        // TODO: Implement unstage all
-        println!("Unstage all triggered");
+    fn stage_all(&mut self, _: &StageAll, cx: &mut ViewContext<Self>) {
+        self.git_state.update(cx, |state, _| state.stage_all());
+    }
+
+    fn unstage_all(&mut self, _: &UnstageAll, cx: &mut ViewContext<Self>) {
+        self.git_state.update(cx, |state, _| {
+            state.unstage_all();
+        });
     }
 
     fn discard_all(&mut self, _: &RevertAll, _cx: &mut ViewContext<Self>) {
@@ -468,14 +617,14 @@ impl GitPanel {
     }
 
     fn clear_message(&mut self, cx: &mut ViewContext<Self>) {
-        let git_state = self.git_state.clone();
-        git_state.update(cx, |state, _cx| state.clear_message());
+        self.git_state
+            .update(cx, |state, _cx| state.clear_commit_message());
         self.commit_editor
             .update(cx, |editor, cx| editor.set_text("", cx));
     }
 
     /// Commit all staged changes
-    fn commit_staged_changes(&mut self, _: &CommitStagedChanges, cx: &mut ViewContext<Self>) {
+    fn commit_changes(&mut self, _: &CommitChanges, cx: &mut ViewContext<Self>) {
         self.clear_message(cx);
 
         // TODO: Implement commit all staged
@@ -500,345 +649,100 @@ impl GitPanel {
     }
 
     fn entry_count(&self) -> usize {
-        self.visible_entries
-            .iter()
-            .map(|worktree_entries| worktree_entries.visible_entries.len())
-            .sum()
+        self.visible_entries.len()
     }
 
     fn for_each_visible_entry(
         &self,
         range: Range<usize>,
         cx: &mut ViewContext<Self>,
-        mut callback: impl FnMut(usize, EntryDetails, &mut ViewContext<Self>),
+        mut callback: impl FnMut(usize, GitListEntry, &mut ViewContext<Self>),
     ) {
-        let mut ix = 0;
-        for worktree_entries in &self.visible_entries {
-            if ix >= range.end {
-                return;
-            }
+        let visible_entries = &self.visible_entries;
 
-            if ix + worktree_entries.visible_entries.len() <= range.start {
-                ix += worktree_entries.visible_entries.len();
-                continue;
-            }
+        for (ix, entry) in visible_entries
+            .iter()
+            .enumerate()
+            .skip(range.start)
+            .take(range.end - range.start)
+        {
+            let status = entry.status.clone();
+            let filename = entry
+                .repo_path
+                .file_name()
+                .map(|name| name.to_string_lossy().into_owned())
+                .unwrap_or_else(|| entry.repo_path.to_string_lossy().into_owned());
+
+            let details = GitListEntry {
+                repo_path: entry.repo_path.clone(),
+                status,
+                depth: 0,
+                display_name: filename,
+                toggle_state: entry.toggle_state,
+            };
 
-            let end_ix = range.end.min(ix + worktree_entries.visible_entries.len());
-            // let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
-            if let Some(worktree) = self
-                .project
-                .read(cx)
-                .worktree_for_id(worktree_entries.worktree_id, cx)
-            {
-                let snapshot = worktree.read(cx).snapshot();
-                let root_name = OsStr::new(snapshot.root_name());
-                // let expanded_entry_ids = self
-                //     .expanded_dir_ids
-                //     .get(&snapshot.id())
-                //     .map(Vec::as_slice)
-                //     .unwrap_or(&[]);
-
-                let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
-                let entries = worktree_entries.paths();
-
-                let index_start = entry_range.start;
-                for (i, entry) in worktree_entries.visible_entries[entry_range]
-                    .iter()
-                    .enumerate()
-                {
-                    let index = index_start + i;
-                    let status = entry.status;
-                    let is_expanded = true; //expanded_entry_ids.binary_search(&entry.id).is_ok();
-
-                    let (depth, difference) = Self::calculate_depth_and_difference(entry, entries);
-
-                    let filename = match difference {
-                        diff if diff > 1 => entry
-                            .repo_path
-                            .iter()
-                            .skip(entry.repo_path.components().count() - diff)
-                            .collect::<PathBuf>()
-                            .to_str()
-                            .unwrap_or_default()
-                            .to_string(),
-                        _ => entry
-                            .repo_path
-                            .file_name()
-                            .map(|name| name.to_string_lossy().into_owned())
-                            .unwrap_or_else(|| root_name.to_string_lossy().to_string()),
-                    };
-
-                    let details = EntryDetails {
-                        filename,
-                        display_name: entry.repo_path.to_string_lossy().into_owned(),
-                        // TODO get it from StatusEntry?
-                        kind: EntryKind::File,
-                        is_expanded,
-                        path: entry.repo_path.clone(),
-                        status: Some(status),
-                        hunks: entry.hunks.clone(),
-                        depth,
-                        index,
-                    };
-                    callback(ix, details, cx);
-                }
-            }
-            ix = end_ix;
+            callback(ix, details, cx);
         }
     }
 
-    // TODO: Update expanded directory state
-    // TODO: Updates happen in the main loop, could be long for large workspaces
+    fn schedule_update(&mut self) {
+        self.rebuild_requested.store(true, Ordering::Relaxed);
+    }
+
     #[track_caller]
-    fn update_visible_entries(
-        &mut self,
-        for_worktree: Option<WorktreeId>,
-        _new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
-        cx: &mut ViewContext<Self>,
-    ) {
-        let project = self.project.read(cx);
-        let mut old_entries_removed = false;
-        let mut after_update = Vec::new();
-        self.visible_entries
-            .retain(|worktree_entries| match for_worktree {
-                Some(for_worktree) => {
-                    if worktree_entries.worktree_id == for_worktree {
-                        old_entries_removed = true;
-                        false
-                    } else if old_entries_removed {
-                        after_update.push(worktree_entries.clone());
-                        false
-                    } else {
-                        true
-                    }
-                }
-                None => false,
-            });
-        for worktree in project.visible_worktrees(cx) {
-            let snapshot = worktree.read(cx).snapshot();
-            let worktree_id = snapshot.id();
+    fn update_visible_entries(&mut self, cx: &mut ViewContext<Self>) {
+        let git_state = self.git_state.read(cx);
 
-            if for_worktree.is_some() && for_worktree != Some(worktree_id) {
-                continue;
-            }
+        self.visible_entries.clear();
 
-            let mut visible_worktree_entries = Vec::new();
-            // Only use the first repository for now
-            let repositories = snapshot.repositories().take(1);
-            // let mut work_directory = None;
-            for repository in repositories {
-                visible_worktree_entries.extend(repository.status());
-                // work_directory = Some(worktree::WorkDirectory::clone(repository));
-            }
+        let Some((_, repo, _)) = git_state.active_repository().as_ref() else {
+            // Just clear entries if no repository is active.
+            cx.notify();
+            return;
+        };
 
-            // TODO use the GitTraversal
-            // let mut visible_worktree_entries = snapshot
-            //     .entries(false, 0)
-            //     .filter(|entry| !entry.is_external)
-            //     .filter(|entry| entry.git_status.is_some())
-            //     .cloned()
-            //     .collect::<Vec<_>>();
-            // snapshot.propagate_git_statuses(&mut visible_worktree_entries);
-            // project::sort_worktree_entries(&mut visible_worktree_entries);
-
-            if !visible_worktree_entries.is_empty() {
-                self.visible_entries.push(WorktreeEntries {
-                    worktree_id,
-                    // work_directory: work_directory.unwrap(),
-                    visible_entries: visible_worktree_entries
-                        .into_iter()
-                        .map(|entry| GitPanelEntry {
-                            entry,
-                            hunks: Rc::default(),
-                        })
-                        .collect(),
-                    paths: Rc::default(),
-                });
-            }
+        // 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
+        for entry in repo.status() {
+            let (depth, difference) =
+                Self::calculate_depth_and_difference(&entry.repo_path, &path_set);
+            let toggle_state = status_to_toggle_state(&entry.status);
+
+            let display_name = if difference > 1 {
+                // Show partial path for deeply nested files
+                entry
+                    .repo_path
+                    .as_ref()
+                    .iter()
+                    .skip(entry.repo_path.components().count() - difference)
+                    .collect::<PathBuf>()
+                    .to_string_lossy()
+                    .into_owned()
+            } else {
+                // Just show filename
+                entry
+                    .repo_path
+                    .file_name()
+                    .map(|name| name.to_string_lossy().into_owned())
+                    .unwrap_or_default()
+            };
+
+            let entry = GitListEntry {
+                depth,
+                display_name,
+                repo_path: entry.repo_path,
+                status: entry.status,
+                toggle_state,
+            };
+
+            self.visible_entries.push(entry);
         }
-        self.visible_entries.extend(after_update);
-
-        // TODO re-implement this
-        // if let Some((worktree_id, entry_id)) = new_selected_entry {
-        //     self.selected_item = self.visible_entries.iter().enumerate().find_map(
-        //         |(worktree_index, worktree_entries)| {
-        //             if worktree_entries.worktree_id == worktree_id {
-        //                 worktree_entries
-        //                     .visible_entries
-        //                     .iter()
-        //                     .position(|entry| entry.id == entry_id)
-        //                     .map(|entry_index| {
-        //                         worktree_index * worktree_entries.visible_entries.len()
-        //                             + entry_index
-        //                     })
-        //             } else {
-        //                 None
-        //             }
-        //         },
-        //     );
-        // }
-
-        // let project = self.project.downgrade();
-        // self.git_diff_editor_updates = cx.spawn(|git_panel, mut cx| async move {
-        //     cx.background_executor()
-        //         .timer(UPDATE_DEBOUNCE)
-        //         .await;
-        //     let Some(project_buffers) = git_panel
-        //         .update(&mut cx, |git_panel, cx| {
-        //             futures::future::join_all(git_panel.visible_entries.iter_mut().flat_map(
-        //                 |worktree_entries| {
-        //                     worktree_entries
-        //                         .visible_entries
-        //                         .iter()
-        //                         .filter_map(|entry| {
-        //                             let git_status = entry.status;
-        //                             let entry_hunks = entry.hunks.clone();
-        //                             let (entry_path, unstaged_changes_task) =
-        //                                 project.update(cx, |project, cx| {
-        //                                     let entry_path = ProjectPath {
-        //                                         worktree_id: worktree_entries.worktree_id,
-        //                                         path: worktree_entries.work_directory.unrelativize(&entry.repo_path)?,
-        //                                     };
-        //                                     let open_task =
-        //                                         project.open_path(entry_path.clone(), cx);
-        //                                     let unstaged_changes_task =
-        //                                         cx.spawn(|project, mut cx| async move {
-        //                                             let (_, opened_model) = open_task
-        //                                                 .await
-        //                                                 .context("opening buffer")?;
-        //                                             let buffer = opened_model
-        //                                                 .downcast::<Buffer>()
-        //                                                 .map_err(|_| {
-        //                                                     anyhow::anyhow!(
-        //                                                         "accessing buffer for entry"
-        //                                                     )
-        //                                                 })?;
-        //                                             // TODO added files have noop changes and those are not expanded properly in the multi buffer
-        //                                             let unstaged_changes = project
-        //                                                 .update(&mut cx, |project, cx| {
-        //                                                     project.open_unstaged_changes(
-        //                                                         buffer.clone(),
-        //                                                         cx,
-        //                                                     )
-        //                                                 })?
-        //                                                 .await
-        //                                                 .context("opening unstaged changes")?;
-
-        //                                             let hunks = cx.update(|cx| {
-        //                                                 entry_hunks
-        //                                                     .get_or_init(|| {
-        //                                                         match git_status {
-        //                                                             GitFileStatus::Added => {
-        //                                                                 let buffer_snapshot = buffer.read(cx).snapshot();
-        //                                                                 let entire_buffer_range =
-        //                                                                     buffer_snapshot.anchor_after(0)
-        //                                                                         ..buffer_snapshot
-        //                                                                             .anchor_before(
-        //                                                                                 buffer_snapshot.len(),
-        //                                                                             );
-        //                                                                 let entire_buffer_point_range =
-        //                                                                     entire_buffer_range
-        //                                                                         .clone()
-        //                                                                         .to_point(&buffer_snapshot);
-
-        //                                                                 vec![DiffHunk {
-        //                                                                     row_range: entire_buffer_point_range
-        //                                                                         .start
-        //                                                                         .row
-        //                                                                         ..entire_buffer_point_range
-        //                                                                             .end
-        //                                                                             .row,
-        //                                                                     buffer_range: entire_buffer_range,
-        //                                                                     diff_base_byte_range: 0..0,
-        //                                                                 }]
-        //                                                             }
-        //                                                             GitFileStatus::Modified => {
-        //                                                                     let buffer_snapshot =
-        //                                                                         buffer.read(cx).snapshot();
-        //                                                                     unstaged_changes.read(cx)
-        //                                                                         .diff_to_buffer
-        //                                                                         .hunks_in_row_range(
-        //                                                                             0..BufferRow::MAX,
-        //                                                                             &buffer_snapshot,
-        //                                                                         )
-        //                                                                         .collect()
-        //                                                             }
-        //                                                             // TODO support these
-        //                                                             GitFileStatus::Conflict | GitFileStatus::Deleted | GitFileStatus::Untracked => Vec::new(),
-        //                                                         }
-        //                                                     }).clone()
-        //                                             })?;
-
-        //                                             anyhow::Ok((buffer, unstaged_changes, hunks))
-        //                                         });
-        //                                     Some((entry_path, unstaged_changes_task))
-        //                                 }).ok()??;
-        //                             Some((entry_path, unstaged_changes_task))
-        //                         })
-        //                         .map(|(entry_path, open_task)| async move {
-        //                             (entry_path, open_task.await)
-        //                         })
-        //                         .collect::<Vec<_>>()
-        //                 },
-        //             ))
-        //         })
-        //         .ok()
-        //     else {
-        //         return;
-        //     };
-
-        //     let project_buffers = project_buffers.await;
-        //     if project_buffers.is_empty() {
-        //         return;
-        //     }
-        //     let mut change_sets = Vec::with_capacity(project_buffers.len());
-        //     if let Some(buffer_update_task) = git_panel
-        //         .update(&mut cx, |git_panel, cx| {
-        //             let editor = git_panel.git_diff_editor.clone()?;
-        //             let multi_buffer = editor.read(cx).buffer().clone();
-        //             let mut buffers_with_ranges = Vec::with_capacity(project_buffers.len());
-        //             for (buffer_path, open_result) in project_buffers {
-        //                 if let Some((buffer, unstaged_changes, diff_hunks)) = open_result
-        //                     .with_context(|| format!("opening buffer {buffer_path:?}"))
-        //                     .log_err()
-        //                 {
-        //                     change_sets.push(unstaged_changes);
-        //                     buffers_with_ranges.push((
-        //                         buffer,
-        //                         diff_hunks
-        //                             .into_iter()
-        //                             .map(|hunk| hunk.buffer_range)
-        //                             .collect(),
-        //                     ));
-        //                 }
-        //             }
-
-        //             Some(multi_buffer.update(cx, |multi_buffer, cx| {
-        //                 multi_buffer.clear(cx);
-        //                 multi_buffer.push_multiple_excerpts_with_context_lines(
-        //                     buffers_with_ranges,
-        //                     DEFAULT_MULTIBUFFER_CONTEXT,
-        //                     cx,
-        //                 )
-        //             }))
-        //         })
-        //         .ok().flatten()
-        //     {
-        //         buffer_update_task.await;
-        //         git_panel
-        //             .update(&mut cx, |git_panel, cx| {
-        //                 if let Some(diff_editor) = git_panel.git_diff_editor.as_ref() {
-        //                     diff_editor.update(cx, |editor, cx| {
-        //                         for change_set in change_sets {
-        //                             editor.add_change_set(change_set, cx);
-        //                         }
-        //                     });
-        //                 }
-        //             })
-        //             .ok();
-        //     }
-        // });
 
+        // Sort entries by path to maintain consistent order
+        self.visible_entries
+            .sort_by(|a, b| a.repo_path.cmp(&b.repo_path));
         cx.notify();
     }
 

crates/git_ui/src/git_ui.rs 🔗

@@ -1,56 +1,282 @@
 use ::settings::Settings;
-use git::repository::GitFileStatus;
-use gpui::{actions, AppContext, Context, Global, Hsla, Model};
+use collections::HashMap;
+use futures::{future::FusedFuture, select, FutureExt};
+use git::repository::{GitFileStatus, GitRepository, RepoPath};
+use gpui::{actions, AppContext, Context, Global, Hsla, Model, ModelContext};
+use project::{Project, WorktreeId};
 use settings::GitPanelSettings;
+use std::sync::mpsc;
+use std::{
+    pin::{pin, Pin},
+    sync::Arc,
+    time::Duration,
+};
+use sum_tree::SumTree;
 use ui::{Color, Icon, IconName, IntoElement, SharedString};
+use worktree::RepositoryEntry;
 
 pub mod git_panel;
 mod settings;
 
+const GIT_TASK_DEBOUNCE: Duration = Duration::from_millis(50);
+
 actions!(
-    git_ui,
+    git,
     [
+        StageFile,
+        UnstageFile,
+        ToggleStaged,
+        // Revert actions are currently in the editor crate:
+        // editor::RevertFile,
+        // editor::RevertSelectedHunks
         StageAll,
         UnstageAll,
         RevertAll,
-        CommitStagedChanges,
+        CommitChanges,
         CommitAllChanges,
-        ClearMessage
+        ClearCommitMessage
     ]
 );
 
 pub fn init(cx: &mut AppContext) {
     GitPanelSettings::register(cx);
-    let git_state = cx.new_model(|_cx| GitState::new());
+    let git_state = cx.new_model(GitState::new);
     cx.set_global(GlobalGitState(git_state));
 }
 
+#[derive(Default, Debug, PartialEq, Eq, Clone)]
+pub enum GitViewMode {
+    #[default]
+    List,
+    Tree,
+}
+
 struct GlobalGitState(Model<GitState>);
 
 impl Global for GlobalGitState {}
 
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+enum StatusAction {
+    Stage,
+    Unstage,
+}
+
 pub struct GitState {
+    /// The current commit message being composed.
     commit_message: Option<SharedString>,
+
+    /// When a git repository is selected, this is used to track which repository's changes
+    /// are currently being viewed or modified in the UI.
+    active_repository: Option<(WorktreeId, RepositoryEntry, Arc<dyn GitRepository>)>,
+
+    updater_tx: mpsc::Sender<(Arc<dyn GitRepository>, Vec<RepoPath>, StatusAction)>,
+
+    all_repositories: HashMap<WorktreeId, SumTree<RepositoryEntry>>,
+
+    list_view_mode: GitViewMode,
 }
 
 impl GitState {
-    pub fn new() -> Self {
+    pub fn new(cx: &mut ModelContext<'_, Self>) -> Self {
+        let (updater_tx, updater_rx) = mpsc::channel();
+        cx.spawn(|_, cx| async move {
+            // Long-running task to periodically update git indices based on messages from the panel.
+
+            // We read messages from the channel in batches that refer to the same repository.
+            // When we read a message whose repository is different from the current batch's repository,
+            // the batch is finished, and since we can't un-receive this last message, we save it
+            // to begin the next batch.
+            let mut leftover_message: Option<(
+                Arc<dyn GitRepository>,
+                Vec<RepoPath>,
+                StatusAction,
+            )> = None;
+            let mut git_task = None;
+            loop {
+                let mut timer = cx.background_executor().timer(GIT_TASK_DEBOUNCE).fuse();
+                let _result = {
+                    let mut task: Pin<&mut dyn FusedFuture<Output = anyhow::Result<()>>> =
+                        match git_task.as_mut() {
+                            Some(task) => pin!(task),
+                            // If no git task is running, just wait for the timeout.
+                            None => pin!(std::future::pending().fuse()),
+                        };
+                    select! {
+                        result = task => {
+                            // Task finished.
+                            git_task = None;
+                            Some(result)
+                        }
+                        _ = timer => None,
+                    }
+                };
+
+                // TODO handle failure of the git command
+
+                if git_task.is_none() {
+                    // No git task running now; let's see if we should launch a new one.
+                    let mut to_stage = Vec::new();
+                    let mut to_unstage = Vec::new();
+                    let mut current_repo = leftover_message.as_ref().map(|msg| msg.0.clone());
+                    for (git_repo, paths, action) in leftover_message
+                        .take()
+                        .into_iter()
+                        .chain(updater_rx.try_iter())
+                    {
+                        if current_repo
+                            .as_ref()
+                            .map_or(false, |repo| !Arc::ptr_eq(repo, &git_repo))
+                        {
+                            // End of a batch, save this for the next one.
+                            leftover_message = Some((git_repo.clone(), paths, action));
+                            break;
+                        } else if current_repo.is_none() {
+                            // Start of a batch.
+                            current_repo = Some(git_repo);
+                        }
+
+                        if action == StatusAction::Stage {
+                            to_stage.extend(paths);
+                        } else {
+                            to_unstage.extend(paths);
+                        }
+                    }
+
+                    // TODO handle the same path being staged and unstaged
+
+                    if to_stage.is_empty() && to_unstage.is_empty() {
+                        continue;
+                    }
+
+                    if let Some(git_repo) = current_repo {
+                        git_task = Some(
+                            cx.background_executor()
+                                .spawn(async move { git_repo.update_index(&to_stage, &to_unstage) })
+                                .fuse(),
+                        );
+                    }
+                }
+            }
+        })
+        .detach();
         GitState {
             commit_message: None,
+            active_repository: None,
+            updater_tx,
+            list_view_mode: GitViewMode::default(),
+            all_repositories: HashMap::default(),
         }
     }
 
-    pub fn set_message(&mut self, message: Option<SharedString>) {
+    pub fn get_global(cx: &mut AppContext) -> Model<GitState> {
+        cx.global::<GlobalGitState>().0.clone()
+    }
+
+    pub fn activate_repository(
+        &mut self,
+        worktree_id: WorktreeId,
+        active_repository: RepositoryEntry,
+        git_repo: Arc<dyn GitRepository>,
+    ) {
+        self.active_repository = Some((worktree_id, active_repository, git_repo));
+    }
+
+    pub fn active_repository(
+        &self,
+    ) -> Option<&(WorktreeId, RepositoryEntry, Arc<dyn GitRepository>)> {
+        self.active_repository.as_ref()
+    }
+
+    pub fn commit_message(&mut self, message: Option<SharedString>) {
         self.commit_message = message;
     }
 
-    pub fn clear_message(&mut self) {
+    pub fn clear_commit_message(&mut self) {
         self.commit_message = None;
     }
 
-    pub fn get_global(cx: &mut AppContext) -> Model<GitState> {
-        cx.global::<GlobalGitState>().0.clone()
+    pub fn stage_entry(&mut self, repo_path: RepoPath) {
+        if let Some((_, _, git_repo)) = self.active_repository.as_ref() {
+            let _ = self
+                .updater_tx
+                .send((git_repo.clone(), vec![repo_path], StatusAction::Stage));
+        }
+    }
+
+    pub fn unstage_entry(&mut self, repo_path: RepoPath) {
+        if let Some((_, _, git_repo)) = self.active_repository.as_ref() {
+            let _ =
+                self.updater_tx
+                    .send((git_repo.clone(), vec![repo_path], StatusAction::Unstage));
+        }
+    }
+
+    pub fn stage_entries(&mut self, entries: Vec<RepoPath>) {
+        if let Some((_, _, git_repo)) = self.active_repository.as_ref() {
+            let _ = self
+                .updater_tx
+                .send((git_repo.clone(), entries, StatusAction::Stage));
+        }
     }
+
+    fn act_on_all(&mut self, action: StatusAction) {
+        if let Some((_, active_repository, git_repo)) = self.active_repository.as_ref() {
+            let _ = self.updater_tx.send((
+                git_repo.clone(),
+                active_repository
+                    .status()
+                    .map(|entry| entry.repo_path)
+                    .collect(),
+                action,
+            ));
+        }
+    }
+
+    pub fn stage_all(&mut self) {
+        self.act_on_all(StatusAction::Stage);
+    }
+
+    pub fn unstage_all(&mut self) {
+        self.act_on_all(StatusAction::Unstage);
+    }
+}
+
+pub fn first_worktree_repository(
+    project: &Model<Project>,
+    worktree_id: WorktreeId,
+    cx: &mut AppContext,
+) -> Option<(RepositoryEntry, Arc<dyn GitRepository>)> {
+    project
+        .read(cx)
+        .worktree_for_id(worktree_id, cx)
+        .and_then(|worktree| {
+            let snapshot = worktree.read(cx).snapshot();
+            let repo = snapshot.repositories().iter().next()?.clone();
+            let git_repo = worktree
+                .read(cx)
+                .as_local()?
+                .get_local_repo(&repo)?
+                .repo()
+                .clone();
+            Some((repo, git_repo))
+        })
+}
+
+pub fn first_repository_in_project(
+    project: &Model<Project>,
+    cx: &mut AppContext,
+) -> Option<(WorktreeId, RepositoryEntry, Arc<dyn GitRepository>)> {
+    project.read(cx).worktrees(cx).next().and_then(|worktree| {
+        let snapshot = worktree.read(cx).snapshot();
+        let repo = snapshot.repositories().iter().next()?.clone();
+        let git_repo = worktree
+            .read(cx)
+            .as_local()?
+            .get_local_repo(&repo)?
+            .repo()
+            .clone();
+        Some((snapshot.id(), repo, git_repo))
+    })
 }
 
 const ADDED_COLOR: Hsla = Hsla {

crates/worktree/src/worktree.rs 🔗

@@ -21,6 +21,7 @@ use fuzzy::CharBag;
 use git::GitHostingProviderRegistry;
 use git::{
     repository::{GitFileStatus, GitRepository, RepoPath},
+    status::GitStatusPair,
     COOKIES, DOT_GIT, FSMONITOR_DAEMON, GITIGNORE,
 };
 use gpui::{
@@ -193,8 +194,8 @@ pub struct RepositoryEntry {
     ///     - my_sub_folder_1/project_root/changed_file_1
     ///     - my_sub_folder_2/changed_file_2
     pub(crate) statuses_by_path: SumTree<StatusEntry>,
-    pub(crate) work_directory_id: ProjectEntryId,
-    pub(crate) work_directory: WorkDirectory,
+    pub work_directory_id: ProjectEntryId,
+    pub work_directory: WorkDirectory,
     pub(crate) branch: Option<Arc<str>>,
 }
 
@@ -225,6 +226,12 @@ impl RepositoryEntry {
         self.statuses_by_path.iter().cloned()
     }
 
+    pub fn status_for_path(&self, path: &RepoPath) -> Option<StatusEntry> {
+        self.statuses_by_path
+            .get(&PathKey(path.0.clone()), &())
+            .cloned()
+    }
+
     pub fn initial_update(&self) -> proto::RepositoryEntry {
         proto::RepositoryEntry {
             work_directory_id: self.work_directory_id.to_proto(),
@@ -234,7 +241,7 @@ impl RepositoryEntry {
                 .iter()
                 .map(|entry| proto::StatusEntry {
                     repo_path: entry.repo_path.to_string_lossy().to_string(),
-                    status: git_status_to_proto(entry.status),
+                    status: status_pair_to_proto(entry.status.clone()),
                 })
                 .collect(),
             removed_statuses: Default::default(),
@@ -259,7 +266,7 @@ impl RepositoryEntry {
                             current_new_entry = new_statuses.next();
                         }
                         Ordering::Equal => {
-                            if new_entry.status != old_entry.status {
+                            if new_entry.combined_status() != old_entry.combined_status() {
                                 updated_statuses.push(new_entry.to_proto());
                             }
                             current_old_entry = old_statuses.next();
@@ -2360,7 +2367,7 @@ impl Snapshot {
             let repo_path = repo.relativize(path).unwrap();
             repo.statuses_by_path
                 .get(&PathKey(repo_path.0), &())
-                .map(|entry| entry.status)
+                .map(|entry| entry.combined_status())
         })
     }
 
@@ -2574,8 +2581,8 @@ impl Snapshot {
             .map(|repo| repo.status().collect())
     }
 
-    pub fn repositories(&self) -> impl Iterator<Item = &RepositoryEntry> {
-        self.repositories.iter()
+    pub fn repositories(&self) -> &SumTree<RepositoryEntry> {
+        &self.repositories
     }
 
     /// Get the repository whose work directory corresponds to the given path.
@@ -2609,7 +2616,7 @@ impl Snapshot {
         entries: impl 'a + Iterator<Item = &'a Entry>,
     ) -> impl 'a + Iterator<Item = (&'a Entry, Option<&'a RepositoryEntry>)> {
         let mut containing_repos = Vec::<&RepositoryEntry>::new();
-        let mut repositories = self.repositories().peekable();
+        let mut repositories = self.repositories().iter().peekable();
         entries.map(move |entry| {
             while let Some(repository) = containing_repos.last() {
                 if repository.directory_contains(&entry.path) {
@@ -3626,14 +3633,31 @@ pub type UpdatedGitRepositoriesSet = Arc<[(Arc<Path>, GitRepositoryChange)]>;
 #[derive(Clone, Debug, PartialEq, Eq)]
 pub struct StatusEntry {
     pub repo_path: RepoPath,
-    pub status: GitFileStatus,
+    pub status: GitStatusPair,
 }
 
 impl StatusEntry {
+    // TODO revisit uses of this
+    pub fn combined_status(&self) -> GitFileStatus {
+        self.status.combined()
+    }
+
+    pub fn index_status(&self) -> Option<GitFileStatus> {
+        self.status.index_status
+    }
+
+    pub fn worktree_status(&self) -> Option<GitFileStatus> {
+        self.status.worktree_status
+    }
+
+    pub fn is_staged(&self) -> Option<bool> {
+        self.status.is_staged()
+    }
+
     fn to_proto(&self) -> proto::StatusEntry {
         proto::StatusEntry {
             repo_path: self.repo_path.to_proto(),
-            status: git_status_to_proto(self.status),
+            status: status_pair_to_proto(self.status.clone()),
         }
     }
 }
@@ -3641,11 +3665,10 @@ impl StatusEntry {
 impl TryFrom<proto::StatusEntry> for StatusEntry {
     type Error = anyhow::Error;
     fn try_from(value: proto::StatusEntry) -> Result<Self, Self::Error> {
-        Ok(Self {
-            repo_path: RepoPath(Path::new(&value.repo_path).into()),
-            status: git_status_from_proto(Some(value.status))
-                .ok_or_else(|| anyhow!("Unable to parse status value {}", value.status))?,
-        })
+        let repo_path = RepoPath(Path::new(&value.repo_path).into());
+        let status = status_pair_from_proto(value.status)
+            .ok_or_else(|| anyhow!("Unable to parse status value {}", value.status))?;
+        Ok(Self { repo_path, status })
     }
 }
 
@@ -3729,7 +3752,7 @@ impl sum_tree::Item for StatusEntry {
     fn summary(&self, _: &<Self::Summary as Summary>::Context) -> Self::Summary {
         PathSummary {
             max_path: self.repo_path.0.clone(),
-            item_summary: match self.status {
+            item_summary: match self.combined_status() {
                 GitFileStatus::Added => GitStatuses {
                     added: 1,
                     ..Default::default()
@@ -4820,15 +4843,15 @@ impl BackgroundScanner {
 
                 for (repo_path, status) in &*status.entries {
                     paths.remove_repo_path(repo_path);
-                    if cursor.seek_forward(&PathTarget::Path(&repo_path), Bias::Left, &()) {
-                        if cursor.item().unwrap().status == *status {
+                    if cursor.seek_forward(&PathTarget::Path(repo_path), Bias::Left, &()) {
+                        if &cursor.item().unwrap().status == status {
                             continue;
                         }
                     }
 
                     changed_path_statuses.push(Edit::Insert(StatusEntry {
                         repo_path: repo_path.clone(),
-                        status: *status,
+                        status: status.clone(),
                     }));
                 }
 
@@ -5257,7 +5280,7 @@ impl BackgroundScanner {
             new_entries_by_path.insert_or_replace(
                 StatusEntry {
                     repo_path: repo_path.clone(),
-                    status: *status,
+                    status: status.clone(),
                 },
                 &(),
             );
@@ -5771,7 +5794,7 @@ impl<'a> GitTraversal<'a> {
         } else if entry.is_file() {
             // For a file entry, park the cursor on the corresponding status
             if statuses.seek_forward(&PathTarget::Path(repo_path.as_ref()), Bias::Left, &()) {
-                self.current_entry_status = Some(statuses.item().unwrap().status);
+                self.current_entry_status = Some(statuses.item().unwrap().combined_status());
             }
         }
     }
@@ -6136,19 +6159,23 @@ impl<'a> TryFrom<(&'a CharBag, &PathMatcher, proto::Entry)> for Entry {
     }
 }
 
-fn git_status_from_proto(git_status: Option<i32>) -> Option<GitFileStatus> {
-    git_status.and_then(|status| {
-        proto::GitStatus::from_i32(status).map(|status| match status {
-            proto::GitStatus::Added => GitFileStatus::Added,
-            proto::GitStatus::Modified => GitFileStatus::Modified,
-            proto::GitStatus::Conflict => GitFileStatus::Conflict,
-            proto::GitStatus::Deleted => GitFileStatus::Deleted,
-        })
+// TODO pass the status pair all the way through
+fn status_pair_from_proto(proto: i32) -> Option<GitStatusPair> {
+    let proto = proto::GitStatus::from_i32(proto)?;
+    let worktree_status = match proto {
+        proto::GitStatus::Added => GitFileStatus::Added,
+        proto::GitStatus::Modified => GitFileStatus::Modified,
+        proto::GitStatus::Conflict => GitFileStatus::Conflict,
+        proto::GitStatus::Deleted => GitFileStatus::Deleted,
+    };
+    Some(GitStatusPair {
+        index_status: None,
+        worktree_status: Some(worktree_status),
     })
 }
 
-fn git_status_to_proto(status: GitFileStatus) -> i32 {
-    match status {
+fn status_pair_to_proto(status: GitStatusPair) -> i32 {
+    match status.combined() {
         GitFileStatus::Added => proto::GitStatus::Added as i32,
         GitFileStatus::Modified => proto::GitStatus::Modified as i32,
         GitFileStatus::Conflict => proto::GitStatus::Conflict as i32,

crates/worktree/src/worktree_tests.rs 🔗

@@ -2179,7 +2179,7 @@ async fn test_rename_work_directory(cx: &mut TestAppContext) {
 
     cx.read(|cx| {
         let tree = tree.read(cx);
-        let repo = tree.repositories().next().unwrap();
+        let repo = tree.repositories().iter().next().unwrap();
         assert_eq!(repo.path.as_ref(), Path::new("projects/project1"));
         assert_eq!(
             tree.status_for_file(Path::new("projects/project1/a")),
@@ -2200,7 +2200,7 @@ async fn test_rename_work_directory(cx: &mut TestAppContext) {
 
     cx.read(|cx| {
         let tree = tree.read(cx);
-        let repo = tree.repositories().next().unwrap();
+        let repo = tree.repositories().iter().next().unwrap();
         assert_eq!(repo.path.as_ref(), Path::new("projects/project2"));
         assert_eq!(
             tree.status_for_file(Path::new("projects/project2/a")),
@@ -2380,8 +2380,8 @@ async fn test_file_status(cx: &mut TestAppContext) {
     // Check that the right git state is observed on startup
     tree.read_with(cx, |tree, _cx| {
         let snapshot = tree.snapshot();
-        assert_eq!(snapshot.repositories().count(), 1);
-        let repo_entry = snapshot.repositories().next().unwrap();
+        assert_eq!(snapshot.repositories().iter().count(), 1);
+        let repo_entry = snapshot.repositories().iter().next().unwrap();
         assert_eq!(repo_entry.path.as_ref(), Path::new("project"));
         assert!(repo_entry.location_in_repo.is_none());
 
@@ -2554,16 +2554,16 @@ async fn test_git_repository_status(cx: &mut TestAppContext) {
     // Check that the right git state is observed on startup
     tree.read_with(cx, |tree, _cx| {
         let snapshot = tree.snapshot();
-        let repo = snapshot.repositories().next().unwrap();
+        let repo = snapshot.repositories().iter().next().unwrap();
         let entries = repo.status().collect::<Vec<_>>();
 
         assert_eq!(entries.len(), 3);
         assert_eq!(entries[0].repo_path.as_ref(), Path::new("a.txt"));
-        assert_eq!(entries[0].status, GitFileStatus::Modified);
+        assert_eq!(entries[0].worktree_status(), Some(GitFileStatus::Modified));
         assert_eq!(entries[1].repo_path.as_ref(), Path::new("b.txt"));
-        assert_eq!(entries[1].status, GitFileStatus::Untracked);
+        assert_eq!(entries[1].worktree_status(), Some(GitFileStatus::Untracked));
         assert_eq!(entries[2].repo_path.as_ref(), Path::new("d.txt"));
-        assert_eq!(entries[2].status, GitFileStatus::Deleted);
+        assert_eq!(entries[2].worktree_status(), Some(GitFileStatus::Deleted));
     });
 
     std::fs::write(work_dir.join("c.txt"), "some changes").unwrap();
@@ -2576,19 +2576,19 @@ async fn test_git_repository_status(cx: &mut TestAppContext) {
 
     tree.read_with(cx, |tree, _cx| {
         let snapshot = tree.snapshot();
-        let repository = snapshot.repositories().next().unwrap();
+        let repository = snapshot.repositories().iter().next().unwrap();
         let entries = repository.status().collect::<Vec<_>>();
 
         std::assert_eq!(entries.len(), 4, "entries: {entries:?}");
         assert_eq!(entries[0].repo_path.as_ref(), Path::new("a.txt"));
-        assert_eq!(entries[0].status, GitFileStatus::Modified);
+        assert_eq!(entries[0].worktree_status(), Some(GitFileStatus::Modified));
         assert_eq!(entries[1].repo_path.as_ref(), Path::new("b.txt"));
-        assert_eq!(entries[1].status, GitFileStatus::Untracked);
+        assert_eq!(entries[1].worktree_status(), Some(GitFileStatus::Untracked));
         // Status updated
         assert_eq!(entries[2].repo_path.as_ref(), Path::new("c.txt"));
-        assert_eq!(entries[2].status, GitFileStatus::Modified);
+        assert_eq!(entries[2].worktree_status(), Some(GitFileStatus::Modified));
         assert_eq!(entries[3].repo_path.as_ref(), Path::new("d.txt"));
-        assert_eq!(entries[3].status, GitFileStatus::Deleted);
+        assert_eq!(entries[3].worktree_status(), Some(GitFileStatus::Deleted));
     });
 
     git_add("a.txt", &repo);
@@ -2609,7 +2609,7 @@ async fn test_git_repository_status(cx: &mut TestAppContext) {
 
     tree.read_with(cx, |tree, _cx| {
         let snapshot = tree.snapshot();
-        let repo = snapshot.repositories().next().unwrap();
+        let repo = snapshot.repositories().iter().next().unwrap();
         let entries = repo.status().collect::<Vec<_>>();
 
         // Deleting an untracked entry, b.txt, should leave no status
@@ -2621,7 +2621,7 @@ async fn test_git_repository_status(cx: &mut TestAppContext) {
             &entries
         );
         assert_eq!(entries[0].repo_path.as_ref(), Path::new("a.txt"));
-        assert_eq!(entries[0].status, GitFileStatus::Deleted);
+        assert_eq!(entries[0].worktree_status(), Some(GitFileStatus::Deleted));
     });
 }
 
@@ -2676,8 +2676,8 @@ async fn test_repository_subfolder_git_status(cx: &mut TestAppContext) {
     // Ensure that the git status is loaded correctly
     tree.read_with(cx, |tree, _cx| {
         let snapshot = tree.snapshot();
-        assert_eq!(snapshot.repositories().count(), 1);
-        let repo = snapshot.repositories().next().unwrap();
+        assert_eq!(snapshot.repositories().iter().count(), 1);
+        let repo = snapshot.repositories().iter().next().unwrap();
         // Path is blank because the working directory of
         // the git repository is located at the root of the project
         assert_eq!(repo.path.as_ref(), Path::new(""));
@@ -2707,7 +2707,7 @@ async fn test_repository_subfolder_git_status(cx: &mut TestAppContext) {
     tree.read_with(cx, |tree, _cx| {
         let snapshot = tree.snapshot();
 
-        assert!(snapshot.repositories().next().is_some());
+        assert!(snapshot.repositories().iter().next().is_some());
 
         assert_eq!(snapshot.status_for_file("c.txt"), None);
         assert_eq!(snapshot.status_for_file("d/e.txt"), None);