Worktree paths in git panel, take 2 (#26047)

Cole Miller created

Modified version of #25950. We still use worktree paths, but repo paths
with a status that lie outside the worktree are not excluded; instead,
we relativize them by adding `..`. This makes the list in the git panel
match what you'd get from running `git status` (with the repo's worktree
root as the working directory).

- [x] Implement + test new unrelativization logic
- [x] ~~When collecting repositories, dedup by .git abs path, so
worktrees can share a repo at the project level~~ dedup repos at the
repository selector layer, with repos coming from larger worktrees being
preferred
- [x] Open single-file worktree with diff when activating a path not in
the worktree

Release Notes:

- N/A

Change summary

Cargo.lock                                       |   1 
crates/editor/src/clangd_ext.rs                  |   3 
crates/editor/src/hover_popover.rs               |  13 
crates/editor/src/items.rs                       |  16 
crates/file_finder/src/file_finder.rs            |   9 
crates/file_finder/src/file_finder_tests.rs      |   7 
crates/git_ui/Cargo.toml                         |   1 
crates/git_ui/src/git_panel.rs                   | 442 +++++++++++++----
crates/git_ui/src/repository_selector.rs         |  46 +
crates/journal/src/journal.rs                    |  22 
crates/markdown_preview/src/markdown_renderer.rs |  22 
crates/outline_panel/src/outline_panel.rs        |  17 
crates/project/src/git.rs                        |  12 
crates/project_panel/src/project_panel.rs        |   5 
crates/snippets_ui/src/snippets_ui.rs            |   7 
crates/tasks_ui/src/modal.rs                     |  62 ++
crates/terminal_view/src/terminal_view.rs        |   9 
crates/util/src/util.rs                          |   4 
crates/workspace/src/pane.rs                     |   9 
crates/workspace/src/workspace.rs                |  68 +
crates/worktree/src/worktree.rs                  |  52 +
crates/worktree/src/worktree_tests.rs            |  37 +
crates/zed/src/zed.rs                            |  36 +
23 files changed, 720 insertions(+), 180 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -5466,6 +5466,7 @@ dependencies = [
  "panel",
  "picker",
  "postage",
+ "pretty_assertions",
  "project",
  "schemars",
  "serde",

crates/editor/src/clangd_ext.rs 🔗

@@ -2,6 +2,7 @@ use anyhow::Context as _;
 use gpui::{App, Context, Entity, Window};
 use language::Language;
 use url::Url;
+use workspace::{OpenOptions, OpenVisible};
 
 use crate::lsp_ext::find_specific_language_server_in_selection;
 
@@ -72,7 +73,7 @@ pub fn switch_source_header(
 
         workspace
             .update_in(&mut cx, |workspace, window, cx| {
-                workspace.open_abs_path(path, false, window, cx)
+                workspace.open_abs_path(path, OpenOptions { visible: Some(OpenVisible::None), ..Default::default() }, window, cx)
             })
             .with_context(|| {
                 format!(

crates/editor/src/hover_popover.rs 🔗

@@ -25,7 +25,7 @@ use theme::ThemeSettings;
 use ui::{prelude::*, theme_is_transparent, Scrollbar, ScrollbarState};
 use url::Url;
 use util::TryFutureExt;
-use workspace::Workspace;
+use workspace::{OpenOptions, OpenVisible, Workspace};
 pub const HOVER_REQUEST_DELAY_MILLIS: u64 = 200;
 
 pub const MIN_POPOVER_CHARACTER_WIDTH: f32 = 20.;
@@ -632,8 +632,15 @@ pub fn open_markdown_url(link: SharedString, window: &mut Window, cx: &mut App)
         if uri.scheme() == "file" {
             if let Some(workspace) = window.root::<Workspace>().flatten() {
                 workspace.update(cx, |workspace, cx| {
-                    let task =
-                        workspace.open_abs_path(PathBuf::from(uri.path()), false, window, cx);
+                    let task = workspace.open_abs_path(
+                        PathBuf::from(uri.path()),
+                        OpenOptions {
+                            visible: Some(OpenVisible::None),
+                            ..Default::default()
+                        },
+                        window,
+                        cx,
+                    );
 
                     cx.spawn_in(window, |_, mut cx| async move {
                         let item = task.await?;

crates/editor/src/items.rs 🔗

@@ -38,10 +38,14 @@ use text::{BufferId, Selection};
 use theme::{Theme, ThemeSettings};
 use ui::{prelude::*, IconDecorationKind};
 use util::{paths::PathExt, ResultExt, TryFutureExt};
-use workspace::item::{Dedup, ItemSettings, SerializableItem, TabContentParams};
 use workspace::{
     item::{BreadcrumbText, FollowEvent},
     searchable::SearchOptions,
+    OpenVisible,
+};
+use workspace::{
+    item::{Dedup, ItemSettings, SerializableItem, TabContentParams},
+    OpenOptions,
 };
 use workspace::{
     item::{FollowableItem, Item, ItemEvent, ProjectItem},
@@ -1157,7 +1161,15 @@ impl SerializableItem for Editor {
                     }
                     None => {
                         let open_by_abs_path = workspace.update(cx, |workspace, cx| {
-                            workspace.open_abs_path(abs_path.clone(), false, window, cx)
+                            workspace.open_abs_path(
+                                abs_path.clone(),
+                                OpenOptions {
+                                    visible: Some(OpenVisible::None),
+                                    ..Default::default()
+                                },
+                                window,
+                                cx,
+                            )
                         });
                         window.spawn(cx, |mut cx| async move {
                             let editor = open_by_abs_path?.await?.downcast::<Editor>().with_context(|| format!("Failed to downcast to Editor after opening abs path {abs_path:?}"))?;

crates/file_finder/src/file_finder.rs 🔗

@@ -42,8 +42,8 @@ use ui::{
 };
 use util::{maybe, paths::PathWithPosition, post_inc, ResultExt};
 use workspace::{
-    item::PreviewTabsSettings, notifications::NotifyResultExt, pane, ModalView, SplitDirection,
-    Workspace,
+    item::PreviewTabsSettings, notifications::NotifyResultExt, pane, ModalView, OpenOptions,
+    OpenVisible, SplitDirection, Workspace,
 };
 
 actions!(file_finder, [SelectPrevious, ToggleMenu]);
@@ -1239,7 +1239,10 @@ impl PickerDelegate for FileFinderDelegate {
                                         } else {
                                             workspace.open_abs_path(
                                                 abs_path.to_path_buf(),
-                                                false,
+                                                OpenOptions {
+                                                    visible: Some(OpenVisible::None),
+                                                    ..Default::default()
+                                                },
                                                 window,
                                                 cx,
                                             )

crates/file_finder/src/file_finder_tests.rs 🔗

@@ -7,7 +7,7 @@ use menu::{Confirm, SelectNext, SelectPrevious};
 use project::{RemoveOptions, FS_WATCH_LATENCY};
 use serde_json::json;
 use util::path;
-use workspace::{AppState, ToggleFileFinder, Workspace};
+use workspace::{AppState, OpenOptions, ToggleFileFinder, Workspace};
 
 #[ctor::ctor]
 fn init_logger() {
@@ -951,7 +951,10 @@ async fn test_external_files_history(cx: &mut gpui::TestAppContext) {
         .update_in(cx, |workspace, window, cx| {
             workspace.open_abs_path(
                 PathBuf::from(path!("/external-src/test/third.rs")),
-                false,
+                OpenOptions {
+                    visible: Some(OpenVisible::None),
+                    ..Default::default()
+                },
                 window,
                 cx,
             )

crates/git_ui/Cargo.toml 🔗

@@ -64,6 +64,7 @@ ctor.workspace = true
 env_logger.workspace = true
 editor = { workspace = true, features = ["test-support"] }
 gpui = { workspace = true, features = ["test-support"] }
+pretty_assertions.workspace = true
 project = { workspace = true, features = ["test-support"] }
 settings = { workspace = true, features = ["test-support"] }
 unindent.workspace = true

crates/git_ui/src/git_panel.rs 🔗

@@ -3,6 +3,7 @@ use crate::branch_picker;
 use crate::commit_modal::CommitModal;
 use crate::git_panel_settings::StatusStyle;
 use crate::remote_output_toast::{RemoteAction, RemoteOutputToast};
+use crate::repository_selector::filtered_repository_entries;
 use crate::{
     git_panel_settings::GitPanelSettings, git_status_icon, repository_selector::RepositorySelector,
 };
@@ -23,7 +24,14 @@ use git::repository::{
 };
 use git::{repository::RepoPath, status::FileStatus, Commit, ToggleStaged};
 use git::{RestoreTrackedFiles, StageAll, TrashUntrackedFiles, UnstageAll};
-use gpui::*;
+use gpui::{
+    actions, anchored, deferred, hsla, percentage, point, uniform_list, Action, Animation,
+    AnimationExt as _, AnyView, BoxShadow, ClickEvent, Corner, DismissEvent, Entity, EventEmitter,
+    FocusHandle, Focusable, KeyContext, ListHorizontalSizingBehavior, ListSizingBehavior,
+    Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent, Point, PromptLevel,
+    ScrollStrategy, Stateful, Subscription, Task, Transformation, UniformListScrollHandle,
+    WeakEntity,
+};
 use itertools::Itertools;
 use language::{Buffer, File};
 use language_model::{
@@ -43,6 +51,7 @@ use settings::Settings as _;
 use smallvec::smallvec;
 use std::cell::RefCell;
 use std::future::Future;
+use std::path::{Path, PathBuf};
 use std::rc::Rc;
 use std::{collections::HashSet, sync::Arc, time::Duration, usize};
 use strum::{IntoEnumIterator, VariantNames};
@@ -52,6 +61,7 @@ use ui::{
     ScrollbarState, Tooltip,
 };
 use util::{maybe, post_inc, ResultExt, TryFutureExt};
+use workspace::{AppState, OpenOptions, OpenVisible};
 
 use workspace::{
     dock::{DockPosition, Panel, PanelEvent},
@@ -71,7 +81,12 @@ actions!(
     ]
 );
 
-fn prompt<T>(msg: &str, detail: Option<&str>, window: &mut Window, cx: &mut App) -> Task<Result<T>>
+fn prompt<T>(
+    msg: &str,
+    detail: Option<&str>,
+    window: &mut Window,
+    cx: &mut App,
+) -> Task<anyhow::Result<T>>
 where
     T: IntoEnumIterator + VariantNames + 'static,
 {
@@ -173,6 +188,8 @@ impl GitListEntry {
 #[derive(Debug, PartialEq, Eq, Clone)]
 pub struct GitStatusEntry {
     pub(crate) repo_path: RepoPath,
+    pub(crate) worktree_path: Arc<Path>,
+    pub(crate) abs_path: PathBuf,
     pub(crate) status: FileStatus,
     pub(crate) is_staged: Option<bool>,
 }
@@ -269,99 +286,98 @@ pub(crate) fn commit_message_editor(
 
 impl GitPanel {
     pub fn new(
-        workspace: &mut Workspace,
+        workspace: Entity<Workspace>,
+        project: Entity<Project>,
+        app_state: Arc<AppState>,
         window: &mut Window,
-        cx: &mut Context<Workspace>,
-    ) -> Entity<Self> {
-        let fs = workspace.app_state().fs.clone();
-        let project = workspace.project().clone();
+        cx: &mut Context<Self>,
+    ) -> Self {
+        let fs = app_state.fs.clone();
         let git_store = project.read(cx).git_store().clone();
         let active_repository = project.read(cx).active_repository(cx);
-        let workspace = cx.entity().downgrade();
+        let workspace = workspace.downgrade();
 
-        cx.new(|cx| {
-            let focus_handle = cx.focus_handle();
-            cx.on_focus(&focus_handle, window, Self::focus_in).detach();
-            cx.on_focus_out(&focus_handle, window, |this, _, window, cx| {
-                this.hide_scrollbar(window, cx);
-            })
-            .detach();
+        let focus_handle = cx.focus_handle();
+        cx.on_focus(&focus_handle, window, Self::focus_in).detach();
+        cx.on_focus_out(&focus_handle, window, |this, _, window, cx| {
+            this.hide_scrollbar(window, cx);
+        })
+        .detach();
 
-            // just to let us render a placeholder editor.
-            // Once the active git repo is set, this buffer will be replaced.
-            let temporary_buffer = cx.new(|cx| Buffer::local("", cx));
-            let commit_editor = cx.new(|cx| {
-                commit_message_editor(temporary_buffer, None, project.clone(), true, window, cx)
-            });
+        // just to let us render a placeholder editor.
+        // Once the active git repo is set, this buffer will be replaced.
+        let temporary_buffer = cx.new(|cx| Buffer::local("", cx));
+        let commit_editor = cx.new(|cx| {
+            commit_message_editor(temporary_buffer, None, project.clone(), true, window, cx)
+        });
 
-            commit_editor.update(cx, |editor, cx| {
-                editor.clear(window, cx);
-            });
+        commit_editor.update(cx, |editor, cx| {
+            editor.clear(window, cx);
+        });
 
-            let scroll_handle = UniformListScrollHandle::new();
+        let scroll_handle = UniformListScrollHandle::new();
 
-            cx.subscribe_in(
-                &git_store,
-                window,
-                move |this, git_store, event, window, cx| match event {
-                    GitEvent::FileSystemUpdated => {
-                        this.schedule_update(false, window, cx);
-                    }
-                    GitEvent::ActiveRepositoryChanged | GitEvent::GitStateUpdated => {
-                        this.active_repository = git_store.read(cx).active_repository();
-                        this.schedule_update(true, window, cx);
-                    }
-                    GitEvent::IndexWriteError(error) => {
-                        this.workspace
-                            .update(cx, |workspace, cx| {
-                                workspace.show_error(error, cx);
-                            })
-                            .ok();
-                    }
-                },
-            )
-            .detach();
+        cx.subscribe_in(
+            &git_store,
+            window,
+            move |this, git_store, event, window, cx| match event {
+                GitEvent::FileSystemUpdated => {
+                    this.schedule_update(false, window, cx);
+                }
+                GitEvent::ActiveRepositoryChanged | GitEvent::GitStateUpdated => {
+                    this.active_repository = git_store.read(cx).active_repository();
+                    this.schedule_update(true, window, cx);
+                }
+                GitEvent::IndexWriteError(error) => {
+                    this.workspace
+                        .update(cx, |workspace, cx| {
+                            workspace.show_error(error, cx);
+                        })
+                        .ok();
+                }
+            },
+        )
+        .detach();
 
-            let scrollbar_state =
-                ScrollbarState::new(scroll_handle.clone()).parent_entity(&cx.entity());
-
-            let mut git_panel = Self {
-                pending_remote_operations: Default::default(),
-                remote_operation_id: 0,
-                active_repository,
-                commit_editor,
-                conflicted_count: 0,
-                conflicted_staged_count: 0,
-                current_modifiers: window.modifiers(),
-                add_coauthors: true,
-                generate_commit_message_task: None,
-                entries: Vec::new(),
-                focus_handle: cx.focus_handle(),
-                fs,
-                hide_scrollbar_task: None,
-                new_count: 0,
-                new_staged_count: 0,
-                pending: Vec::new(),
-                pending_commit: None,
-                pending_serialization: Task::ready(None),
-                project,
-                scroll_handle,
-                scrollbar_state,
-                selected_entry: None,
-                marked_entries: Vec::new(),
-                show_scrollbar: false,
-                tracked_count: 0,
-                tracked_staged_count: 0,
-                update_visible_entries_task: Task::ready(()),
-                width: Some(px(360.)),
-                context_menu: None,
-                workspace,
-                modal_open: false,
-            };
-            git_panel.schedule_update(false, window, cx);
-            git_panel.show_scrollbar = git_panel.should_show_scrollbar(cx);
-            git_panel
-        })
+        let scrollbar_state =
+            ScrollbarState::new(scroll_handle.clone()).parent_entity(&cx.entity());
+
+        let mut git_panel = Self {
+            pending_remote_operations: Default::default(),
+            remote_operation_id: 0,
+            active_repository,
+            commit_editor,
+            conflicted_count: 0,
+            conflicted_staged_count: 0,
+            current_modifiers: window.modifiers(),
+            add_coauthors: true,
+            generate_commit_message_task: None,
+            entries: Vec::new(),
+            focus_handle: cx.focus_handle(),
+            fs,
+            hide_scrollbar_task: None,
+            new_count: 0,
+            new_staged_count: 0,
+            pending: Vec::new(),
+            pending_commit: None,
+            pending_serialization: Task::ready(None),
+            project,
+            scroll_handle,
+            scrollbar_state,
+            selected_entry: None,
+            marked_entries: Vec::new(),
+            show_scrollbar: false,
+            tracked_count: 0,
+            tracked_staged_count: 0,
+            update_visible_entries_task: Task::ready(()),
+            width: Some(px(360.)),
+            context_menu: None,
+            workspace,
+            modal_open: false,
+        };
+        git_panel.schedule_update(false, window, cx);
+        git_panel.show_scrollbar = git_panel.should_show_scrollbar(cx);
+        git_panel
     }
 
     pub fn entry_by_path(&self, path: &RepoPath) -> Option<usize> {
@@ -723,12 +739,31 @@ impl GitPanel {
                 }
             };
 
-            self.workspace
-                .update(cx, |workspace, cx| {
-                    ProjectDiff::deploy_at(workspace, Some(entry.clone()), window, cx);
-                })
-                .ok();
-            self.focus_handle.focus(window);
+            if entry.worktree_path.starts_with("..") {
+                self.workspace
+                    .update(cx, |workspace, cx| {
+                        workspace
+                            .open_abs_path(
+                                entry.abs_path.clone(),
+                                OpenOptions {
+                                    visible: Some(OpenVisible::All),
+                                    focus: Some(false),
+                                    ..Default::default()
+                                },
+                                window,
+                                cx,
+                            )
+                            .detach_and_log_err(cx);
+                    })
+                    .ok();
+            } else {
+                self.workspace
+                    .update(cx, |workspace, cx| {
+                        ProjectDiff::deploy_at(workspace, Some(entry.clone()), window, cx);
+                    })
+                    .ok();
+                self.focus_handle.focus(window);
+            }
 
             Some(())
         });
@@ -1683,7 +1718,7 @@ impl GitPanel {
         &mut self,
         window: &mut Window,
         cx: &mut Context<Self>,
-    ) -> impl Future<Output = Result<Option<Remote>>> {
+    ) -> impl Future<Output = anyhow::Result<Option<Remote>>> {
         let repo = self.active_repository.clone();
         let workspace = self.workspace.clone();
         let mut cx = window.to_async(cx);
@@ -1920,10 +1955,8 @@ impl GitPanel {
             return;
         };
 
-        // First pass - collect all paths
         let repo = repo.read(cx);
 
-        // Second pass - create entries with proper depth calculation
         for entry in repo.status() {
             let is_conflict = repo.has_conflict(&entry.repo_path);
             let is_new = entry.status.is_created();
@@ -1937,8 +1970,17 @@ impl GitPanel {
                 continue;
             }
 
+            // dot_git_abs path always has at least one component, namely .git.
+            let abs_path = repo
+                .dot_git_abs_path
+                .parent()
+                .unwrap()
+                .join(&entry.repo_path);
+            let worktree_path = repo.repository_entry.unrelativize(&entry.repo_path);
             let entry = GitStatusEntry {
                 repo_path: entry.repo_path.clone(),
+                worktree_path,
+                abs_path,
                 status: entry.status,
                 is_staged,
             };
@@ -2636,7 +2678,7 @@ impl GitPanel {
         &self,
         sha: &str,
         cx: &mut Context<Self>,
-    ) -> Task<Result<CommitDetails>> {
+    ) -> Task<anyhow::Result<CommitDetails>> {
         let Some(repo) = self.active_repository.clone() else {
             return Task::ready(Err(anyhow::anyhow!("no active repo")));
         };
@@ -2721,12 +2763,12 @@ impl GitPanel {
         cx: &Context<Self>,
     ) -> AnyElement {
         let display_name = entry
-            .repo_path
+            .worktree_path
             .file_name()
             .map(|name| name.to_string_lossy().into_owned())
-            .unwrap_or_else(|| entry.repo_path.to_string_lossy().into_owned());
+            .unwrap_or_else(|| entry.worktree_path.to_string_lossy().into_owned());
 
-        let repo_path = entry.repo_path.clone();
+        let worktree_path = entry.worktree_path.clone();
         let selected = self.selected_entry == Some(ix);
         let marked = self.marked_entries.contains(&ix);
         let status_style = GitPanelSettings::get_global(cx).status_style;
@@ -2897,7 +2939,7 @@ impl GitPanel {
                 h_flex()
                     .items_center()
                     .overflow_hidden()
-                    .when_some(repo_path.parent(), |this, parent| {
+                    .when_some(worktree_path.parent(), |this, parent| {
                         let parent_str = parent.to_string_lossy();
                         if !parent_str.is_empty() {
                             this.child(
@@ -3570,7 +3612,9 @@ impl RenderOnce for PanelRepoFooter {
 
         let single_repo = project
             .as_ref()
-            .map(|project| project.read(cx).all_repositories(cx).len() == 1)
+            .map(|project| {
+                filtered_repository_entries(project.read(cx).git_store().read(cx), cx).len() == 1
+            })
             .unwrap_or(true);
 
         let repo_selector = PopoverMenu::new("repository-switcher")
@@ -3936,3 +3980,199 @@ impl ComponentPreview for PanelRepoFooter {
             .into_any_element()
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use git::status::StatusCode;
+    use gpui::TestAppContext;
+    use project::{FakeFs, WorktreeSettings};
+    use serde_json::json;
+    use settings::SettingsStore;
+    use theme::LoadThemes;
+    use util::path;
+
+    use super::*;
+
+    fn init_test(cx: &mut gpui::TestAppContext) {
+        if std::env::var("RUST_LOG").is_ok() {
+            env_logger::try_init().ok();
+        }
+
+        cx.update(|cx| {
+            let settings_store = SettingsStore::test(cx);
+            cx.set_global(settings_store);
+            WorktreeSettings::register(cx);
+            workspace::init_settings(cx);
+            theme::init(LoadThemes::JustBase, cx);
+            language::init(cx);
+            editor::init(cx);
+            Project::init_settings(cx);
+            crate::init(cx);
+        });
+    }
+
+    #[gpui::test]
+    async fn test_entry_worktree_paths(cx: &mut TestAppContext) {
+        init_test(cx);
+        let fs = FakeFs::new(cx.background_executor.clone());
+        fs.insert_tree(
+            "/root",
+            json!({
+                "zed": {
+                    ".git": {},
+                    "crates": {
+                        "gpui": {
+                            "gpui.rs": "fn main() {}"
+                        },
+                        "util": {
+                            "util.rs": "fn do_it() {}"
+                        }
+                    }
+                },
+            }),
+        )
+        .await;
+
+        fs.set_status_for_repo_via_git_operation(
+            Path::new("/root/zed/.git"),
+            &[
+                (
+                    Path::new("crates/gpui/gpui.rs"),
+                    StatusCode::Modified.worktree(),
+                ),
+                (
+                    Path::new("crates/util/util.rs"),
+                    StatusCode::Modified.worktree(),
+                ),
+            ],
+        );
+
+        let project =
+            Project::test(fs.clone(), [path!("/root/zed/crates/gpui").as_ref()], cx).await;
+        let (workspace, cx) =
+            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+
+        cx.read(|cx| {
+            project
+                .read(cx)
+                .worktrees(cx)
+                .nth(0)
+                .unwrap()
+                .read(cx)
+                .as_local()
+                .unwrap()
+                .scan_complete()
+        })
+        .await;
+
+        cx.executor().run_until_parked();
+
+        let app_state = workspace.update(cx, |workspace, _| workspace.app_state().clone());
+        let panel = cx.new_window_entity(|window, cx| {
+            GitPanel::new(workspace.clone(), project.clone(), app_state, window, cx)
+        });
+
+        let handle = cx.update_window_entity(&panel, |panel, _, _| {
+            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
+        });
+        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
+        handle.await;
+
+        let entries = panel.update(cx, |panel, _| panel.entries.clone());
+        pretty_assertions::assert_eq!(
+            entries,
+            [
+                GitListEntry::Header(GitHeaderEntry {
+                    header: Section::Tracked
+                }),
+                GitListEntry::GitStatusEntry(GitStatusEntry {
+                    abs_path: "/root/zed/crates/gpui/gpui.rs".into(),
+                    repo_path: "crates/gpui/gpui.rs".into(),
+                    worktree_path: Path::new("gpui.rs").into(),
+                    status: StatusCode::Modified.worktree(),
+                    is_staged: Some(false),
+                }),
+                GitListEntry::GitStatusEntry(GitStatusEntry {
+                    abs_path: "/root/zed/crates/util/util.rs".into(),
+                    repo_path: "crates/util/util.rs".into(),
+                    worktree_path: Path::new("../util/util.rs").into(),
+                    status: StatusCode::Modified.worktree(),
+                    is_staged: Some(false),
+                },),
+            ],
+        );
+
+        cx.update_window_entity(&panel, |panel, window, cx| {
+            panel.select_last(&Default::default(), window, cx);
+            assert_eq!(panel.selected_entry, Some(2));
+            panel.open_diff(&Default::default(), window, cx);
+        });
+        cx.run_until_parked();
+
+        let worktree_roots = workspace.update(cx, |workspace, cx| {
+            workspace
+                .worktrees(cx)
+                .map(|worktree| worktree.read(cx).abs_path())
+                .collect::<Vec<_>>()
+        });
+        pretty_assertions::assert_eq!(
+            worktree_roots,
+            vec![
+                Path::new("/root/zed/crates/gpui").into(),
+                Path::new("/root/zed/crates/util/util.rs").into(),
+            ]
+        );
+
+        let repo_from_single_file_worktree = project.update(cx, |project, cx| {
+            let git_store = project.git_store().read(cx);
+            // The repo that comes from the single-file worktree can't be selected through the UI.
+            let filtered_entries = filtered_repository_entries(git_store, cx)
+                .iter()
+                .map(|repo| repo.read(cx).worktree_abs_path.clone())
+                .collect::<Vec<_>>();
+            assert_eq!(
+                filtered_entries,
+                [Path::new("/root/zed/crates/gpui").into()]
+            );
+            // But we can select it artificially here.
+            git_store
+                .all_repositories()
+                .into_iter()
+                .find(|repo| {
+                    &*repo.read(cx).worktree_abs_path == Path::new("/root/zed/crates/util/util.rs")
+                })
+                .unwrap()
+        });
+
+        // Paths still make sense when we somehow activate a repo that comes from a single-file worktree.
+        repo_from_single_file_worktree.update(cx, |repo, cx| repo.activate(cx));
+        let handle = cx.update_window_entity(&panel, |panel, _, _| {
+            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
+        });
+        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
+        handle.await;
+        let entries = panel.update(cx, |panel, _| panel.entries.clone());
+        pretty_assertions::assert_eq!(
+            entries,
+            [
+                GitListEntry::Header(GitHeaderEntry {
+                    header: Section::Tracked
+                }),
+                GitListEntry::GitStatusEntry(GitStatusEntry {
+                    abs_path: "/root/zed/crates/gpui/gpui.rs".into(),
+                    repo_path: "crates/gpui/gpui.rs".into(),
+                    worktree_path: Path::new("../../gpui/gpui.rs").into(),
+                    status: StatusCode::Modified.worktree(),
+                    is_staged: Some(false),
+                }),
+                GitListEntry::GitStatusEntry(GitStatusEntry {
+                    abs_path: "/root/zed/crates/util/util.rs".into(),
+                    repo_path: "crates/util/util.rs".into(),
+                    worktree_path: Path::new("util.rs").into(),
+                    status: StatusCode::Modified.worktree(),
+                    is_staged: Some(false),
+                },),
+            ],
+        );
+    }
+}

crates/git_ui/src/repository_selector.rs 🔗

@@ -3,7 +3,10 @@ use gpui::{
 };
 use itertools::Itertools;
 use picker::{Picker, PickerDelegate};
-use project::{git::Repository, Project};
+use project::{
+    git::{GitStore, Repository},
+    Project,
+};
 use std::sync::Arc;
 use ui::{prelude::*, ListItem, ListItemSpacing};
 
@@ -17,12 +20,14 @@ impl RepositorySelector {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Self {
+        let git_store = project_handle.read(cx).git_store().clone();
+        let repository_entries = git_store.update(cx, |git_store, cx| {
+            filtered_repository_entries(git_store, cx)
+        });
         let project = project_handle.read(cx);
-        let git_store = project.git_store().clone();
-        let all_repositories = git_store.read(cx).all_repositories();
-        let filtered_repositories = all_repositories.clone();
+        let filtered_repositories = repository_entries.clone();
 
-        let widest_item_ix = all_repositories.iter().position_max_by(|a, b| {
+        let widest_item_ix = repository_entries.iter().position_max_by(|a, b| {
             a.read(cx)
                 .display_name(project, cx)
                 .len()
@@ -32,7 +37,7 @@ impl RepositorySelector {
         let delegate = RepositorySelectorDelegate {
             project: project_handle.downgrade(),
             repository_selector: cx.entity().downgrade(),
-            repository_entries: all_repositories.clone(),
+            repository_entries,
             filtered_repositories,
             selected_index: 0,
         };
@@ -47,6 +52,35 @@ impl RepositorySelector {
     }
 }
 
+pub(crate) fn filtered_repository_entries(
+    git_store: &GitStore,
+    cx: &App,
+) -> Vec<Entity<Repository>> {
+    let mut repository_entries = git_store.all_repositories();
+    repository_entries.sort_by_key(|repo| {
+        let repo = repo.read(cx);
+        (
+            repo.dot_git_abs_path.clone(),
+            repo.worktree_abs_path.clone(),
+        )
+    });
+    // Remove any entry that comes from a single file worktree and represents a repository that is also represented by a non-single-file worktree.
+    repository_entries
+        .chunk_by(|a, b| a.read(cx).dot_git_abs_path == b.read(cx).dot_git_abs_path)
+        .flat_map(|chunk| {
+            let has_non_single_file_worktree = chunk
+                .iter()
+                .any(|repo| !repo.read(cx).is_from_single_file_worktree);
+            chunk
+                .iter()
+                .filter(move |repo| {
+                    !repo.read(cx).is_from_single_file_worktree || !has_non_single_file_worktree
+                })
+                .cloned()
+        })
+        .collect()
+}
+
 impl EventEmitter<DismissEvent> for RepositorySelector {}
 
 impl Focusable for RepositorySelector {

crates/journal/src/journal.rs 🔗

@@ -133,13 +133,31 @@ pub fn new_journal_entry(workspace: &Workspace, window: &mut Window, cx: &mut Ap
                     .await?;
                 new_workspace
                     .update(&mut cx, |workspace, window, cx| {
-                        workspace.open_paths(vec![entry_path], OpenVisible::All, None, window, cx)
+                        workspace.open_paths(
+                            vec![entry_path],
+                            workspace::OpenOptions {
+                                visible: Some(OpenVisible::All),
+                                ..Default::default()
+                            },
+                            None,
+                            window,
+                            cx,
+                        )
                     })?
                     .await
             } else {
                 view_snapshot
                     .update_in(&mut cx, |workspace, window, cx| {
-                        workspace.open_paths(vec![entry_path], OpenVisible::All, None, window, cx)
+                        workspace.open_paths(
+                            vec![entry_path],
+                            workspace::OpenOptions {
+                                visible: Some(OpenVisible::All),
+                                ..Default::default()
+                            },
+                            None,
+                            window,
+                            cx,
+                        )
                     })?
                     .await
             };

crates/markdown_preview/src/markdown_renderer.rs 🔗

@@ -23,7 +23,7 @@ use ui::{
     LabelSize, LinkPreview, StatefulInteractiveElement, StyledExt, StyledImage, ToggleState,
     Tooltip, VisibleOnHover,
 };
-use workspace::Workspace;
+use workspace::{OpenOptions, OpenVisible, Workspace};
 
 type CheckboxClickedCallback = Arc<Box<dyn Fn(bool, Range<usize>, &mut Window, &mut App)>>;
 
@@ -490,7 +490,15 @@ fn render_markdown_text(parsed_new: &MarkdownParagraph, cx: &mut RenderContext)
                                     if let Some(workspace) = &workspace {
                                         _ = workspace.update(cx, |workspace, cx| {
                                             workspace
-                                                .open_abs_path(path.clone(), false, window, cx)
+                                                .open_abs_path(
+                                                    path.clone(),
+                                                    OpenOptions {
+                                                        visible: Some(OpenVisible::None),
+                                                        ..Default::default()
+                                                    },
+                                                    window,
+                                                    cx,
+                                                )
                                                 .detach();
                                         });
                                     }
@@ -545,7 +553,15 @@ fn render_markdown_text(parsed_new: &MarkdownParagraph, cx: &mut RenderContext)
                                         if let Some(workspace) = &workspace {
                                             _ = workspace.update(cx, |workspace, cx| {
                                                 workspace
-                                                    .open_abs_path(path.clone(), false, window, cx)
+                                                    .open_abs_path(
+                                                        path.clone(),
+                                                        OpenOptions {
+                                                            visible: Some(OpenVisible::None),
+                                                            ..Default::default()
+                                                        },
+                                                        window,
+                                                        cx,
+                                                    )
                                                     .detach();
                                             });
                                         }

crates/outline_panel/src/outline_panel.rs 🔗

@@ -5169,7 +5169,7 @@ mod tests {
     use search::project_search::{self, perform_project_search};
     use serde_json::json;
     use util::path;
-    use workspace::OpenVisible;
+    use workspace::{OpenOptions, OpenVisible};
 
     use super::*;
 
@@ -5780,7 +5780,10 @@ mod tests {
             .update(cx, |workspace, window, cx| {
                 workspace.open_paths(
                     vec![PathBuf::from("/root/two")],
-                    OpenVisible::OnlyDirectories,
+                    OpenOptions {
+                        visible: Some(OpenVisible::OnlyDirectories),
+                        ..Default::default()
+                    },
                     None,
                     window,
                     cx,
@@ -5971,7 +5974,15 @@ struct OutlineEntryExcerpt {
 
         let _editor = workspace
             .update(cx, |workspace, window, cx| {
-                workspace.open_abs_path(PathBuf::from(path!("/root/src/lib.rs")), true, window, cx)
+                workspace.open_abs_path(
+                    PathBuf::from(path!("/root/src/lib.rs")),
+                    OpenOptions {
+                        visible: Some(OpenVisible::All),
+                        ..Default::default()
+                    },
+                    window,
+                    cx,
+                )
             })
             .unwrap()
             .await

crates/project/src/git.rs 🔗

@@ -56,6 +56,9 @@ pub struct Repository {
     git_store: WeakEntity<GitStore>,
     pub worktree_id: WorktreeId,
     pub repository_entry: RepositoryEntry,
+    pub dot_git_abs_path: PathBuf,
+    pub worktree_abs_path: Arc<Path>,
+    pub is_from_single_file_worktree: bool,
     pub git_repo: GitRepo,
     pub merge_message: Option<String>,
     job_sender: mpsc::UnboundedSender<GitJob>,
@@ -227,6 +230,9 @@ impl GitStore {
                                 askpass_delegates: Default::default(),
                                 latest_askpass_id: 0,
                                 repository_entry: repo.clone(),
+                                dot_git_abs_path: worktree.dot_git_abs_path(&repo.work_directory),
+                                worktree_abs_path: worktree.abs_path(),
+                                is_from_single_file_worktree: worktree.is_single_file(),
                                 git_repo,
                                 job_sender: self.update_sender.clone(),
                                 merge_message,
@@ -979,7 +985,7 @@ impl Repository {
     }
 
     pub fn repo_path_to_project_path(&self, path: &RepoPath) -> Option<ProjectPath> {
-        let path = self.repository_entry.unrelativize(path)?;
+        let path = self.repository_entry.try_unrelativize(path)?;
         Some((self.worktree_id, path).into())
     }
 
@@ -1218,7 +1224,7 @@ impl Repository {
         if let Some(buffer_store) = self.buffer_store(cx) {
             buffer_store.update(cx, |buffer_store, cx| {
                 for path in &entries {
-                    let Some(path) = self.repository_entry.unrelativize(path) else {
+                    let Some(path) = self.repository_entry.try_unrelativize(path) else {
                         continue;
                     };
                     let project_path = (self.worktree_id, path).into();
@@ -1287,7 +1293,7 @@ impl Repository {
         if let Some(buffer_store) = self.buffer_store(cx) {
             buffer_store.update(cx, |buffer_store, cx| {
                 for path in &entries {
-                    let Some(path) = self.repository_entry.unrelativize(path) else {
+                    let Some(path) = self.repository_entry.try_unrelativize(path) else {
                         continue;
                     };
                     let project_path = (self.worktree_id, path).into();

crates/project_panel/src/project_panel.rs 🔗

@@ -59,7 +59,8 @@ use util::{maybe, paths::compare_paths, ResultExt, TakeUntilExt, TryFutureExt};
 use workspace::{
     dock::{DockPosition, Panel, PanelEvent},
     notifications::{DetachAndPromptErr, NotifyTaskExt},
-    DraggedSelection, OpenInTerminal, PreviewTabsSettings, SelectedEntry, Workspace,
+    DraggedSelection, OpenInTerminal, OpenOptions, OpenVisible, PreviewTabsSettings, SelectedEntry,
+    Workspace,
 };
 use worktree::{CreatedEntry, GitEntry, GitEntryRef};
 
@@ -1211,7 +1212,7 @@ impl ProjectPanel {
                                 project_panel
                                     .workspace
                                     .update(cx, |workspace, cx| {
-                                        workspace.open_abs_path(abs_path, true, window, cx)
+                                        workspace.open_abs_path(abs_path, OpenOptions { visible: Some(OpenVisible::All), ..Default::default() }, window, cx)
                                     })
                                     .ok()
                             }

crates/snippets_ui/src/snippets_ui.rs 🔗

@@ -9,7 +9,7 @@ use picker::{Picker, PickerDelegate};
 use std::{borrow::Borrow, fs, sync::Arc};
 use ui::{prelude::*, HighlightedLabel, ListItem, ListItemSpacing};
 use util::ResultExt;
-use workspace::{notifications::NotifyResultExt, ModalView, Workspace};
+use workspace::{notifications::NotifyResultExt, ModalView, OpenOptions, OpenVisible, Workspace};
 
 actions!(snippets, [ConfigureSnippets, OpenFolder]);
 
@@ -144,7 +144,10 @@ impl PickerDelegate for ScopeSelectorDelegate {
                         workspace
                             .open_abs_path(
                                 config_dir().join("snippets").join(scope + ".json"),
-                                false,
+                                OpenOptions {
+                                    visible: Some(OpenVisible::None),
+                                    ..Default::default()
+                                },
                                 window,
                                 cx,
                             )

crates/tasks_ui/src/modal.rs 🔗

@@ -602,7 +602,7 @@ mod tests {
     use serde_json::json;
     use task::TaskTemplates;
     use util::path;
-    use workspace::CloseInactiveTabsAndPanes;
+    use workspace::{CloseInactiveTabsAndPanes, OpenOptions, OpenVisible};
 
     use crate::{modal::Spawn, tests::init_test};
 
@@ -653,7 +653,15 @@ mod tests {
 
         let _ = workspace
             .update_in(cx, |workspace, window, cx| {
-                workspace.open_abs_path(PathBuf::from(path!("/dir/a.ts")), true, window, cx)
+                workspace.open_abs_path(
+                    PathBuf::from(path!("/dir/a.ts")),
+                    OpenOptions {
+                        visible: Some(OpenVisible::All),
+                        ..Default::default()
+                    },
+                    window,
+                    cx,
+                )
             })
             .await
             .unwrap();
@@ -819,7 +827,10 @@ mod tests {
             .update_in(cx, |workspace, window, cx| {
                 workspace.open_abs_path(
                     PathBuf::from(path!("/dir/file_with.odd_extension")),
-                    true,
+                    OpenOptions {
+                        visible: Some(OpenVisible::All),
+                        ..Default::default()
+                    },
                     window,
                     cx,
                 )
@@ -846,7 +857,10 @@ mod tests {
             .update_in(cx, |workspace, window, cx| {
                 workspace.open_abs_path(
                     PathBuf::from(path!("/dir/file_without_extension")),
-                    true,
+                    OpenOptions {
+                        visible: Some(OpenVisible::All),
+                        ..Default::default()
+                    },
                     window,
                     cx,
                 )
@@ -954,7 +968,15 @@ mod tests {
 
         let _ts_file_1 = workspace
             .update_in(cx, |workspace, window, cx| {
-                workspace.open_abs_path(PathBuf::from(path!("/dir/a1.ts")), true, window, cx)
+                workspace.open_abs_path(
+                    PathBuf::from(path!("/dir/a1.ts")),
+                    OpenOptions {
+                        visible: Some(OpenVisible::All),
+                        ..Default::default()
+                    },
+                    window,
+                    cx,
+                )
             })
             .await
             .unwrap();
@@ -995,7 +1017,15 @@ mod tests {
 
         let _ts_file_2 = workspace
             .update_in(cx, |workspace, window, cx| {
-                workspace.open_abs_path(PathBuf::from(path!("/dir/a2.ts")), true, window, cx)
+                workspace.open_abs_path(
+                    PathBuf::from(path!("/dir/a2.ts")),
+                    OpenOptions {
+                        visible: Some(OpenVisible::All),
+                        ..Default::default()
+                    },
+                    window,
+                    cx,
+                )
             })
             .await
             .unwrap();
@@ -1018,7 +1048,15 @@ mod tests {
 
         let _rs_file = workspace
             .update_in(cx, |workspace, window, cx| {
-                workspace.open_abs_path(PathBuf::from(path!("/dir/b.rs")), true, window, cx)
+                workspace.open_abs_path(
+                    PathBuf::from(path!("/dir/b.rs")),
+                    OpenOptions {
+                        visible: Some(OpenVisible::All),
+                        ..Default::default()
+                    },
+                    window,
+                    cx,
+                )
             })
             .await
             .unwrap();
@@ -1033,7 +1071,15 @@ mod tests {
         emulate_task_schedule(tasks_picker, &project, "Rust task", cx);
         let _ts_file_2 = workspace
             .update_in(cx, |workspace, window, cx| {
-                workspace.open_abs_path(PathBuf::from(path!("/dir/a2.ts")), true, window, cx)
+                workspace.open_abs_path(
+                    PathBuf::from(path!("/dir/a2.ts")),
+                    OpenOptions {
+                        visible: Some(OpenVisible::All),
+                        ..Default::default()
+                    },
+                    window,
+                    cx,
+                )
             })
             .await
             .unwrap();

crates/terminal_view/src/terminal_view.rs 🔗

@@ -38,8 +38,8 @@ use workspace::{
     },
     register_serializable_item,
     searchable::{Direction, SearchEvent, SearchOptions, SearchableItem, SearchableItemHandle},
-    CloseActiveItem, NewCenterTerminal, NewTerminal, OpenVisible, ToolbarItemLocation, Workspace,
-    WorkspaceId,
+    CloseActiveItem, NewCenterTerminal, NewTerminal, OpenOptions, OpenVisible, ToolbarItemLocation,
+    Workspace, WorkspaceId,
 };
 
 use anyhow::Context as _;
@@ -910,7 +910,10 @@ fn subscribe_for_terminal_events(
                                 .update_in(&mut cx, |workspace, window, cx| {
                                     workspace.open_paths(
                                         vec![path_to_open.path.clone()],
-                                        OpenVisible::OnlyDirectories,
+                                        OpenOptions {
+                                            visible: Some(OpenVisible::OnlyDirectories),
+                                            ..Default::default()
+                                        },
                                         None,
                                         window,
                                         cx,

crates/util/src/util.rs 🔗

@@ -828,6 +828,10 @@ pub fn word_consists_of_emojis(s: &str) -> bool {
     prev_end == s.len()
 }
 
+pub fn default<D: Default>() -> D {
+    Default::default()
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;

crates/workspace/src/pane.rs 🔗

@@ -7,8 +7,8 @@ use crate::{
     notifications::NotifyResultExt,
     toolbar::Toolbar,
     workspace_settings::{AutosaveSetting, TabBarSettings, WorkspaceSettings},
-    CloseWindow, NewFile, NewTerminal, OpenInTerminal, OpenTerminal, OpenVisible, SplitDirection,
-    ToggleFileFinder, ToggleProjectSymbols, ToggleZoom, Workspace,
+    CloseWindow, NewFile, NewTerminal, OpenInTerminal, OpenOptions, OpenTerminal, OpenVisible,
+    SplitDirection, ToggleFileFinder, ToggleProjectSymbols, ToggleZoom, Workspace,
 };
 use anyhow::Result;
 use collections::{BTreeSet, HashMap, HashSet, VecDeque};
@@ -3086,7 +3086,10 @@ impl Pane {
                         }
                         workspace.open_paths(
                             paths,
-                            OpenVisible::OnlyDirectories,
+                            OpenOptions {
+                                visible: Some(OpenVisible::OnlyDirectories),
+                                ..Default::default()
+                            },
                             Some(to_pane.downgrade()),
                             window,
                             cx,

crates/workspace/src/workspace.rs 🔗

@@ -1534,7 +1534,7 @@ impl Workspace {
                                 pane.active_item().map(|p| p.item_id())
                             })?;
                             let open_by_abs_path = workspace.update_in(&mut cx, |workspace, window, cx| {
-                                workspace.open_abs_path(abs_path.clone(), false, window, cx)
+                                workspace.open_abs_path(abs_path.clone(), OpenOptions { visible: Some(OpenVisible::None), ..Default::default() }, window, cx)
                             })?;
                             match open_by_abs_path
                                 .await
@@ -2112,7 +2112,7 @@ impl Workspace {
     pub fn open_paths(
         &mut self,
         mut abs_paths: Vec<PathBuf>,
-        visible: OpenVisible,
+        options: OpenOptions,
         pane: Option<WeakEntity<Pane>>,
         window: &mut Window,
         cx: &mut Context<Self>,
@@ -2127,7 +2127,7 @@ impl Workspace {
             let mut tasks = Vec::with_capacity(abs_paths.len());
 
             for abs_path in &abs_paths {
-                let visible = match visible {
+                let visible = match options.visible.as_ref().unwrap_or(&OpenVisible::None) {
                     OpenVisible::All => Some(true),
                     OpenVisible::None => Some(false),
                     OpenVisible::OnlyFiles => match fs.metadata(abs_path).await.log_err() {
@@ -2191,7 +2191,13 @@ impl Workspace {
                     } else {
                         Some(
                             this.update_in(&mut cx, |this, window, cx| {
-                                this.open_path(project_path, pane, true, window, cx)
+                                this.open_path(
+                                    project_path,
+                                    pane,
+                                    options.focus.unwrap_or(true),
+                                    window,
+                                    cx,
+                                )
                             })
                             .log_err()?
                             .await,
@@ -2215,7 +2221,15 @@ impl Workspace {
             ResolvedPath::ProjectPath { project_path, .. } => {
                 self.open_path(project_path, None, true, window, cx)
             }
-            ResolvedPath::AbsPath { path, .. } => self.open_abs_path(path, false, window, cx),
+            ResolvedPath::AbsPath { path, .. } => self.open_abs_path(
+                path,
+                OpenOptions {
+                    visible: Some(OpenVisible::None),
+                    ..Default::default()
+                },
+                window,
+                cx,
+            ),
         }
     }
 
@@ -2259,7 +2273,16 @@ impl Workspace {
             if let Some(paths) = paths.await.log_err().flatten() {
                 let results = this
                     .update_in(&mut cx, |this, window, cx| {
-                        this.open_paths(paths, OpenVisible::All, None, window, cx)
+                        this.open_paths(
+                            paths,
+                            OpenOptions {
+                                visible: Some(OpenVisible::All),
+                                ..Default::default()
+                            },
+                            None,
+                            window,
+                            cx,
+                        )
                     })?
                     .await;
                 for result in results.into_iter().flatten() {
@@ -2752,24 +2775,14 @@ impl Workspace {
     pub fn open_abs_path(
         &mut self,
         abs_path: PathBuf,
-        visible: bool,
+        options: OpenOptions,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Task<anyhow::Result<Box<dyn ItemHandle>>> {
         cx.spawn_in(window, |workspace, mut cx| async move {
             let open_paths_task_result = workspace
                 .update_in(&mut cx, |workspace, window, cx| {
-                    workspace.open_paths(
-                        vec![abs_path.clone()],
-                        if visible {
-                            OpenVisible::All
-                        } else {
-                            OpenVisible::None
-                        },
-                        None,
-                        window,
-                        cx,
-                    )
+                    workspace.open_paths(vec![abs_path.clone()], options, None, window, cx)
                 })
                 .with_context(|| format!("open abs path {abs_path:?} task spawn"))?
                 .await;
@@ -6002,10 +6015,13 @@ pub fn local_workspace_windows(cx: &App) -> Vec<WindowHandle<Workspace>> {
 
 #[derive(Default)]
 pub struct OpenOptions {
+    pub visible: Option<OpenVisible>,
+    pub focus: Option<bool>,
     pub open_new_workspace: Option<bool>,
     pub replace_window: Option<WindowHandle<Workspace>>,
     pub env: Option<HashMap<String, String>>,
 }
+
 #[allow(clippy::type_complexity)]
 pub fn open_paths(
     abs_paths: &[PathBuf],
@@ -6089,7 +6105,16 @@ pub fn open_paths(
             let open_task = existing
                 .update(&mut cx, |workspace, window, cx| {
                     window.activate_window();
-                    workspace.open_paths(abs_paths, open_visible, None, window, cx)
+                    workspace.open_paths(
+                        abs_paths,
+                        OpenOptions {
+                            visible: Some(open_visible),
+                            ..Default::default()
+                        },
+                        None,
+                        window,
+                        cx,
+                    )
                 })?
                 .await;
 
@@ -6154,7 +6179,10 @@ pub fn create_and_open_local_file(
                 workspace.with_local_workspace(window, cx, |workspace, window, cx| {
                     workspace.open_paths(
                         vec![path.to_path_buf()],
-                        OpenVisible::None,
+                        OpenOptions {
+                            visible: Some(OpenVisible::None),
+                            ..Default::default()
+                        },
                         None,
                         window,
                         cx,

crates/worktree/src/worktree.rs 🔗

@@ -58,7 +58,7 @@ use std::{
     future::Future,
     mem::{self},
     ops::{Deref, DerefMut},
-    path::{Path, PathBuf},
+    path::{Component, Path, PathBuf},
     pin::Pin,
     sync::{
         atomic::{self, AtomicU32, AtomicUsize, Ordering::SeqCst},
@@ -212,7 +212,11 @@ impl RepositoryEntry {
         self.work_directory.relativize(path)
     }
 
-    pub fn unrelativize(&self, path: &RepoPath) -> Option<Arc<Path>> {
+    pub fn try_unrelativize(&self, path: &RepoPath) -> Option<Arc<Path>> {
+        self.work_directory.try_unrelativize(path)
+    }
+
+    pub fn unrelativize(&self, path: &RepoPath) -> Arc<Path> {
         self.work_directory.unrelativize(path)
     }
 
@@ -491,7 +495,7 @@ impl WorkDirectory {
     }
 
     /// This is the opposite operation to `relativize` above
-    pub fn unrelativize(&self, path: &RepoPath) -> Option<Arc<Path>> {
+    pub fn try_unrelativize(&self, path: &RepoPath) -> Option<Arc<Path>> {
         match self {
             WorkDirectory::InProject { relative_path } => Some(relative_path.join(path).into()),
             WorkDirectory::AboveProject {
@@ -504,6 +508,33 @@ impl WorkDirectory {
         }
     }
 
+    pub fn unrelativize(&self, path: &RepoPath) -> Arc<Path> {
+        match self {
+            WorkDirectory::InProject { relative_path } => relative_path.join(path).into(),
+            WorkDirectory::AboveProject {
+                location_in_repo, ..
+            } => {
+                if &path.0 == location_in_repo {
+                    // Single-file worktree
+                    return location_in_repo
+                        .file_name()
+                        .map(Path::new)
+                        .unwrap_or(Path::new(""))
+                        .into();
+                }
+                let mut location_in_repo = &**location_in_repo;
+                let mut parents = PathBuf::new();
+                loop {
+                    if let Ok(segment) = path.strip_prefix(location_in_repo) {
+                        return parents.join(segment).into();
+                    }
+                    location_in_repo = location_in_repo.parent().unwrap_or(Path::new(""));
+                    parents.push(Component::ParentDir);
+                }
+            }
+        }
+    }
+
     pub fn display_name(&self) -> String {
         match self {
             WorkDirectory::InProject { relative_path } => relative_path.display().to_string(),
@@ -1422,6 +1453,19 @@ impl Worktree {
             worktree_scan_id: scan_id as u64,
         })
     }
+
+    pub fn dot_git_abs_path(&self, work_directory: &WorkDirectory) -> PathBuf {
+        let mut path = match work_directory {
+            WorkDirectory::InProject { relative_path } => self.abs_path().join(relative_path),
+            WorkDirectory::AboveProject { absolute_path, .. } => absolute_path.as_ref().to_owned(),
+        };
+        path.push(".git");
+        path
+    }
+
+    pub fn is_single_file(&self) -> bool {
+        self.root_dir().is_none()
+    }
 }
 
 impl LocalWorktree {
@@ -5509,7 +5553,7 @@ impl BackgroundScanner {
 
                 let mut new_entries_by_path = SumTree::new(&());
                 for (repo_path, status) in statuses.entries.iter() {
-                    let project_path = repository.work_directory.unrelativize(repo_path);
+                    let project_path = repository.work_directory.try_unrelativize(repo_path);
 
                     new_entries_by_path.insert_or_replace(
                         StatusEntry {

crates/worktree/src/worktree_tests.rs 🔗

@@ -3412,6 +3412,43 @@ async fn test_private_single_file_worktree(cx: &mut TestAppContext) {
     });
 }
 
+#[gpui::test]
+fn test_unrelativize() {
+    let work_directory = WorkDirectory::in_project("");
+    pretty_assertions::assert_eq!(
+        work_directory.try_unrelativize(&"crates/gpui/gpui.rs".into()),
+        Some(Path::new("crates/gpui/gpui.rs").into())
+    );
+
+    let work_directory = WorkDirectory::in_project("vendor/some-submodule");
+    pretty_assertions::assert_eq!(
+        work_directory.try_unrelativize(&"src/thing.c".into()),
+        Some(Path::new("vendor/some-submodule/src/thing.c").into())
+    );
+
+    let work_directory = WorkDirectory::AboveProject {
+        absolute_path: Path::new("/projects/zed").into(),
+        location_in_repo: Path::new("crates/gpui").into(),
+    };
+
+    pretty_assertions::assert_eq!(
+        work_directory.try_unrelativize(&"crates/util/util.rs".into()),
+        None,
+    );
+
+    pretty_assertions::assert_eq!(
+        work_directory.unrelativize(&"crates/util/util.rs".into()),
+        Path::new("../util/util.rs").into()
+    );
+
+    pretty_assertions::assert_eq!(work_directory.try_unrelativize(&"README.md".into()), None,);
+
+    pretty_assertions::assert_eq!(
+        work_directory.unrelativize(&"README.md".into()),
+        Path::new("../../README.md").into()
+    );
+}
+
 #[track_caller]
 fn check_git_statuses(snapshot: &Snapshot, expected_statuses: &[(&Path, GitSummary)]) {
     let mut traversal = snapshot

crates/zed/src/zed.rs 🔗

@@ -22,6 +22,7 @@ use editor::ProposedChangesEditorToolbar;
 use editor::{scroll::Autoscroll, Editor, MultiBuffer};
 use feature_flags::{FeatureFlagAppExt, FeatureFlagViewExt, GitUiFeatureFlag};
 use futures::{channel::mpsc, select_biased, StreamExt};
+use git_ui::git_panel::GitPanel;
 use git_ui::project_diff::ProjectDiffToolbar;
 use gpui::{
     actions, point, px, Action, App, AppContext as _, AsyncApp, Context, DismissEvent, Element,
@@ -429,7 +430,10 @@ fn initialize_panels(
             workspace.add_panel(chat_panel, window, cx);
             workspace.add_panel(notification_panel, window, cx);
             cx.when_flag_enabled::<GitUiFeatureFlag>(window, |workspace, window, cx| {
-                let git_panel = git_ui::git_panel::GitPanel::new(workspace, window, cx);
+                let entity = cx.entity();
+                let project = workspace.project().clone();
+                let app_state = workspace.app_state().clone();
+                let git_panel = cx.new(|cx| GitPanel::new(entity, project, app_state, window, cx));
                 workspace.add_panel(git_panel, window, cx);
             });
         })?;
@@ -1479,8 +1483,7 @@ pub fn open_new_ssh_project_from_project(
             app_state,
             workspace::OpenOptions {
                 open_new_workspace: Some(true),
-                replace_window: None,
-                env: None,
+                ..Default::default()
             },
             &mut cx,
         )
@@ -1749,7 +1752,7 @@ mod tests {
     use util::{path, separator};
     use workspace::{
         item::{Item, ItemHandle},
-        open_new, open_paths, pane, NewFile, OpenVisible, SaveIntent, SplitDirection,
+        open_new, open_paths, pane, NewFile, OpenOptions, OpenVisible, SaveIntent, SplitDirection,
         WorkspaceHandle, SERIALIZATION_THROTTLE_TIME,
     };
 
@@ -2552,7 +2555,10 @@ mod tests {
             .update(cx, |workspace, window, cx| {
                 workspace.open_paths(
                     vec![path!("/dir1/a.txt").into()],
-                    OpenVisible::All,
+                    OpenOptions {
+                        visible: Some(OpenVisible::All),
+                        ..Default::default()
+                    },
                     None,
                     window,
                     cx,
@@ -2587,7 +2593,10 @@ mod tests {
             .update(cx, |workspace, window, cx| {
                 workspace.open_paths(
                     vec![path!("/dir2/b.txt").into()],
-                    OpenVisible::All,
+                    OpenOptions {
+                        visible: Some(OpenVisible::All),
+                        ..Default::default()
+                    },
                     None,
                     window,
                     cx,
@@ -2633,7 +2642,10 @@ mod tests {
             .update(cx, |workspace, window, cx| {
                 workspace.open_paths(
                     vec![path!("/dir3").into(), path!("/dir3/c.txt").into()],
-                    OpenVisible::All,
+                    OpenOptions {
+                        visible: Some(OpenVisible::All),
+                        ..Default::default()
+                    },
                     None,
                     window,
                     cx,
@@ -2679,7 +2691,10 @@ mod tests {
             .update(cx, |workspace, window, cx| {
                 workspace.open_paths(
                     vec![path!("/d.txt").into()],
-                    OpenVisible::None,
+                    OpenOptions {
+                        visible: Some(OpenVisible::None),
+                        ..Default::default()
+                    },
                     None,
                     window,
                     cx,
@@ -2889,7 +2904,10 @@ mod tests {
             .update(cx, |workspace, window, cx| {
                 workspace.open_paths(
                     vec![PathBuf::from(path!("/root/a.txt"))],
-                    OpenVisible::All,
+                    OpenOptions {
+                        visible: Some(OpenVisible::All),
+                        ..Default::default()
+                    },
                     None,
                     window,
                     cx,