git: Implement a basic repository selector (#23419)

Cole Miller , Nate Butler , and Nate created

This PR adds a rough-and-ready picker for selecting which of the
project's repositories the git panel should display.

Release Notes:

- N/A

---------

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

Change summary

Cargo.lock                               |  11 
Cargo.toml                               |   1 
crates/git_ui/Cargo.toml                 |   2 
crates/git_ui/src/git_panel.rs           | 367 ++++++++-----------------
crates/git_ui/src/git_ui.rs              |   1 
crates/git_ui/src/repository_selector.rs | 232 ++++++++++++++++
crates/project/src/git.rs                | 278 ++++++++++++++-----
crates/project/src/lsp_store.rs          |   7 
crates/project/src/project.rs            |  48 +-
crates/project/src/worktree_store.rs     |  25 +
crates/title_bar/Cargo.toml              |   1 
crates/title_bar/src/title_bar.rs        |  38 ++
crates/util/Cargo.toml                   |   2 
13 files changed, 659 insertions(+), 354 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -5245,6 +5245,7 @@ dependencies = [
  "git",
  "gpui",
  "menu",
+ "picker",
  "project",
  "schemars",
  "serde",
@@ -5256,7 +5257,6 @@ dependencies = [
  "util",
  "windows 0.58.0",
  "workspace",
- "worktree",
 ]
 
 [[package]]
@@ -7045,7 +7045,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34"
 dependencies = [
  "cfg-if",
- "windows-targets 0.48.5",
+ "windows-targets 0.52.6",
 ]
 
 [[package]]
@@ -9511,9 +9511,9 @@ dependencies = [
 
 [[package]]
 name = "pin-project-lite"
-version = "0.2.15"
+version = "0.2.16"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "915a1e146535de9163f3987b8944ed8cf49a18bb0056bcebcdcece385cece4ff"
+checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
 
 [[package]]
 name = "pin-utils"
@@ -13372,6 +13372,7 @@ dependencies = [
  "client",
  "collections",
  "feature_flags",
+ "git_ui",
  "gpui",
  "http_client",
  "notifications",
@@ -15264,7 +15265,7 @@ version = "0.1.9"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
 dependencies = [
- "windows-sys 0.48.0",
+ "windows-sys 0.59.0",
 ]
 
 [[package]]

Cargo.toml 🔗

@@ -489,6 +489,7 @@ strum = { version = "0.26.0", features = ["derive"] }
 subtle = "2.5.0"
 sys-locale = "0.3.1"
 sysinfo = "0.31.0"
+take-until = "0.2.0"
 tempfile = "3.9.0"
 thiserror = "1.0.29"
 tiktoken-rs = "0.6.0"

crates/git_ui/Cargo.toml 🔗

@@ -31,7 +31,7 @@ theme.workspace = true
 ui.workspace = true
 util.workspace = true
 workspace.workspace = true
-worktree.workspace = true
+picker.workspace = true
 
 [target.'cfg(windows)'.dependencies]
 windows.workspace = true

crates/git_ui/src/git_panel.rs 🔗

@@ -7,13 +7,13 @@ use editor::scroll::ScrollbarAutoHide;
 use editor::{Editor, EditorMode, EditorSettings, MultiBuffer, ShowScrollbar};
 use futures::channel::mpsc;
 use futures::StreamExt as _;
-use git::repository::{GitRepository, RepoPath};
+use git::repository::RepoPath;
 use git::status::FileStatus;
 use git::{CommitAllChanges, CommitChanges, RevertAll, StageAll, ToggleStaged, UnstageAll};
 use gpui::*;
 use menu::{SelectFirst, SelectLast, SelectNext, SelectPrev};
-use project::git::GitState;
-use project::{Fs, Project, ProjectPath, WorktreeId};
+use project::git::RepositoryHandle;
+use project::{Fs, Project, ProjectPath};
 use serde::{Deserialize, Serialize};
 use settings::Settings as _;
 use std::sync::atomic::{AtomicBool, Ordering};
@@ -22,14 +22,13 @@ use theme::ThemeSettings;
 use ui::{
     prelude::*, Checkbox, Divider, DividerColor, ElevationIndex, Scrollbar, ScrollbarState, Tooltip,
 };
-use util::{maybe, ResultExt, TryFutureExt};
+use util::{ResultExt, TryFutureExt};
 use workspace::notifications::{DetachAndPromptErr, NotificationId};
 use workspace::Toast;
 use workspace::{
     dock::{DockPosition, Panel, PanelEvent},
     Workspace,
 };
-use worktree::RepositoryEntry;
 
 actions!(
     git_panel,
@@ -80,7 +79,6 @@ pub struct GitListEntry {
 }
 
 pub struct GitPanel {
-    weak_workspace: WeakView<Workspace>,
     current_modifiers: Modifiers,
     focus_handle: FocusHandle,
     fs: Arc<dyn Fs>,
@@ -88,6 +86,7 @@ pub struct GitPanel {
     pending_serialization: Task<Option<()>>,
     workspace: WeakView<Workspace>,
     project: Model<Project>,
+    active_repository: Option<RepositoryHandle>,
     scroll_handle: UniformListScrollHandle,
     scrollbar_state: ScrollbarState,
     selected_entry: Option<usize>,
@@ -97,46 +96,46 @@ pub struct GitPanel {
     visible_entries: Vec<GitListEntry>,
     all_staged: Option<bool>,
     width: Option<Pixels>,
-    reveal_in_editor: Task<()>,
     err_sender: mpsc::Sender<anyhow::Error>,
 }
 
-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))
-        })
-}
-
-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))
-    })
+fn commit_message_editor(
+    active_repository: Option<&RepositoryHandle>,
+    cx: &mut ViewContext<'_, Editor>,
+) -> Editor {
+    let theme = ThemeSettings::get_global(cx);
+
+    let mut text_style = cx.text_style();
+    let refinement = TextStyleRefinement {
+        font_family: Some(theme.buffer_font.family.clone()),
+        font_features: Some(FontFeatures::disable_ligatures()),
+        font_size: Some(px(12.).into()),
+        color: Some(cx.theme().colors().editor_foreground),
+        background_color: Some(gpui::transparent_black()),
+        ..Default::default()
+    };
+    text_style.refine(&refinement);
+
+    let mut commit_editor = if let Some(active_repository) = active_repository.as_ref() {
+        let buffer =
+            cx.new_model(|cx| MultiBuffer::singleton(active_repository.commit_message(), cx));
+        Editor::new(
+            EditorMode::AutoHeight { max_lines: 10 },
+            buffer,
+            None,
+            false,
+            cx,
+        )
+    } else {
+        Editor::auto_height(10, cx)
+    };
+    commit_editor.set_use_autoclose(false);
+    commit_editor.set_show_gutter(false, cx);
+    commit_editor.set_show_wrap_guides(false, cx);
+    commit_editor.set_show_indent_guides(false, cx);
+    commit_editor.set_text_style_refinement(refinement);
+    commit_editor.set_placeholder_text("Enter commit message", cx);
+    commit_editor
 }
 
 impl GitPanel {
@@ -150,8 +149,8 @@ impl GitPanel {
     pub fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> View<Self> {
         let fs = workspace.app_state().fs.clone();
         let project = workspace.project().clone();
-        let weak_workspace = cx.view().downgrade();
         let git_state = project.read(cx).git_state().cloned();
+        let active_repository = project.read(cx).active_repository(cx);
         let (err_sender, mut err_receiver) = mpsc::channel(1);
         let workspace = cx.view().downgrade();
 
@@ -162,143 +161,12 @@ impl GitPanel {
                 this.hide_scrollbar(cx);
             })
             .detach();
-            cx.subscribe(&project, {
-                let git_state = git_state.clone();
-                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);
-
-                    let Some(git_state) = git_state.clone() else {
-                        return;
-                    };
-                    git_state.update(cx, |git_state, _| {
-                        match event {
-                            project::Event::WorktreeRemoved(id) => {
-                                let Some((worktree_id, _, _)) =
-                                    git_state.active_repository.as_ref()
-                                else {
-                                    return;
-                                };
-                                if worktree_id == id {
-                                    git_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;
-                                };
-                                if !git_state
-                                    .active_repository
-                                    .as_ref()
-                                    .is_some_and(|(id, _, _)| id == &first_id)
-                                {
-                                    git_state.active_repository = first_repo_in_project;
-                                    this.schedule_update();
-                                }
-                            }
-                            Event::WorktreeAdded(_) => {
-                                let Some(first_id) = first_worktree_id else {
-                                    return;
-                                };
-                                if !git_state
-                                    .active_repository
-                                    .as_ref()
-                                    .is_some_and(|(id, _, _)| id == &first_id)
-                                {
-                                    git_state.active_repository = first_repo_in_project;
-                                    this.schedule_update();
-                                }
-                            }
-                            project::Event::WorktreeUpdatedEntries(id, _) => {
-                                if git_state
-                                    .active_repository
-                                    .as_ref()
-                                    .is_some_and(|(active_id, _, _)| active_id == id)
-                                {
-                                    git_state.active_repository = first_repo_in_project;
-                                    this.schedule_update();
-                                }
-                            }
-                            project::Event::WorktreeUpdatedGitRepositories(_) => {
-                                let Some(first) = first_repo_in_project else {
-                                    return;
-                                };
-                                git_state.active_repository = Some(first);
-                                this.schedule_update();
-                            }
-                            project::Event::Closed => {
-                                this.reveal_in_editor = Task::ready(());
-                                this.visible_entries.clear();
-                            }
-                            _ => {}
-                        };
-                    });
-                }
-            })
-            .detach();
 
