Added git status to the project panel, added worktree test

Mikayla Maki created

Change summary

Cargo.lock                                |   1 
crates/project/Cargo.toml                 |   1 
crates/project/src/worktree.rs            | 240 +++++++++++++++++++++++-
crates/project_panel/src/project_panel.rs |  23 ++
4 files changed, 246 insertions(+), 19 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -4717,6 +4717,7 @@ dependencies = [
  "futures 0.3.25",
  "fuzzy",
  "git",
+ "git2",
  "glob",
  "gpui",
  "ignore",

crates/project/Cargo.toml 🔗

@@ -74,5 +74,6 @@ lsp = { path = "../lsp", features = ["test-support"] }
 settings = { path = "../settings", features = ["test-support"] }
 util = { path = "../util", features = ["test-support"] }
 rpc = { path = "../rpc", features = ["test-support"] }
+git2 = { version = "0.15", default-features = false }
 tempdir.workspace = true
 unindent.workspace = true

crates/project/src/worktree.rs 🔗

@@ -120,6 +120,25 @@ pub struct Snapshot {
     completed_scan_id: usize,
 }
 
+impl Snapshot {
+    pub fn repo_for(&self, path: &Path) -> Option<RepositoryEntry> {
+        let mut max_len = 0;
+        let mut current_candidate = None;
+        for (work_directory, repo) in (&self.repository_entries).iter() {
+            if repo.contains(self, path) {
+                if work_directory.0.as_os_str().len() >= max_len {
+                    current_candidate = Some(repo);
+                    max_len = work_directory.0.as_os_str().len();
+                } else {
+                    break;
+                }
+            }
+        }
+
+        current_candidate.map(|entry| entry.to_owned())
+    }
+}
+
 #[derive(Clone, Debug, PartialEq, Eq)]
 pub struct RepositoryEntry {
     pub(crate) work_directory: WorkDirectoryEntry,
@@ -145,6 +164,13 @@ impl RepositoryEntry {
     pub(crate) fn contains(&self, snapshot: &Snapshot, path: &Path) -> bool {
         self.work_directory.contains(snapshot, path)
     }
+
+    pub fn status_for(&self, snapshot: &Snapshot, path: &Path) -> Option<GitStatus> {
+        self.work_directory
+            .relativize(snapshot, path)
+            .and_then(|repo_path| self.statuses.get(&repo_path))
+            .cloned()
+    }
 }
 
 impl From<&RepositoryEntry> for proto::RepositoryEntry {
@@ -1560,23 +1586,6 @@ impl Snapshot {
 }
 
 impl LocalSnapshot {
-    pub(crate) fn repo_for(&self, path: &Path) -> Option<RepositoryEntry> {
-        let mut max_len = 0;
-        let mut current_candidate = None;
-        for (work_directory, repo) in (&self.repository_entries).iter() {
-            if repo.contains(self, path) {
-                if work_directory.0.as_os_str().len() >= max_len {
-                    current_candidate = Some(repo);
-                    max_len = work_directory.0.as_os_str().len();
-                } else {
-                    break;
-                }
-            }
-        }
-
-        current_candidate.map(|entry| entry.to_owned())
-    }
-
     pub(crate) fn get_local_repo(&self, repo: &RepositoryEntry) -> Option<&LocalRepositoryEntry> {
         self.git_repositories.get(&repo.work_directory.0)
     }
@@ -3751,6 +3760,203 @@ mod tests {
         });
     }
 
+    #[gpui::test]
+    async fn test_git_status(cx: &mut TestAppContext) {
+        #[track_caller]
+        fn git_init(path: &Path) -> git2::Repository {
+            git2::Repository::init(path).expect("Failed to initialize git repository")
+        }
+
+        #[track_caller]
+        fn git_add(path: &Path, repo: &git2::Repository) {
+            let mut index = repo.index().expect("Failed to get index");
+            index.add_path(path).expect("Failed to add a.txt");
+            index.write().expect("Failed to write index");
+        }
+
+        #[track_caller]
+        fn git_remove_index(path: &Path, repo: &git2::Repository) {
+            let mut index = repo.index().expect("Failed to get index");
+            index.remove_path(path).expect("Failed to add a.txt");
+            index.write().expect("Failed to write index");
+        }
+
+        #[track_caller]
+        fn git_commit(msg: &'static str, repo: &git2::Repository) {
+            let signature = repo.signature().unwrap();
+            let oid = repo.index().unwrap().write_tree().unwrap();
+            let tree = repo.find_tree(oid).unwrap();
+            if let Some(head) = repo.head().ok() {
+                let parent_obj = head
+                    .peel(git2::ObjectType::Commit)
+                    .unwrap();
+
+                let parent_commit = parent_obj
+                    .as_commit()
+                    .unwrap();
+
+
+                repo.commit(
+                    Some("HEAD"),
+                    &signature,
+                    &signature,
+                    msg,
+                    &tree,
+                    &[parent_commit],
+                )
+                .expect("Failed to commit with parent");
+            } else {
+                repo.commit(
+                    Some("HEAD"),
+                    &signature,
+                    &signature,
+                    msg,
+                    &tree,
+                    &[],
+                )
+                .expect("Failed to commit");
+            }
+        }
+
+        #[track_caller]
+        fn git_stash(repo: &mut git2::Repository) {
+            let signature = repo.signature().unwrap();
+            repo.stash_save(&signature, "N/A", None)
+                .expect("Failed to stash");
+        }
+
+        #[track_caller]
+        fn git_reset(offset: usize, repo: &git2::Repository) {
+            let head = repo.head().expect("Couldn't get repo head");
+            let object = head.peel(git2::ObjectType::Commit).unwrap();
+            let commit = object.as_commit().unwrap();
+            let new_head = commit
+                .parents()
+                .inspect(|parnet| {
+                    parnet.message();
+                })
+                .skip(offset)
+                .next()
+                .expect("Not enough history");
+            repo.reset(&new_head.as_object(), git2::ResetType::Soft, None)
+                .expect("Could not reset");
+        }
+
+        #[track_caller]
+        fn git_status(repo: &git2::Repository) -> HashMap<String, git2::Status> {
+            repo.statuses(None)
+                .unwrap()
+                .iter()
+                .map(|status| {
+                    (status.path().unwrap().to_string(), status.status())
+                })
+                .collect()
+        }
+
+        let root = temp_tree(json!({
+            "project": {
+                "a.txt": "a",
+                "b.txt": "bb",
+            },
+
+        }));
+
+        let http_client = FakeHttpClient::with_404_response();
+        let client = cx.read(|cx| Client::new(http_client, cx));
+        let tree = Worktree::local(
+            client,
+            root.path(),
+            true,
+            Arc::new(RealFs),
+            Default::default(),
+            &mut cx.to_async(),
+        )
+        .await
+        .unwrap();
+
+        const A_TXT: &'static str = "a.txt";
+        const B_TXT: &'static str = "b.txt";
+        let work_dir = root.path().join("project");
+
+        let mut repo = git_init(work_dir.as_path());
+        git_add(Path::new(A_TXT), &repo);
+        git_commit("Initial commit", &repo);
+
+        std::fs::write(work_dir.join(A_TXT), "aa").unwrap();
+
+        cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
+            .await;
+        tree.flush_fs_events(cx).await;
+
+        // Check that the right git state is observed on startup
+        tree.read_with(cx, |tree, _cx| {
+            let snapshot = tree.snapshot();
+            assert_eq!(snapshot.repository_entries.iter().count(), 1);
+            let (dir, repo) = snapshot.repository_entries.iter().next().unwrap();
+            assert_eq!(dir.0.as_ref(), Path::new("project"));
+
+            assert_eq!(repo.statuses.iter().count(), 2);
+            assert_eq!(
+                repo.statuses.get(&Path::new(A_TXT).into()),
+                Some(&GitStatus::Modified)
+            );
+            assert_eq!(
+                repo.statuses.get(&Path::new(B_TXT).into()),
+                Some(&GitStatus::Added)
+            );
+        });
+
+        git_add(Path::new(A_TXT), &repo);
+        git_add(Path::new(B_TXT), &repo);
+        git_commit("Committing modified and added", &repo);
+        tree.flush_fs_events(cx).await;
+
+        // Check that repo only changes are tracked
+        tree.read_with(cx, |tree, _cx| {
+            let snapshot = tree.snapshot();
+            let (_, repo) = snapshot.repository_entries.iter().next().unwrap();
+
+            assert_eq!(repo.statuses.iter().count(), 0);
+            assert_eq!(repo.statuses.get(&Path::new(A_TXT).into()), None);
+            assert_eq!(repo.statuses.get(&Path::new(B_TXT).into()), None);
+        });
+
+        git_reset(0, &repo);
+        git_remove_index(Path::new(B_TXT), &repo);
+        git_stash(&mut repo);
+        tree.flush_fs_events(cx).await;
+
+        // Check that more complex repo changes are tracked
+        tree.read_with(cx, |tree, _cx| {
+            let snapshot = tree.snapshot();
+            let (_, repo) = snapshot.repository_entries.iter().next().unwrap();
+
+
+            dbg!(&repo.statuses);
+
+
+            assert_eq!(repo.statuses.iter().count(), 1);
+            assert_eq!(repo.statuses.get(&Path::new(A_TXT).into()), None);
+            assert_eq!(
+                repo.statuses.get(&Path::new(B_TXT).into()),
+                Some(&GitStatus::Added)
+            );
+        });
+
+        std::fs::remove_file(work_dir.join(B_TXT)).unwrap();
+        tree.flush_fs_events(cx).await;
+
+        // Check that non-repo behavior is tracked
+        tree.read_with(cx, |tree, _cx| {
+            let snapshot = tree.snapshot();
+            let (_, repo) = snapshot.repository_entries.iter().next().unwrap();
+
+            assert_eq!(repo.statuses.iter().count(), 0);
+            assert_eq!(repo.statuses.get(&Path::new(A_TXT).into()), None);
+            assert_eq!(repo.statuses.get(&Path::new(B_TXT).into()), None);
+        });
+    }
+
     #[gpui::test]
     async fn test_write_file(cx: &mut TestAppContext) {
         let dir = temp_tree(json!({

crates/project_panel/src/project_panel.rs 🔗

@@ -13,10 +13,10 @@ use gpui::{
     keymap_matcher::KeymapContext,
     platform::{CursorStyle, MouseButton, PromptLevel},
     AnyElement, AppContext, ClipboardItem, Element, Entity, ModelHandle, Task, View, ViewContext,
-    ViewHandle, WeakViewHandle,
+    ViewHandle, WeakViewHandle, color::Color,
 };
 use menu::{Confirm, SelectNext, SelectPrev};
-use project::{Entry, EntryKind, Project, ProjectEntryId, ProjectPath, Worktree, WorktreeId};
+use project::{Entry, EntryKind, Project, ProjectEntryId, ProjectPath, Worktree, WorktreeId, repository::GitStatus};
 use settings::Settings;
 use std::{
     cmp::Ordering,
@@ -86,6 +86,7 @@ pub struct EntryDetails {
     is_editing: bool,
     is_processing: bool,
     is_cut: bool,
+    git_status: Option<GitStatus>
 }
 
 actions!(
@@ -1008,6 +1009,13 @@ impl ProjectPanel {
 
                 let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
                 for entry in &visible_worktree_entries[entry_range] {
+                    let path = &entry.path;
+                    let status = snapshot.repo_for(path)
+                        .and_then(|entry| {
+                            entry.status_for(&snapshot, path)
+                        });
+
+
                     let mut details = EntryDetails {
                         filename: entry
                             .path
@@ -1028,6 +1036,7 @@ impl ProjectPanel {
                         is_cut: self
                             .clipboard_entry
                             .map_or(false, |e| e.is_cut() && e.entry_id() == entry.id),
+                        git_status: status
                     };
 
                     if let Some(edit_state) = &self.edit_state {
@@ -1069,6 +1078,15 @@ impl ProjectPanel {
         let kind = details.kind;
         let show_editor = details.is_editing && !details.is_processing;
 
+        let git_color = details.git_status.as_ref().and_then(|status| {
+            match status {
+                GitStatus::Added => Some(Color::green()),
+                GitStatus::Modified => Some(Color::blue()),
+                GitStatus::Conflict => Some(Color::red()),
+                GitStatus::Untracked => None,
+            }
+        }).unwrap_or(Color::transparent_black());
+
         Flex::row()
             .with_child(
                 if kind == EntryKind::Dir {
@@ -1107,6 +1125,7 @@ impl ProjectPanel {
             .with_height(style.height)
             .contained()
             .with_style(row_container_style)
+            .with_background_color(git_color)
             .with_padding_left(padding)
             .into_any_named("project panel entry visual element")
     }