From 94ed0b7767b6e32e3c66106f696f98c3fbb8d6cf Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Fri, 28 Mar 2025 19:58:53 +0100 Subject: [PATCH] Allow reviewing of agent changes without Git (#27668) Release Notes: - N/A --- 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 + .../assistant_tools/src/create_file_tool.rs | 16 +- .../assistant_tools/src/delete_path_tool.rs | 77 +- crates/assistant_tools/src/edit_files_tool.rs | 23 +- .../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 | 320 +----- 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, 2271 insertions(+), 1095 deletions(-) create mode 100644 crates/assistant2/src/assistant_diff.rs create mode 100644 crates/assistant_tool/src/action_log.rs diff --git a/Cargo.lock b/Cargo.lock index 71c50e148e..6ec287023f 100644 --- a/Cargo.lock +++ b/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", diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index ae9b4548b2..f7f03e1515 100644 --- a/assets/keymaps/default-linux.json +++ b/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" } diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 6701bfeb84..e2afc4b2e5 100644 --- a/assets/keymaps/default-macos.json +++ b/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, diff --git a/crates/assistant2/Cargo.toml b/crates/assistant2/Cargo.toml index 8d10c21fc4..cd257c64c5 100644 --- a/crates/assistant2/Cargo.toml +++ b/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 diff --git a/crates/assistant2/src/assistant.rs b/crates/assistant2/src/assistant.rs index 77dd1ab6e2..4d8aad74b6 100644 --- a/crates/assistant2/src/assistant.rs +++ b/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 ] ); diff --git a/crates/assistant2/src/assistant_diff.rs b/crates/assistant2/src/assistant_diff.rs new file mode 100644 index 0000000000..8944ba0975 --- /dev/null +++ b/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, + editor: Entity, + thread: Entity, + focus_handle: FocusHandle, + workspace: WeakEntity, + title: SharedString, + _subscriptions: Vec, +} + +impl AssistantDiff { + pub fn deploy( + thread: Entity, + workspace: WeakEntity, + window: &mut Window, + cx: &mut App, + ) -> Result<()> { + let existing_diff = workspace.update(cx, |workspace, cx| { + workspace + .items_of_type::(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, + workspace: WeakEntity, + window: &mut Window, + cx: &mut Context, + ) -> 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, + 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) { + 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::>(); + + 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::>(); + + 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) { + 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) { + match event { + ThreadEvent::SummaryChanged => self.update_title(cx), + _ => {} + } + } + + fn toggle_keep(&mut self, _: &crate::ToggleKeep, _window: &mut Window, cx: &mut Context) { + let ranges = self + .editor + .read(cx) + .selections + .disjoint_anchor_ranges() + .collect::>(); + + let snapshot = self.multibuffer.read(cx).snapshot(cx); + let diff_hunks_in_ranges = self + .editor + .read(cx) + .diff_hunks_in_ranges(&ranges, &snapshot) + .collect::>(); + + 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) { + 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>, + accept: bool, + cx: &mut Context, + ) { + 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::>(); + + 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 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 { + 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.editor + .update(cx, |editor, cx| editor.deactivated(window, cx)); + } + + fn navigate( + &mut self, + data: Box, + window: &mut Window, + cx: &mut Context, + ) -> bool { + self.editor + .update(cx, |editor, cx| editor.navigate(data, window, cx)) + } + + fn tab_tooltip_text(&self, _: &App) -> Option { + 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) -> Option> { + 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.editor.update(cx, |editor, _| { + editor.set_nav_history(Some(nav_history)); + }); + } + + fn clone_on_split( + &self, + _workspace_id: Option, + window: &mut Window, + cx: &mut Context, + ) -> Option> + 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, + window: &mut Window, + cx: &mut Context, + ) -> Task> { + self.editor.save(format, project, window, cx) + } + + fn save_as( + &mut self, + _: Entity, + _: ProjectPath, + _window: &mut Window, + _: &mut Context, + ) -> Task> { + unreachable!() + } + + fn reload( + &mut self, + project: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Task> { + self.editor.reload(project, window, cx) + } + + fn act_as_type<'a>( + &'a self, + type_id: TypeId, + self_handle: &'a Entity, + _: &'a App, + ) -> Option { + if type_id == TypeId::of::() { + Some(self_handle.to_any()) + } else if type_id == TypeId::of::() { + Some(self.editor.to_any()) + } else { + None + } + } + + fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation { + ToolbarItemLocation::PrimaryLeft + } + + fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option> { + self.editor.breadcrumbs(theme, cx) + } + + fn added_to_workspace( + &mut self, + workspace: &mut Workspace, + window: &mut Window, + cx: &mut Context, + ) { + 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) -> 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, + is_created_file: bool, + line_height: Pixels, + assistant_diff: &Entity, + 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"); + } +} diff --git a/crates/assistant2/src/message_editor.rs b/crates/assistant2/src/message_editor.rs index 6e908b9124..87ab684a02 100644 --- a/crates/assistant2/src/message_editor.rs +++ b/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, model_selector: Entity, profile_selector: Entity, + edits_expanded: bool, _subscriptions: Vec, } @@ -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::>(); + 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) { + 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), + .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("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), - ), - ) - .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 diff --git a/crates/assistant2/src/thread.rs b/crates/assistant2/src/thread.rs index 71ff71a901..b71c8b34b9 100644 --- a/crates/assistant2/src/thread.rs +++ b/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, + buffer_range: Range, + accept: bool, + cx: &mut Context, + ) { + 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 { &self.action_log } diff --git a/crates/assistant_tool/Cargo.toml b/crates/assistant_tool/Cargo.toml index 73ee3ffccb..6b36fb416b 100644 --- a/crates/assistant_tool/Cargo.toml +++ b/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"] } diff --git a/crates/assistant_tool/src/action_log.rs b/crates/assistant_tool/src/action_log.rs new file mode 100644 index 0000000000..6a91524afa --- /dev/null +++ b/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>, + /// Buffers that we want to notify the model about when they change. + tracked_buffers: BTreeMap, 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.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, + created: bool, + cx: &mut Context, + ) -> &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, + event: &BufferEvent, + cx: &mut Context, + ) { + 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, + operation: &Operation, + cx: &mut Context, + ) { + 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::([&operation.timestamp]) + .collect::>(); + let intersects_unreviewed_edits = ranges_intersect( + operation_edit_ranges.iter().cloned(), + buffer.edited_ranges_for_edit_ids::(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::([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, cx: &mut Context) { + 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, + buffer: Entity, + 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, cx: &mut Context) { + 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, + edit_id: Option, + cx: &mut Context, + ) { + 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, + mut edit_ids: Vec, + cx: &mut Context, + ) { + 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, cx: &mut Context) { + 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( + &mut self, + buffer: Entity, + buffer_range: Range, + accept: bool, + cx: &mut Context, + ) { + 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::([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, 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> { + 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> { + std::mem::take(&mut self.stale_buffers_in_context) + } +} + +fn ranges_intersect( + ranges_a: impl IntoIterator>, + ranges_b: impl IntoIterator>, +) -> 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, + change: Change, + version: clock::Global, + diff: Entity, + secondary_diff: Entity, + diff_update: async_watch::Sender<()>, + _maintain_diff: Task<()>, + _subscription: Subscription, +} + +enum Change { + Edited { + unreviewed_edit_ids: HashSet, + accepted_edit_ids: HashSet, + initial_content: Option, + }, + Deleted { + reviewed: bool, + deleted_content: TextBufferSnapshot, + deletion_id: Option, + }, +} + +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::>(); + 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::>(); + 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, + 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, + 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, + cx: &TestAppContext, + ) -> Vec<(Entity, Vec)> { + 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() + }) + } +} diff --git a/crates/assistant_tool/src/assistant_tool.rs b/crates/assistant_tool/src/assistant_tool.rs index 25a6106490..7a1ce22cd6 100644 --- a/crates/assistant_tool/src/assistant_tool.rs +++ b/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>, - /// Buffers that we want to notify the model about when they change. - tracked_buffers: HashMap, 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, cx: &mut Context) { - 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>, cx: &mut Context) { - 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> { - 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> { - std::mem::take(&mut self.stale_buffers_in_context) - } -} diff --git a/crates/assistant_tools/Cargo.toml b/crates/assistant_tools/Cargo.toml index 1120b539c8..839da4b9a8 100644 --- a/crates/assistant_tools/Cargo.toml +++ b/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"] } diff --git a/crates/assistant_tools/src/create_file_tool.rs b/crates/assistant_tools/src/create_file_tool.rs index 9997fe2da0..fdd78d6941 100644 --- a/crates/assistant_tools/src/create_file_tool.rs +++ b/crates/assistant_tools/src/create_file_tool.rs @@ -70,7 +70,7 @@ impl Tool for CreateFileTool { input: serde_json::Value, _messages: &[LanguageModelRequestMessage], project: Entity, - _action_log: Entity, + action_log: Entity, cx: &mut App, ) -> Task> { let input = match serde_json::from_value::(input) { @@ -85,24 +85,20 @@ impl Tool for CreateFileTool { let destination_path: Arc = 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}"))?; diff --git a/crates/assistant_tools/src/delete_path_tool.rs b/crates/assistant_tools/src/delete_path_tool.rs index 460b070b46..282d83ba58 100644 --- a/crates/assistant_tools/src/delete_path_tool.rs +++ b/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, - _action_log: Entity, + action_log: Entity, cx: &mut App, ) -> Task> { let path_str = match serde_json::from_value::(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." + )), + } + }) } } diff --git a/crates/assistant_tools/src/edit_files_tool.rs b/crates/assistant_tools/src/edit_files_tool.rs index 08b275c24a..9929b4328c 100644 --- a/crates/assistant_tools/src/edit_files_tool.rs +++ b/crates/assistant_tools/src/edit_files_tool.rs @@ -173,6 +173,7 @@ enum EditorResponse { struct AppliedAction { source: String, buffer: Entity, + edit_ids: Vec, } #[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))?; + 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 }); + 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, diff --git a/crates/assistant_tools/src/find_replace_file_tool.rs b/crates/assistant_tools/src/find_replace_file_tool.rs index 73a516f0b3..e96d625b37 100644 --- a/crates/assistant_tools/src/find_replace_file_tool.rs +++ b/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()) + })?; + + action_log.update(cx, |log, cx| { + log.buffer_edited(buffer.clone(), edit_ids, cx) })?; project.update(cx, |project, cx| { - project.save_buffer(buffer.clone(), cx) + project.save_buffer(buffer, cx) })?.await?; - action_log.update(cx, |log, cx| { - let mut buffers = HashSet::default(); - buffers.insert(buffer); - log.buffer_edited(buffers, cx); - })?; - Ok(format!("Edited {}", input.path.display())) } else { let err = buffer.read_with(cx, |buffer, _cx| { diff --git a/crates/assistant_tools/src/replace.rs b/crates/assistant_tools/src/replace.rs index 46f54bb8a5..8a11910151 100644 --- a/crates/assistant_tools/src/replace.rs +++ b/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() }) }) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 4a78d7c9a9..faf0fbb56e 100644 --- a/crates/editor/src/editor.rs +++ b/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, + bool, + Pixels, + &Entity, + &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>, git_blame_inline_tooltip: Option>, git_blame_inline_enabled: bool, + render_diff_hunk_controls: RenderDiffHunkControlsFn, serialize_dirty_buffers: bool, show_selection_menu: Option, blame: Option>, @@ -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>, 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.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 for LineHighlight { } } } + +fn render_diff_hunk_controls( + row: u32, + status: &DiffHunkStatus, + hunk_range: Range, + is_created_file: bool, + line_height: Pixels, + editor: &Entity, + 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() +} diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index baa9f6d490..6d490f2fe6 100644 --- a/crates/editor/src/element.rs +++ b/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 { + 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, - is_created_file: bool, - line_height: Pixels, - editor: &Entity, - 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() -} diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index 89ecd57033..365aeae383 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/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, - path: RepoPath, - ) -> BoxFuture> { - if index.is_some() { - unimplemented!(); - } - + fn load_index_text(&self, path: RepoPath) -> BoxFuture> { async { self.with_state_async(false, move |state| { state @@ -179,19 +171,6 @@ impl GitRepository for FakeGitRepository { self.path() } - fn status( - &self, - index: Option, - path_prefixes: &[RepoPath], - ) -> BoxFuture<'static, Result> { - if index.is_some() { - unimplemented!(); - } - - let status = self.status_blocking(path_prefixes); - async move { status }.boxed() - } - fn status_blocking(&self, path_prefixes: &[RepoPath]) -> Result { let workdir_path = self.dot_git_path.parent().unwrap(); @@ -457,12 +436,4 @@ impl GitRepository for FakeGitRepository { ) -> BoxFuture> { unimplemented!() } - - fn create_index(&self) -> BoxFuture> { - unimplemented!() - } - - fn apply_diff(&self, _index: GitIndex, _diff: String) -> BoxFuture> { - unimplemented!() - } } diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 6780997a6b..cdeacc7e97 100644 --- a/crates/git/src/repository.rs +++ b/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, path: RepoPath) - -> BoxFuture>; + fn load_index_text(&self, path: RepoPath) -> BoxFuture>; /// 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; - fn status( - &self, - index: Option, - path_prefixes: &[RepoPath], - ) -> BoxFuture<'static, Result>; fn status_blocking(&self, path_prefixes: &[RepoPath]) -> Result; fn branches(&self) -> BoxFuture>>; @@ -312,12 +306,6 @@ pub trait GitRepository: Send + Sync { base_checkpoint: GitRepositoryCheckpoint, target_checkpoint: GitRepositoryCheckpoint, ) -> BoxFuture>; - - /// Creates a new index for the repository. - fn create_index(&self) -> BoxFuture>; - - /// Applies a diff to the repository's index. - fn apply_diff(&self, index: GitIndex, diff: String) -> BoxFuture>; } 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, - path: RepoPath, - ) -> BoxFuture> { - 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> { + // 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; - } + fn logic(repo: &git2::Repository, path: &RepoPath) -> Result> { + // This check is required because index.get_path() unwraps internally :( + check_path_to_repo_path_errors(path)?; + + let mut index = repo.index()?; + index.read(false)?; + + 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), + }; + + let content = repo.find_blob(oid)?.content().to_owned(); + Ok(Some(String::from_utf8(content)?)) } - let working_directory = match working_directory { - Ok(dir) => dir, - Err(err) => { - log::error!("Error getting working directory: {:?}", err); - return None; - } - }; - - 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 0 " where 100644 is the mode - if ls_files_output.is_empty() { - return Ok(None); // File not in index - } - - 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]; - - // Now get the content - Ok(Some( - git.run_raw(&["cat-file", "blob", sha]) - .await - .context("error getting blob content")?, - )) - }) - .await; - - 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, - path_prefixes: &[RepoPath], - ) -> BoxFuture<'static, Result> { - 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 { 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> { - 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> { - 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 { @@ -1407,7 +1274,7 @@ impl GitBinary { &mut self, f: impl AsyncFnOnce(&Self) -> Result, ) -> Result { - 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( - &mut self, - index: GitIndex, - f: impl AsyncFnOnce(&Self) -> Result, - ) -> Result { - self.with_option_index(Some(index), f).await - } - - pub async fn with_option_index( - &mut self, - index: Option, - f: impl AsyncFnOnce(&Self) -> Result, - ) -> Result { - 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(&self, args: impl IntoIterator) -> Result @@ -1486,26 +1333,6 @@ impl GitBinary { } } - pub async fn run_with_stdin(&self, args: &[&str], stdin: String) -> Result { - 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(&self, args: impl IntoIterator) -> smol::process::Command where S: AsRef, @@ -1787,9 +1614,8 @@ fn checkpoint_author_envs() -> HashMap { #[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" diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index c71c86b282..fb68ab1316 100644 --- a/crates/language/src/buffer.rs +++ b/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) -> Option { - // 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, + ) -> Option { let snapshot = self.snapshot(); let mut edits_since = snapshot.edits_since::(&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) } diff --git a/crates/language/src/buffer_tests.rs b/crates/language/src/buffer_tests.rs index b4ff4a63f8..af5cd61dfe 100644 --- a/crates/language/src/buffer_tests.rs +++ b/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. // diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 5bb6012313..f51ee6a653 100644 --- a/crates/project/src/git_store.rs +++ b/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, } -#[derive(Clone, Debug)] -pub struct GitStoreDiff { - diffs_by_work_dir_abs_path: HashMap, -} - -#[derive(Clone, Debug)] -pub struct GitStoreIndex { - indices_by_work_dir_abs_path: HashMap, -} - -#[derive(Default)] -pub struct GitStoreStatus { - #[allow(dead_code)] - statuses_by_work_dir_abs_path: HashMap, -} - pub struct Repository { pub repository_entry: RepositoryEntry, pub merge_message: Option, @@ -755,113 +739,6 @@ impl GitStore { }) } - pub fn diff_checkpoints( - &self, - base_checkpoint: GitStoreCheckpoint, - target_checkpoint: GitStoreCheckpoint, - cx: &App, - ) -> Task> { - let repositories_by_work_dir_abs_path = self - .repositories - .values() - .map(|repo| { - ( - repo.read(cx) - .repository_entry - .work_directory_abs_path - .clone(), - repo, - ) - }) - .collect::>(); - - 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> { - 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> { - let repositories_by_work_dir_abs_path = self - .repositories - .values() - .map(|repo| { - ( - repo.read(cx) - .repository_entry - .work_directory_abs_path - .clone(), - repo, - ) - }) - .collect::>(); - - 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, cx: &App) -> Task> { - let repositories_by_work_dir_abs_path = self - .repositories - .values() - .map(|repo| { - ( - repo.read(cx) - .repository_entry - .work_directory_abs_path - .clone(), - repo, - ) - }) - .collect::>(); - - 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, - buffer: &Entity, - cx: &App, - ) -> Task> { - 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) -> oneshot::Receiver> { - 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, - path: RepoPath, - ) -> oneshot::Receiver> { + pub fn load_index_text(&self, path: RepoPath) -> oneshot::Receiver> { 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> { - 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> { - 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( diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index b237e3c661..fc59fd964d 100644 --- a/crates/project/src/lsp_store.rs +++ b/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 { diff --git a/crates/text/src/text.rs b/crates/text/src/text.rs index 21fa3b3940..7f4d371d72 100644 --- a/crates/text/src/text.rs +++ b/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, ) -> impl 'a + Iterator> 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> + where + D: TextDimension, + { + self.edited_ranges_for_edit_ids(&transaction.edit_ids) + } + pub fn subscribe(&mut self) -> Subscription { self.subscriptions.subscribe() } diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index ee2d27db8e..0d2b6e00c7 100644 --- a/crates/worktree/src/worktree.rs +++ b/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); } } }