Allow reviewing of agent changes without Git (#27668)

Antonio Scandurra created

Release Notes:

- N/A

Change summary

Cargo.lock                                           |   9 
assets/keymaps/default-linux.json                    |  20 
assets/keymaps/default-macos.json                    |  22 
crates/assistant2/Cargo.toml                         |   4 
crates/assistant2/src/assistant.rs                   |   6 
crates/assistant2/src/assistant_diff.rs              | 665 +++++++++
crates/assistant2/src/message_editor.rs              | 341 ++--
crates/assistant2/src/thread.rs                      |  13 
crates/assistant_tool/Cargo.toml                     |  15 
crates/assistant_tool/src/action_log.rs              | 968 ++++++++++++++
crates/assistant_tool/src/assistant_tool.rs          |  78 -
crates/assistant_tools/Cargo.toml                    |   2 
crates/assistant_tools/src/create_file_tool.rs       |  16 
crates/assistant_tools/src/delete_path_tool.rs       |  77 
crates/assistant_tools/src/edit_files_tool.rs        |  25 
crates/assistant_tools/src/find_replace_file_tool.rs |  21 
crates/assistant_tools/src/replace.rs                |   2 
crates/editor/src/editor.rs                          | 215 +++
crates/editor/src/element.rs                         | 207 --
crates/fs/src/fake_git_repo.rs                       |  35 
crates/git/src/repository.rs                         | 316 ----
crates/language/src/buffer.rs                        |  25 
crates/language/src/buffer_tests.rs                  |   6 
crates/project/src/git_store.rs                      | 249 ---
crates/project/src/lsp_store.rs                      |   6 
crates/text/src/text.rs                              |  16 
crates/worktree/src/worktree.rs                      |   5 
27 files changed, 2,270 insertions(+), 1,094 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -451,6 +451,7 @@ dependencies = [
  "assistant_slash_command",
  "assistant_tool",
  "async-watch",
+ "buffer_diff",
  "chrono",
  "client",
  "clock",
@@ -466,7 +467,6 @@ dependencies = [
  "futures 0.3.31",
  "fuzzy",
  "git",
- "git_ui",
  "gpui",
  "heed",
  "html_to_markdown",
@@ -496,7 +496,6 @@ dependencies = [
  "serde",
  "serde_json",
  "settings",
- "smallvec",
  "smol",
  "streaming_diff",
  "telemetry",
@@ -692,6 +691,8 @@ name = "assistant_tool"
 version = "0.1.0"
 dependencies = [
  "anyhow",
+ "async-watch",
+ "buffer_diff",
  "clock",
  "collections",
  "derive_more",
@@ -703,6 +704,9 @@ dependencies = [
  "project",
  "serde",
  "serde_json",
+ "settings",
+ "text",
+ "util",
 ]
 
 [[package]]
@@ -712,6 +716,7 @@ dependencies = [
  "anyhow",
  "assistant_tool",
  "chrono",
+ "clock",
  "collections",
  "feature_flags",
  "futures 0.3.31",

assets/keymaps/default-linux.json 🔗

@@ -126,7 +126,6 @@
       // "alt-v": ["editor::MovePageUp", { "center_cursor": true }],
       "ctrl-alt-space": "editor::ShowCharacterPalette",
       "ctrl-;": "editor::ToggleLineNumbers",
-      "ctrl-k ctrl-r": "git::Restore",
       "ctrl-'": "editor::ToggleSelectedDiffHunks",
       "ctrl-\"": "editor::ExpandAllDiffHunks",
       "ctrl-i": "editor::ShowSignatureHelp",
@@ -138,6 +137,22 @@
       "shift-f9": "editor::EditLogBreakpoint"
     }
   },
+  {
+    "context": "Editor && !assistant_diff",
+    "bindings": {
+      "ctrl-k ctrl-r": "git::Restore",
+      "ctrl-alt-y": "git::ToggleStaged",
+      "alt-y": "git::StageAndNext",
+      "alt-shift-y": "git::UnstageAndNext"
+    }
+  },
+  {
+    "context": "AssistantDiff",
+    "bindings": {
+      "ctrl-y": "assistant2::ToggleKeep",
+      "ctrl-k ctrl-r": "assistant2::Reject"
+    }
+  },
   {
     "context": "Editor && mode == full",
     "bindings": {
@@ -382,9 +397,6 @@
       "ctrl-k v": "markdown::OpenPreviewToTheSide",
       "ctrl-shift-v": "markdown::OpenPreview",
       "ctrl-alt-shift-c": "editor::DisplayCursorNames",
-      "ctrl-alt-y": "git::ToggleStaged",
-      "alt-y": "git::StageAndNext",
-      "alt-shift-y": "git::UnstageAndNext",
       "alt-.": "editor::GoToHunk",
       "alt-,": "editor::GoToPreviousHunk"
     }

assets/keymaps/default-macos.json 🔗

@@ -147,10 +147,6 @@
       "ctrl-shift-v": ["editor::MovePageUp", { "center_cursor": true }],
       "ctrl-cmd-space": "editor::ShowCharacterPalette",
       "cmd-;": "editor::ToggleLineNumbers",
-      "cmd-alt-z": "git::Restore",
-      "cmd-alt-y": "git::ToggleStaged",
-      "cmd-y": "git::StageAndNext",
-      "cmd-shift-y": "git::UnstageAndNext",
       "cmd-'": "editor::ToggleSelectedDiffHunks",
       "cmd-\"": "editor::ExpandAllDiffHunks",
       "cmd-alt-g b": "editor::ToggleGitBlame",
@@ -231,6 +227,24 @@
       "ctrl-alt-enter": "repl::RunInPlace"
     }
   },
+  {
+    "context": "Editor && !assistant_diff",
+    "use_key_equivalents": true,
+    "bindings": {
+      "cmd-alt-z": "git::Restore",
+      "cmd-alt-y": "git::ToggleStaged",
+      "cmd-y": "git::StageAndNext",
+      "cmd-shift-y": "git::UnstageAndNext"
+    }
+  },
+  {
+    "context": "AssistantDiff",
+    "use_key_equivalents": true,
+    "bindings": {
+      "cmd-y": "assistant2::ToggleKeep",
+      "cmd-alt-z": "assistant2::Reject"
+    }
+  },
   {
     "context": "AssistantPanel",
     "use_key_equivalents": true,

crates/assistant2/Cargo.toml 🔗

@@ -25,6 +25,7 @@ assistant_settings.workspace = true
 assistant_slash_command.workspace = true
 assistant_tool.workspace = true
 async-watch.workspace = true
+buffer_diff.workspace = true
 chrono.workspace = true
 client.workspace = true
 clock.workspace = true
@@ -40,7 +41,6 @@ fs.workspace = true
 futures.workspace = true
 fuzzy.workspace = true
 git.workspace = true
-git_ui.workspace = true
 gpui.workspace = true
 heed.workspace = true
 html_to_markdown.workspace = true
@@ -68,7 +68,6 @@ rope.workspace = true
 serde.workspace = true
 serde_json.workspace = true
 settings.workspace = true
-smallvec.workspace = true
 smol.workspace = true
 streaming_diff.workspace = true
 telemetry.workspace = true
@@ -87,6 +86,7 @@ workspace.workspace = true
 zed_actions.workspace = true
 
 [dev-dependencies]
+buffer_diff = { workspace = true, features = ["test-support"] }
 editor = { workspace = true, features = ["test-support"] }
 gpui = { workspace = true, "features" = ["test-support"] }
 indoc.workspace = true

crates/assistant2/src/assistant.rs 🔗

@@ -1,5 +1,6 @@
 mod active_thread;
 mod assistant_configuration;
+mod assistant_diff;
 mod assistant_model_selector;
 mod assistant_panel;
 mod buffer_codegen;
@@ -37,6 +38,7 @@ pub use crate::assistant_panel::{AssistantPanel, ConcreteAssistantPanelDelegate}
 pub use crate::inline_assistant::InlineAssistant;
 pub use crate::thread::{Message, RequestKind, Thread, ThreadEvent};
 pub use crate::thread_store::ThreadStore;
+pub use assistant_diff::AssistantDiff;
 
 actions!(
     assistant2,
@@ -61,7 +63,9 @@ actions!(
         FocusRight,
         RemoveFocusedContext,
         AcceptSuggestedContext,
-        OpenActiveThreadAsMarkdown
+        OpenActiveThreadAsMarkdown,
+        ToggleKeep,
+        Reject
     ]
 );
 

crates/assistant2/src/assistant_diff.rs 🔗

@@ -0,0 +1,665 @@
+use crate::{Thread, ThreadEvent, ToggleKeep};
+use anyhow::Result;
+use buffer_diff::DiffHunkStatus;
+use collections::HashSet;
+use editor::{
+    actions::{GoToHunk, GoToPreviousHunk},
+    Direction, Editor, EditorEvent, MultiBuffer, ToPoint,
+};
+use gpui::{
+    prelude::*, AnyElement, AnyView, App, Entity, EventEmitter, FocusHandle, Focusable,
+    SharedString, Subscription, Task, WeakEntity, Window,
+};
+use language::{Capability, DiskState, OffsetRangeExt};
+use multi_buffer::PathKey;
+use project::{Project, ProjectPath};
+use std::{
+    any::{Any, TypeId},
+    ops::Range,
+    sync::Arc,
+};
+use ui::{prelude::*, IconButtonShape, Tooltip};
+use workspace::{
+    item::{BreadcrumbText, ItemEvent, TabContentParams},
+    searchable::SearchableItemHandle,
+    Item, ItemHandle, ItemNavHistory, ToolbarItemLocation, Workspace,
+};
+
+pub struct AssistantDiff {
+    multibuffer: Entity<MultiBuffer>,
+    editor: Entity<Editor>,
+    thread: Entity<Thread>,
+    focus_handle: FocusHandle,
+    workspace: WeakEntity<Workspace>,
+    title: SharedString,
+    _subscriptions: Vec<Subscription>,
+}
+
+impl AssistantDiff {
+    pub fn deploy(
+        thread: Entity<Thread>,
+        workspace: WeakEntity<Workspace>,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> Result<()> {
+        let existing_diff = workspace.update(cx, |workspace, cx| {
+            workspace
+                .items_of_type::<AssistantDiff>(cx)
+                .find(|diff| diff.read(cx).thread == thread)
+        })?;
+        if let Some(existing_diff) = existing_diff {
+            workspace.update(cx, |workspace, cx| {
+                workspace.activate_item(&existing_diff, true, true, window, cx);
+            })
+        } else {
+            let assistant_diff =
+                cx.new(|cx| AssistantDiff::new(thread.clone(), workspace.clone(), window, cx));
+            workspace.update(cx, |workspace, cx| {
+                workspace.add_item_to_center(Box::new(assistant_diff), window, cx);
+            })
+        }
+    }
+
+    pub fn new(
+        thread: Entity<Thread>,
+        workspace: WeakEntity<Workspace>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Self {
+        let focus_handle = cx.focus_handle();
+        let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite));
+
+        let project = thread.read(cx).project().clone();
+        let render_diff_hunk_controls = Arc::new({
+            let assistant_diff = cx.entity();
+            move |row,
+                  status: &DiffHunkStatus,
+                  hunk_range,
+                  is_created_file,
+                  line_height,
+                  _editor: &Entity<Editor>,
+                  cx: &mut App| {
+                render_diff_hunk_controls(
+                    row,
+                    status,
+                    hunk_range,
+                    is_created_file,
+                    line_height,
+                    &assistant_diff,
+                    cx,
+                )
+            }
+        });
+        let editor = cx.new(|cx| {
+            let mut editor =
+                Editor::for_multibuffer(multibuffer.clone(), Some(project.clone()), window, cx);
+            editor.disable_inline_diagnostics();
+            editor.set_expand_all_diff_hunks(cx);
+            editor.set_render_diff_hunk_controls(render_diff_hunk_controls, cx);
+            editor.register_addon(AssistantDiffAddon);
+            editor
+        });
+
+        let action_log = thread.read(cx).action_log().clone();
+        let mut this = Self {
+            _subscriptions: vec![
+                cx.observe_in(&action_log, window, |this, _action_log, window, cx| {
+                    this.update_excerpts(window, cx)
+                }),
+                cx.subscribe(&thread, |this, _thread, event, cx| {
+                    this.handle_thread_event(event, cx)
+                }),
+            ],
+            title: SharedString::default(),
+            multibuffer,
+            editor,
+            thread,
+            focus_handle,
+            workspace,
+        };
+        this.update_excerpts(window, cx);
+        this.update_title(cx);
+        this
+    }
+
+    fn update_excerpts(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        let thread = self.thread.read(cx);
+        let changed_buffers = thread.action_log().read(cx).changed_buffers(cx);
+        let mut paths_to_delete = self.multibuffer.read(cx).paths().collect::<HashSet<_>>();
+
+        for (buffer, changed) in changed_buffers {
+            let Some(file) = buffer.read(cx).file().cloned() else {
+                continue;
+            };
+
+            let path_key = PathKey::namespaced("", file.full_path(cx).into());
+            paths_to_delete.remove(&path_key);
+
+            let snapshot = buffer.read(cx).snapshot();
+            let diff = changed.diff.read(cx);
+            let diff_hunk_ranges = diff
+                .hunks_intersecting_range(
+                    language::Anchor::MIN..language::Anchor::MAX,
+                    &snapshot,
+                    cx,
+                )
+                .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot))
+                .collect::<Vec<_>>();
+
+            let (was_empty, is_excerpt_newly_added) =
+                self.multibuffer.update(cx, |multibuffer, cx| {
+                    let was_empty = multibuffer.is_empty();
+                    let is_excerpt_newly_added = multibuffer.set_excerpts_for_path(
+                        path_key.clone(),
+                        buffer.clone(),
+                        diff_hunk_ranges,
+                        editor::DEFAULT_MULTIBUFFER_CONTEXT,
+                        cx,
+                    );
+                    multibuffer.add_diff(changed.diff.clone(), cx);
+                    (was_empty, is_excerpt_newly_added)
+                });
+
+            self.editor.update(cx, |editor, cx| {
+                if was_empty {
+                    editor.change_selections(None, window, cx, |selections| {
+                        selections.select_ranges([0..0])
+                    });
+                }
+
+                if is_excerpt_newly_added
+                    && buffer
+                        .read(cx)
+                        .file()
+                        .map_or(false, |file| file.disk_state() == DiskState::Deleted)
+                {
+                    editor.fold_buffer(snapshot.text.remote_id(), cx)
+                }
+            });
+        }
+
+        self.multibuffer.update(cx, |multibuffer, cx| {
+            for path in paths_to_delete {
+                multibuffer.remove_excerpts_for_path(path, cx);
+            }
+        });
+
+        if self.multibuffer.read(cx).is_empty()
+            && self
+                .editor
+                .read(cx)
+                .focus_handle(cx)
+                .contains_focused(window, cx)
+        {
+            self.focus_handle.focus(window);
+        } else if self.focus_handle.is_focused(window) && !self.multibuffer.read(cx).is_empty() {
+            self.editor.update(cx, |editor, cx| {
+                editor.focus_handle(cx).focus(window);
+            });
+        }
+    }
+
+    fn update_title(&mut self, cx: &mut Context<Self>) {
+        let new_title = self
+            .thread
+            .read(cx)
+            .summary()
+            .unwrap_or("Assistant Changes".into());
+        if new_title != self.title {
+            self.title = new_title;
+            cx.emit(EditorEvent::TitleChanged);
+        }
+    }
+
+    fn handle_thread_event(&mut self, event: &ThreadEvent, cx: &mut Context<Self>) {
+        match event {
+            ThreadEvent::SummaryChanged => self.update_title(cx),
+            _ => {}
+        }
+    }
+
+    fn toggle_keep(&mut self, _: &crate::ToggleKeep, _window: &mut Window, cx: &mut Context<Self>) {
+        let ranges = self
+            .editor
+            .read(cx)
+            .selections
+            .disjoint_anchor_ranges()
+            .collect::<Vec<_>>();
+
+        let snapshot = self.multibuffer.read(cx).snapshot(cx);
+        let diff_hunks_in_ranges = self
+            .editor
+            .read(cx)
+            .diff_hunks_in_ranges(&ranges, &snapshot)
+            .collect::<Vec<_>>();
+
+        for hunk in diff_hunks_in_ranges {
+            let buffer = self.multibuffer.read(cx).buffer(hunk.buffer_id);
+            if let Some(buffer) = buffer {
+                self.thread.update(cx, |thread, cx| {
+                    let accept = hunk.status().has_secondary_hunk();
+                    thread.review_edits_in_range(buffer, hunk.buffer_range, accept, cx)
+                });
+            }
+        }
+    }
+
+    fn reject(&mut self, _: &crate::Reject, window: &mut Window, cx: &mut Context<Self>) {
+        let ranges = self
+            .editor
+            .update(cx, |editor, cx| editor.selections.ranges(cx));
+        self.editor.update(cx, |editor, cx| {
+            editor.restore_hunks_in_ranges(ranges, window, cx)
+        })
+    }
+
+    fn review_diff_hunks(
+        &mut self,
+        hunk_ranges: Vec<Range<editor::Anchor>>,
+        accept: bool,
+        cx: &mut Context<Self>,
+    ) {
+        let snapshot = self.multibuffer.read(cx).snapshot(cx);
+        let diff_hunks_in_ranges = self
+            .editor
+            .read(cx)
+            .diff_hunks_in_ranges(&hunk_ranges, &snapshot)
+            .collect::<Vec<_>>();
+
+        for hunk in diff_hunks_in_ranges {
+            let buffer = self.multibuffer.read(cx).buffer(hunk.buffer_id);
+            if let Some(buffer) = buffer {
+                self.thread.update(cx, |thread, cx| {
+                    thread.review_edits_in_range(buffer, hunk.buffer_range, accept, cx)
+                });
+            }
+        }
+    }
+}
+
+impl EventEmitter<EditorEvent> for AssistantDiff {}
+
+impl Focusable for AssistantDiff {
+    fn focus_handle(&self, cx: &App) -> FocusHandle {
+        if self.multibuffer.read(cx).is_empty() {
+            self.focus_handle.clone()
+        } else {
+            self.editor.focus_handle(cx)
+        }
+    }
+}
+
+impl Item for AssistantDiff {
+    type Event = EditorEvent;
+
+    fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
+        Some(Icon::new(IconName::ZedAssistant).color(Color::Muted))
+    }
+
+    fn to_item_events(event: &EditorEvent, f: impl FnMut(ItemEvent)) {
+        Editor::to_item_events(event, f)
+    }
+
+    fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        self.editor
+            .update(cx, |editor, cx| editor.deactivated(window, cx));
+    }
+
+    fn navigate(
+        &mut self,
+        data: Box<dyn Any>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> bool {
+        self.editor
+            .update(cx, |editor, cx| editor.navigate(data, window, cx))
+    }
+
+    fn tab_tooltip_text(&self, _: &App) -> Option<SharedString> {
+        Some("Assistant Diff".into())
+    }
+
+    fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
+        let summary = self
+            .thread
+            .read(cx)
+            .summary()
+            .unwrap_or("Assistant Changes".into());
+        Label::new(format!("Review: {}", summary))
+            .color(if params.selected {
+                Color::Default
+            } else {
+                Color::Muted
+            })
+            .into_any_element()
+    }
+
+    fn telemetry_event_text(&self) -> Option<&'static str> {
+        Some("Assistant Diff Opened")
+    }
+
+    fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
+        Some(Box::new(self.editor.clone()))
+    }
+
+    fn for_each_project_item(
+        &self,
+        cx: &App,
+        f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
+    ) {
+        self.editor.for_each_project_item(cx, f)
+    }
+
+    fn is_singleton(&self, _: &App) -> bool {
+        false
+    }
+
+    fn set_nav_history(
+        &mut self,
+        nav_history: ItemNavHistory,
+        _: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.editor.update(cx, |editor, _| {
+            editor.set_nav_history(Some(nav_history));
+        });
+    }
+
+    fn clone_on_split(
+        &self,
+        _workspace_id: Option<workspace::WorkspaceId>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Option<Entity<Self>>
+    where
+        Self: Sized,
+    {
+        Some(cx.new(|cx| Self::new(self.thread.clone(), self.workspace.clone(), window, cx)))
+    }
+
+    fn is_dirty(&self, cx: &App) -> bool {
+        self.multibuffer.read(cx).is_dirty(cx)
+    }
+
+    fn has_conflict(&self, cx: &App) -> bool {
+        self.multibuffer.read(cx).has_conflict(cx)
+    }
+
+    fn can_save(&self, _: &App) -> bool {
+        true
+    }
+
+    fn save(
+        &mut self,
+        format: bool,
+        project: Entity<Project>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Task<Result<()>> {
+        self.editor.save(format, project, window, cx)
+    }
+
+    fn save_as(
+        &mut self,
+        _: Entity<Project>,
+        _: ProjectPath,
+        _window: &mut Window,
+        _: &mut Context<Self>,
+    ) -> Task<Result<()>> {
+        unreachable!()
+    }
+
+    fn reload(
+        &mut self,
+        project: Entity<Project>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Task<Result<()>> {
+        self.editor.reload(project, window, cx)
+    }
+
+    fn act_as_type<'a>(
+        &'a self,
+        type_id: TypeId,
+        self_handle: &'a Entity<Self>,
+        _: &'a App,
+    ) -> Option<AnyView> {
+        if type_id == TypeId::of::<Self>() {
+            Some(self_handle.to_any())
+        } else if type_id == TypeId::of::<Editor>() {
+            Some(self.editor.to_any())
+        } else {
+            None
+        }
+    }
+
+    fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
+        ToolbarItemLocation::PrimaryLeft
+    }
+
+    fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
+        self.editor.breadcrumbs(theme, cx)
+    }
+
+    fn added_to_workspace(
+        &mut self,
+        workspace: &mut Workspace,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.editor.update(cx, |editor, cx| {
+            editor.added_to_workspace(workspace, window, cx)
+        });
+    }
+}
+
+impl Render for AssistantDiff {
+    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let is_empty = self.multibuffer.read(cx).is_empty();
+        div()
+            .track_focus(&self.focus_handle)
+            .key_context(if is_empty {
+                "EmptyPane"
+            } else {
+                "AssistantDiff"
+            })
+            .on_action(cx.listener(Self::toggle_keep))
+            .on_action(cx.listener(Self::reject))
+            .bg(cx.theme().colors().editor_background)
+            .flex()
+            .items_center()
+            .justify_center()
+            .size_full()
+            .when(is_empty, |el| el.child("No changes to review"))
+            .when(!is_empty, |el| el.child(self.editor.clone()))
+    }
+}
+
+fn render_diff_hunk_controls(
+    row: u32,
+    status: &DiffHunkStatus,
+    hunk_range: Range<editor::Anchor>,
+    is_created_file: bool,
+    line_height: Pixels,
+    assistant_diff: &Entity<AssistantDiff>,
+    cx: &mut App,
+) -> AnyElement {
+    let editor = assistant_diff.read(cx).editor.clone();
+    h_flex()
+        .h(line_height)
+        .mr_1()
+        .gap_1()
+        .px_0p5()
+        .pb_1()
+        .border_x_1()
+        .border_b_1()
+        .border_color(cx.theme().colors().border_variant)
+        .rounded_b_lg()
+        .bg(cx.theme().colors().editor_background)
+        .gap_1()
+        .occlude()
+        .shadow_md()
+        .children(if status.has_secondary_hunk() {
+            vec![
+                Button::new(("keep", row as u64), "Keep")
+                    .tooltip({
+                        let focus_handle = editor.focus_handle(cx);
+                        move |window, cx| {
+                            Tooltip::for_action_in(
+                                "Keep Hunk",
+                                &crate::ToggleKeep,
+                                &focus_handle,
+                                window,
+                                cx,
+                            )
+                        }
+                    })
+                    .on_click({
+                        let assistant_diff = assistant_diff.clone();
+                        move |_event, _window, cx| {
+                            assistant_diff.update(cx, |diff, cx| {
+                                diff.review_diff_hunks(
+                                    vec![hunk_range.start..hunk_range.start],
+                                    true,
+                                    cx,
+                                );
+                            });
+                        }
+                    }),
+                Button::new("reject", "Reject")
+                    .tooltip({
+                        let focus_handle = editor.focus_handle(cx);
+                        move |window, cx| {
+                            Tooltip::for_action_in(
+                                "Reject Hunk",
+                                &crate::Reject,
+                                &focus_handle,
+                                window,
+                                cx,
+                            )
+                        }
+                    })
+                    .on_click({
+                        let editor = editor.clone();
+                        move |_event, window, cx| {
+                            editor.update(cx, |editor, cx| {
+                                let snapshot = editor.snapshot(window, cx);
+                                let point = hunk_range.start.to_point(&snapshot.buffer_snapshot);
+                                editor.restore_hunks_in_ranges(vec![point..point], window, cx);
+                            });
+                        }
+                    })
+                    .disabled(is_created_file),
+            ]
+        } else {
+            vec![Button::new(("review", row as u64), "Review")
+                .tooltip({
+                    let focus_handle = editor.focus_handle(cx);
+                    move |window, cx| {
+                        Tooltip::for_action_in("Review", &ToggleKeep, &focus_handle, window, cx)
+                    }
+                })
+                .on_click({
+                    let assistant_diff = assistant_diff.clone();
+                    move |_event, _window, cx| {
+                        assistant_diff.update(cx, |diff, cx| {
+                            diff.review_diff_hunks(
+                                vec![hunk_range.start..hunk_range.start],
+                                false,
+                                cx,
+                            );
+                        });
+                    }
+                })]
+        })
+        .when(
+            !editor.read(cx).buffer().read(cx).all_diff_hunks_expanded(),
+            |el| {
+                el.child(
+                    IconButton::new(("next-hunk", row as u64), IconName::ArrowDown)
+                        .shape(IconButtonShape::Square)
+                        .icon_size(IconSize::Small)
+                        // .disabled(!has_multiple_hunks)
+                        .tooltip({
+                            let focus_handle = editor.focus_handle(cx);
+                            move |window, cx| {
+                                Tooltip::for_action_in(
+                                    "Next Hunk",
+                                    &GoToHunk,
+                                    &focus_handle,
+                                    window,
+                                    cx,
+                                )
+                            }
+                        })
+                        .on_click({
+                            let editor = editor.clone();
+                            move |_event, window, cx| {
+                                editor.update(cx, |editor, cx| {
+                                    let snapshot = editor.snapshot(window, cx);
+                                    let position =
+                                        hunk_range.end.to_point(&snapshot.buffer_snapshot);
+                                    editor.go_to_hunk_before_or_after_position(
+                                        &snapshot,
+                                        position,
+                                        Direction::Next,
+                                        window,
+                                        cx,
+                                    );
+                                    editor.expand_selected_diff_hunks(cx);
+                                });
+                            }
+                        }),
+                )
+                .child(
+                    IconButton::new(("prev-hunk", row as u64), IconName::ArrowUp)
+                        .shape(IconButtonShape::Square)
+                        .icon_size(IconSize::Small)
+                        // .disabled(!has_multiple_hunks)
+                        .tooltip({
+                            let focus_handle = editor.focus_handle(cx);
+                            move |window, cx| {
+                                Tooltip::for_action_in(
+                                    "Previous Hunk",
+                                    &GoToPreviousHunk,
+                                    &focus_handle,
+                                    window,
+                                    cx,
+                                )
+                            }
+                        })
+                        .on_click({
+                            let editor = editor.clone();
+                            move |_event, window, cx| {
+                                editor.update(cx, |editor, cx| {
+                                    let snapshot = editor.snapshot(window, cx);
+                                    let point =
+                                        hunk_range.start.to_point(&snapshot.buffer_snapshot);
+                                    editor.go_to_hunk_before_or_after_position(
+                                        &snapshot,
+                                        point,
+                                        Direction::Prev,
+                                        window,
+                                        cx,
+                                    );
+                                    editor.expand_selected_diff_hunks(cx);
+                                });
+                            }
+                        }),
+                )
+            },
+        )
+        .into_any_element()
+}
+
+struct AssistantDiffAddon;
+
+impl editor::Addon for AssistantDiffAddon {
+    fn to_any(&self) -> &dyn std::any::Any {
+        self
+    }
+
+    fn extend_key_context(&self, key_context: &mut gpui::KeyContext, _: &App) {
+        key_context.add("assistant_diff");
+    }
+}