-            let commit_editor = cx.new_view(|cx| {
-                let theme = ThemeSettings::get_global(cx);
-
-                let mut text_style = cx.text_style();
-                let refinement = TextStyleRefinement {
-                    font_family: Some(theme.buffer_font.family.clone()),
-                    font_features: Some(FontFeatures::disable_ligatures()),
-                    font_size: Some(px(12.).into()),
-                    color: Some(cx.theme().colors().editor_foreground),
-                    background_color: Some(gpui::transparent_black()),
-                    ..Default::default()
-                };
-                text_style.refine(&refinement);
-
-                let mut commit_editor = if let Some(git_state) = git_state.as_ref() {
-                    let buffer = cx.new_model(|cx| {
-                        MultiBuffer::singleton(git_state.read(cx).commit_message.clone(), cx)
-                    });
-                    // TODO should we attach the project?
-                    Editor::new(
-                        EditorMode::AutoHeight { max_lines: 10 },
-                        buffer,
-                        None,
-                        false,
-                        cx,
-                    )
-                } else {
-                    Editor::auto_height(10, cx)
-                };
-                commit_editor.set_use_autoclose(false);
-                commit_editor.set_show_gutter(false, cx);
-                commit_editor.set_show_wrap_guides(false, cx);
-                commit_editor.set_show_indent_guides(false, cx);
-                commit_editor.set_text_style_refinement(refinement);
-                commit_editor.set_placeholder_text("Enter commit message", cx);
-                commit_editor
-            });
+            let commit_editor =
+                cx.new_view(|cx| commit_message_editor(active_repository.as_ref(), cx));
 
             let scroll_handle = UniformListScrollHandle::new();
 
