Detailed changes
@@ -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",
@@ -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"
}
@@ -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,
@@ -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
@@ -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
]
);
@@ -0,0 +1,665 @@
+use crate::{Thread, ThreadEvent, ToggleKeep};
+use anyhow::Result;
+use buffer_diff::DiffHunkStatus;
+use collections::HashSet;
+use editor::{
+ actions::{GoToHunk, GoToPreviousHunk},
+ Direction, Editor, EditorEvent, MultiBuffer, ToPoint,
+};
+use gpui::{
+ prelude::*, AnyElement, AnyView, App, Entity, EventEmitter, FocusHandle, Focusable,
+ SharedString, Subscription, Task, WeakEntity, Window,
+};
+use language::{Capability, DiskState, OffsetRangeExt};
+use multi_buffer::PathKey;
+use project::{Project, ProjectPath};
+use std::{
+ any::{Any, TypeId},
+ ops::Range,
+ sync::Arc,
+};
+use ui::{prelude::*, IconButtonShape, Tooltip};
+use workspace::{
+ item::{BreadcrumbText, ItemEvent, TabContentParams},
+ searchable::SearchableItemHandle,
+ Item, ItemHandle, ItemNavHistory, ToolbarItemLocation, Workspace,
+};
+
+pub struct AssistantDiff {
+ multibuffer: Entity<MultiBuffer>,
+ editor: Entity<Editor>,
+ thread: Entity<Thread>,
+ focus_handle: FocusHandle,
+ workspace: WeakEntity<Workspace>,
+ title: SharedString,
+ _subscriptions: Vec<Subscription>,
+}
+
+impl AssistantDiff {
+ pub fn deploy(
+ thread: Entity<Thread>,
+ workspace: WeakEntity<Workspace>,
+ window: &mut Window,
+ cx: &mut App,
+ ) -> Result<()> {
+ let existing_diff = workspace.update(cx, |workspace, cx| {
+ workspace
+ .items_of_type::<AssistantDiff>(cx)
+ .find(|diff| diff.read(cx).thread == thread)
+ })?;
+ if let Some(existing_diff) = existing_diff {
+ workspace.update(cx, |workspace, cx| {
+ workspace.activate_item(&existing_diff, true, true, window, cx);
+ })
+ } else {
+ let assistant_diff =
+ cx.new(|cx| AssistantDiff::new(thread.clone(), workspace.clone(), window, cx));
+ workspace.update(cx, |workspace, cx| {
+ workspace.add_item_to_center(Box::new(assistant_diff), window, cx);
+ })
+ }
+ }
+
+ pub fn new(
+ thread: Entity<Thread>,
+ workspace: WeakEntity<Workspace>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> Self {
+ let focus_handle = cx.focus_handle();
+ let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite));
+
+ let project = thread.read(cx).project().clone();
+ let render_diff_hunk_controls = Arc::new({
+ let assistant_diff = cx.entity();
+ move |row,
+ status: &DiffHunkStatus,
+ hunk_range,
+ is_created_file,
+ line_height,
+ _editor: &Entity<Editor>,
+ cx: &mut App| {
+ render_diff_hunk_controls(
+ row,
+ status,
+ hunk_range,
+ is_created_file,
+ line_height,
+ &assistant_diff,
+ cx,
+ )
+ }
+ });
+ let editor = cx.new(|cx| {
+ let mut editor =
+ Editor::for_multibuffer(multibuffer.clone(), Some(project.clone()), window, cx);
+ editor.disable_inline_diagnostics();
+ editor.set_expand_all_diff_hunks(cx);
+ editor.set_render_diff_hunk_controls(render_diff_hunk_controls, cx);
+ editor.register_addon(AssistantDiffAddon);
+ editor
+ });
+
+ let action_log = thread.read(cx).action_log().clone();
+ let mut this = Self {
+ _subscriptions: vec![
+ cx.observe_in(&action_log, window, |this, _action_log, window, cx| {
+ this.update_excerpts(window, cx)
+ }),
+ cx.subscribe(&thread, |this, _thread, event, cx| {
+ this.handle_thread_event(event, cx)
+ }),
+ ],
+ title: SharedString::default(),
+ multibuffer,
+ editor,
+ thread,
+ focus_handle,
+ workspace,
+ };
+ this.update_excerpts(window, cx);
+ this.update_title(cx);
+ this
+ }
+
+ fn update_excerpts(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+ let thread = self.thread.read(cx);
+ let changed_buffers = thread.action_log().read(cx).changed_buffers(cx);
+ let mut paths_to_delete = self.multibuffer.read(cx).paths().collect::<HashSet<_>>();
+
+ for (buffer, changed) in changed_buffers {
+ let Some(file) = buffer.read(cx).file().cloned() else {
+ continue;
+ };
+
+ let path_key = PathKey::namespaced("", file.full_path(cx).into());
+ paths_to_delete.remove(&path_key);
+
+ let snapshot = buffer.read(cx).snapshot();
+ let diff = changed.diff.read(cx);
+ let diff_hunk_ranges = diff
+ .hunks_intersecting_range(
+ language::Anchor::MIN..language::Anchor::MAX,
+ &snapshot,
+ cx,
+ )
+ .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot))
+ .collect::<Vec<_>>();
+
+ let (was_empty, is_excerpt_newly_added) =
+ self.multibuffer.update(cx, |multibuffer, cx| {
+ let was_empty = multibuffer.is_empty();
+ let is_excerpt_newly_added = multibuffer.set_excerpts_for_path(
+ path_key.clone(),
+ buffer.clone(),
+ diff_hunk_ranges,
+ editor::DEFAULT_MULTIBUFFER_CONTEXT,
+ cx,
+ );
+ multibuffer.add_diff(changed.diff.clone(), cx);
+ (was_empty, is_excerpt_newly_added)
+ });
+
+ self.editor.update(cx, |editor, cx| {
+ if was_empty {
+ editor.change_selections(None, window, cx, |selections| {
+ selections.select_ranges([0..0])
+ });
+ }
+
+ if is_excerpt_newly_added
+ && buffer
+ .read(cx)
+ .file()
+ .map_or(false, |file| file.disk_state() == DiskState::Deleted)
+ {
+ editor.fold_buffer(snapshot.text.remote_id(), cx)
+ }
+ });
+ }
+
+ self.multibuffer.update(cx, |multibuffer, cx| {
+ for path in paths_to_delete {
+ multibuffer.remove_excerpts_for_path(path, cx);
+ }
+ });
+
+ if self.multibuffer.read(cx).is_empty()
+ && self
+ .editor
+ .read(cx)
+ .focus_handle(cx)
+ .contains_focused(window, cx)
+ {
+ self.focus_handle.focus(window);
+ } else if self.focus_handle.is_focused(window) && !self.multibuffer.read(cx).is_empty() {
+ self.editor.update(cx, |editor, cx| {
+ editor.focus_handle(cx).focus(window);
+ });
+ }
+ }
+
+ fn update_title(&mut self, cx: &mut Context<Self>) {
+ let new_title = self
+ .thread
+ .read(cx)
+ .summary()
+ .unwrap_or("Assistant Changes".into());
+ if new_title != self.title {
+ self.title = new_title;
+ cx.emit(EditorEvent::TitleChanged);
+ }
+ }
+
+ fn handle_thread_event(&mut self, event: &ThreadEvent, cx: &mut Context<Self>) {
+ match event {
+ ThreadEvent::SummaryChanged => self.update_title(cx),
+ _ => {}
+ }
+ }
+
+ fn toggle_keep(&mut self, _: &crate::ToggleKeep, _window: &mut Window, cx: &mut Context<Self>) {
+ let ranges = self
+ .editor
+ .read(cx)
+ .selections
+ .disjoint_anchor_ranges()
+ .collect::<Vec<_>>();
+
+ let snapshot = self.multibuffer.read(cx).snapshot(cx);
+ let diff_hunks_in_ranges = self
+ .editor
+ .read(cx)
+ .diff_hunks_in_ranges(&ranges, &snapshot)
+ .collect::<Vec<_>>();
+
+ for hunk in diff_hunks_in_ranges {
+ let buffer = self.multibuffer.read(cx).buffer(hunk.buffer_id);
+ if let Some(buffer) = buffer {
+ self.thread.update(cx, |thread, cx| {
+ let accept = hunk.status().has_secondary_hunk();
+ thread.review_edits_in_range(buffer, hunk.buffer_range, accept, cx)
+ });
+ }
+ }
+ }
+
+ fn reject(&mut self, _: &crate::Reject, window: &mut Window, cx: &mut Context<Self>) {
+ let ranges = self
+ .editor
+ .update(cx, |editor, cx| editor.selections.ranges(cx));
+ self.editor.update(cx, |editor, cx| {
+ editor.restore_hunks_in_ranges(ranges, window, cx)
+ })
+ }
+
+ fn review_diff_hunks(
+ &mut self,
+ hunk_ranges: Vec<Range<editor::Anchor>>,
+ accept: bool,
+ cx: &mut Context<Self>,
+ ) {
+ let snapshot = self.multibuffer.read(cx).snapshot(cx);
+ let diff_hunks_in_ranges = self
+ .editor
+ .read(cx)
+ .diff_hunks_in_ranges(&hunk_ranges, &snapshot)
+ .collect::<Vec<_>>();
+
+ for hunk in diff_hunks_in_ranges {
+ let buffer = self.multibuffer.read(cx).buffer(hunk.buffer_id);
+ if let Some(buffer) = buffer {
+ self.thread.update(cx, |thread, cx| {
+ thread.review_edits_in_range(buffer, hunk.buffer_range, accept, cx)
+ });
+ }
+ }
+ }
+}
+
+impl EventEmitter<EditorEvent> for AssistantDiff {}
+
+impl Focusable for AssistantDiff {
+ fn focus_handle(&self, cx: &App) -> FocusHandle {
+ if self.multibuffer.read(cx).is_empty() {
+ self.focus_handle.clone()
+ } else {
+ self.editor.focus_handle(cx)
+ }
+ }
+}
+
+impl Item for AssistantDiff {
+ type Event = EditorEvent;
+
+ fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
+ Some(Icon::new(IconName::ZedAssistant).color(Color::Muted))
+ }
+
+ fn to_item_events(event: &EditorEvent, f: impl FnMut(ItemEvent)) {
+ Editor::to_item_events(event, f)
+ }
+
+ fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+ self.editor
+ .update(cx, |editor, cx| editor.deactivated(window, cx));
+ }
+
+ fn navigate(
+ &mut self,
+ data: Box<dyn Any>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> bool {
+ self.editor
+ .update(cx, |editor, cx| editor.navigate(data, window, cx))
+ }
+
+ fn tab_tooltip_text(&self, _: &App) -> Option<SharedString> {
+ Some("Assistant Diff".into())
+ }
+
+ fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
+ let summary = self
+ .thread
+ .read(cx)
+ .summary()
+ .unwrap_or("Assistant Changes".into());
+ Label::new(format!("Review: {}", summary))
+ .color(if params.selected {
+ Color::Default
+ } else {
+ Color::Muted
+ })
+ .into_any_element()
+ }
+
+ fn telemetry_event_text(&self) -> Option<&'static str> {
+ Some("Assistant Diff Opened")
+ }
+
+ fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
+ Some(Box::new(self.editor.clone()))
+ }
+
+ fn for_each_project_item(
+ &self,
+ cx: &App,
+ f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
+ ) {
+ self.editor.for_each_project_item(cx, f)
+ }
+
+ fn is_singleton(&self, _: &App) -> bool {
+ false
+ }
+
+ fn set_nav_history(
+ &mut self,
+ nav_history: ItemNavHistory,
+ _: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ self.editor.update(cx, |editor, _| {
+ editor.set_nav_history(Some(nav_history));
+ });
+ }
+
+ fn clone_on_split(
+ &self,
+ _workspace_id: Option<workspace::WorkspaceId>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> Option<Entity<Self>>
+ where
+ Self: Sized,
+ {
+ Some(cx.new(|cx| Self::new(self.thread.clone(), self.workspace.clone(), window, cx)))
+ }
+
+ fn is_dirty(&self, cx: &App) -> bool {
+ self.multibuffer.read(cx).is_dirty(cx)
+ }
+
+ fn has_conflict(&self, cx: &App) -> bool {
+ self.multibuffer.read(cx).has_conflict(cx)
+ }
+
+ fn can_save(&self, _: &App) -> bool {
+ true
+ }
+
+ fn save(
+ &mut self,
+ format: bool,
+ project: Entity<Project>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> Task<Result<()>> {
+ self.editor.save(format, project, window, cx)
+ }
+
+ fn save_as(
+ &mut self,
+ _: Entity<Project>,
+ _: ProjectPath,
+ _window: &mut Window,
+ _: &mut Context<Self>,
+ ) -> Task<Result<()>> {
+ unreachable!()
+ }
+
+ fn reload(
+ &mut self,
+ project: Entity<Project>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> Task<Result<()>> {
+ self.editor.reload(project, window, cx)
+ }
+
+ fn act_as_type<'a>(
+ &'a self,
+ type_id: TypeId,
+ self_handle: &'a Entity<Self>,
+ _: &'a App,
+ ) -> Option<AnyView> {
+ if type_id == TypeId::of::<Self>() {
+ Some(self_handle.to_any())
+ } else if type_id == TypeId::of::<Editor>() {
+ Some(self.editor.to_any())
+ } else {
+ None
+ }
+ }
+
+ fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
+ ToolbarItemLocation::PrimaryLeft
+ }
+
+ fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
+ self.editor.breadcrumbs(theme, cx)
+ }
+
+ fn added_to_workspace(
+ &mut self,
+ workspace: &mut Workspace,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ self.editor.update(cx, |editor, cx| {
+ editor.added_to_workspace(workspace, window, cx)
+ });
+ }
+}
+
+impl Render for AssistantDiff {
+ fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+ let is_empty = self.multibuffer.read(cx).is_empty();
+ div()
+ .track_focus(&self.focus_handle)
+ .key_context(if is_empty {
+ "EmptyPane"
+ } else {
+ "AssistantDiff"
+ })
+ .on_action(cx.listener(Self::toggle_keep))
+ .on_action(cx.listener(Self::reject))
+ .bg(cx.theme().colors().editor_background)
+ .flex()
+ .items_center()
+ .justify_center()
+ .size_full()
+ .when(is_empty, |el| el.child("No changes to review"))
+ .when(!is_empty, |el| el.child(self.editor.clone()))
+ }
+}
+
+fn render_diff_hunk_controls(
+ row: u32,
+ status: &DiffHunkStatus,
+ hunk_range: Range<editor::Anchor>,
+ is_created_file: bool,
+ line_height: Pixels,
+ assistant_diff: &Entity<AssistantDiff>,
+ cx: &mut App,
+) -> AnyElement {
+ let editor = assistant_diff.read(cx).editor.clone();
+ h_flex()
+ .h(line_height)
+ .mr_1()
+ .gap_1()
+ .px_0p5()
+ .pb_1()
+ .border_x_1()
+ .border_b_1()
+ .border_color(cx.theme().colors().border_variant)
+ .rounded_b_lg()
+ .bg(cx.theme().colors().editor_background)
+ .gap_1()
+ .occlude()
+ .shadow_md()
+ .children(if status.has_secondary_hunk() {
+ vec![
+ Button::new(("keep", row as u64), "Keep")
+ .tooltip({
+ let focus_handle = editor.focus_handle(cx);
+ move |window, cx| {
+ Tooltip::for_action_in(
+ "Keep Hunk",
+ &crate::ToggleKeep,
+ &focus_handle,
+ window,
+ cx,
+ )
+ }
+ })
+ .on_click({
+ let assistant_diff = assistant_diff.clone();
+ move |_event, _window, cx| {
+ assistant_diff.update(cx, |diff, cx| {
+ diff.review_diff_hunks(
+ vec![hunk_range.start..hunk_range.start],
+ true,
+ cx,
+ );
+ });
+ }
+ }),
+ Button::new("reject", "Reject")
+ .tooltip({
+ let focus_handle = editor.focus_handle(cx);
+ move |window, cx| {
+ Tooltip::for_action_in(
+ "Reject Hunk",
+ &crate::Reject,
+ &focus_handle,
+ window,
+ cx,
+ )
+ }
+ })
+ .on_click({
+ let editor = editor.clone();
+ move |_event, window, cx| {
+ editor.update(cx, |editor, cx| {
+ let snapshot = editor.snapshot(window, cx);
+ let point = hunk_range.start.to_point(&snapshot.buffer_snapshot);
+ editor.restore_hunks_in_ranges(vec![point..point], window, cx);
+ });
+ }
+ })
+ .disabled(is_created_file),
+ ]
+ } else {
+ vec![Button::new(("review", row as u64), "Review")
+ .tooltip({
+ let focus_handle = editor.focus_handle(cx);
+ move |window, cx| {
+ Tooltip::for_action_in("Review", &ToggleKeep, &focus_handle, window, cx)
+ }
+ })
+ .on_click({
+ let assistant_diff = assistant_diff.clone();
+ move |_event, _window, cx| {
+ assistant_diff.update(cx, |diff, cx| {
+ diff.review_diff_hunks(
+ vec![hunk_range.start..hunk_range.start],
+ false,
+ cx,
+ );
+ });
+ }
+ })]
+ })
+ .when(
+ !editor.read(cx).buffer().read(cx).all_diff_hunks_expanded(),
+ |el| {
+ el.child(
+ IconButton::new(("next-hunk", row as u64), IconName::ArrowDown)
+ .shape(IconButtonShape::Square)
+ .icon_size(IconSize::Small)
+ // .disabled(!has_multiple_hunks)
+ .tooltip({
+ let focus_handle = editor.focus_handle(cx);
+ move |window, cx| {
+ Tooltip::for_action_in(
+ "Next Hunk",
+ &GoToHunk,
+ &focus_handle,
+ window,
+ cx,
+ )
+ }
+ })
+ .on_click({
+ let editor = editor.clone();
+ move |_event, window, cx| {
+ editor.update(cx, |editor, cx| {
+ let snapshot = editor.snapshot(window, cx);
+ let position =
+ hunk_range.end.to_point(&snapshot.buffer_snapshot);
+ editor.go_to_hunk_before_or_after_position(
+ &snapshot,
+ position,
+ Direction::Next,
+ window,
+ cx,
+ );
+ editor.expand_selected_diff_hunks(cx);
+ });
+ }
+ }),
+ )
+ .child(
+ IconButton::new(("prev-hunk", row as u64), IconName::ArrowUp)
+ .shape(IconButtonShape::Square)
+ .icon_size(IconSize::Small)
+ // .disabled(!has_multiple_hunks)
+ .tooltip({
+ let focus_handle = editor.focus_handle(cx);
+ move |window, cx| {
+ Tooltip::for_action_in(
+ "Previous Hunk",
+ &GoToPreviousHunk,
+ &focus_handle,
+ window,
+ cx,
+ )
+ }
+ })
+ .on_click({
+ let editor = editor.clone();
+ move |_event, window, cx| {
+ editor.update(cx, |editor, cx| {
+ let snapshot = editor.snapshot(window, cx);
+ let point =
+ hunk_range.start.to_point(&snapshot.buffer_snapshot);
+ editor.go_to_hunk_before_or_after_position(
+ &snapshot,
+ point,
+ Direction::Prev,
+ window,
+ cx,
+ );
+ editor.expand_selected_diff_hunks(cx);
+ });
+ }
+ }),
+ )
+ },
+ )
+ .into_any_element()
+}
+
+struct AssistantDiffAddon;
+
+impl editor::Addon for AssistantDiffAddon {
+ fn to_any(&self) -> &dyn std::any::Any {
+ self
+ }
+
+ fn extend_key_context(&self, key_context: &mut gpui::KeyContext, _: &App) {
+ key_context.add("assistant_diff");
+ }
+}
@@ -3,11 +3,10 @@ use std::sync::Arc;
use collections::HashSet;
use editor::actions::MoveUp;
use editor::{ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement, EditorStyle};
+use file_icons::FileIcons;
use fs::Fs;
-use git::ExpandCommitEditor;
-use git_ui::git_panel;
use gpui::{
- point, Animation, AnimationExt, App, DismissEvent, Entity, Focusable, Subscription, TextStyle,
+ Animation, AnimationExt, App, DismissEvent, Entity, Focusable, Subscription, TextStyle,
WeakEntity,
};
use language_model::LanguageModelRegistry;
@@ -17,8 +16,10 @@ use settings::Settings;
use std::time::Duration;
use theme::ThemeSettings;
use ui::{
- prelude::*, ButtonLike, KeyBinding, PlatformStyle, PopoverMenu, PopoverMenuHandle, Tooltip,
+ prelude::*, ButtonLike, Disclosure, KeyBinding, PlatformStyle, PopoverMenu, PopoverMenuHandle,
+ Tooltip,
};
+use util::ResultExt;
use vim_mode_setting::VimModeSetting;
use workspace::Workspace;
@@ -30,7 +31,8 @@ use crate::profile_selector::ProfileSelector;
use crate::thread::{RequestKind, Thread};
use crate::thread_store::ThreadStore;
use crate::{
- Chat, ChatMode, RemoveAllContext, ThreadEvent, ToggleContextPicker, ToggleProfileSelector,
+ AssistantDiff, Chat, ChatMode, RemoveAllContext, ThreadEvent, ToggleContextPicker,
+ ToggleProfileSelector,
};
pub struct MessageEditor {
@@ -46,6 +48,7 @@ pub struct MessageEditor {
inline_context_picker_menu_handle: PopoverMenuHandle<ContextPicker>,
model_selector: Entity<AssistantModelSelector>,
profile_selector: Entity<ProfileSelector>,
+ edits_expanded: bool,
_subscriptions: Vec<Subscription>,
}
@@ -137,6 +140,7 @@ impl MessageEditor {
cx,
)
}),
+ edits_expanded: false,
profile_selector: cx
.new(|cx| ProfileSelector::new(fs, thread_store, editor.focus_handle(cx), cx)),
_subscriptions: subscriptions,
@@ -236,6 +240,9 @@ impl MessageEditor {
thread
.update(cx, |thread, cx| {
let context = context_store.read(cx).snapshot(cx).collect::<Vec<_>>();
+ thread.action_log().update(cx, |action_log, cx| {
+ action_log.clear_reviewed_changes(cx);
+ });
thread.insert_user_message(user_message, context, checkpoint, cx);
thread.send_to_model(model, request_kind, cx);
})
@@ -282,6 +289,10 @@ impl MessageEditor {
self.context_strip.focus_handle(cx).focus(window);
}
}
+
+ fn handle_review_click(&self, window: &mut Window, cx: &mut Context<Self>) {
+ AssistantDiff::deploy(self.thread.clone(), self.workspace.clone(), window, cx).log_err();
+ }
}
impl Focusable for MessageEditor {
@@ -298,7 +309,6 @@ impl Render for MessageEditor {
let focus_handle = self.editor.focus_handle(cx);
let inline_context_picker = self.inline_context_picker.clone();
- let empty_thread = self.thread.read(cx).is_empty();
let is_generating = self.thread.read(cx).is_generating();
let is_model_selected = self.is_model_selected(cx);
let is_editor_empty = self.is_editor_empty(cx);
@@ -318,30 +328,10 @@ impl Render for MessageEditor {
px(64.)
};
- let project = self.thread.read(cx).project();
- let changed_files = if let Some(repository) = project.read(cx).active_repository(cx) {
- repository.read(cx).cached_status().count()
- } else {
- 0
- };
-
- let border_color = cx.theme().colors().border;
- let active_color = cx.theme().colors().element_selected;
+ let action_log = self.thread.read(cx).action_log();
+ let changed_buffers = action_log.read(cx).changed_buffers(cx);
+ let changed_buffers_count = changed_buffers.len();
let editor_bg_color = cx.theme().colors().editor_background;
- let bg_edit_files_disclosure = editor_bg_color.blend(active_color.opacity(0.3));
-
- let edit_files_container = || {
- h_flex()
- .mx_2()
- .py_1()
- .pl_2p5()
- .pr_1()
- .bg(bg_edit_files_disclosure)
- .border_1()
- .border_color(border_color)
- .justify_between()
- .flex_wrap()
- };
v_flex()
.size_full()
@@ -403,169 +393,150 @@ impl Render for MessageEditor {
),
)
})
- .when(
- changed_files > 0 && !is_generating && !empty_thread,
- |parent| {
- parent.child(
- edit_files_container()
- .border_b_0()
- .rounded_t_md()
- .shadow(smallvec::smallvec![gpui::BoxShadow {
- color: gpui::black().opacity(0.15),
- offset: point(px(1.), px(-1.)),
- blur_radius: px(3.),
- spread_radius: px(0.),
- }])
- .child(
- h_flex()
- .gap_2()
- .child(Label::new("Edits").size(LabelSize::XSmall))
- .child(div().size_1().rounded_full().bg(border_color))
- .child(
- Label::new(format!(
- "{} {}",
- changed_files,
- if changed_files == 1 { "file" } else { "files" }
- ))
- .size(LabelSize::XSmall),
- ),
- )
- .child(
- h_flex()
- .gap_1()
- .child(
- Button::new("panel", "Open Git Panel")
- .label_size(LabelSize::XSmall)
- .key_binding({
- let focus_handle = focus_handle.clone();
- KeyBinding::for_action_in(
- &git_panel::ToggleFocus,
- &focus_handle,
- window,
- cx,
- )
- .map(|kb| kb.size(rems_from_px(10.)))
- })
- .on_click(|_ev, _window, cx| {
- cx.defer(|cx| {
- cx.dispatch_action(&git_panel::ToggleFocus)
- });
- }),
- )
- .child(
- Button::new("review", "Review Diff")
- .label_size(LabelSize::XSmall)
- .key_binding({
- let focus_handle = focus_handle.clone();
- KeyBinding::for_action_in(
- &git_ui::project_diff::Diff,
- &focus_handle,
- window,
- cx,
- )
- .map(|kb| kb.size(rems_from_px(10.)))
- })
- .on_click(|_event, _window, cx| {
- cx.defer(|cx| {
- cx.dispatch_action(&git_ui::project_diff::Diff)
- });
- }),
- )
- .child(
- Button::new("commit", "Commit Changes")
- .label_size(LabelSize::XSmall)
- .key_binding({
- let focus_handle = focus_handle.clone();
- KeyBinding::for_action_in(
- &ExpandCommitEditor,
- &focus_handle,
- window,
- cx,
- )
- .map(|kb| kb.size(rems_from_px(10.)))
- })
- .on_click(|_event, _window, cx| {
- cx.defer(|cx| {
- cx.dispatch_action(&ExpandCommitEditor)
- });
- }),
- ),
- ),
- )
- },
- )
- .when(
- changed_files > 0 && !is_generating && empty_thread,
- |parent| {
- parent.child(
- edit_files_container()
- .mb_2()
- .rounded_md()
- .child(
- h_flex()
- .gap_2()
- .child(Label::new("Consider committing your changes before starting a fresh thread").size(LabelSize::XSmall))
- .child(div().size_1().rounded_full().bg(border_color))
- .child(
- Label::new(format!(
- "{} {}",
- changed_files,
- if changed_files == 1 { "file" } else { "files" }
- ))
- .size(LabelSize::XSmall),
+ .when(changed_buffers_count > 0, |parent| {
+ parent.child(
+ v_flex()
+ .mx_2()
+ .bg(cx.theme().colors().element_background)
+ .border_1()
+ .border_b_0()
+ .border_color(cx.theme().colors().border)
+ .rounded_t_md()
+ .child(
+ h_flex()
+ .p_2()
+ .justify_between()
+ .child(
+ h_flex()
+ .gap_2()
+ .child(
+ Disclosure::new(
+ "edits-disclosure",
+ self.edits_expanded,
+ )
+ .on_click(
+ cx.listener(|this, _ev, _window, cx| {
+ this.edits_expanded = !this.edits_expanded;
+ cx.notify();
+ }),
+ ),
+ )
+ .child(
+ Label::new("Edits")
+ .size(LabelSize::XSmall)
+ .color(Color::Muted),
+ )
+ .child(
+ Label::new("•")
+ .size(LabelSize::XSmall)
+ .color(Color::Muted),
+ )
+ .child(
+ Label::new(format!(
+ "{} {}",
+ changed_buffers_count,
+ if changed_buffers_count == 1 {
+ "file"
+ } else {
+ "files"
+ }
+ ))
+ .size(LabelSize::XSmall)
+ .color(Color::Muted),
+ ),
+ )
+ .child(
+ Button::new("review", "Review")
+ .label_size(LabelSize::XSmall)
+ .on_click(cx.listener(|this, _, window, cx| {
+ this.handle_review_click(window, cx)
+ })),
+ ),
+ )
+ .when(self.edits_expanded, |parent| {
+ parent.child(
+ v_flex().bg(cx.theme().colors().editor_background).children(
+ changed_buffers.into_iter().enumerate().flat_map(
+ |(index, (buffer, changed))| {
+ let file = buffer.read(cx).file()?;
+ let path = file.path();
+
+ let parent_label = path.parent().and_then(|parent| {
+ let parent_str = parent.to_string_lossy();
+
+ if parent_str.is_empty() {
+ None
+ } else {
+ Some(
+ Label::new(format!(
+ "{}{}",
+ parent_str,
+ std::path::MAIN_SEPARATOR_STR
+ ))
+ .color(Color::Muted)
+ .size(LabelSize::Small),
+ )
+ }
+ });
+
+ let name_label = path.file_name().map(|name| {
+ Label::new(name.to_string_lossy().to_string())
+ .size(LabelSize::Small)
+ });
+
+ let file_icon = FileIcons::get_icon(&path, cx)
+ .map(Icon::from_path)
+ .unwrap_or_else(|| Icon::new(IconName::File));
+
+ let element = div()
+ .p_2()
+ .when(index + 1 < changed_buffers_count, |parent| {
+ parent
+ .border_color(cx.theme().colors().border)
+ .border_b_1()
+ })
+ .child(
+ h_flex()
+ .gap_2()
+ .child(file_icon)
+ .child(
+ // TODO: handle overflow
+ h_flex()
+ .children(parent_label)
+ .children(name_label),
+ )
+ // TODO: show lines changed
+ .child(
+ Label::new("+").color(Color::Created),
+ )
+ .child(
+ Label::new("-").color(Color::Deleted),
+ )
+ .when(!changed.needs_review, |parent| {
+ parent.child(
+ Icon::new(IconName::Check)
+ .color(Color::Success),
+ )
+ }),
+ );
+
+ Some(element)
+ },
),
+ ),
)
- .child(
- h_flex()
- .gap_1()
- .child(
- Button::new("review", "Review Diff")
- .label_size(LabelSize::XSmall)
- .key_binding({
- let focus_handle = focus_handle.clone();
- KeyBinding::for_action_in(
- &git_ui::project_diff::Diff,
- &focus_handle,
- window,
- cx,
- )
- .map(|kb| kb.size(rems_from_px(10.)))
- })
- .on_click(|_event, _window, cx| {
- cx.defer(|cx| {
- cx.dispatch_action(&git_ui::project_diff::Diff)
- });
- }),
- )
- .child(
- Button::new("commit", "Commit Changes")
- .label_size(LabelSize::XSmall)
- .key_binding({
- let focus_handle = focus_handle.clone();
- KeyBinding::for_action_in(
- &ExpandCommitEditor,
- &focus_handle,
- window,
- cx,
- )
- .map(|kb| kb.size(rems_from_px(10.)))
- })
- .on_click(|_event, _window, cx| {
- cx.defer(|cx| {
- cx.dispatch_action(&ExpandCommitEditor)
- });
- }),
- ),
- ),
- )
- },
- )
+ }),
+ )
+ })
.child(
v_flex()
.key_context("MessageEditor")
.on_action(cx.listener(Self::chat))
.on_action(cx.listener(|this, _: &ToggleProfileSelector, window, cx| {
- this.profile_selector.read(cx).menu_handle().toggle(window, cx);
+ this.profile_selector
+ .read(cx)
+ .menu_handle()
+ .toggle(window, cx);
}))
.on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| {
this.model_selector
@@ -1,5 +1,6 @@
use std::fmt::Write as _;
use std::io::Write;
+use std::ops::Range;
use std::sync::Arc;
use anyhow::{Context as _, Result};
@@ -1529,6 +1530,18 @@ impl Thread {
Ok(String::from_utf8_lossy(&markdown).to_string())
}
+ pub fn review_edits_in_range(
+ &mut self,
+ buffer: Entity<language::Buffer>,
+ buffer_range: Range<language::Anchor>,
+ accept: bool,
+ cx: &mut Context<Self>,
+ ) {
+ self.action_log.update(cx, |action_log, cx| {
+ action_log.review_edits_in_range(buffer, buffer_range, accept, cx)
+ });
+ }
+
pub fn action_log(&self) -> &Entity<ActionLog> {
&self.action_log
}
@@ -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"] }
@@ -0,0 +1,968 @@
+use anyhow::{Context as _, Result};
+use buffer_diff::BufferDiff;
+use collections::{BTreeMap, HashMap, HashSet};
+use gpui::{App, AppContext, AsyncApp, Context, Entity, Subscription, Task, WeakEntity};
+use language::{
+ Buffer, BufferEvent, DiskState, OffsetRangeExt, Operation, TextBufferSnapshot, ToOffset,
+};
+use std::{ops::Range, sync::Arc};
+
+/// Tracks actions performed by tools in a thread
+pub struct ActionLog {
+ /// Buffers that user manually added to the context, and whose content has
+ /// changed since the model last saw them.
+ stale_buffers_in_context: HashSet<Entity<Buffer>>,
+ /// Buffers that we want to notify the model about when they change.
+ tracked_buffers: BTreeMap<Entity<Buffer>, TrackedBuffer>,
+ /// Has the model edited a file since it last checked diagnostics?
+ edited_since_project_diagnostics_check: bool,
+}
+
+impl ActionLog {
+ /// Creates a new, empty action log.
+ pub fn new() -> Self {
+ Self {
+ stale_buffers_in_context: HashSet::default(),
+ tracked_buffers: BTreeMap::default(),
+ edited_since_project_diagnostics_check: false,
+ }
+ }
+
+ pub fn clear_reviewed_changes(&mut self, cx: &mut Context<Self>) {
+ self.tracked_buffers
+ .retain(|_buffer, tracked_buffer| match &mut tracked_buffer.change {
+ Change::Edited {
+ accepted_edit_ids, ..
+ } => {
+ accepted_edit_ids.clear();
+ tracked_buffer.schedule_diff_update();
+ true
+ }
+ Change::Deleted { reviewed, .. } => !*reviewed,
+ });
+ cx.notify();
+ }
+
+ /// Notifies a diagnostics check
+ pub fn checked_project_diagnostics(&mut self) {
+ self.edited_since_project_diagnostics_check = false;
+ }
+
+ /// Returns true if any files have been edited since the last project diagnostics check
+ pub fn has_edited_files_since_project_diagnostics_check(&self) -> bool {
+ self.edited_since_project_diagnostics_check
+ }
+
+ fn track_buffer(
+ &mut self,
+ buffer: Entity<Buffer>,
+ created: bool,
+ cx: &mut Context<Self>,
+ ) -> &mut TrackedBuffer {
+ let tracked_buffer = self
+ .tracked_buffers
+ .entry(buffer.clone())
+ .or_insert_with(|| {
+ let text_snapshot = buffer.read(cx).text_snapshot();
+ let unreviewed_diff = cx.new(|cx| BufferDiff::new(&text_snapshot, cx));
+ let diff = cx.new(|cx| {
+ let mut diff = BufferDiff::new(&text_snapshot, cx);
+ diff.set_secondary_diff(unreviewed_diff.clone());
+ diff
+ });
+ let (diff_update_tx, diff_update_rx) = async_watch::channel(());
+ TrackedBuffer {
+ buffer: buffer.clone(),
+ change: Change::Edited {
+ unreviewed_edit_ids: HashSet::default(),
+ accepted_edit_ids: HashSet::default(),
+ initial_content: if created {
+ None
+ } else {
+ Some(text_snapshot.clone())
+ },
+ },
+ version: buffer.read(cx).version(),
+ diff,
+ secondary_diff: unreviewed_diff,
+ diff_update: diff_update_tx,
+ _maintain_diff: cx.spawn({
+ let buffer = buffer.clone();
+ async move |this, cx| {
+ Self::maintain_diff(this, buffer, diff_update_rx, cx)
+ .await
+ .ok();
+ }
+ }),
+ _subscription: cx.subscribe(&buffer, Self::handle_buffer_event),
+ }
+ });
+ tracked_buffer.version = buffer.read(cx).version();
+ tracked_buffer
+ }
+
+ fn handle_buffer_event(
+ &mut self,
+ buffer: Entity<Buffer>,
+ event: &BufferEvent,
+ cx: &mut Context<Self>,
+ ) {
+ match event {
+ BufferEvent::Operation { operation, .. } => {
+ self.handle_buffer_operation(buffer, operation, cx)
+ }
+ BufferEvent::FileHandleChanged => {
+ self.handle_buffer_file_changed(buffer, cx);
+ }
+ _ => {}
+ };
+ }
+
+ fn handle_buffer_operation(
+ &mut self,
+ buffer: Entity<Buffer>,
+ operation: &Operation,
+ cx: &mut Context<Self>,
+ ) {
+ let Some(tracked_buffer) = self.tracked_buffers.get_mut(&buffer) else {
+ return;
+ };
+ let Operation::Buffer(text::Operation::Edit(operation)) = operation else {
+ return;
+ };
+ let Change::Edited {
+ unreviewed_edit_ids,
+ accepted_edit_ids,
+ ..
+ } = &mut tracked_buffer.change
+ else {
+ return;
+ };
+
+ if unreviewed_edit_ids.contains(&operation.timestamp)
+ || accepted_edit_ids.contains(&operation.timestamp)
+ {
+ return;
+ }
+
+ let buffer = buffer.read(cx);
+ let operation_edit_ranges = buffer
+ .edited_ranges_for_edit_ids::<usize>([&operation.timestamp])
+ .collect::<Vec<_>>();
+ let intersects_unreviewed_edits = ranges_intersect(
+ operation_edit_ranges.iter().cloned(),
+ buffer.edited_ranges_for_edit_ids::<usize>(unreviewed_edit_ids.iter()),
+ );
+ let mut intersected_accepted_edits = HashSet::default();
+ for accepted_edit_id in accepted_edit_ids.iter() {
+ let intersects_accepted_edit = ranges_intersect(
+ operation_edit_ranges.iter().cloned(),
+ buffer.edited_ranges_for_edit_ids::<usize>([accepted_edit_id]),
+ );
+ if intersects_accepted_edit {
+ intersected_accepted_edits.insert(*accepted_edit_id);
+ }
+ }
+
+ // If the buffer operation overlaps with any tracked edits, mark it as unreviewed.
+ // If it intersects an already-accepted id, mark that edit as unreviewed again.
+ if intersects_unreviewed_edits || !intersected_accepted_edits.is_empty() {
+ unreviewed_edit_ids.insert(operation.timestamp);
+ for accepted_edit_id in intersected_accepted_edits {
+ unreviewed_edit_ids.insert(accepted_edit_id);
+ accepted_edit_ids.remove(&accepted_edit_id);
+ }
+ tracked_buffer.schedule_diff_update();
+ }
+ }
+
+ fn handle_buffer_file_changed(&mut self, buffer: Entity<Buffer>, cx: &mut Context<Self>) {
+ let Some(tracked_buffer) = self.tracked_buffers.get_mut(&buffer) else {
+ return;
+ };
+
+ match tracked_buffer.change {
+ Change::Deleted { .. } => {
+ if buffer
+ .read(cx)
+ .file()
+ .map_or(false, |file| file.disk_state() != DiskState::Deleted)
+ {
+ // If the buffer had been deleted by a tool, but it got
+ // resurrected externally, we want to clear the changes we
+ // were tracking and reset the buffer's state.
+ tracked_buffer.change = Change::Edited {
+ unreviewed_edit_ids: HashSet::default(),
+ accepted_edit_ids: HashSet::default(),
+ initial_content: Some(buffer.read(cx).text_snapshot()),
+ };
+ }
+ tracked_buffer.schedule_diff_update();
+ }
+ Change::Edited { .. } => {
+ if buffer
+ .read(cx)
+ .file()
+ .map_or(false, |file| file.disk_state() == DiskState::Deleted)
+ {
+ // If the buffer had been edited by a tool, but it got
+ // deleted externally, we want to stop tracking it.
+ self.tracked_buffers.remove(&buffer);
+ } else {
+ tracked_buffer.schedule_diff_update();
+ }
+ }
+ }
+ }
+
+ async fn maintain_diff(
+ this: WeakEntity<Self>,
+ buffer: Entity<Buffer>,
+ mut diff_update: async_watch::Receiver<()>,
+ cx: &mut AsyncApp,
+ ) -> Result<()> {
+ while let Some(_) = diff_update.recv().await.ok() {
+ let update = this.update(cx, |this, cx| {
+ let tracked_buffer = this
+ .tracked_buffers
+ .get_mut(&buffer)
+ .context("buffer not tracked")?;
+ anyhow::Ok(tracked_buffer.update_diff(cx))
+ })??;
+ update.await;
+ this.update(cx, |_this, cx| cx.notify())?;
+ }
+
+ Ok(())
+ }
+
+ /// Track a buffer as read, so we can notify the model about user edits.
+ pub fn buffer_read(&mut self, buffer: Entity<Buffer>, cx: &mut Context<Self>) {
+ self.track_buffer(buffer, false, cx);
+ }
+
+ /// Track a buffer as read, so we can notify the model about user edits.
+ pub fn will_create_buffer(
+ &mut self,
+ buffer: Entity<Buffer>,
+ edit_id: Option<clock::Lamport>,
+ cx: &mut Context<Self>,
+ ) {
+ self.track_buffer(buffer.clone(), true, cx);
+ self.buffer_edited(buffer, edit_id.into_iter().collect(), cx)
+ }
+
+ /// Mark a buffer as edited, so we can refresh it in the context
+ pub fn buffer_edited(
+ &mut self,
+ buffer: Entity<Buffer>,
+ mut edit_ids: Vec<clock::Lamport>,
+ cx: &mut Context<Self>,
+ ) {
+ self.edited_since_project_diagnostics_check = true;
+ self.stale_buffers_in_context.insert(buffer.clone());
+
+ let tracked_buffer = self.track_buffer(buffer.clone(), false, cx);
+
+ match &mut tracked_buffer.change {
+ Change::Edited {
+ unreviewed_edit_ids,
+ ..
+ } => {
+ unreviewed_edit_ids.extend(edit_ids.iter().copied());
+ }
+ Change::Deleted {
+ deleted_content,
+ deletion_id,
+ ..
+ } => {
+ edit_ids.extend(*deletion_id);
+ tracked_buffer.change = Change::Edited {
+ unreviewed_edit_ids: edit_ids.into_iter().collect(),
+ accepted_edit_ids: HashSet::default(),
+ initial_content: Some(deleted_content.clone()),
+ };
+ }
+ }
+
+ tracked_buffer.schedule_diff_update();
+ }
+
+ pub fn will_delete_buffer(&mut self, buffer: Entity<Buffer>, cx: &mut Context<Self>) {
+ let tracked_buffer = self.track_buffer(buffer.clone(), false, cx);
+ if let Change::Edited {
+ initial_content, ..
+ } = &tracked_buffer.change
+ {
+ if let Some(initial_content) = initial_content {
+ let deletion_id = buffer.update(cx, |buffer, cx| buffer.set_text("", cx));
+ tracked_buffer.change = Change::Deleted {
+ reviewed: false,
+ deleted_content: initial_content.clone(),
+ deletion_id,
+ };
+ tracked_buffer.schedule_diff_update();
+ } else {
+ self.tracked_buffers.remove(&buffer);
+ cx.notify();
+ }
+ }
+ }
+
+ /// Accepts edits in a given range within a buffer.
+ pub fn review_edits_in_range<T: ToOffset>(
+ &mut self,
+ buffer: Entity<Buffer>,
+ buffer_range: Range<T>,
+ accept: bool,
+ cx: &mut Context<Self>,
+ ) {
+ let Some(tracked_buffer) = self.tracked_buffers.get_mut(&buffer) else {
+ return;
+ };
+
+ let buffer = buffer.read(cx);
+ let buffer_range = buffer_range.to_offset(buffer);
+
+ match &mut tracked_buffer.change {
+ Change::Deleted { reviewed, .. } => {
+ *reviewed = accept;
+ }
+ Change::Edited {
+ unreviewed_edit_ids,
+ accepted_edit_ids,
+ ..
+ } => {
+ let (source, destination) = if accept {
+ (unreviewed_edit_ids, accepted_edit_ids)
+ } else {
+ (accepted_edit_ids, unreviewed_edit_ids)
+ };
+ source.retain(|edit_id| {
+ for range in buffer.edited_ranges_for_edit_ids::<usize>([edit_id]) {
+ if buffer_range.end >= range.start && buffer_range.start <= range.end {
+ destination.insert(*edit_id);
+ return false;
+ }
+ }
+ true
+ });
+ }
+ }
+
+ tracked_buffer.schedule_diff_update();
+ }
+
+ /// Returns the set of buffers that contain changes that haven't been reviewed by the user.
+ pub fn changed_buffers(&self, cx: &App) -> BTreeMap<Entity<Buffer>, ChangedBuffer> {
+ self.tracked_buffers
+ .iter()
+ .filter(|(_, tracked)| tracked.has_changes(cx))
+ .map(|(buffer, tracked)| {
+ (
+ buffer.clone(),
+ ChangedBuffer {
+ diff: tracked.diff.clone(),
+ needs_review: match &tracked.change {
+ Change::Edited {
+ unreviewed_edit_ids,
+ ..
+ } => !unreviewed_edit_ids.is_empty(),
+ Change::Deleted { reviewed, .. } => !reviewed,
+ },
+ },
+ )
+ })
+ .collect()
+ }
+
+ /// Iterate over buffers changed since last read or edited by the model
+ pub fn stale_buffers<'a>(&'a self, cx: &'a App) -> impl Iterator<Item = &'a Entity<Buffer>> {
+ self.tracked_buffers
+ .iter()
+ .filter(|(buffer, tracked)| tracked.version != buffer.read(cx).version)
+ .map(|(buffer, _)| buffer)
+ }
+
+ /// Takes and returns the set of buffers pending refresh, clearing internal state.
+ pub fn take_stale_buffers_in_context(&mut self) -> HashSet<Entity<Buffer>> {
+ std::mem::take(&mut self.stale_buffers_in_context)
+ }
+}
+
+fn ranges_intersect(
+ ranges_a: impl IntoIterator<Item = Range<usize>>,
+ ranges_b: impl IntoIterator<Item = Range<usize>>,
+) -> bool {
+ let mut ranges_a_iter = ranges_a.into_iter().peekable();
+ let mut ranges_b_iter = ranges_b.into_iter().peekable();
+ while let (Some(range_a), Some(range_b)) = (ranges_a_iter.peek(), ranges_b_iter.peek()) {
+ if range_a.end < range_b.start {
+ ranges_a_iter.next();
+ } else if range_b.end < range_a.start {
+ ranges_b_iter.next();
+ } else {
+ return true;
+ }
+ }
+ false
+}
+
+struct TrackedBuffer {
+ buffer: Entity<Buffer>,
+ change: Change,
+ version: clock::Global,
+ diff: Entity<BufferDiff>,
+ secondary_diff: Entity<BufferDiff>,
+ diff_update: async_watch::Sender<()>,
+ _maintain_diff: Task<()>,
+ _subscription: Subscription,
+}
+
+enum Change {
+ Edited {
+ unreviewed_edit_ids: HashSet<clock::Lamport>,
+ accepted_edit_ids: HashSet<clock::Lamport>,
+ initial_content: Option<TextBufferSnapshot>,
+ },
+ Deleted {
+ reviewed: bool,
+ deleted_content: TextBufferSnapshot,
+ deletion_id: Option<clock::Lamport>,
+ },
+}
+
+impl TrackedBuffer {
+ fn has_changes(&self, cx: &App) -> bool {
+ self.diff
+ .read(cx)
+ .hunks(&self.buffer.read(cx), cx)
+ .next()
+ .is_some()
+ }
+
+ fn schedule_diff_update(&self) {
+ self.diff_update.send(()).ok();
+ }
+
+ fn update_diff(&mut self, cx: &mut App) -> Task<()> {
+ match &self.change {
+ Change::Edited {
+ unreviewed_edit_ids,
+ accepted_edit_ids,
+ ..
+ } => {
+ let edits_to_undo = unreviewed_edit_ids
+ .iter()
+ .chain(accepted_edit_ids)
+ .map(|edit_id| (*edit_id, u32::MAX))
+ .collect::<HashMap<_, _>>();
+ let buffer_without_edits = self.buffer.update(cx, |buffer, cx| buffer.branch(cx));
+ buffer_without_edits
+ .update(cx, |buffer, cx| buffer.undo_operations(edits_to_undo, cx));
+ let primary_diff_update = self.diff.update(cx, |diff, cx| {
+ diff.set_base_text(
+ buffer_without_edits,
+ self.buffer.read(cx).text_snapshot(),
+ cx,
+ )
+ });
+
+ let unreviewed_edits_to_undo = unreviewed_edit_ids
+ .iter()
+ .map(|edit_id| (*edit_id, u32::MAX))
+ .collect::<HashMap<_, _>>();
+ let buffer_without_unreviewed_edits =
+ self.buffer.update(cx, |buffer, cx| buffer.branch(cx));
+ buffer_without_unreviewed_edits.update(cx, |buffer, cx| {
+ buffer.undo_operations(unreviewed_edits_to_undo, cx)
+ });
+ let secondary_diff_update = self.secondary_diff.update(cx, |diff, cx| {
+ diff.set_base_text(
+ buffer_without_unreviewed_edits.clone(),
+ self.buffer.read(cx).text_snapshot(),
+ cx,
+ )
+ });
+
+ cx.background_spawn(async move {
+ _ = primary_diff_update.await;
+ _ = secondary_diff_update.await;
+ })
+ }
+ Change::Deleted {
+ reviewed,
+ deleted_content,
+ ..
+ } => {
+ let reviewed = *reviewed;
+ let deleted_content = deleted_content.clone();
+
+ let primary_diff = self.diff.clone();
+ let secondary_diff = self.secondary_diff.clone();
+ let buffer_snapshot = self.buffer.read(cx).text_snapshot();
+ let language = self.buffer.read(cx).language().cloned();
+ let language_registry = self.buffer.read(cx).language_registry().clone();
+
+ cx.spawn(async move |cx| {
+ let base_text = Arc::new(deleted_content.text());
+
+ let primary_diff_snapshot = BufferDiff::update_diff(
+ primary_diff.clone(),
+ buffer_snapshot.clone(),
+ Some(base_text.clone()),
+ true,
+ false,
+ language.clone(),
+ language_registry.clone(),
+ cx,
+ )
+ .await;
+ let secondary_diff_snapshot = BufferDiff::update_diff(
+ secondary_diff.clone(),
+ buffer_snapshot.clone(),
+ if reviewed {
+ None
+ } else {
+ Some(base_text.clone())
+ },
+ true,
+ false,
+ language.clone(),
+ language_registry.clone(),
+ cx,
+ )
+ .await;
+
+ if let Ok(primary_diff_snapshot) = primary_diff_snapshot {
+ primary_diff
+ .update(cx, |diff, cx| {
+ diff.set_snapshot(
+ &buffer_snapshot,
+ primary_diff_snapshot,
+ false,
+ None,
+ cx,
+ )
+ })
+ .ok();
+ }
+
+ if let Ok(secondary_diff_snapshot) = secondary_diff_snapshot {
+ secondary_diff
+ .update(cx, |diff, cx| {
+ diff.set_snapshot(
+ &buffer_snapshot,
+ secondary_diff_snapshot,
+ false,
+ None,
+ cx,
+ )
+ })
+ .ok();
+ }
+ })
+ }
+ }
+ }
+}
+
+pub struct ChangedBuffer {
+ pub diff: Entity<BufferDiff>,
+ pub needs_review: bool,
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use buffer_diff::DiffHunkStatusKind;
+ use gpui::TestAppContext;
+ use language::Point;
+ use project::{FakeFs, Fs, Project, RemoveOptions};
+ use serde_json::json;
+ use settings::SettingsStore;
+ use util::path;
+
+ #[gpui::test(iterations = 10)]
+ async fn test_edit_review(cx: &mut TestAppContext) {
+ let action_log = cx.new(|_| ActionLog::new());
+ let buffer = cx.new(|cx| Buffer::local("abc\ndef\nghi\njkl\nmno", cx));
+
+ let edit1 = buffer.update(cx, |buffer, cx| {
+ buffer
+ .edit([(Point::new(1, 1)..Point::new(1, 2), "E")], None, cx)
+ .unwrap()
+ });
+ let edit2 = buffer.update(cx, |buffer, cx| {
+ buffer
+ .edit([(Point::new(4, 2)..Point::new(4, 3), "O")], None, cx)
+ .unwrap()
+ });
+ assert_eq!(
+ buffer.read_with(cx, |buffer, _| buffer.text()),
+ "abc\ndEf\nghi\njkl\nmnO"
+ );
+
+ action_log.update(cx, |log, cx| {
+ log.buffer_edited(buffer.clone(), vec![edit1, edit2], cx)
+ });
+ cx.run_until_parked();
+ assert_eq!(
+ unreviewed_hunks(&action_log, cx),
+ vec![(
+ buffer.clone(),
+ vec![
+ HunkStatus {
+ range: Point::new(1, 0)..Point::new(2, 0),
+ review_status: ReviewStatus::Unreviewed,
+ diff_status: DiffHunkStatusKind::Modified,
+ old_text: "def\n".into(),
+ },
+ HunkStatus {
+ range: Point::new(4, 0)..Point::new(4, 3),
+ review_status: ReviewStatus::Unreviewed,
+ diff_status: DiffHunkStatusKind::Modified,
+ old_text: "mno".into(),
+ }
+ ],
+ )]
+ );
+
+ action_log.update(cx, |log, cx| {
+ log.review_edits_in_range(buffer.clone(), Point::new(3, 0)..Point::new(4, 3), true, cx)
+ });
+ cx.run_until_parked();
+ assert_eq!(
+ unreviewed_hunks(&action_log, cx),
+ vec![(
+ buffer.clone(),
+ vec![
+ HunkStatus {
+ range: Point::new(1, 0)..Point::new(2, 0),
+ review_status: ReviewStatus::Unreviewed,
+ diff_status: DiffHunkStatusKind::Modified,
+ old_text: "def\n".into(),
+ },
+ HunkStatus {
+ range: Point::new(4, 0)..Point::new(4, 3),
+ review_status: ReviewStatus::Reviewed,
+ diff_status: DiffHunkStatusKind::Modified,
+ old_text: "mno".into(),
+ }
+ ],
+ )]
+ );
+
+ action_log.update(cx, |log, cx| {
+ log.review_edits_in_range(
+ buffer.clone(),
+ Point::new(3, 0)..Point::new(4, 3),
+ false,
+ cx,
+ )
+ });
+ cx.run_until_parked();
+ assert_eq!(
+ unreviewed_hunks(&action_log, cx),
+ vec![(
+ buffer.clone(),
+ vec![
+ HunkStatus {
+ range: Point::new(1, 0)..Point::new(2, 0),
+ review_status: ReviewStatus::Unreviewed,
+ diff_status: DiffHunkStatusKind::Modified,
+ old_text: "def\n".into(),
+ },
+ HunkStatus {
+ range: Point::new(4, 0)..Point::new(4, 3),
+ review_status: ReviewStatus::Unreviewed,
+ diff_status: DiffHunkStatusKind::Modified,
+ old_text: "mno".into(),
+ }
+ ],
+ )]
+ );
+
+ action_log.update(cx, |log, cx| {
+ log.review_edits_in_range(buffer.clone(), Point::new(0, 0)..Point::new(4, 3), true, cx)
+ });
+ cx.run_until_parked();
+ assert_eq!(
+ unreviewed_hunks(&action_log, cx),
+ vec![(
+ buffer.clone(),
+ vec![
+ HunkStatus {
+ range: Point::new(1, 0)..Point::new(2, 0),
+ review_status: ReviewStatus::Reviewed,
+ diff_status: DiffHunkStatusKind::Modified,
+ old_text: "def\n".into(),
+ },
+ HunkStatus {
+ range: Point::new(4, 0)..Point::new(4, 3),
+ review_status: ReviewStatus::Reviewed,
+ diff_status: DiffHunkStatusKind::Modified,
+ old_text: "mno".into(),
+ }
+ ],
+ )]
+ );
+ }
+
+ #[gpui::test(iterations = 10)]
+ async fn test_overlapping_user_edits(cx: &mut TestAppContext) {
+ let action_log = cx.new(|_| ActionLog::new());
+ let buffer = cx.new(|cx| Buffer::local("abc\ndef\nghi\njkl\nmno", cx));
+
+ let tool_edit = buffer.update(cx, |buffer, cx| {
+ buffer
+ .edit(
+ [(Point::new(0, 2)..Point::new(2, 3), "C\nDEF\nGHI")],
+ None,
+ cx,
+ )
+ .unwrap()
+ });
+ assert_eq!(
+ buffer.read_with(cx, |buffer, _| buffer.text()),
+ "abC\nDEF\nGHI\njkl\nmno"
+ );
+
+ action_log.update(cx, |log, cx| {
+ log.buffer_edited(buffer.clone(), vec![tool_edit], cx)
+ });
+ cx.run_until_parked();
+ assert_eq!(
+ unreviewed_hunks(&action_log, cx),
+ vec![(
+ buffer.clone(),
+ vec![HunkStatus {
+ range: Point::new(0, 0)..Point::new(3, 0),
+ review_status: ReviewStatus::Unreviewed,
+ diff_status: DiffHunkStatusKind::Modified,
+ old_text: "abc\ndef\nghi\n".into(),
+ }],
+ )]
+ );
+
+ action_log.update(cx, |log, cx| {
+ log.review_edits_in_range(buffer.clone(), Point::new(0, 0)..Point::new(1, 0), true, cx)
+ });
+ cx.run_until_parked();
+ assert_eq!(
+ unreviewed_hunks(&action_log, cx),
+ vec![(
+ buffer.clone(),
+ vec![HunkStatus {
+ range: Point::new(0, 0)..Point::new(3, 0),
+ review_status: ReviewStatus::Reviewed,
+ diff_status: DiffHunkStatusKind::Modified,
+ old_text: "abc\ndef\nghi\n".into(),
+ }],
+ )]
+ );
+
+ buffer.update(cx, |buffer, cx| {
+ buffer.edit([(Point::new(0, 2)..Point::new(0, 2), "X")], None, cx)
+ });
+ cx.run_until_parked();
+ assert_eq!(
+ unreviewed_hunks(&action_log, cx),
+ vec![(
+ buffer.clone(),
+ vec![HunkStatus {
+ range: Point::new(0, 0)..Point::new(3, 0),
+ review_status: ReviewStatus::Unreviewed,
+ diff_status: DiffHunkStatusKind::Modified,
+ old_text: "abc\ndef\nghi\n".into(),
+ }],
+ )]
+ );
+
+ action_log.update(cx, |log, cx| log.clear_reviewed_changes(cx));
+ cx.run_until_parked();
+ assert_eq!(
+ unreviewed_hunks(&action_log, cx),
+ vec![(
+ buffer.clone(),
+ vec![HunkStatus {
+ range: Point::new(0, 0)..Point::new(3, 0),
+ review_status: ReviewStatus::Unreviewed,
+ diff_status: DiffHunkStatusKind::Modified,
+ old_text: "abc\ndef\nghi\n".into(),
+ }],
+ )]
+ );
+ }
+
+ #[gpui::test(iterations = 10)]
+ async fn test_deletion(cx: &mut TestAppContext) {
+ cx.update(|cx| {
+ let settings_store = SettingsStore::test(cx);
+ cx.set_global(settings_store);
+ language::init(cx);
+ Project::init_settings(cx);
+ });
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ path!("/dir"),
+ json!({"file1": "lorem\n", "file2": "ipsum\n"}),
+ )
+ .await;
+
+ let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
+ let file1_path = project
+ .read_with(cx, |project, cx| project.find_project_path("dir/file1", cx))
+ .unwrap();
+ let file2_path = project
+ .read_with(cx, |project, cx| project.find_project_path("dir/file2", cx))
+ .unwrap();
+
+ let action_log = cx.new(|_| ActionLog::new());
+ let buffer1 = project
+ .update(cx, |project, cx| {
+ project.open_buffer(file1_path.clone(), cx)
+ })
+ .await
+ .unwrap();
+ let buffer2 = project
+ .update(cx, |project, cx| {
+ project.open_buffer(file2_path.clone(), cx)
+ })
+ .await
+ .unwrap();
+
+ action_log.update(cx, |log, cx| log.will_delete_buffer(buffer1.clone(), cx));
+ action_log.update(cx, |log, cx| log.will_delete_buffer(buffer2.clone(), cx));
+ project
+ .update(cx, |project, cx| {
+ project.delete_file(file1_path.clone(), false, cx)
+ })
+ .unwrap()
+ .await
+ .unwrap();
+ project
+ .update(cx, |project, cx| {
+ project.delete_file(file2_path.clone(), false, cx)
+ })
+ .unwrap()
+ .await
+ .unwrap();
+ cx.run_until_parked();
+ assert_eq!(
+ unreviewed_hunks(&action_log, cx),
+ vec![
+ (
+ buffer1.clone(),
+ vec![HunkStatus {
+ range: Point::new(0, 0)..Point::new(0, 0),
+ review_status: ReviewStatus::Unreviewed,
+ diff_status: DiffHunkStatusKind::Deleted,
+ old_text: "lorem\n".into(),
+ }]
+ ),
+ (
+ buffer2.clone(),
+ vec![HunkStatus {
+ range: Point::new(0, 0)..Point::new(0, 0),
+ review_status: ReviewStatus::Unreviewed,
+ diff_status: DiffHunkStatusKind::Deleted,
+ old_text: "ipsum\n".into(),
+ }],
+ )
+ ]
+ );
+
+ // Simulate file1 being recreated externally.
+ fs.insert_file(path!("/dir/file1"), "LOREM".as_bytes().to_vec())
+ .await;
+ let buffer2 = project
+ .update(cx, |project, cx| project.open_buffer(file2_path, cx))
+ .await
+ .unwrap();
+ cx.run_until_parked();
+ // Simulate file2 being recreated by a tool.
+ let edit_id = buffer2.update(cx, |buffer, cx| buffer.set_text("IPSUM", cx));
+ action_log.update(cx, |log, cx| {
+ log.will_create_buffer(buffer2.clone(), edit_id, cx)
+ });
+ project
+ .update(cx, |project, cx| project.save_buffer(buffer2.clone(), cx))
+ .await
+ .unwrap();
+ cx.run_until_parked();
+ assert_eq!(
+ unreviewed_hunks(&action_log, cx),
+ vec![(
+ buffer2.clone(),
+ vec![HunkStatus {
+ range: Point::new(0, 0)..Point::new(0, 5),
+ review_status: ReviewStatus::Unreviewed,
+ diff_status: DiffHunkStatusKind::Modified,
+ old_text: "ipsum\n".into(),
+ }],
+ )]
+ );
+
+ // Simulate file2 being deleted externally.
+ fs.remove_file(path!("/dir/file2").as_ref(), RemoveOptions::default())
+ .await
+ .unwrap();
+ cx.run_until_parked();
+ assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
+ }
+
+ #[derive(Debug, Clone, PartialEq, Eq)]
+ struct HunkStatus {
+ range: Range<Point>,
+ review_status: ReviewStatus,
+ diff_status: DiffHunkStatusKind,
+ old_text: String,
+ }
+
+ #[derive(Copy, Clone, Debug, PartialEq, Eq)]
+ enum ReviewStatus {
+ Unreviewed,
+ Reviewed,
+ }
+
+ fn unreviewed_hunks(
+ action_log: &Entity<ActionLog>,
+ cx: &TestAppContext,
+ ) -> Vec<(Entity<Buffer>, Vec<HunkStatus>)> {
+ cx.read(|cx| {
+ action_log
+ .read(cx)
+ .changed_buffers(cx)
+ .into_iter()
+ .map(|(buffer, tracked_buffer)| {
+ let snapshot = buffer.read(cx).snapshot();
+ (
+ buffer,
+ tracked_buffer
+ .diff
+ .read(cx)
+ .hunks(&snapshot, cx)
+ .map(|hunk| HunkStatus {
+ review_status: if hunk.status().has_secondary_hunk() {
+ ReviewStatus::Unreviewed
+ } else {
+ ReviewStatus::Reviewed
+ },
+ diff_status: hunk.status().kind,
+ range: hunk.range,
+ old_text: tracked_buffer
+ .diff
+ .read(cx)
+ .base_text()
+ .text_for_range(hunk.diff_base_byte_range)
+ .collect(),
+ })
+ .collect(),
+ )
+ })
+ .collect()
+ })
+ }
+}
@@ -1,17 +1,19 @@
+mod action_log;
mod tool_registry;
mod tool_working_set;
-use std::fmt::{self, Debug, Formatter};
+use std::fmt;
+use std::fmt::Debug;
+use std::fmt::Formatter;
use std::sync::Arc;
use anyhow::Result;
-use collections::{HashMap, HashSet};
-use gpui::{App, Context, Entity, SharedString, Task};
+use gpui::{App, Entity, SharedString, Task};
use icons::IconName;
-use language::Buffer;
use language_model::LanguageModelRequestMessage;
use project::Project;
+pub use crate::action_log::*;
pub use crate::tool_registry::*;
pub use crate::tool_working_set::*;
@@ -71,71 +73,3 @@ impl Debug for dyn Tool {
f.debug_struct("Tool").field("name", &self.name()).finish()
}
}
-
-/// Tracks actions performed by tools in a thread
-#[derive(Debug)]
-pub struct ActionLog {
- /// Buffers that user manually added to the context, and whose content has
- /// changed since the model last saw them.
- stale_buffers_in_context: HashSet<Entity<Buffer>>,
- /// Buffers that we want to notify the model about when they change.
- tracked_buffers: HashMap<Entity<Buffer>, TrackedBuffer>,
- /// Has the model edited a file since it last checked diagnostics?
- edited_since_project_diagnostics_check: bool,
-}
-
-#[derive(Debug, Default)]
-struct TrackedBuffer {
- version: clock::Global,
-}
-
-impl ActionLog {
- /// Creates a new, empty action log.
- pub fn new() -> Self {
- Self {
- stale_buffers_in_context: HashSet::default(),
- tracked_buffers: HashMap::default(),
- edited_since_project_diagnostics_check: false,
- }
- }
-
- /// Track a buffer as read, so we can notify the model about user edits.
- pub fn buffer_read(&mut self, buffer: Entity<Buffer>, cx: &mut Context<Self>) {
- let tracked_buffer = self.tracked_buffers.entry(buffer.clone()).or_default();
- tracked_buffer.version = buffer.read(cx).version();
- }
-
- /// Mark a buffer as edited, so we can refresh it in the context
- pub fn buffer_edited(&mut self, buffers: HashSet<Entity<Buffer>>, cx: &mut Context<Self>) {
- for buffer in &buffers {
- let tracked_buffer = self.tracked_buffers.entry(buffer.clone()).or_default();
- tracked_buffer.version = buffer.read(cx).version();
- }
-
- self.stale_buffers_in_context.extend(buffers);
- self.edited_since_project_diagnostics_check = true;
- }
-
- /// Notifies a diagnostics check
- pub fn checked_project_diagnostics(&mut self) {
- self.edited_since_project_diagnostics_check = false;
- }
-
- /// Iterate over buffers changed since last read or edited by the model
- pub fn stale_buffers<'a>(&'a self, cx: &'a App) -> impl Iterator<Item = &'a Entity<Buffer>> {
- self.tracked_buffers
- .iter()
- .filter(|(buffer, tracked)| tracked.version != buffer.read(cx).version)
- .map(|(buffer, _)| buffer)
- }
-
- /// Returns true if any files have been edited since the last project diagnostics check
- pub fn has_edited_files_since_project_diagnostics_check(&self) -> bool {
- self.edited_since_project_diagnostics_check
- }
-
- /// Takes and returns the set of buffers pending refresh, clearing internal state.
- pub fn take_stale_buffers_in_context(&mut self) -> HashSet<Entity<Buffer>> {
- std::mem::take(&mut self.stale_buffers_in_context)
- }
-}
@@ -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"] }
@@ -70,7 +70,7 @@ impl Tool for CreateFileTool {
input: serde_json::Value,
_messages: &[LanguageModelRequestMessage],
project: Entity<Project>,
- _action_log: Entity<ActionLog>,
+ action_log: Entity<ActionLog>,
cx: &mut App,
) -> Task<Result<String>> {
let input = match serde_json::from_value::<CreateFileToolInput>(input) {
@@ -85,24 +85,20 @@ impl Tool for CreateFileTool {
let destination_path: Arc<str> = input.path.as_str().into();
cx.spawn(async move |cx| {
- project
- .update(cx, |project, cx| {
- project.create_entry(project_path.clone(), false, cx)
- })?
- .await
- .map_err(|err| anyhow!("Unable to create {destination_path}: {err}"))?;
let buffer = project
.update(cx, |project, cx| {
project.open_buffer(project_path.clone(), cx)
})?
.await
.map_err(|err| anyhow!("Unable to open buffer for {destination_path}: {err}"))?;
- buffer.update(cx, |buffer, cx| {
- buffer.set_text(contents, cx);
+ let edit_id = buffer.update(cx, |buffer, cx| buffer.set_text(contents, cx))?;
+
+ action_log.update(cx, |action_log, cx| {
+ action_log.will_create_buffer(buffer.clone(), edit_id, cx)
})?;
project
- .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))?
+ .update(cx, |project, cx| project.save_buffer(buffer, cx))?
.await
.map_err(|err| anyhow!("Unable to save buffer for {destination_path}: {err}"))?;
@@ -1,8 +1,9 @@
use anyhow::{anyhow, Result};
use assistant_tool::{ActionLog, Tool};
+use futures::{channel::mpsc, SinkExt, StreamExt};
use gpui::{App, AppContext, Entity, Task};
use language_model::LanguageModelRequestMessage;
-use project::Project;
+use project::{Project, ProjectPath};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
@@ -60,28 +61,76 @@ impl Tool for DeletePathTool {
input: serde_json::Value,
_messages: &[LanguageModelRequestMessage],
project: Entity<Project>,
- _action_log: Entity<ActionLog>,
+ action_log: Entity<ActionLog>,
cx: &mut App,
) -> Task<Result<String>> {
let path_str = match serde_json::from_value::<DeletePathToolInput>(input) {
Ok(input) => input.path,
Err(err) => return Task::ready(Err(anyhow!(err))),
};
+ let Some(project_path) = project.read(cx).find_project_path(&path_str, cx) else {
+ return Task::ready(Err(anyhow!(
+ "Couldn't delete {path_str} because that path isn't in this project."
+ )));
+ };
- match project
+ let Some(worktree) = project
.read(cx)
- .find_project_path(&path_str, cx)
- .and_then(|path| project.update(cx, |project, cx| project.delete_file(path, false, cx)))
- {
- Some(deletion_task) => cx.background_spawn(async move {
- match deletion_task.await {
+ .worktree_for_id(project_path.worktree_id, cx)
+ else {
+ return Task::ready(Err(anyhow!(
+ "Couldn't delete {path_str} because that path isn't in this project."
+ )));
+ };
+
+ let worktree_snapshot = worktree.read(cx).snapshot();
+ let (mut paths_tx, mut paths_rx) = mpsc::channel(256);
+ cx.background_spawn({
+ let project_path = project_path.clone();
+ async move {
+ for entry in
+ worktree_snapshot.traverse_from_path(true, false, false, &project_path.path)
+ {
+ if !entry.path.starts_with(&project_path.path) {
+ break;
+ }
+ paths_tx
+ .send(ProjectPath {
+ worktree_id: project_path.worktree_id,
+ path: entry.path.clone(),
+ })
+ .await?;
+ }
+ anyhow::Ok(())
+ }
+ })
+ .detach();
+
+ cx.spawn(async move |cx| {
+ while let Some(path) = paths_rx.next().await {
+ if let Ok(buffer) = project
+ .update(cx, |project, cx| project.open_buffer(path, cx))?
+ .await
+ {
+ action_log.update(cx, |action_log, cx| {
+ action_log.will_delete_buffer(buffer.clone(), cx)
+ })?;
+ }
+ }
+
+ let delete = project.update(cx, |project, cx| {
+ project.delete_file(project_path, false, cx)
+ })?;
+
+ match delete {
+ Some(deletion_task) => match deletion_task.await {
Ok(()) => Ok(format!("Deleted {path_str}")),
Err(err) => Err(anyhow!("Failed to delete {path_str}: {err}")),
- }
- }),
- None => Task::ready(Err(anyhow!(
- "Couldn't delete {path_str} because that path isn't in this project."
- ))),
- }
+ },
+ None => Err(anyhow!(
+ "Couldn't delete {path_str} because that path isn't in this project."
+ )),
+ }
+ })
}
}
@@ -173,6 +173,7 @@ enum EditorResponse {
struct AppliedAction {
source: String,
buffer: Entity<language::Buffer>,
+ edit_ids: Vec<clock::Lamport>,
}
#[derive(Debug)]
@@ -340,9 +341,18 @@ impl EditToolRequest {
self.push_search_error(error);
}
DiffResult::Diff(diff) => {
- let _clock = buffer.update(cx, |buffer, cx| buffer.apply_diff(diff, cx))?;
-
- self.push_applied_action(AppliedAction { source, buffer });
+ let edit_ids = buffer.update(cx, |buffer, cx| {
+ buffer.finalize_last_transaction();
+ buffer.apply_diff(diff, false, cx);
+ let transaction = buffer.finalize_last_transaction();
+ transaction.map_or(Vec::new(), |transaction| transaction.edit_ids.clone())
+ })?;
+
+ self.push_applied_action(AppliedAction {
+ source,
+ buffer,
+ edit_ids,
+ });
}
}
@@ -464,7 +474,10 @@ impl EditToolRequest {
let mut changed_buffers = HashSet::default();
for action in applied {
- changed_buffers.insert(action.buffer);
+ changed_buffers.insert(action.buffer.clone());
+ self.action_log.update(cx, |log, cx| {
+ log.buffer_edited(action.buffer, action.edit_ids, cx)
+ })?;
write!(&mut output, "\n\n{}", action.source)?;
}
@@ -474,10 +487,6 @@ impl EditToolRequest {
.await?;
}
- self.action_log
- .update(cx, |log, cx| log.buffer_edited(changed_buffers.clone(), cx))
- .log_err();
-
if !search_errors.is_empty() {
writeln!(
&mut output,
@@ -5,7 +5,7 @@ use language_model::LanguageModelRequestMessage;
use project::Project;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
-use std::{collections::HashSet, path::PathBuf, sync::Arc};
+use std::{path::PathBuf, sync::Arc};
use ui::IconName;
use crate::replace::replace_exact;
@@ -189,20 +189,21 @@ impl Tool for FindReplaceFileTool {
.await;
if let Some(diff) = result {
- buffer.update(cx, |buffer, cx| {
- let _ = buffer.apply_diff(diff, cx);
+ let edit_ids = buffer.update(cx, |buffer, cx| {
+ buffer.finalize_last_transaction();
+ buffer.apply_diff(diff, false, cx);
+ let transaction = buffer.finalize_last_transaction();
+ transaction.map_or(Vec::new(), |transaction| transaction.edit_ids.clone())
})?;
- project.update(cx, |project, cx| {
- project.save_buffer(buffer.clone(), cx)
- })?.await?;
-
action_log.update(cx, |log, cx| {
- let mut buffers = HashSet::default();
- buffers.insert(buffer);
- log.buffer_edited(buffers, cx);
+ log.buffer_edited(buffer.clone(), edit_ids, cx)
})?;
+ project.update(cx, |project, cx| {
+ project.save_buffer(buffer, cx)
+ })?.await?;
+
Ok(format!("Edited {}", input.path.display()))
} else {
let err = buffer.read_with(cx, |buffer, _cx| {
@@ -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()
})
})
@@ -185,8 +185,8 @@ use theme::{
ThemeColors, ThemeSettings,
};
use ui::{
- h_flex, prelude::*, ButtonSize, ButtonStyle, Disclosure, IconButton, IconName, IconSize, Key,
- Tooltip,
+ h_flex, prelude::*, ButtonSize, ButtonStyle, Disclosure, IconButton, IconButtonShape, IconName,
+ IconSize, Key, Tooltip,
};
use util::{maybe, post_inc, RangeExt, ResultExt, TryFutureExt};
use workspace::{
@@ -220,6 +220,18 @@ pub(crate) const EDIT_PREDICTION_KEY_CONTEXT: &str = "edit_prediction";
pub(crate) const EDIT_PREDICTION_CONFLICT_KEY_CONTEXT: &str = "edit_prediction_conflict";
pub(crate) const MIN_LINE_NUMBER_DIGITS: u32 = 4;
+pub type RenderDiffHunkControlsFn = Arc<
+ dyn Fn(
+ u32,
+ &DiffHunkStatus,
+ Range<Anchor>,
+ bool,
+ Pixels,
+ &Entity<Editor>,
+ &mut App,
+ ) -> AnyElement,
+>;
+
const COLUMNAR_SELECTION_MODIFIERS: Modifiers = Modifiers {
alt: true,
shift: true,
@@ -752,6 +764,7 @@ pub struct Editor {
show_git_blame_inline_delay_task: Option<Task<()>>,
git_blame_inline_tooltip: Option<WeakEntity<crate::commit_tooltip::CommitTooltip>>,
git_blame_inline_enabled: bool,
+ render_diff_hunk_controls: RenderDiffHunkControlsFn,
serialize_dirty_buffers: bool,
show_selection_menu: Option<bool>,
blame: Option<Entity<GitBlame>>,
@@ -1527,6 +1540,7 @@ impl Editor {
show_git_blame_inline_delay_task: None,
git_blame_inline_tooltip: None,
git_blame_inline_enabled: ProjectSettings::get_global(cx).git.inline_blame_enabled(),
+ render_diff_hunk_controls: Arc::new(render_diff_hunk_controls),
serialize_dirty_buffers: ProjectSettings::get_global(cx)
.session
.restore_unsaved_buffers,
@@ -8399,7 +8413,7 @@ impl Editor {
self.restore_hunks_in_ranges(selections, window, cx);
}
- fn restore_hunks_in_ranges(
+ pub fn restore_hunks_in_ranges(
&mut self,
ranges: Vec<Range<Point>>,
window: &mut Window,
@@ -12623,7 +12637,7 @@ impl Editor {
);
}
- fn go_to_hunk_before_or_after_position(
+ pub fn go_to_hunk_before_or_after_position(
&mut self,
snapshot: &EditorSnapshot,
position: Point,
@@ -14786,6 +14800,15 @@ impl Editor {
self.stage_or_unstage_diff_hunks(stage, ranges, cx);
}
+ pub fn set_render_diff_hunk_controls(
+ &mut self,
+ render_diff_hunk_controls: RenderDiffHunkControlsFn,
+ cx: &mut Context<Self>,
+ ) {
+ self.render_diff_hunk_controls = render_diff_hunk_controls;
+ cx.notify();
+ }
+
pub fn stage_and_next(
&mut self,
_: &::git::StageAndNext,
@@ -19913,3 +19936,187 @@ impl From<Background> for LineHighlight {
}
}
}
+
+fn render_diff_hunk_controls(
+ row: u32,
+ status: &DiffHunkStatus,
+ hunk_range: Range<Anchor>,
+ is_created_file: bool,
+ line_height: Pixels,
+ editor: &Entity<Editor>,
+ cx: &mut App,
+) -> AnyElement {
+ h_flex()
+ .h(line_height)
+ .mr_1()
+ .gap_1()
+ .px_0p5()
+ .pb_1()
+ .border_x_1()
+ .border_b_1()
+ .border_color(cx.theme().colors().border_variant)
+ .rounded_b_lg()
+ .bg(cx.theme().colors().editor_background)
+ .gap_1()
+ .occlude()
+ .shadow_md()
+ .child(if status.has_secondary_hunk() {
+ Button::new(("stage", row as u64), "Stage")
+ .alpha(if status.is_pending() { 0.66 } else { 1.0 })
+ .tooltip({
+ let focus_handle = editor.focus_handle(cx);
+ move |window, cx| {
+ Tooltip::for_action_in(
+ "Stage Hunk",
+ &::git::ToggleStaged,
+ &focus_handle,
+ window,
+ cx,
+ )
+ }
+ })
+ .on_click({
+ let editor = editor.clone();
+ move |_event, _window, cx| {
+ editor.update(cx, |editor, cx| {
+ editor.stage_or_unstage_diff_hunks(
+ true,
+ vec![hunk_range.start..hunk_range.start],
+ cx,
+ );
+ });
+ }
+ })
+ } else {
+ Button::new(("unstage", row as u64), "Unstage")
+ .alpha(if status.is_pending() { 0.66 } else { 1.0 })
+ .tooltip({
+ let focus_handle = editor.focus_handle(cx);
+ move |window, cx| {
+ Tooltip::for_action_in(
+ "Unstage Hunk",
+ &::git::ToggleStaged,
+ &focus_handle,
+ window,
+ cx,
+ )
+ }
+ })
+ .on_click({
+ let editor = editor.clone();
+ move |_event, _window, cx| {
+ editor.update(cx, |editor, cx| {
+ editor.stage_or_unstage_diff_hunks(
+ false,
+ vec![hunk_range.start..hunk_range.start],
+ cx,
+ );
+ });
+ }
+ })
+ })
+ .child(
+ Button::new("restore", "Restore")
+ .tooltip({
+ let focus_handle = editor.focus_handle(cx);
+ move |window, cx| {
+ Tooltip::for_action_in(
+ "Restore Hunk",
+ &::git::Restore,
+ &focus_handle,
+ window,
+ cx,
+ )
+ }
+ })
+ .on_click({
+ let editor = editor.clone();
+ move |_event, window, cx| {
+ editor.update(cx, |editor, cx| {
+ let snapshot = editor.snapshot(window, cx);
+ let point = hunk_range.start.to_point(&snapshot.buffer_snapshot);
+ editor.restore_hunks_in_ranges(vec![point..point], window, cx);
+ });
+ }
+ })
+ .disabled(is_created_file),
+ )
+ .when(
+ !editor.read(cx).buffer().read(cx).all_diff_hunks_expanded(),
+ |el| {
+ el.child(
+ IconButton::new(("next-hunk", row as u64), IconName::ArrowDown)
+ .shape(IconButtonShape::Square)
+ .icon_size(IconSize::Small)
+ // .disabled(!has_multiple_hunks)
+ .tooltip({
+ let focus_handle = editor.focus_handle(cx);
+ move |window, cx| {
+ Tooltip::for_action_in(
+ "Next Hunk",
+ &GoToHunk,
+ &focus_handle,
+ window,
+ cx,
+ )
+ }
+ })
+ .on_click({
+ let editor = editor.clone();
+ move |_event, window, cx| {
+ editor.update(cx, |editor, cx| {
+ let snapshot = editor.snapshot(window, cx);
+ let position =
+ hunk_range.end.to_point(&snapshot.buffer_snapshot);
+ editor.go_to_hunk_before_or_after_position(
+ &snapshot,
+ position,
+ Direction::Next,
+ window,
+ cx,
+ );
+ editor.expand_selected_diff_hunks(cx);
+ });
+ }
+ }),
+ )
+ .child(
+ IconButton::new(("prev-hunk", row as u64), IconName::ArrowUp)
+ .shape(IconButtonShape::Square)
+ .icon_size(IconSize::Small)
+ // .disabled(!has_multiple_hunks)
+ .tooltip({
+ let focus_handle = editor.focus_handle(cx);
+ move |window, cx| {
+ Tooltip::for_action_in(
+ "Previous Hunk",
+ &GoToPreviousHunk,
+ &focus_handle,
+ window,
+ cx,
+ )
+ }
+ })
+ .on_click({
+ let editor = editor.clone();
+ move |_event, window, cx| {
+ editor.update(cx, |editor, cx| {
+ let snapshot = editor.snapshot(window, cx);
+ let point =
+ hunk_range.start.to_point(&snapshot.buffer_snapshot);
+ editor.go_to_hunk_before_or_after_position(
+ &snapshot,
+ point,
+ Direction::Prev,
+ window,
+ cx,
+ );
+ editor.expand_selected_diff_hunks(cx);
+ });
+ }
+ }),
+ )
+ },
+ )
+ .into_any_element()
+}
@@ -18,13 +18,13 @@ use crate::{
scroll::scroll_amount::ScrollAmount,
BlockId, ChunkReplacement, ContextMenuPlacement, CursorShape, CustomBlockId, DisplayDiffHunk,
DisplayPoint, DisplayRow, DocumentHighlightRead, DocumentHighlightWrite, EditDisplayMode,
- Editor, EditorMode, EditorSettings, EditorSnapshot, EditorStyle, FocusedBlock, GoToHunk,
- GoToPreviousHunk, GutterDimensions, HalfPageDown, HalfPageUp, HandleInput, HoveredCursor,
- InlayHintRefreshReason, InlineCompletion, JumpData, LineDown, LineHighlight, LineUp,
- OpenExcerpts, PageDown, PageUp, Point, RowExt, RowRangeExt, SelectPhase, SelectedTextHighlight,
- Selection, SoftWrap, StickyHeaderExcerpt, ToPoint, ToggleFold, COLUMNAR_SELECTION_MODIFIERS,
- CURSORS_VISIBLE_FOR, FILE_HEADER_HEIGHT, GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED, MAX_LINE_LEN,
- MIN_LINE_NUMBER_DIGITS, MULTI_BUFFER_EXCERPT_HEADER_HEIGHT,
+ Editor, EditorMode, EditorSettings, EditorSnapshot, EditorStyle, FocusedBlock,
+ GutterDimensions, HalfPageDown, HalfPageUp, HandleInput, HoveredCursor, InlayHintRefreshReason,
+ InlineCompletion, JumpData, LineDown, LineHighlight, LineUp, OpenExcerpts, PageDown, PageUp,
+ Point, RowExt, RowRangeExt, SelectPhase, SelectedTextHighlight, Selection, SoftWrap,
+ StickyHeaderExcerpt, ToPoint, ToggleFold, COLUMNAR_SELECTION_MODIFIERS, CURSORS_VISIBLE_FOR,
+ FILE_HEADER_HEIGHT, GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED, MAX_LINE_LEN, MIN_LINE_NUMBER_DIGITS,
+ MULTI_BUFFER_EXCERPT_HEADER_HEIGHT,
};
use buffer_diff::{DiffHunkStatus, DiffHunkStatusKind};
use client::ParticipantIndex;
@@ -43,7 +43,6 @@ use gpui::{
ScrollWheelEvent, ShapedLine, SharedString, Size, StatefulInteractiveElement, Style, Styled,
Subscription, TextRun, TextStyleRefinement, Window,
};
-use inline_completion::Direction;
use itertools::Itertools;
use language::{
language_settings::{
@@ -76,10 +75,7 @@ use std::{
use sum_tree::Bias;
use text::BufferId;
use theme::{ActiveTheme, Appearance, BufferLineHeight, PlayerColor};
-use ui::{
- h_flex, prelude::*, ButtonLike, ContextMenu, IconButtonShape, KeyBinding, Tooltip,
- POPOVER_Y_PADDING,
-};
+use ui::{h_flex, prelude::*, ButtonLike, ContextMenu, KeyBinding, Tooltip, POPOVER_Y_PADDING};
use unicode_segmentation::UnicodeSegmentation;
use util::{debug_panic, RangeExt, ResultExt};
use workspace::{item::Item, notifications::NotifyTaskExt};
@@ -3919,6 +3915,7 @@ impl EditorElement {
window: &mut Window,
cx: &mut App,
) -> Vec<AnyElement> {
+ let render_diff_hunk_controls = editor.read(cx).render_diff_hunk_controls.clone();
let point_for_position = position_map.point_for_position(window.mouse_position());
let mut controls = vec![];
@@ -3961,7 +3958,7 @@ impl EditorElement {
+ text_hitbox.bounds.top()
- scroll_pixel_position.y;
- let mut element = diff_hunk_controls(
+ let mut element = render_diff_hunk_controls(
display_row_range.start.0,
status,
multi_buffer_range.clone(),
@@ -8882,187 +8879,3 @@ mod tests {
.collect()
}
}
-
-fn diff_hunk_controls(
- row: u32,
- status: &DiffHunkStatus,
- hunk_range: Range<Anchor>,
- is_created_file: bool,
- line_height: Pixels,
- editor: &Entity<Editor>,
- cx: &mut App,
-) -> AnyElement {
- h_flex()
- .h(line_height)
- .mr_1()
- .gap_1()
- .px_0p5()
- .pb_1()
- .border_x_1()
- .border_b_1()
- .border_color(cx.theme().colors().border_variant)
- .rounded_b_lg()
- .bg(cx.theme().colors().editor_background)
- .gap_1()
- .occlude()
- .shadow_md()
- .child(if status.has_secondary_hunk() {
- Button::new(("stage", row as u64), "Stage")
- .alpha(if status.is_pending() { 0.66 } else { 1.0 })
- .tooltip({
- let focus_handle = editor.focus_handle(cx);
- move |window, cx| {
- Tooltip::for_action_in(
- "Stage Hunk",
- &::git::ToggleStaged,
- &focus_handle,
- window,
- cx,
- )
- }
- })
- .on_click({
- let editor = editor.clone();
- move |_event, _window, cx| {
- editor.update(cx, |editor, cx| {
- editor.stage_or_unstage_diff_hunks(
- true,
- vec![hunk_range.start..hunk_range.start],
- cx,
- );
- });
- }
- })
- } else {
- Button::new(("unstage", row as u64), "Unstage")
- .alpha(if status.is_pending() { 0.66 } else { 1.0 })
- .tooltip({
- let focus_handle = editor.focus_handle(cx);
- move |window, cx| {
- Tooltip::for_action_in(
- "Unstage Hunk",
- &::git::ToggleStaged,
- &focus_handle,
- window,
- cx,
- )
- }
- })
- .on_click({
- let editor = editor.clone();
- move |_event, _window, cx| {
- editor.update(cx, |editor, cx| {
- editor.stage_or_unstage_diff_hunks(
- false,
- vec![hunk_range.start..hunk_range.start],
- cx,
- );
- });
- }
- })
- })
- .child(
- Button::new("restore", "Restore")
- .tooltip({
- let focus_handle = editor.focus_handle(cx);
- move |window, cx| {
- Tooltip::for_action_in(
- "Restore Hunk",
- &::git::Restore,
- &focus_handle,
- window,
- cx,
- )
- }
- })
- .on_click({
- let editor = editor.clone();
- move |_event, window, cx| {
- editor.update(cx, |editor, cx| {
- let snapshot = editor.snapshot(window, cx);
- let point = hunk_range.start.to_point(&snapshot.buffer_snapshot);
- editor.restore_hunks_in_ranges(vec![point..point], window, cx);
- });
- }
- })
- .disabled(is_created_file),
- )
- .when(
- !editor.read(cx).buffer().read(cx).all_diff_hunks_expanded(),
- |el| {
- el.child(
- IconButton::new(("next-hunk", row as u64), IconName::ArrowDown)
- .shape(IconButtonShape::Square)
- .icon_size(IconSize::Small)
- // .disabled(!has_multiple_hunks)
- .tooltip({
- let focus_handle = editor.focus_handle(cx);
- move |window, cx| {
- Tooltip::for_action_in(
- "Next Hunk",
- &GoToHunk,
- &focus_handle,
- window,
- cx,
- )
- }
- })
- .on_click({
- let editor = editor.clone();
- move |_event, window, cx| {
- editor.update(cx, |editor, cx| {
- let snapshot = editor.snapshot(window, cx);
- let position =
- hunk_range.end.to_point(&snapshot.buffer_snapshot);
- editor.go_to_hunk_before_or_after_position(
- &snapshot,
- position,
- Direction::Next,
- window,
- cx,
- );
- editor.expand_selected_diff_hunks(cx);
- });
- }
- }),
- )
- .child(
- IconButton::new(("prev-hunk", row as u64), IconName::ArrowUp)
- .shape(IconButtonShape::Square)
- .icon_size(IconSize::Small)
- // .disabled(!has_multiple_hunks)
- .tooltip({
- let focus_handle = editor.focus_handle(cx);
- move |window, cx| {
- Tooltip::for_action_in(
- "Previous Hunk",
- &GoToPreviousHunk,
- &focus_handle,
- window,
- cx,
- )
- }
- })
- .on_click({
- let editor = editor.clone();
- move |_event, window, cx| {
- editor.update(cx, |editor, cx| {
- let snapshot = editor.snapshot(window, cx);
- let point =
- hunk_range.start.to_point(&snapshot.buffer_snapshot);
- editor.go_to_hunk_before_or_after_position(
- &snapshot,
- point,
- Direction::Prev,
- window,
- cx,
- );
- editor.expand_selected_diff_hunks(cx);
- });
- }
- }),
- )
- },
- )
- .into_any_element()
-}
@@ -5,8 +5,8 @@ use futures::future::{self, BoxFuture};
use git::{
blame::Blame,
repository::{
- AskPassSession, Branch, CommitDetails, GitIndex, GitRepository, GitRepositoryCheckpoint,
- PushOptions, Remote, RepoPath, ResetMode,
+ AskPassSession, Branch, CommitDetails, GitRepository, GitRepositoryCheckpoint, PushOptions,
+ Remote, RepoPath, ResetMode,
},
status::{FileStatus, GitStatus, StatusCode, TrackedStatus, UnmergedStatus},
};
@@ -81,15 +81,7 @@ impl FakeGitRepository {
impl GitRepository for FakeGitRepository {
fn reload_index(&self) {}
- fn load_index_text(
- &self,
- index: Option<GitIndex>,
- path: RepoPath,
- ) -> BoxFuture<Option<String>> {
- if index.is_some() {
- unimplemented!();
- }
-
+ fn load_index_text(&self, path: RepoPath) -> BoxFuture<Option<String>> {
async {
self.with_state_async(false, move |state| {
state
@@ -179,19 +171,6 @@ impl GitRepository for FakeGitRepository {
self.path()
}
- fn status(
- &self,
- index: Option<GitIndex>,
- path_prefixes: &[RepoPath],
- ) -> BoxFuture<'static, Result<GitStatus>> {
- if index.is_some() {
- unimplemented!();
- }
-
- let status = self.status_blocking(path_prefixes);
- async move { status }.boxed()
- }
-
fn status_blocking(&self, path_prefixes: &[RepoPath]) -> Result<GitStatus> {
let workdir_path = self.dot_git_path.parent().unwrap();
@@ -457,12 +436,4 @@ impl GitRepository for FakeGitRepository {
) -> BoxFuture<Result<String>> {
unimplemented!()
}
-
- fn create_index(&self) -> BoxFuture<Result<GitIndex>> {
- unimplemented!()
- }
-
- fn apply_diff(&self, _index: GitIndex, _diff: String) -> BoxFuture<Result<()>> {
- unimplemented!()
- }
}
@@ -12,6 +12,7 @@ use schemars::JsonSchema;
use serde::Deserialize;
use std::borrow::{Borrow, Cow};
use std::ffi::{OsStr, OsString};
+use std::future;
use std::path::Component;
use std::process::{ExitStatus, Stdio};
use std::sync::LazyLock;
@@ -20,7 +21,6 @@ use std::{
path::{Path, PathBuf},
sync::Arc,
};
-use std::{future, mem};
use sum_tree::MapSeekTarget;
use thiserror::Error;
use util::command::{new_smol_command, new_std_command};
@@ -161,8 +161,7 @@ pub trait GitRepository: Send + Sync {
/// Returns the contents of an entry in the repository's index, or None if there is no entry for the given path.
///
/// Also returns `None` for symlinks.
- fn load_index_text(&self, index: Option<GitIndex>, path: RepoPath)
- -> BoxFuture<Option<String>>;
+ fn load_index_text(&self, path: RepoPath) -> BoxFuture<Option<String>>;
/// Returns the contents of an entry in the repository's HEAD, or None if HEAD does not exist or has no entry for the given path.
///
@@ -184,11 +183,6 @@ pub trait GitRepository: Send + Sync {
fn merge_head_shas(&self) -> Vec<String>;
- fn status(
- &self,
- index: Option<GitIndex>,
- path_prefixes: &[RepoPath],
- ) -> BoxFuture<'static, Result<GitStatus>>;
fn status_blocking(&self, path_prefixes: &[RepoPath]) -> Result<GitStatus>;
fn branches(&self) -> BoxFuture<Result<Vec<Branch>>>;
@@ -312,12 +306,6 @@ pub trait GitRepository: Send + Sync {
base_checkpoint: GitRepositoryCheckpoint,
target_checkpoint: GitRepositoryCheckpoint,
) -> BoxFuture<Result<String>>;
-
- /// Creates a new index for the repository.
- fn create_index(&self) -> BoxFuture<Result<GitIndex>>;
-
- /// Applies a diff to the repository's index.
- fn apply_diff(&self, index: GitIndex, diff: String) -> BoxFuture<Result<()>>;
}
pub enum DiffType {
@@ -374,11 +362,6 @@ pub struct GitRepositoryCheckpoint {
commit_sha: Oid,
}
-#[derive(Copy, Clone, Debug)]
-pub struct GitIndex {
- id: Uuid,
-}
-
impl GitRepository for RealGitRepository {
fn reload_index(&self) {
if let Ok(mut index) = self.repository.lock().index() {
@@ -484,82 +467,35 @@ impl GitRepository for RealGitRepository {
.boxed()
}
- fn load_index_text(
- &self,
- index: Option<GitIndex>,
- path: RepoPath,
- ) -> BoxFuture<Option<String>> {
- let working_directory = self.working_directory();
- let git_binary_path = self.git_binary_path.clone();
- let executor = self.executor.clone();
+ fn load_index_text(&self, path: RepoPath) -> BoxFuture<Option<String>> {
+ // https://git-scm.com/book/en/v2/Git-Internals-Git-Objects
+ const GIT_MODE_SYMLINK: u32 = 0o120000;
+
+ let repo = self.repository.clone();
self.executor
.spawn(async move {
- match check_path_to_repo_path_errors(&path) {
- Ok(_) => {}
- Err(err) => {
- log::error!("Error with repo path: {:?}", err);
- return None;
- }
- }
-
- let working_directory = match working_directory {
- Ok(dir) => dir,
- Err(err) => {
- log::error!("Error getting working directory: {:?}", err);
- return None;
- }
- };
+ fn logic(repo: &git2::Repository, path: &RepoPath) -> Result<Option<String>> {
+ // This check is required because index.get_path() unwraps internally :(
+ check_path_to_repo_path_errors(path)?;
- let mut git = GitBinary::new(git_binary_path, working_directory, executor);
- let text = git
- .with_option_index(index, async |git| {
- // First check if the file is a symlink using ls-files
- let ls_files_output = git
- .run(&[
- OsStr::new("ls-files"),
- OsStr::new("--stage"),
- path.to_unix_style().as_ref(),
- ])
- .await
- .context("error running ls-files")?;
-
- // Parse ls-files output to check if it's a symlink
- // Format is: "100644 <sha> 0 <filename>" where 100644 is the mode
- if ls_files_output.is_empty() {
- return Ok(None); // File not in index
- }
+ let mut index = repo.index()?;
+ index.read(false)?;
- let parts: Vec<&str> = ls_files_output.split_whitespace().collect();
- if parts.len() < 2 {
- return Err(anyhow!(
- "unexpected ls-files output format: {}",
- ls_files_output
- ));
- }
-
- // Check if it's a symlink (120000 mode)
- if parts[0] == "120000" {
- return Ok(None);
- }
-
- let sha = parts[1];
+ const STAGE_NORMAL: i32 = 0;
+ let oid = match index.get_path(path, STAGE_NORMAL) {
+ Some(entry) if entry.mode != GIT_MODE_SYMLINK => entry.id,
+ _ => return Ok(None),
+ };
- // Now get the content
- Ok(Some(
- git.run_raw(&["cat-file", "blob", sha])
- .await
- .context("error getting blob content")?,
- ))
- })
- .await;
+ let content = repo.find_blob(oid)?.content().to_owned();
+ Ok(Some(String::from_utf8(content)?))
+ }
- match text {
- Ok(text) => text,
- Err(error) => {
- log::error!("Error getting text: {}", error);
- None
- }
+ match logic(&repo.lock(), &path) {
+ Ok(value) => return value,
+ Err(err) => log::error!("Error loading index text: {:?}", err),
}
+ None
})
.boxed()
}
@@ -678,40 +614,6 @@ impl GitRepository for RealGitRepository {
shas
}
- fn status(
- &self,
- index: Option<GitIndex>,
- path_prefixes: &[RepoPath],
- ) -> BoxFuture<'static, Result<GitStatus>> {
- let working_directory = self.working_directory();
- let git_binary_path = self.git_binary_path.clone();
- let executor = self.executor.clone();
- let mut args = vec![
- OsString::from("--no-optional-locks"),
- OsString::from("status"),
- OsString::from("--porcelain=v1"),
- OsString::from("--untracked-files=all"),
- OsString::from("--no-renames"),
- OsString::from("-z"),
- ];
- args.extend(path_prefixes.iter().map(|path_prefix| {
- if path_prefix.0.as_ref() == Path::new("") {
- Path::new(".").into()
- } else {
- path_prefix.as_os_str().into()
- }
- }));
- self.executor
- .spawn(async move {
- let working_directory = working_directory?;
- let mut git = GitBinary::new(git_binary_path, working_directory, executor);
- git.with_option_index(index, async |git| git.run(&args).await)
- .await?
- .parse()
- })
- .boxed()
- }
-
fn status_blocking(&self, path_prefixes: &[RepoPath]) -> Result<GitStatus> {
let output = new_std_command(&self.git_binary_path)
.current_dir(self.working_directory()?)
@@ -1319,41 +1221,6 @@ impl GitRepository for RealGitRepository {
})
.boxed()
}
-
- fn create_index(&self) -> BoxFuture<Result<GitIndex>> {
- let working_directory = self.working_directory();
- let git_binary_path = self.git_binary_path.clone();
-
- let executor = self.executor.clone();
- self.executor
- .spawn(async move {
- let working_directory = working_directory?;
- let mut git = GitBinary::new(git_binary_path, working_directory, executor);
- let index = GitIndex { id: Uuid::new_v4() };
- git.with_index(index, async move |git| git.run(&["add", "--all"]).await)
- .await?;
- Ok(index)
- })
- .boxed()
- }
-
- fn apply_diff(&self, index: GitIndex, diff: String) -> BoxFuture<Result<()>> {
- let working_directory = self.working_directory();
- let git_binary_path = self.git_binary_path.clone();
-
- let executor = self.executor.clone();
- self.executor
- .spawn(async move {
- let working_directory = working_directory?;
- let mut git = GitBinary::new(git_binary_path, working_directory, executor);
- git.with_index(index, async move |git| {
- git.run_with_stdin(&["apply", "--cached", "-"], diff).await
- })
- .await?;
- Ok(())
- })
- .boxed()
- }
}
fn git_status_args(path_prefixes: &[RepoPath]) -> Vec<OsString> {
@@ -1407,7 +1274,7 @@ impl GitBinary {
&mut self,
f: impl AsyncFnOnce(&Self) -> Result<R>,
) -> Result<R> {
- let index_file_path = self.path_for_index(GitIndex { id: Uuid::new_v4() });
+ let index_file_path = self.path_for_index_id(Uuid::new_v4());
let delete_temp_index = util::defer({
let index_file_path = index_file_path.clone();
@@ -1432,30 +1299,10 @@ impl GitBinary {
Ok(result)
}
- pub async fn with_index<R>(
- &mut self,
- index: GitIndex,
- f: impl AsyncFnOnce(&Self) -> Result<R>,
- ) -> Result<R> {
- self.with_option_index(Some(index), f).await
- }
-
- pub async fn with_option_index<R>(
- &mut self,
- index: Option<GitIndex>,
- f: impl AsyncFnOnce(&Self) -> Result<R>,
- ) -> Result<R> {
- let new_index_path = index.map(|index| self.path_for_index(index));
- let old_index_path = mem::replace(&mut self.index_file_path, new_index_path);
- let result = f(self).await;
- self.index_file_path = old_index_path;
- result
- }
-
- fn path_for_index(&self, index: GitIndex) -> PathBuf {
+ fn path_for_index_id(&self, id: Uuid) -> PathBuf {
self.working_directory
.join(".git")
- .join(format!("index-{}.tmp", index.id))
+ .join(format!("index-{}.tmp", id))
}
pub async fn run<S>(&self, args: impl IntoIterator<Item = S>) -> Result<String>
@@ -1486,26 +1333,6 @@ impl GitBinary {
}
}
- pub async fn run_with_stdin(&self, args: &[&str], stdin: String) -> Result<String> {
- let mut command = self.build_command(args);
- command.stdin(Stdio::piped());
- let mut child = command.spawn()?;
-
- let mut child_stdin = child.stdin.take().context("failed to write to stdin")?;
- child_stdin.write_all(stdin.as_bytes()).await?;
- drop(child_stdin);
-
- let output = child.output().await?;
- if output.status.success() {
- Ok(String::from_utf8(output.stdout)?.trim_end().to_string())
- } else {
- Err(anyhow!(GitBinaryCommandError {
- stdout: String::from_utf8_lossy(&output.stdout).to_string(),
- status: output.status,
- }))
- }
- }
-
fn build_command<S>(&self, args: impl IntoIterator<Item = S>) -> smol::process::Command
where
S: AsRef<OsStr>,
@@ -1787,9 +1614,8 @@ fn checkpoint_author_envs() -> HashMap<String, String> {
#[cfg(test)]
mod tests {
use super::*;
- use crate::status::{FileStatus, StatusCode, TrackedStatus};
+ use crate::status::FileStatus;
use gpui::TestAppContext;
- use unindent::Unindent;
#[gpui::test]
async fn test_checkpoint_basic(cx: &mut TestAppContext) {
@@ -1969,7 +1795,7 @@ mod tests {
"content2"
);
assert_eq!(
- repo.status(None, &[]).await.unwrap().entries.as_ref(),
+ repo.status_blocking(&[]).unwrap().entries.as_ref(),
&[
(RepoPath::from_str("new_file1"), FileStatus::Untracked),
(RepoPath::from_str("new_file2"), FileStatus::Untracked)
@@ -2008,90 +1834,6 @@ mod tests {
.unwrap());
}
- #[gpui::test]
- async fn test_secondary_indices(cx: &mut TestAppContext) {
- cx.executor().allow_parking();
-
- let repo_dir = tempfile::tempdir().unwrap();
- git2::Repository::init(repo_dir.path()).unwrap();
- let repo =
- RealGitRepository::new(&repo_dir.path().join(".git"), None, cx.executor()).unwrap();
- let index = repo.create_index().await.unwrap();
- smol::fs::write(repo_dir.path().join("file1"), "file1\n")
- .await
- .unwrap();
- smol::fs::write(repo_dir.path().join("file2"), "file2\n")
- .await
- .unwrap();
- let diff = r#"
- diff --git a/file2 b/file2
- new file mode 100644
- index 0000000..cbc4e2e
- --- /dev/null
- +++ b/file2
- @@ -0,0 +1 @@
- +file2
- "#
- .unindent();
- repo.apply_diff(index, diff.to_string()).await.unwrap();
-
- assert_eq!(
- repo.status(Some(index), &[])
- .await
- .unwrap()
- .entries
- .as_ref(),
- vec![
- (RepoPath::from_str("file1"), FileStatus::Untracked),
- (
- RepoPath::from_str("file2"),
- FileStatus::index(StatusCode::Added)
- )
- ]
- );
- assert_eq!(
- repo.load_index_text(Some(index), RepoPath::from_str("file1"))
- .await,
- None
- );
- assert_eq!(
- repo.load_index_text(Some(index), RepoPath::from_str("file2"))
- .await,
- Some("file2\n".to_string())
- );
-
- smol::fs::write(repo_dir.path().join("file2"), "file2-changed\n")
- .await
- .unwrap();
- assert_eq!(
- repo.status(Some(index), &[])
- .await
- .unwrap()
- .entries
- .as_ref(),
- vec![
- (RepoPath::from_str("file1"), FileStatus::Untracked),
- (
- RepoPath::from_str("file2"),
- FileStatus::Tracked(TrackedStatus {
- worktree_status: StatusCode::Modified,
- index_status: StatusCode::Added,
- })
- )
- ]
- );
- assert_eq!(
- repo.load_index_text(Some(index), RepoPath::from_str("file1"))
- .await,
- None
- );
- assert_eq!(
- repo.load_index_text(Some(index), RepoPath::from_str("file2"))
- .await,
- Some("file2\n".to_string())
- );
- }
-
#[test]
fn test_branches_parsing() {
// suppress "help: octal escapes are not supported, `\0` is always null"
@@ -1320,7 +1320,7 @@ impl Buffer {
this.update(cx, |this, cx| {
if this.version() == diff.base_version {
this.finalize_last_transaction();
- this.apply_diff(diff, cx);
+ this.apply_diff(diff, true, cx);
tx.send(this.finalize_last_transaction().cloned()).ok();
this.has_conflict = false;
this.did_reload(this.version(), this.line_ending(), new_mtime, cx);
@@ -1879,9 +1879,14 @@ impl Buffer {
/// Applies a diff to the buffer. If the buffer has changed since the given diff was
/// calculated, then adjust the diff to account for those changes, and discard any
/// parts of the diff that conflict with those changes.
- pub fn apply_diff(&mut self, diff: Diff, cx: &mut Context<Self>) -> Option<TransactionId> {
- // Check for any edits to the buffer that have occurred since this diff
- // was computed.
+ ///
+ /// If `atomic` is true, the diff will be applied as a single edit.
+ pub fn apply_diff(
+ &mut self,
+ diff: Diff,
+ atomic: bool,
+ cx: &mut Context<Self>,
+ ) -> Option<TransactionId> {
let snapshot = self.snapshot();
let mut edits_since = snapshot.edits_since::<usize>(&diff.base_version).peekable();
let mut delta = 0;
@@ -1911,7 +1916,17 @@ impl Buffer {
self.start_transaction();
self.text.set_line_ending(diff.line_ending);
- self.edit(adjusted_edits, None, cx);
+ if atomic {
+ self.edit(adjusted_edits, None, cx);
+ } else {
+ let mut delta = 0isize;
+ for (range, new_text) in adjusted_edits {
+ let adjusted_range =
+ (range.start as isize + delta) as usize..(range.end as isize + delta) as usize;
+ delta += new_text.len() as isize - range.len() as isize;
+ self.edit([(adjusted_range, new_text)], None, cx);
+ }
+ }
self.end_transaction(cx)
}
@@ -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.
//
@@ -20,10 +20,10 @@ use git::{
blame::Blame,
parse_git_remote_url,
repository::{
- Branch, CommitDetails, DiffType, GitIndex, GitRepository, GitRepositoryCheckpoint,
- PushOptions, Remote, RemoteCommandOutput, RepoPath, ResetMode,
+ Branch, CommitDetails, DiffType, GitRepository, GitRepositoryCheckpoint, PushOptions,
+ Remote, RemoteCommandOutput, RepoPath, ResetMode,
},
- status::{FileStatus, GitStatus},
+ status::FileStatus,
BuildPermalinkParams, GitHostingProviderRegistry,
};
use gpui::{
@@ -146,22 +146,6 @@ pub struct GitStoreCheckpoint {
checkpoints_by_work_dir_abs_path: HashMap<PathBuf, GitRepositoryCheckpoint>,
}
-#[derive(Clone, Debug)]
-pub struct GitStoreDiff {
- diffs_by_work_dir_abs_path: HashMap<PathBuf, String>,
-}
-
-#[derive(Clone, Debug)]
-pub struct GitStoreIndex {
- indices_by_work_dir_abs_path: HashMap<PathBuf, GitIndex>,
-}
-
-#[derive(Default)]
-pub struct GitStoreStatus {
- #[allow(dead_code)]
- statuses_by_work_dir_abs_path: HashMap<PathBuf, GitStatus>,
-}
-
pub struct Repository {
pub repository_entry: RepositoryEntry,
pub merge_message: Option<String>,
@@ -755,113 +739,6 @@ impl GitStore {
})
}
- pub fn diff_checkpoints(
- &self,
- base_checkpoint: GitStoreCheckpoint,
- target_checkpoint: GitStoreCheckpoint,
- cx: &App,
- ) -> Task<Result<GitStoreDiff>> {
- let repositories_by_work_dir_abs_path = self
- .repositories
- .values()
- .map(|repo| {
- (
- repo.read(cx)
- .repository_entry
- .work_directory_abs_path
- .clone(),
- repo,
- )
- })
- .collect::<HashMap<_, _>>();
-
- let mut tasks = Vec::new();
- for (work_dir_abs_path, base_checkpoint) in base_checkpoint.checkpoints_by_work_dir_abs_path
- {
- if let Some(target_checkpoint) = target_checkpoint
- .checkpoints_by_work_dir_abs_path
- .get(&work_dir_abs_path)
- .cloned()
- {
- if let Some(repository) = repositories_by_work_dir_abs_path.get(&work_dir_abs_path)
- {
- let diff = repository
- .read(cx)
- .diff_checkpoints(base_checkpoint, target_checkpoint);
- tasks.push(async move {
- let diff = diff.await??;
- anyhow::Ok((work_dir_abs_path, diff))
- });
- }
- }
- }
-
- cx.background_spawn(async move {
- let diffs_by_path = future::try_join_all(tasks).await?;
- Ok(GitStoreDiff {
- diffs_by_work_dir_abs_path: diffs_by_path.into_iter().collect(),
- })
- })
- }
-
- pub fn create_index(&self, cx: &App) -> Task<Result<GitStoreIndex>> {
- let mut indices = Vec::new();
- for repository in self.repositories.values() {
- let repository = repository.read(cx);
- let work_dir_abs_path = repository.repository_entry.work_directory_abs_path.clone();
- let index = repository.create_index().map(|index| index?);
- indices.push(async move {
- let index = index.await?;
- anyhow::Ok((work_dir_abs_path, index))
- });
- }
-
- cx.background_executor().spawn(async move {
- let indices = future::try_join_all(indices).await?;
- Ok(GitStoreIndex {
- indices_by_work_dir_abs_path: indices.into_iter().collect(),
- })
- })
- }
-
- pub fn apply_diff(
- &self,
- mut index: GitStoreIndex,
- diff: GitStoreDiff,
- cx: &App,
- ) -> Task<Result<()>> {
- let repositories_by_work_dir_abs_path = self
- .repositories
- .values()
- .map(|repo| {
- (
- repo.read(cx)
- .repository_entry
- .work_directory_abs_path
- .clone(),
- repo,
- )
- })
- .collect::<HashMap<_, _>>();
-
- let mut tasks = Vec::new();
- for (work_dir_abs_path, diff) in diff.diffs_by_work_dir_abs_path {
- if let Some(repository) = repositories_by_work_dir_abs_path.get(&work_dir_abs_path) {
- if let Some(branch) = index
- .indices_by_work_dir_abs_path
- .remove(&work_dir_abs_path)
- {
- let apply = repository.read(cx).apply_diff(branch, diff);
- tasks.push(async move { apply.await? });
- }
- }
- }
- cx.background_spawn(async move {
- future::try_join_all(tasks).await?;
- Ok(())
- })
- }
-
/// Blames a buffer.
pub fn blame_buffer(
&self,
@@ -1406,7 +1283,7 @@ impl GitStore {
let index_text = if current_index_text.is_some() {
local_repo
.repo()
- .load_index_text(None, relative_path.clone())
+ .load_index_text(relative_path.clone())
.await
} else {
None
@@ -1521,87 +1398,6 @@ impl GitStore {
Some(status.status)
}
- pub fn status(&self, index: Option<GitStoreIndex>, cx: &App) -> Task<Result<GitStoreStatus>> {
- let repositories_by_work_dir_abs_path = self
- .repositories
- .values()
- .map(|repo| {
- (
- repo.read(cx)
- .repository_entry
- .work_directory_abs_path
- .clone(),
- repo,
- )
- })
- .collect::<HashMap<_, _>>();
-
- let mut tasks = Vec::new();
-
- if let Some(index) = index {
- // When we have an index, just check the repositories that are part of it
- for (work_dir_abs_path, git_index) in index.indices_by_work_dir_abs_path {
- if let Some(repository) = repositories_by_work_dir_abs_path.get(&work_dir_abs_path)
- {
- let status = repository.read(cx).status(Some(git_index));
- tasks.push(
- async move {
- let status = status.await??;
- anyhow::Ok((work_dir_abs_path, status))
- }
- .boxed(),
- );
- }
- }
- } else {
- // Otherwise, check all repositories
- for repository in self.repositories.values() {
- let repository = repository.read(cx);
- let work_dir_abs_path = repository.repository_entry.work_directory_abs_path.clone();
- let status = repository.status(None);
- tasks.push(
- async move {
- let status = status.await??;
- anyhow::Ok((work_dir_abs_path, status))
- }
- .boxed(),
- );
- }
- }
-
- cx.background_executor().spawn(async move {
- let statuses = future::try_join_all(tasks).await?;
- Ok(GitStoreStatus {
- statuses_by_work_dir_abs_path: statuses.into_iter().collect(),
- })
- })
- }
-
- pub fn load_index_text(
- &self,
- index: Option<GitStoreIndex>,
- buffer: &Entity<Buffer>,
- cx: &App,
- ) -> Task<Option<String>> {
- let Some((repository, path)) =
- self.repository_and_path_for_buffer_id(buffer.read(cx).remote_id(), cx)
- else {
- return Task::ready(None);
- };
-
- let git_index = index.and_then(|index| {
- index
- .indices_by_work_dir_abs_path
- .get(&repository.read(cx).repository_entry.work_directory_abs_path)
- .copied()
- });
- let text = repository.read(cx).load_index_text(git_index, path);
- cx.background_spawn(async move {
- let text = text.await;
- text.ok().flatten()
- })
- }
-
pub fn repository_and_path_for_buffer_id(
&self,
buffer_id: BufferId,
@@ -2851,24 +2647,11 @@ impl Repository {
self.repository_entry.status()
}
- pub fn status(&self, index: Option<GitIndex>) -> oneshot::Receiver<Result<GitStatus>> {
- self.send_job(move |repo, _cx| async move {
- match repo {
- RepositoryState::Local(git_repository) => git_repository.status(index, &[]).await,
- RepositoryState::Remote { .. } => Err(anyhow!("not implemented yet")),
- }
- })
- }
-
- pub fn load_index_text(
- &self,
- index: Option<GitIndex>,
- path: RepoPath,
- ) -> oneshot::Receiver<Option<String>> {
+ pub fn load_index_text(&self, path: RepoPath) -> oneshot::Receiver<Option<String>> {
self.send_job(move |repo, _cx| async move {
match repo {
RepositoryState::Local(git_repository) => {
- git_repository.load_index_text(index, path).await
+ git_repository.load_index_text(path).await
}
RepositoryState::Remote { .. } => None,
}
@@ -3779,26 +3562,6 @@ impl Repository {
}
})
}
-
- pub fn create_index(&self) -> oneshot::Receiver<Result<GitIndex>> {
- self.send_job(move |repo, _cx| async move {
- match repo {
- RepositoryState::Local(git_repository) => git_repository.create_index().await,
- RepositoryState::Remote { .. } => Err(anyhow!("not implemented yet")),
- }
- })
- }
-
- pub fn apply_diff(&self, index: GitIndex, diff: String) -> oneshot::Receiver<Result<()>> {
- self.send_job(move |repo, _cx| async move {
- match repo {
- RepositoryState::Local(git_repository) => {
- git_repository.apply_diff(index, diff).await
- }
- RepositoryState::Remote { .. } => Err(anyhow!("not implemented yet")),
- }
- })
- }
}
fn get_permalink_in_rust_registry_src(
@@ -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 {
@@ -1498,9 +1498,9 @@ impl Buffer {
.flat_map(|transaction| self.edited_ranges_for_transaction(transaction))
}
- pub fn edited_ranges_for_transaction<'a, D>(
+ pub fn edited_ranges_for_edit_ids<'a, D>(
&'a self,
- transaction: &'a Transaction,
+ edit_ids: impl IntoIterator<Item = &'a clock::Lamport>,
) -> impl 'a + Iterator<Item = Range<D>>
where
D: TextDimension,
@@ -1508,7 +1508,7 @@ impl Buffer {
// get fragment ranges
let mut cursor = self.fragments.cursor::<(Option<&Locator>, usize)>(&None);
let offset_ranges = self
- .fragment_ids_for_edits(transaction.edit_ids.iter())
+ .fragment_ids_for_edits(edit_ids.into_iter())
.into_iter()
.filter_map(move |fragment_id| {
cursor.seek_forward(&Some(fragment_id), Bias::Left, &None);
@@ -1547,6 +1547,16 @@ impl Buffer {
})
}
+ pub fn edited_ranges_for_transaction<'a, D>(
+ &'a self,
+ transaction: &'a Transaction,
+ ) -> impl 'a + Iterator<Item = Range<D>>
+ where
+ D: TextDimension,
+ {
+ self.edited_ranges_for_edit_ids(&transaction.edit_ids)
+ }
+
pub fn subscribe(&mut self) -> Subscription {
self.subscriptions.subscribe()
}
@@ -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);
}
}
}