crates/assistant2/src/message_editor.rs 🔗

@@ -3,11 +3,10 @@ use std::sync::Arc;
 use collections::HashSet;
 use editor::actions::MoveUp;
 use editor::{ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement, EditorStyle};
+use file_icons::FileIcons;
 use fs::Fs;
-use git::ExpandCommitEditor;
-use git_ui::git_panel;
 use gpui::{
-    point, Animation, AnimationExt, App, DismissEvent, Entity, Focusable, Subscription, TextStyle,
+    Animation, AnimationExt, App, DismissEvent, Entity, Focusable, Subscription, TextStyle,
     WeakEntity,
 };
 use language_model::LanguageModelRegistry;
@@ -17,8 +16,10 @@ use settings::Settings;
 use std::time::Duration;
 use theme::ThemeSettings;
 use ui::{
-    prelude::*, ButtonLike, KeyBinding, PlatformStyle, PopoverMenu, PopoverMenuHandle, Tooltip,
+    prelude::*, ButtonLike, Disclosure, KeyBinding, PlatformStyle, PopoverMenu, PopoverMenuHandle,
+    Tooltip,
 };
+use util::ResultExt;
 use vim_mode_setting::VimModeSetting;
 use workspace::Workspace;
 
@@ -30,7 +31,8 @@ use crate::profile_selector::ProfileSelector;
 use crate::thread::{RequestKind, Thread};
 use crate::thread_store::ThreadStore;
 use crate::{
-    Chat, ChatMode, RemoveAllContext, ThreadEvent, ToggleContextPicker, ToggleProfileSelector,
+    AssistantDiff, Chat, ChatMode, RemoveAllContext, ThreadEvent, ToggleContextPicker,
+    ToggleProfileSelector,
 };
 
 pub struct MessageEditor {
@@ -46,6 +48,7 @@ pub struct MessageEditor {
     inline_context_picker_menu_handle: PopoverMenuHandle<ContextPicker>,
     model_selector: Entity<AssistantModelSelector>,
     profile_selector: Entity<ProfileSelector>,
+    edits_expanded: bool,
     _subscriptions: Vec<Subscription>,
 }
 
@@ -137,6 +140,7 @@ impl MessageEditor {
                     cx,
                 )
             }),
+            edits_expanded: false,
             profile_selector: cx
                 .new(|cx| ProfileSelector::new(fs, thread_store, editor.focus_handle(cx), cx)),
             _subscriptions: subscriptions,
@@ -236,6 +240,9 @@ impl MessageEditor {
             thread
                 .update(cx, |thread, cx| {
                     let context = context_store.read(cx).snapshot(cx).collect::<Vec<_>>();
+                    thread.action_log().update(cx, |action_log, cx| {
+                        action_log.clear_reviewed_changes(cx);
+                    });
                     thread.insert_user_message(user_message, context, checkpoint, cx);
                     thread.send_to_model(model, request_kind, cx);
                 })
@@ -282,6 +289,10 @@ impl MessageEditor {
             self.context_strip.focus_handle(cx).focus(window);
         }
     }
+
+    fn handle_review_click(&self, window: &mut Window, cx: &mut Context<Self>) {
+        AssistantDiff::deploy(self.thread.clone(), self.workspace.clone(), window, cx).log_err();
+    }
 }
 
 impl Focusable for MessageEditor {
@@ -298,7 +309,6 @@ impl Render for MessageEditor {
         let focus_handle = self.editor.focus_handle(cx);
         let inline_context_picker = self.inline_context_picker.clone();
 
-        let empty_thread = self.thread.read(cx).is_empty();
         let is_generating = self.thread.read(cx).is_generating();
         let is_model_selected = self.is_model_selected(cx);
         let is_editor_empty = self.is_editor_empty(cx);
@@ -318,30 +328,10 @@ impl Render for MessageEditor {
             px(64.)
         };
 
-        let project = self.thread.read(cx).project();
-        let changed_files = if let Some(repository) = project.read(cx).active_repository(cx) {
-            repository.read(cx).cached_status().count()
-        } else {
-            0
-        };
-
-        let border_color = cx.theme().colors().border;
-        let active_color = cx.theme().colors().element_selected;
+        let action_log = self.thread.read(cx).action_log();
+        let changed_buffers = action_log.read(cx).changed_buffers(cx);
+        let changed_buffers_count = changed_buffers.len();
         let editor_bg_color = cx.theme().colors().editor_background;
-        let bg_edit_files_disclosure = editor_bg_color.blend(active_color.opacity(0.3));
-
-        let edit_files_container = || {
-            h_flex()
-                .mx_2()
-                .py_1()
-                .pl_2p5()
-                .pr_1()
-                .bg(bg_edit_files_disclosure)
-                .border_1()
-                .border_color(border_color)
-                .justify_between()
-                .flex_wrap()
-        };
 
         v_flex()
             .size_full()
@@ -403,169 +393,150 @@ impl Render for MessageEditor {
                     ),
                 )
             })
