Add the ability to propose changes to a set of buffers (#18170)

This PR introduces functionality for creating *branches* of buffers that
can be used to preview and edit change sets that haven't yet been
applied to the buffers themselves.

Release Notes:

- N/A

---------

Co-authored-by: Marshall Bowers <elliott.codes@gmail.com>
Co-authored-by: Marshall <marshall@zed.dev>
This commit is contained in:
Max Brunsfeld 2024-09-20 15:28:50 -07:00 committed by GitHub
parent e309fbda2a
commit 743feb98bc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 622 additions and 186 deletions

View file

@ -21,8 +21,8 @@ use async_watch as watch;
pub use clock::ReplicaId;
use futures::channel::oneshot;
use gpui::{
AnyElement, AppContext, EventEmitter, HighlightStyle, ModelContext, Pixels, Task, TaskLabel,
WindowContext,
AnyElement, AppContext, Context as _, EventEmitter, HighlightStyle, Model, ModelContext,
Pixels, Task, TaskLabel, WindowContext,
};
use lsp::LanguageServerId;
use parking_lot::Mutex;
@ -84,11 +84,17 @@ pub enum Capability {
pub type BufferRow = u32;
#[derive(Clone)]
enum BufferDiffBase {
Git(Rope),
PastBufferVersion(Model<Buffer>, BufferSnapshot),
}
/// An in-memory representation of a source code file, including its text,
/// syntax trees, git status, and diagnostics.
pub struct Buffer {
text: TextBuffer,
diff_base: Option<Rope>,
diff_base: Option<BufferDiffBase>,
git_diff: git::diff::BufferDiff,
file: Option<Arc<dyn File>>,
/// The mtime of the file when this buffer was last loaded from
@ -121,6 +127,7 @@ pub struct Buffer {
/// Memoize calls to has_changes_since(saved_version).
/// The contents of a cell are (self.version, has_changes) at the time of a last call.
has_unsaved_edits: Cell<(clock::Global, bool)>,
_subscriptions: Vec<gpui::Subscription>,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
@ -308,7 +315,10 @@ pub enum Operation {
pub enum BufferEvent {
/// The buffer was changed in a way that must be
/// propagated to its other replicas.
Operation(Operation),
Operation {
operation: Operation,
is_local: bool,
},
/// The buffer was edited.
Edited,
/// The buffer's `dirty` bit changed.
@ -644,7 +654,7 @@ impl Buffer {
id: self.remote_id().into(),
file: self.file.as_ref().map(|f| f.to_proto(cx)),
base_text: self.base_text().to_string(),
diff_base: self.diff_base.as_ref().map(|h| h.to_string()),
diff_base: self.diff_base().as_ref().map(|h| h.to_string()),
line_ending: proto::serialize_line_ending(self.line_ending()) as i32,
saved_version: proto::serialize_version(&self.saved_version),
saved_mtime: self.saved_mtime.map(|time| time.into()),
@ -734,12 +744,10 @@ impl Buffer {
was_dirty_before_starting_transaction: None,
has_unsaved_edits: Cell::new((buffer.version(), false)),
text: buffer,
diff_base: diff_base
.map(|mut raw_diff_base| {
LineEnding::normalize(&mut raw_diff_base);
raw_diff_base
})
.map(Rope::from),
diff_base: diff_base.map(|mut raw_diff_base| {
LineEnding::normalize(&mut raw_diff_base);
BufferDiffBase::Git(Rope::from(raw_diff_base))
}),
diff_base_version: 0,
git_diff,
file,
@ -759,6 +767,7 @@ impl Buffer {
completion_triggers_timestamp: Default::default(),
deferred_ops: OperationQueue::new(),
has_conflict: false,
_subscriptions: Vec::new(),
}
}
@ -782,6 +791,52 @@ impl Buffer {
}
}
pub fn branch(&mut self, cx: &mut ModelContext<Self>) -> Model<Self> {
let this = cx.handle();
cx.new_model(|cx| {
let mut branch = Self {
diff_base: Some(BufferDiffBase::PastBufferVersion(
this.clone(),
self.snapshot(),
)),
language: self.language.clone(),
has_conflict: self.has_conflict,
has_unsaved_edits: Cell::new(self.has_unsaved_edits.get_mut().clone()),
_subscriptions: vec![cx.subscribe(&this, |branch: &mut Self, _, event, cx| {
if let BufferEvent::Operation { operation, .. } = event {
branch.apply_ops([operation.clone()], cx);
branch.diff_base_version += 1;
}
})],
..Self::build(
self.text.branch(),
None,
self.file.clone(),
self.capability(),
)
};
if let Some(language_registry) = self.language_registry() {
branch.set_language_registry(language_registry);
}
branch
})
}
pub fn merge(&mut self, branch: &Model<Self>, cx: &mut ModelContext<Self>) {
let branch = branch.read(cx);
let edits = branch
.edits_since::<usize>(&self.version)
.map(|edit| {
(
edit.old,
branch.text_for_range(edit.new).collect::<String>(),
)
})
.collect::<Vec<_>>();
self.edit(edits, None, cx);
}
#[cfg(test)]
pub(crate) fn as_text_snapshot(&self) -> &text::BufferSnapshot {
&self.text
@ -961,20 +1016,23 @@ impl Buffer {
/// Returns the current diff base, see [Buffer::set_diff_base].
pub fn diff_base(&self) -> Option<&Rope> {
self.diff_base.as_ref()
match self.diff_base.as_ref()? {
BufferDiffBase::Git(rope) => Some(rope),
BufferDiffBase::PastBufferVersion(_, buffer_snapshot) => {
Some(buffer_snapshot.as_rope())
}
}
}
/// Sets the text that will be used to compute a Git diff
/// against the buffer text.
pub fn set_diff_base(&mut self, diff_base: Option<String>, cx: &mut ModelContext<Self>) {
self.diff_base = diff_base
.map(|mut raw_diff_base| {
LineEnding::normalize(&mut raw_diff_base);
raw_diff_base
})
.map(Rope::from);
self.diff_base = diff_base.map(|mut raw_diff_base| {
LineEnding::normalize(&mut raw_diff_base);
BufferDiffBase::Git(Rope::from(raw_diff_base))
});
self.diff_base_version += 1;
if let Some(recalc_task) = self.git_diff_recalc(cx) {
if let Some(recalc_task) = self.recalculate_diff(cx) {
cx.spawn(|buffer, mut cx| async move {
recalc_task.await;
buffer
@ -992,14 +1050,21 @@ impl Buffer {
self.diff_base_version
}
/// Recomputes the Git diff status.
pub fn git_diff_recalc(&mut self, cx: &mut ModelContext<Self>) -> Option<Task<()>> {
let diff_base = self.diff_base.clone()?;
/// Recomputes the diff.
pub fn recalculate_diff(&mut self, cx: &mut ModelContext<Self>) -> Option<Task<()>> {
let diff_base_rope = match self.diff_base.as_mut()? {
BufferDiffBase::Git(rope) => rope.clone(),
BufferDiffBase::PastBufferVersion(base_buffer, base_buffer_snapshot) => {
let new_base_snapshot = base_buffer.read(cx).snapshot();
*base_buffer_snapshot = new_base_snapshot;
base_buffer_snapshot.as_rope().clone()
}
};
let snapshot = self.snapshot();
let mut diff = self.git_diff.clone();
let diff = cx.background_executor().spawn(async move {
diff.update(&diff_base, &snapshot).await;
diff.update(&diff_base_rope, &snapshot).await;
diff
});
@ -1169,7 +1234,7 @@ impl Buffer {
lamport_timestamp,
};
self.apply_diagnostic_update(server_id, diagnostics, lamport_timestamp, cx);
self.send_operation(op, cx);
self.send_operation(op, true, cx);
}
fn request_autoindent(&mut self, cx: &mut ModelContext<Self>) {
@ -1743,6 +1808,7 @@ impl Buffer {
lamport_timestamp,
cursor_shape,
},
true,
cx,
);
self.non_text_state_update_count += 1;
@ -1889,7 +1955,7 @@ impl Buffer {
}
self.end_transaction(cx);
self.send_operation(Operation::Buffer(edit_operation), cx);
self.send_operation(Operation::Buffer(edit_operation), true, cx);
Some(edit_id)
}
@ -1991,6 +2057,9 @@ impl Buffer {
}
})
.collect::<Vec<_>>();
for operation in buffer_ops.iter() {
self.send_operation(Operation::Buffer(operation.clone()), false, cx);
}
self.text.apply_ops(buffer_ops);
self.deferred_ops.insert(deferred_ops);
self.flush_deferred_ops(cx);
@ -2114,8 +2183,16 @@ impl Buffer {
}
}
fn send_operation(&mut self, operation: Operation, cx: &mut ModelContext<Self>) {
cx.emit(BufferEvent::Operation(operation));
fn send_operation(
&mut self,
operation: Operation,
is_local: bool,
cx: &mut ModelContext<Self>,
) {
cx.emit(BufferEvent::Operation {
operation,
is_local,
});
}
/// Removes the selections for a given peer.
@ -2130,7 +2207,7 @@ impl Buffer {
let old_version = self.version.clone();
if let Some((transaction_id, operation)) = self.text.undo() {
self.send_operation(Operation::Buffer(operation), cx);
self.send_operation(Operation::Buffer(operation), true, cx);
self.did_edit(&old_version, was_dirty, cx);
Some(transaction_id)
} else {
@ -2147,7 +2224,7 @@ impl Buffer {
let was_dirty = self.is_dirty();
let old_version = self.version.clone();
if let Some(operation) = self.text.undo_transaction(transaction_id) {
self.send_operation(Operation::Buffer(operation), cx);
self.send_operation(Operation::Buffer(operation), true, cx);
self.did_edit(&old_version, was_dirty, cx);
true
} else {
@ -2167,7 +2244,7 @@ impl Buffer {
let operations = self.text.undo_to_transaction(transaction_id);
let undone = !operations.is_empty();
for operation in operations {
self.send_operation(Operation::Buffer(operation), cx);
self.send_operation(Operation::Buffer(operation), true, cx);
}
if undone {
self.did_edit(&old_version, was_dirty, cx)
@ -2181,7 +2258,7 @@ impl Buffer {
let old_version = self.version.clone();
if let Some((transaction_id, operation)) = self.text.redo() {
self.send_operation(Operation::Buffer(operation), cx);
self.send_operation(Operation::Buffer(operation), true, cx);
self.did_edit(&old_version, was_dirty, cx);
Some(transaction_id)
} else {
@ -2201,7 +2278,7 @@ impl Buffer {
let operations = self.text.redo_to_transaction(transaction_id);
let redone = !operations.is_empty();
for operation in operations {
self.send_operation(Operation::Buffer(operation), cx);
self.send_operation(Operation::Buffer(operation), true, cx);
}
if redone {
self.did_edit(&old_version, was_dirty, cx)
@ -2218,6 +2295,7 @@ impl Buffer {
triggers,
lamport_timestamp: self.completion_triggers_timestamp,
},
true,
cx,
);
cx.notify();
@ -2297,7 +2375,7 @@ impl Buffer {
let ops = self.text.randomly_undo_redo(rng);
if !ops.is_empty() {
for op in ops {
self.send_operation(Operation::Buffer(op), cx);
self.send_operation(Operation::Buffer(op), true, cx);
self.did_edit(&old_version, was_dirty, cx);
}
}
@ -3638,12 +3716,12 @@ impl BufferSnapshot {
!self.git_diff.is_empty()
}
/// Returns all the Git diff hunks intersecting the given
/// row range.
/// Returns all the Git diff hunks intersecting the given row range.
#[cfg(any(test, feature = "test-support"))]
pub fn git_diff_hunks_in_row_range(
&self,
range: Range<BufferRow>,
) -> impl '_ + Iterator<Item = git::diff::DiffHunk<u32>> {
) -> impl '_ + Iterator<Item = git::diff::DiffHunk> {
self.git_diff.hunks_in_row_range(range, self)
}
@ -3652,7 +3730,7 @@ impl BufferSnapshot {
pub fn git_diff_hunks_intersecting_range(
&self,
range: Range<Anchor>,
) -> impl '_ + Iterator<Item = git::diff::DiffHunk<u32>> {
) -> impl '_ + Iterator<Item = git::diff::DiffHunk> {
self.git_diff.hunks_intersecting_range(range, self)
}
@ -3661,7 +3739,7 @@ impl BufferSnapshot {
pub fn git_diff_hunks_intersecting_range_rev(
&self,
range: Range<Anchor>,
) -> impl '_ + Iterator<Item = git::diff::DiffHunk<u32>> {
) -> impl '_ + Iterator<Item = git::diff::DiffHunk> {
self.git_diff.hunks_intersecting_range_rev(range, self)
}

View file

@ -6,6 +6,7 @@ use crate::Buffer;
use clock::ReplicaId;
use collections::BTreeMap;
use futures::FutureExt as _;
use git::diff::assert_hunks;
use gpui::{AppContext, BorrowAppContext, Model};
use gpui::{Context, TestAppContext};
use indoc::indoc;
@ -275,13 +276,19 @@ fn test_edit_events(cx: &mut gpui::AppContext) {
|buffer, cx| {
let buffer_1_events = buffer_1_events.clone();
cx.subscribe(&buffer1, move |_, _, event, _| match event.clone() {
BufferEvent::Operation(op) => buffer1_ops.lock().push(op),
BufferEvent::Operation {
operation,
is_local: true,
} => buffer1_ops.lock().push(operation),
event => buffer_1_events.lock().push(event),
})
.detach();
let buffer_2_events = buffer_2_events.clone();
cx.subscribe(&buffer2, move |_, _, event, _| {
buffer_2_events.lock().push(event.clone())
cx.subscribe(&buffer2, move |_, _, event, _| match event.clone() {
BufferEvent::Operation {
is_local: false, ..
} => {}
event => buffer_2_events.lock().push(event),
})
.detach();
@ -2370,6 +2377,118 @@ async fn test_find_matching_indent(cx: &mut TestAppContext) {
);
}
#[gpui::test]
fn test_branch_and_merge(cx: &mut TestAppContext) {
cx.update(|cx| init_settings(cx, |_| {}));
let base_buffer = cx.new_model(|cx| Buffer::local("one\ntwo\nthree\n", cx));
// Create a remote replica of the base buffer.
let base_buffer_replica = cx.new_model(|cx| {
Buffer::from_proto(
1,
Capability::ReadWrite,
base_buffer.read(cx).to_proto(cx),
None,
)
.unwrap()
});
base_buffer.update(cx, |_buffer, cx| {
cx.subscribe(&base_buffer_replica, |this, _, event, cx| {
if let BufferEvent::Operation {
operation,
is_local: true,
} = event
{
this.apply_ops([operation.clone()], cx);
}
})
.detach();
});
// Create a branch, which initially has the same state as the base buffer.
let branch_buffer = base_buffer.update(cx, |buffer, cx| buffer.branch(cx));
branch_buffer.read_with(cx, |buffer, _| {
assert_eq!(buffer.text(), "one\ntwo\nthree\n");
});
// Edits to the branch are not applied to the base.
branch_buffer.update(cx, |buffer, cx| {
buffer.edit(
[(Point::new(1, 0)..Point::new(1, 0), "ONE_POINT_FIVE\n")],
None,
cx,
)
});
branch_buffer.read_with(cx, |branch_buffer, cx| {
assert_eq!(base_buffer.read(cx).text(), "one\ntwo\nthree\n");
assert_eq!(branch_buffer.text(), "one\nONE_POINT_FIVE\ntwo\nthree\n");
});
// Edits to the base are applied to the branch.
base_buffer.update(cx, |buffer, cx| {
buffer.edit([(Point::new(0, 0)..Point::new(0, 0), "ZERO\n")], None, cx)
});
branch_buffer.read_with(cx, |branch_buffer, cx| {
assert_eq!(base_buffer.read(cx).text(), "ZERO\none\ntwo\nthree\n");
assert_eq!(
branch_buffer.text(),
"ZERO\none\nONE_POINT_FIVE\ntwo\nthree\n"
);
});
assert_diff_hunks(&branch_buffer, cx, &[(2..3, "", "ONE_POINT_FIVE\n")]);
// Edits to any replica of the base are applied to the branch.
base_buffer_replica.update(cx, |buffer, cx| {
buffer.edit(
[(Point::new(2, 0)..Point::new(2, 0), "TWO_POINT_FIVE\n")],
None,
cx,
)
});
branch_buffer.read_with(cx, |branch_buffer, cx| {
assert_eq!(
base_buffer.read(cx).text(),
"ZERO\none\ntwo\nTWO_POINT_FIVE\nthree\n"
);
assert_eq!(
branch_buffer.text(),
"ZERO\none\nONE_POINT_FIVE\ntwo\nTWO_POINT_FIVE\nthree\n"
);
});
// Merging the branch applies all of its changes to the base.
base_buffer.update(cx, |base_buffer, cx| {
base_buffer.merge(&branch_buffer, cx);
assert_eq!(
base_buffer.text(),
"ZERO\none\nONE_POINT_FIVE\ntwo\nTWO_POINT_FIVE\nthree\n"
);
});
}
fn assert_diff_hunks(
buffer: &Model<Buffer>,
cx: &mut TestAppContext,
expected_hunks: &[(Range<u32>, &str, &str)],
) {
buffer
.update(cx, |buffer, cx| buffer.recalculate_diff(cx).unwrap())
.detach();
cx.executor().run_until_parked();
buffer.read_with(cx, |buffer, _| {
let snapshot = buffer.snapshot();
assert_hunks(
snapshot.git_diff_hunks_intersecting_range(Anchor::MIN..Anchor::MAX),
&snapshot,
&buffer.diff_base().unwrap().to_string(),
expected_hunks,
);
});
}
#[gpui::test(iterations = 100)]
fn test_random_collaboration(cx: &mut AppContext, mut rng: StdRng) {
let min_peers = env::var("MIN_PEERS")
@ -2407,10 +2526,15 @@ fn test_random_collaboration(cx: &mut AppContext, mut rng: StdRng) {
buffer.set_group_interval(Duration::from_millis(rng.gen_range(0..=200)));
let network = network.clone();
cx.subscribe(&cx.handle(), move |buffer, _, event, _| {
if let BufferEvent::Operation(op) = event {
network
.lock()
.broadcast(buffer.replica_id(), vec![proto::serialize_operation(op)]);
if let BufferEvent::Operation {
operation,
is_local: true,
} = event
{
network.lock().broadcast(
buffer.replica_id(),
vec![proto::serialize_operation(operation)],
);
}
})
.detach();
@ -2533,10 +2657,14 @@ fn test_random_collaboration(cx: &mut AppContext, mut rng: StdRng) {
new_buffer.set_group_interval(Duration::from_millis(rng.gen_range(0..=200)));
let network = network.clone();
cx.subscribe(&cx.handle(), move |buffer, _, event, _| {
if let BufferEvent::Operation(op) = event {
if let BufferEvent::Operation {
operation,
is_local: true,
} = event
{
network.lock().broadcast(
buffer.replica_id(),
vec![proto::serialize_operation(op)],
vec![proto::serialize_operation(operation)],
);
}
})