git: Branch diff (#40188)

Conrad Irwin and Cole Miller created

Release Notes:

- git: Adds the ability to view the diff of the current branch since
main

---------

Co-authored-by: Cole Miller <cole@zed.dev>

Change summary

Cargo.lock                                   |   1 
crates/buffer_diff/src/buffer_diff.rs        |  20 
crates/collab/src/rpc.rs                     |   3 
crates/editor/src/editor.rs                  |  82 --
crates/editor/src/element.rs                 |   9 
crates/editor/src/proposed_changes_editor.rs | 523 -------------------
crates/fs/src/fake_git_repo.rs               |  49 +
crates/fs/src/fs.rs                          |  20 
crates/git/src/repository.rs                 |  81 ++
crates/git/src/status.rs                     | 137 +++++
crates/git_ui/Cargo.toml                     |   1 
crates/git_ui/src/project_diff.rs            | 577 ++++++++++++++++-----
crates/project/src/git_store.rs              | 197 +++++++
crates/project/src/git_store/branch_diff.rs  | 386 ++++++++++++++
crates/proto/proto/git.proto                 |  34 +
crates/proto/proto/zed.proto                 |   8 
crates/proto/src/proto.rs                    |   8 
crates/zed/src/zed.rs                        |   3 
18 files changed, 1,362 insertions(+), 777 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -7071,7 +7071,6 @@ dependencies = [
  "notifications",
  "panel",
  "picker",
- "postage",
  "pretty_assertions",
  "project",
  "schemars 1.0.4",

crates/buffer_diff/src/buffer_diff.rs 🔗

@@ -1162,34 +1162,22 @@ impl BufferDiff {
         self.hunks_intersecting_range(start..end, buffer, cx)
     }
 
-    pub fn set_base_text_buffer(
-        &mut self,
-        base_buffer: Entity<language::Buffer>,
-        buffer: text::BufferSnapshot,
-        cx: &mut Context<Self>,
-    ) -> oneshot::Receiver<()> {
-        let base_buffer = base_buffer.read(cx);
-        let language_registry = base_buffer.language_registry();
-        let base_buffer = base_buffer.snapshot();
-        self.set_base_text(base_buffer, language_registry, buffer, cx)
-    }
-
     /// Used in cases where the change set isn't derived from git.
     pub fn set_base_text(
         &mut self,
-        base_buffer: language::BufferSnapshot,
+        base_text: Option<Arc<String>>,
+        language: Option<Arc<Language>>,
         language_registry: Option<Arc<LanguageRegistry>>,
         buffer: text::BufferSnapshot,
         cx: &mut Context<Self>,
     ) -> oneshot::Receiver<()> {
         let (tx, rx) = oneshot::channel();
         let this = cx.weak_entity();
-        let base_text = Arc::new(base_buffer.text());
 
         let snapshot = BufferDiffSnapshot::new_with_base_text(
             buffer.clone(),
-            Some(base_text),
-            base_buffer.language().cloned(),
+            base_text,
+            language,
             language_registry,
             cx,
         );

crates/collab/src/rpc.rs 🔗

@@ -347,6 +347,7 @@ impl Server {
             .add_request_handler(forward_read_only_project_request::<proto::GetColorPresentation>)
             .add_request_handler(forward_read_only_project_request::<proto::OpenBufferByPath>)
             .add_request_handler(forward_read_only_project_request::<proto::GitGetBranches>)
+            .add_request_handler(forward_read_only_project_request::<proto::GetDefaultBranch>)
             .add_request_handler(forward_read_only_project_request::<proto::OpenUnstagedDiff>)
             .add_request_handler(forward_read_only_project_request::<proto::OpenUncommittedDiff>)
             .add_request_handler(forward_read_only_project_request::<proto::LspExtExpandMacro>)
@@ -461,6 +462,8 @@ impl Server {
             .add_message_handler(broadcast_project_message_from_host::<proto::BreakpointsForFile>)
             .add_request_handler(forward_mutating_project_request::<proto::OpenCommitMessageBuffer>)
             .add_request_handler(forward_mutating_project_request::<proto::GitDiff>)
+            .add_request_handler(forward_mutating_project_request::<proto::GetTreeDiff>)
+            .add_request_handler(forward_mutating_project_request::<proto::GetBlobContent>)
             .add_request_handler(forward_mutating_project_request::<proto::GitCreateBranch>)
             .add_request_handler(forward_mutating_project_request::<proto::GitChangeBranch>)
             .add_request_handler(forward_mutating_project_request::<proto::CheckForPushedCommits>)

crates/editor/src/editor.rs 🔗

@@ -32,7 +32,6 @@ mod lsp_ext;
 mod mouse_context_menu;
 pub mod movement;
 mod persistence;
-mod proposed_changes_editor;
 mod rust_analyzer_ext;
 pub mod scroll;
 mod selections_collection;
@@ -68,14 +67,12 @@ pub use multi_buffer::{
     Anchor, AnchorRangeExt, ExcerptId, ExcerptRange, MultiBuffer, MultiBufferSnapshot, PathKey,
     RowInfo, ToOffset, ToPoint,
 };
-pub use proposed_changes_editor::{
-    ProposedChangeLocation, ProposedChangesEditor, ProposedChangesEditorToolbar,
-};
 pub use text::Bias;
 
 use ::git::{
     Restore,
     blame::{BlameEntry, ParsedCommitMessage},
+    status::FileStatus,
 };
 use aho_corasick::AhoCorasick;
 use anyhow::{Context as _, Result, anyhow};
@@ -847,6 +844,10 @@ pub trait Addon: 'static {
         None
     }
 
+    fn override_status_for_buffer_id(&self, _: BufferId, _: &App) -> Option<FileStatus> {
+        None
+    }
+
     fn to_any(&self) -> &dyn std::any::Any;
 
     fn to_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
@@ -10641,6 +10642,20 @@ impl Editor {
         }
     }
 
+    pub fn status_for_buffer_id(&self, buffer_id: BufferId, cx: &App) -> Option<FileStatus> {
+        if let Some(status) = self
+            .addons
+            .iter()
+            .find_map(|(_, addon)| addon.override_status_for_buffer_id(buffer_id, cx))
+        {
+            return Some(status);
+        }
+        self.project
+            .as_ref()?
+            .read(cx)
+            .status_for_buffer_id(buffer_id, cx)
+    }
+
     pub fn open_active_item_in_terminal(
         &mut self,
         _: &OpenInTerminal,
@@ -21011,65 +21026,6 @@ impl Editor {
         self.searchable
     }
 
-    fn open_proposed_changes_editor(
-        &mut self,
-        _: &OpenProposedChangesEditor,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) {
-        let Some(workspace) = self.workspace() else {
-            cx.propagate();
-            return;
-        };
-
-        let selections = self.selections.all::<usize>(&self.display_snapshot(cx));
-        let multi_buffer = self.buffer.read(cx);
-        let multi_buffer_snapshot = multi_buffer.snapshot(cx);
-        let mut new_selections_by_buffer = HashMap::default();
-        for selection in selections {
-            for (buffer, range, _) in
-                multi_buffer_snapshot.range_to_buffer_ranges(selection.start..selection.end)
-            {
-                let mut range = range.to_point(buffer);
-                range.start.column = 0;
-                range.end.column = buffer.line_len(range.end.row);
-                new_selections_by_buffer
-                    .entry(multi_buffer.buffer(buffer.remote_id()).unwrap())
-                    .or_insert(Vec::new())
-                    .push(range)
-            }
-        }
-
-        let proposed_changes_buffers = new_selections_by_buffer
-            .into_iter()
-            .map(|(buffer, ranges)| ProposedChangeLocation { buffer, ranges })
-            .collect::<Vec<_>>();
-        let proposed_changes_editor = cx.new(|cx| {
-            ProposedChangesEditor::new(
-                "Proposed changes",
-                proposed_changes_buffers,
-                self.project.clone(),
-                window,
-                cx,
-            )
-        });
-
-        window.defer(cx, move |window, cx| {
-            workspace.update(cx, |workspace, cx| {
-                workspace.active_pane().update(cx, |pane, cx| {
-                    pane.add_item(
-                        Box::new(proposed_changes_editor),
-                        true,
-                        true,
-                        None,
-                        window,
-                        cx,
-                    );
-                });
-            });
-        });
-    }
-
     pub fn open_excerpts_in_split(
         &mut self,
         _: &OpenExcerptsSplit,

crates/editor/src/element.rs 🔗

@@ -458,7 +458,6 @@ impl EditorElement {
         register_action(editor, window, Editor::toggle_code_actions);
         register_action(editor, window, Editor::open_excerpts);
         register_action(editor, window, Editor::open_excerpts_in_split);
-        register_action(editor, window, Editor::open_proposed_changes_editor);
         register_action(editor, window, Editor::toggle_soft_wrap);
         register_action(editor, window, Editor::toggle_tab_bar);
         register_action(editor, window, Editor::toggle_line_numbers);
@@ -3828,13 +3827,7 @@ impl EditorElement {
         let multi_buffer = editor.buffer.read(cx);
         let file_status = multi_buffer
             .all_diff_hunks_expanded()
-            .then(|| {
-                editor
-                    .project
-                    .as_ref()?
-                    .read(cx)
-                    .status_for_buffer_id(for_excerpt.buffer_id, cx)
-            })
+            .then(|| editor.status_for_buffer_id(for_excerpt.buffer_id, cx))
             .flatten();
         let indicator = multi_buffer
             .buffer(for_excerpt.buffer_id)

crates/editor/src/proposed_changes_editor.rs 🔗

@@ -1,523 +0,0 @@
-use crate::{ApplyAllDiffHunks, Editor, EditorEvent, SelectionEffects, SemanticsProvider};
-use buffer_diff::BufferDiff;
-use collections::{HashMap, HashSet};
-use futures::{channel::mpsc, future::join_all};
-use gpui::{App, Entity, EventEmitter, Focusable, Render, Subscription, Task};
-use language::{Buffer, BufferEvent, BufferRow, Capability};
-use multi_buffer::{ExcerptRange, MultiBuffer};
-use project::{InvalidationStrategy, Project, lsp_store::CacheInlayHints};
-use smol::stream::StreamExt;
-use std::{any::TypeId, ops::Range, rc::Rc, time::Duration};
-use text::{BufferId, ToOffset};
-use ui::{ButtonLike, KeyBinding, prelude::*};
-use workspace::{
-    Item, ItemHandle as _, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace,
-    item::SaveOptions, searchable::SearchableItemHandle,
-};
-
-pub struct ProposedChangesEditor {
-    editor: Entity<Editor>,
-    multibuffer: Entity<MultiBuffer>,
-    title: SharedString,
-    buffer_entries: Vec<BufferEntry>,
-    _recalculate_diffs_task: Task<Option<()>>,
-    recalculate_diffs_tx: mpsc::UnboundedSender<RecalculateDiff>,
-}
-
-pub struct ProposedChangeLocation<T> {
-    pub buffer: Entity<Buffer>,
-    pub ranges: Vec<Range<T>>,
-}
-
-struct BufferEntry {
-    base: Entity<Buffer>,
-    branch: Entity<Buffer>,
-    _subscription: Subscription,
-}
-
-pub struct ProposedChangesEditorToolbar {
-    current_editor: Option<Entity<ProposedChangesEditor>>,
-}
-
-struct RecalculateDiff {
-    buffer: Entity<Buffer>,
-    debounce: bool,
-}
-
-/// A provider of code semantics for branch buffers.
-///
-/// Requests in edited regions will return nothing, but requests in unchanged
-/// regions will be translated into the base buffer's coordinates.
-struct BranchBufferSemanticsProvider(Rc<dyn SemanticsProvider>);
-
-impl ProposedChangesEditor {
-    pub fn new<T: Clone + ToOffset>(
-        title: impl Into<SharedString>,
-        locations: Vec<ProposedChangeLocation<T>>,
-        project: Option<Entity<Project>>,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) -> Self {
-        let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite));
-        let (recalculate_diffs_tx, mut recalculate_diffs_rx) = mpsc::unbounded();
-        let mut this = Self {
-            editor: cx.new(|cx| {
-                let mut editor = Editor::for_multibuffer(multibuffer.clone(), project, window, cx);
-                editor.set_expand_all_diff_hunks(cx);
-                editor.set_completion_provider(None);
-                editor.clear_code_action_providers();
-                editor.set_semantics_provider(
-                    editor
-                        .semantics_provider()
-                        .map(|provider| Rc::new(BranchBufferSemanticsProvider(provider)) as _),
-                );
-                editor
-            }),
-            multibuffer,
-            title: title.into(),
-            buffer_entries: Vec::new(),
-            recalculate_diffs_tx,
-            _recalculate_diffs_task: cx.spawn_in(window, async move |this, cx| {
-                let mut buffers_to_diff = HashSet::default();
-                while let Some(mut recalculate_diff) = recalculate_diffs_rx.next().await {
-                    buffers_to_diff.insert(recalculate_diff.buffer);
-
-                    while recalculate_diff.debounce {
-                        cx.background_executor()
-                            .timer(Duration::from_millis(50))
-                            .await;
-                        let mut had_further_changes = false;
-                        while let Ok(next_recalculate_diff) = recalculate_diffs_rx.try_next() {
-                            let next_recalculate_diff = next_recalculate_diff?;
-                            recalculate_diff.debounce &= next_recalculate_diff.debounce;
-                            buffers_to_diff.insert(next_recalculate_diff.buffer);
-                            had_further_changes = true;
-                        }
-                        if !had_further_changes {
-                            break;
-                        }
-                    }
-
-                    let recalculate_diff_futures = this
-                        .update(cx, |this, cx| {
-                            buffers_to_diff
-                                .drain()
-                                .filter_map(|buffer| {
-                                    let buffer = buffer.read(cx);
-                                    let base_buffer = buffer.base_buffer()?;
-                                    let buffer = buffer.text_snapshot();
-                                    let diff =
-                                        this.multibuffer.read(cx).diff_for(buffer.remote_id())?;
-                                    Some(diff.update(cx, |diff, cx| {
-                                        diff.set_base_text_buffer(base_buffer.clone(), buffer, cx)
-                                    }))
-                                })
-                                .collect::<Vec<_>>()
-                        })
-                        .ok()?;
-
-                    join_all(recalculate_diff_futures).await;
-                }
-                None
-            }),
-        };
-        this.reset_locations(locations, window, cx);
-        this
-    }
-
-    pub fn branch_buffer_for_base(&self, base_buffer: &Entity<Buffer>) -> Option<Entity<Buffer>> {
-        self.buffer_entries.iter().find_map(|entry| {
-            if &entry.base == base_buffer {
-                Some(entry.branch.clone())
-            } else {
-                None
-            }
-        })
-    }
-
-    pub fn set_title(&mut self, title: SharedString, cx: &mut Context<Self>) {
-        self.title = title;
-        cx.notify();
-    }
-
-    pub fn reset_locations<T: Clone + ToOffset>(
-        &mut self,
-        locations: Vec<ProposedChangeLocation<T>>,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) {
-        // Undo all branch changes
-        for entry in &self.buffer_entries {
-            let base_version = entry.base.read(cx).version();
-            entry.branch.update(cx, |buffer, cx| {
-                let undo_counts = buffer
-                    .operations()
-                    .iter()
-                    .filter_map(|(timestamp, _)| {
-                        if !base_version.observed(*timestamp) {
-                            Some((*timestamp, u32::MAX))
-                        } else {
-                            None
-                        }
-                    })
-                    .collect();
-                buffer.undo_operations(undo_counts, cx);
-            });
-        }
-
-        self.multibuffer.update(cx, |multibuffer, cx| {
-            multibuffer.clear(cx);
-        });
-
-        let mut buffer_entries = Vec::new();
-        let mut new_diffs = Vec::new();
-        for location in locations {
-            let branch_buffer;
-            if let Some(ix) = self
-                .buffer_entries
-                .iter()
-                .position(|entry| entry.base == location.buffer)
-            {
-                let entry = self.buffer_entries.remove(ix);
-                branch_buffer = entry.branch.clone();
-                buffer_entries.push(entry);
-            } else {
-                branch_buffer = location.buffer.update(cx, |buffer, cx| buffer.branch(cx));
-                new_diffs.push(cx.new(|cx| {
-                    let mut diff = BufferDiff::new(&branch_buffer.read(cx).snapshot(), cx);
-                    let _ = diff.set_base_text_buffer(
-                        location.buffer.clone(),
-                        branch_buffer.read(cx).text_snapshot(),
-                        cx,
-                    );
-                    diff
-                }));
-                buffer_entries.push(BufferEntry {
-                    branch: branch_buffer.clone(),
-                    base: location.buffer.clone(),
-                    _subscription: cx.subscribe(&branch_buffer, Self::on_buffer_event),
-                });
-            }
-
-            self.multibuffer.update(cx, |multibuffer, cx| {
-                multibuffer.push_excerpts(
-                    branch_buffer,
-                    location
-                        .ranges
-                        .into_iter()
-                        .map(|range| ExcerptRange::new(range)),
-                    cx,
-                );
-            });
-        }
-
-        self.buffer_entries = buffer_entries;
-        self.editor.update(cx, |editor, cx| {
-            editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| {
-                selections.refresh()
-            });
-            editor.buffer.update(cx, |buffer, cx| {
-                for diff in new_diffs {
-                    buffer.add_diff(diff, cx)
-                }
-            })
-        });
-    }
-
-    pub fn recalculate_all_buffer_diffs(&self) {
-        for (ix, entry) in self.buffer_entries.iter().enumerate().rev() {
-            self.recalculate_diffs_tx
-                .unbounded_send(RecalculateDiff {
-                    buffer: entry.branch.clone(),
-                    debounce: ix > 0,
-                })
-                .ok();
-        }
-    }
-
-    fn on_buffer_event(
-        &mut self,
-        buffer: Entity<Buffer>,
-        event: &BufferEvent,
-        _cx: &mut Context<Self>,
-    ) {
-        if let BufferEvent::Operation { .. } = event {
-            self.recalculate_diffs_tx
-                .unbounded_send(RecalculateDiff {
-                    buffer,
-                    debounce: true,
-                })
-                .ok();
-        }
-    }
-}
-
-impl Render for ProposedChangesEditor {
-    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
-        div()
-            .size_full()
-            .key_context("ProposedChangesEditor")
-            .child(self.editor.clone())
-    }
-}
-
-impl Focusable for ProposedChangesEditor {
-    fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
-        self.editor.focus_handle(cx)
-    }
-}
-
-impl EventEmitter<EditorEvent> for ProposedChangesEditor {}
-
-impl Item for ProposedChangesEditor {
-    type Event = EditorEvent;
-
-    fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
-        Some(Icon::new(IconName::Diff))
-    }
-
-    fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
-        self.title.clone()
-    }
-
-    fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
-        Some(Box::new(self.editor.clone()))
-    }
-
-    fn act_as_type<'a>(
-        &'a self,
-        type_id: TypeId,
-        self_handle: &'a Entity<Self>,
-        _: &'a App,
-    ) -> Option<gpui::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 added_to_workspace(
-        &mut self,
-        workspace: &mut Workspace,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) {
-        self.editor.update(cx, |editor, cx| {
-            Item::added_to_workspace(editor, workspace, window, cx)
-        });
-    }
-
-    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 std::any::Any>,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) -> bool {
-        self.editor
-            .update(cx, |editor, cx| Item::navigate(editor, data, window, cx))
-    }
-
-    fn set_nav_history(
-        &mut self,
-        nav_history: workspace::ItemNavHistory,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) {
-        self.editor.update(cx, |editor, cx| {
-            Item::set_nav_history(editor, nav_history, window, cx)
-        });
-    }
-
-    fn can_save(&self, cx: &App) -> bool {
-        self.editor.read(cx).can_save(cx)
-    }
-
-    fn save(
-        &mut self,
-        options: SaveOptions,
-        project: Entity<Project>,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) -> Task<anyhow::Result<()>> {
-        self.editor.update(cx, |editor, cx| {
-            Item::save(editor, options, project, window, cx)
-        })
-    }
-}
-
-impl ProposedChangesEditorToolbar {
-    pub fn new() -> Self {
-        Self {
-            current_editor: None,
-        }
-    }
-
-    fn get_toolbar_item_location(&self) -> ToolbarItemLocation {
-        if self.current_editor.is_some() {
-            ToolbarItemLocation::PrimaryRight
-        } else {
-            ToolbarItemLocation::Hidden
-        }
-    }
-}
-
-impl Render for ProposedChangesEditorToolbar {
-    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
-        let button_like = ButtonLike::new("apply-changes").child(Label::new("Apply All"));
-
-        match &self.current_editor {
-            Some(editor) => {
-                let focus_handle = editor.focus_handle(cx);
-                let keybinding = KeyBinding::for_action_in(&ApplyAllDiffHunks, &focus_handle, cx);
-
-                button_like.child(keybinding).on_click({
-                    move |_event, window, cx| {
-                        focus_handle.dispatch_action(&ApplyAllDiffHunks, window, cx)
-                    }
-                })
-            }
-            None => button_like.disabled(true),
-        }
-    }
-}
-
-impl EventEmitter<ToolbarItemEvent> for ProposedChangesEditorToolbar {}
-
-impl ToolbarItemView for ProposedChangesEditorToolbar {
-    fn set_active_pane_item(
-        &mut self,
-        active_pane_item: Option<&dyn workspace::ItemHandle>,
-        _window: &mut Window,
-        _cx: &mut Context<Self>,
-    ) -> workspace::ToolbarItemLocation {
-        self.current_editor =
-            active_pane_item.and_then(|item| item.downcast::<ProposedChangesEditor>());
-        self.get_toolbar_item_location()
-    }
-}
-
-impl BranchBufferSemanticsProvider {
-    fn to_base(
-        &self,
-        buffer: &Entity<Buffer>,
-        positions: &[text::Anchor],
-        cx: &App,
-    ) -> Option<Entity<Buffer>> {
-        let base_buffer = buffer.read(cx).base_buffer()?;
-        let version = base_buffer.read(cx).version();
-        if positions
-            .iter()
-            .any(|position| !version.observed(position.timestamp))
-        {
-            return None;
-        }
-        Some(base_buffer)
-    }
-}
-
-impl SemanticsProvider for BranchBufferSemanticsProvider {
-    fn hover(
-        &self,
-        buffer: &Entity<Buffer>,
-        position: text::Anchor,
-        cx: &mut App,
-    ) -> Option<Task<Option<Vec<project::Hover>>>> {
-        let buffer = self.to_base(buffer, &[position], cx)?;
-        self.0.hover(&buffer, position, cx)
-    }
-
-    fn applicable_inlay_chunks(
-        &self,
-        buffer: &Entity<Buffer>,
-        ranges: &[Range<text::Anchor>],
-        cx: &mut App,
-    ) -> Vec<Range<BufferRow>> {
-        self.0.applicable_inlay_chunks(buffer, ranges, cx)
-    }
-
-    fn invalidate_inlay_hints(&self, for_buffers: &HashSet<BufferId>, cx: &mut App) {
-        self.0.invalidate_inlay_hints(for_buffers, cx);
-    }
-
-    fn inlay_hints(
-        &self,
-        invalidate: InvalidationStrategy,
-        buffer: Entity<Buffer>,
-        ranges: Vec<Range<text::Anchor>>,
-        known_chunks: Option<(clock::Global, HashSet<Range<BufferRow>>)>,
-        cx: &mut App,
-    ) -> Option<HashMap<Range<BufferRow>, Task<anyhow::Result<CacheInlayHints>>>> {
-        let positions = ranges
-            .iter()
-            .flat_map(|range| [range.start, range.end])
-            .collect::<Vec<_>>();
-        let buffer = self.to_base(&buffer, &positions, cx)?;
-        self.0
-            .inlay_hints(invalidate, buffer, ranges, known_chunks, cx)
-    }
-
-    fn inline_values(
-        &self,
-        _: Entity<Buffer>,
-        _: Range<text::Anchor>,
-        _: &mut App,
-    ) -> Option<Task<anyhow::Result<Vec<project::InlayHint>>>> {
-        None
-    }
-
-    fn supports_inlay_hints(&self, buffer: &Entity<Buffer>, cx: &mut App) -> bool {
-        if let Some(buffer) = self.to_base(buffer, &[], cx) {
-            self.0.supports_inlay_hints(&buffer, cx)
-        } else {
-            false
-        }
-    }
-
-    fn document_highlights(
-        &self,
-        buffer: &Entity<Buffer>,
-        position: text::Anchor,
-        cx: &mut App,
-    ) -> Option<Task<anyhow::Result<Vec<project::DocumentHighlight>>>> {
-        let buffer = self.to_base(buffer, &[position], cx)?;
-        self.0.document_highlights(&buffer, position, cx)
-    }
-
-    fn definitions(
-        &self,
-        buffer: &Entity<Buffer>,
-        position: text::Anchor,
-        kind: crate::GotoDefinitionKind,
-        cx: &mut App,
-    ) -> Option<Task<anyhow::Result<Option<Vec<project::LocationLink>>>>> {
-        let buffer = self.to_base(buffer, &[position], cx)?;
-        self.0.definitions(&buffer, position, kind, cx)
-    }
-
-    fn range_for_rename(
-        &self,
-        _: &Entity<Buffer>,
-        _: text::Anchor,
-        _: &mut App,
-    ) -> Option<Task<anyhow::Result<Option<Range<text::Anchor>>>>> {
-        None
-    }
-
-    fn perform_rename(
-        &self,
-        _: &Entity<Buffer>,
-        _: text::Anchor,
-        _: String,
-        _: &mut App,
-    ) -> Option<Task<anyhow::Result<project::ProjectTransaction>>> {
-        None
-    }
-}

