Project Diff 2 (#23891)

Conrad Irwin created

This adds a new version of the project diff editor to go alongside the
new git panel.

The basics seem to be working, but still todo:

* [ ] Fix untracked files
* [ ] Fix deleted files
* [ ] Show commit message editor at top
* [x] Handle empty state
* [x] Fix panic where locator sometimes seeks to wrong excerpt

Release Notes:

- N/A

Change summary

Cargo.lock                                    |   4 
crates/client/src/user.rs                     |   5 
crates/editor/Cargo.toml                      |   1 
crates/editor/src/editor.rs                   |  13 
crates/editor/src/git.rs                      |   1 
crates/editor/src/inlay_hint_cache.rs         |   4 
crates/feature_flags/src/feature_flags.rs     |  51 ++
crates/git/src/status.rs                      |   4 
crates/git_ui/Cargo.toml                      |   3 
crates/git_ui/src/git_panel.rs                | 103 ++--
crates/git_ui/src/git_ui.rs                   |   3 
crates/git_ui/src/project_diff.rs             | 495 +++++++++++++++++++++
crates/git_ui/src/repository_selector.rs      |  13 
crates/go_to_line/src/go_to_line.rs           |   2 
crates/multi_buffer/src/multi_buffer.rs       | 155 ++++++
crates/multi_buffer/src/multi_buffer_tests.rs | 220 +++++++++
crates/outline_panel/src/outline_panel.rs     |   2 
crates/project/src/git.rs                     |  13 
crates/project/src/project.rs                 |  25 
crates/rope/src/point.rs                      |  12 
crates/zed/src/zed.rs                         |  19 
21 files changed, 1,023 insertions(+), 125 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -4009,7 +4009,6 @@ dependencies = [
  "db",
  "emojis",
  "env_logger 0.11.6",
- "feature_flags",
  "file_icons",
  "fs",
  "futures 0.3.31",
@@ -5307,12 +5306,15 @@ dependencies = [
  "collections",
  "db",
  "editor",
+ "feature_flags",
  "futures 0.3.31",
  "git",
  "gpui",
  "language",
  "menu",
+ "multi_buffer",
  "picker",
+ "postage",
  "project",
  "rpc",
  "schemars",

crates/client/src/user.rs 🔗

@@ -201,9 +201,8 @@ impl UserStore {
 
                                 cx.update(|cx| {
                                     if let Some(info) = info {
-                                        let disable_staff = std::env::var("ZED_DISABLE_STAFF")
-                                            .map_or(false, |v| !v.is_empty() && v != "0");
-                                        let staff = info.staff && !disable_staff;
+                                        let staff =
+                                            info.staff && !*feature_flags::ZED_DISABLE_STAFF;
                                         cx.update_flags(staff, info.flags);
                                         client.telemetry.set_authenticated_user_info(
                                             Some(info.metrics_id.clone()),

crates/editor/Cargo.toml 🔗

@@ -39,7 +39,6 @@ collections.workspace = true
 convert_case.workspace = true
 db.workspace = true
 emojis.workspace = true
-feature_flags.workspace = true
 file_icons.workspace = true
 futures.workspace = true
 fuzzy.workspace = true

crates/editor/src/editor.rs 🔗

@@ -339,7 +339,6 @@ pub fn init(cx: &mut App) {
             .detach();
         }
     });
-    git::project_diff::init(cx);
 }
 
 pub struct SearchWithinRange;
@@ -4653,7 +4652,7 @@ impl Editor {
                     let mut read_ranges = Vec::new();
                     for highlight in highlights {
                         for (excerpt_id, excerpt_range) in
-                            buffer.excerpts_for_buffer(&cursor_buffer, cx)
+                            buffer.excerpts_for_buffer(cursor_buffer.read(cx).remote_id(), cx)
                         {
                             let start = highlight
                                 .range
@@ -11747,10 +11746,7 @@ impl Editor {
         if self.buffer().read(cx).is_singleton() || self.is_buffer_folded(buffer_id, cx) {
             return;
         }
-        let Some(buffer) = self.buffer().read(cx).buffer(buffer_id) else {
-            return;
-        };
-        let folded_excerpts = self.buffer().read(cx).excerpts_for_buffer(&buffer, cx);
+        let folded_excerpts = self.buffer().read(cx).excerpts_for_buffer(buffer_id, cx);
         self.display_map
             .update(cx, |display_map, cx| display_map.fold_buffer(buffer_id, cx));
         cx.emit(EditorEvent::BufferFoldToggled {
@@ -11764,10 +11760,7 @@ impl Editor {
         if self.buffer().read(cx).is_singleton() || !self.is_buffer_folded(buffer_id, cx) {
             return;
         }
-        let Some(buffer) = self.buffer().read(cx).buffer(buffer_id) else {
-            return;
-        };
-        let unfolded_excerpts = self.buffer().read(cx).excerpts_for_buffer(&buffer, cx);
+        let unfolded_excerpts = self.buffer().read(cx).excerpts_for_buffer(buffer_id, cx);
         self.display_map.update(cx, |display_map, cx| {
             display_map.unfold_buffer(buffer_id, cx);
         });

crates/editor/src/inlay_hint_cache.rs 🔗

@@ -743,12 +743,12 @@ fn determine_query_ranges(
     excerpt_visible_range: Range<usize>,
     cx: &mut Context<'_, MultiBuffer>,
 ) -> Option<QueryRanges> {
+    let buffer = excerpt_buffer.read(cx);
     let full_excerpt_range = multi_buffer
-        .excerpts_for_buffer(excerpt_buffer, cx)
+        .excerpts_for_buffer(buffer.remote_id(), cx)
         .into_iter()
         .find(|(id, _)| id == &excerpt_id)
         .map(|(_, range)| range.context)?;
-    let buffer = excerpt_buffer.read(cx);
     let snapshot = buffer.snapshot();
     let excerpt_visible_len = excerpt_visible_range.end - excerpt_visible_range.start;
 

crates/feature_flags/src/feature_flags.rs 🔗

@@ -1,6 +1,9 @@
 use futures::channel::oneshot;
 use futures::{select_biased, FutureExt};
 use gpui::{App, Context, Global, Subscription, Task, Window};
+use std::cell::RefCell;
+use std::rc::Rc;
+use std::sync::LazyLock;
 use std::time::Duration;
 use std::{future::Future, pin::Pin, task::Poll};
 
@@ -10,12 +13,21 @@ struct FeatureFlags {
     staff: bool,
 }
 
+pub static ZED_DISABLE_STAFF: LazyLock<bool> = LazyLock::new(|| {
+    std::env::var("ZED_DISABLE_STAFF").map_or(false, |value| !value.is_empty() && value != "0")
+});
+
 impl FeatureFlags {
     fn has_flag<T: FeatureFlag>(&self) -> bool {
         if self.staff && T::enabled_for_staff() {
             return true;
         }
 
+        #[cfg(debug_assertions)]
+        if T::enabled_in_development() {
+            return true;
+        }
+
         self.flags.iter().any(|f| f.as_str() == T::NAME)
     }
 }
@@ -35,6 +47,10 @@ pub trait FeatureFlag {
     fn enabled_for_staff() -> bool {
         true
     }
+
+    fn enabled_in_development() -> bool {
+        Self::enabled_for_staff() && !*ZED_DISABLE_STAFF
+    }
 }
 
 pub struct Assistant2FeatureFlag;
@@ -97,6 +113,12 @@ pub trait FeatureFlagViewExt<V: 'static> {
     fn observe_flag<T: FeatureFlag, F>(&mut self, window: &Window, callback: F) -> Subscription
     where
         F: Fn(bool, &mut V, &mut Window, &mut Context<V>) + Send + Sync + 'static;
+
+    fn when_flag_enabled<T: FeatureFlag>(
+        &mut self,
+        window: &mut Window,
+        callback: impl Fn(&mut V, &mut Window, &mut Context<V>) + Send + Sync + 'static,
+    );
 }
 
 impl<V> FeatureFlagViewExt<V> for Context<'_, V>
@@ -112,6 +134,35 @@ where
             callback(feature_flags.has_flag::<T>(), v, window, cx);
         })
     }
+
+    fn when_flag_enabled<T: FeatureFlag>(
+        &mut self,
+        window: &mut Window,
+        callback: impl Fn(&mut V, &mut Window, &mut Context<V>) + Send + Sync + 'static,
+    ) {
+        if self
+            .try_global::<FeatureFlags>()
+            .is_some_and(|f| f.has_flag::<T>())
+            || cfg!(debug_assertions) && T::enabled_in_development()
+        {
+            self.defer_in(window, move |view, window, cx| {
+                callback(view, window, cx);
+            });
+            return;
+        }
+        let subscription = Rc::new(RefCell::new(None));
+        let inner = self.observe_global_in::<FeatureFlags>(window, {
+            let subscription = subscription.clone();
+            move |v, window, cx| {
+                let feature_flags = cx.global::<FeatureFlags>();
+                if feature_flags.has_flag::<T>() {
+                    callback(v, window, cx);
+                    subscription.take();
+                }
+            }
+        });
+        subscription.borrow_mut().replace(inner);
+    }
 }
 
 pub trait FeatureFlagAppExt {

crates/git/src/status.rs 🔗

@@ -133,6 +133,10 @@ impl FileStatus {
         }
     }
 
+    pub fn has_changes(&self) -> bool {
+        self.is_modified() || self.is_created() || self.is_deleted() || self.is_untracked()
+    }
+
     pub fn is_modified(self) -> bool {
         match self {
             FileStatus::Tracked(tracked) => match (tracked.index_status, tracked.worktree_status) {

crates/git_ui/Cargo.toml 🔗

@@ -17,11 +17,14 @@ anyhow.workspace = true
 collections.workspace = true
 db.workspace = true
 editor.workspace = true
+feature_flags.workspace = true
 futures.workspace = true
 git.workspace = true
 gpui.workspace = true
 language.workspace = true
+multi_buffer.workspace = true
 menu.workspace = true
+postage.workspace = true
 project.workspace = true
 rpc.workspace = true
 schemars.workspace = true

crates/git_ui/src/git_panel.rs 🔗

@@ -1,5 +1,6 @@
 use crate::git_panel_settings::StatusStyle;
 use crate::repository_selector::RepositorySelectorPopoverMenu;
+use crate::ProjectDiff;
 use crate::{
     git_panel_settings::GitPanelSettings, git_status_icon, repository_selector::RepositorySelector,
 };
@@ -207,31 +208,6 @@ fn commit_message_editor(
 }
 
 impl GitPanel {
-    pub fn load(
-        workspace: WeakEntity<Workspace>,
-        cx: AsyncWindowContext,
-    ) -> Task<Result<Entity<Self>>> {
-        cx.spawn(|mut cx| async move {
-            let commit_message_buffer = workspace.update(&mut cx, |workspace, cx| {
-                let project = workspace.project();
-                let active_repository = project.read(cx).active_repository(cx);
-                active_repository
-                    .map(|active_repository| commit_message_buffer(project, &active_repository, cx))
-            })?;
-            let commit_message_buffer = match commit_message_buffer {
-                Some(commit_message_buffer) => Some(
-                    commit_message_buffer
-                        .await
-                        .context("opening commit buffer")?,
-                ),
-                None => None,
-            };
-            workspace.update_in(&mut cx, |workspace, window, cx| {
-                Self::new(workspace, window, commit_message_buffer, cx)
-            })
-        })
-    }
-
     pub fn new(
         workspace: &mut Workspace,
         window: &mut Window,
@@ -240,7 +216,7 @@ impl GitPanel {
     ) -> Entity<Self> {
         let fs = workspace.app_state().fs.clone();
         let project = workspace.project().clone();
-        let git_state = project.read(cx).git_state().cloned();
+        let git_state = project.read(cx).git_state().clone();
         let active_repository = project.read(cx).active_repository(cx);
         let (err_sender, mut err_receiver) = mpsc::channel(1);
         let workspace = cx.entity().downgrade();
@@ -261,19 +237,17 @@ impl GitPanel {
 
             let scroll_handle = UniformListScrollHandle::new();
 
-            if let Some(git_state) = git_state {
-                cx.subscribe_in(
-                    &git_state,
-                    window,
-                    move |this, git_state, event, window, cx| match event {
-                        project::git::Event::RepositoriesUpdated => {
-                            this.active_repository = git_state.read(cx).active_repository();
-                            this.schedule_update(window, cx);
-                        }
-                    },
-                )
-                .detach();
-            }
+            cx.subscribe_in(
+                &git_state,
+                window,
+                move |this, git_state, event, window, cx| match event {
+                    project::git::Event::RepositoriesUpdated => {
+                        this.active_repository = git_state.read(cx).active_repository();
+                        this.schedule_update(window, cx);
+                    }
+                },
+            )
+            .detach();
 
             let repository_selector =
                 cx.new(|cx| RepositorySelector::new(project.clone(), window, cx));
@@ -344,8 +318,24 @@ impl GitPanel {
         git_panel
     }
 
+    pub fn set_focused_path(&mut self, path: ProjectPath, _: &mut Window, cx: &mut Context<Self>) {
+        let Some(git_repo) = self.active_repository.as_ref() else {
+            return;
+        };
+        let Some(repo_path) = git_repo.project_path_to_repo_path(&path) else {
+            return;
+        };
+        let Ok(ix) = self
+            .visible_entries
+            .binary_search_by_key(&&repo_path, |entry| &entry.repo_path)
+        else {
+            return;
+        };
+        self.selected_entry = Some(ix);
+        cx.notify();
+    }
+
     fn serialize(&mut self, cx: &mut Context<Self>) {
-        // TODO: we can store stage status here
         let width = self.width;
         self.pending_serialization = cx.background_executor().spawn(
             async move {
@@ -623,7 +613,7 @@ impl GitPanel {
         let Some(active_repository) = self.active_repository.as_ref() else {
             return;
         };
-        let Some(path) = active_repository.unrelativize(&entry.repo_path) else {
+        let Some(path) = active_repository.repo_path_to_project_path(&entry.repo_path) else {
             return;
         };
         let path_exists = self.project.update(cx, |project, cx| {
@@ -1021,8 +1011,8 @@ impl GitPanel {
             .project
             .read(cx)
             .git_state()
-            .map(|state| state.read(cx).all_repositories())
-            .unwrap_or_default();
+            .read(cx)
+            .all_repositories();
         let entry_count = self
             .active_repository
             .as_ref()
@@ -1408,17 +1398,26 @@ impl GitPanel {
                 .toggle_state(selected)
                 .disabled(!has_write_access)
                 .on_click({
-                    let handle = cx.entity().downgrade();
-                    move |_, window, cx| {
-                        let Some(this) = handle.upgrade() else {
+                    let repo_path = entry_details.repo_path.clone();
+                    cx.listener(move |this, _, window, cx| {
+                        this.selected_entry = Some(ix);
+                        window.dispatch_action(Box::new(OpenSelected), cx);
+                        cx.notify();
+                        let Some(workspace) = this.workspace.upgrade() else {
                             return;
                         };
-                        this.update(cx, |this, cx| {
-                            this.selected_entry = Some(ix);
-                            window.dispatch_action(Box::new(OpenSelected), cx);
-                            cx.notify();
-                        });
-                    }
+                        let Some(git_repo) = this.active_repository.as_ref() else {
+                            return;
+                        };
+                        let Some(path) = git_repo.repo_path_to_project_path(&repo_path).and_then(
+                            |project_path| this.project.read(cx).absolute_path(&project_path, cx),
+                        ) else {
+                            return;
+                        };
+                        workspace.update(cx, |workspace, cx| {
+                            ProjectDiff::deploy_at(workspace, Some(path.into()), window, cx);
+                        })
+                    })
                 })
                 .child(
                     h_flex()

crates/git_ui/src/git_ui.rs 🔗

@@ -2,14 +2,17 @@ use ::settings::Settings;
 use git::status::FileStatus;
 use git_panel_settings::GitPanelSettings;
 use gpui::App;
+use project_diff::ProjectDiff;
 use ui::{ActiveTheme, Color, Icon, IconName, IntoElement};
 
 pub mod git_panel;
 mod git_panel_settings;
+pub mod project_diff;
 pub mod repository_selector;
 
 pub fn init(cx: &mut App) {
     GitPanelSettings::register(cx);
+    cx.observe_new(ProjectDiff::register).detach();
 }
 
 // TODO: Add updated status colors to theme

crates/git_ui/src/project_diff.rs 🔗

@@ -0,0 +1,495 @@
+use std::{
+    any::{Any, TypeId},
+    path::Path,
+    sync::Arc,
+};
+
+use anyhow::Result;
+use collections::HashSet;
+use editor::{scroll::Autoscroll, Editor, EditorEvent};
+use feature_flags::FeatureFlagViewExt;
+use futures::StreamExt;
+use gpui::{
+    actions, AnyElement, AnyView, App, AppContext, AsyncWindowContext, Entity, EventEmitter,
+    FocusHandle, Focusable, Render, Subscription, Task, WeakEntity,
+};
+use language::{Anchor, Buffer, Capability, OffsetRangeExt};
+use multi_buffer::MultiBuffer;
+use project::{buffer_store::BufferChangeSet, git::GitState, Project, ProjectPath};
+use theme::ActiveTheme;
+use ui::prelude::*;
+use util::ResultExt as _;
+use workspace::{
+    item::{BreadcrumbText, Item, ItemEvent, ItemHandle, TabContentParams},
+    searchable::SearchableItemHandle,
+    ItemNavHistory, ToolbarItemLocation, Workspace,
+};
+
+use crate::git_panel::GitPanel;
+
+actions!(git, [ShowUncommittedChanges]);
+
+pub(crate) struct ProjectDiff {
+    multibuffer: Entity<MultiBuffer>,
+    editor: Entity<Editor>,
+    project: Entity<Project>,
+    git_state: Entity<GitState>,
+    workspace: WeakEntity<Workspace>,
+    focus_handle: FocusHandle,
+    update_needed: postage::watch::Sender<()>,
+    pending_scroll: Option<Arc<Path>>,
+
+    _task: Task<Result<()>>,
+    _subscription: Subscription,
+}
+
+struct DiffBuffer {
+    abs_path: Arc<Path>,
+    buffer: Entity<Buffer>,
+    change_set: Entity<BufferChangeSet>,
+}
+
+impl ProjectDiff {
+    pub(crate) fn register(
+        _: &mut Workspace,
+        window: Option<&mut Window>,
+        cx: &mut Context<Workspace>,
+    ) {
+        let Some(window) = window else { return };
+        cx.when_flag_enabled::<feature_flags::GitUiFeatureFlag>(window, |workspace, _, _cx| {
+            workspace.register_action(Self::deploy);
+        });
+    }
+
+    fn deploy(
+        workspace: &mut Workspace,
+        _: &ShowUncommittedChanges,
+        window: &mut Window,
+        cx: &mut Context<Workspace>,
+    ) {
+        Self::deploy_at(workspace, None, window, cx)
+    }
+
+    pub fn deploy_at(
+        workspace: &mut Workspace,
+        path: Option<Arc<Path>>,
+        window: &mut Window,
+        cx: &mut Context<Workspace>,
+    ) {
+        let project_diff = if let Some(existing) = workspace.item_of_type::<Self>(cx) {
+            workspace.activate_item(&existing, true, true, window, cx);
+            existing
+        } else {
+            let workspace_handle = cx.entity().downgrade();
+            let project_diff =
+                cx.new(|cx| Self::new(workspace.project().clone(), workspace_handle, window, cx));
+            workspace.add_item_to_active_pane(
+                Box::new(project_diff.clone()),
+                None,
+                true,
+                window,
+                cx,
+            );
+            project_diff
+        };
+        if let Some(path) = path {
+            project_diff.update(cx, |project_diff, cx| {
+                project_diff.scroll_to(path, window, cx);
+            })
+        }
+    }
+
+    fn new(
+        project: Entity<Project>,
+        workspace: WeakEntity<Workspace>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Self {
+        let focus_handle = cx.focus_handle();
+        let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite));
+
+        let editor = cx.new(|cx| {
+            let mut diff_display_editor = Editor::for_multibuffer(
+                multibuffer.clone(),
+                Some(project.clone()),
+                true,
+                window,
+                cx,
+            );
+            diff_display_editor.set_expand_all_diff_hunks(cx);
+            diff_display_editor
+        });
+        cx.subscribe_in(&editor, window, Self::handle_editor_event)
+            .detach();
+
+        let git_state = project.read(cx).git_state().clone();
+        let git_state_subscription = cx.subscribe_in(
+            &git_state,
+            window,
+            move |this, _git_state, event, _window, _cx| match event {
+                project::git::Event::RepositoriesUpdated => {
+                    *this.update_needed.borrow_mut() = ();
+                }
+            },
+        );
+
+        let (mut send, recv) = postage::watch::channel::<()>();
+        let worker = window.spawn(cx, {
+            let this = cx.weak_entity();
+            |cx| Self::handle_status_updates(this, recv, cx)
+        });
+        // Kick of a refresh immediately
+        *send.borrow_mut() = ();
+
+        Self {
+            project,
+            git_state: git_state.clone(),
+            workspace,
+            focus_handle,
+            editor,
+            multibuffer,
+            pending_scroll: None,
+            update_needed: send,
+            _task: worker,
+            _subscription: git_state_subscription,
+        }
+    }
+
+    pub fn scroll_to(&mut self, path: Arc<Path>, window: &mut Window, cx: &mut Context<Self>) {
+        if let Some(position) = self.multibuffer.read(cx).location_for_path(&path, cx) {
+            self.editor.update(cx, |editor, cx| {
+                editor.change_selections(Some(Autoscroll::focused()), window, cx, |s| {
+                    s.select_ranges([position..position]);
+                })
+            })
+        } else {
+            self.pending_scroll = Some(path);
+        }
+    }
+
+    fn handle_editor_event(
+        &mut self,
+        editor: &Entity<Editor>,
+        event: &EditorEvent,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        match event {
+            EditorEvent::ScrollPositionChanged { .. } => editor.update(cx, |editor, cx| {
+                let anchor = editor.scroll_manager.anchor().anchor;
+                let Some((_, buffer, _)) = self.multibuffer.read(cx).excerpt_containing(anchor, cx)
+                else {
+                    return;
+                };
+                let Some(project_path) = buffer
+                    .read(cx)
+                    .file()
+                    .map(|file| (file.worktree_id(cx), file.path().clone()))
+                else {
+                    return;
+                };
+                self.workspace
+                    .update(cx, |workspace, cx| {
+                        if let Some(git_panel) = workspace.panel::<GitPanel>(cx) {
+                            git_panel.update(cx, |git_panel, cx| {
+                                git_panel.set_focused_path(project_path.into(), window, cx)
+                            })
+                        }
+                    })
+                    .ok();
+            }),
+            _ => {}
+        }
+    }
+
+    fn load_buffers(&mut self, cx: &mut Context<Self>) -> Vec<Task<Result<DiffBuffer>>> {
+        let Some(repo) = self.git_state.read(cx).active_repository() else {
+            self.multibuffer.update(cx, |multibuffer, cx| {
+                multibuffer.clear(cx);
+            });
+            return vec![];
+        };
+
+        let mut previous_paths = self.multibuffer.read(cx).paths().collect::<HashSet<_>>();
+
+        let mut result = vec![];
+        for entry in repo.status() {
+            if !entry.status.has_changes() {
+                continue;
+            }
+            let Some(project_path) = repo.repo_path_to_project_path(&entry.repo_path) else {
+                continue;
+            };
+            let Some(abs_path) = self.project.read(cx).absolute_path(&project_path, cx) else {
+                continue;
+            };
+            let abs_path = Arc::from(abs_path);
+
+            previous_paths.remove(&abs_path);
+            let load_buffer = self
+                .project
+                .update(cx, |project, cx| project.open_buffer(project_path, cx));
+
+            let project = self.project.clone();
+            result.push(cx.spawn(|_, mut cx| async move {
+                let buffer = load_buffer.await?;
+                let changes = project
+                    .update(&mut cx, |project, cx| {
+                        project.open_unstaged_changes(buffer.clone(), cx)
+                    })?
+                    .await?;
+                Ok(DiffBuffer {
+                    abs_path,
+                    buffer,
+                    change_set: changes,
+                })
+            }));
+        }
+        self.multibuffer.update(cx, |multibuffer, cx| {
+            for path in previous_paths {
+                multibuffer.remove_excerpts_for_path(path, cx);
+            }
+        });
+        result
+    }
+
+    fn register_buffer(
+        &mut self,
+        diff_buffer: DiffBuffer,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let abs_path = diff_buffer.abs_path;
+        let buffer = diff_buffer.buffer;
+        let change_set = diff_buffer.change_set;
+
+        let snapshot = buffer.read(cx).snapshot();
+        let diff_hunk_ranges = change_set
+            .read(cx)
+            .diff_hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot)
+            .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot))
+            .collect::<Vec<_>>();
+
+        self.multibuffer.update(cx, |multibuffer, cx| {
+            multibuffer.set_excerpts_for_path(
+                abs_path.clone(),
+                buffer,
+                diff_hunk_ranges,
+                editor::DEFAULT_MULTIBUFFER_CONTEXT,
+                cx,
+            );
+        });
+        if self.pending_scroll.as_ref() == Some(&abs_path) {
+            self.scroll_to(abs_path, window, cx);
+        }
+    }
+
+    pub async fn handle_status_updates(
+        this: WeakEntity<Self>,
+        mut recv: postage::watch::Receiver<()>,
+        mut cx: AsyncWindowContext,
+    ) -> Result<()> {
+        while let Some(_) = recv.next().await {
+            let buffers_to_load = this.update(&mut cx, |this, cx| this.load_buffers(cx))?;
+            for buffer_to_load in buffers_to_load {
+                if let Some(buffer) = buffer_to_load.await.log_err() {
+                    cx.update(|window, cx| {
+                        this.update(cx, |this, cx| this.register_buffer(buffer, window, cx))
+                            .ok();
+                    })?;
+                }
+            }
+            this.update(&mut cx, |this, _| this.pending_scroll.take())?;
+        }
+
+        Ok(())
+    }
+}
+
+impl EventEmitter<EditorEvent> for ProjectDiff {}
+
+impl Focusable for ProjectDiff {
+    fn focus_handle(&self, _: &App) -> FocusHandle {
+        self.focus_handle.clone()
+    }
+}
+
+impl Item for ProjectDiff {
+    type Event = EditorEvent;
+
+    fn to_item_events(event: &EditorEvent, f: impl FnMut(ItemEvent)) {
+        Editor::to_item_events(event, f)
+    }
+
+    fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        self.editor
+            .update(cx, |editor, cx| editor.deactivated(window, cx));
+    }
+
+    fn navigate(
+        &mut self,
+        data: Box<dyn Any>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> bool {
+        self.editor
+            .update(cx, |editor, cx| editor.navigate(data, window, cx))
+    }
+
+    fn tab_tooltip_text(&self, _: &App) -> Option<SharedString> {
+        Some("Project Diff".into())
+    }
+
+    fn tab_content(&self, params: TabContentParams, _window: &Window, _: &App) -> AnyElement {
+        Label::new("Uncommitted Changes")
+            .color(if params.selected {
+                Color::Default
+            } else {
+                Color::Muted
+            })
+            .into_any_element()
+    }
+
+    fn telemetry_event_text(&self) -> Option<&'static str> {
+        Some("project diagnostics")
+    }
+
+    fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
+        Some(Box::new(self.editor.clone()))
+    }
+
+    fn for_each_project_item(
+        &self,
+        cx: &App,
+        f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
+    ) {
+        self.editor.for_each_project_item(cx, f)
+    }
+
+    fn is_singleton(&self, _: &App) -> bool {
+        false
+    }
+
+    fn set_nav_history(
+        &mut self,
+        nav_history: ItemNavHistory,
+        _: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.editor.update(cx, |editor, _| {
+            editor.set_nav_history(Some(nav_history));
+        });
+    }
+
+    fn clone_on_split(
+        &self,
+        _workspace_id: Option<workspace::WorkspaceId>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Option<Entity<Self>>
+    where
+        Self: Sized,
+    {
+        Some(
+            cx.new(|cx| ProjectDiff::new(self.project.clone(), self.workspace.clone(), window, cx)),
+        )
+    }
+
+    fn is_dirty(&self, cx: &App) -> bool {
+        self.multibuffer.read(cx).is_dirty(cx)
+    }
+
+    fn has_conflict(&self, cx: &App) -> bool {
+        self.multibuffer.read(cx).has_conflict(cx)
+    }
+
+    fn can_save(&self, _: &App) -> bool {
+        true
+    }
+
+    fn save(
+        &mut self,
+        format: bool,
+        project: Entity<Project>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Task<Result<()>> {
+        self.editor.save(format, project, window, cx)
+    }
+
+    fn save_as(
+        &mut self,
+        _: Entity<Project>,
+        _: ProjectPath,
+        _window: &mut Window,
+        _: &mut Context<Self>,
+    ) -> Task<Result<()>> {
+        unreachable!()
+    }
+
+    fn reload(
+        &mut self,
+        project: Entity<Project>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Task<Result<()>> {
+        self.editor.reload(project, window, cx)
+    }
+
+    fn act_as_type<'a>(
+        &'a self,
+        type_id: TypeId,
+        self_handle: &'a Entity<Self>,
+        _: &'a App,
+    ) -> Option<AnyView> {
+        if type_id == TypeId::of::<Self>() {
+            Some(self_handle.to_any())
+        } else if type_id == TypeId::of::<Editor>() {
+            Some(self.editor.to_any())
+        } else {
+            None
+        }
+    }
+
+    fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
+        ToolbarItemLocation::PrimaryLeft
+    }
+
+    fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
+        self.editor.breadcrumbs(theme, cx)
+    }
+
+    fn added_to_workspace(
+        &mut self,
+        workspace: &mut Workspace,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.editor.update(cx, |editor, cx| {
+            editor.added_to_workspace(workspace, window, cx)
+        });
+    }
+}
+
+impl Render for ProjectDiff {
+    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let is_empty = self.multibuffer.read(cx).is_empty();
+        if is_empty {
+            div()
+                .bg(cx.theme().colors().editor_background)
+                .flex()
+                .items_center()
+                .justify_center()
+                .size_full()
+                .child(Label::new("No uncommitted changes"))
+        } else {
+            div()
+                .bg(cx.theme().colors().editor_background)
+                .flex()
+                .items_center()
+                .justify_center()
+                .size_full()
+                .child(self.editor.clone())
+        }
+    }
+}

crates/git_ui/src/repository_selector.rs 🔗

@@ -20,10 +20,8 @@ pub struct RepositorySelector {
 
 impl RepositorySelector {
     pub fn new(project: Entity<Project>, window: &mut Window, cx: &mut Context<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 git_state = project.read(cx).git_state().clone();
+        let all_repositories = git_state.read(cx).all_repositories();
         let filtered_repositories = all_repositories.clone();
         let delegate = RepositorySelectorDelegate {
             project: project.downgrade(),
@@ -38,11 +36,8 @@ impl RepositorySelector {
                 .max_height(Some(rems(20.).into()))
         });
 
-        let _subscriptions = if let Some(git_state) = git_state {
-            vec![cx.subscribe_in(&git_state, window, Self::handle_project_git_event)]
-        } else {
-            Vec::new()
-        };
+        let _subscriptions =
+            vec![cx.subscribe_in(&git_state, window, Self::handle_project_git_event)];
 
         RepositorySelector {
             picker,

crates/go_to_line/src/go_to_line.rs 🔗

@@ -80,7 +80,7 @@ impl GoToLine {
             let last_line = editor
                 .buffer()
                 .read(cx)
-                .excerpts_for_buffer(&active_buffer, cx)
+                .excerpts_for_buffer(snapshot.remote_id(), cx)
                 .into_iter()
                 .map(move |(_, range)| text::ToPoint::to_point(&range.context.end, &snapshot).row)
                 .max()

crates/multi_buffer/src/multi_buffer.rs 🔗

@@ -35,6 +35,7 @@ use std::{
     iter::{self, FromIterator},
     mem,
     ops::{Range, RangeBounds, Sub},
+    path::Path,
     str,
     sync::Arc,
     time::{Duration, Instant},
@@ -65,6 +66,8 @@ pub struct MultiBuffer {
     snapshot: RefCell<MultiBufferSnapshot>,
     /// Contains the state of the buffers being edited
     buffers: RefCell<HashMap<BufferId, BufferState>>,
+    // only used by consumers using `set_excerpts_for_buffer`
+    buffers_by_path: BTreeMap<Arc<Path>, Vec<ExcerptId>>,
     diff_bases: HashMap<BufferId, ChangeSetState>,
     all_diff_hunks_expanded: bool,
     subscriptions: Topic,
@@ -494,6 +497,7 @@ impl MultiBuffer {
             singleton: false,
             capability,
             title: None,
+            buffers_by_path: Default::default(),
             history: History {
                 next_transaction_id: clock::Lamport::default(),
                 undo_stack: Vec::new(),
@@ -508,6 +512,7 @@ impl MultiBuffer {
         Self {
             snapshot: Default::default(),
             buffers: Default::default(),
+            buffers_by_path: Default::default(),
             diff_bases: HashMap::default(),
             all_diff_hunks_expanded: false,
             subscriptions: Default::default(),
@@ -561,6 +566,7 @@ impl MultiBuffer {
         Self {
             snapshot: RefCell::new(self.snapshot.borrow().clone()),
             buffers: RefCell::new(buffers),
+            buffers_by_path: Default::default(),
             diff_bases,
             all_diff_hunks_expanded: self.all_diff_hunks_expanded,
             subscriptions: Default::default(),
@@ -648,8 +654,8 @@ impl MultiBuffer {
         self.read(cx).len()
     }
 
-    pub fn is_empty(&self, cx: &App) -> bool {
-        self.len(cx) != 0
+    pub fn is_empty(&self) -> bool {
+        self.buffers.borrow().is_empty()
     }
 
     pub fn symbols_containing<T: ToOffset>(
@@ -1388,6 +1394,138 @@ impl MultiBuffer {
         anchor_ranges
     }
 
+    pub fn location_for_path(&self, path: &Arc<Path>, cx: &App) -> Option<Anchor> {
+        let excerpt_id = self.buffers_by_path.get(path)?.first()?;
+        let snapshot = self.snapshot(cx);
+        let excerpt = snapshot.excerpt(*excerpt_id)?;
+        Some(Anchor::in_buffer(
+            *excerpt_id,
+            excerpt.buffer_id,
+            excerpt.range.context.start,
+        ))
+    }
+
+    pub fn set_excerpts_for_path(
+        &mut self,
+        path: Arc<Path>,
+        buffer: Entity<Buffer>,
+        ranges: Vec<Range<Point>>,
+        context_line_count: u32,
+        cx: &mut Context<Self>,
+    ) {
+        let buffer_snapshot = buffer.update(cx, |buffer, _| buffer.snapshot());
+        let (mut insert_after, excerpt_ids) =
+            if let Some(existing) = self.buffers_by_path.get(&path) {
+                (*existing.last().unwrap(), existing.clone())
+            } else {
+                (
+                    self.buffers_by_path
+                        .range(..path.clone())
+                        .next_back()
+                        .map(|(_, value)| *value.last().unwrap())
+                        .unwrap_or(ExcerptId::min()),
+                    Vec::default(),
+                )
+            };
+
+        let (new, _) = build_excerpt_ranges(&buffer_snapshot, &ranges, context_line_count);
+
+        let mut new_iter = new.into_iter().peekable();
+        let mut existing_iter = excerpt_ids.into_iter().peekable();
+
+        let mut new_excerpt_ids = Vec::new();
+        let mut to_remove = Vec::new();
+        let mut to_insert = Vec::new();
+        let snapshot = self.snapshot(cx);
+
+        let mut excerpts_cursor = snapshot.excerpts.cursor::<Option<&Locator>>(&());
+        excerpts_cursor.next(&());
+
+        loop {
+            let (new, existing) = match (new_iter.peek(), existing_iter.peek()) {
+                (Some(new), Some(existing)) => (new, existing),
+                (None, None) => break,
+                (None, Some(_)) => {
+                    to_remove.push(existing_iter.next().unwrap());
+                    continue;
+                }
+                (Some(_), None) => {
+                    to_insert.push(new_iter.next().unwrap());
+                    continue;
+                }
+            };
+            let locator = snapshot.excerpt_locator_for_id(*existing);
+            excerpts_cursor.seek_forward(&Some(locator), Bias::Left, &());
+            let existing_excerpt = excerpts_cursor.item().unwrap();
+            if existing_excerpt.buffer_id != buffer_snapshot.remote_id() {
+                to_remove.push(existing_iter.next().unwrap());
+                to_insert.push(new_iter.next().unwrap());
+                continue;
+            }
+
+            let existing_start = existing_excerpt
+                .range
+                .context
+                .start
+                .to_point(&buffer_snapshot);
+            let existing_end = existing_excerpt
+                .range
+                .context
+                .end
+                .to_point(&buffer_snapshot);
+
+            if existing_end < new.context.start {
+                to_remove.push(existing_iter.next().unwrap());
+                continue;
+            } else if existing_start > new.context.end {
+                to_insert.push(new_iter.next().unwrap());
+                continue;
+            }
+
+            // maybe merge overlapping excerpts?
+            // it's hard to distinguish between a manually expanded excerpt, and one that
+            // got smaller because of a missing diff.
+            //
+            if existing_start == new.context.start && existing_end == new.context.end {
+                new_excerpt_ids.append(&mut self.insert_excerpts_after(
+                    insert_after,
+                    buffer.clone(),
+                    mem::take(&mut to_insert),
+                    cx,
+                ));
+                insert_after = existing_iter.next().unwrap();
+                new_excerpt_ids.push(insert_after);
+                new_iter.next();
+            } else {
+                to_remove.push(existing_iter.next().unwrap());
+                to_insert.push(new_iter.next().unwrap());
+            }
+        }
+
+        new_excerpt_ids.append(&mut self.insert_excerpts_after(
+            insert_after,
+            buffer,
+            to_insert,
+            cx,
+        ));
+        self.remove_excerpts(to_remove, cx);
+        if new_excerpt_ids.is_empty() {
+            self.buffers_by_path.remove(&path);
+        } else {
+            self.buffers_by_path.insert(path, new_excerpt_ids);
+        }
+    }
+
+    pub fn paths(&self) -> impl Iterator<Item = Arc<Path>> + '_ {
+        self.buffers_by_path.keys().cloned()
+    }
+
+    pub fn remove_excerpts_for_path(&mut self, path: Arc<Path>, cx: &mut Context<Self>) {
+        if let Some(to_remove) = self.buffers_by_path.remove(&path) {
+            self.remove_excerpts(to_remove, cx)
+        }
+    }
+
     pub fn push_multiple_excerpts_with_context_lines(
         &self,
         buffers_with_ranges: Vec<(Entity<Buffer>, Vec<Range<text::Anchor>>)>,
@@ -1654,7 +1792,7 @@ impl MultiBuffer {
 
     pub fn excerpts_for_buffer(
         &self,
-        buffer: &Entity<Buffer>,
+        buffer_id: BufferId,
         cx: &App,
     ) -> Vec<(ExcerptId, ExcerptRange<text::Anchor>)> {
         let mut excerpts = Vec::new();
@@ -1662,7 +1800,7 @@ impl MultiBuffer {
         let buffers = self.buffers.borrow();
         let mut cursor = snapshot.excerpts.cursor::<Option<&Locator>>(&());
         for locator in buffers
-            .get(&buffer.read(cx).remote_id())
+            .get(&buffer_id)
             .map(|state| &state.excerpts)
             .into_iter()
             .flatten()
@@ -1812,7 +1950,7 @@ impl MultiBuffer {
     ) -> Option<Anchor> {
         let mut found = None;
         let snapshot = buffer.read(cx).snapshot();
-        for (excerpt_id, range) in self.excerpts_for_buffer(buffer, cx) {
+        for (excerpt_id, range) in self.excerpts_for_buffer(snapshot.remote_id(), cx) {
             let start = range.context.start.to_point(&snapshot);
             let end = range.context.end.to_point(&snapshot);
             if start <= point && point < end {
@@ -4790,7 +4928,7 @@ impl MultiBufferSnapshot {
         cursor.next_excerpt();
 
         let mut visited_end = false;
-        iter::from_fn(move || {
+        iter::from_fn(move || loop {
             if self.singleton {
                 return None;
             }
@@ -4800,7 +4938,8 @@ impl MultiBufferSnapshot {
 
             let next_region_start = if let Some(region) = &next_region {
                 if !bounds.contains(&region.range.start.key) {
-                    return None;
+                    prev_region = next_region;
+                    continue;
                 }
                 region.range.start.value.unwrap()
             } else {
@@ -4847,7 +4986,7 @@ impl MultiBufferSnapshot {
 
             prev_region = next_region;
 
-            Some(ExcerptBoundary { row, prev, next })
+            return Some(ExcerptBoundary { row, prev, next });
         })
     }
 

crates/multi_buffer/src/multi_buffer_tests.rs 🔗

@@ -6,7 +6,7 @@ use language::{Buffer, Rope};
 use parking_lot::RwLock;
 use rand::prelude::*;
 use settings::SettingsStore;
-use std::env;
+use std::{env, path::PathBuf};
 use util::test::sample_text;
 
 #[ctor::ctor]
@@ -315,7 +315,8 @@ fn test_excerpt_boundaries_and_clipping(cx: &mut App) {
     );
 
     let snapshot = multibuffer.update(cx, |multibuffer, cx| {
-        let (buffer_2_excerpt_id, _) = multibuffer.excerpts_for_buffer(&buffer_2, cx)[0].clone();
+        let (buffer_2_excerpt_id, _) =
+            multibuffer.excerpts_for_buffer(buffer_2.read(cx).remote_id(), cx)[0].clone();
         multibuffer.remove_excerpts([buffer_2_excerpt_id], cx);
         multibuffer.snapshot(cx)
     });
@@ -1527,6 +1528,202 @@ fn test_repeatedly_expand_a_diff_hunk(cx: &mut TestAppContext) {
     );
 }
 
+#[gpui::test]
+fn test_set_excerpts_for_buffer(cx: &mut TestAppContext) {
+    let buf1 = cx.new(|cx| {
+        Buffer::local(
+            indoc! {
+            "zero
+            one
+            two
+            three
+            four
+            five
+            six
+            seven
+            ",
+            },
+            cx,
+        )
+    });
+    let path1: Arc<Path> = Arc::from(PathBuf::from("path1"));
+    let buf2 = cx.new(|cx| {
+        Buffer::local(
+            indoc! {
+            "000
+            111
+            222
+            333
+            444
+            555
+            666
+            777
+            888
+            999
+            "
+            },
+            cx,
+        )
+    });
+    let path2: Arc<Path> = Arc::from(PathBuf::from("path2"));
+
+    let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite));
+    multibuffer.update(cx, |multibuffer, cx| {
+        multibuffer.set_excerpts_for_path(
+            path1.clone(),
+            buf1.clone(),
+            vec![Point::row_range(0..1)],
+            2,
+            cx,
+        );
+    });
+
+    assert_excerpts_match(
+        &multibuffer,
+        cx,
+        indoc! {
+        "-----
+        zero
+        one
+        two
+        three
+        "
+        },
+    );
+
+    multibuffer.update(cx, |multibuffer, cx| {
+        multibuffer.set_excerpts_for_path(path1.clone(), buf1.clone(), vec![], 2, cx);
+    });
+
+    assert_excerpts_match(&multibuffer, cx, "");
+
+    multibuffer.update(cx, |multibuffer, cx| {
+        multibuffer.set_excerpts_for_path(
+            path1.clone(),
+            buf1.clone(),
+            vec![Point::row_range(0..1), Point::row_range(7..8)],
+            2,
+            cx,
+        );
+    });
+
+    assert_excerpts_match(
+        &multibuffer,
+        cx,
+        indoc! {"-----
+                zero
+                one
+                two
+                three
+                -----
+                five
+                six
+                seven
+                "},
+    );
+
+    multibuffer.update(cx, |multibuffer, cx| {
+        multibuffer.set_excerpts_for_path(
+            path1.clone(),
+            buf1.clone(),
+            vec![Point::row_range(0..1), Point::row_range(5..6)],
+            2,
+            cx,
+        );
+    });
+
+    assert_excerpts_match(
+        &multibuffer,
+        cx,
+        indoc! {"-----
+                    zero
+                    one
+                    two
+                    three
+                    four
+                    five
+                    six
+                    seven
+                    "},
+    );
+
+    multibuffer.update(cx, |multibuffer, cx| {
+        multibuffer.set_excerpts_for_path(
+            path2.clone(),
+            buf2.clone(),
+            vec![Point::row_range(2..3)],
+            2,
+            cx,
+        );
+    });
+
+    assert_excerpts_match(
+        &multibuffer,
+        cx,
+        indoc! {"-----
+                zero
+                one
+                two
+                three
+                four
+                five
+                six
+                seven
+                -----
+                000
+                111
+                222
+                333
+                444
+                555
+                "},
+    );
+
+    multibuffer.update(cx, |multibuffer, cx| {
+        multibuffer.set_excerpts_for_path(path1.clone(), buf1.clone(), vec![], 2, cx);
+    });
+
+    multibuffer.update(cx, |multibuffer, cx| {
+        multibuffer.set_excerpts_for_path(
+            path1.clone(),
+            buf1.clone(),
+            vec![Point::row_range(3..4)],
+            2,
+            cx,
+        );
+    });
+
+    assert_excerpts_match(
+        &multibuffer,
+        cx,
+        indoc! {"-----
+                one
+                two
+                three
+                four
+                five
+                six
+                -----
+                000
+                111
+                222
+                333
+                444
+                555
+                "},
+    );
+
+    multibuffer.update(cx, |multibuffer, cx| {
+        multibuffer.set_excerpts_for_path(
+            path1.clone(),
+            buf1.clone(),
+            vec![Point::row_range(3..4)],
+            2,
+            cx,
+        );
+    });
+}
+
 #[gpui::test]
 fn test_diff_hunks_with_multiple_excerpts(cx: &mut TestAppContext) {
     let base_text_1 = indoc!(
@@ -2700,6 +2897,25 @@ fn format_diff(
         .join("\n")
 }
 
+#[track_caller]
+fn assert_excerpts_match(
+    multibuffer: &Entity<MultiBuffer>,
+    cx: &mut TestAppContext,
+    expected: &str,
+) {
+    let mut output = String::new();
+    multibuffer.read_with(cx, |multibuffer, cx| {
+        for (_, buffer, range) in multibuffer.snapshot(cx).excerpts() {
+            output.push_str("-----\n");
+            output.extend(buffer.text_for_range(range.context));
+            if !output.ends_with('\n') {
+                output.push('\n');
+            }
+        }
+    });
+    assert_eq!(output, expected);
+}
+
 #[track_caller]
 fn assert_new_snapshot(
     multibuffer: &Entity<MultiBuffer>,

crates/outline_panel/src/outline_panel.rs 🔗

@@ -1017,7 +1017,7 @@ impl OutlinePanel {
                     .map(|buffer| {
                         active_multi_buffer
                             .read(cx)
-                            .excerpts_for_buffer(&buffer, cx)
+                            .excerpts_for_buffer(buffer.read(cx).remote_id(), cx)
                     })
                     .and_then(|excerpts| {
                         let (excerpt_id, excerpt_range) = excerpts.first()?;

crates/project/src/git.rs 🔗

@@ -12,7 +12,7 @@ use gpui::{App, Context, Entity, EventEmitter, SharedString, Subscription, WeakE
 use rpc::{proto, AnyProtoClient};
 use settings::WorktreeId;
 use std::sync::Arc;
-use util::maybe;
+use util::{maybe, ResultExt};
 use worktree::{ProjectEntryId, RepositoryEntry, StatusEntry};
 
 pub struct GitState {
@@ -332,7 +332,7 @@ impl GitState {
 impl RepositoryHandle {
     pub fn display_name(&self, project: &Project, cx: &App) -> SharedString {
         maybe!({
-            let path = self.unrelativize(&"".into())?;
+            let path = self.repo_path_to_project_path(&"".into())?;
             Some(
                 project
                     .absolute_path(&path, cx)?
@@ -367,11 +367,18 @@ impl RepositoryHandle {
         self.repository_entry.status()
     }
 
-    pub fn unrelativize(&self, path: &RepoPath) -> Option<ProjectPath> {
+    pub fn repo_path_to_project_path(&self, path: &RepoPath) -> Option<ProjectPath> {
         let path = self.repository_entry.unrelativize(path)?;
         Some((self.worktree_id, path).into())
     }
 
+    pub fn project_path_to_repo_path(&self, path: &ProjectPath) -> Option<RepoPath> {
+        if path.worktree_id != self.worktree_id {
+            return None;
+        }
+        self.repository_entry.relativize(&path.path).log_err()
+    }
+
     pub fn stage_entries(
         &self,
         entries: Vec<RepoPath>,

crates/project/src/project.rs 🔗

@@ -158,7 +158,7 @@ pub struct Project {
     fs: Arc<dyn Fs>,
     ssh_client: Option<Entity<SshRemoteClient>>,
     client_state: ProjectClientState,
-    git_state: Option<Entity<GitState>>,
+    git_state: Entity<GitState>,
     collaborators: HashMap<proto::PeerId, Collaborator>,
     client_subscriptions: Vec<client::Subscription>,
     worktree_store: Entity<WorktreeStore>,
@@ -701,7 +701,7 @@ impl Project {
                 )
             });
 
-            let git_state = Some(cx.new(|cx| GitState::new(&worktree_store, None, None, cx)));
+            let git_state = cx.new(|cx| GitState::new(&worktree_store, None, None, cx));
 
             cx.subscribe(&lsp_store, Self::on_lsp_store_event).detach();
 
@@ -821,14 +821,14 @@ impl Project {
             });
             cx.subscribe(&lsp_store, Self::on_lsp_store_event).detach();
 
-            let git_state = Some(cx.new(|cx| {
+            let git_state = cx.new(|cx| {
                 GitState::new(
                     &worktree_store,
                     Some(ssh_proto.clone()),
                     Some(ProjectId(SSH_PROJECT_ID)),
                     cx,
                 )
-            }));
+            });
 
             cx.subscribe(&ssh, Self::on_ssh_event).detach();
             cx.observe(&ssh, |_, _, cx| cx.notify()).detach();
@@ -1026,15 +1026,14 @@ impl Project {
             SettingsObserver::new_remote(worktree_store.clone(), task_store.clone(), cx)
         })?;
 
-        let git_state = Some(cx.new(|cx| {
+        let git_state = cx.new(|cx| {
             GitState::new(
                 &worktree_store,
                 Some(client.clone().into()),
                 Some(ProjectId(remote_id)),
                 cx,
             )
-        }))
-        .transpose()?;
+        })?;
 
         let this = cx.new(|cx| {
             let replica_id = response.payload.replica_id as ReplicaId;
@@ -4117,7 +4116,6 @@ impl Project {
         this.update(cx, |project, cx| {
             let repository_handle = project
                 .git_state()
-                .context("missing git state")?
                 .read(cx)
                 .all_repositories()
                 .into_iter()
@@ -4332,19 +4330,16 @@ impl Project {
         &self.buffer_store
     }
 
-    pub fn git_state(&self) -> Option<&Entity<GitState>> {
-        self.git_state.as_ref()
+    pub fn git_state(&self) -> &Entity<GitState> {
+        &self.git_state
     }
 
     pub fn active_repository(&self, cx: &App) -> Option<RepositoryHandle> {
-        self.git_state()
-            .and_then(|git_state| git_state.read(cx).active_repository())
+        self.git_state.read(cx).active_repository()
     }
 
     pub fn all_repositories(&self, cx: &App) -> Vec<RepositoryHandle> {
-        self.git_state()
-            .map(|git_state| git_state.read(cx).all_repositories())
-            .unwrap_or_default()
+        self.git_state.read(cx).all_repositories()
     }
 }
 

crates/rope/src/point.rs 🔗

@@ -1,6 +1,6 @@
 use std::{
     cmp::Ordering,
-    ops::{Add, AddAssign, Sub},
+    ops::{Add, AddAssign, Range, Sub},
 };
 
 /// A zero-indexed point in a text buffer consisting of a row and column.
@@ -20,6 +20,16 @@ impl Point {
         Point { row, column }
     }
 
+    pub fn row_range(range: Range<u32>) -> Range<Self> {
+        Point {
+            row: range.start,
+            column: 0,
+        }..Point {
+            row: range.end,
+            column: 0,
+        }
+    }
+
     pub fn zero() -> Self {
         Point::new(0, 0)
     }

crates/zed/src/zed.rs 🔗

@@ -19,7 +19,7 @@ use collections::VecDeque;
 use command_palette_hooks::CommandPaletteFilter;
 use editor::ProposedChangesEditorToolbar;
 use editor::{scroll::Autoscroll, Editor, MultiBuffer};
-use feature_flags::FeatureFlagAppExt;
+use feature_flags::{FeatureFlagAppExt, FeatureFlagViewExt, GitUiFeatureFlag};
 use futures::{channel::mpsc, select_biased, StreamExt};
 use gpui::{
     actions, point, px, Action, App, AppContext as _, AsyncApp, Context, DismissEvent, Element,
@@ -364,8 +364,6 @@ fn initialize_panels(
 ) {
     let assistant2_feature_flag =
         cx.wait_for_flag_or_timeout::<feature_flags::Assistant2FeatureFlag>(Duration::from_secs(5));
-    let git_ui_feature_flag =
-        cx.wait_for_flag_or_timeout::<feature_flags::GitUiFeatureFlag>(Duration::from_secs(5));
 
     let prompt_builder = prompt_builder.clone();
 
@@ -405,19 +403,10 @@ fn initialize_panels(
             workspace.add_panel(channels_panel, window, cx);
             workspace.add_panel(chat_panel, window, cx);
             workspace.add_panel(notification_panel, window, cx);
-        })?;
-
-        let git_ui_enabled = git_ui_feature_flag.await;
-
-        let git_panel = if git_ui_enabled {
-            Some(git_ui::git_panel::GitPanel::load(workspace_handle.clone(), cx.clone()).await?)
-        } else {
-            None
-        };
-        workspace_handle.update_in(&mut cx, |workspace, window, cx| {
-            if let Some(git_panel) = git_panel {
+            cx.when_flag_enabled::<GitUiFeatureFlag>(window, |workspace, window, cx| {
+                let git_panel = git_ui::git_panel::GitPanel::new(workspace, window, None, cx);
                 workspace.add_panel(git_panel, window, cx);
-            }
+            });
         })?;
 
         let is_assistant2_enabled = if cfg!(test) {