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

Max Brunsfeld , Marshall Bowers , and Marshall created

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>

Change summary

Cargo.lock                                   |   1 
crates/assistant/src/context.rs              |   9 
crates/channel/src/channel_buffer.rs         |   5 
crates/clock/src/clock.rs                    |  83 ++++++++---
crates/editor/src/actions.rs                 |   1 
crates/editor/src/editor.rs                  |  78 +++++++++--
crates/editor/src/element.rs                 |   5 
crates/editor/src/git.rs                     |  24 +-
crates/editor/src/hunk_diff.rs               |  24 +-
crates/editor/src/proposed_changes_editor.rs | 125 +++++++++++++++++
crates/editor/src/test.rs                    |   6 
crates/git/src/diff.rs                       |  70 ++++-----
crates/language/src/buffer.rs                | 154 ++++++++++++++++-----
crates/language/src/buffer_tests.rs          | 146 +++++++++++++++++++-
crates/multi_buffer/Cargo.toml               |   1 
crates/multi_buffer/src/multi_buffer.rs      |  46 +++--
crates/project/src/project.rs                |   7 
crates/project/src/project_tests.rs          |   2 
crates/remote_server/src/headless_project.rs |   7 
crates/text/src/text.rs                      |  14 ++
20 files changed, 622 insertions(+), 186 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -7055,7 +7055,6 @@ dependencies = [
  "ctor",
  "env_logger",
  "futures 0.3.30",
- "git",
  "gpui",
  "itertools 0.13.0",
  "language",

crates/assistant/src/context.rs 🔗

@@ -1006,9 +1006,12 @@ impl Context {
         cx: &mut ModelContext<Self>,
     ) {
         match event {
-            language::BufferEvent::Operation(operation) => cx.emit(ContextEvent::Operation(
-                ContextOperation::BufferOperation(operation.clone()),
-            )),
+            language::BufferEvent::Operation {
+                operation,
+                is_local: true,
+            } => cx.emit(ContextEvent::Operation(ContextOperation::BufferOperation(
+                operation.clone(),
+            ))),
             language::BufferEvent::Edited => {
                 self.count_remaining_tokens(cx);
                 self.reparse(cx);

crates/channel/src/channel_buffer.rs 🔗

@@ -175,7 +175,10 @@ impl ChannelBuffer {
         cx: &mut ModelContext<Self>,
     ) {
         match event {
-            language::BufferEvent::Operation(operation) => {
+            language::BufferEvent::Operation {
+                operation,
+                is_local: true,
+            } => {
                 if *ZED_ALWAYS_ACTIVE {
                     if let language::Operation::UpdateSelections { selections, .. } = operation {
                         if selections.is_empty() {

crates/clock/src/clock.rs 🔗

@@ -9,6 +9,8 @@ use std::{
 
 pub use system_clock::*;
 
+pub const LOCAL_BRANCH_REPLICA_ID: u16 = u16::MAX;
+
 /// A unique identifier for each distributed node.
 pub type ReplicaId = u16;
 
@@ -25,7 +27,10 @@ pub struct Lamport {
 
 /// A [vector clock](https://en.wikipedia.org/wiki/Vector_clock).
 #[derive(Clone, Default, Hash, Eq, PartialEq)]
-pub struct Global(SmallVec<[u32; 8]>);
+pub struct Global {
+    values: SmallVec<[u32; 8]>,
+    local_branch_value: u32,
+}
 
 impl Global {
     pub fn new() -> Self {
@@ -33,41 +38,51 @@ impl Global {
     }
 
     pub fn get(&self, replica_id: ReplicaId) -> Seq {
-        self.0.get(replica_id as usize).copied().unwrap_or(0) as Seq
+        if replica_id == LOCAL_BRANCH_REPLICA_ID {
+            self.local_branch_value
+        } else {
+            self.values.get(replica_id as usize).copied().unwrap_or(0) as Seq
+        }
     }
 
     pub fn observe(&mut self, timestamp: Lamport) {
         if timestamp.value > 0 {
-            let new_len = timestamp.replica_id as usize + 1;
-            if new_len > self.0.len() {
-                self.0.resize(new_len, 0);
+            if timestamp.replica_id == LOCAL_BRANCH_REPLICA_ID {
+                self.local_branch_value = cmp::max(self.local_branch_value, timestamp.value);
+            } else {
+                let new_len = timestamp.replica_id as usize + 1;
+                if new_len > self.values.len() {
+                    self.values.resize(new_len, 0);
+                }
+
+                let entry = &mut self.values[timestamp.replica_id as usize];
+                *entry = cmp::max(*entry, timestamp.value);
             }
-
-            let entry = &mut self.0[timestamp.replica_id as usize];
-            *entry = cmp::max(*entry, timestamp.value);
         }
     }
 
     pub fn join(&mut self, other: &Self) {
-        if other.0.len() > self.0.len() {
-            self.0.resize(other.0.len(), 0);
+        if other.values.len() > self.values.len() {
+            self.values.resize(other.values.len(), 0);
         }
 
-        for (left, right) in self.0.iter_mut().zip(&other.0) {
+        for (left, right) in self.values.iter_mut().zip(&other.values) {
             *left = cmp::max(*left, *right);
         }
+
+        self.local_branch_value = cmp::max(self.local_branch_value, other.local_branch_value);
     }
 
     pub fn meet(&mut self, other: &Self) {
-        if other.0.len() > self.0.len() {
-            self.0.resize(other.0.len(), 0);
+        if other.values.len() > self.values.len() {
+            self.values.resize(other.values.len(), 0);
         }
 
         let mut new_len = 0;
         for (ix, (left, right)) in self
-            .0
+            .values
             .iter_mut()
-            .zip(other.0.iter().chain(iter::repeat(&0)))
+            .zip(other.values.iter().chain(iter::repeat(&0)))
             .enumerate()
         {
             if *left == 0 {
@@ -80,7 +95,8 @@ impl Global {
                 new_len = ix + 1;
             }
         }
-        self.0.resize(new_len, 0);
+        self.values.resize(new_len, 0);
+        self.local_branch_value = cmp::min(self.local_branch_value, other.local_branch_value);
     }
 
     pub fn observed(&self, timestamp: Lamport) -> bool {
@@ -88,34 +104,44 @@ impl Global {
     }
 
     pub fn observed_any(&self, other: &Self) -> bool {
-        self.0
+        self.values
             .iter()
-            .zip(other.0.iter())
+            .zip(other.values.iter())
             .any(|(left, right)| *right > 0 && left >= right)
+            || (other.local_branch_value > 0 && self.local_branch_value >= other.local_branch_value)
     }
 
     pub fn observed_all(&self, other: &Self) -> bool {
-        let mut rhs = other.0.iter();
-        self.0.iter().all(|left| match rhs.next() {
+        let mut rhs = other.values.iter();
+        self.values.iter().all(|left| match rhs.next() {
             Some(right) => left >= right,
             None => true,
         }) && rhs.next().is_none()
+            && self.local_branch_value >= other.local_branch_value
     }
 
     pub fn changed_since(&self, other: &Self) -> bool {
-        self.0.len() > other.0.len()
+        self.values.len() > other.values.len()
             || self
-                .0
+                .values
                 .iter()
-                .zip(other.0.iter())
+                .zip(other.values.iter())
                 .any(|(left, right)| left > right)
+            || self.local_branch_value > other.local_branch_value
     }
 
     pub fn iter(&self) -> impl Iterator<Item = Lamport> + '_ {
-        self.0.iter().enumerate().map(|(replica_id, seq)| Lamport {
-            replica_id: replica_id as ReplicaId,
-            value: *seq,
-        })
+        self.values
+            .iter()
+            .enumerate()
+            .map(|(replica_id, seq)| Lamport {
+                replica_id: replica_id as ReplicaId,
+                value: *seq,
+            })
+            .chain((self.local_branch_value > 0).then_some(Lamport {
+                replica_id: LOCAL_BRANCH_REPLICA_ID,
+                value: self.local_branch_value,
+            }))
     }
 }
 
@@ -192,6 +218,9 @@ impl fmt::Debug for Global {
             }
             write!(f, "{}: {}", timestamp.replica_id, timestamp.value)?;
         }
+        if self.local_branch_value > 0 {
+            write!(f, "<branch>: {}", self.local_branch_value)?;
+        }
         write!(f, "}}")
     }
 }

crates/editor/src/actions.rs 🔗

@@ -273,6 +273,7 @@ gpui::actions!(
         NextScreen,
         OpenExcerpts,
         OpenExcerptsSplit,
+        OpenProposedChangesEditor,
         OpenFile,
         OpenPermalinkToLine,
         OpenUrl,

crates/editor/src/editor.rs 🔗

@@ -35,6 +35,7 @@ mod lsp_ext;
 mod mouse_context_menu;
 pub mod movement;
 mod persistence;
+mod proposed_changes_editor;
 mod rust_analyzer_ext;
 pub mod scroll;
 mod selections_collection;
@@ -46,7 +47,7 @@ mod signature_help;
 #[cfg(any(test, feature = "test-support"))]
 pub mod test;
 
-use ::git::diff::{DiffHunk, DiffHunkStatus};
+use ::git::diff::DiffHunkStatus;
 use ::git::{parse_git_remote_url, BuildPermalinkParams, GitHostingProviderRegistry};
 pub(crate) use actions::*;
 use aho_corasick::AhoCorasick;
@@ -98,6 +99,7 @@ use language::{
 };
 use language::{point_to_lsp, BufferRow, CharClassifier, Runnable, RunnableRange};
 use linked_editing_ranges::refresh_linked_ranges;
+use proposed_changes_editor::{ProposedChangesBuffer, ProposedChangesEditor};
 use similar::{ChangeTag, TextDiff};
 use task::{ResolvedTask, TaskTemplate, TaskVariables};
 
@@ -113,7 +115,9 @@ pub use multi_buffer::{
     Anchor, AnchorRangeExt, ExcerptId, ExcerptRange, MultiBuffer, MultiBufferSnapshot, ToOffset,
     ToPoint,
 };
-use multi_buffer::{ExpandExcerptDirection, MultiBufferPoint, MultiBufferRow, ToOffsetUtf16};
+use multi_buffer::{
+    ExpandExcerptDirection, MultiBufferDiffHunk, MultiBufferPoint, MultiBufferRow, ToOffsetUtf16,
+};
 use ordered_float::OrderedFloat;
 use parking_lot::{Mutex, RwLock};
 use project::project_settings::{GitGutterSetting, ProjectSettings};
@@ -6152,7 +6156,7 @@ impl Editor {
     pub fn prepare_revert_change(
         revert_changes: &mut HashMap<BufferId, Vec<(Range<text::Anchor>, Rope)>>,
         multi_buffer: &Model<MultiBuffer>,
-        hunk: &DiffHunk<MultiBufferRow>,
+        hunk: &MultiBufferDiffHunk,
         cx: &AppContext,
     ) -> Option<()> {
         let buffer = multi_buffer.read(cx).buffer(hunk.buffer_id)?;
@@ -9338,7 +9342,7 @@ impl Editor {
         snapshot: &DisplaySnapshot,
         initial_point: Point,
         is_wrapped: bool,
-        hunks: impl Iterator<Item = DiffHunk<MultiBufferRow>>,
+        hunks: impl Iterator<Item = MultiBufferDiffHunk>,
         cx: &mut ViewContext<Editor>,
     ) -> bool {
         let display_point = initial_point.to_display_point(snapshot);
@@ -11885,6 +11889,52 @@ impl Editor {
         self.searchable
     }
 
+    fn open_proposed_changes_editor(
+        &mut self,
+        _: &OpenProposedChangesEditor,
+        cx: &mut ViewContext<Self>,
+    ) {
+        let Some(workspace) = self.workspace() else {
+            cx.propagate();
+            return;
+        };
+
+        let buffer = self.buffer.read(cx);
+        let mut new_selections_by_buffer = HashMap::default();
+        for selection in self.selections.all::<usize>(cx) {
+            for (buffer, mut range, _) in
+                buffer.range_to_buffer_ranges(selection.start..selection.end, cx)
+            {
+                if selection.reversed {
+                    mem::swap(&mut range.start, &mut range.end);
+                }
+                let mut range = range.to_point(buffer.read(cx));
+                range.start.column = 0;
+                range.end.column = buffer.read(cx).line_len(range.end.row);
+                new_selections_by_buffer
+                    .entry(buffer)
+                    .or_insert(Vec::new())
+                    .push(range)
+            }
+        }
+
+        let proposed_changes_buffers = new_selections_by_buffer
+            .into_iter()
+            .map(|(buffer, ranges)| ProposedChangesBuffer { buffer, ranges })
+            .collect::<Vec<_>>();
+        let proposed_changes_editor = cx.new_view(|cx| {
+            ProposedChangesEditor::new(proposed_changes_buffers, self.project.clone(), cx)
+        });
+
+        cx.window_context().defer(move |cx| {
+            workspace.update(cx, |workspace, cx| {
+                workspace.active_pane().update(cx, |pane, cx| {
+                    pane.add_item(Box::new(proposed_changes_editor), true, true, None, cx);
+                });
+            });
+        });
+    }
+
     fn open_excerpts_in_split(&mut self, _: &OpenExcerptsSplit, cx: &mut ViewContext<Self>) {
         self.open_excerpts_common(true, cx)
     }
@@ -12399,7 +12449,7 @@ impl Editor {
 fn hunks_for_selections(
     multi_buffer_snapshot: &MultiBufferSnapshot,
     selections: &[Selection<Anchor>],
-) -> Vec<DiffHunk<MultiBufferRow>> {
+) -> Vec<MultiBufferDiffHunk> {
     let buffer_rows_for_selections = selections.iter().map(|selection| {
         let head = selection.head();
         let tail = selection.tail();
@@ -12418,7 +12468,7 @@ fn hunks_for_selections(
 pub fn hunks_for_rows(
     rows: impl Iterator<Item = Range<MultiBufferRow>>,
     multi_buffer_snapshot: &MultiBufferSnapshot,
-) -> Vec<DiffHunk<MultiBufferRow>> {
+) -> Vec<MultiBufferDiffHunk> {
     let mut hunks = Vec::new();
     let mut processed_buffer_rows: HashMap<BufferId, HashSet<Range<text::Anchor>>> =
         HashMap::default();
@@ -12430,14 +12480,14 @@ pub fn hunks_for_rows(
             // when the caret is just above or just below the deleted hunk.
             let allow_adjacent = hunk_status(&hunk) == 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
+                hunk.row_range.overlaps(&query_rows)
+                    || hunk.row_range.start == query_rows.end
+                    || hunk.row_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
+                // `hunk.row_range` is exclusive (e.g. [2..3] means 2nd row is selected)
+                hunk.row_range.overlaps(&selected_multi_buffer_rows)
+                    || selected_multi_buffer_rows.end == hunk.row_range.start
             };
             if related_to_selection {
                 if !processed_buffer_rows
@@ -13738,10 +13788,10 @@ impl RowRangeExt for Range<DisplayRow> {
     }
 }
 
-fn hunk_status(hunk: &DiffHunk<MultiBufferRow>) -> DiffHunkStatus {
+fn hunk_status(hunk: &MultiBufferDiffHunk) -> DiffHunkStatus {
     if hunk.diff_base_byte_range.is_empty() {
         DiffHunkStatus::Added
-    } else if hunk.associated_range.is_empty() {
+    } else if hunk.row_range.is_empty() {
         DiffHunkStatus::Removed
     } else {
         DiffHunkStatus::Modified

crates/editor/src/element.rs 🔗

@@ -346,6 +346,7 @@ impl EditorElement {
         register_action(view, cx, Editor::toggle_code_actions);
         register_action(view, cx, Editor::open_excerpts);
         register_action(view, cx, Editor::open_excerpts_in_split);
+        register_action(view, cx, Editor::open_proposed_changes_editor);
         register_action(view, cx, Editor::toggle_soft_wrap);
         register_action(view, cx, Editor::toggle_tab_bar);
         register_action(view, cx, Editor::toggle_line_numbers);
@@ -3710,11 +3711,11 @@ impl EditorElement {
                                     )
                                     .map(|hunk| {
                                         let start_display_row =
-                                            MultiBufferPoint::new(hunk.associated_range.start.0, 0)
+                                            MultiBufferPoint::new(hunk.row_range.start.0, 0)
                                                 .to_display_point(&snapshot.display_snapshot)
                                                 .row();
                                         let mut end_display_row =
-                                            MultiBufferPoint::new(hunk.associated_range.end.0, 0)
+                                            MultiBufferPoint::new(hunk.row_range.end.0, 0)
                                                 .to_display_point(&snapshot.display_snapshot)
                                                 .row();
                                         if end_display_row != start_display_row {

crates/editor/src/git.rs 🔗

@@ -2,9 +2,9 @@ pub mod blame;
 
 use std::ops::Range;
 
-use git::diff::{DiffHunk, DiffHunkStatus};
+use git::diff::DiffHunkStatus;
 use language::Point;
-use multi_buffer::{Anchor, MultiBufferRow};
+use multi_buffer::{Anchor, MultiBufferDiffHunk};
 
 use crate::{
     display_map::{DisplaySnapshot, ToDisplayPoint},
@@ -49,25 +49,25 @@ impl DisplayDiffHunk {
 }
 
 pub fn diff_hunk_to_display(
-    hunk: &DiffHunk<MultiBufferRow>,
+    hunk: &MultiBufferDiffHunk,
     snapshot: &DisplaySnapshot,
 ) -> DisplayDiffHunk {
-    let hunk_start_point = Point::new(hunk.associated_range.start.0, 0);
-    let hunk_start_point_sub = Point::new(hunk.associated_range.start.0.saturating_sub(1), 0);
+    let hunk_start_point = Point::new(hunk.row_range.start.0, 0);
+    let hunk_start_point_sub = Point::new(hunk.row_range.start.0.saturating_sub(1), 0);
     let hunk_end_point_sub = Point::new(
-        hunk.associated_range
+        hunk.row_range
             .end
             .0
             .saturating_sub(1)
-            .max(hunk.associated_range.start.0),
+            .max(hunk.row_range.start.0),
         0,
     );
 
     let status = hunk_status(hunk);
     let is_removal = status == DiffHunkStatus::Removed;
 
-    let folds_start = Point::new(hunk.associated_range.start.0.saturating_sub(2), 0);
-    let folds_end = Point::new(hunk.associated_range.end.0 + 2, 0);
+    let folds_start = Point::new(hunk.row_range.start.0.saturating_sub(2), 0);
+    let folds_end = Point::new(hunk.row_range.end.0 + 2, 0);
     let folds_range = folds_start..folds_end;
 
     let containing_fold = snapshot.folds_in_range(folds_range).find(|fold| {
@@ -87,7 +87,7 @@ pub fn diff_hunk_to_display(
     } else {
         let start = hunk_start_point.to_display_point(snapshot).row();
 
-        let hunk_end_row = hunk.associated_range.end.max(hunk.associated_range.start);
+        let hunk_end_row = hunk.row_range.end.max(hunk.row_range.start);
         let hunk_end_point = Point::new(hunk_end_row.0, 0);
 
         let multi_buffer_start = snapshot.buffer_snapshot.anchor_after(hunk_start_point);
@@ -288,7 +288,7 @@ mod tests {
         assert_eq!(
             snapshot
                 .git_diff_hunks_in_range(MultiBufferRow(0)..MultiBufferRow(12))
-                .map(|hunk| (hunk_status(&hunk), hunk.associated_range))
+                .map(|hunk| (hunk_status(&hunk), hunk.row_range))
                 .collect::<Vec<_>>(),
             &expected,
         );
@@ -296,7 +296,7 @@ mod tests {
         assert_eq!(
             snapshot
                 .git_diff_hunks_in_range_rev(MultiBufferRow(0)..MultiBufferRow(12))
-                .map(|hunk| (hunk_status(&hunk), hunk.associated_range))
+                .map(|hunk| (hunk_status(&hunk), hunk.row_range))
                 .collect::<Vec<_>>(),
             expected
                 .iter()

crates/editor/src/hunk_diff.rs 🔗

@@ -4,11 +4,12 @@ use std::{
 };
 
 use collections::{hash_map, HashMap, HashSet};
-use git::diff::{DiffHunk, DiffHunkStatus};
+use git::diff::DiffHunkStatus;
 use gpui::{Action, AppContext, CursorStyle, Hsla, Model, MouseButton, Subscription, Task, View};
 use language::Buffer;
 use multi_buffer::{
-    Anchor, AnchorRangeExt, ExcerptRange, MultiBuffer, MultiBufferRow, MultiBufferSnapshot, ToPoint,
+    Anchor, AnchorRangeExt, ExcerptRange, MultiBuffer, MultiBufferDiffHunk, MultiBufferRow,
+    MultiBufferSnapshot, ToPoint,
 };
 use settings::SettingsStore;
 use text::{BufferId, Point};
@@ -190,9 +191,9 @@ impl Editor {
             .buffer_snapshot
             .git_diff_hunks_in_range(MultiBufferRow::MIN..MultiBufferRow::MAX)
             .filter(|hunk| {
-                let hunk_display_row_range = Point::new(hunk.associated_range.start.0, 0)
+                let hunk_display_row_range = Point::new(hunk.row_range.start.0, 0)
                     .to_display_point(&snapshot.display_snapshot)
-                    ..Point::new(hunk.associated_range.end.0, 0)
+                    ..Point::new(hunk.row_range.end.0, 0)
                         .to_display_point(&snapshot.display_snapshot);
                 let row_range_end =
                     display_rows_with_expanded_hunks.get(&hunk_display_row_range.start.row());
@@ -203,7 +204,7 @@ impl Editor {
 
     fn toggle_hunks_expanded(
         &mut self,
-        hunks_to_toggle: Vec<DiffHunk<MultiBufferRow>>,
+        hunks_to_toggle: Vec<MultiBufferDiffHunk>,
         cx: &mut ViewContext<Self>,
     ) {
         let previous_toggle_task = self.expanded_hunks.hunk_update_tasks.remove(&None);
@@ -274,8 +275,8 @@ impl Editor {
                     });
                     for remaining_hunk in hunks_to_toggle {
                         let remaining_hunk_point_range =
-                            Point::new(remaining_hunk.associated_range.start.0, 0)
-                                ..Point::new(remaining_hunk.associated_range.end.0, 0);
+                            Point::new(remaining_hunk.row_range.start.0, 0)
+                                ..Point::new(remaining_hunk.row_range.end.0, 0);
                         hunks_to_expand.push(HoveredHunk {
                             status: hunk_status(&remaining_hunk),
                             multi_buffer_range: remaining_hunk_point_range
@@ -705,7 +706,7 @@ impl Editor {
 fn to_diff_hunk(
     hovered_hunk: &HoveredHunk,
     multi_buffer_snapshot: &MultiBufferSnapshot,
-) -> Option<DiffHunk<MultiBufferRow>> {
+) -> Option<MultiBufferDiffHunk> {
     let buffer_id = hovered_hunk
         .multi_buffer_range
         .start
@@ -716,9 +717,8 @@ fn to_diff_hunk(
     let point_range = hovered_hunk
         .multi_buffer_range
         .to_point(multi_buffer_snapshot);
-    Some(DiffHunk {
-        associated_range: MultiBufferRow(point_range.start.row)
-            ..MultiBufferRow(point_range.end.row),
+    Some(MultiBufferDiffHunk {
+        row_range: MultiBufferRow(point_range.start.row)..MultiBufferRow(point_range.end.row),
         buffer_id,
         buffer_range,
         diff_base_byte_range: hovered_hunk.diff_base_byte_range.clone(),
@@ -868,7 +868,7 @@ fn editor_with_deleted_text(
 fn buffer_diff_hunk(
     buffer_snapshot: &MultiBufferSnapshot,
     row_range: Range<Point>,
-) -> Option<DiffHunk<MultiBufferRow>> {
+) -> Option<MultiBufferDiffHunk> {
     let mut hunks = buffer_snapshot.git_diff_hunks_in_range(
         MultiBufferRow(row_range.start.row)..MultiBufferRow(row_range.end.row),
     );

crates/editor/src/proposed_changes_editor.rs 🔗

@@ -0,0 +1,125 @@
+use crate::{Editor, EditorEvent};
+use collections::HashSet;
+use futures::{channel::mpsc, future::join_all};
+use gpui::{AppContext, EventEmitter, FocusableView, Model, Render, Subscription, Task, View};
+use language::{Buffer, BufferEvent, Capability};
+use multi_buffer::{ExcerptRange, MultiBuffer};
+use project::Project;
+use smol::stream::StreamExt;
+use std::{ops::Range, time::Duration};
+use text::ToOffset;
+use ui::prelude::*;
+use workspace::Item;
+
+pub struct ProposedChangesEditor {
+    editor: View<Editor>,
+    _subscriptions: Vec<Subscription>,
+    _recalculate_diffs_task: Task<Option<()>>,
+    recalculate_diffs_tx: mpsc::UnboundedSender<Model<Buffer>>,
+}
+
+pub struct ProposedChangesBuffer<T> {
+    pub buffer: Model<Buffer>,
+    pub ranges: Vec<Range<T>>,
+}
+
+impl ProposedChangesEditor {
+    pub fn new<T: ToOffset>(
+        buffers: Vec<ProposedChangesBuffer<T>>,
+        project: Option<Model<Project>>,
+        cx: &mut ViewContext<Self>,
+    ) -> Self {
+        let mut subscriptions = Vec::new();
+        let multibuffer = cx.new_model(|_| MultiBuffer::new(Capability::ReadWrite));
+
+        for buffer in buffers {
+            let branch_buffer = buffer.buffer.update(cx, |buffer, cx| buffer.branch(cx));
+            subscriptions.push(cx.subscribe(&branch_buffer, Self::on_buffer_event));
+
+            multibuffer.update(cx, |multibuffer, cx| {
+                multibuffer.push_excerpts(
+                    branch_buffer,
+                    buffer.ranges.into_iter().map(|range| ExcerptRange {
+                        context: range,
+                        primary: None,
+                    }),
+                    cx,
+                );
+            });
+        }
+
+        let (recalculate_diffs_tx, mut recalculate_diffs_rx) = mpsc::unbounded();
+
+        Self {
+            editor: cx
+                .new_view(|cx| Editor::for_multibuffer(multibuffer.clone(), project, true, cx)),
+            recalculate_diffs_tx,
+            _recalculate_diffs_task: cx.spawn(|_, mut cx| async move {
+                let mut buffers_to_diff = HashSet::default();
+                while let Some(buffer) = recalculate_diffs_rx.next().await {
+                    buffers_to_diff.insert(buffer);
+
+                    loop {
+                        cx.background_executor()
+                            .timer(Duration::from_millis(250))
+                            .await;
+                        let mut had_further_changes = false;
+                        while let Ok(next_buffer) = recalculate_diffs_rx.try_next() {
+                            buffers_to_diff.insert(next_buffer?);
+                            had_further_changes = true;
+                        }
+                        if !had_further_changes {
+                            break;
+                        }
+                    }
+
+                    join_all(buffers_to_diff.drain().filter_map(|buffer| {
+                        buffer
+                            .update(&mut cx, |buffer, cx| buffer.recalculate_diff(cx))
+                            .ok()?
+                    }))
+                    .await;
+                }
+                None
+            }),
+            _subscriptions: subscriptions,
+        }
+    }
+
+    fn on_buffer_event(
+        &mut self,
+        buffer: Model<Buffer>,
+        event: &BufferEvent,
+        _cx: &mut ViewContext<Self>,
+    ) {
+        if let BufferEvent::Edited = event {
+            self.recalculate_diffs_tx.unbounded_send(buffer).ok();
+        }
+    }
+}
+
+impl Render for ProposedChangesEditor {
+    fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
+        self.editor.clone()
+    }
+}
+
+impl FocusableView for ProposedChangesEditor {
+    fn focus_handle(&self, cx: &AppContext) -> gpui::FocusHandle {
+        self.editor.focus_handle(cx)
+    }
+}
+
+impl EventEmitter<EditorEvent> for ProposedChangesEditor {}
+
+impl Item for ProposedChangesEditor {
+    type Event = EditorEvent;
+
+    fn tab_icon(&self, _cx: &ui::WindowContext) -> Option<Icon> {
+        Some(Icon::new(IconName::Pencil))
+    }
+
+    fn tab_content_text(&self, _cx: &WindowContext) -> Option<SharedString> {
+        Some("Proposed changes".into())
+    }
+}

crates/editor/src/test.rs 🔗

@@ -108,16 +108,16 @@ pub fn editor_hunks(
         .buffer_snapshot
         .git_diff_hunks_in_range(MultiBufferRow::MIN..MultiBufferRow::MAX)
         .map(|hunk| {
-            let display_range = Point::new(hunk.associated_range.start.0, 0)
+            let display_range = Point::new(hunk.row_range.start.0, 0)
                 .to_display_point(snapshot)
                 .row()
-                ..Point::new(hunk.associated_range.end.0, 0)
+                ..Point::new(hunk.row_range.end.0, 0)
                     .to_display_point(snapshot)
                     .row();
             let (_, buffer, _) = editor
                 .buffer()
                 .read(cx)
-                .excerpt_containing(Point::new(hunk.associated_range.start.0, 0), cx)
+                .excerpt_containing(Point::new(hunk.row_range.start.0, 0), cx)
                 .expect("no excerpt for expanded buffer's hunk start");
             let diff_base = buffer
                 .read(cx)

crates/git/src/diff.rs 🔗

@@ -1,7 +1,7 @@
 use rope::Rope;
 use std::{iter, ops::Range};
 use sum_tree::SumTree;
-use text::{Anchor, BufferId, BufferSnapshot, OffsetRangeExt, Point};
+use text::{Anchor, BufferSnapshot, OffsetRangeExt, Point};
 
 pub use git2 as libgit;
 use libgit::{DiffLineType as GitDiffLineType, DiffOptions as GitOptions, Patch as GitPatch};
@@ -13,29 +13,30 @@ pub enum DiffHunkStatus {
     Removed,
 }
 
-/// A diff hunk, representing a range of consequent lines in a singleton buffer, associated with a generic range.
+/// A diff hunk resolved to rows in the buffer.
 #[derive(Debug, Clone, PartialEq, Eq)]
-pub struct DiffHunk<T> {
-    /// 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<T>,
-    /// 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 struct DiffHunk {
+    /// The buffer range, expressed in terms of rows.
+    pub row_range: Range<u32>,
+    /// The range in the buffer to which this hunk corresponds.
     pub buffer_range: Range<Anchor>,
-    /// Original singleton buffer text before the change, that was instead of the `buffer_range`.
+    /// The range in the buffer's diff base text to which this hunk corresponds.
     pub diff_base_byte_range: Range<usize>,
 }
 
-impl sum_tree::Item for DiffHunk<Anchor> {
+/// We store [`InternalDiffHunk`]s internally so we don't need to store the additional row range.
+#[derive(Debug, Clone)]
+struct InternalDiffHunk {
+    buffer_range: Range<Anchor>,
+    diff_base_byte_range: Range<usize>,
+}
+
+impl sum_tree::Item for InternalDiffHunk {
     type Summary = DiffHunkSummary;
 
     fn summary(&self) -> Self::Summary {
         DiffHunkSummary {
-            buffer_range: self.associated_range.clone(),
+            buffer_range: self.buffer_range.clone(),
         }
     }
 }
@@ -64,7 +65,7 @@ impl sum_tree::Summary for DiffHunkSummary {
 #[derive(Debug, Clone)]
 pub struct BufferDiff {
     last_buffer_version: Option<clock::Global>,
-    tree: SumTree<DiffHunk<Anchor>>,
+    tree: SumTree<InternalDiffHunk>,
 }
 
 impl BufferDiff {
@@ -79,11 +80,12 @@ impl BufferDiff {
         self.tree.is_empty()
     }
 
+    #[cfg(any(test, feature = "test-support"))]
     pub fn hunks_in_row_range<'a>(
         &'a self,
         range: Range<u32>,
         buffer: &'a BufferSnapshot,
-    ) -> impl 'a + Iterator<Item = DiffHunk<u32>> {
+    ) -> impl 'a + Iterator<Item = DiffHunk> {
         let start = buffer.anchor_before(Point::new(range.start, 0));
         let end = buffer.anchor_after(Point::new(range.end, 0));
 
@@ -94,7 +96,7 @@ impl BufferDiff {
         &'a self,
         range: Range<Anchor>,
         buffer: &'a BufferSnapshot,
-    ) -> impl 'a + Iterator<Item = DiffHunk<u32>> {
+    ) -> impl 'a + Iterator<Item = DiffHunk> {
         let mut cursor = self
             .tree
             .filter::<_, DiffHunkSummary>(buffer, move |summary| {
@@ -109,11 +111,8 @@ impl BufferDiff {
         })
         .flat_map(move |hunk| {
             [
-                (
-                    &hunk.associated_range.start,
-                    hunk.diff_base_byte_range.start,
-                ),
-                (&hunk.associated_range.end, hunk.diff_base_byte_range.end),
+                (&hunk.buffer_range.start, hunk.diff_base_byte_range.start),
+                (&hunk.buffer_range.end, hunk.diff_base_byte_range.end),
             ]
             .into_iter()
         });
@@ -129,10 +128,9 @@ impl BufferDiff {
             }
 
             Some(DiffHunk {
-                associated_range: start_point.row..end_point.row,
+                row_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(),
             })
         })
     }
@@ -141,7 +139,7 @@ impl BufferDiff {
         &'a self,
         range: Range<Anchor>,
         buffer: &'a BufferSnapshot,
-    ) -> impl 'a + Iterator<Item = DiffHunk<u32>> {
+    ) -> impl 'a + Iterator<Item = DiffHunk> {
         let mut cursor = self
             .tree
             .filter::<_, DiffHunkSummary>(buffer, move |summary| {
@@ -154,7 +152,7 @@ impl BufferDiff {
             cursor.prev(buffer);
 
             let hunk = cursor.item()?;
-            let range = hunk.associated_range.to_point(buffer);
+            let range = hunk.buffer_range.to_point(buffer);
             let end_row = if range.end.column > 0 {
                 range.end.row + 1
             } else {
@@ -162,10 +160,9 @@ impl BufferDiff {
             };
 
             Some(DiffHunk {
-                associated_range: range.start.row..end_row,
+                row_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,
             })
         })
     }
@@ -196,7 +193,7 @@ impl BufferDiff {
     }
 
     #[cfg(test)]
-    fn hunks<'a>(&'a self, text: &'a BufferSnapshot) -> impl 'a + Iterator<Item = DiffHunk<u32>> {
+    fn hunks<'a>(&'a self, text: &'a BufferSnapshot) -> impl 'a + Iterator<Item = DiffHunk> {
         let start = text.anchor_before(Point::new(0, 0));
         let end = text.anchor_after(Point::new(u32::MAX, u32::MAX));
         self.hunks_intersecting_range(start..end, text)
@@ -229,7 +226,7 @@ impl BufferDiff {
         hunk_index: usize,
         buffer: &text::BufferSnapshot,
         buffer_row_divergence: &mut i64,
-    ) -> DiffHunk<Anchor> {
+    ) -> InternalDiffHunk {
         let line_item_count = patch.num_lines_in_hunk(hunk_index).unwrap();
         assert!(line_item_count > 0);
 
@@ -284,11 +281,9 @@ impl BufferDiff {
         let start = Point::new(buffer_row_range.start, 0);
         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(),
+        InternalDiffHunk {
             buffer_range,
             diff_base_byte_range,
-            buffer_id: buffer.remote_id(),
         }
     }
 }
@@ -302,17 +297,16 @@ pub fn assert_hunks<Iter>(
     diff_base: &str,
     expected_hunks: &[(Range<u32>, &str, &str)],
 ) where
-    Iter: Iterator<Item = DiffHunk<u32>>,
+    Iter: Iterator<Item = DiffHunk>,
 {
     let actual_hunks = diff_hunks
         .map(|hunk| {
             (
-                hunk.associated_range.clone(),
+                hunk.row_range.clone(),
                 &diff_base[hunk.diff_base_byte_range],
                 buffer
                     .text_for_range(
-                        Point::new(hunk.associated_range.start, 0)
-                            ..Point::new(hunk.associated_range.end, 0),
+                        Point::new(hunk.row_range.start, 0)..Point::new(hunk.row_range.end, 0),
                     )
                     .collect::<String>(),
             )

crates/language/src/buffer.rs 🔗

@@ -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)
     }
 

crates/language/src/buffer_tests.rs 🔗

@@ -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)],
                             );
                         }
                     })

crates/multi_buffer/Cargo.toml 🔗

@@ -27,7 +27,6 @@ collections.workspace = true
 ctor.workspace = true
 env_logger.workspace = true
 futures.workspace = true
-git.workspace = true
 gpui.workspace = true
 itertools.workspace = true
 language.workspace = true

crates/multi_buffer/src/multi_buffer.rs 🔗

@@ -5,7 +5,6 @@ use anyhow::{anyhow, Result};
 use clock::ReplicaId;
 use collections::{BTreeMap, Bound, HashMap, HashSet};
 use futures::{channel::mpsc, SinkExt};
-use git::diff::DiffHunk;
 use gpui::{AppContext, EntityId, EventEmitter, Model, ModelContext};
 use itertools::Itertools;
 use language::{
@@ -110,6 +109,19 @@ pub enum Event {
     DiagnosticsUpdated,
 }
 
+/// A diff hunk, representing a range of consequent lines in a multibuffer.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct MultiBufferDiffHunk {
+    /// The row range in the multibuffer where this diff hunk appears.
+    pub row_range: Range<MultiBufferRow>,
+    /// The buffer ID that this hunk belongs to.
+    pub buffer_id: BufferId,
+    /// The range of the underlying buffer that this hunk corresponds to.
+    pub buffer_range: Range<text::Anchor>,
+    /// The range within the buffer's diff base that this hunk corresponds to.
+    pub diff_base_byte_range: Range<usize>,
+}
+
 pub type MultiBufferPoint = Point;
 
 #[derive(Copy, Clone, Debug, Default, Eq, Ord, PartialOrd, PartialEq, serde::Deserialize)]
@@ -1711,7 +1723,7 @@ impl MultiBuffer {
             }
 
             //
-            language::BufferEvent::Operation(_) => return,
+            language::BufferEvent::Operation { .. } => return,
         });
     }
 
@@ -3561,7 +3573,7 @@ impl MultiBufferSnapshot {
     pub fn git_diff_hunks_in_range_rev(
         &self,
         row_range: Range<MultiBufferRow>,
-    ) -> impl Iterator<Item = DiffHunk<MultiBufferRow>> + '_ {
+    ) -> impl Iterator<Item = MultiBufferDiffHunk> + '_ {
         let mut cursor = self.excerpts.cursor::<Point>(&());
 
         cursor.seek(&Point::new(row_range.end.0, 0), Bias::Left, &());
@@ -3599,22 +3611,19 @@ impl MultiBufferSnapshot {
                 .git_diff_hunks_intersecting_range_rev(buffer_start..buffer_end)
                 .map(move |hunk| {
                     let start = multibuffer_start.row
-                        + hunk
-                            .associated_range
-                            .start
-                            .saturating_sub(excerpt_start_point.row);
+                        + hunk.row_range.start.saturating_sub(excerpt_start_point.row);
                     let end = multibuffer_start.row
                         + hunk
-                            .associated_range
+                            .row_range
                             .end
                             .min(excerpt_end_point.row + 1)
                             .saturating_sub(excerpt_start_point.row);
 
-                    DiffHunk {
-                        associated_range: MultiBufferRow(start)..MultiBufferRow(end),
+                    MultiBufferDiffHunk {
+                        row_range: MultiBufferRow(start)..MultiBufferRow(end),
                         diff_base_byte_range: hunk.diff_base_byte_range.clone(),
                         buffer_range: hunk.buffer_range.clone(),
-                        buffer_id: hunk.buffer_id,
+                        buffer_id: excerpt.buffer_id,
                     }
                 });
 
@@ -3628,7 +3637,7 @@ impl MultiBufferSnapshot {
     pub fn git_diff_hunks_in_range(
         &self,
         row_range: Range<MultiBufferRow>,
-    ) -> impl Iterator<Item = DiffHunk<MultiBufferRow>> + '_ {
+    ) -> impl Iterator<Item = MultiBufferDiffHunk> + '_ {
         let mut cursor = self.excerpts.cursor::<Point>(&());
 
         cursor.seek(&Point::new(row_range.start.0, 0), Bias::Left, &());
@@ -3673,23 +3682,20 @@ impl MultiBufferSnapshot {
                         MultiBufferRow(0)..MultiBufferRow(1)
                     } else {
                         let start = multibuffer_start.row
-                            + hunk
-                                .associated_range
-                                .start
-                                .saturating_sub(excerpt_rows.start);
+                            + hunk.row_range.start.saturating_sub(excerpt_rows.start);
                         let end = multibuffer_start.row
                             + hunk
-                                .associated_range
+                                .row_range
                                 .end
                                 .min(excerpt_rows.end + 1)
                                 .saturating_sub(excerpt_rows.start);
                         MultiBufferRow(start)..MultiBufferRow(end)
                     };
-                    DiffHunk {
-                        associated_range: buffer_range,
+                    MultiBufferDiffHunk {
+                        row_range: buffer_range,
                         diff_base_byte_range: hunk.diff_base_byte_range.clone(),
                         buffer_range: hunk.buffer_range.clone(),
-                        buffer_id: hunk.buffer_id,
+                        buffer_id: excerpt.buffer_id,
                     }
                 });
 

crates/project/src/project.rs 🔗

@@ -2182,7 +2182,10 @@ impl Project {
 
         let buffer_id = buffer.read(cx).remote_id();
         match event {
-            BufferEvent::Operation(operation) => {
+            BufferEvent::Operation {
+                operation,
+                is_local: true,
+            } => {
                 let operation = language::proto::serialize_operation(operation);
 
                 if let Some(ssh) = &self.ssh_session {
@@ -2267,7 +2270,7 @@ impl Project {
                 .filter_map(|buffer| {
                     let buffer = buffer.upgrade()?;
                     buffer
-                        .update(&mut cx, |buffer, cx| buffer.git_diff_recalc(cx))
+                        .update(&mut cx, |buffer, cx| buffer.recalculate_diff(cx))
                         .ok()
                         .flatten()
                 })

crates/project/src/project_tests.rs 🔗

@@ -3288,7 +3288,7 @@ async fn test_buffer_is_dirty(cx: &mut gpui::TestAppContext) {
         cx.subscribe(&buffer1, {
             let events = events.clone();
             move |_, _, event, _| match event {
-                BufferEvent::Operation(_) => {}
+                BufferEvent::Operation { .. } => {}
                 _ => events.lock().push(event.clone()),
             }
         })

crates/remote_server/src/headless_project.rs 🔗

@@ -146,12 +146,15 @@ impl HeadlessProject {
         cx: &mut ModelContext<Self>,
     ) {
         match event {
-            BufferEvent::Operation(op) => cx
+            BufferEvent::Operation {
+                operation,
+                is_local: true,
+            } => cx
                 .background_executor()
                 .spawn(self.session.request(proto::UpdateBuffer {
                     project_id: SSH_PROJECT_ID,
                     buffer_id: buffer.read(cx).remote_id().to_proto(),
-                    operations: vec![serialize_operation(op)],
+                    operations: vec![serialize_operation(operation)],
                 }))
                 .detach(),
             _ => {}

crates/text/src/text.rs 🔗

@@ -13,6 +13,7 @@ mod undo_map;
 pub use anchor::*;
 use anyhow::{anyhow, Context as _, Result};
 pub use clock::ReplicaId;
+use clock::LOCAL_BRANCH_REPLICA_ID;
 use collections::{HashMap, HashSet};
 use locator::Locator;
 use operation_queue::OperationQueue;
@@ -715,6 +716,19 @@ impl Buffer {
         self.snapshot.clone()
     }
 
+    pub fn branch(&self) -> Self {
+        Self {
+            snapshot: self.snapshot.clone(),
+            history: History::new(self.base_text().clone()),
+            deferred_ops: OperationQueue::new(),
+            deferred_replicas: HashSet::default(),
+            lamport_clock: clock::Lamport::new(LOCAL_BRANCH_REPLICA_ID),
+            subscriptions: Default::default(),
+            edit_id_resolvers: Default::default(),
+            wait_for_version_txs: Default::default(),
+        }
+    }
+
     pub fn replica_id(&self) -> ReplicaId {
         self.lamport_clock.replica_id
     }