crates/fs/src/fake_git_repo.rs 🔗

@@ -9,7 +9,10 @@ use git::{
         AskPassDelegate, Branch, CommitDetails, CommitOptions, FetchOptions, GitRepository,
         GitRepositoryCheckpoint, PushOptions, Remote, RepoPath, ResetMode,
     },
-    status::{FileStatus, GitStatus, StatusCode, TrackedStatus, UnmergedStatus},
+    status::{
+        DiffTreeType, FileStatus, GitStatus, StatusCode, TrackedStatus, TreeDiff, TreeDiffStatus,
+        UnmergedStatus,
+    },
 };
 use gpui::{AsyncApp, BackgroundExecutor, SharedString, Task, TaskLabel};
 use ignore::gitignore::GitignoreBuilder;
@@ -41,6 +44,9 @@ pub struct FakeGitRepositoryState {
     pub unmerged_paths: HashMap<RepoPath, UnmergedStatus>,
     pub head_contents: HashMap<RepoPath, String>,
     pub index_contents: HashMap<RepoPath, String>,
+    // everything in commit contents is in oids
+    pub merge_base_contents: HashMap<RepoPath, Oid>,
+    pub oids: HashMap<Oid, String>,
     pub blames: HashMap<RepoPath, Blame>,
     pub current_branch_name: Option<String>,
     pub branches: HashSet<String>,
@@ -60,6 +66,8 @@ impl FakeGitRepositoryState {
             branches: Default::default(),
             simulated_index_write_error_message: Default::default(),
             refs: HashMap::from_iter([("HEAD".into(), "abc".into())]),
+            merge_base_contents: Default::default(),
+            oids: Default::default(),
         }
     }
 }