-            .when(
-                changed_files > 0 && !is_generating && !empty_thread,
-                |parent| {
-                    parent.child(
-                        edit_files_container()
-                            .border_b_0()
-                            .rounded_t_md()
-                            .shadow(smallvec::smallvec![gpui::BoxShadow {
-                                color: gpui::black().opacity(0.15),
-                                offset: point(px(1.), px(-1.)),
-                                blur_radius: px(3.),
-                                spread_radius: px(0.),
-                            }])
-                            .child(
-                                h_flex()
-                                    .gap_2()
-                                    .child(Label::new("Edits").size(LabelSize::XSmall))
-                                    .child(div().size_1().rounded_full().bg(border_color))
-                                    .child(
-                                        Label::new(format!(
-                                            "{} {}",
-                                            changed_files,
-                                            if changed_files == 1 { "file" } else { "files" }
-                                        ))
-                                        .size(LabelSize::XSmall),
-                                    ),
-                            )
-                            .child(
-                                h_flex()
-                                    .gap_1()
-                                    .child(
-                                        Button::new("panel", "Open Git Panel")
-                                            .label_size(LabelSize::XSmall)
-                                            .key_binding({
-                                                let focus_handle = focus_handle.clone();
-                                                KeyBinding::for_action_in(
-                                                    &git_panel::ToggleFocus,
-                                                    &focus_handle,
-                                                    window,
-                                                    cx,
-                                                )
-                                                .map(|kb| kb.size(rems_from_px(10.)))
-                                            })
-                                            .on_click(|_ev, _window, cx| {
-                                                cx.defer(|cx| {
-                                                    cx.dispatch_action(&git_panel::ToggleFocus)
-                                                });
-                                            }),
-                                    )
-                                    .child(
-                                        Button::new("review", "Review Diff")
-                                            .label_size(LabelSize::XSmall)
-                                            .key_binding({
-                                                let focus_handle = focus_handle.clone();
-                                                KeyBinding::for_action_in(
-                                                    &git_ui::project_diff::Diff,
-                                                    &focus_handle,
-                                                    window,
-                                                    cx,
-                                                )
-                                                .map(|kb| kb.size(rems_from_px(10.)))
-                                            })
-                                            .on_click(|_event, _window, cx| {
-                                                cx.defer(|cx| {
-                                                    cx.dispatch_action(&git_ui::project_diff::Diff)
-                                                });
-                                            }),
-                                    )
-                                    .child(
-                                        Button::new("commit", "Commit Changes")
-                                            .label_size(LabelSize::XSmall)
-                                            .key_binding({
-                                                let focus_handle = focus_handle.clone();
-                                                KeyBinding::for_action_in(
-                                                    &ExpandCommitEditor,
-                                                    &focus_handle,
-                                                    window,
-                                                    cx,
-                                                )
-                                                .map(|kb| kb.size(rems_from_px(10.)))
-                                            })
-                                            .on_click(|_event, _window, cx| {
-                                                cx.defer(|cx| {
-                                                    cx.dispatch_action(&ExpandCommitEditor)
-                                                });
-                                            }),
-                                    ),
-                            ),
-                    )
-                },
-            )
-            .when(
-                changed_files > 0 && !is_generating && empty_thread,
-                |parent| {
-                    parent.child(
-                        edit_files_container()
-                            .mb_2()
-                            .rounded_md()
-                            .child(
-                                h_flex()
-                                    .gap_2()
-                                    .child(Label::new("Consider committing your changes before starting a fresh thread").size(LabelSize::XSmall))
-                                    .child(div().size_1().rounded_full().bg(border_color))
-                                    .child(
-                                        Label::new(format!(
-                                            "{} {}",
-                                            changed_files,
-                                            if changed_files == 1 { "file" } else { "files" }
-                                        ))
-                                        .size(LabelSize::XSmall),
+            .when(changed_buffers_count > 0, |parent| {
+                parent.child(
+                    v_flex()
+                        .mx_2()
+                        .bg(cx.theme().colors().element_background)
+                        .border_1()
+                        .border_b_0()
+                        .border_color(cx.theme().colors().border)
+                        .rounded_t_md()
+                        .child(
+                            h_flex()
+                                .p_2()
+                                .justify_between()
+                                .child(
+                                    h_flex()
+                                        .gap_2()
+                                        .child(
+                                            Disclosure::new(
+                                                "edits-disclosure",
+                                                self.edits_expanded,
+                                            )
+                                            .on_click(
+                                                cx.listener(|this, _ev, _window, cx| {
+                                                    this.edits_expanded = !this.edits_expanded;
+                                                    cx.notify();
+                                                }),
+                                            ),
+                                        )
+                                        .child(
+                                            Label::new("Edits")
+                                                .size(LabelSize::XSmall)
+                                                .color(Color::Muted),
+                                        )
+                                        .child(
+                                            Label::new("•")
+                                                .size(LabelSize::XSmall)
+                                                .color(Color::Muted),
+                                        )
+                                        .child(
+                                            Label::new(format!(
+                                                "{} {}",
+                                                changed_buffers_count,
+                                                if changed_buffers_count == 1 {
+                                                    "file"
+                                                } else {
+                                                    "files"
+                                                }
+                                            ))
+                                            .size(LabelSize::XSmall)
+                                            .color(Color::Muted),
+                                        ),
+                                )
+                                .child(
+                                    Button::new("review", "Review")
+                                        .label_size(LabelSize::XSmall)
+                                        .on_click(cx.listener(|this, _, window, cx| {
+                                            this.handle_review_click(window, cx)
+                                        })),
+                                ),
+                        )
+                        .when(self.edits_expanded, |parent| {
+                            parent.child(
+                                v_flex().bg(cx.theme().colors().editor_background).children(
+                                    changed_buffers.into_iter().enumerate().flat_map(
+                                        |(index, (buffer, changed))| {
+                                            let file = buffer.read(cx).file()?;
+                                            let path = file.path();
+
+                                            let parent_label = path.parent().and_then(|parent| {
+                                                let parent_str = parent.to_string_lossy();
+
+                                                if parent_str.is_empty() {
+                                                    None
+                                                } else {
+                                                    Some(
+                                                        Label::new(format!(
+                                                            "{}{}",
+                                                            parent_str,
+                                                            std::path::MAIN_SEPARATOR_STR
+                                                        ))
+                                                        .color(Color::Muted)
+                                                        .size(LabelSize::Small),
+                                                    )
+                                                }
+                                            });
+
+                                            let name_label = path.file_name().map(|name| {
+                                                Label::new(name.to_string_lossy().to_string())
+                                                    .size(LabelSize::Small)
+                                            });
+
+                                            let file_icon = FileIcons::get_icon(&path, cx)
+                                                .map(Icon::from_path)
+                                                .unwrap_or_else(|| Icon::new(IconName::File));
+
+                                            let element = div()
+                                                .p_2()
+                                                .when(index + 1 < changed_buffers_count, |parent| {
+                                                    parent
+                                                        .border_color(cx.theme().colors().border)
+                                                        .border_b_1()
+                                                })
+                                                .child(
+                                                    h_flex()
+                                                        .gap_2()
+                                                        .child(file_icon)
+                                                        .child(
+                                                            // TODO: handle overflow
+                                                            h_flex()
+                                                                .children(parent_label)
+                                                                .children(name_label),
+                                                        )
+                                                        // TODO: show lines changed
+                                                        .child(
+                                                            Label::new("+").color(Color::Created),
+                                                        )
+                                                        .child(
+                                                            Label::new("-").color(Color::Deleted),
+                                                        )
+                                                        .when(!changed.needs_review, |parent| {
+                                                            parent.child(
+                                                                Icon::new(IconName::Check)
+                                                                    .color(Color::Success),
+                                                            )
+                                                        }),
+                                                );
+
+                                            Some(element)
+                                        },
                                     ),
+                                ),
                             )
-                            .child(
-                                h_flex()
-                                    .gap_1()
-                                    .child(
-                                        Button::new("review", "Review Diff")
-                                            .label_size(LabelSize::XSmall)
-                                            .key_binding({
-                                                let focus_handle = focus_handle.clone();
-                                                KeyBinding::for_action_in(
-                                                    &git_ui::project_diff::Diff,
-                                                    &focus_handle,
-                                                    window,
-                                                    cx,
-                                                )
-                                                .map(|kb| kb.size(rems_from_px(10.)))
-                                            })
-                                            .on_click(|_event, _window, cx| {
-                                                cx.defer(|cx| {
-                                                    cx.dispatch_action(&git_ui::project_diff::Diff)
-                                                });
-                                            }),
-                                    )
-                                    .child(
-                                        Button::new("commit", "Commit Changes")
-                                            .label_size(LabelSize::XSmall)
-                                            .key_binding({
-                                                let focus_handle = focus_handle.clone();
-                                                KeyBinding::for_action_in(
-                                                    &ExpandCommitEditor,
-                                                    &focus_handle,
-                                                    window,
-                                                    cx,
-                                                )
-                                                .map(|kb| kb.size(rems_from_px(10.)))
-                                            })
-                                            .on_click(|_event, _window, cx| {
-                                                cx.defer(|cx| {
-                                                    cx.dispatch_action(&ExpandCommitEditor)
-                                                });
-                                            }),
-                                    ),
-                            ),
-                    )
-                },
-            )
+                        }),
+                )
+            })
             .child(
                 v_flex()
                     .key_context("MessageEditor")
                     .on_action(cx.listener(Self::chat))
                     .on_action(cx.listener(|this, _: &ToggleProfileSelector, window, cx| {
-                        this.profile_selector.read(cx).menu_handle().toggle(window, cx);
+                        this.profile_selector
+                            .read(cx)
+                            .menu_handle()
+                            .toggle(window, cx);
                     }))
                     .on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| {
                         this.model_selector

crates/assistant2/src/thread.rs 🔗

@@ -1,5 +1,6 @@
 use std::fmt::Write as _;
 use std::io::Write;
+use std::ops::Range;
 use std::sync::Arc;
 
 use anyhow::{Context as _, Result};
@@ -1529,6 +1530,18 @@ impl Thread {
         Ok(String::from_utf8_lossy(&markdown).to_string())
     }
 
+    pub fn review_edits_in_range(
+        &mut self,
+        buffer: Entity<language::Buffer>,
+        buffer_range: Range<language::Anchor>,
+        accept: bool,
+        cx: &mut Context<Self>,
+    ) {
+        self.action_log.update(cx, |action_log, cx| {
+            action_log.review_edits_in_range(buffer, buffer_range, accept, cx)
+        });
+    }
+
     pub fn action_log(&self) -> &Entity<ActionLog> {
         &self.action_log
     }

crates/assistant_tool/Cargo.toml 🔗

@@ -13,6 +13,8 @@ path = "src/assistant_tool.rs"
 
 [dependencies]
 anyhow.workspace = true
+async-watch.workspace = true
+buffer_diff.workspace = true
 clock.workspace = true
 collections.workspace = true
 derive_more.workspace = true
@@ -24,3 +26,16 @@ parking_lot.workspace = true
 project.workspace = true
 serde.workspace = true
 serde_json.workspace = true
+text.workspace = true
+
+[dev-dependencies]
+buffer_diff = { workspace = true, features = ["test-support"] }
+collections = { workspace = true, features = ["test-support"] }
+clock = { workspace = true, features = ["test-support"] }
+gpui = { workspace = true, features = ["test-support"] }
+language = { workspace = true, features = ["test-support"] }
+language_model = { workspace = true, features = ["test-support"] }
+project = { workspace = true, features = ["test-support"] }
+settings = { workspace = true, features = ["test-support"] }
+text = { workspace = true, features = ["test-support"] }
+util = { workspace = true, features = ["test-support"] }

crates/assistant_tool/src/action_log.rs 🔗

@@ -0,0 +1,968 @@
+use anyhow::{Context as _, Result};
+use buffer_diff::BufferDiff;
+use collections::{BTreeMap, HashMap, HashSet};
+use gpui::{App, AppContext, AsyncApp, Context, Entity, Subscription, Task, WeakEntity};
+use language::{
+    Buffer, BufferEvent, DiskState, OffsetRangeExt, Operation, TextBufferSnapshot, ToOffset,
+};
+use std::{ops::Range, sync::Arc};
+
+/// Tracks actions performed by tools in a thread
+pub struct ActionLog {
+    /// Buffers that user manually added to the context, and whose content has
+    /// changed since the model last saw them.
+    stale_buffers_in_context: HashSet<Entity<Buffer>>,
+    /// Buffers that we want to notify the model about when they change.
+    tracked_buffers: BTreeMap<Entity<Buffer>, TrackedBuffer>,
+    /// Has the model edited a file since it last checked diagnostics?
+    edited_since_project_diagnostics_check: bool,
+}
+
+impl ActionLog {
+    /// Creates a new, empty action log.
+    pub fn new() -> Self {
+        Self {
+            stale_buffers_in_context: HashSet::default(),
+            tracked_buffers: BTreeMap::default(),
+            edited_since_project_diagnostics_check: false,
+        }
+    }
+
+    pub fn clear_reviewed_changes(&mut self, cx: &mut Context<Self>) {
+        self.tracked_buffers
+            .retain(|_buffer, tracked_buffer| match &mut tracked_buffer.change {
+                Change::Edited {
+                    accepted_edit_ids, ..
+                } => {
+                    accepted_edit_ids.clear();
+                    tracked_buffer.schedule_diff_update();
+                    true
+                }
+                Change::Deleted { reviewed, .. } => !*reviewed,
+            });
+        cx.notify();
+    }
+
+    /// Notifies a diagnostics check
+    pub fn checked_project_diagnostics(&mut self) {
+        self.edited_since_project_diagnostics_check = false;
+    }
+
+    /// Returns true if any files have been edited since the last project diagnostics check
+    pub fn has_edited_files_since_project_diagnostics_check(&self) -> bool {
+        self.edited_since_project_diagnostics_check
+    }
+
+    fn track_buffer(
+        &mut self,
+        buffer: Entity<Buffer>,
+        created: bool,
+        cx: &mut Context<Self>,
+    ) -> &mut TrackedBuffer {
+        let tracked_buffer = self
+            .tracked_buffers
+            .entry(buffer.clone())
+            .or_insert_with(|| {
+                let text_snapshot = buffer.read(cx).text_snapshot();
+                let unreviewed_diff = cx.new(|cx| BufferDiff::new(&text_snapshot, cx));
+                let diff = cx.new(|cx| {
+                    let mut diff = BufferDiff::new(&text_snapshot, cx);
+                    diff.set_secondary_diff(unreviewed_diff.clone());
+                    diff
+                });
+                let (diff_update_tx, diff_update_rx) = async_watch::channel(());
+                TrackedBuffer {
+                    buffer: buffer.clone(),
+                    change: Change::Edited {
+                        unreviewed_edit_ids: HashSet::default(),
+                        accepted_edit_ids: HashSet::default(),
+                        initial_content: if created {
+                            None
+                        } else {
+                            Some(text_snapshot.clone())
+                        },
+                    },
+                    version: buffer.read(cx).version(),
+                    diff,
+                    secondary_diff: unreviewed_diff,
+                    diff_update: diff_update_tx,
+                    _maintain_diff: cx.spawn({
+                        let buffer = buffer.clone();
+                        async move |this, cx| {
+                            Self::maintain_diff(this, buffer, diff_update_rx, cx)
+                                .await
+                                .ok();
+                        }
+                    }),
+                    _subscription: cx.subscribe(&buffer, Self::handle_buffer_event),
+                }
+            });
+        tracked_buffer.version = buffer.read(cx).version();
+        tracked_buffer
+    }
+
+    fn handle_buffer_event(
+        &mut self,
+        buffer: Entity<Buffer>,
+        event: &BufferEvent,
+        cx: &mut Context<Self>,
+    ) {
+        match event {
+            BufferEvent::Operation { operation, .. } => {
+                self.handle_buffer_operation(buffer, operation, cx)
+            }
+            BufferEvent::FileHandleChanged => {
+                self.handle_buffer_file_changed(buffer, cx);
+            }
+            _ => {}
+        };
+    }
+
+    fn handle_buffer_operation(
+        &mut self,
+        buffer: Entity<Buffer>,
+        operation: &Operation,
+        cx: &mut Context<Self>,
+    ) {
+        let Some(tracked_buffer) = self.tracked_buffers.get_mut(&buffer) else {
+            return;
+        };
+        let Operation::Buffer(text::Operation::Edit(operation)) = operation else {
+            return;
+        };
+        let Change::Edited {
+            unreviewed_edit_ids,
+            accepted_edit_ids,
+            ..
+        } = &mut tracked_buffer.change
+        else {
+            return;
+        };
+
+        if unreviewed_edit_ids.contains(&operation.timestamp)
+            || accepted_edit_ids.contains(&operation.timestamp)
+        {
+            return;
+        }
+
+        let buffer = buffer.read(cx);
+        let operation_edit_ranges = buffer
+            .edited_ranges_for_edit_ids::<usize>([&operation.timestamp])
+            .collect::<Vec<_>>();
+        let intersects_unreviewed_edits = ranges_intersect(
+            operation_edit_ranges.iter().cloned(),
+            buffer.edited_ranges_for_edit_ids::<usize>(unreviewed_edit_ids.iter()),
+        );
+        let mut intersected_accepted_edits = HashSet::default();
+        for accepted_edit_id in accepted_edit_ids.iter() {
+            let intersects_accepted_edit = ranges_intersect(
+                operation_edit_ranges.iter().cloned(),
+                buffer.edited_ranges_for_edit_ids::<usize>([accepted_edit_id]),
+            );
+            if intersects_accepted_edit {
+                intersected_accepted_edits.insert(*accepted_edit_id);
+            }
+        }
+
+        // If the buffer operation overlaps with any tracked edits, mark it as unreviewed.
+        // If it intersects an already-accepted id, mark that edit as unreviewed again.
+        if intersects_unreviewed_edits || !intersected_accepted_edits.is_empty() {
+            unreviewed_edit_ids.insert(operation.timestamp);
+            for accepted_edit_id in intersected_accepted_edits {
+                unreviewed_edit_ids.insert(accepted_edit_id);
+                accepted_edit_ids.remove(&accepted_edit_id);
+            }
+            tracked_buffer.schedule_diff_update();
+        }
+    }
+
+    fn handle_buffer_file_changed(&mut self, buffer: Entity<Buffer>, cx: &mut Context<Self>) {
+        let Some(tracked_buffer) = self.tracked_buffers.get_mut(&buffer) else {
+            return;
+        };
+
+        match tracked_buffer.change {
+            Change::Deleted { .. } => {
+                if buffer
+                    .read(cx)
+                    .file()
+                    .map_or(false, |file| file.disk_state() != DiskState::Deleted)
+                {
+                    // If the buffer had been deleted by a tool, but it got
+                    // resurrected externally, we want to clear the changes we
+                    // were tracking and reset the buffer's state.
+                    tracked_buffer.change = Change::Edited {
+                        unreviewed_edit_ids: HashSet::default(),
+                        accepted_edit_ids: HashSet::default(),
+                        initial_content: Some(buffer.read(cx).text_snapshot()),
+                    };
+                }
+                tracked_buffer.schedule_diff_update();
+            }
+            Change::Edited { .. } => {
+                if buffer
+                    .read(cx)
+                    .file()
+                    .map_or(false, |file| file.disk_state() == DiskState::Deleted)
+                {
+                    // If the buffer had been edited by a tool, but it got
+                    // deleted externally, we want to stop tracking it.
+                    self.tracked_buffers.remove(&buffer);
+                } else {
+                    tracked_buffer.schedule_diff_update();
+                }
+            }
+        }
+    }
+
+    async fn maintain_diff(
+        this: WeakEntity<Self>,
+        buffer: Entity<Buffer>,
+        mut diff_update: async_watch::Receiver<()>,
+        cx: &mut AsyncApp,
+    ) -> Result<()> {
+        while let Some(_) = diff_update.recv().await.ok() {
+            let update = this.update(cx, |this, cx| {
+                let tracked_buffer = this
+                    .tracked_buffers
+                    .get_mut(&buffer)
+                    .context("buffer not tracked")?;
+                anyhow::Ok(tracked_buffer.update_diff(cx))
+            })??;
+            update.await;
+            this.update(cx, |_this, cx| cx.notify())?;
+        }
+
+        Ok(())
+    }
+
+    /// Track a buffer as read, so we can notify the model about user edits.
+    pub fn buffer_read(&mut self, buffer: Entity<Buffer>, cx: &mut Context<Self>) {
+        self.track_buffer(buffer, false, cx);
+    }
+
+    /// Track a buffer as read, so we can notify the model about user edits.
+    pub fn will_create_buffer(
+        &mut self,
+        buffer: Entity<Buffer>,
+        edit_id: Option<clock::Lamport>,
+        cx: &mut Context<Self>,
+    ) {
+        self.track_buffer(buffer.clone(), true, cx);
+        self.buffer_edited(buffer, edit_id.into_iter().collect(), cx)
+    }
+
+    /// Mark a buffer as edited, so we can refresh it in the context
+    pub fn buffer_edited(
+        &mut self,
+        buffer: Entity<Buffer>,
+        mut edit_ids: Vec<clock::Lamport>,
+        cx: &mut Context<Self>,
+    ) {
+        self.edited_since_project_diagnostics_check = true;
+        self.stale_buffers_in_context.insert(buffer.clone());
+
+        let tracked_buffer = self.track_buffer(buffer.clone(), false, cx);
+
+        match &mut tracked_buffer.change {
+            Change::Edited {
+                unreviewed_edit_ids,
+                ..
+            } => {
+                unreviewed_edit_ids.extend(edit_ids.iter().copied());
+            }
+            Change::Deleted {
+                deleted_content,
+                deletion_id,
+                ..
+            } => {
+                edit_ids.extend(*deletion_id);
+                tracked_buffer.change = Change::Edited {
+                    unreviewed_edit_ids: edit_ids.into_iter().collect(),
+                    accepted_edit_ids: HashSet::default(),
+                    initial_content: Some(deleted_content.clone()),
+                };
+            }
+        }
+
+        tracked_buffer.schedule_diff_update();
+    }
+
+    pub fn will_delete_buffer(&mut self, buffer: Entity<Buffer>, cx: &mut Context<Self>) {
+        let tracked_buffer = self.track_buffer(buffer.clone(), false, cx);
+        if let Change::Edited {
+            initial_content, ..
+        } = &tracked_buffer.change
+        {
+            if let Some(initial_content) = initial_content {
+                let deletion_id = buffer.update(cx, |buffer, cx| buffer.set_text("", cx));
+                tracked_buffer.change = Change::Deleted {
+                    reviewed: false,
+                    deleted_content: initial_content.clone(),
+                    deletion_id,
+                };
+                tracked_buffer.schedule_diff_update();
+            } else {
+                self.tracked_buffers.remove(&buffer);
+                cx.notify();
+            }
+        }
+    }
+
+    /// Accepts edits in a given range within a buffer.
+    pub fn review_edits_in_range<T: ToOffset>(
+        &mut self,
+        buffer: Entity<Buffer>,
+        buffer_range: Range<T>,
+        accept: bool,
+        cx: &mut Context<Self>,
+    ) {
+        let Some(tracked_buffer) = self.tracked_buffers.get_mut(&buffer) else {
+            return;
+        };
+
+        let buffer = buffer.read(cx);
+        let buffer_range = buffer_range.to_offset(buffer);
+
+        match &mut tracked_buffer.change {
+            Change::Deleted { reviewed, .. } => {
+                *reviewed = accept;
+            }
+            Change::Edited {
+                unreviewed_edit_ids,
+                accepted_edit_ids,
+                ..
+            } => {
+                let (source, destination) = if accept {
+                    (unreviewed_edit_ids, accepted_edit_ids)
+                } else {
+                    (accepted_edit_ids, unreviewed_edit_ids)
+                };
+                source.retain(|edit_id| {
+                    for range in buffer.edited_ranges_for_edit_ids::<usize>([edit_id]) {
+                        if buffer_range.end >= range.start && buffer_range.start <= range.end {
+                            destination.insert(*edit_id);
+                            return false;
+                        }
+                    }
+                    true
+                });
+            }
+        }
+
+        tracked_buffer.schedule_diff_update();
+    }
+
+    /// Returns the set of buffers that contain changes that haven't been reviewed by the user.
+    pub fn changed_buffers(&self, cx: &App) -> BTreeMap<Entity<Buffer>, ChangedBuffer> {
+        self.tracked_buffers
+            .iter()
+            .filter(|(_, tracked)| tracked.has_changes(cx))
+            .map(|(buffer, tracked)| {
+                (
+                    buffer.clone(),
+                    ChangedBuffer {
+                        diff: tracked.diff.clone(),
+                        needs_review: match &tracked.change {
+                            Change::Edited {
+                                unreviewed_edit_ids,
+                                ..
+                            } => !unreviewed_edit_ids.is_empty(),
+                            Change::Deleted { reviewed, .. } => !reviewed,
+                        },
+                    },
+                )
+            })
+            .collect()
+    }
+
+    /// Iterate over buffers changed since last read or edited by the model
+    pub fn stale_buffers<'a>(&'a self, cx: &'a App) -> impl Iterator<Item = &'a Entity<Buffer>> {
+        self.tracked_buffers
+            .iter()
+            .filter(|(buffer, tracked)| tracked.version != buffer.read(cx).version)
+            .map(|(buffer, _)| buffer)
+    }
+
+    /// Takes and returns the set of buffers pending refresh, clearing internal state.
+    pub fn take_stale_buffers_in_context(&mut self) -> HashSet<Entity<Buffer>> {
+        std::mem::take(&mut self.stale_buffers_in_context)
+    }
+}
+
+fn ranges_intersect(
+    ranges_a: impl IntoIterator<Item = Range<usize>>,
+    ranges_b: impl IntoIterator<Item = Range<usize>>,
+) -> bool {
+    let mut ranges_a_iter = ranges_a.into_iter().peekable();
+    let mut ranges_b_iter = ranges_b.into_iter().peekable();
+    while let (Some(range_a), Some(range_b)) = (ranges_a_iter.peek(), ranges_b_iter.peek()) {
+        if range_a.end < range_b.start {
+            ranges_a_iter.next();
+        } else if range_b.end < range_a.start {
+            ranges_b_iter.next();
+        } else {
+            return true;
+        }
+    }
+    false
+}
+
+struct TrackedBuffer {
+    buffer: Entity<Buffer>,
+    change: Change,
+    version: clock::Global,
+    diff: Entity<BufferDiff>,
+    secondary_diff: Entity<BufferDiff>,
+    diff_update: async_watch::Sender<()>,
+    _maintain_diff: Task<()>,
+    _subscription: Subscription,
+}
+
+enum Change {
+    Edited {
+        unreviewed_edit_ids: HashSet<clock::Lamport>,
+        accepted_edit_ids: HashSet<clock::Lamport>,
+        initial_content: Option<TextBufferSnapshot>,
+    },
+    Deleted {
+        reviewed: bool,
+        deleted_content: TextBufferSnapshot,
+        deletion_id: Option<clock::Lamport>,
+    },
+}
+
+impl TrackedBuffer {
+    fn has_changes(&self, cx: &App) -> bool {
+        self.diff
+            .read(cx)
+            .hunks(&self.buffer.read(cx), cx)
+            .next()
+            .is_some()
+    }
+
+    fn schedule_diff_update(&self) {
+        self.diff_update.send(()).ok();
+    }
+
+    fn update_diff(&mut self, cx: &mut App) -> Task<()> {
+        match &self.change {
+            Change::Edited {
+                unreviewed_edit_ids,
+                accepted_edit_ids,
+                ..
+            } => {
+                let edits_to_undo = unreviewed_edit_ids
+                    .iter()
+                    .chain(accepted_edit_ids)
+                    .map(|edit_id| (*edit_id, u32::MAX))
+                    .collect::<HashMap<_, _>>();
+                let buffer_without_edits = self.buffer.update(cx, |buffer, cx| buffer.branch(cx));
+                buffer_without_edits
+                    .update(cx, |buffer, cx| buffer.undo_operations(edits_to_undo, cx));
+                let primary_diff_update = self.diff.update(cx, |diff, cx| {
+                    diff.set_base_text(
+                        buffer_without_edits,
+                        self.buffer.read(cx).text_snapshot(),
+                        cx,
+                    )
+                });
+
+                let unreviewed_edits_to_undo = unreviewed_edit_ids
+                    .iter()
+                    .map(|edit_id| (*edit_id, u32::MAX))
+                    .collect::<HashMap<_, _>>();
+                let buffer_without_unreviewed_edits =
+                    self.buffer.update(cx, |buffer, cx| buffer.branch(cx));
+                buffer_without_unreviewed_edits.update(cx, |buffer, cx| {
+                    buffer.undo_operations(unreviewed_edits_to_undo, cx)
+                });
+                let secondary_diff_update = self.secondary_diff.update(cx, |diff, cx| {
+                    diff.set_base_text(
+                        buffer_without_unreviewed_edits.clone(),
+                        self.buffer.read(cx).text_snapshot(),
+                        cx,
+                    )
+                });
+
+                cx.background_spawn(async move {
+                    _ = primary_diff_update.await;
+                    _ = secondary_diff_update.await;
+                })
+            }
+            Change::Deleted {
+                reviewed,
+                deleted_content,
+                ..
+            } => {
+                let reviewed = *reviewed;
+                let deleted_content = deleted_content.clone();
+
+                let primary_diff = self.diff.clone();
+                let secondary_diff = self.secondary_diff.clone();
+                let buffer_snapshot = self.buffer.read(cx).text_snapshot();
+                let language = self.buffer.read(cx).language().cloned();
+                let language_registry = self.buffer.read(cx).language_registry().clone();
+
+                cx.spawn(async move |cx| {
+                    let base_text = Arc::new(deleted_content.text());
+
+                    let primary_diff_snapshot = BufferDiff::update_diff(
+                        primary_diff.clone(),
+                        buffer_snapshot.clone(),
+                        Some(base_text.clone()),
+                        true,
+                        false,
+                        language.clone(),
+                        language_registry.clone(),
+                        cx,
+                    )
+                    .await;
+                    let secondary_diff_snapshot = BufferDiff::update_diff(
+                        secondary_diff.clone(),
+                        buffer_snapshot.clone(),
+                        if reviewed {
+                            None
+                        } else {
+                            Some(base_text.clone())
+                        },
+                        true,
+                        false,
+                        language.clone(),
+                        language_registry.clone(),
+                        cx,
+                    )
+                    .await;
+
+                    if let Ok(primary_diff_snapshot) = primary_diff_snapshot {
+                        primary_diff
+                            .update(cx, |diff, cx| {
+                                diff.set_snapshot(
+                                    &buffer_snapshot,
+                                    primary_diff_snapshot,
+                                    false,
+                                    None,
+                                    cx,
+                                )
+                            })
+                            .ok();
+                    }
+
+                    if let Ok(secondary_diff_snapshot) = secondary_diff_snapshot {
+                        secondary_diff
+                            .update(cx, |diff, cx| {
+                                diff.set_snapshot(
+                                    &buffer_snapshot,
+                                    secondary_diff_snapshot,
+                                    false,
+                                    None,
+                                    cx,
+                                )
+                            })
+                            .ok();
+                    }
+                })
+            }
+        }
+    }
+}
+
+pub struct ChangedBuffer {
+    pub diff: Entity<BufferDiff>,
+    pub needs_review: bool,
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use buffer_diff::DiffHunkStatusKind;
+    use gpui::TestAppContext;
+    use language::Point;
+    use project::{FakeFs, Fs, Project, RemoveOptions};
+    use serde_json::json;
+    use settings::SettingsStore;
+    use util::path;
+
+    #[gpui::test(iterations = 10)]
+    async fn test_edit_review(cx: &mut TestAppContext) {
+        let action_log = cx.new(|_| ActionLog::new());
+        let buffer = cx.new(|cx| Buffer::local("abc\ndef\nghi\njkl\nmno", cx));
+
+        let edit1 = buffer.update(cx, |buffer, cx| {
+            buffer
+                .edit([(Point::new(1, 1)..Point::new(1, 2), "E")], None, cx)
+                .unwrap()
+        });
+        let edit2 = buffer.update(cx, |buffer, cx| {
+            buffer
+                .edit([(Point::new(4, 2)..Point::new(4, 3), "O")], None, cx)
+                .unwrap()
+        });
+        assert_eq!(
+            buffer.read_with(cx, |buffer, _| buffer.text()),
+            "abc\ndEf\nghi\njkl\nmnO"
+        );
+
+        action_log.update(cx, |log, cx| {
+            log.buffer_edited(buffer.clone(), vec![edit1, edit2], cx)
+        });
+        cx.run_until_parked();
+        assert_eq!(
+            unreviewed_hunks(&action_log, cx),
+            vec![(
+                buffer.clone(),
+                vec![
+                    HunkStatus {
+                        range: Point::new(1, 0)..Point::new(2, 0),
+                        review_status: ReviewStatus::Unreviewed,
+                        diff_status: DiffHunkStatusKind::Modified,
+                        old_text: "def\n".into(),
+                    },
+                    HunkStatus {
+                        range: Point::new(4, 0)..Point::new(4, 3),
+                        review_status: ReviewStatus::Unreviewed,
+                        diff_status: DiffHunkStatusKind::Modified,
+                        old_text: "mno".into(),
+                    }
+                ],
+            )]
+        );
+
+        action_log.update(cx, |log, cx| {
+            log.review_edits_in_range(buffer.clone(), Point::new(3, 0)..Point::new(4, 3), true, cx)
+        });
+        cx.run_until_parked();
+        assert_eq!(
+            unreviewed_hunks(&action_log, cx),
+            vec![(
+                buffer.clone(),
+                vec![
+                    HunkStatus {
+                        range: Point::new(1, 0)..Point::new(2, 0),
+                        review_status: ReviewStatus::Unreviewed,
+                        diff_status: DiffHunkStatusKind::Modified,
+                        old_text: "def\n".into(),
+                    },
+                    HunkStatus {
+                        range: Point::new(4, 0)..Point::new(4, 3),
+                        review_status: ReviewStatus::Reviewed,
+                        diff_status: DiffHunkStatusKind::Modified,
+                        old_text: "mno".into(),
+                    }
+                ],
+            )]
+        );
+
+        action_log.update(cx, |log, cx| {
+            log.review_edits_in_range(
+                buffer.clone(),
+                Point::new(3, 0)..Point::new(4, 3),
+                false,
+                cx,
+            )
+        });
+        cx.run_until_parked();
+        assert_eq!(
+            unreviewed_hunks(&action_log, cx),
+            vec![(
+                buffer.clone(),
+                vec![
+                    HunkStatus {
+                        range: Point::new(1, 0)..Point::new(2, 0),
+                        review_status: ReviewStatus::Unreviewed,
+                        diff_status: DiffHunkStatusKind::Modified,
+                        old_text: "def\n".into(),
+                    },
+                    HunkStatus {
+                        range: Point::new(4, 0)..Point::new(4, 3),
+                        review_status: ReviewStatus::Unreviewed,
+                        diff_status: DiffHunkStatusKind::Modified,
+                        old_text: "mno".into(),
+                    }
+                ],
+            )]
+        );
+
+        action_log.update(cx, |log, cx| {
+            log.review_edits_in_range(buffer.clone(), Point::new(0, 0)..Point::new(4, 3), true, cx)
+        });
+        cx.run_until_parked();
+        assert_eq!(
+            unreviewed_hunks(&action_log, cx),
+            vec![(
+                buffer.clone(),
+                vec![
+                    HunkStatus {
+                        range: Point::new(1, 0)..Point::new(2, 0),
+                        review_status: ReviewStatus::Reviewed,
+                        diff_status: DiffHunkStatusKind::Modified,
+                        old_text: "def\n".into(),
+                    },
+                    HunkStatus {
+                        range: Point::new(4, 0)..Point::new(4, 3),
+                        review_status: ReviewStatus::Reviewed,
+                        diff_status: DiffHunkStatusKind::Modified,
+                        old_text: "mno".into(),
+                    }
+                ],
+            )]
+        );
+    }
+
+    #[gpui::test(iterations = 10)]
+    async fn test_overlapping_user_edits(cx: &mut TestAppContext) {
+        let action_log = cx.new(|_| ActionLog::new());
+        let buffer = cx.new(|cx| Buffer::local("abc\ndef\nghi\njkl\nmno", cx));
+
+        let tool_edit = buffer.update(cx, |buffer, cx| {
+            buffer
+                .edit(
+                    [(Point::new(0, 2)..Point::new(2, 3), "C\nDEF\nGHI")],
+                    None,
+                    cx,
+                )
+                .unwrap()
+        });
+        assert_eq!(
+            buffer.read_with(cx, |buffer, _| buffer.text()),
+            "abC\nDEF\nGHI\njkl\nmno"
+        );
+
+        action_log.update(cx, |log, cx| {
+            log.buffer_edited(buffer.clone(), vec![tool_edit], cx)
+        });
+        cx.run_until_parked();
+        assert_eq!(
+            unreviewed_hunks(&action_log, cx),
+            vec![(
+                buffer.clone(),
+                vec![HunkStatus {
+                    range: Point::new(0, 0)..Point::new(3, 0),
+                    review_status: ReviewStatus::Unreviewed,
+                    diff_status: DiffHunkStatusKind::Modified,
+                    old_text: "abc\ndef\nghi\n".into(),
+                }],
+            )]
+        );
+
+        action_log.update(cx, |log, cx| {
+            log.review_edits_in_range(buffer.clone(), Point::new(0, 0)..Point::new(1, 0), true, cx)
+        });
+        cx.run_until_parked();
+        assert_eq!(
+            unreviewed_hunks(&action_log, cx),
+            vec![(
+                buffer.clone(),
+                vec![HunkStatus {
+                    range: Point::new(0, 0)..Point::new(3, 0),
+                    review_status: ReviewStatus::Reviewed,
+                    diff_status: DiffHunkStatusKind::Modified,
+                    old_text: "abc\ndef\nghi\n".into(),
+                }],
+            )]
+        );
+
+        buffer.update(cx, |buffer, cx| {
+            buffer.edit([(Point::new(0, 2)..Point::new(0, 2), "X")], None, cx)
+        });
+        cx.run_until_parked();
+        assert_eq!(
+            unreviewed_hunks(&action_log, cx),
+            vec![(
+                buffer.clone(),
+                vec![HunkStatus {
+                    range: Point::new(0, 0)..Point::new(3, 0),
+                    review_status: ReviewStatus::Unreviewed,
+                    diff_status: DiffHunkStatusKind::Modified,
+                    old_text: "abc\ndef\nghi\n".into(),
+                }],
+            )]
+        );
+
+        action_log.update(cx, |log, cx| log.clear_reviewed_changes(cx));
+        cx.run_until_parked();
+        assert_eq!(
+            unreviewed_hunks(&action_log, cx),
+            vec![(
+                buffer.clone(),
+                vec![HunkStatus {
+                    range: Point::new(0, 0)..Point::new(3, 0),
+                    review_status: ReviewStatus::Unreviewed,
+                    diff_status: DiffHunkStatusKind::Modified,
+                    old_text: "abc\ndef\nghi\n".into(),
+                }],
+            )]
+        );
+    }
+
+    #[gpui::test(iterations = 10)]
+    async fn test_deletion(cx: &mut TestAppContext) {
+        cx.update(|cx| {
+            let settings_store = SettingsStore::test(cx);
+            cx.set_global(settings_store);
+            language::init(cx);
+            Project::init_settings(cx);
+        });
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            path!("/dir"),
+            json!({"file1": "lorem\n", "file2": "ipsum\n"}),
+        )
+        .await;
+
+        let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
+        let file1_path = project
+            .read_with(cx, |project, cx| project.find_project_path("dir/file1", cx))
+            .unwrap();
+        let file2_path = project
+            .read_with(cx, |project, cx| project.find_project_path("dir/file2", cx))
+            .unwrap();
+
+        let action_log = cx.new(|_| ActionLog::new());
+        let buffer1 = project
+            .update(cx, |project, cx| {
+                project.open_buffer(file1_path.clone(), cx)
+            })
+            .await
+            .unwrap();
+        let buffer2 = project
+            .update(cx, |project, cx| {
+                project.open_buffer(file2_path.clone(), cx)
+            })
+            .await
+            .unwrap();
+
+        action_log.update(cx, |log, cx| log.will_delete_buffer(buffer1.clone(), cx));
+        action_log.update(cx, |log, cx| log.will_delete_buffer(buffer2.clone(), cx));
+        project
+            .update(cx, |project, cx| {
+                project.delete_file(file1_path.clone(), false, cx)
+            })
+            .unwrap()
+            .await
+            .unwrap();
+        project
+            .update(cx, |project, cx| {
+                project.delete_file(file2_path.clone(), false, cx)
+            })
+            .unwrap()
+            .await
+            .unwrap();
+        cx.run_until_parked();
+        assert_eq!(
+            unreviewed_hunks(&action_log, cx),
+            vec![
+                (
+                    buffer1.clone(),
+                    vec![HunkStatus {
+                        range: Point::new(0, 0)..Point::new(0, 0),
+                        review_status: ReviewStatus::Unreviewed,
+                        diff_status: DiffHunkStatusKind::Deleted,
+                        old_text: "lorem\n".into(),
+                    }]
+                ),
+                (
+                    buffer2.clone(),
+                    vec![HunkStatus {
+                        range: Point::new(0, 0)..Point::new(0, 0),
+                        review_status: ReviewStatus::Unreviewed,
+                        diff_status: DiffHunkStatusKind::Deleted,
+                        old_text: "ipsum\n".into(),
+                    }],
+                )
+            ]
+        );
+
+        // Simulate file1 being recreated externally.
+        fs.insert_file(path!("/dir/file1"), "LOREM".as_bytes().to_vec())
+            .await;
+        let buffer2 = project
+            .update(cx, |project, cx| project.open_buffer(file2_path, cx))
+            .await
+            .unwrap();
+        cx.run_until_parked();
+        // Simulate file2 being recreated by a tool.
+        let edit_id = buffer2.update(cx, |buffer, cx| buffer.set_text("IPSUM", cx));
+        action_log.update(cx, |log, cx| {
+            log.will_create_buffer(buffer2.clone(), edit_id, cx)
+        });
+        project
+            .update(cx, |project, cx| project.save_buffer(buffer2.clone(), cx))
+            .await
+            .unwrap();
+        cx.run_until_parked();
+        assert_eq!(
+            unreviewed_hunks(&action_log, cx),
+            vec![(
+                buffer2.clone(),
+                vec![HunkStatus {
+                    range: Point::new(0, 0)..Point::new(0, 5),
+                    review_status: ReviewStatus::Unreviewed,
+                    diff_status: DiffHunkStatusKind::Modified,
+                    old_text: "ipsum\n".into(),
+                }],
+            )]
+        );
+
+        // Simulate file2 being deleted externally.
+        fs.remove_file(path!("/dir/file2").as_ref(), RemoveOptions::default())
+            .await
+            .unwrap();
+        cx.run_until_parked();
+        assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
+    }
+
+    #[derive(Debug, Clone, PartialEq, Eq)]
+    struct HunkStatus {
+        range: Range<Point>,
+        review_status: ReviewStatus,
+        diff_status: DiffHunkStatusKind,
+        old_text: String,
+    }
+
+    #[derive(Copy, Clone, Debug, PartialEq, Eq)]
+    enum ReviewStatus {
+        Unreviewed,
+        Reviewed,
+    }
+
+    fn unreviewed_hunks(
+        action_log: &Entity<ActionLog>,
+        cx: &TestAppContext,
+    ) -> Vec<(Entity<Buffer>, Vec<HunkStatus>)> {
+        cx.read(|cx| {
+            action_log
+                .read(cx)
+                .changed_buffers(cx)
+                .into_iter()
+                .map(|(buffer, tracked_buffer)| {
+                    let snapshot = buffer.read(cx).snapshot();
+                    (
+                        buffer,
+                        tracked_buffer
+                            .diff
+                            .read(cx)
+                            .hunks(&snapshot, cx)
+                            .map(|hunk| HunkStatus {
+                                review_status: if hunk.status().has_secondary_hunk() {
+                                    ReviewStatus::Unreviewed
+                                } else {
+                                    ReviewStatus::Reviewed
+                                },
+                                diff_status: hunk.status().kind,
+                                range: hunk.range,
+                                old_text: tracked_buffer
+                                    .diff
+                                    .read(cx)
+                                    .base_text()
+                                    .text_for_range(hunk.diff_base_byte_range)
+                                    .collect(),
+                            })
+                            .collect(),
+                    )
+                })
+                .collect()
+        })
+    }
+}

crates/assistant_tool/src/assistant_tool.rs 🔗

@@ -1,17 +1,19 @@
+mod action_log;
 mod tool_registry;
 mod tool_working_set;
 
-use std::fmt::{self, Debug, Formatter};
+use std::fmt;
+use std::fmt::Debug;
+use std::fmt::Formatter;
 use std::sync::Arc;
 
 use anyhow::Result;
-use collections::{HashMap, HashSet};
-use gpui::{App, Context, Entity, SharedString, Task};
+use gpui::{App, Entity, SharedString, Task};
 use icons::IconName;
-use language::Buffer;
 use language_model::LanguageModelRequestMessage;
 use project::Project;
 
+pub use crate::action_log::*;
 pub use crate::tool_registry::*;
 pub use crate::tool_working_set::*;
 
@@ -71,71 +73,3 @@ impl Debug for dyn Tool {
         f.debug_struct("Tool").field("name", &self.name()).finish()
     }
 }
-
-/// Tracks actions performed by tools in a thread
-#[derive(Debug)]
-pub struct ActionLog {
-    /// Buffers that user manually added to the context, and whose content has
-    /// changed since the model last saw them.
-    stale_buffers_in_context: HashSet<Entity<Buffer>>,
-    /// Buffers that we want to notify the model about when they change.
-    tracked_buffers: HashMap<Entity<Buffer>, TrackedBuffer>,
-    /// Has the model edited a file since it last checked diagnostics?
-    edited_since_project_diagnostics_check: bool,
-}
-
-#[derive(Debug, Default)]
-struct TrackedBuffer {
-    version: clock::Global,
-}
-
-impl ActionLog {
-    /// Creates a new, empty action log.
-    pub fn new() -> Self {
-        Self {
-            stale_buffers_in_context: HashSet::default(),
-            tracked_buffers: HashMap::default(),
-            edited_since_project_diagnostics_check: false,
-        }
-    }
-
-    /// Track a buffer as read, so we can notify the model about user edits.
-    pub fn buffer_read(&mut self, buffer: Entity<Buffer>, cx: &mut Context<Self>) {
-        let tracked_buffer = self.tracked_buffers.entry(buffer.clone()).or_default();
-        tracked_buffer.version = buffer.read(cx).version();
-    }
-
-    /// Mark a buffer as edited, so we can refresh it in the context
-    pub fn buffer_edited(&mut self, buffers: HashSet<Entity<Buffer>>, cx: &mut Context<Self>) {
-        for buffer in &buffers {
-            let tracked_buffer = self.tracked_buffers.entry(buffer.clone()).or_default();
-            tracked_buffer.version = buffer.read(cx).version();
-        }
-
-        self.stale_buffers_in_context.extend(buffers);
-        self.edited_since_project_diagnostics_check = true;
-    }
-
-    /// Notifies a diagnostics check
-    pub fn checked_project_diagnostics(&mut self) {
-        self.edited_since_project_diagnostics_check = false;
-    }
-
-    /// Iterate over buffers changed since last read or edited by the model
-    pub fn stale_buffers<'a>(&'a self, cx: &'a App) -> impl Iterator<Item = &'a Entity<Buffer>> {
-        self.tracked_buffers
-            .iter()
-            .filter(|(buffer, tracked)| tracked.version != buffer.read(cx).version)
-            .map(|(buffer, _)| buffer)
-    }
-
-    /// Returns true if any files have been edited since the last project diagnostics check
-    pub fn has_edited_files_since_project_diagnostics_check(&self) -> bool {
-        self.edited_since_project_diagnostics_check
-    }
-
-    /// Takes and returns the set of buffers pending refresh, clearing internal state.
-    pub fn take_stale_buffers_in_context(&mut self) -> HashSet<Entity<Buffer>> {
-        std::mem::take(&mut self.stale_buffers_in_context)
-    }
-}

crates/assistant_tools/Cargo.toml 🔗

@@ -14,6 +14,7 @@ path = "src/assistant_tools.rs"
 [dependencies]
 anyhow.workspace = true
 assistant_tool.workspace = true
+clock.workspace = true
 chrono.workspace = true
 collections.workspace = true
 feature_flags.workspace = true
@@ -38,6 +39,7 @@ worktree.workspace = true
 open = { workspace = true }
 
 [dev-dependencies]
+clock = { workspace = true, features = ["test-support"] }
 collections = { workspace = true, features = ["test-support"] }
 gpui = { workspace = true, features = ["test-support"] }
 language = { workspace = true, features = ["test-support"] }

crates/assistant_tools/src/create_file_tool.rs 🔗

@@ -70,7 +70,7 @@ impl Tool for CreateFileTool {
         input: serde_json::Value,
         _messages: &[LanguageModelRequestMessage],
         project: Entity<Project>,
-        _action_log: Entity<ActionLog>,
+        action_log: Entity<ActionLog>,
         cx: &mut App,
     ) -> Task<Result<String>> {
         let input = match serde_json::from_value::<CreateFileToolInput>(input) {
@@ -85,24 +85,20 @@ impl Tool for CreateFileTool {
         let destination_path: Arc<str> = input.path.as_str().into();
 
         cx.spawn(async move |cx| {
-            project
-                .update(cx, |project, cx| {
-                    project.create_entry(project_path.clone(), false, cx)
-                })?
-                .await
-                .map_err(|err| anyhow!("Unable to create {destination_path}: {err}"))?;
             let buffer = project
                 .update(cx, |project, cx| {
                     project.open_buffer(project_path.clone(), cx)
                 })?
                 .await
                 .map_err(|err| anyhow!("Unable to open buffer for {destination_path}: {err}"))?;
-            buffer.update(cx, |buffer, cx| {
-                buffer.set_text(contents, cx);
+            let edit_id = buffer.update(cx, |buffer, cx| buffer.set_text(contents, cx))?;
+
+            action_log.update(cx, |action_log, cx| {
+                action_log.will_create_buffer(buffer.clone(), edit_id, cx)
             })?;
 
             project
-                .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))?
+                .update(cx, |project, cx| project.save_buffer(buffer, cx))?
                 .await
                 .map_err(|err| anyhow!("Unable to save buffer for {destination_path}: {err}"))?;
 

crates/assistant_tools/src/delete_path_tool.rs 🔗

@@ -1,8 +1,9 @@
 use anyhow::{anyhow, Result};
 use assistant_tool::{ActionLog, Tool};
+use futures::{channel::mpsc, SinkExt, StreamExt};
 use gpui::{App, AppContext, Entity, Task};
 use language_model::LanguageModelRequestMessage;
-use project::Project;
+use project::{Project, ProjectPath};
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use std::sync::Arc;
@@ -60,28 +61,76 @@ impl Tool for DeletePathTool {
         input: serde_json::Value,
         _messages: &[LanguageModelRequestMessage],
         project: Entity<Project>,
-        _action_log: Entity<ActionLog>,
+        action_log: Entity<ActionLog>,
         cx: &mut App,
     ) -> Task<Result<String>> {
         let path_str = match serde_json::from_value::<DeletePathToolInput>(input) {
             Ok(input) => input.path,
             Err(err) => return Task::ready(Err(anyhow!(err))),
         };
+        let Some(project_path) = project.read(cx).find_project_path(&path_str, cx) else {
+            return Task::ready(Err(anyhow!(
+                "Couldn't delete {path_str} because that path isn't in this project."
+            )));
+        };
 
-        match project
+        let Some(worktree) = project
             .read(cx)
-            .find_project_path(&path_str, cx)
-            .and_then(|path| project.update(cx, |project, cx| project.delete_file(path, false, cx)))
-        {
-            Some(deletion_task) => cx.background_spawn(async move {
-                match deletion_task.await {
+            .worktree_for_id(project_path.worktree_id, cx)
+        else {
+            return Task::ready(Err(anyhow!(
+                "Couldn't delete {path_str} because that path isn't in this project."
+            )));
+        };
+
+        let worktree_snapshot = worktree.read(cx).snapshot();
+        let (mut paths_tx, mut paths_rx) = mpsc::channel(256);
+        cx.background_spawn({
+            let project_path = project_path.clone();
+            async move {
+                for entry in
+                    worktree_snapshot.traverse_from_path(true, false, false, &project_path.path)
+                {
+                    if !entry.path.starts_with(&project_path.path) {
+                        break;
+                    }
+                    paths_tx
+                        .send(ProjectPath {
+                            worktree_id: project_path.worktree_id,
+                            path: entry.path.clone(),
+                        })
+                        .await?;
+                }
+                anyhow::Ok(())
+            }
+        })
+        .detach();
+
+        cx.spawn(async move |cx| {
+            while let Some(path) = paths_rx.next().await {
+                if let Ok(buffer) = project
+                    .update(cx, |project, cx| project.open_buffer(path, cx))?
+                    .await
+                {
+                    action_log.update(cx, |action_log, cx| {
+                        action_log.will_delete_buffer(buffer.clone(), cx)
+                    })?;
+                }
+            }
+
+            let delete = project.update(cx, |project, cx| {
+                project.delete_file(project_path, false, cx)
+            })?;
+
+            match delete {
+                Some(deletion_task) => match deletion_task.await {
                     Ok(()) => Ok(format!("Deleted {path_str}")),
                     Err(err) => Err(anyhow!("Failed to delete {path_str}: {err}")),
-                }
-            }),
-            None => Task::ready(Err(anyhow!(
-                "Couldn't delete {path_str} because that path isn't in this project."
-            ))),
-        }
+                },
+                None => Err(anyhow!(
+                    "Couldn't delete {path_str} because that path isn't in this project."
+                )),
+            }
+        })
     }
 }

crates/assistant_tools/src/edit_files_tool.rs 🔗

@@ -173,6 +173,7 @@ enum EditorResponse {
 struct AppliedAction {
     source: String,
     buffer: Entity<language::Buffer>,
+    edit_ids: Vec<clock::Lamport>,
 }
 
 #[derive(Debug)]
@@ -340,9 +341,18 @@ impl EditToolRequest {
                 self.push_search_error(error);
             }
             DiffResult::Diff(diff) => {
-                let _clock = buffer.update(cx, |buffer, cx| buffer.apply_diff(diff, cx))?;
-
-                self.push_applied_action(AppliedAction { source, buffer });
+                let edit_ids = buffer.update(cx, |buffer, cx| {
+                    buffer.finalize_last_transaction();
+                    buffer.apply_diff(diff, false, cx);
+                    let transaction = buffer.finalize_last_transaction();
+                    transaction.map_or(Vec::new(), |transaction| transaction.edit_ids.clone())
+                })?;
+
+                self.push_applied_action(AppliedAction {
+                    source,
+                    buffer,
+                    edit_ids,
+                });
             }
         }
 
@@ -464,7 +474,10 @@ impl EditToolRequest {
                 let mut changed_buffers = HashSet::default();
 
                 for action in applied {
-                    changed_buffers.insert(action.buffer);
+                    changed_buffers.insert(action.buffer.clone());
+                    self.action_log.update(cx, |log, cx| {
+                        log.buffer_edited(action.buffer, action.edit_ids, cx)
+                    })?;
                     write!(&mut output, "\n\n{}", action.source)?;
                 }
 
@@ -474,10 +487,6 @@ impl EditToolRequest {
                         .await?;
                 }
 
-                self.action_log
-                    .update(cx, |log, cx| log.buffer_edited(changed_buffers.clone(), cx))
-                    .log_err();
-
                 if !search_errors.is_empty() {
                     writeln!(
                         &mut output,

crates/assistant_tools/src/find_replace_file_tool.rs 🔗

@@ -5,7 +5,7 @@ use language_model::LanguageModelRequestMessage;
 use project::Project;
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
-use std::{collections::HashSet, path::PathBuf, sync::Arc};
+use std::{path::PathBuf, sync::Arc};
 use ui::IconName;
 
 use crate::replace::replace_exact;
@@ -189,20 +189,21 @@ impl Tool for FindReplaceFileTool {
                 .await;
 
             if let Some(diff) = result {
-                buffer.update(cx, |buffer, cx| {
-                    let _ = buffer.apply_diff(diff, cx);
+                let edit_ids = buffer.update(cx, |buffer, cx| {
+                    buffer.finalize_last_transaction();
+                    buffer.apply_diff(diff, false, cx);
+                    let transaction = buffer.finalize_last_transaction();
+                    transaction.map_or(Vec::new(), |transaction| transaction.edit_ids.clone())
                 })?;
 
-                project.update(cx, |project, cx| {
-                    project.save_buffer(buffer.clone(), cx)
-                })?.await?;
-
                 action_log.update(cx, |log, cx| {
-                    let mut buffers = HashSet::default();
-                    buffers.insert(buffer);
-                    log.buffer_edited(buffers, cx);
+                    log.buffer_edited(buffer.clone(), edit_ids, cx)
                 })?;
 
+                project.update(cx, |project, cx| {
+                    project.save_buffer(buffer, cx)
+                })?.await?;
+
                 Ok(format!("Edited {}", input.path.display()))
             } else {
                 let err = buffer.read_with(cx, |buffer, _cx| {

crates/assistant_tools/src/replace.rs 🔗

@@ -518,7 +518,7 @@ mod tests {
         // Call replace_flexible and transform the result
         replace_with_flexible_indent(old, new, &buffer_snapshot).map(|diff| {
             buffer.update(cx, |buffer, cx| {
-                let _ = buffer.apply_diff(diff, cx);
+                let _ = buffer.apply_diff(diff, false, cx);
                 buffer.text()
             })
         })

crates/editor/src/editor.rs 🔗

@@ -185,8 +185,8 @@ use theme::{
     ThemeColors, ThemeSettings,
 };
 use ui::{
-    h_flex, prelude::*, ButtonSize, ButtonStyle, Disclosure, IconButton, IconName, IconSize, Key,
-    Tooltip,
+    h_flex, prelude::*, ButtonSize, ButtonStyle, Disclosure, IconButton, IconButtonShape, IconName,
+    IconSize, Key, Tooltip,
 };
 use util::{maybe, post_inc, RangeExt, ResultExt, TryFutureExt};
 use workspace::{
@@ -220,6 +220,18 @@ pub(crate) const EDIT_PREDICTION_KEY_CONTEXT: &str = "edit_prediction";
 pub(crate) const EDIT_PREDICTION_CONFLICT_KEY_CONTEXT: &str = "edit_prediction_conflict";
 pub(crate) const MIN_LINE_NUMBER_DIGITS: u32 = 4;
 
+pub type RenderDiffHunkControlsFn = Arc<
+    dyn Fn(
+        u32,
+        &DiffHunkStatus,
+        Range<Anchor>,
+        bool,
+        Pixels,
+        &Entity<Editor>,
+        &mut App,
+    ) -> AnyElement,
+>;
+
 const COLUMNAR_SELECTION_MODIFIERS: Modifiers = Modifiers {
     alt: true,
     shift: true,
@@ -752,6 +764,7 @@ pub struct Editor {
     show_git_blame_inline_delay_task: Option<Task<()>>,
     git_blame_inline_tooltip: Option<WeakEntity<crate::commit_tooltip::CommitTooltip>>,
     git_blame_inline_enabled: bool,
+    render_diff_hunk_controls: RenderDiffHunkControlsFn,
     serialize_dirty_buffers: bool,
     show_selection_menu: Option<bool>,
     blame: Option<Entity<GitBlame>>,
@@ -1527,6 +1540,7 @@ impl Editor {
             show_git_blame_inline_delay_task: None,
             git_blame_inline_tooltip: None,
             git_blame_inline_enabled: ProjectSettings::get_global(cx).git.inline_blame_enabled(),
+            render_diff_hunk_controls: Arc::new(render_diff_hunk_controls),
             serialize_dirty_buffers: ProjectSettings::get_global(cx)
                 .session
                 .restore_unsaved_buffers,
@@ -8399,7 +8413,7 @@ impl Editor {
         self.restore_hunks_in_ranges(selections, window, cx);
     }
 
-    fn restore_hunks_in_ranges(
+    pub fn restore_hunks_in_ranges(
         &mut self,
         ranges: Vec<Range<Point>>,
         window: &mut Window,
@@ -12623,7 +12637,7 @@ impl Editor {
         );
     }
 
-    fn go_to_hunk_before_or_after_position(
+    pub fn go_to_hunk_before_or_after_position(
         &mut self,
         snapshot: &EditorSnapshot,
         position: Point,
@@ -14786,6 +14800,15 @@ impl Editor {
         self.stage_or_unstage_diff_hunks(stage, ranges, cx);
     }
 
+    pub fn set_render_diff_hunk_controls(
+        &mut self,
+        render_diff_hunk_controls: RenderDiffHunkControlsFn,
+        cx: &mut Context<Self>,
+    ) {
+        self.render_diff_hunk_controls = render_diff_hunk_controls;
+        cx.notify();
+    }
+
     pub fn stage_and_next(
         &mut self,
         _: &::git::StageAndNext,
@@ -19913,3 +19936,187 @@ impl From<Background> for LineHighlight {
         }
     }
 }
+
+fn render_diff_hunk_controls(
+    row: u32,
+    status: &DiffHunkStatus,
+    hunk_range: Range<Anchor>,
+    is_created_file: bool,
+    line_height: Pixels,
+    editor: &Entity<Editor>,
+    cx: &mut App,
+) -> AnyElement {
+    h_flex()
+        .h(line_height)
+        .mr_1()
+        .gap_1()
+        .px_0p5()
+        .pb_1()
+        .border_x_1()
+        .border_b_1()
+        .border_color(cx.theme().colors().border_variant)
+        .rounded_b_lg()
+        .bg(cx.theme().colors().editor_background)
+        .gap_1()
+        .occlude()
+        .shadow_md()
+        .child(if status.has_secondary_hunk() {
+            Button::new(("stage", row as u64), "Stage")
+                .alpha(if status.is_pending() { 0.66 } else { 1.0 })
+                .tooltip({
+                    let focus_handle = editor.focus_handle(cx);
+                    move |window, cx| {
+                        Tooltip::for_action_in(
+                            "Stage Hunk",
+                            &::git::ToggleStaged,
+                            &focus_handle,
+                            window,
+                            cx,
+                        )
+                    }
+                })
+                .on_click({
+                    let editor = editor.clone();
+                    move |_event, _window, cx| {
+                        editor.update(cx, |editor, cx| {
+                            editor.stage_or_unstage_diff_hunks(
+                                true,
+                                vec![hunk_range.start..hunk_range.start],
+                                cx,
+                            );
+                        });
+                    }
+                })
+        } else {
+            Button::new(("unstage", row as u64), "Unstage")
+                .alpha(if status.is_pending() { 0.66 } else { 1.0 })
+                .tooltip({
+                    let focus_handle = editor.focus_handle(cx);
+                    move |window, cx| {
+                        Tooltip::for_action_in(
+                            "Unstage Hunk",
+                            &::git::ToggleStaged,
+                            &focus_handle,
+                            window,
+                            cx,
+                        )
+                    }
+                })
+                .on_click({
+                    let editor = editor.clone();
+                    move |_event, _window, cx| {
+                        editor.update(cx, |editor, cx| {
+                            editor.stage_or_unstage_diff_hunks(
+                                false,
+                                vec![hunk_range.start..hunk_range.start],
+                                cx,
+                            );
+                        });
+                    }
+                })
+        })
+        .child(
+            Button::new("restore", "Restore")
+                .tooltip({
+                    let focus_handle = editor.focus_handle(cx);
+                    move |window, cx| {
+                        Tooltip::for_action_in(
+                            "Restore Hunk",
+                            &::git::Restore,
+                            &focus_handle,
+                            window,
+                            cx,
+                        )
+                    }
+                })
+                .on_click({
+                    let editor = editor.clone();
+                    move |_event, window, cx| {
+                        editor.update(cx, |editor, cx| {
+                            let snapshot = editor.snapshot(window, cx);
+                            let point = hunk_range.start.to_point(&snapshot.buffer_snapshot);
+                            editor.restore_hunks_in_ranges(vec![point..point], window, cx);
+                        });
+                    }
+                })
+                .disabled(is_created_file),
+        )
+        .when(
+            !editor.read(cx).buffer().read(cx).all_diff_hunks_expanded(),
+            |el| {
+                el.child(
+                    IconButton::new(("next-hunk", row as u64), IconName::ArrowDown)
+                        .shape(IconButtonShape::Square)
+                        .icon_size(IconSize::Small)
+                        // .disabled(!has_multiple_hunks)
+                        .tooltip({
+                            let focus_handle = editor.focus_handle(cx);
+                            move |window, cx| {
+                                Tooltip::for_action_in(
+                                    "Next Hunk",
+                                    &GoToHunk,
+                                    &focus_handle,
+                                    window,
+                                    cx,
+                                )
+                            }
+                        })
+                        .on_click({
+                            let editor = editor.clone();
+                            move |_event, window, cx| {
+                                editor.update(cx, |editor, cx| {
+                                    let snapshot = editor.snapshot(window, cx);
+                                    let position =
+                                        hunk_range.end.to_point(&snapshot.buffer_snapshot);
+                                    editor.go_to_hunk_before_or_after_position(
+                                        &snapshot,
+                                        position,
+                                        Direction::Next,
+                                        window,
+                                        cx,
+                                    );
+                                    editor.expand_selected_diff_hunks(cx);
+                                });
+                            }
+                        }),
+                )
+                .child(
+                    IconButton::new(("prev-hunk", row as u64), IconName::ArrowUp)
+                        .shape(IconButtonShape::Square)
+                        .icon_size(IconSize::Small)
+                        // .disabled(!has_multiple_hunks)
+                        .tooltip({
+                            let focus_handle = editor.focus_handle(cx);
+                            move |window, cx| {
+                                Tooltip::for_action_in(
+                                    "Previous Hunk",
+                                    &GoToPreviousHunk,
+                                    &focus_handle,
+                                    window,
+                                    cx,
+                                )
+                            }
+                        })
+                        .on_click({
+                            let editor = editor.clone();
+                            move |_event, window, cx| {
+                                editor.update(cx, |editor, cx| {
+                                    let snapshot = editor.snapshot(window, cx);
+                                    let point =
+                                        hunk_range.start.to_point(&snapshot.buffer_snapshot);
+                                    editor.go_to_hunk_before_or_after_position(
+                                        &snapshot,
+                                        point,
+                                        Direction::Prev,
+                                        window,
+                                        cx,
+                                    );
+                                    editor.expand_selected_diff_hunks(cx);
+                                });
+                            }
+                        }),
+                )
+            },
+        )
+        .into_any_element()
+}

crates/editor/src/element.rs 🔗

@@ -18,13 +18,13 @@ use crate::{
     scroll::scroll_amount::ScrollAmount,
     BlockId, ChunkReplacement, ContextMenuPlacement, CursorShape, CustomBlockId, DisplayDiffHunk,
     DisplayPoint, DisplayRow, DocumentHighlightRead, DocumentHighlightWrite, EditDisplayMode,
-    Editor, EditorMode, EditorSettings, EditorSnapshot, EditorStyle, FocusedBlock, GoToHunk,
-    GoToPreviousHunk, GutterDimensions, HalfPageDown, HalfPageUp, HandleInput, HoveredCursor,
-    InlayHintRefreshReason, InlineCompletion, JumpData, LineDown, LineHighlight, LineUp,
-    OpenExcerpts, PageDown, PageUp, Point, RowExt, RowRangeExt, SelectPhase, SelectedTextHighlight,
-    Selection, SoftWrap, StickyHeaderExcerpt, ToPoint, ToggleFold, COLUMNAR_SELECTION_MODIFIERS,
-    CURSORS_VISIBLE_FOR, FILE_HEADER_HEIGHT, GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED, MAX_LINE_LEN,
-    MIN_LINE_NUMBER_DIGITS, MULTI_BUFFER_EXCERPT_HEADER_HEIGHT,
+    Editor, EditorMode, EditorSettings, EditorSnapshot, EditorStyle, FocusedBlock,
+    GutterDimensions, HalfPageDown, HalfPageUp, HandleInput, HoveredCursor, InlayHintRefreshReason,
+    InlineCompletion, JumpData, LineDown, LineHighlight, LineUp, OpenExcerpts, PageDown, PageUp,
+    Point, RowExt, RowRangeExt, SelectPhase, SelectedTextHighlight, Selection, SoftWrap,
+    StickyHeaderExcerpt, ToPoint, ToggleFold, COLUMNAR_SELECTION_MODIFIERS, CURSORS_VISIBLE_FOR,
+    FILE_HEADER_HEIGHT, GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED, MAX_LINE_LEN, MIN_LINE_NUMBER_DIGITS,
+    MULTI_BUFFER_EXCERPT_HEADER_HEIGHT,
 };
 use buffer_diff::{DiffHunkStatus, DiffHunkStatusKind};
 use client::ParticipantIndex;
@@ -43,7 +43,6 @@ use gpui::{
     ScrollWheelEvent, ShapedLine, SharedString, Size, StatefulInteractiveElement, Style, Styled,
     Subscription, TextRun, TextStyleRefinement, Window,
 };
-use inline_completion::Direction;
 use itertools::Itertools;
 use language::{
     language_settings::{
@@ -76,10 +75,7 @@ use std::{
 use sum_tree::Bias;
 use text::BufferId;
 use theme::{ActiveTheme, Appearance, BufferLineHeight, PlayerColor};
-use ui::{
-    h_flex, prelude::*, ButtonLike, ContextMenu, IconButtonShape, KeyBinding, Tooltip,
-    POPOVER_Y_PADDING,
-};
+use ui::{h_flex, prelude::*, ButtonLike, ContextMenu, KeyBinding, Tooltip, POPOVER_Y_PADDING};
 use unicode_segmentation::UnicodeSegmentation;
 use util::{debug_panic, RangeExt, ResultExt};
 use workspace::{item::Item, notifications::NotifyTaskExt};
@@ -3919,6 +3915,7 @@ impl EditorElement {
         window: &mut Window,
         cx: &mut App,
     ) -> Vec<AnyElement> {
+        let render_diff_hunk_controls = editor.read(cx).render_diff_hunk_controls.clone();
         let point_for_position = position_map.point_for_position(window.mouse_position());
 
         let mut controls = vec![];
@@ -3961,7 +3958,7 @@ impl EditorElement {
                         + text_hitbox.bounds.top()
                         - scroll_pixel_position.y;
 
-                    let mut element = diff_hunk_controls(
+                    let mut element = render_diff_hunk_controls(
                         display_row_range.start.0,
                         status,
                         multi_buffer_range.clone(),
@@ -8882,187 +8879,3 @@ mod tests {
             .collect()
     }
 }
-
-fn diff_hunk_controls(
-    row: u32,
-    status: &DiffHunkStatus,
-    hunk_range: Range<Anchor>,
-    is_created_file: bool,
-    line_height: Pixels,
-    editor: &Entity<Editor>,
-    cx: &mut App,
-) -> AnyElement {
-    h_flex()
-        .h(line_height)
-        .mr_1()
-        .gap_1()
-        .px_0p5()
-        .pb_1()
-        .border_x_1()
-        .border_b_1()
-        .border_color(cx.theme().colors().border_variant)
-        .rounded_b_lg()
-        .bg(cx.theme().colors().editor_background)
-        .gap_1()
-        .occlude()
-        .shadow_md()
-        .child(if status.has_secondary_hunk() {
-            Button::new(("stage", row as u64), "Stage")
-                .alpha(if status.is_pending() { 0.66 } else { 1.0 })
-                .tooltip({
-                    let focus_handle = editor.focus_handle(cx);
-                    move |window, cx| {
-                        Tooltip::for_action_in(
-                            "Stage Hunk",
-                            &::git::ToggleStaged,
-                            &focus_handle,
-                            window,
-                            cx,
-                        )
-                    }
-                })
-                .on_click({
-                    let editor = editor.clone();
-                    move |_event, _window, cx| {
-                        editor.update(cx, |editor, cx| {
-                            editor.stage_or_unstage_diff_hunks(
-                                true,
-                                vec![hunk_range.start..hunk_range.start],
-                                cx,
-                            );
-                        });
-                    }
-                })
-        } else {
-            Button::new(("unstage", row as u64), "Unstage")
-                .alpha(if status.is_pending() { 0.66 } else { 1.0 })
-                .tooltip({
-                    let focus_handle = editor.focus_handle(cx);
-                    move |window, cx| {
-                        Tooltip::for_action_in(
-                            "Unstage Hunk",
-                            &::git::ToggleStaged,
-                            &focus_handle,
-                            window,
-                            cx,
-                        )
-                    }
-                })
-                .on_click({
-                    let editor = editor.clone();
-                    move |_event, _window, cx| {
-                        editor.update(cx, |editor, cx| {
-                            editor.stage_or_unstage_diff_hunks(
-                                false,
-                                vec![hunk_range.start..hunk_range.start],
-                                cx,
-                            );
-                        });
-                    }
-                })
-        })
-        .child(
-            Button::new("restore", "Restore")
-                .tooltip({
-                    let focus_handle = editor.focus_handle(cx);
-                    move |window, cx| {
-                        Tooltip::for_action_in(
-                            "Restore Hunk",
-                            &::git::Restore,
-                            &focus_handle,
-                            window,
-                            cx,
-                        )
-                    }
-                })
-                .on_click({
-                    let editor = editor.clone();
-                    move |_event, window, cx| {
-                        editor.update(cx, |editor, cx| {
-                            let snapshot = editor.snapshot(window, cx);
-                            let point = hunk_range.start.to_point(&snapshot.buffer_snapshot);
-                            editor.restore_hunks_in_ranges(vec![point..point], window, cx);
-                        });
-                    }
-                })
-                .disabled(is_created_file),
-        )
-        .when(
-            !editor.read(cx).buffer().read(cx).all_diff_hunks_expanded(),
-            |el| {
-                el.child(
-                    IconButton::new(("next-hunk", row as u64), IconName::ArrowDown)
-                        .shape(IconButtonShape::Square)
-                        .icon_size(IconSize::Small)
-                        // .disabled(!has_multiple_hunks)
-                        .tooltip({
-                            let focus_handle = editor.focus_handle(cx);
-                            move |window, cx| {
-                                Tooltip::for_action_in(
-                                    "Next Hunk",
-                                    &GoToHunk,
-                                    &focus_handle,
-                                    window,
-                                    cx,
-                                )
-                            }
-                        })
-                        .on_click({
-                            let editor = editor.clone();
-                            move |_event, window, cx| {
-                                editor.update(cx, |editor, cx| {
-                                    let snapshot = editor.snapshot(window, cx);
-                                    let position =
-                                        hunk_range.end.to_point(&snapshot.buffer_snapshot);
-                                    editor.go_to_hunk_before_or_after_position(
-                                        &snapshot,
-                                        position,
-                                        Direction::Next,
-                                        window,
-                                        cx,
-                                    );
-                                    editor.expand_selected_diff_hunks(cx);
-                                });
-                            }
-                        }),
-                )
-                .child(
-                    IconButton::new(("prev-hunk", row as u64), IconName::ArrowUp)
-                        .shape(IconButtonShape::Square)
-                        .icon_size(IconSize::Small)
-                        // .disabled(!has_multiple_hunks)
-                        .tooltip({
-                            let focus_handle = editor.focus_handle(cx);
-                            move |window, cx| {
-                                Tooltip::for_action_in(
-                                    "Previous Hunk",
-                                    &GoToPreviousHunk,
-                                    &focus_handle,
-                                    window,
-                                    cx,
-                                )
-                            }
-                        })
-                        .on_click({
-                            let editor = editor.clone();
-                            move |_event, window, cx| {
-                                editor.update(cx, |editor, cx| {
-                                    let snapshot = editor.snapshot(window, cx);
-                                    let point =
-                                        hunk_range.start.to_point(&snapshot.buffer_snapshot);
-                                    editor.go_to_hunk_before_or_after_position(
-                                        &snapshot,
-                                        point,
-                                        Direction::Prev,
-                                        window,
-                                        cx,
-                                    );
-                                    editor.expand_selected_diff_hunks(cx);
-                                });
-                            }
-                        }),
-                )
-            },
-        )
-        .into_any_element()
-}

crates/fs/src/fake_git_repo.rs 🔗

@@ -5,8 +5,8 @@ use futures::future::{self, BoxFuture};
 use git::{
     blame::Blame,
     repository::{
-        AskPassSession, Branch, CommitDetails, GitIndex, GitRepository, GitRepositoryCheckpoint,
-        PushOptions, Remote, RepoPath, ResetMode,
+        AskPassSession, Branch, CommitDetails, GitRepository, GitRepositoryCheckpoint, PushOptions,
+        Remote, RepoPath, ResetMode,
     },
     status::{FileStatus, GitStatus, StatusCode, TrackedStatus, UnmergedStatus},
 };
@@ -81,15 +81,7 @@ impl FakeGitRepository {
 impl GitRepository for FakeGitRepository {
     fn reload_index(&self) {}
 
-    fn load_index_text(
-        &self,
-        index: Option<GitIndex>,
-        path: RepoPath,
-    ) -> BoxFuture<Option<String>> {
-        if index.is_some() {
-            unimplemented!();
-        }
-
+    fn load_index_text(&self, path: RepoPath) -> BoxFuture<Option<String>> {
         async {
             self.with_state_async(false, move |state| {
                 state
@@ -179,19 +171,6 @@ impl GitRepository for FakeGitRepository {
         self.path()
     }
 
-    fn status(
-        &self,
-        index: Option<GitIndex>,
-        path_prefixes: &[RepoPath],
-    ) -> BoxFuture<'static, Result<GitStatus>> {
-        if index.is_some() {
-            unimplemented!();
-        }
-
-        let status = self.status_blocking(path_prefixes);
-        async move { status }.boxed()
-    }
-
     fn status_blocking(&self, path_prefixes: &[RepoPath]) -> Result<GitStatus> {
         let workdir_path = self.dot_git_path.parent().unwrap();
 
@@ -457,12 +436,4 @@ impl GitRepository for FakeGitRepository {
     ) -> BoxFuture<Result<String>> {
         unimplemented!()
     }
-
-    fn create_index(&self) -> BoxFuture<Result<GitIndex>> {
-        unimplemented!()
-    }
-
-    fn apply_diff(&self, _index: GitIndex, _diff: String) -> BoxFuture<Result<()>> {
-        unimplemented!()
-    }
 }

crates/git/src/repository.rs 🔗

@@ -12,6 +12,7 @@ use schemars::JsonSchema;
 use serde::Deserialize;
 use std::borrow::{Borrow, Cow};
 use std::ffi::{OsStr, OsString};
+use std::future;
 use std::path::Component;
 use std::process::{ExitStatus, Stdio};
 use std::sync::LazyLock;
@@ -20,7 +21,6 @@ use std::{
     path::{Path, PathBuf},
     sync::Arc,
 };
-use std::{future, mem};
 use sum_tree::MapSeekTarget;
 use thiserror::Error;
 use util::command::{new_smol_command, new_std_command};
@@ -161,8 +161,7 @@ pub trait GitRepository: Send + Sync {
     /// Returns the contents of an entry in the repository's index, or None if there is no entry for the given path.
     ///
     /// Also returns `None` for symlinks.
-    fn load_index_text(&self, index: Option<GitIndex>, path: RepoPath)
-        -> BoxFuture<Option<String>>;
+    fn load_index_text(&self, path: RepoPath) -> BoxFuture<Option<String>>;
 
     /// Returns the contents of an entry in the repository's HEAD, or None if HEAD does not exist or has no entry for the given path.
     ///
@@ -184,11 +183,6 @@ pub trait GitRepository: Send + Sync {
 
     fn merge_head_shas(&self) -> Vec<String>;
 
-    fn status(
-        &self,
-        index: Option<GitIndex>,
-        path_prefixes: &[RepoPath],
-    ) -> BoxFuture<'static, Result<GitStatus>>;
     fn status_blocking(&self, path_prefixes: &[RepoPath]) -> Result<GitStatus>;
 
     fn branches(&self) -> BoxFuture<Result<Vec<Branch>>>;
@@ -312,12 +306,6 @@ pub trait GitRepository: Send + Sync {
         base_checkpoint: GitRepositoryCheckpoint,
         target_checkpoint: GitRepositoryCheckpoint,
     ) -> BoxFuture<Result<String>>;
-
-    /// Creates a new index for the repository.
-    fn create_index(&self) -> BoxFuture<Result<GitIndex>>;
-
-    /// Applies a diff to the repository's index.
-    fn apply_diff(&self, index: GitIndex, diff: String) -> BoxFuture<Result<()>>;
 }
 
 pub enum DiffType {
@@ -374,11 +362,6 @@ pub struct GitRepositoryCheckpoint {
     commit_sha: Oid,
 }
 
-#[derive(Copy, Clone, Debug)]
-pub struct GitIndex {
-    id: Uuid,
-}
-
 impl GitRepository for RealGitRepository {
     fn reload_index(&self) {
         if let Ok(mut index) = self.repository.lock().index() {
@@ -484,82 +467,35 @@ impl GitRepository for RealGitRepository {
         .boxed()
     }
 
-    fn load_index_text(
-        &self,
-        index: Option<GitIndex>,
-        path: RepoPath,
-    ) -> BoxFuture<Option<String>> {
-        let working_directory = self.working_directory();
-        let git_binary_path = self.git_binary_path.clone();
-        let executor = self.executor.clone();
+    fn load_index_text(&self, path: RepoPath) -> BoxFuture<Option<String>> {
+        // https://git-scm.com/book/en/v2/Git-Internals-Git-Objects
+        const GIT_MODE_SYMLINK: u32 = 0o120000;
+
+        let repo = self.repository.clone();
         self.executor
             .spawn(async move {
-                match check_path_to_repo_path_errors(&path) {
-                    Ok(_) => {}
-                    Err(err) => {
-                        log::error!("Error with repo path: {:?}", err);
-                        return None;
-                    }
-                }
-
-                let working_directory = match working_directory {
-                    Ok(dir) => dir,
-                    Err(err) => {
-                        log::error!("Error getting working directory: {:?}", err);
-                        return None;
-                    }
-                };
+                fn logic(repo: &git2::Repository, path: &RepoPath) -> Result<Option<String>> {
+                    // This check is required because index.get_path() unwraps internally :(
+                    check_path_to_repo_path_errors(path)?;
 
-                let mut git = GitBinary::new(git_binary_path, working_directory, executor);
-                let text = git
-                    .with_option_index(index, async |git| {
-                        // First check if the file is a symlink using ls-files
-                        let ls_files_output = git
-                            .run(&[
-                                OsStr::new("ls-files"),
-                                OsStr::new("--stage"),
-                                path.to_unix_style().as_ref(),
-                            ])
-                            .await
-                            .context("error running ls-files")?;
-
-                        // Parse ls-files output to check if it's a symlink
-                        // Format is: "100644 <sha> 0 <filename>" where 100644 is the mode
-                        if ls_files_output.is_empty() {
-                            return Ok(None); // File not in index
-                        }
+                    let mut index = repo.index()?;
+                    index.read(false)?;
 
-                        let parts: Vec<&str> = ls_files_output.split_whitespace().collect();
-                        if parts.len() < 2 {
-                            return Err(anyhow!(
-                                "unexpected ls-files output format: {}",
-                                ls_files_output
-                            ));
-                        }
-
-                        // Check if it's a symlink (120000 mode)
-                        if parts[0] == "120000" {
-                            return Ok(None);
-                        }
-
-                        let sha = parts[1];
+                    const STAGE_NORMAL: i32 = 0;
+                    let oid = match index.get_path(path, STAGE_NORMAL) {
+                        Some(entry) if entry.mode != GIT_MODE_SYMLINK => entry.id,
+                        _ => return Ok(None),
+                    };
 
-                        // Now get the content
-                        Ok(Some(
-                            git.run_raw(&["cat-file", "blob", sha])
-                                .await
-                                .context("error getting blob content")?,
-                        ))
-                    })
-                    .await;
+                    let content = repo.find_blob(oid)?.content().to_owned();
+                    Ok(Some(String::from_utf8(content)?))
+                }
 
-                match text {
-                    Ok(text) => text,
-                    Err(error) => {
-                        log::error!("Error getting text: {}", error);
-                        None
-                    }
+                match logic(&repo.lock(), &path) {
+                    Ok(value) => return value,
+                    Err(err) => log::error!("Error loading index text: {:?}", err),
                 }
+                None
             })
             .boxed()
     }
@@ -678,40 +614,6 @@ impl GitRepository for RealGitRepository {
         shas
     }
 
-    fn status(
-        &self,
-        index: Option<GitIndex>,
-        path_prefixes: &[RepoPath],
-    ) -> BoxFuture<'static, Result<GitStatus>> {
-        let working_directory = self.working_directory();
-        let git_binary_path = self.git_binary_path.clone();
-        let executor = self.executor.clone();
-        let mut args = vec![
-            OsString::from("--no-optional-locks"),
-            OsString::from("status"),
-            OsString::from("--porcelain=v1"),
-            OsString::from("--untracked-files=all"),
-            OsString::from("--no-renames"),
-            OsString::from("-z"),
-        ];
-        args.extend(path_prefixes.iter().map(|path_prefix| {
-            if path_prefix.0.as_ref() == Path::new("") {
-                Path::new(".").into()
-            } else {
-                path_prefix.as_os_str().into()
-            }
-        }));
-        self.executor
-            .spawn(async move {
-                let working_directory = working_directory?;
-                let mut git = GitBinary::new(git_binary_path, working_directory, executor);
-                git.with_option_index(index, async |git| git.run(&args).await)
-                    .await?
-                    .parse()
-            })
-            .boxed()
-    }
-
     fn status_blocking(&self, path_prefixes: &[RepoPath]) -> Result<GitStatus> {
         let output = new_std_command(&self.git_binary_path)
             .current_dir(self.working_directory()?)
@@ -1319,41 +1221,6 @@ impl GitRepository for RealGitRepository {
             })
             .boxed()
     }
-
-    fn create_index(&self) -> BoxFuture<Result<GitIndex>> {
-        let working_directory = self.working_directory();
-        let git_binary_path = self.git_binary_path.clone();
-
-        let executor = self.executor.clone();
-        self.executor
-            .spawn(async move {
-                let working_directory = working_directory?;
-                let mut git = GitBinary::new(git_binary_path, working_directory, executor);
-                let index = GitIndex { id: Uuid::new_v4() };
-                git.with_index(index, async move |git| git.run(&["add", "--all"]).await)
-                    .await?;
-                Ok(index)
-            })
-            .boxed()
-    }
-
-    fn apply_diff(&self, index: GitIndex, diff: String) -> BoxFuture<Result<()>> {
-        let working_directory = self.working_directory();
-        let git_binary_path = self.git_binary_path.clone();
-
-        let executor = self.executor.clone();
-        self.executor
-            .spawn(async move {
-                let working_directory = working_directory?;
-                let mut git = GitBinary::new(git_binary_path, working_directory, executor);
-                git.with_index(index, async move |git| {
-                    git.run_with_stdin(&["apply", "--cached", "-"], diff).await
-                })
-                .await?;
-                Ok(())
-            })
-            .boxed()
-    }
 }
 
 fn git_status_args(path_prefixes: &[RepoPath]) -> Vec<OsString> {
@@ -1407,7 +1274,7 @@ impl GitBinary {
         &mut self,
         f: impl AsyncFnOnce(&Self) -> Result<R>,
     ) -> Result<R> {
-        let index_file_path = self.path_for_index(GitIndex { id: Uuid::new_v4() });
+        let index_file_path = self.path_for_index_id(Uuid::new_v4());
 
         let delete_temp_index = util::defer({
             let index_file_path = index_file_path.clone();
@@ -1432,30 +1299,10 @@ impl GitBinary {
         Ok(result)
     }
 
-    pub async fn with_index<R>(
-        &mut self,
-        index: GitIndex,
-        f: impl AsyncFnOnce(&Self) -> Result<R>,
-    ) -> Result<R> {
-        self.with_option_index(Some(index), f).await
-    }
-
-    pub async fn with_option_index<R>(
-        &mut self,
-        index: Option<GitIndex>,
-        f: impl AsyncFnOnce(&Self) -> Result<R>,
-    ) -> Result<R> {
-        let new_index_path = index.map(|index| self.path_for_index(index));
-        let old_index_path = mem::replace(&mut self.index_file_path, new_index_path);
-        let result = f(self).await;
-        self.index_file_path = old_index_path;
-        result
-    }
-
-    fn path_for_index(&self, index: GitIndex) -> PathBuf {
+    fn path_for_index_id(&self, id: Uuid) -> PathBuf {
         self.working_directory
             .join(".git")
-            .join(format!("index-{}.tmp", index.id))
+            .join(format!("index-{}.tmp", id))
     }
 
     pub async fn run<S>(&self, args: impl IntoIterator<Item = S>) -> Result<String>
@@ -1486,26 +1333,6 @@ impl GitBinary {
         }
     }
 
-    pub async fn run_with_stdin(&self, args: &[&str], stdin: String) -> Result<String> {
-        let mut command = self.build_command(args);
-        command.stdin(Stdio::piped());
-        let mut child = command.spawn()?;
-
-        let mut child_stdin = child.stdin.take().context("failed to write to stdin")?;
-        child_stdin.write_all(stdin.as_bytes()).await?;
-        drop(child_stdin);
-
-        let output = child.output().await?;
-        if output.status.success() {
-            Ok(String::from_utf8(output.stdout)?.trim_end().to_string())
-        } else {
-            Err(anyhow!(GitBinaryCommandError {
-                stdout: String::from_utf8_lossy(&output.stdout).to_string(),
-                status: output.status,
-            }))
-        }
-    }
-
     fn build_command<S>(&self, args: impl IntoIterator<Item = S>) -> smol::process::Command
     where
         S: AsRef<OsStr>,
@@ -1787,9 +1614,8 @@ fn checkpoint_author_envs() -> HashMap<String, String> {
 #[cfg(test)]
 mod tests {
     use super::*;
-    use crate::status::{FileStatus, StatusCode, TrackedStatus};
+    use crate::status::FileStatus;
     use gpui::TestAppContext;
-    use unindent::Unindent;
 
     #[gpui::test]
     async fn test_checkpoint_basic(cx: &mut TestAppContext) {
@@ -1969,7 +1795,7 @@ mod tests {
             "content2"
         );
         assert_eq!(
-            repo.status(None, &[]).await.unwrap().entries.as_ref(),
+            repo.status_blocking(&[]).unwrap().entries.as_ref(),
             &[
                 (RepoPath::from_str("new_file1"), FileStatus::Untracked),
                 (RepoPath::from_str("new_file2"), FileStatus::Untracked)
@@ -2008,90 +1834,6 @@ mod tests {
             .unwrap());
     }
 
-    #[gpui::test]
-    async fn test_secondary_indices(cx: &mut TestAppContext) {
-        cx.executor().allow_parking();
-
-        let repo_dir = tempfile::tempdir().unwrap();
-        git2::Repository::init(repo_dir.path()).unwrap();
-        let repo =
-            RealGitRepository::new(&repo_dir.path().join(".git"), None, cx.executor()).unwrap();
-        let index = repo.create_index().await.unwrap();
-        smol::fs::write(repo_dir.path().join("file1"), "file1\n")
-            .await
-            .unwrap();
-        smol::fs::write(repo_dir.path().join("file2"), "file2\n")
-            .await
-            .unwrap();
-        let diff = r#"
-            diff --git a/file2 b/file2
-            new file mode 100644
-            index 0000000..cbc4e2e
-            --- /dev/null
-            +++ b/file2
-            @@ -0,0 +1 @@
-            +file2
-        "#
-        .unindent();
-        repo.apply_diff(index, diff.to_string()).await.unwrap();
-
-        assert_eq!(
-            repo.status(Some(index), &[])
-                .await
-                .unwrap()
-                .entries
-                .as_ref(),
-            vec![
-                (RepoPath::from_str("file1"), FileStatus::Untracked),
-                (
-                    RepoPath::from_str("file2"),
-                    FileStatus::index(StatusCode::Added)
-                )
-            ]
-        );
-        assert_eq!(
-            repo.load_index_text(Some(index), RepoPath::from_str("file1"))
-                .await,
-            None
-        );
-        assert_eq!(
-            repo.load_index_text(Some(index), RepoPath::from_str("file2"))
-                .await,
-            Some("file2\n".to_string())
-        );
-
-        smol::fs::write(repo_dir.path().join("file2"), "file2-changed\n")
-            .await
-            .unwrap();
-        assert_eq!(
-            repo.status(Some(index), &[])
-                .await
-                .unwrap()
-                .entries
-                .as_ref(),
-            vec![
-                (RepoPath::from_str("file1"), FileStatus::Untracked),
-                (
-                    RepoPath::from_str("file2"),
-                    FileStatus::Tracked(TrackedStatus {
-                        worktree_status: StatusCode::Modified,
-                        index_status: StatusCode::Added,
-                    })
-                )
-            ]
-        );
-        assert_eq!(
-            repo.load_index_text(Some(index), RepoPath::from_str("file1"))
-                .await,
-            None
-        );
-        assert_eq!(
-            repo.load_index_text(Some(index), RepoPath::from_str("file2"))
-                .await,
-            Some("file2\n".to_string())
-        );
-    }
-
     #[test]
     fn test_branches_parsing() {
         // suppress "help: octal escapes are not supported, `\0` is always null"

crates/language/src/buffer.rs 🔗

@@ -1320,7 +1320,7 @@ impl Buffer {
             this.update(cx, |this, cx| {
                 if this.version() == diff.base_version {
                     this.finalize_last_transaction();
-                    this.apply_diff(diff, cx);
+                    this.apply_diff(diff, true, cx);
                     tx.send(this.finalize_last_transaction().cloned()).ok();
                     this.has_conflict = false;
                     this.did_reload(this.version(), this.line_ending(), new_mtime, cx);
@@ -1879,9 +1879,14 @@ impl Buffer {
     /// Applies a diff to the buffer. If the buffer has changed since the given diff was
     /// calculated, then adjust the diff to account for those changes, and discard any
     /// parts of the diff that conflict with those changes.
-    pub fn apply_diff(&mut self, diff: Diff, cx: &mut Context<Self>) -> Option<TransactionId> {
-        // Check for any edits to the buffer that have occurred since this diff
-        // was computed.
+    ///
+    /// If `atomic` is true, the diff will be applied as a single edit.
+    pub fn apply_diff(
+        &mut self,
+        diff: Diff,
+        atomic: bool,
+        cx: &mut Context<Self>,
+    ) -> Option<TransactionId> {
         let snapshot = self.snapshot();
         let mut edits_since = snapshot.edits_since::<usize>(&diff.base_version).peekable();
         let mut delta = 0;
@@ -1911,7 +1916,17 @@ impl Buffer {
 
         self.start_transaction();
         self.text.set_line_ending(diff.line_ending);
-        self.edit(adjusted_edits, None, cx);
+        if atomic {
+            self.edit(adjusted_edits, None, cx);
+        } else {
+            let mut delta = 0isize;
+            for (range, new_text) in adjusted_edits {
+                let adjusted_range =
+                    (range.start as isize + delta) as usize..(range.end as isize + delta) as usize;
+                delta += new_text.len() as isize - range.len() as isize;
+                self.edit([(adjusted_range, new_text)], None, cx);
+            }
+        }
         self.end_transaction(cx)
     }
 

crates/language/src/buffer_tests.rs 🔗

@@ -374,7 +374,7 @@ async fn test_apply_diff(cx: &mut TestAppContext) {
 
     let diff = buffer.update(cx, |b, cx| b.diff(text.clone(), cx)).await;
     buffer.update(cx, |buffer, cx| {
-        buffer.apply_diff(diff, cx).unwrap();
+        buffer.apply_diff(diff, true, cx).unwrap();
         assert_eq!(buffer.text(), text);
         let actual_offsets = anchors
             .iter()
@@ -388,7 +388,7 @@ async fn test_apply_diff(cx: &mut TestAppContext) {
 
     let diff = buffer.update(cx, |b, cx| b.diff(text.clone(), cx)).await;
     buffer.update(cx, |buffer, cx| {
-        buffer.apply_diff(diff, cx).unwrap();
+        buffer.apply_diff(diff, true, cx).unwrap();
         assert_eq!(buffer.text(), text);
         let actual_offsets = anchors
             .iter()
@@ -433,7 +433,7 @@ async fn test_normalize_whitespace(cx: &mut gpui::TestAppContext) {
     let format_diff = format.await;
     buffer.update(cx, |buffer, cx| {
         let version_before_format = format_diff.base_version.clone();
-        buffer.apply_diff(format_diff, cx);
+        buffer.apply_diff(format_diff, true, cx);
 
         // The outcome depends on the order of concurrent tasks.
         //

crates/project/src/git_store.rs 🔗

@@ -20,10 +20,10 @@ use git::{
     blame::Blame,
     parse_git_remote_url,
     repository::{
-        Branch, CommitDetails, DiffType, GitIndex, GitRepository, GitRepositoryCheckpoint,
-        PushOptions, Remote, RemoteCommandOutput, RepoPath, ResetMode,
+        Branch, CommitDetails, DiffType, GitRepository, GitRepositoryCheckpoint, PushOptions,
+        Remote, RemoteCommandOutput, RepoPath, ResetMode,
     },
-    status::{FileStatus, GitStatus},
+    status::FileStatus,
     BuildPermalinkParams, GitHostingProviderRegistry,
 };
 use gpui::{
@@ -146,22 +146,6 @@ pub struct GitStoreCheckpoint {
     checkpoints_by_work_dir_abs_path: HashMap<PathBuf, GitRepositoryCheckpoint>,
 }
 
-#[derive(Clone, Debug)]
-pub struct GitStoreDiff {
-    diffs_by_work_dir_abs_path: HashMap<PathBuf, String>,
-}
-
-#[derive(Clone, Debug)]
-pub struct GitStoreIndex {
-    indices_by_work_dir_abs_path: HashMap<PathBuf, GitIndex>,
-}
-
-#[derive(Default)]
-pub struct GitStoreStatus {
-    #[allow(dead_code)]
-    statuses_by_work_dir_abs_path: HashMap<PathBuf, GitStatus>,
-}
-
 pub struct Repository {
     pub repository_entry: RepositoryEntry,
     pub merge_message: Option<String>,
@@ -755,113 +739,6 @@ impl GitStore {
         })
     }
 
-    pub fn diff_checkpoints(
-        &self,
-        base_checkpoint: GitStoreCheckpoint,
-        target_checkpoint: GitStoreCheckpoint,
-        cx: &App,
-    ) -> Task<Result<GitStoreDiff>> {
-        let repositories_by_work_dir_abs_path = self
-            .repositories
-            .values()
-            .map(|repo| {
-                (
-                    repo.read(cx)
-                        .repository_entry
-                        .work_directory_abs_path
-                        .clone(),
-                    repo,
-                )
-            })
-            .collect::<HashMap<_, _>>();
-
-        let mut tasks = Vec::new();
-        for (work_dir_abs_path, base_checkpoint) in base_checkpoint.checkpoints_by_work_dir_abs_path
-        {
-            if let Some(target_checkpoint) = target_checkpoint
-                .checkpoints_by_work_dir_abs_path
-                .get(&work_dir_abs_path)
-                .cloned()
-            {
-                if let Some(repository) = repositories_by_work_dir_abs_path.get(&work_dir_abs_path)
-                {
-                    let diff = repository
-                        .read(cx)
-                        .diff_checkpoints(base_checkpoint, target_checkpoint);
-                    tasks.push(async move {
-                        let diff = diff.await??;
-                        anyhow::Ok((work_dir_abs_path, diff))
-                    });
-                }
-            }
-        }
-
-        cx.background_spawn(async move {
-            let diffs_by_path = future::try_join_all(tasks).await?;
-            Ok(GitStoreDiff {
-                diffs_by_work_dir_abs_path: diffs_by_path.into_iter().collect(),
-            })
-        })
-    }
-
-    pub fn create_index(&self, cx: &App) -> Task<Result<GitStoreIndex>> {
-        let mut indices = Vec::new();
-        for repository in self.repositories.values() {
-            let repository = repository.read(cx);
-            let work_dir_abs_path = repository.repository_entry.work_directory_abs_path.clone();
-            let index = repository.create_index().map(|index| index?);
-            indices.push(async move {
-                let index = index.await?;
-                anyhow::Ok((work_dir_abs_path, index))
-            });
-        }
-
-        cx.background_executor().spawn(async move {
-            let indices = future::try_join_all(indices).await?;
-            Ok(GitStoreIndex {
-                indices_by_work_dir_abs_path: indices.into_iter().collect(),
-            })
-        })
-    }
-
-    pub fn apply_diff(
-        &self,
-        mut index: GitStoreIndex,
-        diff: GitStoreDiff,
-        cx: &App,
-    ) -> Task<Result<()>> {
-        let repositories_by_work_dir_abs_path = self
-            .repositories
-            .values()
-            .map(|repo| {
-                (
-                    repo.read(cx)
-                        .repository_entry
-                        .work_directory_abs_path
-                        .clone(),
-                    repo,
-                )
-            })
-            .collect::<HashMap<_, _>>();
-
-        let mut tasks = Vec::new();
-        for (work_dir_abs_path, diff) in diff.diffs_by_work_dir_abs_path {
-            if let Some(repository) = repositories_by_work_dir_abs_path.get(&work_dir_abs_path) {
-                if let Some(branch) = index
-                    .indices_by_work_dir_abs_path
-                    .remove(&work_dir_abs_path)
-                {
-                    let apply = repository.read(cx).apply_diff(branch, diff);
-                    tasks.push(async move { apply.await? });
-                }
-            }
-        }
-        cx.background_spawn(async move {
-            future::try_join_all(tasks).await?;
-            Ok(())
-        })
-    }
-
     /// Blames a buffer.
     pub fn blame_buffer(
         &self,
@@ -1406,7 +1283,7 @@ impl GitStore {
                         let index_text = if current_index_text.is_some() {
                             local_repo
                                 .repo()
-                                .load_index_text(None, relative_path.clone())
+                                .load_index_text(relative_path.clone())
                                 .await
                         } else {
                             None
@@ -1521,87 +1398,6 @@ impl GitStore {
         Some(status.status)
     }
 
-    pub fn status(&self, index: Option<GitStoreIndex>, cx: &App) -> Task<Result<GitStoreStatus>> {
-        let repositories_by_work_dir_abs_path = self
-            .repositories
-            .values()
-            .map(|repo| {
-                (
-                    repo.read(cx)
-                        .repository_entry
-                        .work_directory_abs_path
-                        .clone(),
-                    repo,
-                )
-            })
-            .collect::<HashMap<_, _>>();
-
-        let mut tasks = Vec::new();
-
-        if let Some(index) = index {
-            // When we have an index, just check the repositories that are part of it
-            for (work_dir_abs_path, git_index) in index.indices_by_work_dir_abs_path {
-                if let Some(repository) = repositories_by_work_dir_abs_path.get(&work_dir_abs_path)
-                {
-                    let status = repository.read(cx).status(Some(git_index));
-                    tasks.push(
-                        async move {
-                            let status = status.await??;
-                            anyhow::Ok((work_dir_abs_path, status))
-                        }
-                        .boxed(),
-                    );
-                }
-            }
-        } else {
-            // Otherwise, check all repositories
-            for repository in self.repositories.values() {
-                let repository = repository.read(cx);
-                let work_dir_abs_path = repository.repository_entry.work_directory_abs_path.clone();
-                let status = repository.status(None);
-                tasks.push(
-                    async move {
-                        let status = status.await??;
-                        anyhow::Ok((work_dir_abs_path, status))
-                    }
-                    .boxed(),
-                );
-            }
-        }
-
-        cx.background_executor().spawn(async move {
-            let statuses = future::try_join_all(tasks).await?;
-            Ok(GitStoreStatus {
-                statuses_by_work_dir_abs_path: statuses.into_iter().collect(),
-            })
-        })
-    }
-
-    pub fn load_index_text(
-        &self,
-        index: Option<GitStoreIndex>,
-        buffer: &Entity<Buffer>,
-        cx: &App,
-    ) -> Task<Option<String>> {
-        let Some((repository, path)) =
-            self.repository_and_path_for_buffer_id(buffer.read(cx).remote_id(), cx)
-        else {
-            return Task::ready(None);
-        };
-
-        let git_index = index.and_then(|index| {
-            index
-                .indices_by_work_dir_abs_path
-                .get(&repository.read(cx).repository_entry.work_directory_abs_path)
-                .copied()
-        });
-        let text = repository.read(cx).load_index_text(git_index, path);
-        cx.background_spawn(async move {
-            let text = text.await;
-            text.ok().flatten()
-        })
-    }
-
     pub fn repository_and_path_for_buffer_id(
         &self,
         buffer_id: BufferId,
@@ -2851,24 +2647,11 @@ impl Repository {
         self.repository_entry.status()
     }
 
-    pub fn status(&self, index: Option<GitIndex>) -> oneshot::Receiver<Result<GitStatus>> {
-        self.send_job(move |repo, _cx| async move {
-            match repo {
-                RepositoryState::Local(git_repository) => git_repository.status(index, &[]).await,
-                RepositoryState::Remote { .. } => Err(anyhow!("not implemented yet")),
-            }
-        })
-    }
-
-    pub fn load_index_text(
-        &self,
-        index: Option<GitIndex>,
-        path: RepoPath,
-    ) -> oneshot::Receiver<Option<String>> {
+    pub fn load_index_text(&self, path: RepoPath) -> oneshot::Receiver<Option<String>> {
         self.send_job(move |repo, _cx| async move {
             match repo {
                 RepositoryState::Local(git_repository) => {
-                    git_repository.load_index_text(index, path).await
+                    git_repository.load_index_text(path).await
                 }
                 RepositoryState::Remote { .. } => None,
             }
@@ -3779,26 +3562,6 @@ impl Repository {
             }
         })
     }
-
-    pub fn create_index(&self) -> oneshot::Receiver<Result<GitIndex>> {
-        self.send_job(move |repo, _cx| async move {
-            match repo {
-                RepositoryState::Local(git_repository) => git_repository.create_index().await,
-                RepositoryState::Remote { .. } => Err(anyhow!("not implemented yet")),
-            }
-        })
-    }
-
-    pub fn apply_diff(&self, index: GitIndex, diff: String) -> oneshot::Receiver<Result<()>> {
-        self.send_job(move |repo, _cx| async move {
-            match repo {
-                RepositoryState::Local(git_repository) => {
-                    git_repository.apply_diff(index, diff).await
-                }
-                RepositoryState::Remote { .. } => Err(anyhow!("not implemented yet")),
-            }
-        })
-    }
 }
 
 fn get_permalink_in_rust_registry_src(

crates/project/src/lsp_store.rs 🔗

@@ -1228,7 +1228,7 @@ impl LocalLspStore {
                         .await;
                     buffer.handle.update(cx, |buffer, cx| {
                         buffer.start_transaction();
-                        buffer.apply_diff(diff, cx);
+                        buffer.apply_diff(diff, true, cx);
                         transaction_id_format =
                             transaction_id_format.or(buffer.end_transaction(cx));
                         if let Some(transaction_id) = transaction_id_format {
@@ -1362,7 +1362,7 @@ impl LocalLspStore {
                         zlog::trace!(logger => "Applying changes");
                         buffer.handle.update(cx, |buffer, cx| {
                             buffer.start_transaction();
-                            buffer.apply_diff(diff, cx);
+                            buffer.apply_diff(diff, true, cx);
                             transaction_id_format =
                                 transaction_id_format.or(buffer.end_transaction(cx));
                             if let Some(transaction_id) = transaction_id_format {
@@ -1405,7 +1405,7 @@ impl LocalLspStore {
                         zlog::trace!(logger => "Applying changes");
                         buffer.handle.update(cx, |buffer, cx| {
                             buffer.start_transaction();
-                            buffer.apply_diff(diff, cx);
+                            buffer.apply_diff(diff, true, cx);
                             transaction_id_format =
                                 transaction_id_format.or(buffer.end_transaction(cx));
                             if let Some(transaction_id) = transaction_id_format {

crates/text/src/text.rs 🔗

@@ -1498,9 +1498,9 @@ impl Buffer {
             .flat_map(|transaction| self.edited_ranges_for_transaction(transaction))
     }
 
-    pub fn edited_ranges_for_transaction<'a, D>(
+    pub fn edited_ranges_for_edit_ids<'a, D>(
         &'a self,
-        transaction: &'a Transaction,
+        edit_ids: impl IntoIterator<Item = &'a clock::Lamport>,
     ) -> impl 'a + Iterator<Item = Range<D>>
     where
         D: TextDimension,
@@ -1508,7 +1508,7 @@ impl Buffer {
         // get fragment ranges
         let mut cursor = self.fragments.cursor::<(Option<&Locator>, usize)>(&None);
         let offset_ranges = self
-            .fragment_ids_for_edits(transaction.edit_ids.iter())
+            .fragment_ids_for_edits(edit_ids.into_iter())
             .into_iter()
             .filter_map(move |fragment_id| {
                 cursor.seek_forward(&Some(fragment_id), Bias::Left, &None);
@@ -1547,6 +1547,16 @@ impl Buffer {
         })
     }
 
+    pub fn edited_ranges_for_transaction<'a, D>(
+        &'a self,
+        transaction: &'a Transaction,
+    ) -> impl 'a + Iterator<Item = Range<D>>
+    where
+        D: TextDimension,
+    {
+        self.edited_ranges_for_edit_ids(&transaction.edit_ids)
+    }
+
     pub fn subscribe(&mut self) -> Subscription {
         self.subscriptions.subscribe()
     }

crates/worktree/src/worktree.rs 🔗

@@ -1041,10 +1041,7 @@ impl Worktree {
                             if let Some(git_repo) =
                                 snapshot.git_repositories.get(&repo.work_directory_id)
                             {
-                                return Ok(git_repo
-                                    .repo_ptr
-                                    .load_index_text(None, repo_path)
-                                    .await);
+                                return Ok(git_repo.repo_ptr.load_index_text(repo_path).await);
                             }
                         }
                     }