From 347178039c20841105615b02d5deb129730c05e0 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Sat, 9 Mar 2024 01:37:24 +0200 Subject: [PATCH] Add `editor::RevertSelectedHunks` to revert git diff hunks in the editor (#9068) https://github.com/zed-industries/zed/assets/2690773/653b5658-e3f3-4aee-9a9d-0f2153b4141b Release Notes: - Added `editor::RevertSelectedHunks` (`cmd-alt-z` by default) for reverting git hunks from the editor --- assets/keymaps/default-linux.json | 3 +- assets/keymaps/default-macos.json | 3 +- crates/collab/src/tests/editor_tests.rs | 168 ++++- crates/editor/src/actions.rs | 1 + crates/editor/src/editor.rs | 101 ++- crates/editor/src/editor_tests.rs | 596 ++++++++++++++++++ crates/editor/src/element.rs | 7 +- crates/editor/src/git.rs | 18 +- crates/editor/src/test/editor_test_context.rs | 2 +- crates/git/src/diff.rs | 81 ++- crates/language/src/buffer.rs | 13 +- crates/multi_buffer/src/multi_buffer.rs | 81 ++- crates/project/src/project.rs | 1 - 13 files changed, 1003 insertions(+), 72 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 29e3d19d78..639cdd4aef 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -118,7 +118,8 @@ "stop_at_soft_wraps": true } ], - "ctrl-;": "editor::ToggleLineNumbers" + "ctrl-;": "editor::ToggleLineNumbers", + "ctrl-alt-z": "editor::RevertSelectedHunks" } }, { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 9881720717..d2a9a5af0f 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -153,7 +153,8 @@ } ], "ctrl-cmd-space": "editor::ShowCharacterPalette", - "cmd-;": "editor::ToggleLineNumbers" + "cmd-;": "editor::ToggleLineNumbers", + "cmd-alt-z": "editor::RevertSelectedHunks" } }, { diff --git a/crates/collab/src/tests/editor_tests.rs b/crates/collab/src/tests/editor_tests.rs index d379900f9b..366fd2ccff 100644 --- a/crates/collab/src/tests/editor_tests.rs +++ b/crates/collab/src/tests/editor_tests.rs @@ -5,7 +5,8 @@ use crate::{ use call::ActiveCall; use editor::{ actions::{ - ConfirmCodeAction, ConfirmCompletion, ConfirmRename, Redo, Rename, ToggleCodeActions, Undo, + ConfirmCodeAction, ConfirmCompletion, ConfirmRename, Redo, Rename, RevertSelectedHunks, + ToggleCodeActions, Undo, }, test::editor_test_context::{AssertionContextManager, EditorTestContext}, Editor, @@ -1814,6 +1815,171 @@ async fn test_inlay_hint_refresh_is_forwarded( }); } +#[gpui::test] +async fn test_multiple_types_reverts(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { + let mut server = TestServer::start(cx_a.executor()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + server + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) + .await; + let active_call_a = cx_a.read(ActiveCall::global); + let active_call_b = cx_b.read(ActiveCall::global); + + cx_a.update(editor::init); + cx_b.update(editor::init); + + client_a.language_registry().add(rust_lang()); + client_b.language_registry().add(rust_lang()); + + let base_text = indoc! {r#"struct Row; +struct Row1; +struct Row2; + +struct Row4; +struct Row5; +struct Row6; + +struct Row8; +struct Row9; +struct Row10;"#}; + + client_a + .fs() + .insert_tree( + "/a", + json!({ + "main.rs": base_text, + }), + ) + .await; + let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await; + active_call_a + .update(cx_a, |call, cx| call.set_location(Some(&project_a), cx)) + .await + .unwrap(); + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + + let project_b = client_b.build_remote_project(project_id, cx_b).await; + active_call_b + .update(cx_b, |call, cx| call.set_location(Some(&project_b), cx)) + .await + .unwrap(); + + let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a); + let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b); + + let editor_a = workspace_a + .update(cx_a, |workspace, cx| { + workspace.open_path((worktree_id, "main.rs"), None, true, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + let editor_b = workspace_b + .update(cx_b, |workspace, cx| { + workspace.open_path((worktree_id, "main.rs"), None, true, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + let mut editor_cx_a = EditorTestContext { + cx: cx_a.clone(), + window: cx_a.handle(), + editor: editor_a, + assertion_cx: AssertionContextManager::new(), + }; + let mut editor_cx_b = EditorTestContext { + cx: cx_b.clone(), + window: cx_b.handle(), + editor: editor_b, + assertion_cx: AssertionContextManager::new(), + }; + + // host edits the file, that differs from the base text, producing diff hunks + editor_cx_a.set_state(indoc! {r#"struct Row; + struct Row0.1; + struct Row0.2; + struct Row1; + + struct Row4; + struct Row5444; + struct Row6; + + struct Row9; + struct Row1220;ˇ"#}); + editor_cx_a.update_editor(|editor, cx| { + editor + .buffer() + .read(cx) + .as_singleton() + .unwrap() + .update(cx, |buffer, cx| { + buffer.set_diff_base(Some(base_text.to_string()), cx); + }); + }); + editor_cx_b.update_editor(|editor, cx| { + editor + .buffer() + .read(cx) + .as_singleton() + .unwrap() + .update(cx, |buffer, cx| { + buffer.set_diff_base(Some(base_text.to_string()), cx); + }); + }); + cx_a.executor().run_until_parked(); + cx_b.executor().run_until_parked(); + + // client, selects a range in the updated buffer, and reverts it + // both host and the client observe the reverted state (with one hunk left, not covered by client's selection) + editor_cx_b.set_selections_state(indoc! {r#"«ˇstruct Row; + struct Row0.1; + struct Row0.2; + struct Row1; + + struct Row4; + struct Row5444; + struct Row6; + + struct R»ow9; + struct Row1220;"#}); + editor_cx_b.update_editor(|editor, cx| { + editor.revert_selected_hunks(&RevertSelectedHunks, cx); + }); + cx_a.executor().run_until_parked(); + cx_b.executor().run_until_parked(); + editor_cx_a.assert_editor_state(indoc! {r#"struct Row; + struct Row1; + struct Row2; + + struct Row4; + struct Row5; + struct Row6; + + struct Row8; + struct Row9; + struct Row1220;ˇ"#}); + editor_cx_b.assert_editor_state(indoc! {r#"«ˇstruct Row; + struct Row1; + struct Row2; + + struct Row4; + struct Row5; + struct Row6; + + struct Row8; + struct R»ow9; + struct Row1220;"#}); +} + fn extract_hint_labels(editor: &Editor) -> Vec { let mut labels = Vec::new(); for hint in editor.inlay_hint_cache().hints() { diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index 018718943f..bbc68be472 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -210,6 +210,7 @@ gpui::actions!( PageDown, PageUp, Paste, + RevertSelectedHunks, Redo, RedoSelection, Rename, diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index df4836425c..cbb1412b53 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -36,7 +36,7 @@ mod selections_collection; mod editor_tests; #[cfg(any(test, feature = "test-support"))] pub mod test; -use ::git::diff::DiffHunk; +use ::git::diff::{DiffHunk, DiffHunkStatus}; pub(crate) use actions::*; use aho_corasick::AhoCorasick; use anyhow::{anyhow, Context as _, Result}; @@ -4908,6 +4908,105 @@ impl Editor { }) } + pub fn revert_selected_hunks(&mut self, _: &RevertSelectedHunks, cx: &mut ViewContext) { + let revert_changes = self.gather_revert_changes(&self.selections.disjoint_anchors(), cx); + if !revert_changes.is_empty() { + self.transact(cx, |editor, cx| { + editor.buffer().update(cx, |multi_buffer, cx| { + for (buffer_id, buffer_revert_ranges) in revert_changes { + if let Some(buffer) = multi_buffer.buffer(buffer_id) { + buffer.update(cx, |buffer, cx| { + buffer.edit(buffer_revert_ranges, None, cx); + }); + } + } + }); + editor.change_selections(None, cx, |selections| selections.refresh()); + }); + } + } + + fn gather_revert_changes( + &mut self, + selections: &[Selection], + cx: &mut ViewContext<'_, Editor>, + ) -> HashMap, Arc)>> { + let mut revert_changes = HashMap::default(); + self.buffer.update(cx, |multi_buffer, cx| { + let multi_buffer_snapshot = multi_buffer.snapshot(cx); + let selected_multi_buffer_rows = selections.iter().map(|selection| { + let head = selection.head(); + let tail = selection.tail(); + let start = tail.to_point(&multi_buffer_snapshot).row; + let end = head.to_point(&multi_buffer_snapshot).row; + if start > end { + end..start + } else { + start..end + } + }); + + let mut processed_buffer_rows = + HashMap::>>::default(); + for selected_multi_buffer_rows in selected_multi_buffer_rows { + let query_rows = + selected_multi_buffer_rows.start..selected_multi_buffer_rows.end + 1; + for hunk in multi_buffer_snapshot.git_diff_hunks_in_range(query_rows.clone()) { + // Deleted hunk is an empty row range, no caret can be placed there and Zed allows to revert it + // when the caret is just above or just below the deleted hunk. + let allow_adjacent = hunk.status() == DiffHunkStatus::Removed; + let related_to_selection = if allow_adjacent { + hunk.associated_range.overlaps(&query_rows) + || hunk.associated_range.start == query_rows.end + || hunk.associated_range.end == query_rows.start + } else { + // `selected_multi_buffer_rows` are inclusive (e.g. [2..2] means 2nd row is selected) + // `hunk.associated_range` is exclusive (e.g. [2..3] means 2nd row is selected) + hunk.associated_range.overlaps(&selected_multi_buffer_rows) + || selected_multi_buffer_rows.end == hunk.associated_range.start + }; + if related_to_selection { + if !processed_buffer_rows + .entry(hunk.buffer_id) + .or_default() + .insert(hunk.buffer_range.start..hunk.buffer_range.end) + { + continue; + } + Self::prepare_revert_change(&mut revert_changes, &multi_buffer, &hunk, cx); + } + } + } + }); + revert_changes + } + + fn prepare_revert_change( + revert_changes: &mut HashMap, Arc)>>, + multi_buffer: &MultiBuffer, + hunk: &DiffHunk, + cx: &mut AppContext, + ) -> Option<()> { + let buffer = multi_buffer.buffer(hunk.buffer_id)?; + let buffer = buffer.read(cx); + let original_text = buffer.diff_base()?.get(hunk.diff_base_byte_range.clone())?; + let buffer_snapshot = buffer.snapshot(); + let buffer_revert_changes = revert_changes.entry(buffer.remote_id()).or_default(); + if let Err(i) = buffer_revert_changes.binary_search_by(|probe| { + probe + .0 + .start + .cmp(&hunk.buffer_range.start, &buffer_snapshot) + .then(probe.0.end.cmp(&hunk.buffer_range.end, &buffer_snapshot)) + .then(probe.1.as_ref().cmp(original_text)) + }) { + buffer_revert_changes.insert(i, (hunk.buffer_range.clone(), Arc::from(original_text))); + Some(()) + } else { + None + } + } + pub fn reverse_lines(&mut self, _: &ReverseLines, cx: &mut ViewContext) { self.manipulate_lines(cx, |lines| lines.reverse()) } diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index b0e6b49431..a99525176a 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -8743,6 +8743,560 @@ async fn test_find_all_references(cx: &mut gpui::TestAppContext) { "}); } +#[gpui::test] +async fn test_addition_reverts(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await; + let base_text = indoc! {r#"struct Row; +struct Row1; +struct Row2; + +struct Row4; +struct Row5; +struct Row6; + +struct Row8; +struct Row9; +struct Row10;"#}; + + // When addition hunks are not adjacent to carets, no hunk revert is performed + assert_hunk_revert( + indoc! {r#"struct Row; + struct Row1; + struct Row1.1; + struct Row1.2; + struct Row2;ˇ + + struct Row4; + struct Row5; + struct Row6; + + struct Row8; + ˇstruct Row9; + struct Row9.1; + struct Row9.2; + struct Row9.3; + struct Row10;"#}, + vec![DiffHunkStatus::Added, DiffHunkStatus::Added], + indoc! {r#"struct Row; + struct Row1; + struct Row1.1; + struct Row1.2; + struct Row2;ˇ + + struct Row4; + struct Row5; + struct Row6; + + struct Row8; + ˇstruct Row9; + struct Row9.1; + struct Row9.2; + struct Row9.3; + struct Row10;"#}, + base_text, + &mut cx, + ); + // Same for selections + assert_hunk_revert( + indoc! {r#"struct Row; + struct Row1; + struct Row2; + struct Row2.1; + struct Row2.2; + «ˇ + struct Row4; + struct» Row5; + «struct Row6; + ˇ» + struct Row9.1; + struct Row9.2; + struct Row9.3; + struct Row8; + struct Row9; + struct Row10;"#}, + vec![DiffHunkStatus::Added, DiffHunkStatus::Added], + indoc! {r#"struct Row; + struct Row1; + struct Row2; + struct Row2.1; + struct Row2.2; + «ˇ + struct Row4; + struct» Row5; + «struct Row6; + ˇ» + struct Row9.1; + struct Row9.2; + struct Row9.3; + struct Row8; + struct Row9; + struct Row10;"#}, + base_text, + &mut cx, + ); + + // When carets and selections intersect the addition hunks, those are reverted. + // Adjacent carets got merged. + assert_hunk_revert( + indoc! {r#"struct Row; + ˇ// something on the top + struct Row1; + struct Row2; + struct Roˇw3.1; + struct Row2.2; + struct Row2.3;ˇ + + struct Row4; + struct ˇRow5.1; + struct Row5.2; + struct «Rowˇ»5.3; + struct Row5; + struct Row6; + ˇ + struct Row9.1; + struct «Rowˇ»9.2; + struct «ˇRow»9.3; + struct Row8; + struct Row9; + «ˇ// something on bottom» + struct Row10;"#}, + vec![ + DiffHunkStatus::Added, + DiffHunkStatus::Added, + DiffHunkStatus::Added, + DiffHunkStatus::Added, + DiffHunkStatus::Added, + ], + indoc! {r#"struct Row; + ˇstruct Row1; + struct Row2; + ˇ + struct Row4; + ˇstruct Row5; + struct Row6; + ˇ + ˇstruct Row8; + struct Row9; + ˇstruct Row10;"#}, + base_text, + &mut cx, + ); +} + +#[gpui::test] +async fn test_modification_reverts(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await; + let base_text = indoc! {r#"struct Row; +struct Row1; +struct Row2; + +struct Row4; +struct Row5; +struct Row6; + +struct Row8; +struct Row9; +struct Row10;"#}; + + // Modification hunks behave the same as the addition ones. + assert_hunk_revert( + indoc! {r#"struct Row; + struct Row1; + struct Row33; + ˇ + struct Row4; + struct Row5; + struct Row6; + ˇ + struct Row99; + struct Row9; + struct Row10;"#}, + vec![DiffHunkStatus::Modified, DiffHunkStatus::Modified], + indoc! {r#"struct Row; + struct Row1; + struct Row33; + ˇ + struct Row4; + struct Row5; + struct Row6; + ˇ + struct Row99; + struct Row9; + struct Row10;"#}, + base_text, + &mut cx, + ); + assert_hunk_revert( + indoc! {r#"struct Row; + struct Row1; + struct Row33; + «ˇ + struct Row4; + struct» Row5; + «struct Row6; + ˇ» + struct Row99; + struct Row9; + struct Row10;"#}, + vec![DiffHunkStatus::Modified, DiffHunkStatus::Modified], + indoc! {r#"struct Row; + struct Row1; + struct Row33; + «ˇ + struct Row4; + struct» Row5; + «struct Row6; + ˇ» + struct Row99; + struct Row9; + struct Row10;"#}, + base_text, + &mut cx, + ); + + assert_hunk_revert( + indoc! {r#"ˇstruct Row1.1; + struct Row1; + «ˇstr»uct Row22; + + struct ˇRow44; + struct Row5; + struct «Rˇ»ow66;ˇ + + «struˇ»ct Row88; + struct Row9; + struct Row1011;ˇ"#}, + vec![ + DiffHunkStatus::Modified, + DiffHunkStatus::Modified, + DiffHunkStatus::Modified, + DiffHunkStatus::Modified, + DiffHunkStatus::Modified, + DiffHunkStatus::Modified, + ], + indoc! {r#"struct Row; + ˇstruct Row1; + struct Row2; + ˇ + struct Row4; + ˇstruct Row5; + struct Row6; + ˇ + struct Row8; + ˇstruct Row9; + struct Row10;ˇ"#}, + base_text, + &mut cx, + ); +} + +#[gpui::test] +async fn test_deletion_reverts(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await; + let base_text = indoc! {r#"struct Row; +struct Row1; +struct Row2; + +struct Row4; +struct Row5; +struct Row6; + +struct Row8; +struct Row9; +struct Row10;"#}; + + // Deletion hunks trigger with carets on ajacent rows, so carets and selections have to stay farther to avoid the revert + assert_hunk_revert( + indoc! {r#"struct Row; + struct Row2; + + ˇstruct Row4; + struct Row5; + struct Row6; + ˇ + struct Row8; + struct Row10;"#}, + vec![DiffHunkStatus::Removed, DiffHunkStatus::Removed], + indoc! {r#"struct Row; + struct Row2; + + ˇstruct Row4; + struct Row5; + struct Row6; + ˇ + struct Row8; + struct Row10;"#}, + base_text, + &mut cx, + ); + assert_hunk_revert( + indoc! {r#"struct Row; + struct Row2; + + «ˇstruct Row4; + struct» Row5; + «struct Row6; + ˇ» + struct Row8; + struct Row10;"#}, + vec![DiffHunkStatus::Removed, DiffHunkStatus::Removed], + indoc! {r#"struct Row; + struct Row2; + + «ˇstruct Row4; + struct» Row5; + «struct Row6; + ˇ» + struct Row8; + struct Row10;"#}, + base_text, + &mut cx, + ); + + // Deletion hunks are ephemeral, so it's impossible to place the caret into them — Zed triggers reverts for lines, adjacent to carets and selections. + assert_hunk_revert( + indoc! {r#"struct Row; + ˇstruct Row2; + + struct Row4; + struct Row5; + struct Row6; + + struct Row8;ˇ + struct Row10;"#}, + vec![DiffHunkStatus::Removed, DiffHunkStatus::Removed], + indoc! {r#"struct Row; + struct Row1; + ˇstruct Row2; + + struct Row4; + struct Row5; + struct Row6; + + struct Row8;ˇ + struct Row9; + struct Row10;"#}, + base_text, + &mut cx, + ); + assert_hunk_revert( + indoc! {r#"struct Row; + struct Row2«ˇ; + struct Row4; + struct» Row5; + «struct Row6; + + struct Row8;ˇ» + struct Row10;"#}, + vec![ + DiffHunkStatus::Removed, + DiffHunkStatus::Removed, + DiffHunkStatus::Removed, + ], + indoc! {r#"struct Row; + struct Row1; + struct Row2«ˇ; + + struct Row4; + struct» Row5; + «struct Row6; + + struct Row8;ˇ» + struct Row9; + struct Row10;"#}, + base_text, + &mut cx, + ); +} + +#[gpui::test] +async fn test_multibuffer_reverts(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + + let cols = 4; + let rows = 10; + let sample_text_1 = sample_text(rows, cols, 'a'); + assert_eq!( + sample_text_1, + "aaaa\nbbbb\ncccc\ndddd\neeee\nffff\ngggg\nhhhh\niiii\njjjj" + ); + let sample_text_2 = sample_text(rows, cols, 'l'); + assert_eq!( + sample_text_2, + "llll\nmmmm\nnnnn\noooo\npppp\nqqqq\nrrrr\nssss\ntttt\nuuuu" + ); + let sample_text_3 = sample_text(rows, cols, 'v'); + assert_eq!( + sample_text_3, + "vvvv\nwwww\nxxxx\nyyyy\nzzzz\n{{{{\n||||\n}}}}\n~~~~\n\u{7f}\u{7f}\u{7f}\u{7f}" + ); + + fn diff_every_buffer_row( + buffer: &Model, + sample_text: String, + cols: usize, + cx: &mut gpui::TestAppContext, + ) { + // revert first character in each row, creating one large diff hunk per buffer + let is_first_char = |offset: usize| offset % cols == 0; + buffer.update(cx, |buffer, cx| { + buffer.set_text( + sample_text + .chars() + .enumerate() + .map(|(offset, c)| if is_first_char(offset) { 'X' } else { c }) + .collect::(), + cx, + ); + buffer.set_diff_base(Some(sample_text), cx); + }); + cx.executor().run_until_parked(); + } + + let buffer_1 = cx.new_model(|cx| { + Buffer::new( + 0, + BufferId::new(cx.entity_id().as_u64()).unwrap(), + sample_text_1.clone(), + ) + }); + diff_every_buffer_row(&buffer_1, sample_text_1.clone(), cols, cx); + + let buffer_2 = cx.new_model(|cx| { + Buffer::new( + 1, + BufferId::new(cx.entity_id().as_u64() + 1).unwrap(), + sample_text_2.clone(), + ) + }); + diff_every_buffer_row(&buffer_2, sample_text_2.clone(), cols, cx); + + let buffer_3 = cx.new_model(|cx| { + Buffer::new( + 2, + BufferId::new(cx.entity_id().as_u64() + 2).unwrap(), + sample_text_3.clone(), + ) + }); + diff_every_buffer_row(&buffer_3, sample_text_3.clone(), cols, cx); + + let multibuffer = cx.new_model(|cx| { + let mut multibuffer = MultiBuffer::new(0, ReadWrite); + multibuffer.push_excerpts( + buffer_1.clone(), + [ + ExcerptRange { + context: Point::new(0, 0)..Point::new(3, 0), + primary: None, + }, + ExcerptRange { + context: Point::new(5, 0)..Point::new(7, 0), + primary: None, + }, + ExcerptRange { + context: Point::new(9, 0)..Point::new(10, 4), + primary: None, + }, + ], + cx, + ); + multibuffer.push_excerpts( + buffer_2.clone(), + [ + ExcerptRange { + context: Point::new(0, 0)..Point::new(3, 0), + primary: None, + }, + ExcerptRange { + context: Point::new(5, 0)..Point::new(7, 0), + primary: None, + }, + ExcerptRange { + context: Point::new(9, 0)..Point::new(10, 4), + primary: None, + }, + ], + cx, + ); + multibuffer.push_excerpts( + buffer_3.clone(), + [ + ExcerptRange { + context: Point::new(0, 0)..Point::new(3, 0), + primary: None, + }, + ExcerptRange { + context: Point::new(5, 0)..Point::new(7, 0), + primary: None, + }, + ExcerptRange { + context: Point::new(9, 0)..Point::new(10, 4), + primary: None, + }, + ], + cx, + ); + multibuffer + }); + + let (editor, cx) = cx.add_window_view(|cx| build_editor(multibuffer, cx)); + editor.update(cx, |editor, cx| { + assert_eq!(editor.text(cx), "XaaaXbbbX\nccXc\ndXdd\n\nhXhh\nXiiiXjjjX\n\nXlllXmmmX\nnnXn\noXoo\n\nsXss\nXtttXuuuX\n\nXvvvXwwwX\nxxXx\nyXyy\n\n}X}}\nX~~~X\u{7f}\u{7f}\u{7f}X\n"); + editor.select_all(&SelectAll, cx); + editor.revert_selected_hunks(&RevertSelectedHunks, cx); + }); + cx.executor().run_until_parked(); + // When all ranges are selected, all buffer hunks are reverted. + editor.update(cx, |editor, cx| { + assert_eq!(editor.text(cx), "aaaa\nbbbb\ncccc\ndddd\neeee\nffff\ngggg\nhhhh\niiii\njjjj\n\n\nllll\nmmmm\nnnnn\noooo\npppp\nqqqq\nrrrr\nssss\ntttt\nuuuu\n\n\nvvvv\nwwww\nxxxx\nyyyy\nzzzz\n{{{{\n||||\n}}}}\n~~~~\n\u{7f}\u{7f}\u{7f}\u{7f}\n\n"); + }); + buffer_1.update(cx, |buffer, _| { + assert_eq!(buffer.text(), sample_text_1); + }); + buffer_2.update(cx, |buffer, _| { + assert_eq!(buffer.text(), sample_text_2); + }); + buffer_3.update(cx, |buffer, _| { + assert_eq!(buffer.text(), sample_text_3); + }); + + diff_every_buffer_row(&buffer_1, sample_text_1.clone(), cols, cx); + diff_every_buffer_row(&buffer_2, sample_text_2.clone(), cols, cx); + diff_every_buffer_row(&buffer_3, sample_text_3.clone(), cols, cx); + editor.update(cx, |editor, cx| { + editor.change_selections(None, cx, |s| { + s.select_ranges(Some(Point::new(0, 0)..Point::new(6, 0))); + }); + editor.revert_selected_hunks(&RevertSelectedHunks, cx); + }); + // Now, when all ranges selected belong to buffer_1, the revert should succeed, + // but not affect buffer_2 and its related excerpts. + editor.update(cx, |editor, cx| { + assert_eq!( + editor.text(cx), + "aaaa\nbbbb\ncccc\ndddd\neeee\nffff\ngggg\nhhhh\niiii\njjjj\n\n\nXlllXmmmX\nnnXn\noXoo\nXpppXqqqX\nrrXr\nsXss\nXtttXuuuX\n\n\nXvvvXwwwX\nxxXx\nyXyy\nXzzzX{{{X\n||X|\n}X}}\nX~~~X\u{7f}\u{7f}\u{7f}X\n\n" + ); + }); + buffer_1.update(cx, |buffer, _| { + assert_eq!(buffer.text(), sample_text_1); + }); + buffer_2.update(cx, |buffer, _| { + assert_eq!( + buffer.text(), + "XlllXmmmX\nnnXn\noXoo\nXpppXqqqX\nrrXr\nsXss\nXtttXuuuX" + ); + }); + buffer_3.update(cx, |buffer, _| { + assert_eq!( + buffer.text(), + "XvvvXwwwX\nxxXx\nyXyy\nXzzzX{{{X\n||X|\n}X}}\nX~~~X\u{7f}\u{7f}\u{7f}X" + ); + }); +} + fn empty_range(row: usize, column: usize) -> Range { let point = DisplayPoint::new(row as u32, column as u32); point..point @@ -8913,3 +9467,45 @@ pub(crate) fn rust_lang() -> Arc { Some(tree_sitter_rust::language()), )) } + +#[track_caller] +fn assert_hunk_revert( + not_reverted_text_with_selections: &str, + expected_not_reverted_hunk_statuses: Vec, + expected_reverted_text_with_selections: &str, + base_text: &str, + cx: &mut EditorLspTestContext, +) { + cx.set_state(not_reverted_text_with_selections); + cx.update_editor(|editor, cx| { + editor + .buffer() + .read(cx) + .as_singleton() + .unwrap() + .update(cx, |buffer, cx| { + buffer.set_diff_base(Some(base_text.to_string()), cx); + }); + }); + cx.executor().run_until_parked(); + + let reverted_hunk_statuses = cx.update_editor(|editor, cx| { + let snapshot = editor + .buffer() + .read(cx) + .as_singleton() + .unwrap() + .read(cx) + .snapshot(); + let reverted_hunk_statuses = snapshot + .git_diff_hunks_in_row_range(0..u32::MAX) + .map(|hunk| hunk.status()) + .collect::>(); + + editor.revert_selected_hunks(&RevertSelectedHunks, cx); + reverted_hunk_statuses + }); + cx.executor().run_until_parked(); + cx.assert_editor_state(expected_reverted_text_with_selections); + assert_eq!(reverted_hunk_statuses, expected_not_reverted_hunk_statuses); +} diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index d46f0f2a31..7fb3068424 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -339,6 +339,7 @@ impl EditorElement { register_action(view, cx, Editor::unique_lines_case_insensitive); register_action(view, cx, Editor::unique_lines_case_sensitive); register_action(view, cx, Editor::accept_partial_copilot_suggestion); + register_action(view, cx, Editor::revert_selected_hunks); } fn register_key_listeners( @@ -1452,12 +1453,12 @@ impl EditorElement { .buffer_snapshot .git_diff_hunks_in_range(0..(max_row.floor() as u32)) { - let start_display = Point::new(hunk.buffer_range.start, 0) + let start_display = Point::new(hunk.associated_range.start, 0) .to_display_point(&layout.position_map.snapshot.display_snapshot); - let end_display = Point::new(hunk.buffer_range.end, 0) + let end_display = Point::new(hunk.associated_range.end, 0) .to_display_point(&layout.position_map.snapshot.display_snapshot); let start_y = y_for_row(start_display.row() as f32); - let mut end_y = if hunk.buffer_range.start == hunk.buffer_range.end { + let mut end_y = if hunk.associated_range.start == hunk.associated_range.end { y_for_row((end_display.row() + 1) as f32) } else { y_for_row((end_display.row()) as f32) diff --git a/crates/editor/src/git.rs b/crates/editor/src/git.rs index 18e544e4a6..c02ad2c7f2 100644 --- a/crates/editor/src/git.rs +++ b/crates/editor/src/git.rs @@ -46,20 +46,20 @@ impl DisplayDiffHunk { } pub fn diff_hunk_to_display(hunk: DiffHunk, snapshot: &DisplaySnapshot) -> DisplayDiffHunk { - let hunk_start_point = Point::new(hunk.buffer_range.start, 0); - let hunk_start_point_sub = Point::new(hunk.buffer_range.start.saturating_sub(1), 0); + let hunk_start_point = Point::new(hunk.associated_range.start, 0); + let hunk_start_point_sub = Point::new(hunk.associated_range.start.saturating_sub(1), 0); let hunk_end_point_sub = Point::new( - hunk.buffer_range + hunk.associated_range .end .saturating_sub(1) - .max(hunk.buffer_range.start), + .max(hunk.associated_range.start), 0, ); let is_removal = hunk.status() == DiffHunkStatus::Removed; - let folds_start = Point::new(hunk.buffer_range.start.saturating_sub(2), 0); - let folds_end = Point::new(hunk.buffer_range.end + 2, 0); + let folds_start = Point::new(hunk.associated_range.start.saturating_sub(2), 0); + let folds_end = Point::new(hunk.associated_range.end + 2, 0); let folds_range = folds_start..folds_end; let containing_fold = snapshot.folds_in_range(folds_range).find(|fold| { @@ -79,7 +79,7 @@ pub fn diff_hunk_to_display(hunk: DiffHunk, snapshot: &DisplaySnapshot) -> } else { let start = hunk_start_point.to_display_point(snapshot).row(); - let hunk_end_row = hunk.buffer_range.end.max(hunk.buffer_range.start); + let hunk_end_row = hunk.associated_range.end.max(hunk.associated_range.start); let hunk_end_point = Point::new(hunk_end_row, 0); let end = hunk_end_point.to_display_point(snapshot).row(); @@ -264,7 +264,7 @@ mod tests { assert_eq!( snapshot .git_diff_hunks_in_range(0..12) - .map(|hunk| (hunk.status(), hunk.buffer_range)) + .map(|hunk| (hunk.status(), hunk.associated_range)) .collect::>(), &expected, ); @@ -272,7 +272,7 @@ mod tests { assert_eq!( snapshot .git_diff_hunks_in_range_rev(0..12) - .map(|hunk| (hunk.status(), hunk.buffer_range)) + .map(|hunk| (hunk.status(), hunk.associated_range)) .collect::>(), expected .iter() diff --git a/crates/editor/src/test/editor_test_context.rs b/crates/editor/src/test/editor_test_context.rs index d4a5ee7c6e..9543e46f96 100644 --- a/crates/editor/src/test/editor_test_context.rs +++ b/crates/editor/src/test/editor_test_context.rs @@ -274,7 +274,7 @@ impl EditorTestContext { let buffer_text = self.buffer_text(); if buffer_text != unmarked_text { - panic!("Unmarked text doesn't match buffer text\nBuffer text: {buffer_text:?}\nUnmarked text: {unmarked_text:?}\nRaw buffer text\n{buffer_text}Raw unmarked text\n{unmarked_text}"); + panic!("Unmarked text doesn't match buffer text\nBuffer text: {buffer_text:?}\nUnmarked text: {unmarked_text:?}\nRaw buffer text\n{buffer_text}\nRaw unmarked text\n{unmarked_text}"); } self.assert_selections(expected_selections, marked_text.to_string()) diff --git a/crates/git/src/diff.rs b/crates/git/src/diff.rs index 20f425f42c..305b732e6d 100644 --- a/crates/git/src/diff.rs +++ b/crates/git/src/diff.rs @@ -1,6 +1,6 @@ use std::{iter, ops::Range}; use sum_tree::SumTree; -use text::{Anchor, BufferSnapshot, OffsetRangeExt, Point}; +use text::{Anchor, BufferId, BufferSnapshot, OffsetRangeExt, Point}; pub use git2 as libgit; use libgit::{DiffLineType as GitDiffLineType, DiffOptions as GitOptions, Patch as GitPatch}; @@ -12,17 +12,53 @@ pub enum DiffHunkStatus { Removed, } +/// A diff hunk, representing a range of consequent lines in a singleton buffer, associated with a generic range. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DiffHunk { - pub buffer_range: Range, + /// E.g. a range in multibuffer, that has an excerpt added, singleton buffer for which has this diff hunk. + /// Consider a singleton buffer with 10 lines, all of them are modified — so a corresponding diff hunk would have a range 0..10. + /// And a multibuffer with the excerpt of lines 2-6 from the singleton buffer. + /// If the multibuffer is searched for diff hunks, the associated range would be multibuffer rows, corresponding to rows 2..6 from the singleton buffer. + /// But the hunk range would be 0..10, same for any other excerpts from the same singleton buffer. + pub associated_range: Range, + /// Singleton buffer ID this hunk belongs to. + pub buffer_id: BufferId, + /// A consequent range of lines in the singleton buffer, that were changed and produced this diff hunk. + pub buffer_range: Range, + /// Original singleton buffer text before the change, that was instead of the `buffer_range`. pub diff_base_byte_range: Range, } +impl DiffHunk { + fn buffer_range_empty(&self) -> bool { + if self.buffer_range.start == self.buffer_range.end { + return true; + } + + // buffer diff hunks are per line, so if we arrive to the same line with different bias, it's the same hunk + let Anchor { + timestamp: timestamp_start, + offset: offset_start, + buffer_id: buffer_id_start, + bias: _, + } = self.buffer_range.start; + let Anchor { + timestamp: timestamp_end, + offset: offset_end, + buffer_id: buffer_id_end, + bias: _, + } = self.buffer_range.end; + timestamp_start == timestamp_end + && offset_start == offset_end + && buffer_id_start == buffer_id_end + } +} + impl DiffHunk { pub fn status(&self) -> DiffHunkStatus { if self.diff_base_byte_range.is_empty() { DiffHunkStatus::Added - } else if self.buffer_range.is_empty() { + } else if self.buffer_range_empty() { DiffHunkStatus::Removed } else { DiffHunkStatus::Modified @@ -35,7 +71,7 @@ impl sum_tree::Item for DiffHunk { fn summary(&self) -> Self::Summary { DiffHunkSummary { - buffer_range: self.buffer_range.clone(), + buffer_range: self.associated_range.clone(), } } } @@ -57,7 +93,7 @@ impl sum_tree::Summary for DiffHunkSummary { } } -#[derive(Clone)] +#[derive(Debug, Clone)] pub struct BufferDiff { last_buffer_version: Option, tree: SumTree>, @@ -103,8 +139,11 @@ impl BufferDiff { }) .flat_map(move |hunk| { [ - (&hunk.buffer_range.start, hunk.diff_base_byte_range.start), - (&hunk.buffer_range.end, hunk.diff_base_byte_range.end), + ( + &hunk.associated_range.start, + hunk.diff_base_byte_range.start, + ), + (&hunk.associated_range.end, hunk.diff_base_byte_range.end), ] .into_iter() }); @@ -112,17 +151,17 @@ impl BufferDiff { let mut summaries = buffer.summaries_for_anchors_with_payload::(anchor_iter); iter::from_fn(move || { let (start_point, start_base) = summaries.next()?; - let (end_point, end_base) = summaries.next()?; + let (mut end_point, end_base) = summaries.next()?; - let end_row = if end_point.column > 0 { - end_point.row + 1 - } else { - end_point.row - }; + if end_point.column > 0 { + end_point.row += 1; + } Some(DiffHunk { - buffer_range: start_point.row..end_row, + associated_range: start_point.row..end_point.row, diff_base_byte_range: start_base..end_base, + buffer_range: buffer.anchor_before(start_point)..buffer.anchor_after(end_point), + buffer_id: buffer.remote_id(), }) }) } @@ -142,7 +181,7 @@ impl BufferDiff { cursor.prev(buffer); let hunk = cursor.item()?; - let range = hunk.buffer_range.to_point(buffer); + let range = hunk.associated_range.to_point(buffer); let end_row = if range.end.column > 0 { range.end.row + 1 } else { @@ -150,8 +189,10 @@ impl BufferDiff { }; Some(DiffHunk { - buffer_range: range.start.row..end_row, + associated_range: range.start.row..end_row, diff_base_byte_range: hunk.diff_base_byte_range.clone(), + buffer_range: hunk.buffer_range.clone(), + buffer_id: hunk.buffer_id, }) }) } @@ -269,8 +310,10 @@ impl BufferDiff { let end = Point::new(buffer_row_range.end, 0); let buffer_range = buffer.anchor_before(start)..buffer.anchor_before(end); DiffHunk { + associated_range: buffer_range.clone(), buffer_range, diff_base_byte_range, + buffer_id: buffer.remote_id(), } } } @@ -289,12 +332,12 @@ pub fn assert_hunks( let actual_hunks = diff_hunks .map(|hunk| { ( - hunk.buffer_range.clone(), + hunk.associated_range.clone(), &diff_base[hunk.diff_base_byte_range], buffer .text_for_range( - Point::new(hunk.buffer_range.start, 0) - ..Point::new(hunk.buffer_range.end, 0), + Point::new(hunk.associated_range.start, 0) + ..Point::new(hunk.associated_range.end, 0), ) .collect::(), ) diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 7e60cd91f4..c4f1a650bb 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -930,8 +930,17 @@ impl Buffer { /// against the buffer text. pub fn set_diff_base(&mut self, diff_base: Option, cx: &mut ModelContext) { self.diff_base = diff_base; - self.git_diff_recalc(cx); - cx.emit(Event::DiffBaseChanged); + if let Some(recalc_task) = self.git_diff_recalc(cx) { + cx.spawn(|buffer, mut cx| async move { + recalc_task.await; + buffer + .update(&mut cx, |_, cx| { + cx.emit(Event::DiffBaseChanged); + }) + .ok(); + }) + .detach(); + } } /// Recomputes the Git diff status. diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index ce50a9345a..a6509c4ee9 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -3186,19 +3186,21 @@ impl MultiBufferSnapshot { .map(move |hunk| { let start = multibuffer_start.row + hunk - .buffer_range + .associated_range .start .saturating_sub(excerpt_start_point.row); let end = multibuffer_start.row + hunk - .buffer_range + .associated_range .end .min(excerpt_end_point.row + 1) .saturating_sub(excerpt_start_point.row); DiffHunk { - buffer_range: start..end, + associated_range: start..end, diff_base_byte_range: hunk.diff_base_byte_range.clone(), + buffer_range: hunk.buffer_range.clone(), + buffer_id: hunk.buffer_id, } }); @@ -3215,52 +3217,65 @@ impl MultiBufferSnapshot { ) -> impl Iterator> + '_ { let mut cursor = self.excerpts.cursor::(); - cursor.seek(&Point::new(row_range.start, 0), Bias::Right, &()); + cursor.seek(&Point::new(row_range.start, 0), Bias::Left, &()); std::iter::from_fn(move || { let excerpt = cursor.item()?; let multibuffer_start = *cursor.start(); let multibuffer_end = multibuffer_start + excerpt.text_summary.lines; - if multibuffer_start.row >= row_range.end { - return None; - } - let mut buffer_start = excerpt.range.context.start; let mut buffer_end = excerpt.range.context.end; - let excerpt_start_point = buffer_start.to_point(&excerpt.buffer); - let excerpt_end_point = excerpt_start_point + excerpt.text_summary.lines; - if row_range.start > multibuffer_start.row { - let buffer_start_point = - excerpt_start_point + Point::new(row_range.start - multibuffer_start.row, 0); - buffer_start = excerpt.buffer.anchor_before(buffer_start_point); - } + let excerpt_rows = match multibuffer_start.row.cmp(&row_range.end) { + cmp::Ordering::Less => { + let excerpt_start_point = buffer_start.to_point(&excerpt.buffer); + let excerpt_end_point = excerpt_start_point + excerpt.text_summary.lines; - if row_range.end < multibuffer_end.row { - let buffer_end_point = - excerpt_start_point + Point::new(row_range.end - multibuffer_start.row, 0); - buffer_end = excerpt.buffer.anchor_before(buffer_end_point); - } + if row_range.start > multibuffer_start.row { + let buffer_start_point = excerpt_start_point + + Point::new(row_range.start - multibuffer_start.row, 0); + buffer_start = excerpt.buffer.anchor_before(buffer_start_point); + } + + if row_range.end < multibuffer_end.row { + let buffer_end_point = excerpt_start_point + + Point::new(row_range.end - multibuffer_start.row, 0); + buffer_end = excerpt.buffer.anchor_before(buffer_end_point); + } + excerpt_start_point.row..excerpt_end_point.row + } + cmp::Ordering::Equal if row_range.end == 0 => { + buffer_end = buffer_start; + 0..0 + } + cmp::Ordering::Greater | cmp::Ordering::Equal => return None, + }; let buffer_hunks = excerpt .buffer .git_diff_hunks_intersecting_range(buffer_start..buffer_end) .map(move |hunk| { - let start = multibuffer_start.row - + hunk - .buffer_range - .start - .saturating_sub(excerpt_start_point.row); - let end = multibuffer_start.row - + hunk - .buffer_range - .end - .min(excerpt_end_point.row + 1) - .saturating_sub(excerpt_start_point.row); - + let buffer_range = if excerpt_rows.start == 0 && excerpt_rows.end == 0 { + 0..1 + } else { + let start = multibuffer_start.row + + hunk + .associated_range + .start + .saturating_sub(excerpt_rows.start); + let end = multibuffer_start.row + + hunk + .associated_range + .end + .min(excerpt_rows.end + 1) + .saturating_sub(excerpt_rows.start); + start..end + }; DiffHunk { - buffer_range: start..end, + associated_range: buffer_range, diff_base_byte_range: hunk.diff_base_byte_range.clone(), + buffer_range: hunk.buffer_range.clone(), + buffer_id: hunk.buffer_id, } }); diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 37c6c96dcb..fa7c8483d7 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -4427,7 +4427,6 @@ impl Project { project_transaction.0.extend(new.0); } - // TODO kb here too: if let Some(command) = action.lsp_action.command { project.update(&mut cx, |this, _| { this.last_workspace_edits_by_language_server