Rework diff rendering to allow putting the cursor into deleted text, soft-wrapping and scrolling deleted text correctly (#22994)

Closes #12553

* [x] Fix `diff_hunk_before`
* [x] Fix failure to show deleted text when expanding hunk w/ cursor on
second line of the hunk
* [x] Failure to expand diff hunk below the cursor.
* [x] Delete the whole file, and expand the diff. Backspace over the
deleted hunk, panic!
* [x] Go-to-line now counts the diff hunks, but it should not
* [x] backspace at the beginning of a deleted hunk deletes too much text
* [x] Indent guides are rendered incorrectly 
* [ ] Fix randomized multi buffer tests

Maybe:
* [ ] Buffer search should include deleted text (in vim mode it turns
out I use `/x` all the time to jump to the next x I can see).
* [ ] vim: should refuse to switch into insert mode if selection is
fully within a diff.
* [ ] vim `o` command when cursor is on last line of deleted hunk.
* [ ] vim `shift-o` on first line of deleted hunk moves cursor but
doesn't insert line
* [x] `enter` at end of diff hunk inserts a new line but doesn't move
cursor
* [x] (`shift-enter` at start of diff hunk does nothing)
* [ ] Inserting a line just before an expanded hunk collapses it

Release Notes:


- Improved diff rendering, allowing you to navigate with your cursor
inside of deleted text in diff hunks.

---------

Co-authored-by: Conrad <conrad@zed.dev>
Co-authored-by: Cole <cole@zed.dev>
Co-authored-by: Mikayla <mikayla@zed.dev>
Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
Co-authored-by: Michael <michael@zed.dev>
Co-authored-by: Agus <agus@zed.dev>
Co-authored-by: João <joao@zed.dev>
This commit is contained in:
Max Brunsfeld 2025-01-24 13:18:22 -08:00 committed by GitHub
parent 1fdae4bae0
commit d2c55cbe3d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
64 changed files with 7653 additions and 5495 deletions

View file

@ -21,7 +21,7 @@ use language::{
deserialize_line_ending, deserialize_version, serialize_line_ending, serialize_version,
split_operations,
},
Buffer, BufferEvent, Capability, DiskState, File as _, Language, Operation,
Buffer, BufferEvent, Capability, DiskState, File as _, Language, LanguageRegistry, Operation,
};
use rpc::{proto, AnyProtoClient, ErrorExt as _, TypedEnvelope};
use serde::Deserialize;
@ -60,14 +60,14 @@ struct SharedBuffer {
lsp_handle: Option<OpenLspBufferHandle>,
}
#[derive(Debug)]
pub struct BufferChangeSet {
pub buffer_id: BufferId,
pub base_text: Option<Model<Buffer>>,
pub base_text: Option<language::BufferSnapshot>,
pub language: Option<Arc<Language>>,
pub diff_to_buffer: git::diff::BufferDiff,
pub recalculate_diff_task: Option<Task<Result<()>>>,
pub diff_updated_futures: Vec<oneshot::Sender<()>>,
pub base_text_version: usize,
pub language_registry: Option<Arc<LanguageRegistry>>,
}
enum BufferStoreState {
@ -1080,9 +1080,9 @@ impl BufferStore {
Ok(text) => text,
};
let change_set = buffer.update(&mut cx, |buffer, cx| {
cx.new_model(|_| BufferChangeSet::new(buffer))
})?;
let change_set = cx
.new_model(|cx| BufferChangeSet::new(&buffer, cx))
.unwrap();
if let Some(text) = text {
change_set
@ -1976,11 +1976,8 @@ impl BufferStore {
shared.unstaged_changes = Some(change_set.clone());
}
})?;
let staged_text = change_set.read_with(&cx, |change_set, cx| {
change_set
.base_text
.as_ref()
.map(|buffer| buffer.read(cx).text())
let staged_text = change_set.read_with(&cx, |change_set, _| {
change_set.base_text.as_ref().map(|buffer| buffer.text())
})?;
Ok(proto::GetStagedTextResponse { staged_text })
}
@ -2225,25 +2222,51 @@ impl BufferStore {
}
impl BufferChangeSet {
pub fn new(buffer: &text::BufferSnapshot) -> Self {
pub fn new(buffer: &Model<Buffer>, cx: &mut ModelContext<Self>) -> Self {
cx.subscribe(buffer, |this, buffer, event, cx| match event {
BufferEvent::LanguageChanged => {
this.language = buffer.read(cx).language().cloned();
if let Some(base_text) = &this.base_text {
let snapshot = language::Buffer::build_snapshot(
base_text.as_rope().clone(),
this.language.clone(),
this.language_registry.clone(),
cx,
);
this.recalculate_diff_task = Some(cx.spawn(|this, mut cx| async move {
let base_text = cx.background_executor().spawn(snapshot).await;
this.update(&mut cx, |this, cx| {
this.base_text = Some(base_text);
cx.notify();
})
}));
}
}
_ => {}
})
.detach();
let buffer = buffer.read(cx);
Self {
buffer_id: buffer.remote_id(),
base_text: None,
diff_to_buffer: git::diff::BufferDiff::new(buffer),
recalculate_diff_task: None,
diff_updated_futures: Vec::new(),
base_text_version: 0,
language: buffer.language().cloned(),
language_registry: buffer.language_registry(),
}
}
#[cfg(any(test, feature = "test-support"))]
pub fn new_with_base_text(
base_text: String,
buffer: text::BufferSnapshot,
buffer: &Model<Buffer>,
cx: &mut ModelContext<Self>,
) -> Self {
let mut this = Self::new(&buffer);
let _ = this.set_base_text(base_text, buffer, cx);
let mut this = Self::new(&buffer, cx);
let _ = this.set_base_text(base_text, buffer.read(cx).text_snapshot(), cx);
this
}
@ -2266,8 +2289,8 @@ impl BufferChangeSet {
}
#[cfg(any(test, feature = "test-support"))]
pub fn base_text_string(&self, cx: &AppContext) -> Option<String> {
self.base_text.as_ref().map(|buffer| buffer.read(cx).text())
pub fn base_text_string(&self) -> Option<String> {
self.base_text.as_ref().map(|buffer| buffer.text())
}
pub fn set_base_text(
@ -2289,7 +2312,6 @@ impl BufferChangeSet {
self.base_text = None;
self.diff_to_buffer = BufferDiff::new(&buffer_snapshot);
self.recalculate_diff_task.take();
self.base_text_version += 1;
cx.notify();
}
}
@ -2300,7 +2322,7 @@ impl BufferChangeSet {
cx: &mut ModelContext<Self>,
) -> oneshot::Receiver<()> {
if let Some(base_text) = self.base_text.clone() {
self.recalculate_diff_internal(base_text.read(cx).text(), buffer_snapshot, false, cx)
self.recalculate_diff_internal(base_text.text(), buffer_snapshot, false, cx)
} else {
oneshot::channel().1
}
@ -2316,19 +2338,30 @@ impl BufferChangeSet {
let (tx, rx) = oneshot::channel();
self.diff_updated_futures.push(tx);
self.recalculate_diff_task = Some(cx.spawn(|this, mut cx| async move {
let (base_text, diff) = cx
let new_base_text = if base_text_changed {
let base_text_rope: Rope = base_text.as_str().into();
let snapshot = this.update(&mut cx, |this, cx| {
language::Buffer::build_snapshot(
base_text_rope,
this.language.clone(),
this.language_registry.clone(),
cx,
)
})?;
Some(cx.background_executor().spawn(snapshot).await)
} else {
None
};
let diff = cx
.background_executor()
.spawn(async move {
let diff = BufferDiff::build(&base_text, &buffer_snapshot).await;
(base_text, diff)
.spawn({
let buffer_snapshot = buffer_snapshot.clone();
async move { BufferDiff::build(&base_text, &buffer_snapshot) }
})
.await;
this.update(&mut cx, |this, cx| {
if base_text_changed {
this.base_text_version += 1;
this.base_text = Some(cx.new_model(|cx| {
Buffer::local_normalized(Rope::from(base_text), LineEnding::default(), cx)
}));
if let Some(new_base_text) = new_base_text {
this.base_text = Some(new_base_text)
}
this.diff_to_buffer = diff;
this.recalculate_diff_task.take();
@ -2341,6 +2374,33 @@ impl BufferChangeSet {
}));
rx
}
#[cfg(any(test, feature = "test-support"))]
pub fn recalculate_diff_sync(
&mut self,
mut base_text: String,
buffer_snapshot: text::BufferSnapshot,
base_text_changed: bool,
cx: &mut ModelContext<Self>,
) {
LineEnding::normalize(&mut base_text);
let diff = BufferDiff::build(&base_text, &buffer_snapshot);
if base_text_changed {
self.base_text = Some(
cx.background_executor()
.clone()
.block(Buffer::build_snapshot(
base_text.into(),
self.language.clone(),
self.language_registry.clone(),
cx,
)),
);
}
self.diff_to_buffer = diff;
self.recalculate_diff_task.take();
cx.notify();
}
}
impl OpenBuffer {

View file

@ -1851,14 +1851,11 @@ impl LocalLspStore {
let edits_since_save = std::cell::LazyCell::new(|| {
let saved_version = buffer.read(cx).saved_version();
Patch::new(
snapshot
.edits_since::<Unclipped<PointUtf16>>(saved_version)
.collect(),
)
Patch::new(snapshot.edits_since::<PointUtf16>(saved_version).collect())
});
let mut sanitized_diagnostics = Vec::new();
for entry in diagnostics {
let start;
let end;
@ -1866,8 +1863,8 @@ impl LocalLspStore {
// Some diagnostics are based on files on disk instead of buffers'
// current contents. Adjust these diagnostics' ranges to reflect
// any unsaved edits.
start = (*edits_since_save).old_to_new(entry.range.start);
end = (*edits_since_save).old_to_new(entry.range.end);
start = Unclipped((*edits_since_save).old_to_new(entry.range.start.0));
end = Unclipped((*edits_since_save).old_to_new(entry.range.end.0));
} else {
start = entry.range.start;
end = entry.range.end;

View file

@ -5651,7 +5651,7 @@ async fn test_unstaged_changes_for_buffer(cx: &mut gpui::TestAppContext) {
assert_hunks(
unstaged_changes.diff_hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot),
&snapshot,
&unstaged_changes.base_text.as_ref().unwrap().read(cx).text(),
&unstaged_changes.base_text.as_ref().unwrap().text(),
&[
(0..1, "", "// print goodbye\n"),
(
@ -5681,7 +5681,7 @@ async fn test_unstaged_changes_for_buffer(cx: &mut gpui::TestAppContext) {
assert_hunks(
unstaged_changes.diff_hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot),
&snapshot,
&unstaged_changes.base_text.as_ref().unwrap().read(cx).text(),
&unstaged_changes.base_text.as_ref().unwrap().text(),
&[(2..3, "", " println!(\"goodbye world\");\n")],
);
});

View file

@ -12,7 +12,7 @@ use language::{
use rpc::{proto, AnyProtoClient, TypedEnvelope};
use settings::{watch_config_file, SettingsLocation};
use task::{TaskContext, TaskVariables, VariableName};
use text::BufferId;
use text::{BufferId, OffsetRangeExt};
use util::ResultExt;
use crate::{
@ -125,12 +125,10 @@ impl TaskStore {
.filter_map(|(k, v)| Some((k.parse().log_err()?, v))),
);
for range in location
.buffer
.read(cx)
.snapshot()
.runnable_ranges(location.range.clone())
{
let snapshot = location.buffer.read(cx).snapshot();
let range = location.range.to_offset(&snapshot);
for range in snapshot.runnable_ranges(range) {
for (capture_name, value) in range.extra_captures {
variables.insert(VariableName::Custom(capture_name.into()), value);
}