@@ -110,6 +118,13 @@ impl GitRepository for FakeGitRepository {
             .boxed()
     }
 
+    fn load_blob_content(&self, oid: git::Oid) -> BoxFuture<'_, Result<String>> {
+        self.with_state_async(false, move |state| {
+            state.oids.get(&oid).cloned().context("oid does not exist")
+        })
+        .boxed()
+    }
+
     fn load_commit(
         &self,
         _commit: String,
@@ -140,6 +155,34 @@ impl GitRepository for FakeGitRepository {
         None
     }
 
+    fn diff_tree(&self, _request: DiffTreeType) -> BoxFuture<'_, Result<TreeDiff>> {
+        let mut entries = HashMap::default();
+        self.with_state_async(false, |state| {
+            for (path, content) in &state.head_contents {
+                let status = if let Some((oid, original)) = state
+                    .merge_base_contents
+                    .get(path)
+                    .map(|oid| (oid, &state.oids[oid]))
+                {
+                    if original == content {
+                        continue;
+                    }
+                    TreeDiffStatus::Modified { old: *oid }
+                } else {
+                    TreeDiffStatus::Added
+                };
+                entries.insert(path.clone(), status);
+            }
+            for (path, oid) in &state.merge_base_contents {
+                if !entries.contains_key(path) {
+                    entries.insert(path.clone(), TreeDiffStatus::Deleted { old: *oid });
+                }
+            }
+            Ok(TreeDiff { entries })
+        })
+        .boxed()
+    }
+
     fn revparse_batch(&self, revs: Vec<String>) -> BoxFuture<'_, Result<Vec<Option<String>>>> {
         self.with_state_async(false, |state| {
             Ok(revs
@@ -523,7 +566,7 @@ impl GitRepository for FakeGitRepository {
         let repository_dir_path = self.repository_dir_path.parent().unwrap().to_path_buf();
         async move {
             executor.simulate_random_delay().await;
-            let oid = Oid::random(&mut executor.rng());
+            let oid = git::Oid::random(&mut executor.rng());
             let entry = fs.entry(&repository_dir_path)?;
             checkpoints.lock().insert(oid, entry);
             Ok(GitRepositoryCheckpoint { commit_sha: oid })
@@ -579,7 +622,7 @@ impl GitRepository for FakeGitRepository {
     }
 
     fn default_branch(&self) -> BoxFuture<'_, Result<Option<SharedString>>> {
-        unimplemented!()
+        async { Ok(Some("main".into())) }.boxed()
     }
 }
 

crates/fs/src/fs.rs 🔗

@@ -1752,6 +1752,26 @@ impl FakeFs {
         .unwrap();
     }
 
+    pub fn set_merge_base_content_for_repo(
+        &self,
+        dot_git: &Path,
+        contents_by_path: &[(&str, String)],
+    ) {
+        self.with_git_state(dot_git, true, |state| {
+            use git::Oid;
+
+            state.merge_base_contents.clear();
+            let oids = (1..)
+                .map(|n| n.to_string())
+                .map(|n| Oid::from_bytes(n.repeat(20).as_bytes()).unwrap());
+            for ((path, content), oid) in contents_by_path.iter().zip(oids) {
+                state.merge_base_contents.insert(repo_path(path), oid);
+                state.oids.insert(oid, content.clone());
+            }
+        })
+        .unwrap();
+    }
+
     pub fn set_blame_for_repo(&self, dot_git: &Path, blames: Vec<(RepoPath, git::blame::Blame)>) {
         self.with_git_state(dot_git, true, |state| {
             state.blames.clear();

crates/git/src/repository.rs 🔗

@@ -1,6 +1,6 @@
 use crate::commit::parse_git_diff_name_status;
 use crate::stash::GitStash;
-use crate::status::{GitStatus, StatusCode};
+use crate::status::{DiffTreeType, GitStatus, StatusCode, TreeDiff};
 use crate::{Oid, SHORT_SHA_LENGTH};
 use anyhow::{Context as _, Result, anyhow, bail};
 use collections::HashMap;
@@ -350,6 +350,7 @@ pub trait GitRepository: Send + Sync {
     ///
     /// Also returns `None` for symlinks.
     fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>>;
+    fn load_blob_content(&self, oid: Oid) -> BoxFuture<'_, Result<String>>;
 
     fn set_index_text(
         &self,
@@ -379,6 +380,7 @@ pub trait GitRepository: Send + Sync {
     fn merge_message(&self) -> BoxFuture<'_, Option<String>>;
 
     fn status(&self, path_prefixes: &[RepoPath]) -> Task<Result<GitStatus>>;
+    fn diff_tree(&self, request: DiffTreeType) -> BoxFuture<'_, Result<TreeDiff>>;
 
     fn stash_entries(&self) -> BoxFuture<'_, Result<GitStash>>;
 
@@ -908,6 +910,17 @@ impl GitRepository for RealGitRepository {
             .boxed()
     }
 
+    fn load_blob_content(&self, oid: Oid) -> BoxFuture<'_, Result<String>> {
+        let repo = self.repository.clone();
+        self.executor
+            .spawn(async move {
+                let repo = repo.lock();
+                let content = repo.find_blob(oid.0)?.content().to_owned();
+                Ok(String::from_utf8(content)?)
+            })
+            .boxed()
+    }
+
     fn set_index_text(
         &self,
         path: RepoPath,
@@ -1060,6 +1073,50 @@ impl GitRepository for RealGitRepository {
         })
     }
 
+    fn diff_tree(&self, request: DiffTreeType) -> BoxFuture<'_, Result<TreeDiff>> {
+        let git_binary_path = self.any_git_binary_path.clone();
+        let working_directory = match self.working_directory() {
+            Ok(working_directory) => working_directory,
+            Err(e) => return Task::ready(Err(e)).boxed(),
+        };
+
+        let mut args = vec![
+            OsString::from("--no-optional-locks"),
+            OsString::from("diff-tree"),
+            OsString::from("-r"),
+            OsString::from("-z"),
+            OsString::from("--no-renames"),
+        ];
+        match request {
+            DiffTreeType::MergeBase { base, head } => {
+                args.push("--merge-base".into());
+                args.push(OsString::from(base.as_str()));
+                args.push(OsString::from(head.as_str()));
+            }
+            DiffTreeType::Since { base, head } => {
+                args.push(OsString::from(base.as_str()));
+                args.push(OsString::from(head.as_str()));
+            }
+        }
+
+        self.executor
+            .spawn(async move {
+                let output = new_smol_command(&git_binary_path)
+                    .current_dir(working_directory)
+                    .args(args)
+                    .output()
+                    .await?;
+                if output.status.success() {
+                    let stdout = String::from_utf8_lossy(&output.stdout);
+                    stdout.parse()
+                } else {
+                    let stderr = String::from_utf8_lossy(&output.stderr);
+                    anyhow::bail!("git status failed: {stderr}");
+                }
+            })
+            .boxed()
+    }
+
     fn stash_entries(&self) -> BoxFuture<'_, Result<GitStash>> {
         let git_binary_path = self.any_git_binary_path.clone();
         let working_directory = self.working_directory();
@@ -1827,13 +1884,23 @@ impl GitRepository for RealGitRepository {
                     return Ok(output);
                 }
 
-                let output = git
-                    .run(&["symbolic-ref", "refs/remotes/origin/HEAD"])
-                    .await?;
+                if let Ok(output) = git.run(&["symbolic-ref", "refs/remotes/origin/HEAD"]).await {
+                    return Ok(output
+                        .strip_prefix("refs/remotes/origin/")
+                        .map(|s| SharedString::from(s.to_owned())));
+                }
+
+                if let Ok(default_branch) = git.run(&["config", "init.defaultBranch"]).await {
+                    if git.run(&["rev-parse", &default_branch]).await.is_ok() {
+                        return Ok(Some(default_branch.into()));
+                    }
+                }
+
+                if git.run(&["rev-parse", "master"]).await.is_ok() {
+                    return Ok(Some("master".into()));
+                }
 
-                Ok(output
-                    .strip_prefix("refs/remotes/origin/")
-                    .map(|s| SharedString::from(s.to_owned())))
+                Ok(None)
             })
             .boxed()
     }

crates/git/src/status.rs 🔗

@@ -1,5 +1,7 @@
-use crate::repository::RepoPath;
-use anyhow::Result;
+use crate::{Oid, repository::RepoPath};
+use anyhow::{Result, anyhow};
+use collections::HashMap;
+use gpui::SharedString;
 use serde::{Deserialize, Serialize};
 use std::{str::FromStr, sync::Arc};
 use util::{ResultExt, rel_path::RelPath};
@@ -190,7 +192,11 @@ impl FileStatus {
     }
 
     pub fn is_deleted(self) -> bool {
-        matches!(self, FileStatus::Tracked(tracked) if matches!((tracked.index_status, tracked.worktree_status), (StatusCode::Deleted, _) | (_, StatusCode::Deleted)))
+        let FileStatus::Tracked(tracked) = self else {
+            return false;
+        };
+        tracked.index_status == StatusCode::Deleted && tracked.worktree_status != StatusCode::Added
+            || tracked.worktree_status == StatusCode::Deleted
     }
 
     pub fn is_untracked(self) -> bool {
@@ -486,3 +492,128 @@ impl Default for GitStatus {
         }
     }
 }
+
+pub enum DiffTreeType {
+    MergeBase {
+        base: SharedString,
+        head: SharedString,
+    },
+    Since {
+        base: SharedString,
+        head: SharedString,
+    },
+}
+
+impl DiffTreeType {
+    pub fn base(&self) -> &SharedString {
+        match self {
+            DiffTreeType::MergeBase { base, .. } => base,
+            DiffTreeType::Since { base, .. } => base,
+        }
+    }
+
+    pub fn head(&self) -> &SharedString {
+        match self {
+            DiffTreeType::MergeBase { head, .. } => head,
+            DiffTreeType::Since { head, .. } => head,
+        }
+    }
+}
+
+#[derive(Debug, PartialEq)]
+pub struct TreeDiff {
+    pub entries: HashMap<RepoPath, TreeDiffStatus>,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub enum TreeDiffStatus {
+    Added,
+    Modified { old: Oid },
+    Deleted { old: Oid },
+}
+
+impl FromStr for TreeDiff {
+    type Err = anyhow::Error;
+
+    fn from_str(s: &str) -> Result<Self> {
+        let mut fields = s.split('\0');
+        let mut parsed = HashMap::default();
+        while let Some((status, path)) = fields.next().zip(fields.next()) {
+            let path = RepoPath(RelPath::unix(path)?.into());
+
+            let mut fields = status.split(" ").skip(2);
+            let old_sha = fields
+                .next()
+                .ok_or_else(|| anyhow!("expected to find old_sha"))?
+                .to_owned()
+                .parse()?;
+            let _new_sha = fields
+                .next()
+                .ok_or_else(|| anyhow!("expected to find new_sha"))?;
+            let status = fields
+                .next()
+                .and_then(|s| {
+                    if s.len() == 1 {
+                        s.as_bytes().first()
+                    } else {
+                        None
+                    }
+                })
+                .ok_or_else(|| anyhow!("expected to find status"))?;
+
+            let result = match StatusCode::from_byte(*status)? {
+                StatusCode::Modified => TreeDiffStatus::Modified { old: old_sha },
+                StatusCode::Added => TreeDiffStatus::Added,
+                StatusCode::Deleted => TreeDiffStatus::Deleted { old: old_sha },
+                _status => continue,
+            };
+
+            parsed.insert(path, result);
+        }
+
+        Ok(Self { entries: parsed })
+    }
+}
+
+#[cfg(test)]
+mod tests {
+
+    use crate::{
+        repository::RepoPath,
+        status::{TreeDiff, TreeDiffStatus},
+    };
+
+    #[test]
+    fn test_tree_diff_parsing() {
+        let input = ":000000 100644 0000000000000000000000000000000000000000 0062c311b8727c3a2e3cd7a41bc9904feacf8f98 A\x00.zed/settings.json\x00".to_owned() +
+            ":100644 000000 bb3e9ed2e97a8c02545bae243264d342c069afb3 0000000000000000000000000000000000000000 D\x00README.md\x00" +
+            ":100644 100644 42f097005a1f21eb2260fad02ec8c991282beee8 a437d85f63bb8c62bd78f83f40c506631fabf005 M\x00parallel.go\x00";
+
+        let output: TreeDiff = input.parse().unwrap();
+        assert_eq!(
+            output,
+            TreeDiff {
+                entries: [
+                    (
+                        RepoPath::new(".zed/settings.json").unwrap(),
+                        TreeDiffStatus::Added,
+                    ),
+                    (
+                        RepoPath::new("README.md").unwrap(),
+                        TreeDiffStatus::Deleted {
+                            old: "bb3e9ed2e97a8c02545bae243264d342c069afb3".parse().unwrap()
+                        }
+                    ),
+                    (
+                        RepoPath::new("parallel.go").unwrap(),
+                        TreeDiffStatus::Modified {
+                            old: "42f097005a1f21eb2260fad02ec8c991282beee8".parse().unwrap(),
+                        }
+                    ),
+                ]
+                .into_iter()
+                .collect()
+            }
+        )
+    }
+}

crates/git_ui/Cargo.toml 🔗

@@ -44,7 +44,6 @@ multi_buffer.workspace = true
 notifications.workspace = true
 panel.workspace = true
 picker.workspace = true
-postage.workspace = true
 project.workspace = true
 schemars.workspace = true
 serde.workspace = true

crates/git_ui/src/project_diff.rs 🔗

@@ -4,16 +4,15 @@ use crate::{
     git_panel_settings::GitPanelSettings,
     remote_button::{render_publish_button, render_push_button},
 };
-use anyhow::Result;
+use anyhow::{Context as _, Result, anyhow};
 use buffer_diff::{BufferDiff, DiffHunkSecondaryStatus};
 use collections::{HashMap, HashSet};
 use editor::{
-    Editor, EditorEvent, SelectionEffects,
+    Addon, Editor, EditorEvent, SelectionEffects,
     actions::{GoToHunk, GoToPreviousHunk},
     multibuffer_context_lines,
     scroll::Autoscroll,
 };
-use futures::StreamExt;
 use git::{
     Commit, StageAll, StageAndNext, ToggleStaged, UnstageAll, UnstageAndNext,
     repository::{Branch, RepoPath, Upstream, UpstreamTracking, UpstreamTrackingStatus},
@@ -27,18 +26,23 @@ use language::{Anchor, Buffer, Capability, OffsetRangeExt};
 use multi_buffer::{MultiBuffer, PathKey};
 use project::{
     Project, ProjectPath,
-    git_store::{GitStore, GitStoreEvent, Repository, RepositoryEvent},
+    git_store::{
+        Repository,
+        branch_diff::{self, BranchDiffEvent, DiffBase},
+    },
 };
 use settings::{Settings, SettingsStore};
 use std::any::{Any, TypeId};
 use std::ops::Range;
+use std::sync::Arc;
 use theme::ActiveTheme;
 use ui::{KeyBinding, Tooltip, prelude::*, vertical_divider};
-use util::ResultExt as _;
+use util::{ResultExt as _, rel_path::RelPath};
 use workspace::{
     CloseActiveItem, ItemNavHistory, SerializableItem, ToolbarItemEvent, ToolbarItemLocation,
     ToolbarItemView, Workspace,
     item::{BreadcrumbText, Item, ItemEvent, ItemHandle, SaveOptions, TabContentParams},
+    notifications::NotifyTaskExt,
     searchable::SearchableItemHandle,
 };
 
@@ -48,30 +52,24 @@ actions!(
         /// Shows the diff between the working directory and the index.
         Diff,
         /// Adds files to the git staging area.
-        Add
+        Add,
+        /// Shows the diff between the working directory and your default
+        /// branch (typically main or master).
+        BranchDiff
     ]
 );
 
 pub struct ProjectDiff {
     project: Entity<Project>,
     multibuffer: Entity<MultiBuffer>,
+    branch_diff: Entity<branch_diff::BranchDiff>,
     editor: Entity<Editor>,
-    git_store: Entity<GitStore>,
-    buffer_diff_subscriptions: HashMap<RepoPath, (Entity<BufferDiff>, Subscription)>,
+    buffer_diff_subscriptions: HashMap<Arc<RelPath>, (Entity<BufferDiff>, Subscription)>,
     workspace: WeakEntity<Workspace>,
     focus_handle: FocusHandle,
-    update_needed: postage::watch::Sender<()>,
     pending_scroll: Option<PathKey>,
     _task: Task<Result<()>>,
-    _git_store_subscription: Subscription,
-}
-
-#[derive(Debug)]
-struct DiffBuffer {
-    path_key: PathKey,
-    buffer: Entity<Buffer>,
-    diff: Entity<BufferDiff>,
-    file_status: FileStatus,
+    _subscription: Subscription,
 }
 
 const CONFLICT_SORT_PREFIX: u64 = 1;
@@ -81,6 +79,7 @@ const NEW_SORT_PREFIX: u64 = 3;
 impl ProjectDiff {
     pub(crate) fn register(workspace: &mut Workspace, cx: &mut Context<Workspace>) {
         workspace.register_action(Self::deploy);
+        workspace.register_action(Self::deploy_branch_diff);
         workspace.register_action(|workspace, _: &Add, window, cx| {
             Self::deploy(workspace, &Diff, window, cx);
         });
@@ -96,6 +95,40 @@ impl ProjectDiff {
         Self::deploy_at(workspace, None, window, cx)
     }
 
+    fn deploy_branch_diff(
+        workspace: &mut Workspace,
+        _: &BranchDiff,
+        window: &mut Window,
+        cx: &mut Context<Workspace>,
+    ) {
+        telemetry::event!("Git Branch Diff Opened");
+        let project = workspace.project().clone();
+
+        let existing = workspace
+            .items_of_type::<Self>(cx)
+            .find(|item| matches!(item.read(cx).diff_base(cx), DiffBase::Merge { .. }));
+        if let Some(existing) = existing {
+            workspace.activate_item(&existing, true, true, window, cx);
+            return;
+        }
+        let workspace = cx.entity();
+        window
+            .spawn(cx, async move |cx| {
+                let this = cx
+                    .update(|window, cx| {
+                        Self::new_with_default_branch(project, workspace.clone(), window, cx)
+                    })?
+                    .await?;
+                workspace
+                    .update_in(cx, |workspace, window, cx| {
+                        workspace.add_item_to_active_pane(Box::new(this), None, true, window, cx);
+                    })
+                    .ok();
+                anyhow::Ok(())
+            })
+            .detach_and_notify_err(window, cx);
+    }
+
     pub fn deploy_at(
         workspace: &mut Workspace,
         entry: Option<GitStatusEntry>,
@@ -110,7 +143,10 @@ impl ProjectDiff {
                 "Action"
             }
         );
-        let project_diff = if let Some(existing) = workspace.item_of_type::<Self>(cx) {
+        let existing = workspace
+            .items_of_type::<Self>(cx)
+            .find(|item| matches!(item.read(cx).diff_base(cx), DiffBase::Head));
+        let project_diff = if let Some(existing) = existing {
             workspace.activate_item(&existing, true, true, window, cx);
             existing
         } else {
@@ -139,11 +175,54 @@ impl ProjectDiff {
         })
     }
 
+    fn new_with_default_branch(
+        project: Entity<Project>,
+        workspace: Entity<Workspace>,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> Task<Result<Entity<Self>>> {
+        let Some(repo) = project.read(cx).git_store().read(cx).active_repository() else {
+            return Task::ready(Err(anyhow!("No active repository")));
+        };
+        let main_branch = repo.update(cx, |repo, _| repo.default_branch());
+        window.spawn(cx, async move |cx| {
+            let main_branch = main_branch
+                .await??
+                .context("Could not determine default branch")?;
+
+            let branch_diff = cx.new_window_entity(|window, cx| {
+                branch_diff::BranchDiff::new(
+                    DiffBase::Merge {
+                        base_ref: main_branch,
+                    },
+                    project.clone(),
+                    window,
+                    cx,
+                )
+            })?;
+            cx.new_window_entity(|window, cx| {
+                Self::new_impl(branch_diff, project, workspace, window, cx)
+            })
+        })
+    }
+
     fn new(
         project: Entity<Project>,
         workspace: Entity<Workspace>,
         window: &mut Window,
         cx: &mut Context<Self>,
+    ) -> Self {
+        let branch_diff =
+            cx.new(|cx| branch_diff::BranchDiff::new(DiffBase::Head, project.clone(), window, cx));
+        Self::new_impl(branch_diff, project, workspace, window, cx)
+    }
+
+    fn new_impl(
+        branch_diff: Entity<branch_diff::BranchDiff>,
+        project: Entity<Project>,
+        workspace: Entity<Workspace>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
     ) -> Self {
         let focus_handle = cx.focus_handle();
         let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite));
@@ -153,9 +232,25 @@ impl ProjectDiff {
                 Editor::for_multibuffer(multibuffer.clone(), Some(project.clone()), window, cx);
             diff_display_editor.disable_diagnostics(cx);
             diff_display_editor.set_expand_all_diff_hunks(cx);
-            diff_display_editor.register_addon(GitPanelAddon {
-                workspace: workspace.downgrade(),
-            });
+
+            match branch_diff.read(cx).diff_base() {
+                DiffBase::Head => {
+                    diff_display_editor.register_addon(GitPanelAddon {
+                        workspace: workspace.downgrade(),
+                    });
+                }
+                DiffBase::Merge { .. } => {
+                    diff_display_editor.register_addon(BranchDiffAddon {
+                        branch_diff: branch_diff.clone(),
+                    });
+                    diff_display_editor.start_temporary_diff_override();
+                    diff_display_editor.set_render_diff_hunk_controls(
+                        Arc::new(|_, _, _, _, _, _, _, _| gpui::Empty.into_any_element()),
+                        cx,
+                    );
+                    //
+                }
+            }
             diff_display_editor
         });
         window.defer(cx, {
@@ -172,71 +267,71 @@ impl ProjectDiff {
         cx.subscribe_in(&editor, window, Self::handle_editor_event)
             .detach();
 
-        let git_store = project.read(cx).git_store().clone();
-        let git_store_subscription = cx.subscribe_in(
-            &git_store,
+        let branch_diff_subscription = cx.subscribe_in(
+            &branch_diff,
             window,
-            move |this, _git_store, event, _window, _cx| match event {
-                GitStoreEvent::ActiveRepositoryChanged(_)
-                | GitStoreEvent::RepositoryUpdated(
-                    _,
-                    RepositoryEvent::StatusesChanged { full_scan: _ },
-                    true,
-                )
-                | GitStoreEvent::ConflictsUpdated => {
-                    *this.update_needed.borrow_mut() = ();
+            move |this, _git_store, event, window, cx| match event {
+                BranchDiffEvent::FileListChanged => {
+                    this._task = window.spawn(cx, {
+                        let this = cx.weak_entity();
+                        async |cx| Self::refresh(this, cx).await
+                    })
                 }
-                _ => {}
             },
         );
 
         let mut was_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
         let mut was_collapse_untracked_diff =
             GitPanelSettings::get_global(cx).collapse_untracked_diff;
-        cx.observe_global::<SettingsStore>(move |this, cx| {
+        cx.observe_global_in::<SettingsStore>(window, move |this, window, cx| {
             let is_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
             let is_collapse_untracked_diff =
                 GitPanelSettings::get_global(cx).collapse_untracked_diff;
             if is_sort_by_path != was_sort_by_path
                 || is_collapse_untracked_diff != was_collapse_untracked_diff
             {
-                *this.update_needed.borrow_mut() = ();
+                this._task = {
+                    window.spawn(cx, {
+                        let this = cx.weak_entity();
+                        async |cx| Self::refresh(this, cx).await
+                    })
+                }
             }
             was_sort_by_path = is_sort_by_path;
             was_collapse_untracked_diff = is_collapse_untracked_diff;
         })
         .detach();
 
-        let (mut send, recv) = postage::watch::channel::<()>();
-        let worker = window.spawn(cx, {
+        let task = window.spawn(cx, {
             let this = cx.weak_entity();
-            async |cx| Self::handle_status_updates(this, recv, cx).await
+            async |cx| Self::refresh(this, cx).await
         });
-        // Kick off a refresh immediately
-        *send.borrow_mut() = ();
 
         Self {
             project,
-            git_store: git_store.clone(),
             workspace: workspace.downgrade(),
+            branch_diff,
             focus_handle,
             editor,
             multibuffer,
             buffer_diff_subscriptions: Default::default(),
             pending_scroll: None,
-            update_needed: send,
-            _task: worker,
-            _git_store_subscription: git_store_subscription,
+            _task: task,
+            _subscription: branch_diff_subscription,
         }
     }
 
+    pub fn diff_base<'a>(&'a self, cx: &'a App) -> &'a DiffBase {
+        self.branch_diff.read(cx).diff_base()
+    }
+
     pub fn move_to_entry(
         &mut self,
         entry: GitStatusEntry,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        let Some(git_repo) = self.git_store.read(cx).active_repository() else {
+        let Some(git_repo) = self.branch_diff.read(cx).repo() else {
             return;
         };
         let repo = git_repo.read(cx);
@@ -366,77 +461,28 @@ impl ProjectDiff {
         }
     }
 
-    fn load_buffers(&mut self, cx: &mut Context<Self>) -> Vec<Task<Result<DiffBuffer>>> {
-        let Some(repo) = self.git_store.read(cx).active_repository() else {
-            self.multibuffer.update(cx, |multibuffer, cx| {
-                multibuffer.clear(cx);
-            });
-            self.buffer_diff_subscriptions.clear();
-            return vec![];
-        };
-
-        let mut previous_paths = self.multibuffer.read(cx).paths().collect::<HashSet<_>>();
-
-        let mut result = vec![];
-        repo.update(cx, |repo, cx| {
-            for entry in repo.cached_status() {
-                if !entry.status.has_changes() {
-                    continue;
-                }
-                let Some(project_path) = repo.repo_path_to_project_path(&entry.repo_path, cx)
-                else {
-                    continue;
-                };
-                let sort_prefix = sort_prefix(repo, &entry.repo_path, entry.status, cx);
-                let path_key = PathKey::with_sort_prefix(sort_prefix, entry.repo_path.0.clone());
-
-                previous_paths.remove(&path_key);
-                let load_buffer = self
-                    .project
-                    .update(cx, |project, cx| project.open_buffer(project_path, cx));
-
-                let project = self.project.clone();
-                result.push(cx.spawn(async move |_, cx| {
-                    let buffer = load_buffer.await?;
-                    let changes = project
-                        .update(cx, |project, cx| {
-                            project.open_uncommitted_diff(buffer.clone(), cx)
-                        })?
-                        .await?;
-                    Ok(DiffBuffer {
-                        path_key,
-                        buffer,
-                        diff: changes,
-                        file_status: entry.status,
-                    })
-                }));
-            }
-        });
-        self.multibuffer.update(cx, |multibuffer, cx| {
-            for path in previous_paths {
-                self.buffer_diff_subscriptions
-                    .remove(&path.path.clone().into());
-                multibuffer.remove_excerpts_for_path(path, cx);
-            }
-        });
-        result
-    }
-
     fn register_buffer(
         &mut self,
-        diff_buffer: DiffBuffer,
+        path_key: PathKey,
+        file_status: FileStatus,
+        buffer: Entity<Buffer>,
+        diff: Entity<BufferDiff>,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        let path_key = diff_buffer.path_key.clone();
-        let buffer = diff_buffer.buffer.clone();
-        let diff = diff_buffer.diff.clone();
-
-        let subscription = cx.subscribe(&diff, move |this, _, _, _| {
-            *this.update_needed.borrow_mut() = ();
+        if self.branch_diff.read(cx).diff_base().is_merge_base() {
+            self.multibuffer.update(cx, |multibuffer, cx| {
+                multibuffer.add_diff(diff.clone(), cx);
+            });
+        }
+        let subscription = cx.subscribe_in(&diff, window, move |this, _, _, window, cx| {
+            this._task = window.spawn(cx, {
+                let this = cx.weak_entity();
+                async |cx| Self::refresh(this, cx).await
+            })
         });
         self.buffer_diff_subscriptions
-            .insert(path_key.path.clone().into(), (diff.clone(), subscription));
+            .insert(path_key.path.clone(), (diff.clone(), subscription));
 
         let conflict_addon = self
             .editor
@@ -480,8 +526,8 @@ impl ProjectDiff {
                 });
             }
             if is_excerpt_newly_added
-                && (diff_buffer.file_status.is_deleted()
-                    || (diff_buffer.file_status.is_untracked()
+                && (file_status.is_deleted()
+                    || (file_status.is_untracked()
                         && GitPanelSettings::get_global(cx).collapse_untracked_diff))
             {
                 editor.fold_buffer(snapshot.text.remote_id(), cx)
@@ -506,26 +552,51 @@ impl ProjectDiff {
         }
     }
 
-    pub async fn handle_status_updates(
-        this: WeakEntity<Self>,
-        mut recv: postage::watch::Receiver<()>,
-        cx: &mut AsyncWindowContext,
-    ) -> Result<()> {
-        while (recv.next().await).is_some() {
-            let buffers_to_load = this.update(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();
-                    })?;
+    pub async fn refresh(this: WeakEntity<Self>, cx: &mut AsyncWindowContext) -> Result<()> {
+        let mut path_keys = Vec::new();
+        let buffers_to_load = this.update(cx, |this, cx| {
+            let (repo, buffers_to_load) = this.branch_diff.update(cx, |branch_diff, cx| {
+                let load_buffers = branch_diff.load_buffers(cx);
+                (branch_diff.repo().cloned(), load_buffers)
+            });
+            let mut previous_paths = this.multibuffer.read(cx).paths().collect::<HashSet<_>>();
+
+            if let Some(repo) = repo {
+                let repo = repo.read(cx);
+
+                path_keys = Vec::with_capacity(buffers_to_load.len());
+                for entry in buffers_to_load.iter() {
+                    let sort_prefix = sort_prefix(&repo, &entry.repo_path, entry.file_status, cx);
+                    let path_key =
+                        PathKey::with_sort_prefix(sort_prefix, entry.repo_path.0.clone());
+                    previous_paths.remove(&path_key);
+                    path_keys.push(path_key)
                 }
             }
-            this.update(cx, |this, cx| {
-                this.pending_scroll.take();
-                cx.notify();
-            })?;
+
+            this.multibuffer.update(cx, |multibuffer, cx| {
+                for path in previous_paths {
+                    this.buffer_diff_subscriptions.remove(&path.path);
+                    multibuffer.remove_excerpts_for_path(path, cx);
+                }
+            });
+            buffers_to_load
+        })?;
+
+        for (entry, path_key) in buffers_to_load.into_iter().zip(path_keys.into_iter()) {
+            if let Some((buffer, diff)) = entry.load.await.log_err() {
+                cx.update(|window, cx| {
+                    this.update(cx, |this, cx| {
+                        this.register_buffer(path_key, entry.file_status, buffer, diff, window, cx)
+                    })
+                    .ok();
+                })?;
+            }
         }
+        this.update(cx, |this, cx| {
+            this.pending_scroll.take();
+            cx.notify();
+        })?;
 
         Ok(())
     }
@@ -594,8 +665,8 @@ impl Item for ProjectDiff {
         Some("Project Diff".into())
     }
 
-    fn tab_content(&self, params: TabContentParams, _window: &Window, _: &App) -> AnyElement {
-        Label::new("Uncommitted Changes")
+    fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
+        Label::new(self.tab_content_text(0, cx))
             .color(if params.selected {
                 Color::Default
             } else {
@@ -604,8 +675,11 @@ impl Item for ProjectDiff {
             .into_any_element()
     }
 
-    fn tab_content_text(&self, _detail: usize, _: &App) -> SharedString {
-        "Uncommitted Changes".into()
+    fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString {
+        match self.branch_diff.read(cx).diff_base() {
+            DiffBase::Head => "Uncommitted Changes".into(),
+            DiffBase::Merge { base_ref } => format!("Changes since {}", base_ref).into(),
+        }
     }
 
     fn telemetry_event_text(&self) -> Option<&'static str> {
@@ -802,30 +876,47 @@ impl SerializableItem for ProjectDiff {
     }
 
     fn deserialize(
-        _project: Entity<Project>,
+        project: Entity<Project>,
         workspace: WeakEntity<Workspace>,
-        _workspace_id: workspace::WorkspaceId,
-        _item_id: workspace::ItemId,
+        workspace_id: workspace::WorkspaceId,
+        item_id: workspace::ItemId,
         window: &mut Window,
         cx: &mut App,
     ) -> Task<Result<Entity<Self>>> {
         window.spawn(cx, async move |cx| {
-            workspace.update_in(cx, |workspace, window, cx| {
-                let workspace_handle = cx.entity();
-                cx.new(|cx| Self::new(workspace.project().clone(), workspace_handle, window, cx))
-            })
+            let diff_base = persistence::PROJECT_DIFF_DB.get_diff_base(item_id, workspace_id)?;
+
+            let diff = cx.update(|window, cx| {
+                let branch_diff = cx
+                    .new(|cx| branch_diff::BranchDiff::new(diff_base, project.clone(), window, cx));
+                let workspace = workspace.upgrade().context("workspace gone")?;
+                anyhow::Ok(
+                    cx.new(|cx| ProjectDiff::new_impl(branch_diff, project, workspace, window, cx)),
+                )
+            })??;
+
+            Ok(diff)
         })
     }
 
     fn serialize(
         &mut self,
-        _workspace: &mut Workspace,
-        _item_id: workspace::ItemId,
+        workspace: &mut Workspace,
+        item_id: workspace::ItemId,
         _closing: bool,
         _window: &mut Window,
-        _cx: &mut Context<Self>,
+        cx: &mut Context<Self>,
     ) -> Option<Task<Result<()>>> {
-        None
+        let workspace_id = workspace.database_id()?;
+        let diff_base = self.diff_base(cx).clone();
+
+        Some(cx.background_spawn({
+            async move {
+                persistence::PROJECT_DIFF_DB
+                    .save_diff_base(item_id, workspace_id, diff_base.clone())
+                    .await
+            }
+        }))
     }
 
     fn should_serialize(&self, _: &Self::Event) -> bool {
@@ -833,6 +924,80 @@ impl SerializableItem for ProjectDiff {
     }
 }
 
+mod persistence {
+
+    use anyhow::Context as _;
+    use db::{
+        sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
+        sqlez_macros::sql,
+    };
+    use project::git_store::branch_diff::DiffBase;
+    use workspace::{ItemId, WorkspaceDb, WorkspaceId};
+
+    pub struct ProjectDiffDb(ThreadSafeConnection);
+
+    impl Domain for ProjectDiffDb {
+        const NAME: &str = stringify!(ProjectDiffDb);
+
+        const MIGRATIONS: &[&str] = &[sql!(
+                CREATE TABLE project_diffs(
+                    workspace_id INTEGER,
+                    item_id INTEGER UNIQUE,
+
+                    diff_base TEXT,
+
+                    PRIMARY KEY(workspace_id, item_id),
+                    FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
+                    ON DELETE CASCADE
+                ) STRICT;
+        )];
+    }
+
+    db::static_connection!(PROJECT_DIFF_DB, ProjectDiffDb, [WorkspaceDb]);
+
+    impl ProjectDiffDb {
+        pub async fn save_diff_base(
+            &self,
+            item_id: ItemId,
+            workspace_id: WorkspaceId,
+            diff_base: DiffBase,
+        ) -> anyhow::Result<()> {
+            self.write(move |connection| {
+                let sql_stmt = sql!(
+                    INSERT OR REPLACE INTO project_diffs(item_id, workspace_id, diff_base) VALUES (?, ?, ?)
+                );
+                let diff_base_str = serde_json::to_string(&diff_base)?;
+                let mut query = connection.exec_bound::<(ItemId, WorkspaceId, String)>(sql_stmt)?;
+                query((item_id, workspace_id, diff_base_str)).context(format!(
+                    "exec_bound failed to execute or parse for: {}",
+                    sql_stmt
+                ))
+            })
+            .await
+        }
+
+        pub fn get_diff_base(
+            &self,
+            item_id: ItemId,
+            workspace_id: WorkspaceId,
+        ) -> anyhow::Result<DiffBase> {
+            let sql_stmt =
+                sql!(SELECT diff_base FROM project_diffs WHERE item_id =  ?AND workspace_id =  ?);
+            let diff_base_str = self.select_row_bound::<(ItemId, WorkspaceId), String>(sql_stmt)?(
+                (item_id, workspace_id),
+            )
+            .context(::std::format!(
+                "Error in get_diff_base, select_row_bound failed to execute or parse for: {}",
+                sql_stmt
+            ))?;
+            let Some(diff_base_str) = diff_base_str else {
+                return Ok(DiffBase::Head);
+            };
+            serde_json::from_str(&diff_base_str).context("deserializing diff base")
+        }
+    }
+}
+
 pub struct ProjectDiffToolbar {
     project_diff: Option<WeakEntity<ProjectDiff>>,
     workspace: WeakEntity<Workspace>,
@@ -897,6 +1062,7 @@ impl ToolbarItemView for ProjectDiffToolbar {
     ) -> ToolbarItemLocation {
         self.project_diff = active_pane_item
             .and_then(|item| item.act_as::<ProjectDiff>(cx))
+            .filter(|item| item.read(cx).diff_base(cx) == &DiffBase::Head)
             .map(|entity| entity.downgrade());
         if self.project_diff.is_some() {
             ToolbarItemLocation::PrimaryRight
@@ -1366,18 +1532,42 @@ fn merge_anchor_ranges<'a>(
     })
 }
 
+struct BranchDiffAddon {
+    branch_diff: Entity<branch_diff::BranchDiff>,
+}
+
+impl Addon for BranchDiffAddon {
+    fn to_any(&self) -> &dyn std::any::Any {
+        self
+    }
+
+    fn override_status_for_buffer_id(
+        &self,
+        buffer_id: language::BufferId,
+        cx: &App,
+    ) -> Option<FileStatus> {
+        self.branch_diff
+            .read(cx)
+            .status_for_buffer_id(buffer_id, cx)
+    }
+}
+
 #[cfg(test)]
 mod tests {
+    use collections::HashMap;
     use db::indoc;
     use editor::test::editor_test_context::{EditorTestContext, assert_state_with_diff};
-    use git::status::{UnmergedStatus, UnmergedStatusCode};
+    use git::status::{TrackedStatus, UnmergedStatus, UnmergedStatusCode};
     use gpui::TestAppContext;
     use project::FakeFs;
     use serde_json::json;
     use settings::SettingsStore;
     use std::path::Path;
     use unindent::Unindent as _;
-    use util::{path, rel_path::rel_path};
+    use util::{
+        path,
+        rel_path::{RelPath, rel_path},
+    };
 
     use super::*;
 
@@ -2015,6 +2205,99 @@ mod tests {
         );
     }
 
+    #[gpui::test]
+    async fn test_branch_diff(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            path!("/project"),
+            json!({
+                ".git": {},
+                "a.txt": "C",
+                "b.txt": "new",
+                "c.txt": "in-merge-base-and-work-tree",
+                "d.txt": "created-in-head",
+            }),
+        )
+        .await;
+        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
+        let (workspace, cx) =
+            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+        let diff = cx
+            .update(|window, cx| {
+                ProjectDiff::new_with_default_branch(project.clone(), workspace, window, cx)
+            })
+            .await
+            .unwrap();
+        cx.run_until_parked();
+
+        fs.set_head_for_repo(
+            Path::new(path!("/project/.git")),
+            &[("a.txt", "B".into()), ("d.txt", "created-in-head".into())],
+            "sha",
+        );
+        // fs.set_index_for_repo(dot_git, index_state);
+        fs.set_merge_base_content_for_repo(
+            Path::new(path!("/project/.git")),
+            &[
+                ("a.txt", "A".into()),
+                ("c.txt", "in-merge-base-and-work-tree".into()),
+            ],
+        );
+        cx.run_until_parked();
+
+        let editor = diff.read_with(cx, |diff, _| diff.editor.clone());
+
+        assert_state_with_diff(
+            &editor,
+            cx,
+            &"
+                - A
+                + ˇC
+                + new
+                + created-in-head"
+                .unindent(),
+        );
+
+        let statuses: HashMap<Arc<RelPath>, Option<FileStatus>> =
+            editor.update(cx, |editor, cx| {
+                editor
+                    .buffer()
+                    .read(cx)
+                    .all_buffers()
+                    .iter()
+                    .map(|buffer| {
+                        (
+                            buffer.read(cx).file().unwrap().path().clone(),
+                            editor.status_for_buffer_id(buffer.read(cx).remote_id(), cx),
+                        )
+                    })
+                    .collect()
+            });
+
+        assert_eq!(
+            statuses,
+            HashMap::from_iter([
+                (
+                    rel_path("a.txt").into_arc(),
+                    Some(FileStatus::Tracked(TrackedStatus {
+                        index_status: git::status::StatusCode::Modified,
+                        worktree_status: git::status::StatusCode::Modified
+                    }))
+                ),
+                (rel_path("b.txt").into_arc(), Some(FileStatus::Untracked)),
+                (
+                    rel_path("d.txt").into_arc(),
+                    Some(FileStatus::Tracked(TrackedStatus {
+                        index_status: git::status::StatusCode::Added,
+                        worktree_status: git::status::StatusCode::Added
+                    }))
+                )
+            ])
+        );
+    }
+
     #[gpui::test]
     async fn test_update_on_uncommit(cx: &mut TestAppContext) {
         init_test(cx);

crates/project/src/git_store.rs 🔗

@@ -1,3 +1,4 @@
+pub mod branch_diff;
 mod conflict_set;
 pub mod git_traversal;
 
@@ -30,7 +31,8 @@ use git::{
     },
     stash::{GitStash, StashEntry},
     status::{
-        FileStatus, GitSummary, StatusCode, TrackedStatus, UnmergedStatus, UnmergedStatusCode,
+        DiffTreeType, FileStatus, GitSummary, StatusCode, TrackedStatus, TreeDiff, TreeDiffStatus,
+        UnmergedStatus, UnmergedStatusCode,
     },
 };
 use gpui::{
@@ -55,6 +57,7 @@ use std::{
     mem,
     ops::Range,
     path::{Path, PathBuf},
+    str::FromStr,
     sync::{
         Arc,
         atomic::{self, AtomicU64},
@@ -432,6 +435,8 @@ impl GitStore {
         client.add_entity_request_handler(Self::handle_askpass);
         client.add_entity_request_handler(Self::handle_check_for_pushed_commits);
         client.add_entity_request_handler(Self::handle_git_diff);
+        client.add_entity_request_handler(Self::handle_tree_diff);
+        client.add_entity_request_handler(Self::handle_get_blob_content);
         client.add_entity_request_handler(Self::handle_open_unstaged_diff);
         client.add_entity_request_handler(Self::handle_open_uncommitted_diff);
         client.add_entity_message_handler(Self::handle_update_diff_bases);
@@ -619,6 +624,52 @@ impl GitStore {
         cx.background_spawn(async move { task.await.map_err(|e| anyhow!("{e}")) })
     }
 
+    pub fn open_diff_since(
+        &mut self,
+        oid: Option<git::Oid>,
+        buffer: Entity<Buffer>,
+        repo: Entity<Repository>,
+        languages: Arc<LanguageRegistry>,
+        cx: &mut Context<Self>,
+    ) -> Task<Result<Entity<BufferDiff>>> {
+        cx.spawn(async move |this, cx| {
+            let buffer_snapshot = buffer.update(cx, |buffer, _| buffer.snapshot())?;
+            let content = match oid {
+                None => None,
+                Some(oid) => Some(
+                    repo.update(cx, |repo, cx| repo.load_blob_content(oid, cx))?
+                        .await?,
+                ),
+            };
+            let buffer_diff = cx.new(|cx| BufferDiff::new(&buffer_snapshot, cx))?;
+
+            buffer_diff
+                .update(cx, |buffer_diff, cx| {
+                    buffer_diff.set_base_text(
+                        content.map(Arc::new),
+                        buffer_snapshot.language().cloned(),
+                        Some(languages.clone()),
+                        buffer_snapshot.text,
+                        cx,
+                    )
+                })?
+                .await?;
+            let unstaged_diff = this
+                .update(cx, |this, cx| this.open_unstaged_diff(buffer.clone(), cx))?
+                .await?;
+            buffer_diff.update(cx, |buffer_diff, _| {
+                buffer_diff.set_secondary_diff(unstaged_diff);
+            })?;
+
+            this.update(cx, |_, cx| {
+                cx.subscribe(&buffer_diff, Self::on_buffer_diff_event)
+                    .detach();
+            })?;
+
+            Ok(buffer_diff)
+        })
+    }
+
     pub fn open_uncommitted_diff(
         &mut self,
         buffer: Entity<Buffer>,
@@ -2168,6 +2219,75 @@ impl GitStore {
         Ok(proto::GitDiffResponse { diff })
     }
 
+    async fn handle_tree_diff(
+        this: Entity<Self>,
+        request: TypedEnvelope<proto::GetTreeDiff>,
+        mut cx: AsyncApp,
+    ) -> Result<proto::GetTreeDiffResponse> {
+        let repository_id = RepositoryId(request.payload.repository_id);
+        let diff_type = if request.payload.is_merge {
+            DiffTreeType::MergeBase {
+                base: request.payload.base.into(),
+                head: request.payload.head.into(),
+            }
+        } else {
+            DiffTreeType::Since {
+                base: request.payload.base.into(),
+                head: request.payload.head.into(),
+            }
+        };
+
+        let diff = this
+            .update(&mut cx, |this, cx| {
+                let repository = this.repositories().get(&repository_id)?;
+                Some(repository.update(cx, |repo, cx| repo.diff_tree(diff_type, cx)))
+            })?
+            .context("missing repository")?
+            .await??;
+
+        Ok(proto::GetTreeDiffResponse {
+            entries: diff
+                .entries
+                .into_iter()
+                .map(|(path, status)| proto::TreeDiffStatus {
+                    path: path.0.to_proto(),
+                    status: match status {
+                        TreeDiffStatus::Added {} => proto::tree_diff_status::Status::Added.into(),
+                        TreeDiffStatus::Modified { .. } => {
+                            proto::tree_diff_status::Status::Modified.into()
+                        }
+                        TreeDiffStatus::Deleted { .. } => {
+                            proto::tree_diff_status::Status::Deleted.into()
+                        }
+                    },
+                    oid: match status {
+                        TreeDiffStatus::Deleted { old } | TreeDiffStatus::Modified { old } => {
+                            Some(old.to_string())
+                        }
+                        TreeDiffStatus::Added => None,
+                    },
+                })
+                .collect(),
+        })
+    }
+
+    async fn handle_get_blob_content(
+        this: Entity<Self>,
+        request: TypedEnvelope<proto::GetBlobContent>,
+        mut cx: AsyncApp,
+    ) -> Result<proto::GetBlobContentResponse> {
+        let oid = git::Oid::from_str(&request.payload.oid)?;
+        let repository_id = RepositoryId(request.payload.repository_id);
+        let content = this
+            .update(&mut cx, |this, cx| {
+                let repository = this.repositories().get(&repository_id)?;
+                Some(repository.update(cx, |repo, cx| repo.load_blob_content(oid, cx)))
+            })?
+            .context("missing repository")?
+            .await?;
+        Ok(proto::GetBlobContentResponse { content })
+    }
+
     async fn handle_open_unstaged_diff(
         this: Entity<Self>,
         request: TypedEnvelope<proto::OpenUnstagedDiff>,
@@ -4303,6 +4423,62 @@ impl Repository {
         })
     }
 
+    pub fn diff_tree(
+        &mut self,
+        diff_type: DiffTreeType,
+        _cx: &App,
+    ) -> oneshot::Receiver<Result<TreeDiff>> {
+        let repository_id = self.snapshot.id;
+        self.send_job(None, move |repo, _cx| async move {
+            match repo {
+                RepositoryState::Local { backend, .. } => backend.diff_tree(diff_type).await,
+                RepositoryState::Remote { client, project_id } => {
+                    let response = client
+                        .request(proto::GetTreeDiff {
+                            project_id: project_id.0,
+                            repository_id: repository_id.0,
+                            is_merge: matches!(diff_type, DiffTreeType::MergeBase { .. }),
+                            base: diff_type.base().to_string(),
+                            head: diff_type.head().to_string(),
+                        })
+                        .await?;
+
+                    let entries = response
+                        .entries
+                        .into_iter()
+                        .filter_map(|entry| {
+                            let status = match entry.status() {
+                                proto::tree_diff_status::Status::Added => TreeDiffStatus::Added,
+                                proto::tree_diff_status::Status::Modified => {
+                                    TreeDiffStatus::Modified {
+                                        old: git::Oid::from_str(
+                                            &entry.oid.context("missing oid").log_err()?,
+                                        )
+                                        .log_err()?,
+                                    }
+                                }
+                                proto::tree_diff_status::Status::Deleted => {
+                                    TreeDiffStatus::Deleted {
+                                        old: git::Oid::from_str(
+                                            &entry.oid.context("missing oid").log_err()?,
+                                        )
+                                        .log_err()?,
+                                    }
+                                }
+                            };
+                            Some((
+                                RepoPath(RelPath::from_proto(&entry.path).log_err()?),
+                                status,
+                            ))
+                        })
+                        .collect();
+
+                    Ok(TreeDiff { entries })
+                }
+            }
+        })
+    }
+
     pub fn diff(&mut self, diff_type: DiffType, _cx: &App) -> oneshot::Receiver<Result<String>> {
         let id = self.id;
         self.send_job(None, move |repo, _cx| async move {
@@ -4775,6 +4951,25 @@ impl Repository {
 
         cx.spawn(|_: &mut AsyncApp| async move { rx.await? })
     }
+    fn load_blob_content(&mut self, oid: Oid, cx: &App) -> Task<Result<String>> {
+        let repository_id = self.snapshot.id;
+        let rx = self.send_job(None, move |state, _| async move {
+            match state {
+                RepositoryState::Local { backend, .. } => backend.load_blob_content(oid).await,
+                RepositoryState::Remote { client, project_id } => {
+                    let response = client
+                        .request(proto::GetBlobContent {
+                            project_id: project_id.to_proto(),
+                            repository_id: repository_id.0,
+                            oid: oid.to_string(),
+                        })
+                        .await?;
+                    Ok(response.content)
+                }
+            }
+        });
+        cx.spawn(|_: &mut AsyncApp| async move { rx.await? })
+    }
 
     fn paths_changed(
         &mut self,

crates/project/src/git_store/branch_diff.rs 🔗

@@ -0,0 +1,386 @@
+use anyhow::Result;
+use buffer_diff::BufferDiff;
+use collections::HashSet;
+use futures::StreamExt;
+use git::{
+    repository::RepoPath,
+    status::{DiffTreeType, FileStatus, StatusCode, TrackedStatus, TreeDiff, TreeDiffStatus},
+};
+use gpui::{
+    App, AsyncWindowContext, Context, Entity, EventEmitter, SharedString, Subscription, Task,
+    WeakEntity, Window,
+};
+
+use language::Buffer;
+use text::BufferId;
+use util::ResultExt;
+
+use crate::{
+    Project,
+    git_store::{GitStoreEvent, Repository, RepositoryEvent},
+};
+
+#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
+pub enum DiffBase {
+    Head,
+    Merge { base_ref: SharedString },
+}
+
+impl DiffBase {
+    pub fn is_merge_base(&self) -> bool {
+        matches!(self, DiffBase::Merge { .. })
+    }
+}
+
+pub struct BranchDiff {
+    diff_base: DiffBase,
+    repo: Option<Entity<Repository>>,
+    project: Entity<Project>,
+    base_commit: Option<SharedString>,
+    head_commit: Option<SharedString>,
+    tree_diff: Option<TreeDiff>,
+    _subscription: Subscription,
+    update_needed: postage::watch::Sender<()>,
+    _task: Task<()>,
+}
+
+pub enum BranchDiffEvent {
+    FileListChanged,
+}
+
+impl EventEmitter<BranchDiffEvent> for BranchDiff {}
+
+impl BranchDiff {
+    pub fn new(
+        source: DiffBase,
+        project: Entity<Project>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Self {
+        let git_store = project.read(cx).git_store().clone();
+        let git_store_subscription = cx.subscribe_in(
+            &git_store,
+            window,
+            move |this, _git_store, event, _window, cx| match event {
+                GitStoreEvent::ActiveRepositoryChanged(_)
+                | GitStoreEvent::RepositoryUpdated(
+                    _,
+                    RepositoryEvent::StatusesChanged { full_scan: _ },
+                    true,
+                )
+                | GitStoreEvent::ConflictsUpdated => {
+                    cx.emit(BranchDiffEvent::FileListChanged);
+                    *this.update_needed.borrow_mut() = ();
+                }
+                _ => {}
+            },
+        );
+
+        let (send, recv) = postage::watch::channel::<()>();
+        let worker = window.spawn(cx, {
+            let this = cx.weak_entity();
+            async |cx| Self::handle_status_updates(this, recv, cx).await
+        });
+        let repo = git_store.read(cx).active_repository();
+
+        Self {
+            diff_base: source,
+            repo,
+            project,
+            tree_diff: None,
+            base_commit: None,
+            head_commit: None,
+            _subscription: git_store_subscription,
+            _task: worker,
+            update_needed: send,
+        }
+    }
+
+    pub fn diff_base(&self) -> &DiffBase {
+        &self.diff_base
+    }
+
+    pub async fn handle_status_updates(
+        this: WeakEntity<Self>,
+        mut recv: postage::watch::Receiver<()>,
+        cx: &mut AsyncWindowContext,
+    ) {
+        Self::reload_tree_diff(this.clone(), cx).await.log_err();
+        while recv.next().await.is_some() {
+            let Ok(needs_update) = this.update(cx, |this, cx| {
+                let mut needs_update = false;
+                let active_repo = this
+                    .project
+                    .read(cx)
+                    .git_store()
+                    .read(cx)
+                    .active_repository();
+                if active_repo != this.repo {
+                    needs_update = true;
+                    this.repo = active_repo;
+                } else if let Some(repo) = this.repo.as_ref() {
+                    repo.update(cx, |repo, _| {
+                        if let Some(branch) = &repo.branch
+                            && let DiffBase::Merge { base_ref } = &this.diff_base
+                            && let Some(commit) = branch.most_recent_commit.as_ref()
+                            && &branch.ref_name == base_ref
+                            && this.base_commit.as_ref() != Some(&commit.sha)
+                        {
+                            this.base_commit = Some(commit.sha.clone());
+                            needs_update = true;
+                        }
+
+                        if repo.head_commit.as_ref().map(|c| &c.sha) != this.head_commit.as_ref() {
+                            this.head_commit = repo.head_commit.as_ref().map(|c| c.sha.clone());
+                            needs_update = true;
+                        }
+                    })
+                }
+                needs_update
+            }) else {
+                return;
+            };
+
+            if needs_update {
+                Self::reload_tree_diff(this.clone(), cx).await.log_err();
+            }
+        }
+    }
+
+    pub fn status_for_buffer_id(&self, buffer_id: BufferId, cx: &App) -> Option<FileStatus> {
+        let (repo, path) = self
+            .project
+            .read(cx)
+            .git_store()
+            .read(cx)
+            .repository_and_path_for_buffer_id(buffer_id, cx)?;
+        if self.repo() == Some(&repo) {
+            return self.merge_statuses(
+                repo.read(cx)
+                    .status_for_path(&path)
+                    .map(|status| status.status),
+                self.tree_diff
+                    .as_ref()
+                    .and_then(|diff| diff.entries.get(&path)),
+            );
+        }
+        None
+    }
+
+    pub fn merge_statuses(
+        &self,
+        diff_from_head: Option<FileStatus>,
+        diff_from_merge_base: Option<&TreeDiffStatus>,
+    ) -> Option<FileStatus> {
+        match (diff_from_head, diff_from_merge_base) {
+            (None, None) => None,
+            (Some(diff_from_head), None) => Some(diff_from_head),
+            (Some(diff_from_head @ FileStatus::Unmerged(_)), _) => Some(diff_from_head),
+
+            // file does not exist in HEAD
+            // but *does* exist in work-tree
+            // and *does* exist in merge-base
+            (
+                Some(FileStatus::Untracked)
+                | Some(FileStatus::Tracked(TrackedStatus {
+                    index_status: StatusCode::Added,
+                    worktree_status: _,
+                })),
+                Some(_),
+            ) => Some(FileStatus::Tracked(TrackedStatus {
+                index_status: StatusCode::Modified,
+                worktree_status: StatusCode::Modified,
+            })),
+
+            // file exists in HEAD
+            // but *does not* exist in work-tree
+            (Some(diff_from_head), Some(diff_from_merge_base)) if diff_from_head.is_deleted() => {
+                match diff_from_merge_base {
+                    TreeDiffStatus::Added => None, // unchanged, didn't exist in merge base or worktree
+                    _ => Some(diff_from_head),
+                }
+            }
+
+            // file exists in HEAD
+            // and *does* exist in work-tree
+            (Some(FileStatus::Tracked(_)), Some(tree_status)) => {
+                Some(FileStatus::Tracked(TrackedStatus {
+                    index_status: match tree_status {
+                        TreeDiffStatus::Added { .. } => StatusCode::Added,
+                        _ => StatusCode::Modified,
+                    },
+                    worktree_status: match tree_status {
+                        TreeDiffStatus::Added => StatusCode::Added,
+                        _ => StatusCode::Modified,
+                    },
+                }))
+            }
+
+            (_, Some(diff_from_merge_base)) => {
+                Some(diff_status_to_file_status(diff_from_merge_base))
+            }
+        }
+    }
+
+    pub async fn reload_tree_diff(
+        this: WeakEntity<Self>,
+        cx: &mut AsyncWindowContext,
+    ) -> Result<()> {
+        let task = this.update(cx, |this, cx| {
+            let DiffBase::Merge { base_ref } = this.diff_base.clone() else {
+                return None;
+            };
+            let Some(repo) = this.repo.as_ref() else {
+                this.tree_diff.take();
+                return None;
+            };
+            repo.update(cx, |repo, cx| {
+                Some(repo.diff_tree(
+                    DiffTreeType::MergeBase {
+                        base: base_ref,
+                        head: "HEAD".into(),
+                    },
+                    cx,
+                ))
+            })
+        })?;
+        let Some(task) = task else { return Ok(()) };
+
+        let diff = task.await??;
+        this.update(cx, |this, cx| {
+            this.tree_diff = Some(diff);
+            cx.emit(BranchDiffEvent::FileListChanged);
+            cx.notify();
+        })
+    }
+
+    pub fn repo(&self) -> Option<&Entity<Repository>> {
+        self.repo.as_ref()
+    }
+
+    pub fn load_buffers(&mut self, cx: &mut Context<Self>) -> Vec<DiffBuffer> {
+        let mut output = Vec::default();
+        let Some(repo) = self.repo.clone() else {
+            return output;
+        };
+
+        self.project.update(cx, |_project, cx| {
+            let mut seen = HashSet::default();
+
+            for item in repo.read(cx).cached_status() {
+                seen.insert(item.repo_path.clone());
+                let branch_diff = self
+                    .tree_diff
+                    .as_ref()
+                    .and_then(|t| t.entries.get(&item.repo_path))
+                    .cloned();
+                let status = self
+                    .merge_statuses(Some(item.status), branch_diff.as_ref())
+                    .unwrap();
+                if !status.has_changes() {
+                    continue;
+                }
+
+                let Some(project_path) =
+                    repo.read(cx).repo_path_to_project_path(&item.repo_path, cx)
+                else {
+                    continue;
+                };
+                let task = Self::load_buffer(branch_diff, project_path, repo.clone(), cx);
+
+                output.push(DiffBuffer {
+                    repo_path: item.repo_path.clone(),
+                    load: task,
+                    file_status: item.status,
+                });
+            }
+            let Some(tree_diff) = self.tree_diff.as_ref() else {
+                return;
+            };
+
+            for (path, branch_diff) in tree_diff.entries.iter() {
+                if seen.contains(&path) {
+                    continue;
+                }
+
+                let Some(project_path) = repo.read(cx).repo_path_to_project_path(&path, cx) else {
+                    continue;
+                };
+                let task =
+                    Self::load_buffer(Some(branch_diff.clone()), project_path, repo.clone(), cx);
+
+                let file_status = diff_status_to_file_status(branch_diff);
+
+                output.push(DiffBuffer {
+                    repo_path: path.clone(),
+                    load: task,
+                    file_status,
+                });
+            }
+        });
+        output
+    }
+
+    fn load_buffer(
+        branch_diff: Option<git::status::TreeDiffStatus>,
+        project_path: crate::ProjectPath,
+        repo: Entity<Repository>,
+        cx: &Context<'_, Project>,
+    ) -> Task<Result<(Entity<Buffer>, Entity<BufferDiff>)>> {
+        let task = cx.spawn(async move |project, cx| {
+            let buffer = project
+                .update(cx, |project, cx| project.open_buffer(project_path, cx))?
+                .await?;
+
+            let languages = project.update(cx, |project, _cx| project.languages().clone())?;
+
+            let changes = if let Some(entry) = branch_diff {
+                let oid = match entry {
+                    git::status::TreeDiffStatus::Added { .. } => None,
+                    git::status::TreeDiffStatus::Modified { old, .. }
+                    | git::status::TreeDiffStatus::Deleted { old } => Some(old),
+                };
+                project
+                    .update(cx, |project, cx| {
+                        project.git_store().update(cx, |git_store, cx| {
+                            git_store.open_diff_since(oid, buffer.clone(), repo, languages, cx)
+                        })
+                    })?
+                    .await?
+            } else {
+                project
+                    .update(cx, |project, cx| {
+                        project.open_uncommitted_diff(buffer.clone(), cx)
+                    })?
+                    .await?
+            };
+            Ok((buffer, changes))
+        });
+        task
+    }
+}
+
+fn diff_status_to_file_status(branch_diff: &git::status::TreeDiffStatus) -> FileStatus {
+    let file_status = match branch_diff {
+        git::status::TreeDiffStatus::Added { .. } => FileStatus::Tracked(TrackedStatus {
+            index_status: StatusCode::Added,
+            worktree_status: StatusCode::Added,
+        }),
+        git::status::TreeDiffStatus::Modified { .. } => FileStatus::Tracked(TrackedStatus {
+            index_status: StatusCode::Modified,
+            worktree_status: StatusCode::Modified,
+        }),
+        git::status::TreeDiffStatus::Deleted { .. } => FileStatus::Tracked(TrackedStatus {
+            index_status: StatusCode::Deleted,
+            worktree_status: StatusCode::Deleted,
+        }),
+    };
+    file_status
+}
+
+#[derive(Debug)]
+pub struct DiffBuffer {
+    pub repo_path: RepoPath,
+    pub file_status: FileStatus,
+    pub load: Task<Result<(Entity<Buffer>, Entity<BufferDiff>)>>,
+}

crates/proto/proto/git.proto 🔗

@@ -472,3 +472,37 @@ message GetDefaultBranch {
 message GetDefaultBranchResponse {
     optional string branch = 1;
 }
+
+message GetTreeDiff {
+    uint64 project_id = 1;
+    uint64 repository_id = 2;
+    bool is_merge = 3;
+    string base = 4;
+    string head = 5;
+}
+
+message GetTreeDiffResponse {
+    repeated TreeDiffStatus entries = 1;
+}
+
+message TreeDiffStatus {
+    enum Status {
+        ADDED = 0;
+        MODIFIED = 1;
+        DELETED = 2;
+    }
+
+    Status status = 1;
+    string path = 2;
+    optional string oid = 3;
+}
+
+message GetBlobContent {
+    uint64 project_id = 1;
+    uint64 repository_id = 2;
+    string oid =3;
+}
+
+message GetBlobContentResponse {
+    string content = 1;
+}

crates/proto/proto/zed.proto 🔗

@@ -421,7 +421,13 @@ message Envelope {
         RemoteStarted remote_started = 381;
 
         GetDirectoryEnvironment get_directory_environment = 382;
-        DirectoryEnvironment directory_environment = 383; // current max
+        DirectoryEnvironment directory_environment = 383;
+
+        GetTreeDiff get_tree_diff = 384;
+        GetTreeDiffResponse get_tree_diff_response = 385;
+
+        GetBlobContent get_blob_content = 386;
+        GetBlobContentResponse get_blob_content_response = 387; // current max
     }
 
     reserved 87 to 88;

crates/proto/src/proto.rs 🔗

@@ -316,6 +316,10 @@ messages!(
     (PullWorkspaceDiagnostics, Background),
     (GetDefaultBranch, Background),
     (GetDefaultBranchResponse, Background),
+    (GetTreeDiff, Background),
+    (GetTreeDiffResponse, Background),
+    (GetBlobContent, Background),
+    (GetBlobContentResponse, Background),
     (GitClone, Background),
     (GitCloneResponse, Background),
     (ToggleLspLogs, Background),
@@ -497,6 +501,8 @@ request_messages!(
     (GetDocumentDiagnostics, GetDocumentDiagnosticsResponse),
     (PullWorkspaceDiagnostics, Ack),
     (GetDefaultBranch, GetDefaultBranchResponse),
+    (GetBlobContent, GetBlobContentResponse),
+    (GetTreeDiff, GetTreeDiffResponse),
     (GitClone, GitCloneResponse),
     (ToggleLspLogs, Ack),
     (GetDirectoryEnvironment, DirectoryEnvironment),
@@ -659,6 +665,8 @@ entity_messages!(
     GetDocumentDiagnostics,
     PullWorkspaceDiagnostics,
     GetDefaultBranch,
+    GetTreeDiff,
+    GetBlobContent,
     GitClone,
     GetAgentServerCommand,
     ExternalAgentsUpdated,

crates/zed/src/zed.rs 🔗

@@ -18,7 +18,6 @@ use breadcrumbs::Breadcrumbs;
 use client::zed_urls;
 use collections::VecDeque;
 use debugger_ui::debugger_panel::DebugPanel;
-use editor::ProposedChangesEditorToolbar;
 use editor::{Editor, MultiBuffer};
 use extension_host::ExtensionStore;
 use feature_flags::{FeatureFlagAppExt, PanicFeatureFlag};
@@ -1035,8 +1034,6 @@ fn initialize_pane(
                 )
             });
             toolbar.add_item(buffer_search_bar.clone(), window, cx);
-            let proposed_change_bar = cx.new(|_| ProposedChangesEditorToolbar::new());
-            toolbar.add_item(proposed_change_bar, window, cx);
             let quick_action_bar =
                 cx.new(|cx| QuickActionBar::new(buffer_search_bar, workspace, cx));
             toolbar.add_item(quick_action_bar, window, cx);