-            let mut visible_worktrees = project.read(cx).visible_worktrees(cx);
-            let first_worktree = visible_worktrees.next();
-            drop(visible_worktrees);
-            if let Some(first_worktree) = first_worktree {
-                let snapshot = first_worktree.read(cx).snapshot();
-
-                if let Some(((repo, git_repo), git_state)) =
-                    first_worktree_repository(&project, snapshot.id(), cx).zip(git_state)
-                {
-                    git_state.update(cx, |git_state, _| {
-                        git_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();
@@ -309,6 +177,9 @@ impl GitPanel {
                         if let Some(this) = handle.upgrade() {
                             this.update(&mut cx, |this, cx| {
                                 this.update_visible_entries(cx);
+                                let active_repository = this.active_repository.as_ref();
+                                this.commit_editor =
+                                    cx.new_view(|cx| commit_message_editor(active_repository, cx));
                             })
                             .ok();
                         }
@@ -318,24 +189,33 @@ impl GitPanel {
             })
             .detach();
 
+            if let Some(git_state) = git_state {
+                cx.subscribe(&git_state, move |this, git_state, event, cx| match event {
+                    project::git::Event::RepositoriesUpdated => {
+                        this.active_repository = git_state.read(cx).active_repository();
+                        this.schedule_update();
+                    }
+                })
+                .detach();
+            }
+
             let mut git_panel = Self {
-                weak_workspace,
                 focus_handle: cx.focus_handle(),
-                fs,
                 pending_serialization: Task::ready(None),
                 visible_entries: Vec::new(),
                 all_staged: None,
                 current_modifiers: cx.modifiers(),
                 width: Some(px(360.)),
                 scrollbar_state: ScrollbarState::new(scroll_handle.clone()).parent_view(cx.view()),
-                scroll_handle,
                 selected_entry: None,
                 show_scrollbar: false,
                 hide_scrollbar_task: None,
+                active_repository,
+                scroll_handle,
+                fs,
                 rebuild_requested,
                 commit_editor,
                 project,
-                reveal_in_editor: Task::ready(()),
                 err_sender,
                 workspace,
             };
@@ -380,19 +260,6 @@ impl GitPanel {
         git_panel
     }
 
-    fn git_state(&self, cx: &AppContext) -> Option<Model<GitState>> {
-        self.project.read(cx).git_state().cloned()
-    }
-
-    fn active_repository<'a>(
-        &self,
-        cx: &'a AppContext,
-    ) -> Option<&'a (WorktreeId, RepositoryEntry, Arc<dyn GitRepository>)> {
-        let git_state = self.git_state(cx)?;
-        let active_repository = git_state.read(cx).active_repository.as_ref()?;
-        Some(active_repository)
-    }
-
     fn serialize(&mut self, cx: &mut ViewContext<Self>) {
         // TODO: we can store stage status here
         let width = self.width;
@@ -595,7 +462,13 @@ impl GitPanel {
     }
 
     fn select_first_entry_if_none(&mut self, cx: &mut ViewContext<Self>) {
-        if !self.no_entries(cx) && self.selected_entry.is_none() {
+        let have_entries = self
+            .active_repository
+            .as_ref()
+            .map_or(false, |active_repository| {
+                active_repository.entry_count() > 0
+            });
+        if have_entries && self.selected_entry.is_none() {
             self.selected_entry = Some(0);
             self.scroll_to_selected_entry(cx);
             cx.notify();
@@ -624,16 +497,15 @@ impl GitPanel {
     }
 
     fn toggle_staged_for_entry(&mut self, entry: &GitListEntry, cx: &mut ViewContext<Self>) {
-        let Some(git_state) = self.git_state(cx) else {
+        let Some(active_repository) = self.active_repository.as_ref() else {
             return;
         };
-        let result = git_state.update(cx, |git_state, _| {
-            if entry.status.is_staged().unwrap_or(false) {
-                git_state.unstage_entries(vec![entry.repo_path.clone()], self.err_sender.clone())
-            } else {
-                git_state.stage_entries(vec![entry.repo_path.clone()], self.err_sender.clone())
-            }
-        });
+        let result = if entry.status.is_staged().unwrap_or(false) {
+            active_repository
+                .unstage_entries(vec![entry.repo_path.clone()], self.err_sender.clone())
+        } else {
+            active_repository.stage_entries(vec![entry.repo_path.clone()], self.err_sender.clone())
+        };
         if let Err(e) = result {
             self.show_err_toast("toggle staged error", e, cx);
         }
@@ -647,26 +519,24 @@ impl GitPanel {
     }
 
     fn open_entry(&self, entry: &GitListEntry, cx: &mut ViewContext<Self>) {
-        let Some((worktree_id, path)) = maybe!({
-            let git_state = self.git_state(cx)?;
-            let (id, repo, _) = git_state.read(cx).active_repository.as_ref()?;
-            let path = repo.work_directory.unrelativize(&entry.repo_path)?;
-            Some((*id, path))
-        }) else {
+        let Some(active_repository) = self.active_repository.as_ref() else {
+            return;
+        };
+        let Some(path) = active_repository.unrelativize(&entry.repo_path) else {
             return;
         };
-        let path = (worktree_id, path).into();
         let path_exists = self.project.update(cx, |project, cx| {
             project.entry_for_path(&path, cx).is_some()
         });
         if !path_exists {
             return;
         }
+        // TODO maybe move all of this into project?
         cx.emit(Event::OpenedEntry { path });
     }
 
     fn stage_all(&mut self, _: &git::StageAll, cx: &mut ViewContext<Self>) {
-        let Some(git_state) = self.git_state(cx) else {
+        let Some(active_repository) = self.active_repository.as_ref() else {
             return;
         };
         for entry in &mut self.visible_entries {
@@ -674,20 +544,20 @@ impl GitPanel {
         }
         self.all_staged = Some(true);
 
-        if let Err(e) = git_state.read(cx).stage_all(self.err_sender.clone()) {
+        if let Err(e) = active_repository.stage_all(self.err_sender.clone()) {
             self.show_err_toast("stage all error", e, cx);
         };
     }
 
     fn unstage_all(&mut self, _: &git::UnstageAll, cx: &mut ViewContext<Self>) {
-        let Some(git_state) = self.git_state(cx) else {
+        let Some(active_repository) = self.active_repository.as_ref() else {
             return;
         };
         for entry in &mut self.visible_entries {
             entry.is_staged = Some(false);
         }
         self.all_staged = Some(false);
-        if let Err(e) = git_state.read(cx).unstage_all(self.err_sender.clone()) {
+        if let Err(e) = active_repository.unstage_all(self.err_sender.clone()) {
             self.show_err_toast("unstage all error", e, cx);
         };
     }
@@ -699,12 +569,10 @@ impl GitPanel {
 
     /// Commit all staged changes
     fn commit_changes(&mut self, _: &git::CommitChanges, cx: &mut ViewContext<Self>) {
-        let Some(git_state) = self.git_state(cx) else {
+        let Some(active_repository) = self.active_repository.as_ref() else {
             return;
         };
-        if let Err(e) = git_state.update(cx, |git_state, cx| {
-            git_state.commit(self.err_sender.clone(), cx)
-        }) {
+        if let Err(e) = active_repository.commit(self.err_sender.clone(), cx) {
             self.show_err_toast("commit error", e, cx);
         };
         self.commit_editor
@@ -713,12 +581,10 @@ impl GitPanel {
 
     /// Commit all changes, regardless of whether they are staged or not
     fn commit_all_changes(&mut self, _: &git::CommitAllChanges, cx: &mut ViewContext<Self>) {
-        let Some(git_state) = self.git_state(cx) else {
+        let Some(active_repository) = self.active_repository.as_ref() else {
             return;
         };
-        if let Err(e) = git_state.update(cx, |git_state, cx| {
-            git_state.commit_all(self.err_sender.clone(), cx)
-        }) {
+        if let Err(e) = active_repository.commit_all(self.err_sender.clone(), cx) {
             self.show_err_toast("commit all error", e, cx);
         };
         self.commit_editor
@@ -790,11 +656,6 @@ impl GitPanel {
         });
     }
 
-    fn no_entries(&self, cx: &mut ViewContext<Self>) -> bool {
-        self.git_state(cx)
-            .map_or(true, |git_state| git_state.read(cx).entry_count() == 0)
-    }
-
     fn for_each_visible_entry(
         &self,
         range: Range<usize>,
@@ -832,11 +693,10 @@ impl GitPanel {
         self.rebuild_requested.store(true, Ordering::Relaxed);
     }
 
-    #[track_caller]
     fn update_visible_entries(&mut self, cx: &mut ViewContext<Self>) {
         self.visible_entries.clear();
 
-        let Some((_, repo, _)) = self.active_repository(cx) else {
+        let Some(repo) = self.active_repository.as_ref() else {
             // Just clear entries if no repository is active.
             cx.notify();
             return;
@@ -882,7 +742,7 @@ impl GitPanel {
             let entry = GitListEntry {
                 depth,
                 display_name,
-                repo_path: entry.repo_path,
+                repo_path: entry.repo_path.clone(),
                 status: entry.status,
                 is_staged,
             };
@@ -901,7 +761,7 @@ impl GitPanel {
     }
 
     fn show_err_toast(&self, id: &'static str, e: anyhow::Error, cx: &mut ViewContext<Self>) {
-        let Some(workspace) = self.weak_workspace.upgrade() else {
+        let Some(workspace) = self.workspace.upgrade() else {
             return;
         };
         let notif_id = NotificationId::Named(id.into());
@@ -942,8 +802,9 @@ impl GitPanel {
     pub fn render_panel_header(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
         let focus_handle = self.focus_handle(cx).clone();
         let entry_count = self
-            .git_state(cx)
-            .map_or(0, |git_state| git_state.read(cx).entry_count());
+            .active_repository
+            .as_ref()
+            .map_or(0, RepositoryHandle::entry_count);
 
         let changes_string = match entry_count {
             0 => "No changes".to_string(),
@@ -965,7 +826,7 @@ impl GitPanel {
                     .child(
                         Checkbox::new(
                             "all-changes",
-                            if self.no_entries(cx) {
+                            if entry_count == 0 {
                                 ToggleState::Selected
                             } else {
                                 self.all_staged
@@ -1056,13 +917,15 @@ impl GitPanel {
     pub fn render_commit_editor(&self, cx: &ViewContext<Self>) -> impl IntoElement {
         let editor = self.commit_editor.clone();
         let editor_focus_handle = editor.read(cx).focus_handle(cx).clone();
-        let (can_commit, can_commit_all) = self.git_state(cx).map_or((false, false), |git_state| {
-            let git_state = git_state.read(cx);
-            (
-                git_state.can_commit(false, cx),
-                git_state.can_commit(true, cx),
-            )
-        });
+        let (can_commit, can_commit_all) = self.active_repository.as_ref().map_or_else(
+            || (false, false),
+            |active_repository| {
+                (
+                    active_repository.can_commit(false, cx),
+                    active_repository.can_commit(true, cx),
+                )
+            },
+        );
 
         let focus_handle_1 = self.focus_handle(cx).clone();
         let focus_handle_2 = self.focus_handle(cx).clone();
@@ -1316,15 +1179,17 @@ impl GitPanel {
                                 ToggleState::Indeterminate => None,
                             };
                             let repo_path = repo_path.clone();
-                            let Some(git_state) = this.git_state(cx) else {
+                            let Some(active_repository) = this.active_repository.as_ref() else {
                                 return;
                             };
-                            let result = git_state.update(cx, |git_state, _| match toggle {
-                                ToggleState::Selected | ToggleState::Indeterminate => git_state
-                                    .stage_entries(vec![repo_path], this.err_sender.clone()),
-                                ToggleState::Unselected => git_state
+                            let result = match toggle {
+                                ToggleState::Selected | ToggleState::Indeterminate => {
+                                    active_repository
+                                        .stage_entries(vec![repo_path], this.err_sender.clone())
+                                }
+                                ToggleState::Unselected => active_repository
                                     .unstage_entries(vec![repo_path], this.err_sender.clone()),
-                            });
+                            };
                             if let Err(e) = result {
                                 this.show_err_toast("toggle staged error", e, cx);
                             }
@@ -1373,6 +1238,12 @@ impl GitPanel {
 impl Render for GitPanel {
     fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
         let project = self.project.read(cx);
+        let has_entries = self
+            .active_repository
+            .as_ref()
+            .map_or(false, |active_repository| {
+                active_repository.entry_count() > 0
+            });
         let has_co_authors = self
             .workspace
             .upgrade()
@@ -1437,7 +1308,7 @@ impl Render for GitPanel {
             .bg(ElevationIndex::Surface.bg(cx))
             .child(self.render_panel_header(cx))
             .child(self.render_divider(cx))
-            .child(if !self.no_entries(cx) {
+            .child(if has_entries {
                 self.render_entries(cx).into_any_element()
             } else {
                 self.render_empty_state(cx).into_any_element()

crates/git_ui/src/git_ui.rs 🔗

@@ -6,6 +6,7 @@ use ui::{Color, Icon, IconName, IntoElement};
 
 pub mod git_panel;
 mod git_panel_settings;
+pub mod repository_selector;
 
 pub fn init(cx: &mut AppContext) {
     GitPanelSettings::register(cx);

crates/git_ui/src/repository_selector.rs 🔗

@@ -0,0 +1,232 @@
+use gpui::{
+    AnyElement, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView, Model,
+    Subscription, Task, View, WeakModel, WeakView,
+};
+use picker::{Picker, PickerDelegate};
+use project::{
+    git::{GitState, RepositoryHandle},
+    Project,
+};
+use std::sync::Arc;
+use ui::{prelude::*, ListItem, ListItemSpacing, PopoverMenu, PopoverMenuHandle, PopoverTrigger};
+
+pub struct RepositorySelector {
+    picker: View<Picker<RepositorySelectorDelegate>>,
+    /// The task used to update the picker's matches when there is a change to
+    /// the repository list.
+    update_matches_task: Option<Task<()>>,
+    _subscriptions: Vec<Subscription>,
+}
+
+impl RepositorySelector {
+    pub fn new(project: Model<Project>, cx: &mut ViewContext<Self>) -> Self {
+        let git_state = project.read(cx).git_state().cloned();
+        let all_repositories = git_state
+            .as_ref()
+            .map_or(vec![], |git_state| git_state.read(cx).all_repositories());
+        let filtered_repositories = all_repositories.clone();
+        let delegate = RepositorySelectorDelegate {
+            project: project.downgrade(),
+            repository_selector: cx.view().downgrade(),
+            repository_entries: all_repositories,
+            filtered_repositories,
+            selected_index: 0,
+        };
+
+        let picker =
+            cx.new_view(|cx| Picker::uniform_list(delegate, cx).max_height(Some(rems(20.).into())));
+
+        let _subscriptions = if let Some(git_state) = git_state {
+            vec![cx.subscribe(&git_state, Self::handle_project_git_event)]
+        } else {
+            Vec::new()
+        };
+
+        RepositorySelector {
+            picker,
+            update_matches_task: None,
+            _subscriptions,
+        }
+    }
+
+    fn handle_project_git_event(
+        &mut self,
+        git_state: Model<GitState>,
+        _event: &project::git::Event,
+        cx: &mut ViewContext<Self>,
+    ) {
+        // TODO handle events individually
+        let task = self.picker.update(cx, |this, cx| {
+            let query = this.query(cx);
+            this.delegate.repository_entries = git_state.read(cx).all_repositories();
+            this.delegate.update_matches(query, cx)
+        });
+        self.update_matches_task = Some(task);
+    }
+}
+
+impl EventEmitter<DismissEvent> for RepositorySelector {}
+
+impl FocusableView for RepositorySelector {
+    fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
+        self.picker.focus_handle(cx)
+    }
+}
+
+impl Render for RepositorySelector {
+    fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
+        self.picker.clone()
+    }
+}
+
+#[derive(IntoElement)]
+pub struct RepositorySelectorPopoverMenu<T>
+where
+    T: PopoverTrigger,
+{
+    repository_selector: View<RepositorySelector>,
+    trigger: T,
+    handle: Option<PopoverMenuHandle<RepositorySelector>>,
+}
+
+impl<T: PopoverTrigger> RepositorySelectorPopoverMenu<T> {
+    pub fn new(repository_selector: View<RepositorySelector>, trigger: T) -> Self {
+        Self {
+            repository_selector,
+            trigger,
+            handle: None,
+        }
+    }
+
+    pub fn with_handle(mut self, handle: PopoverMenuHandle<RepositorySelector>) -> Self {
+        self.handle = Some(handle);
+        self
+    }
+}
+
+impl<T: PopoverTrigger> RenderOnce for RepositorySelectorPopoverMenu<T> {
+    fn render(self, _cx: &mut WindowContext) -> impl IntoElement {
+        let repository_selector = self.repository_selector.clone();
+
+        PopoverMenu::new("repository-switcher")
+            .menu(move |_cx| Some(repository_selector.clone()))
+            .trigger(self.trigger)
+            .attach(gpui::Corner::BottomLeft)
+            .when_some(self.handle.clone(), |menu, handle| menu.with_handle(handle))
+    }
+}
+
+pub struct RepositorySelectorDelegate {
+    project: WeakModel<Project>,
+    repository_selector: WeakView<RepositorySelector>,
+    repository_entries: Vec<RepositoryHandle>,
+    filtered_repositories: Vec<RepositoryHandle>,
+    selected_index: usize,
+}
+
+impl RepositorySelectorDelegate {
+    pub fn update_repository_entries(&mut self, all_repositories: Vec<RepositoryHandle>) {
+        self.repository_entries = all_repositories.clone();
+        self.filtered_repositories = all_repositories;
+        self.selected_index = 0;
+    }
+}
+
+impl PickerDelegate for RepositorySelectorDelegate {
+    type ListItem = ListItem;
+
+    fn match_count(&self) -> usize {
+        self.filtered_repositories.len()
+    }
+
+    fn selected_index(&self) -> usize {
+        self.selected_index
+    }
+
+    fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext<Picker<Self>>) {
+        self.selected_index = ix.min(self.filtered_repositories.len().saturating_sub(1));
+        cx.notify();
+    }
+
+    fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc<str> {
+        "Select a repository...".into()
+    }
+
+    fn update_matches(&mut self, query: String, cx: &mut ViewContext<Picker<Self>>) -> Task<()> {
+        let all_repositories = self.repository_entries.clone();
+
+        cx.spawn(|this, mut cx| async move {
+            let filtered_repositories = cx
+                .background_executor()
+                .spawn(async move {
+                    if query.is_empty() {
+                        all_repositories
+                    } else {
+                        all_repositories
+                            .into_iter()
+                            .filter(|_repo_info| {
+                                // TODO: Implement repository filtering logic
+                                true
+                            })
+                            .collect()
+                    }
+                })
+                .await;
+
+            this.update(&mut cx, |this, cx| {
+                this.delegate.filtered_repositories = filtered_repositories;
+                this.delegate.set_selected_index(0, cx);
+                cx.notify();
+            })
+            .ok();
+        })
+    }
+
+    fn confirm(&mut self, _secondary: bool, cx: &mut ViewContext<Picker<Self>>) {
+        let Some(selected_repo) = self.filtered_repositories.get(self.selected_index) else {
+            return;
+        };
+        selected_repo.activate(cx);
+        self.dismissed(cx);
+    }
+
+    fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) {
+        self.repository_selector
+            .update(cx, |_this, cx| cx.emit(DismissEvent))
+            .ok();
+    }
+
+    fn render_header(&self, _cx: &mut ViewContext<Picker<Self>>) -> Option<AnyElement> {
+        // TODO: Implement header rendering if needed
+        None
+    }
+
+    fn render_match(
+        &self,
+        ix: usize,
+        selected: bool,
+        cx: &mut ViewContext<Picker<Self>>,
+    ) -> Option<Self::ListItem> {
+        let project = self.project.upgrade()?;
+        let repo_info = self.filtered_repositories.get(ix)?;
+        let display_name = repo_info.display_name(project.read(cx), cx);
+        // TODO: Implement repository item rendering
+        Some(
+            ListItem::new(ix)
+                .inset(true)
+                .spacing(ListItemSpacing::Sparse)
+                .toggle_state(selected)
+                .child(Label::new(display_name)),
+        )
+    }
+
+    fn render_footer(&self, cx: &mut ViewContext<Picker<Self>>) -> Option<gpui::AnyElement> {
+        // TODO: Implement footer rendering if needed
+        Some(
+            div()
+                .text_ui_sm(cx)
+                .child("Temporary location for repo selector")
+                .into_any_element(),
+        )
+    }
+}

crates/project/src/git.rs 🔗

@@ -1,27 +1,57 @@
-use anyhow::{anyhow, Context as _};
+use crate::worktree_store::{WorktreeStore, WorktreeStoreEvent};
+use crate::{Project, ProjectPath};
+use anyhow::anyhow;
 use futures::channel::mpsc;
 use futures::{SinkExt as _, StreamExt as _};
 use git::{
     repository::{GitRepository, RepoPath},
     status::{GitSummary, TrackedSummary},
 };
-use gpui::{AppContext, Context as _, Model};
+use gpui::{
+    AppContext, Context as _, EventEmitter, Model, ModelContext, SharedString, Subscription,
+    WeakModel,
+};
 use language::{Buffer, LanguageRegistry};
 use settings::WorktreeId;
 use std::sync::Arc;
 use text::Rope;
-use worktree::RepositoryEntry;
+use util::maybe;
+use worktree::{RepositoryEntry, StatusEntry};
 
 pub struct GitState {
-    pub commit_message: Model<Buffer>,
-
-    /// When a git repository is selected, this is used to track which repository's changes
-    /// are currently being viewed or modified in the UI.
-    pub active_repository: Option<(WorktreeId, RepositoryEntry, Arc<dyn GitRepository>)>,
+    repositories: Vec<RepositoryHandle>,
+    active_index: Option<usize>,
+    update_sender: mpsc::UnboundedSender<(Message, mpsc::Sender<anyhow::Error>)>,
+    languages: Arc<LanguageRegistry>,
+    _subscription: Subscription,
+}
 
+#[derive(Clone)]
+pub struct RepositoryHandle {
+    git_state: WeakModel<GitState>,
+    worktree_id: WorktreeId,
+    repository_entry: RepositoryEntry,
+    git_repo: Arc<dyn GitRepository>,
+    commit_message: Model<Buffer>,
     update_sender: mpsc::UnboundedSender<(Message, mpsc::Sender<anyhow::Error>)>,
 }
 
+impl PartialEq<Self> for RepositoryHandle {
+    fn eq(&self, other: &Self) -> bool {
+        self.worktree_id == other.worktree_id
+            && self.repository_entry.work_directory_id()
+                == other.repository_entry.work_directory_id()
+    }
+}
+
+impl Eq for RepositoryHandle {}
+
+impl PartialEq<RepositoryEntry> for RepositoryHandle {
+    fn eq(&self, other: &RepositoryEntry) -> bool {
+        self.repository_entry.work_directory_id() == other.work_directory_id()
+    }
+}
+
 enum Message {
     StageAndCommit(Arc<dyn GitRepository>, Rope, Vec<RepoPath>),
     Commit(Arc<dyn GitRepository>, Rope),
@@ -29,11 +59,21 @@ enum Message {
     Unstage(Arc<dyn GitRepository>, Vec<RepoPath>),
 }
 
+pub enum Event {
+    RepositoriesUpdated,
+}
+
+impl EventEmitter<Event> for GitState {}
+
 impl GitState {
-    pub fn new(languages: Arc<LanguageRegistry>, cx: &mut AppContext) -> Self {
+    pub fn new(
+        worktree_store: &Model<WorktreeStore>,
+        languages: Arc<LanguageRegistry>,
+        cx: &mut ModelContext<'_, Self>,
+    ) -> Self {
         let (update_sender, mut update_receiver) =
             mpsc::unbounded::<(Message, mpsc::Sender<anyhow::Error>)>();
-        cx.spawn(|cx| async move {
+        cx.spawn(|_, cx| async move {
             while let Some((msg, mut err_sender)) = update_receiver.next().await {
                 let result = cx
                     .background_executor()
@@ -57,39 +97,147 @@ impl GitState {
         })
         .detach();
 
-        let commit_message = cx.new_model(|cx| Buffer::local("", cx));
-        let markdown = languages.language_for_name("Markdown");
-        cx.spawn({
-            let commit_message = commit_message.clone();
-            |mut cx| async move {
-                let markdown = markdown.await.context("failed to load Markdown language")?;
-                commit_message.update(&mut cx, |commit_message, cx| {
-                    commit_message.set_language(Some(markdown), cx)
-                })
-            }
-        })
-        .detach_and_log_err(cx);
+        let _subscription = cx.subscribe(worktree_store, Self::on_worktree_store_event);
 
         GitState {
-            commit_message,
-            active_repository: None,
+            languages,
+            repositories: vec![],
+            active_index: None,
             update_sender,
+            _subscription,
         }
     }
 
-    pub fn activate_repository(
+    pub fn active_repository(&self) -> Option<RepositoryHandle> {
+        self.active_index
+            .map(|index| self.repositories[index].clone())
+    }
+
+    fn on_worktree_store_event(
         &mut self,
-        worktree_id: WorktreeId,
-        active_repository: RepositoryEntry,
-        git_repo: Arc<dyn GitRepository>,
+        worktree_store: Model<WorktreeStore>,
+        _event: &WorktreeStoreEvent,
+        cx: &mut ModelContext<'_, Self>,
     ) {
-        self.active_repository = Some((worktree_id, active_repository, git_repo));
+        // TODO inspect the event
+
+        let mut new_repositories = Vec::new();
+        let mut new_active_index = None;
+        let this = cx.weak_model();
+
+        worktree_store.update(cx, |worktree_store, cx| {
+            for worktree in worktree_store.worktrees() {
+                worktree.update(cx, |worktree, cx| {
+                    let snapshot = worktree.snapshot();
+                    let Some(local) = worktree.as_local() else {
+                        return;
+                    };
+                    for repo in snapshot.repositories().iter() {
+                        let Some(local_repo) = local.get_local_repo(repo) else {
+                            continue;
+                        };
+                        let existing = self
+                            .repositories
+                            .iter()
+                            .enumerate()
+                            .find(|(_, existing_handle)| existing_handle == &repo);
+                        let handle = if let Some((index, handle)) = existing {
+                            if self.active_index == Some(index) {
+                                new_active_index = Some(new_repositories.len());
+                            }
+                            // Update the statuses but keep everything else.
+                            let mut existing_handle = handle.clone();
+                            existing_handle.repository_entry = repo.clone();
+                            existing_handle
+                        } else {
+                            let commit_message = cx.new_model(|cx| Buffer::local("", cx));
+                            cx.spawn({
+                                let commit_message = commit_message.downgrade();
+                                let languages = self.languages.clone();
+                                |_, mut cx| async move {
+                                    let markdown = languages.language_for_name("Markdown").await?;
+                                    commit_message.update(&mut cx, |commit_message, cx| {
+                                        commit_message.set_language(Some(markdown), cx);
+                                    })?;
+                                    anyhow::Ok(())
+                                }
+                            })
+                            .detach_and_log_err(cx);
+                            RepositoryHandle {
+                                git_state: this.clone(),
+                                worktree_id: worktree.id(),
+                                repository_entry: repo.clone(),
+                                git_repo: local_repo.repo().clone(),
+                                commit_message,
+                                update_sender: self.update_sender.clone(),
+                            }
+                        };
+                        new_repositories.push(handle);
+                    }
+                })
+            }
+        });
+
+        if new_active_index == None && new_repositories.len() > 0 {
+            new_active_index = Some(0);
+        }
+
+        self.repositories = new_repositories;
+        self.active_index = new_active_index;
+
+        cx.emit(Event::RepositoriesUpdated);
     }
 
-    pub fn active_repository(
-        &self,
-    ) -> Option<&(WorktreeId, RepositoryEntry, Arc<dyn GitRepository>)> {
-        self.active_repository.as_ref()
+    pub fn all_repositories(&self) -> Vec<RepositoryHandle> {
+        self.repositories.clone()
+    }
+}
+
+impl RepositoryHandle {
+    pub fn display_name(&self, project: &Project, cx: &AppContext) -> SharedString {
+        maybe!({
+            let path = self.unrelativize(&"".into())?;
+            Some(
+                project
+                    .absolute_path(&path, cx)?
+                    .file_name()?
+                    .to_string_lossy()
+                    .to_string()
+                    .into(),
+            )
+        })
+        .unwrap_or("".into())
+    }
+
+    pub fn activate(&self, cx: &mut AppContext) {
+        let Some(git_state) = self.git_state.upgrade() else {
+            return;
+        };
+        git_state.update(cx, |git_state, cx| {
+            let Some((index, _)) = git_state
+                .repositories
+                .iter()
+                .enumerate()
+                .find(|(_, handle)| handle == &self)
+            else {
+                return;
+            };
+            git_state.active_index = Some(index);
+            cx.emit(Event::RepositoriesUpdated);
+        });
+    }
+
+    pub fn status(&self) -> impl '_ + Iterator<Item = StatusEntry> {
+        self.repository_entry.status()
+    }
+
+    pub fn unrelativize(&self, path: &RepoPath) -> Option<ProjectPath> {
+        let path = self.repository_entry.unrelativize(path)?;
+        Some((self.worktree_id, path).into())
+    }
+
+    pub fn commit_message(&self) -> Model<Buffer> {
+        self.commit_message.clone()
     }
 
     pub fn stage_entries(
@@ -100,11 +248,8 @@ impl GitState {
         if entries.is_empty() {
             return Ok(());
         }
-        let Some((_, _, git_repo)) = self.active_repository.as_ref() else {
-            return Err(anyhow!("No active repository"));
-        };
         self.update_sender
-            .unbounded_send((Message::Stage(git_repo.clone(), entries), err_sender))
+            .unbounded_send((Message::Stage(self.git_repo.clone(), entries), err_sender))
             .map_err(|_| anyhow!("Failed to submit stage operation"))?;
         Ok(())
     }
@@ -117,20 +262,15 @@ impl GitState {
         if entries.is_empty() {
             return Ok(());
         }
-        let Some((_, _, git_repo)) = self.active_repository.as_ref() else {
-            return Err(anyhow!("No active repository"));
-        };
         self.update_sender
-            .unbounded_send((Message::Unstage(git_repo.clone(), entries), err_sender))
+            .unbounded_send((Message::Unstage(self.git_repo.clone(), entries), err_sender))
             .map_err(|_| anyhow!("Failed to submit unstage operation"))?;
         Ok(())
     }
 
     pub fn stage_all(&self, err_sender: mpsc::Sender<anyhow::Error>) -> anyhow::Result<()> {
-        let Some((_, entry, _)) = self.active_repository.as_ref() else {
-            return Err(anyhow!("No active repository"));
-        };
-        let to_stage = entry
+        let to_stage = self
+            .repository_entry
             .status()
             .filter(|entry| !entry.status.is_staged().unwrap_or(false))
             .map(|entry| entry.repo_path.clone())
@@ -140,10 +280,8 @@ impl GitState {
     }
 
     pub fn unstage_all(&self, err_sender: mpsc::Sender<anyhow::Error>) -> anyhow::Result<()> {
-        let Some((_, entry, _)) = self.active_repository.as_ref() else {
-            return Err(anyhow!("No active repository"));
-        };
-        let to_unstage = entry
+        let to_unstage = self
+            .repository_entry
             .status()
             .filter(|entry| entry.status.is_staged().unwrap_or(true))
             .map(|entry| entry.repo_path.clone())
@@ -155,23 +293,15 @@ impl GitState {
     /// Get a count of all entries in the active repository, including
     /// untracked files.
     pub fn entry_count(&self) -> usize {
-        self.active_repository
-            .as_ref()
-            .map_or(0, |(_, entry, _)| entry.status_len())
+        self.repository_entry.status_len()
     }
 
     fn have_changes(&self) -> bool {
-        let Some((_, entry, _)) = self.active_repository.as_ref() else {
-            return false;
-        };
-        entry.status_summary() != GitSummary::UNCHANGED
+        self.repository_entry.status_summary() != GitSummary::UNCHANGED
     }
 
     fn have_staged_changes(&self) -> bool {
-        let Some((_, entry, _)) = self.active_repository.as_ref() else {
-            return false;
-        };
-        entry.status_summary().index != TrackedSummary::UNCHANGED
+        self.repository_entry.status_summary().index != TrackedSummary::UNCHANGED
     }
 
     pub fn can_commit(&self, commit_all: bool, cx: &AppContext) -> bool {
@@ -185,36 +315,33 @@ impl GitState {
     }
 
     pub fn commit(
-        &mut self,
+        &self,
         err_sender: mpsc::Sender<anyhow::Error>,
-        cx: &AppContext,
+        cx: &mut AppContext,
     ) -> anyhow::Result<()> {
         if !self.can_commit(false, cx) {
             return Err(anyhow!("Unable to commit"));
         }
-        let Some((_, _, git_repo)) = self.active_repository() else {
-            return Err(anyhow!("No active repository"));
-        };
-        let git_repo = git_repo.clone();
         let message = self.commit_message.read(cx).as_rope().clone();
         self.update_sender
-            .unbounded_send((Message::Commit(git_repo, message), err_sender))
+            .unbounded_send((Message::Commit(self.git_repo.clone(), message), err_sender))
             .map_err(|_| anyhow!("Failed to submit commit operation"))?;
+        self.commit_message.update(cx, |commit_message, cx| {
+            commit_message.set_text("", cx);
+        });
         Ok(())
     }
 
     pub fn commit_all(
-        &mut self,
+        &self,
         err_sender: mpsc::Sender<anyhow::Error>,
-        cx: &AppContext,
+        cx: &mut AppContext,
     ) -> anyhow::Result<()> {
         if !self.can_commit(true, cx) {
             return Err(anyhow!("Unable to commit"));
         }
-        let Some((_, entry, git_repo)) = self.active_repository.as_ref() else {
-            return Err(anyhow!("No active repository"));
-        };
-        let to_stage = entry
+        let to_stage = self
+            .repository_entry
             .status()
             .filter(|entry| !entry.status.is_staged().unwrap_or(false))
             .map(|entry| entry.repo_path.clone())
@@ -222,10 +349,13 @@ impl GitState {
         let message = self.commit_message.read(cx).as_rope().clone();
         self.update_sender
             .unbounded_send((
-                Message::StageAndCommit(git_repo.clone(), message, to_stage),
+                Message::StageAndCommit(self.git_repo.clone(), message, to_stage),
                 err_sender,
             ))
             .map_err(|_| anyhow!("Failed to submit commit operation"))?;
+        self.commit_message.update(cx, |commit_message, cx| {
+            commit_message.set_text("", cx);
+        });
         Ok(())
     }
 }

crates/project/src/lsp_store.rs 🔗

@@ -3128,12 +3128,15 @@ impl LspStore {
                 })
                 .detach()
             }
-            WorktreeStoreEvent::WorktreeReleased(..) => {}
             WorktreeStoreEvent::WorktreeRemoved(_, id) => self.remove_worktree(*id, cx),
-            WorktreeStoreEvent::WorktreeOrderChanged => {}
             WorktreeStoreEvent::WorktreeUpdateSent(worktree) => {
                 worktree.update(cx, |worktree, _cx| self.send_diagnostic_summaries(worktree));
             }
+            WorktreeStoreEvent::WorktreeReleased(..)
+            | WorktreeStoreEvent::WorktreeOrderChanged
+            | WorktreeStoreEvent::WorktreeUpdatedEntries(..)
+            | WorktreeStoreEvent::WorktreeUpdatedGitRepositories(..)
+            | WorktreeStoreEvent::WorktreeDeletedEntry(..) => {}
         }
     }
 

crates/project/src/project.rs 🔗

@@ -22,6 +22,7 @@ mod project_tests;
 mod direnv;
 mod environment;
 pub use environment::EnvironmentErrorMessage;
+use git::RepositoryHandle;
 pub mod search_history;
 mod yarn;
 
@@ -691,7 +692,8 @@ impl Project {
                 )
             });
 
-            let git_state = Some(cx.new_model(|cx| GitState::new(languages.clone(), cx)));
+            let git_state =
+                Some(cx.new_model(|cx| GitState::new(&worktree_store, languages.clone(), cx)));
 
             cx.subscribe(&lsp_store, Self::on_lsp_store_event).detach();
 
@@ -2324,6 +2326,18 @@ impl Project {
             }
             WorktreeStoreEvent::WorktreeOrderChanged => cx.emit(Event::WorktreeOrderChanged),
             WorktreeStoreEvent::WorktreeUpdateSent(_) => {}
+            WorktreeStoreEvent::WorktreeUpdatedEntries(worktree_id, changes) => {
+                self.client()
+                    .telemetry()
+                    .report_discovered_project_events(*worktree_id, changes);
+                cx.emit(Event::WorktreeUpdatedEntries(*worktree_id, changes.clone()))
+            }
+            WorktreeStoreEvent::WorktreeUpdatedGitRepositories(worktree_id) => {
+                cx.emit(Event::WorktreeUpdatedGitRepositories(*worktree_id))
+            }
+            WorktreeStoreEvent::WorktreeDeletedEntry(worktree_id, id) => {
+                cx.emit(Event::DeletedEntry(*worktree_id, *id))
+            }
         }
     }
 
@@ -2335,27 +2349,6 @@ impl Project {
             }
         }
         cx.observe(worktree, |_, _, cx| cx.notify()).detach();
-        cx.subscribe(worktree, |project, worktree, event, cx| {
-            let worktree_id = worktree.update(cx, |worktree, _| worktree.id());
-            match event {
-                worktree::Event::UpdatedEntries(changes) => {
-                    cx.emit(Event::WorktreeUpdatedEntries(
-                        worktree.read(cx).id(),
-                        changes.clone(),
-                    ));
-
-                    project
-                        .client()
-                        .telemetry()
-                        .report_discovered_project_events(worktree_id, changes);
-                }
-                worktree::Event::UpdatedGitRepositories(_) => {
-                    cx.emit(Event::WorktreeUpdatedGitRepositories(worktree_id));
-                }
-                worktree::Event::DeletedEntry(id) => cx.emit(Event::DeletedEntry(worktree_id, *id)),
-            }
-        })
-        .detach();
         cx.notify();
     }
 
@@ -4169,6 +4162,17 @@ impl Project {
     pub fn git_state(&self) -> Option<&Model<GitState>> {
         self.git_state.as_ref()
     }
+
+    pub fn active_repository(&self, cx: &AppContext) -> Option<RepositoryHandle> {
+        self.git_state()
+            .and_then(|git_state| git_state.read(cx).active_repository())
+    }
+
+    pub fn all_repositories(&self, cx: &AppContext) -> Vec<RepositoryHandle> {
+        self.git_state()
+            .map(|git_state| git_state.read(cx).all_repositories())
+            .unwrap_or_default()
+    }
 }
 
 fn deserialize_code_actions(code_actions: &HashMap<String, bool>) -> Vec<lsp::CodeActionKind> {

crates/project/src/worktree_store.rs 🔗

@@ -25,7 +25,7 @@ use smol::{
 };
 use text::ReplicaId;
 use util::{paths::SanitizedPath, ResultExt};
-use worktree::{Entry, ProjectEntryId, Worktree, WorktreeId, WorktreeSettings};
+use worktree::{Entry, ProjectEntryId, UpdatedEntriesSet, Worktree, WorktreeId, WorktreeSettings};
 
 use crate::{search::SearchQuery, ProjectPath};
 
@@ -63,6 +63,9 @@ pub enum WorktreeStoreEvent {
     WorktreeReleased(EntityId, WorktreeId),
     WorktreeOrderChanged,
     WorktreeUpdateSent(Model<Worktree>),
+    WorktreeUpdatedEntries(WorktreeId, UpdatedEntriesSet),
+    WorktreeUpdatedGitRepositories(WorktreeId),
+    WorktreeDeletedEntry(WorktreeId, ProjectEntryId),
 }
 
 impl EventEmitter<WorktreeStoreEvent> for WorktreeStore {}
@@ -364,6 +367,26 @@ impl WorktreeStore {
         self.send_project_updates(cx);
 
         let handle_id = worktree.entity_id();
+        cx.subscribe(worktree, |_, worktree, event, cx| {
+            let worktree_id = worktree.update(cx, |worktree, _| worktree.id());
+            match event {
+                worktree::Event::UpdatedEntries(changes) => {
+                    cx.emit(WorktreeStoreEvent::WorktreeUpdatedEntries(
+                        worktree.read(cx).id(),
+                        changes.clone(),
+                    ));
+                }
+                worktree::Event::UpdatedGitRepositories(_) => {
+                    cx.emit(WorktreeStoreEvent::WorktreeUpdatedGitRepositories(
+                        worktree_id,
+                    ));
+                }
+                worktree::Event::DeletedEntry(id) => {
+                    cx.emit(WorktreeStoreEvent::WorktreeDeletedEntry(worktree_id, *id))
+                }
+            }
+        })
+        .detach();
         cx.observe_release(worktree, move |this, worktree, cx| {
             cx.emit(WorktreeStoreEvent::WorktreeReleased(
                 handle_id,

crates/title_bar/Cargo.toml 🔗

@@ -47,6 +47,7 @@ util.workspace = true
 telemetry.workspace = true
 workspace.workspace = true
 zed_actions.workspace = true
+git_ui.workspace = true
 
 [target.'cfg(windows)'.dependencies]
 windows.workspace = true

crates/title_bar/src/title_bar.rs 🔗

@@ -16,6 +16,8 @@ use auto_update::AutoUpdateStatus;
 use call::ActiveCall;
 use client::{Client, UserStore};
 use feature_flags::{FeatureFlagAppExt, ZedPro};
+use git_ui::repository_selector::RepositorySelector;
+use git_ui::repository_selector::RepositorySelectorPopoverMenu;
 use gpui::{
     actions, div, px, Action, AnyElement, AppContext, Decorations, Element, InteractiveElement,
     Interactivity, IntoElement, Model, MouseButton, ParentElement, Render, Stateful,
@@ -98,6 +100,7 @@ pub struct TitleBar {
     platform_style: PlatformStyle,
     content: Stateful<Div>,
     children: SmallVec<[AnyElement; 2]>,
+    repository_selector: View<RepositorySelector>,
     project: Model<Project>,
     user_store: Model<UserStore>,
     client: Arc<Client>,
@@ -181,6 +184,7 @@ impl Render for TitleBar {
                                         title_bar
                                             .children(self.render_project_host(cx))
                                             .child(self.render_project_name(cx))
+                                            .children(self.render_current_repository(cx))
                                             .children(self.render_project_branch(cx))
                                     })
                             })
@@ -290,6 +294,7 @@ impl TitleBar {
             content: div().id(id.into()),
             children: SmallVec::new(),
             application_menu,
+            repository_selector: cx.new_view(|cx| RepositorySelector::new(project.clone(), cx)),
             workspace: workspace.weak_handle(),
             should_move: false,
             project,
@@ -474,6 +479,39 @@ impl TitleBar {
             }))
     }
 
+    // NOTE: Not sure we want to keep this in the titlebar, but for while we are working on Git it is helpful in the short term
+    pub fn render_current_repository(
+        &self,
+        cx: &mut ViewContext<Self>,
+    ) -> Option<impl IntoElement> {
+        // TODO what to render if no active repository?
+        let active_repository = self.project.read(cx).active_repository(cx)?;
+        let display_name = active_repository.display_name(self.project.read(cx), cx);
+        Some(RepositorySelectorPopoverMenu::new(
+            self.repository_selector.clone(),
+            ButtonLike::new("active-repository")
+                .style(ButtonStyle::Subtle)
+                .child(
+                    h_flex().w_full().gap_0p5().child(
+                        div()
+                            .overflow_x_hidden()
+                            .flex_grow()
+                            .whitespace_nowrap()
+                            .child(
+                                h_flex()
+                                    .gap_1()
+                                    .child(
+                                        Label::new(display_name)
+                                            .size(LabelSize::Small)
+                                            .color(Color::Muted),
+                                    )
+                                    .into_any_element(),
+                            ),
+                    ),
+                ),
+        ))
+    }
+
     pub fn render_project_branch(&self, cx: &mut ViewContext<Self>) -> Option<impl IntoElement> {
         let entry = {
             let mut names_and_branches =

crates/util/Cargo.toml 🔗

@@ -32,7 +32,7 @@ rust-embed.workspace = true
 serde.workspace = true
 serde_json.workspace = true
 smol.workspace = true
-take-until = "0.2.0"
+take-until.workspace = true
 tempfile = { workspace = true, optional = true }
 unicase.workspace = true