Merge branch 'main' into v0.173.x

Joseph T. Lyons created

Change summary

Cargo.lock                                                             |  51 
Cargo.toml                                                             |   4 
assets/icons/file_icons/file_types.json                                |   6 
assets/icons/zed_predict_down.svg                                      |   5 
assets/icons/zed_predict_up.svg                                        |   5 
assets/keymaps/default-linux.json                                      |  13 
assets/keymaps/default-macos.json                                      |   3 
assets/keymaps/vim.json                                                |  17 
crates/buffer_diff/Cargo.toml                                          |   7 
crates/buffer_diff/LICENSE-GPL                                         |   0 
crates/buffer_diff/src/buffer_diff.rs                                  | 844 
crates/collab/Cargo.toml                                               |   2 
crates/collab/src/tests/integration_tests.rs                           |  71 
crates/command_palette/src/command_palette.rs                          |  35 
crates/command_palette_hooks/src/command_palette_hooks.rs              |  14 
crates/editor/Cargo.toml                                               |   2 
crates/editor/src/editor.rs                                            | 640 
crates/editor/src/editor_tests.rs                                      |  44 
crates/editor/src/element.rs                                           | 263 
crates/editor/src/proposed_changes_editor.rs                           |   4 
crates/editor/src/scroll/autoscroll.rs                                 |  24 
crates/editor/src/test/editor_test_context.rs                          |   8 
crates/file_finder/src/open_path_prompt.rs                             |   2 
crates/git/src/repository.rs                                           |   4 
crates/git_ui/Cargo.toml                                               |   2 
crates/git_ui/src/project_diff.rs                                      |   7 
crates/gpui/Cargo.toml                                                 |   7 
crates/gpui/examples/gif_viewer.rs                                     |  11 
crates/gpui/examples/image/image.rs                                    |  15 
crates/gpui/examples/image_loading.rs                                  |   2 
crates/gpui/examples/opacity.rs                                        |   3 
crates/gpui/examples/pattern.rs                                        |  36 
crates/gpui/examples/svg/svg.rs                                        |   3 
crates/gpui/src/color.rs                                               |   9 
crates/gpui/src/platform/blade/shaders.wgsl                            |  26 
crates/gpui/src/platform/mac/shaders.metal                             |  20 
crates/inline_completion/src/inline_completion.rs                      |  20 
crates/inline_completion_button/src/inline_completion_button.rs        |  76 
crates/multi_buffer/Cargo.toml                                         |   6 
crates/multi_buffer/src/anchor.rs                                      |   6 
crates/multi_buffer/src/multi_buffer.rs                                | 217 
crates/multi_buffer/src/multi_buffer_tests.rs                          |  49 
crates/project/Cargo.toml                                              |   4 
crates/project/src/buffer_store.rs                                     | 139 
crates/project/src/git.rs                                              |   5 
crates/project/src/lsp_store.rs                                        |  25 
crates/project/src/project.rs                                          |  18 
crates/project/src/project_settings.rs                                 |  19 
crates/project/src/project_tests.rs                                    |  49 
crates/project/src/toolchain_store.rs                                  |  50 
crates/project/src/worktree_store.rs                                   |  20 
crates/proto/proto/zed.proto                                           |   3 
crates/proto/src/proto.rs                                              |  76 
crates/remote_server/src/headless_project.rs                           |  19 
crates/remote_server/src/remote_editing_tests.rs                       |  29 
crates/settings/Cargo.toml                                             |   1 
crates/settings/src/keymap_file.rs                                     |  40 
crates/settings/src/settings_store.rs                                  |  49 
crates/theme/src/icon_theme.rs                                         |   2 
crates/ui/src/components/context_menu.rs                               |   6 
crates/ui/src/components/icon.rs                                       |   3 
crates/vim/src/command.rs                                              | 134 
crates/vim/src/object.rs                                               |  60 
crates/vim/src/vim.rs                                                  |  31 
crates/vim/src/visual.rs                                               |   5 
crates/vim/test_data/test_multiline_surrounding_character_objects.json |  13 
crates/worktree/src/worktree.rs                                        |  76 
crates/zed/Cargo.toml                                                  |   1 
crates/zed/src/zed.rs                                                  |  10 
crates/zed/src/zed/migrate.rs                                          |  79 
crates/zeta/src/init.rs                                                |   4 
crates/zeta/src/rate_completion_modal.rs                               |   1 
crates/zeta/src/zeta.rs                                                |  36 
docs/src/languages/lua.md                                              |   8 
docs/src/vim.md                                                        |  32 
extensions/EXTRACTION.md                                               |  10 
76 files changed, 2,408 insertions(+), 1,232 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -2024,6 +2024,24 @@ dependencies = [
  "serde",
 ]
 
+[[package]]
+name = "buffer_diff"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "futures 0.3.31",
+ "git2",
+ "gpui",
+ "language",
+ "pretty_assertions",
+ "rope",
+ "serde_json",
+ "sum_tree",
+ "text",
+ "unindent",
+ "util",
+]
+
 [[package]]
 name = "built"
 version = "0.7.5"
@@ -2742,6 +2760,7 @@ dependencies = [
  "axum",
  "axum-extra",
  "base64 0.22.1",
+ "buffer_diff",
  "call",
  "channel",
  "chrono",
@@ -2753,7 +2772,6 @@ dependencies = [
  "ctor",
  "dashmap 6.1.0",
  "derive_more",
- "diff 0.1.0",
  "editor",
  "env_logger 0.11.6",
  "envy",
@@ -3860,24 +3878,6 @@ dependencies = [
  "zeroize",
 ]
 
-[[package]]
-name = "diff"
-version = "0.1.0"
-dependencies = [
- "futures 0.3.31",
- "git2",
- "gpui",
- "language",
- "log",
- "pretty_assertions",
- "rope",
- "serde_json",
- "sum_tree",
- "text",
- "unindent",
- "util",
-]
-
 [[package]]
 name = "diff"
 version = "0.1.13"
@@ -4041,6 +4041,7 @@ dependencies = [
  "aho-corasick",
  "anyhow",
  "assets",
+ "buffer_diff",
  "chrono",
  "client",
  "clock",
@@ -4048,7 +4049,6 @@ dependencies = [
  "convert_case 0.7.1",
  "ctor",
  "db",
- "diff 0.1.0",
  "emojis",
  "env_logger 0.11.6",
  "file_icons",
@@ -5347,9 +5347,9 @@ name = "git_ui"
 version = "0.1.0"
 dependencies = [
  "anyhow",
+ "buffer_diff",
  "collections",
  "db",
- "diff 0.1.0",
  "editor",
  "feature_flags",
  "futures 0.3.31",
@@ -5546,6 +5546,7 @@ dependencies = [
  "rand 0.8.5",
  "raw-window-handle",
  "refineable",
+ "reqwest_client",
  "resvg",
  "schemars",
  "seahash",
@@ -7980,10 +7981,10 @@ name = "multi_buffer"
 version = "0.1.0"
 dependencies = [
  "anyhow",
+ "buffer_diff",
  "clock",
  "collections",
  "ctor",
- "diff 0.1.0",
  "env_logger 0.11.6",
  "futures 0.3.31",
  "gpui",
@@ -9995,7 +9996,7 @@ version = "1.4.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d"
 dependencies = [
- "diff 0.1.13",
+ "diff",
  "yansi",
 ]
 
@@ -10088,10 +10089,10 @@ dependencies = [
  "aho-corasick",
  "anyhow",
  "async-trait",
+ "buffer_diff",
  "client",
  "clock",
  "collections",
- "diff 0.1.0",
  "env_logger 0.11.6",
  "fancy-regex 0.14.0",
  "fs",
@@ -12035,7 +12036,6 @@ dependencies = [
  "indoc",
  "inventory",
  "log",
- "migrator",
  "paths",
  "pretty_assertions",
  "release_channel",
@@ -16647,6 +16647,7 @@ dependencies = [
  "markdown",
  "markdown_preview",
  "menu",
+ "migrator",
  "mimalloc",
  "nix",
  "node_runtime",

Cargo.toml 🔗

@@ -34,7 +34,7 @@ members = [
     "crates/db",
     "crates/deepseek",
     "crates/diagnostics",
-    "crates/diff",
+    "crates/buffer_diff",
     "crates/docs_preprocessor",
     "crates/editor",
     "crates/evals",
@@ -235,7 +235,7 @@ copilot = { path = "crates/copilot" }
 db = { path = "crates/db" }
 deepseek = { path = "crates/deepseek" }
 diagnostics = { path = "crates/diagnostics" }
-diff = { path = "crates/diff" }
+buffer_diff = { path = "crates/buffer_diff" }
 editor = { path = "crates/editor" }
 extension = { path = "crates/extension" }
 extension_host = { path = "crates/extension_host" }

assets/icons/file_icons/file_types.json 🔗

@@ -113,8 +113,8 @@
     "lua": "lua",
     "m4a": "audio",
     "m4v": "video",
-    "markdown": "document",
-    "md": "document",
+    "markdown": "markdown",
+    "md": "markdown",
     "mdb": "storage",
     "mdf": "storage",
     "mdx": "document",
@@ -186,7 +186,7 @@
     "sh": "terminal",
     "sql": "storage",
     "sqlite": "storage",
-    "svelte": "template",
+    "svelte": "svelte",
     "svg": "image",
     "swift": "swift",
     "tcl": "tcl",

assets/icons/zed_predict_down.svg 🔗

@@ -0,0 +1,5 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M15 9.33333L14.5 9.66667L12.5 11L10.5 9.66667L10 9.33333" stroke="black" stroke-width="1.5"/>
+<path d="M12.5 11V4.5" stroke="black" stroke-width="1.5"/>
+<path d="M7.5 8.9V11C5.43097 11 4.56903 11 2.5 11V10.4L7.5 5.6V5H2.5V7.1" stroke="black" stroke-width="1.5"/>
+</svg>

assets/icons/zed_predict_up.svg 🔗

@@ -0,0 +1,5 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M10 6.66667L10.5 6.33333L12.5 5L14.5 6.33333L15 6.66667" stroke="black" stroke-width="1.5"/>
+<path d="M12.5 11V5" stroke="black" stroke-width="1.5"/>
+<path d="M7.5 8.9V11C5.43097 11 4.56903 11 2.5 11V10.4L7.5 5.6V5H2.5V7.1" stroke="black" stroke-width="1.5"/>
+</svg>

assets/keymaps/default-linux.json 🔗

@@ -31,7 +31,7 @@
       "ctrl-,": "zed::OpenSettings",
       "ctrl-q": "zed::Quit",
       "f11": "zed::ToggleFullScreen",
-      "ctrl-alt-z": "zeta::RateCompletions",
+      "ctrl-alt-z": "edit_prediction::RateCompletions",
       "ctrl-shift-i": "edit_prediction::ToggleMenu"
     }
   },
@@ -502,17 +502,22 @@
       "tab": "editor::ComposeCompletion"
     }
   },
+  // Bindings for accepting edit predictions
+  //
+  // alt-l is provided as an alternative to tab/alt-tab. and will be displayed in the UI. This is
+  // because alt-tab may not be available, as it is often used for window switching.
   {
     "context": "Editor && edit_prediction",
     "bindings": {
-      // Changing the modifier currently breaks accepting while you also an LSP completions menu open
-      "alt-enter": "editor::AcceptEditPrediction"
+      "alt-tab": "editor::AcceptEditPrediction",
+      "alt-l": "editor::AcceptEditPrediction"
     }
   },
   {
     "context": "Editor && edit_prediction && !edit_prediction_requires_modifier",
     "bindings": {
-      "tab": "editor::AcceptEditPrediction"
+      "tab": "editor::AcceptEditPrediction",
+      "alt-l": "editor::AcceptEditPrediction"
     }
   },
   {

assets/keymaps/default-macos.json 🔗

@@ -39,7 +39,7 @@
       "cmd-m": "zed::Minimize",
       "fn-f": "zed::ToggleFullScreen",
       "ctrl-cmd-f": "zed::ToggleFullScreen",
-      "ctrl-cmd-z": "zeta::RateCompletions",
+      "ctrl-cmd-z": "edit_prediction::RateCompletions",
       "ctrl-cmd-i": "edit_prediction::ToggleMenu"
     }
   },
@@ -583,7 +583,6 @@
   {
     "context": "Editor && edit_prediction",
     "bindings": {
-      // Changing the modifier currently breaks accepting while you also an LSP completions menu open
       "alt-tab": "editor::AcceptEditPrediction"
     }
   },

assets/keymaps/vim.json 🔗

@@ -694,5 +694,22 @@
       "shift-x": "git::StageAll",
       "shift-u": "git::UnstageAll"
     }
+  },
+  {
+    "context": "edit_prediction && !edit_prediction_requires_modifier",
+    "bindings": {
+      // This is identical to the binding in the base keymap, but the vim bindings above to
+      // "vim::Tab" shadow it, so it needs to be bound again.
+      "tab": "editor::AcceptEditPrediction"
+    }
+  },
+  {
+    "context": "os != macos && edit_prediction",
+    "bindings": {
+      // alt-l is provided as an alternative to tab/alt-tab. and will be displayed in the UI. This
+      // is because alt-tab may not be available, as it is often used for window switching on Linux
+      // and Windows.
+      "alt-l": "editor::AcceptEditPrediction"
+    }
   }
 ]

crates/diff/Cargo.toml → crates/buffer_diff/Cargo.toml 🔗

@@ -1,5 +1,5 @@
 [package]
-name = "diff"
+name = "buffer_diff"
 version = "0.1.0"
 edition.workspace = true
 publish.workspace = true
@@ -9,17 +9,17 @@ license = "GPL-3.0-or-later"
 workspace = true
 
 [lib]
-path = "src/diff.rs"
+path = "src/buffer_diff.rs"
 
 [features]
 test-support = []
 
 [dependencies]
+anyhow.workspace = true
 futures.workspace = true
 git2.workspace = true
 gpui.workspace = true
 language.workspace = true
-log.workspace = true
 rope.workspace = true
 sum_tree.workspace = true
 text.workspace = true
@@ -29,4 +29,5 @@ util.workspace = true
 pretty_assertions.workspace = true
 serde_json.workspace = true
 text = { workspace = true, features = ["test-support"] }
+gpui = { workspace = true, features = ["test-support"] }
 unindent.workspace = true

crates/diff/src/diff.rs → crates/buffer_diff/src/buffer_diff.rs 🔗

@@ -1,20 +1,60 @@
 use futures::{channel::oneshot, future::OptionFuture};
 use git2::{DiffLineType as GitDiffLineType, DiffOptions as GitOptions, Patch as GitPatch};
-use gpui::{App, Context, Entity, EventEmitter};
+use gpui::{App, AsyncApp, Context, Entity, EventEmitter};
 use language::{Language, LanguageRegistry};
 use rope::Rope;
 use std::{cmp, future::Future, iter, ops::Range, sync::Arc};
 use sum_tree::SumTree;
-use text::{Anchor, BufferId, OffsetRangeExt, Point};
+use text::{Anchor, Bias, BufferId, OffsetRangeExt, Point};
 use util::ResultExt;
 
-#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub struct BufferDiff {
+    pub buffer_id: BufferId,
+    inner: BufferDiffInner,
+    secondary_diff: Option<Entity<BufferDiff>>,
+}
+
+#[derive(Clone)]
+pub struct BufferDiffSnapshot {
+    inner: BufferDiffInner,
+    secondary_diff: Option<Box<BufferDiffSnapshot>>,
+}
+
+#[derive(Clone)]
+struct BufferDiffInner {
+    hunks: SumTree<InternalDiffHunk>,
+    base_text: Option<language::BufferSnapshot>,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
 pub enum DiffHunkStatus {
-    Added,
-    Modified,
-    Removed,
+    Added(DiffHunkSecondaryStatus),
+    Modified(DiffHunkSecondaryStatus),
+    Removed(DiffHunkSecondaryStatus),
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub enum DiffHunkSecondaryStatus {
+    HasSecondaryHunk,
+    OverlapsWithSecondaryHunk,
+    None,
 }
 
+// to stage a hunk:
+// - assume hunk starts out as not staged
+// - hunk exists with the same buffer range in the unstaged diff and the uncommitted diff
+// - we want to construct a "version" of the file that
+//   - starts from the index base text
+//   - has the single hunk applied to it
+//     - the hunk is the one from the UNSTAGED diff, so that the diff base offset range is correct to apply to that diff base
+// - write that new version of the file into the index
+
+// to unstage a hunk
+// - no hunk in the unstaged diff intersects this hunk from the uncommitted diff
+// - we want to compute the hunk that
+//   - we can apply to the index text
+//   - at the end of applying it,
+
 /// A diff hunk resolved to rows in the buffer.
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub struct DiffHunk {
@@ -24,6 +64,7 @@ pub struct DiffHunk {
     pub buffer_range: Range<Anchor>,
     /// The range in the buffer's diff base text to which this hunk corresponds.
     pub diff_base_byte_range: Range<usize>,
+    pub secondary_status: DiffHunkSecondaryStatus,
 }
 
 /// We store [`InternalDiffHunk`]s internally so we don't need to store the additional row range.
@@ -64,13 +105,17 @@ impl sum_tree::Summary for DiffHunkSummary {
     }
 }
 
-#[derive(Clone)]
-pub struct BufferDiffSnapshot {
-    hunks: SumTree<InternalDiffHunk>,
-    pub base_text: Option<language::BufferSnapshot>,
+impl<'a> sum_tree::SeekTarget<'a, DiffHunkSummary, DiffHunkSummary> for Anchor {
+    fn cmp(
+        &self,
+        cursor_location: &DiffHunkSummary,
+        buffer: &text::BufferSnapshot,
+    ) -> cmp::Ordering {
+        self.cmp(&cursor_location.buffer_range.end, buffer)
+    }
 }
 
-impl std::fmt::Debug for BufferDiffSnapshot {
+impl std::fmt::Debug for BufferDiffInner {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
         f.debug_struct("BufferDiffSnapshot")
             .field("hunks", &self.hunks)
@@ -79,142 +124,56 @@ impl std::fmt::Debug for BufferDiffSnapshot {
 }
 
 impl BufferDiffSnapshot {
-    pub fn new(buffer: &text::BufferSnapshot) -> BufferDiffSnapshot {
-        BufferDiffSnapshot {
-            hunks: SumTree::new(buffer),
-            base_text: None,
-        }
+    pub fn is_empty(&self) -> bool {
+        self.inner.hunks.is_empty()
     }
 
-    pub fn new_with_single_insertion(cx: &mut App) -> Self {
-        let base_text = language::Buffer::build_empty_snapshot(cx);
-        Self {
-            hunks: SumTree::from_item(
-                InternalDiffHunk {
-                    buffer_range: Anchor::MIN..Anchor::MAX,
-                    diff_base_byte_range: 0..0,
-                },
-                &base_text,
-            ),
-            base_text: Some(base_text),
-        }
+    pub fn secondary_diff(&self) -> Option<&BufferDiffSnapshot> {
+        self.secondary_diff.as_deref()
     }
 
-    #[cfg(any(test, feature = "test-support"))]
-    pub fn build_sync(
-        buffer: text::BufferSnapshot,
-        diff_base: String,
-        cx: &mut gpui::TestAppContext,
-    ) -> Self {
-        let snapshot =
-            cx.update(|cx| Self::build(buffer, Some(Arc::new(diff_base)), None, None, cx));
-        cx.executor().block(snapshot)
+    pub fn hunks_intersecting_range<'a>(
+        &'a self,
+        range: Range<Anchor>,
+        buffer: &'a text::BufferSnapshot,
+    ) -> impl 'a + Iterator<Item = DiffHunk> {
+        let unstaged_counterpart = self.secondary_diff.as_ref().map(|diff| &diff.inner);
+        self.inner
+            .hunks_intersecting_range(range, buffer, unstaged_counterpart)
     }
 
-    pub fn build(
-        buffer: text::BufferSnapshot,
-        diff_base: Option<Arc<String>>,
-        language: Option<Arc<Language>>,
-        language_registry: Option<Arc<LanguageRegistry>>,
-        cx: &mut App,
-    ) -> impl Future<Output = Self> {
-        let base_text_snapshot = diff_base.as_ref().map(|base_text| {
-            language::Buffer::build_snapshot(
-                Rope::from(base_text.as_str()),
-                language.clone(),
-                language_registry.clone(),
-                cx,
-            )
-        });
-        let base_text_snapshot = cx
-            .background_executor()
-            .spawn(OptionFuture::from(base_text_snapshot));
-
-        let hunks = cx.background_executor().spawn({
-            let buffer = buffer.clone();
-            async move { Self::recalculate_hunks(diff_base, buffer) }
-        });
-
-        async move {
-            let (base_text, hunks) = futures::join!(base_text_snapshot, hunks);
-            Self { base_text, hunks }
-        }
+    pub fn hunks_intersecting_range_rev<'a>(
+        &'a self,
+        range: Range<Anchor>,
+        buffer: &'a text::BufferSnapshot,
+    ) -> impl 'a + Iterator<Item = DiffHunk> {
+        self.inner.hunks_intersecting_range_rev(range, buffer)
     }
 
-    pub fn build_with_base_buffer(
-        buffer: text::BufferSnapshot,
-        diff_base: Option<Arc<String>>,
-        diff_base_buffer: Option<language::BufferSnapshot>,
-        cx: &App,
-    ) -> impl Future<Output = Self> {
-        cx.background_executor().spawn({
-            let buffer = buffer.clone();
-            async move {
-                let hunks = Self::recalculate_hunks(diff_base, buffer);
-                Self {
-                    hunks,
-                    base_text: diff_base_buffer,
-                }
-            }
-        })
+    pub fn base_text(&self) -> Option<&language::BufferSnapshot> {
+        self.inner.base_text.as_ref()
     }
 
-    fn recalculate_hunks(
-        diff_base: Option<Arc<String>>,
-        buffer: text::BufferSnapshot,
-    ) -> SumTree<InternalDiffHunk> {
-        let mut tree = SumTree::new(&buffer);
-
-        if let Some(diff_base) = diff_base {
-            let buffer_text = buffer.as_rope().to_string();
-            let patch = Self::diff(&diff_base, &buffer_text);
-
-            // A common case in Zed is that the empty buffer is represented as just a newline,
-            // but if we just compute a naive diff you get a "preserved" line in the middle,
-            // which is a bit odd.
-            if buffer_text == "\n" && diff_base.ends_with("\n") && diff_base.len() > 1 {
-                tree.push(
-                    InternalDiffHunk {
-                        buffer_range: buffer.anchor_before(0)..buffer.anchor_before(0),
-                        diff_base_byte_range: 0..diff_base.len() - 1,
-                    },
-                    &buffer,
-                );
-                return tree;
-            }
-
-            if let Some(patch) = patch {
-                let mut divergence = 0;
-                for hunk_index in 0..patch.num_hunks() {
-                    let hunk =
-                        Self::process_patch_hunk(&patch, hunk_index, &buffer, &mut divergence);
-                    tree.push(hunk, &buffer);
-                }
+    pub fn base_texts_eq(&self, other: &Self) -> bool {
+        match (other.base_text(), self.base_text()) {
+            (None, None) => true,
+            (None, Some(_)) => false,
+            (Some(_), None) => false,
+            (Some(old), Some(new)) => {
+                let (old_id, old_empty) = (old.remote_id(), old.is_empty());
+                let (new_id, new_empty) = (new.remote_id(), new.is_empty());
+                new_id == old_id || (new_empty && old_empty)
             }
         }
-
-        tree
-    }
-
-    pub fn is_empty(&self) -> bool {
-        self.hunks.is_empty()
-    }
-
-    pub fn hunks_in_row_range<'a>(
-        &'a self,
-        range: Range<u32>,
-        buffer: &'a text::BufferSnapshot,
-    ) -> 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));
-
-        self.hunks_intersecting_range(start..end, buffer)
     }
+}
 
-    pub fn hunks_intersecting_range<'a>(
+impl BufferDiffInner {
+    fn hunks_intersecting_range<'a>(
         &'a self,
         range: Range<Anchor>,
         buffer: &'a text::BufferSnapshot,
+        secondary: Option<&'a Self>,
     ) -> impl 'a + Iterator<Item = DiffHunk> {
         let range = range.to_offset(buffer);
 
@@ -244,6 +203,12 @@ impl BufferDiffSnapshot {
             ]
         });
 
+        let mut secondary_cursor = secondary.as_ref().map(|diff| {
+            let mut cursor = diff.hunks.cursor::<DiffHunkSummary>(buffer);
+            cursor.next(buffer);
+            cursor
+        });
+
         let mut summaries = buffer.summaries_for_anchors_with_payload::<Point, _, _>(anchor_iter);
         iter::from_fn(move || loop {
             let (start_point, (start_anchor, start_base)) = summaries.next()?;
@@ -259,15 +224,35 @@ impl BufferDiffSnapshot {
                 end_anchor = buffer.anchor_before(end_point);
             }
 
+            let mut secondary_status = DiffHunkSecondaryStatus::None;
+            if let Some(secondary_cursor) = secondary_cursor.as_mut() {
+                if start_anchor
+                    .cmp(&secondary_cursor.start().buffer_range.start, buffer)
+                    .is_gt()
+                {
+                    secondary_cursor.seek_forward(&end_anchor, Bias::Left, buffer);
+                }
+
+                if let Some(secondary_hunk) = secondary_cursor.item() {
+                    let secondary_range = secondary_hunk.buffer_range.to_point(buffer);
+                    if secondary_range == (start_point..end_point) {
+                        secondary_status = DiffHunkSecondaryStatus::HasSecondaryHunk;
+                    } else if secondary_range.start <= end_point {
+                        secondary_status = DiffHunkSecondaryStatus::OverlapsWithSecondaryHunk;
+                    }
+                }
+            }
+
             return Some(DiffHunk {
                 row_range: start_point.row..end_point.row,
                 diff_base_byte_range: start_base..end_base,
                 buffer_range: start_anchor..end_anchor,
+                secondary_status,
             });
         })
     }
 
-    pub fn hunks_intersecting_range_rev<'a>(
+    fn hunks_intersecting_range_rev<'a>(
         &'a self,
         range: Range<Anchor>,
         buffer: &'a text::BufferSnapshot,
@@ -295,15 +280,13 @@ impl BufferDiffSnapshot {
                 row_range: range.start.row..end_row,
                 diff_base_byte_range: hunk.diff_base_byte_range.clone(),
                 buffer_range: hunk.buffer_range.clone(),
+                // The secondary status is not used by callers of this method.
+                secondary_status: DiffHunkSecondaryStatus::None,
             })
         })
     }
 
-    pub fn compare(
-        &self,
-        old: &Self,
-        new_snapshot: &text::BufferSnapshot,
-    ) -> Option<Range<Anchor>> {
+    fn compare(&self, old: &Self, new_snapshot: &text::BufferSnapshot) -> Option<Range<Anchor>> {
         let mut new_cursor = self.hunks.cursor::<()>(new_snapshot);
         let mut old_cursor = old.hunks.cursor::<()>(new_snapshot);
         old_cursor.next(new_snapshot);
@@ -365,174 +348,370 @@ impl BufferDiffSnapshot {
 
         start.zip(end).map(|(start, end)| start..end)
     }
+}
 
-    #[cfg(test)]
-    fn clear(&mut self, buffer: &text::BufferSnapshot) {
-        self.hunks = SumTree::new(buffer);
-    }
+fn compute_hunks(
+    diff_base: Option<Arc<String>>,
+    buffer: text::BufferSnapshot,
+) -> SumTree<InternalDiffHunk> {
+    let mut tree = SumTree::new(&buffer);
 
-    #[cfg(test)]
-    fn hunks<'a>(&'a self, text: &'a text::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)
-    }
+    if let Some(diff_base) = diff_base {
+        let buffer_text = buffer.as_rope().to_string();
 
-    fn diff<'a>(head: &'a str, current: &'a str) -> Option<GitPatch<'a>> {
         let mut options = GitOptions::default();
         options.context_lines(0);
-
         let patch = GitPatch::from_buffers(
-            head.as_bytes(),
+            diff_base.as_bytes(),
             None,
-            current.as_bytes(),
+            buffer_text.as_bytes(),
             None,
             Some(&mut options),
-        );
-
-        match patch {
-            Ok(patch) => Some(patch),
+        )
+        .log_err();
+
+        // A common case in Zed is that the empty buffer is represented as just a newline,
+        // but if we just compute a naive diff you get a "preserved" line in the middle,
+        // which is a bit odd.
+        if buffer_text == "\n" && diff_base.ends_with("\n") && diff_base.len() > 1 {
+            tree.push(
+                InternalDiffHunk {
+                    buffer_range: buffer.anchor_before(0)..buffer.anchor_before(0),
+                    diff_base_byte_range: 0..diff_base.len() - 1,
+                },
+                &buffer,
+            );
+            return tree;
+        }
 
-            Err(err) => {
-                log::error!("`GitPatch::from_buffers` failed: {}", err);
-                None
+        if let Some(patch) = patch {
+            let mut divergence = 0;
+            for hunk_index in 0..patch.num_hunks() {
+                let hunk = process_patch_hunk(&patch, hunk_index, &buffer, &mut divergence);
+                tree.push(hunk, &buffer);
             }
         }
     }
 
-    fn process_patch_hunk(
-        patch: &GitPatch<'_>,
-        hunk_index: usize,
-        buffer: &text::BufferSnapshot,
-        buffer_row_divergence: &mut i64,
-    ) -> InternalDiffHunk {
-        let line_item_count = patch.num_lines_in_hunk(hunk_index).unwrap();
-        assert!(line_item_count > 0);
-
-        let mut first_deletion_buffer_row: Option<u32> = None;
-        let mut buffer_row_range: Option<Range<u32>> = None;
-        let mut diff_base_byte_range: Option<Range<usize>> = None;
-
-        for line_index in 0..line_item_count {
-            let line = patch.line_in_hunk(hunk_index, line_index).unwrap();
-            let kind = line.origin_value();
-            let content_offset = line.content_offset() as isize;
-            let content_len = line.content().len() as isize;
-
-            if kind == GitDiffLineType::Addition {
-                *buffer_row_divergence += 1;
-                let row = line.new_lineno().unwrap().saturating_sub(1);
-
-                match &mut buffer_row_range {
-                    Some(buffer_row_range) => buffer_row_range.end = row + 1,
-                    None => buffer_row_range = Some(row..row + 1),
-                }
-            }
-
-            if kind == GitDiffLineType::Deletion {
-                let end = content_offset + content_len;
-
-                match &mut diff_base_byte_range {
-                    Some(head_byte_range) => head_byte_range.end = end as usize,
-                    None => diff_base_byte_range = Some(content_offset as usize..end as usize),
-                }
-
-                if first_deletion_buffer_row.is_none() {
-                    let old_row = line.old_lineno().unwrap().saturating_sub(1);
-                    let row = old_row as i64 + *buffer_row_divergence;
-                    first_deletion_buffer_row = Some(row as u32);
-                }
+    tree
+}
 
-                *buffer_row_divergence -= 1;
+fn process_patch_hunk(
+    patch: &GitPatch<'_>,
+    hunk_index: usize,
+    buffer: &text::BufferSnapshot,
+    buffer_row_divergence: &mut i64,
+) -> InternalDiffHunk {
+    let line_item_count = patch.num_lines_in_hunk(hunk_index).unwrap();
+    assert!(line_item_count > 0);
+
+    let mut first_deletion_buffer_row: Option<u32> = None;
+    let mut buffer_row_range: Option<Range<u32>> = None;
+    let mut diff_base_byte_range: Option<Range<usize>> = None;
+
+    for line_index in 0..line_item_count {
+        let line = patch.line_in_hunk(hunk_index, line_index).unwrap();
+        let kind = line.origin_value();
+        let content_offset = line.content_offset() as isize;
+        let content_len = line.content().len() as isize;
+
+        if kind == GitDiffLineType::Addition {
+            *buffer_row_divergence += 1;
+            let row = line.new_lineno().unwrap().saturating_sub(1);
+
+            match &mut buffer_row_range {
+                Some(buffer_row_range) => buffer_row_range.end = row + 1,
+                None => buffer_row_range = Some(row..row + 1),
             }
         }
 
-        //unwrap_or deletion without addition
-        let buffer_row_range = buffer_row_range.unwrap_or_else(|| {
-            //we cannot have an addition-less hunk without deletion(s) or else there would be no hunk
-            let row = first_deletion_buffer_row.unwrap();
-            row..row
-        });
+        if kind == GitDiffLineType::Deletion {
+            let end = content_offset + content_len;
+
+            match &mut diff_base_byte_range {
+                Some(head_byte_range) => head_byte_range.end = end as usize,
+                None => diff_base_byte_range = Some(content_offset as usize..end as usize),
+            }
 
-        //unwrap_or addition without deletion
-        let diff_base_byte_range = diff_base_byte_range.unwrap_or(0..0);
+            if first_deletion_buffer_row.is_none() {
+                let old_row = line.old_lineno().unwrap().saturating_sub(1);
+                let row = old_row as i64 + *buffer_row_divergence;
+                first_deletion_buffer_row = Some(row as u32);
+            }
 
-        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);
-        InternalDiffHunk {
-            buffer_range,
-            diff_base_byte_range,
+            *buffer_row_divergence -= 1;
         }
     }
-}
 
-pub struct BufferDiff {
-    pub buffer_id: BufferId,
-    pub snapshot: BufferDiffSnapshot,
-    pub unstaged_diff: Option<Entity<BufferDiff>>,
+    //unwrap_or deletion without addition
+    let buffer_row_range = buffer_row_range.unwrap_or_else(|| {
+        //we cannot have an addition-less hunk without deletion(s) or else there would be no hunk
+        let row = first_deletion_buffer_row.unwrap();
+        row..row
+    });
+
+    //unwrap_or addition without deletion
+    let diff_base_byte_range = diff_base_byte_range.unwrap_or(0..0);
+
+    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);
+    InternalDiffHunk {
+        buffer_range,
+        diff_base_byte_range,
+    }
 }
 
 impl std::fmt::Debug for BufferDiff {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
         f.debug_struct("BufferChangeSet")
             .field("buffer_id", &self.buffer_id)
-            .field("snapshot", &self.snapshot)
+            .field("snapshot", &self.inner)
             .finish()
     }
 }
 
 pub enum BufferDiffEvent {
-    DiffChanged { changed_range: Range<text::Anchor> },
+    DiffChanged {
+        changed_range: Option<Range<text::Anchor>>,
+    },
     LanguageChanged,
 }
 
 impl EventEmitter<BufferDiffEvent> for BufferDiff {}
 
 impl BufferDiff {
-    pub fn set_state(
+    #[cfg(test)]
+    fn build_sync(
+        buffer: text::BufferSnapshot,
+        diff_base: String,
+        cx: &mut gpui::TestAppContext,
+    ) -> BufferDiffInner {
+        let snapshot =
+            cx.update(|cx| Self::build(buffer, Some(Arc::new(diff_base)), None, None, cx));
+        cx.executor().block(snapshot)
+    }
+
+    fn build(
+        buffer: text::BufferSnapshot,
+        diff_base: Option<Arc<String>>,
+        language: Option<Arc<Language>>,
+        language_registry: Option<Arc<LanguageRegistry>>,
+        cx: &mut App,
+    ) -> impl Future<Output = BufferDiffInner> {
+        let base_text_snapshot = diff_base.as_ref().map(|base_text| {
+            language::Buffer::build_snapshot(
+                Rope::from(base_text.as_str()),
+                language.clone(),
+                language_registry.clone(),
+                cx,
+            )
+        });
+        let base_text_snapshot = cx
+            .background_executor()
+            .spawn(OptionFuture::from(base_text_snapshot));
+
+        let hunks = cx.background_executor().spawn({
+            let buffer = buffer.clone();
+            async move { compute_hunks(diff_base, buffer) }
+        });
+
+        async move {
+            let (base_text, hunks) = futures::join!(base_text_snapshot, hunks);
+            BufferDiffInner { base_text, hunks }
+        }
+    }
+
+    fn build_with_base_buffer(
+        buffer: text::BufferSnapshot,
+        diff_base: Option<Arc<String>>,
+        diff_base_buffer: Option<language::BufferSnapshot>,
+        cx: &App,
+    ) -> impl Future<Output = BufferDiffInner> {
+        cx.background_executor().spawn(async move {
+            BufferDiffInner {
+                hunks: compute_hunks(diff_base, buffer),
+                base_text: diff_base_buffer,
+            }
+        })
+    }
+
+    fn build_empty(buffer: &text::BufferSnapshot) -> BufferDiffInner {
+        BufferDiffInner {
+            hunks: SumTree::new(buffer),
+            base_text: None,
+        }
+    }
+
+    pub fn build_with_single_insertion(
+        insertion_present_in_secondary_diff: bool,
+        cx: &mut App,
+    ) -> BufferDiffSnapshot {
+        let base_text = language::Buffer::build_empty_snapshot(cx);
+        let hunks = SumTree::from_item(
+            InternalDiffHunk {
+                buffer_range: Anchor::MIN..Anchor::MAX,
+                diff_base_byte_range: 0..0,
+            },
+            &base_text,
+        );
+        BufferDiffSnapshot {
+            inner: BufferDiffInner {
+                hunks: hunks.clone(),
+                base_text: Some(base_text.clone()),
+            },
+            secondary_diff: if insertion_present_in_secondary_diff {
+                Some(Box::new(BufferDiffSnapshot {
+                    inner: BufferDiffInner {
+                        hunks,
+                        base_text: Some(base_text),
+                    },
+                    secondary_diff: None,
+                }))
+            } else {
+                None
+            },
+        }
+    }
+
+    pub fn set_secondary_diff(&mut self, diff: Entity<BufferDiff>) {
+        self.secondary_diff = Some(diff);
+    }
+
+    pub fn secondary_diff(&self) -> Option<Entity<BufferDiff>> {
+        Some(self.secondary_diff.as_ref()?.clone())
+    }
+
+    pub fn range_to_hunk_range(
+        &self,
+        range: Range<Anchor>,
+        buffer: &text::BufferSnapshot,
+        cx: &App,
+    ) -> Option<Range<Anchor>> {
+        let start = self
+            .hunks_intersecting_range(range.clone(), &buffer, cx)
+            .next()?
+            .buffer_range
+            .start;
+        let end = self
+            .hunks_intersecting_range_rev(range.clone(), &buffer)
+            .next()?
+            .buffer_range
+            .end;
+        Some(start..end)
+    }
+
+    #[allow(clippy::too_many_arguments)]
+    pub async fn update_diff(
+        this: Entity<BufferDiff>,
+        buffer: text::BufferSnapshot,
+        base_text: Option<Arc<String>>,
+        base_text_changed: bool,
+        language_changed: bool,
+        language: Option<Arc<Language>>,
+        language_registry: Option<Arc<LanguageRegistry>>,
+        cx: &mut AsyncApp,
+    ) -> anyhow::Result<Option<Range<Anchor>>> {
+        let snapshot = if base_text_changed || language_changed {
+            cx.update(|cx| {
+                Self::build(
+                    buffer.clone(),
+                    base_text,
+                    language.clone(),
+                    language_registry.clone(),
+                    cx,
+                )
+            })?
+            .await
+        } else {
+            this.read_with(cx, |this, cx| {
+                Self::build_with_base_buffer(
+                    buffer.clone(),
+                    base_text,
+                    this.base_text().cloned(),
+                    cx,
+                )
+            })?
+            .await
+        };
+
+        this.update(cx, |this, _| this.set_state(snapshot, &buffer))
+    }
+
+    pub fn update_diff_from(
         &mut self,
-        snapshot: BufferDiffSnapshot,
         buffer: &text::BufferSnapshot,
+        other: &Entity<Self>,
         cx: &mut Context<Self>,
-    ) {
-        if let Some(base_text) = snapshot.base_text.as_ref() {
-            let changed_range = if Some(base_text.remote_id())
-                != self
-                    .snapshot
-                    .base_text
-                    .as_ref()
-                    .map(|buffer| buffer.remote_id())
-            {
-                Some(text::Anchor::MIN..text::Anchor::MAX)
-            } else {
-                snapshot.compare(&self.snapshot, buffer)
-            };
-            if let Some(changed_range) = changed_range {
-                cx.emit(BufferDiffEvent::DiffChanged { changed_range });
+    ) -> Option<Range<Anchor>> {
+        let other = other.read(cx).inner.clone();
+        self.set_state(other, buffer)
+    }
+
+    fn set_state(
+        &mut self,
+        inner: BufferDiffInner,
+        buffer: &text::BufferSnapshot,
+    ) -> Option<Range<Anchor>> {
+        let changed_range = match (self.inner.base_text.as_ref(), inner.base_text.as_ref()) {
+            (None, None) => None,
+            (Some(old), Some(new)) if old.remote_id() == new.remote_id() => {
+                inner.compare(&self.inner, buffer)
             }
+            _ => Some(text::Anchor::MIN..text::Anchor::MAX),
+        };
+        self.inner = inner;
+        changed_range
+    }
+
+    pub fn base_text(&self) -> Option<&language::BufferSnapshot> {
+        self.inner.base_text.as_ref()
+    }
+
+    pub fn snapshot(&self, cx: &App) -> BufferDiffSnapshot {
+        BufferDiffSnapshot {
+            inner: self.inner.clone(),
+            secondary_diff: self
+                .secondary_diff
+                .as_ref()
+                .map(|diff| Box::new(diff.read(cx).snapshot(cx))),
         }
-        self.snapshot = snapshot;
     }
 
-    pub fn diff_hunks_intersecting_range<'a>(
+    pub fn hunks_intersecting_range<'a>(
         &'a self,
         range: Range<text::Anchor>,
         buffer_snapshot: &'a text::BufferSnapshot,
+        cx: &'a App,
     ) -> impl 'a + Iterator<Item = DiffHunk> {
-        self.snapshot
-            .hunks_intersecting_range(range, buffer_snapshot)
+        let unstaged_counterpart = self
+            .secondary_diff
+            .as_ref()
+            .map(|diff| &diff.read(cx).inner);
+        self.inner
+            .hunks_intersecting_range(range, buffer_snapshot, unstaged_counterpart)
     }
 
-    pub fn diff_hunks_intersecting_range_rev<'a>(
+    pub fn hunks_intersecting_range_rev<'a>(
         &'a self,
         range: Range<text::Anchor>,
         buffer_snapshot: &'a text::BufferSnapshot,
     ) -> impl 'a + Iterator<Item = DiffHunk> {
-        self.snapshot
+        self.inner
             .hunks_intersecting_range_rev(range, buffer_snapshot)
     }
 
+    pub fn hunks_in_row_range<'a>(
+        &'a self,
+        range: Range<u32>,
+        buffer: &'a text::BufferSnapshot,
+        cx: &'a App,
+    ) -> 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));
+        self.hunks_intersecting_range(start..end, buffer, cx)
+    }
+
     /// Used in cases where the change set isn't derived from git.
     pub fn set_base_text(
         &mut self,
@@ -547,7 +726,7 @@ impl BufferDiff {
         let base_buffer = base_buffer.snapshot();
         let base_text = Arc::new(base_buffer.text());
 
-        let snapshot = BufferDiffSnapshot::build(
+        let snapshot = BufferDiff::build(
             buffer.clone(),
             Some(base_text),
             base_buffer.language().cloned(),
@@ -562,8 +741,8 @@ impl BufferDiff {
             let Some(this) = this.upgrade() else {
                 return;
             };
-            this.update(&mut cx, |this, cx| {
-                this.set_state(snapshot, &buffer, cx);
+            this.update(&mut cx, |this, _| {
+                this.set_state(snapshot, &buffer);
             })
             .log_err();
             drop(complete_on_drop)
@@ -574,14 +753,14 @@ impl BufferDiff {
 
     #[cfg(any(test, feature = "test-support"))]
     pub fn base_text_string(&self) -> Option<String> {
-        self.snapshot.base_text.as_ref().map(|buffer| buffer.text())
+        self.inner.base_text.as_ref().map(|buffer| buffer.text())
     }
 
-    pub fn new(buffer: &Entity<language::Buffer>, cx: &mut App) -> Self {
+    pub fn new(buffer: &text::BufferSnapshot) -> Self {
         BufferDiff {
-            buffer_id: buffer.read(cx).remote_id(),
-            snapshot: BufferDiffSnapshot::new(&buffer.read(cx)),
-            unstaged_diff: None,
+            buffer_id: buffer.remote_id(),
+            inner: BufferDiff::build_empty(buffer),
+            secondary_diff: None,
         }
     }
 
@@ -593,7 +772,7 @@ impl BufferDiff {
     ) -> Self {
         let mut base_text = base_text.to_owned();
         text::LineEnding::normalize(&mut base_text);
-        let snapshot = BufferDiffSnapshot::build(
+        let snapshot = BufferDiff::build(
             buffer.read(cx).text_snapshot(),
             Some(base_text.into()),
             None,
@@ -603,26 +782,60 @@ impl BufferDiff {
         let snapshot = cx.background_executor().block(snapshot);
         BufferDiff {
             buffer_id: buffer.read(cx).remote_id(),
-            snapshot,
-            unstaged_diff: None,
+            inner: snapshot,
+            secondary_diff: None,
         }
     }
 
     #[cfg(any(test, feature = "test-support"))]
     pub fn recalculate_diff_sync(&mut self, buffer: text::BufferSnapshot, cx: &mut Context<Self>) {
         let base_text = self
-            .snapshot
+            .inner
             .base_text
             .as_ref()
             .map(|base_text| base_text.text());
-        let snapshot = BufferDiffSnapshot::build_with_base_buffer(
+        let snapshot = BufferDiff::build_with_base_buffer(
             buffer.clone(),
             base_text.clone().map(Arc::new),
-            self.snapshot.base_text.clone(),
+            self.inner.base_text.clone(),
             cx,
         );
         let snapshot = cx.background_executor().block(snapshot);
-        self.set_state(snapshot, &buffer, cx);
+        let changed_range = self.set_state(snapshot, &buffer);
+        cx.emit(BufferDiffEvent::DiffChanged { changed_range });
+    }
+}
+
+impl DiffHunk {
+    pub fn status(&self) -> DiffHunkStatus {
+        if self.buffer_range.start == self.buffer_range.end {
+            DiffHunkStatus::Removed(self.secondary_status)
+        } else if self.diff_base_byte_range.is_empty() {
+            DiffHunkStatus::Added(self.secondary_status)
+        } else {
+            DiffHunkStatus::Modified(self.secondary_status)
+        }
+    }
+}
+
+impl DiffHunkStatus {
+    pub fn is_removed(&self) -> bool {
+        matches!(self, DiffHunkStatus::Removed(_))
+    }
+
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn removed() -> Self {
+        DiffHunkStatus::Removed(DiffHunkSecondaryStatus::None)
+    }
+
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn added() -> Self {
+        DiffHunkStatus::Added(DiffHunkSecondaryStatus::None)
+    }
+
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn modified() -> Self {
+        DiffHunkStatus::Modified(DiffHunkSecondaryStatus::None)
     }
 }
 
@@ -633,7 +846,7 @@ pub fn assert_hunks<Iter>(
     diff_hunks: Iter,
     buffer: &text::BufferSnapshot,
     diff_base: &str,
-    expected_hunks: &[(Range<u32>, &str, &str)],
+    expected_hunks: &[(Range<u32>, &str, &str, DiffHunkStatus)],
 ) where
     Iter: Iterator<Item = DiffHunk>,
 {
@@ -641,19 +854,20 @@ pub fn assert_hunks<Iter>(
         .map(|hunk| {
             (
                 hunk.row_range.clone(),
-                &diff_base[hunk.diff_base_byte_range],
+                &diff_base[hunk.diff_base_byte_range.clone()],
                 buffer
                     .text_for_range(
                         Point::new(hunk.row_range.start, 0)..Point::new(hunk.row_range.end, 0),
                     )
                     .collect::<String>(),
+                hunk.status(),
             )
         })
         .collect::<Vec<_>>();
 
     let expected_hunks: Vec<_> = expected_hunks
         .iter()
-        .map(|(r, s, h)| (r.clone(), *s, h.to_string()))
+        .map(|(r, s, h, status)| (r.clone(), *s, h.to_string(), *status))
         .collect();
 
     assert_eq!(actual_hunks, expected_hunks);
@@ -685,25 +899,115 @@ mod tests {
         .unindent();
 
         let mut buffer = Buffer::new(0, BufferId::new(1).unwrap(), buffer_text);
-        let mut diff = BufferDiffSnapshot::build_sync(buffer.clone(), diff_base.clone(), cx);
+        let mut diff = BufferDiff::build_sync(buffer.clone(), diff_base.clone(), cx);
         assert_hunks(
-            diff.hunks(&buffer),
+            diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &buffer, None),
             &buffer,
             &diff_base,
-            &[(1..2, "two\n", "HELLO\n")],
+            &[(1..2, "two\n", "HELLO\n", DiffHunkStatus::modified())],
         );
 
         buffer.edit([(0..0, "point five\n")]);
-        diff = BufferDiffSnapshot::build_sync(buffer.clone(), diff_base.clone(), cx);
+        diff = BufferDiff::build_sync(buffer.clone(), diff_base.clone(), cx);
         assert_hunks(
-            diff.hunks(&buffer),
+            diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &buffer, None),
             &buffer,
             &diff_base,
-            &[(0..1, "", "point five\n"), (2..3, "two\n", "HELLO\n")],
+            &[
+                (0..1, "", "point five\n", DiffHunkStatus::added()),
+                (2..3, "two\n", "HELLO\n", DiffHunkStatus::modified()),
+            ],
+        );
+
+        diff = BufferDiff::build_empty(&buffer);
+        assert_hunks(
+            diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &buffer, None),
+            &buffer,
+            &diff_base,
+            &[],
         );
+    }
+
+    #[gpui::test]
+    async fn test_buffer_diff_with_secondary(cx: &mut gpui::TestAppContext) {
+        let head_text = "
+            zero
+            one
+            two
+            three
+            four
+            five
+            six
+            seven
+            eight
+            nine
+        "
+        .unindent();
 
-        diff.clear(&buffer);
-        assert_hunks(diff.hunks(&buffer), &buffer, &diff_base, &[]);
+        let index_text = "
+            zero
+            one
+            TWO
+            three
+            FOUR
+            five
+            six
+            seven
+            eight
+            NINE
+        "
+        .unindent();
+
+        let buffer_text = "
+            zero
+            one
+            TWO
+            three
+            FOUR
+            FIVE
+            six
+            SEVEN
+            eight
+            nine
+        "
+        .unindent();
+
+        let buffer = Buffer::new(0, BufferId::new(1).unwrap(), buffer_text);
+        let unstaged_diff = BufferDiff::build_sync(buffer.clone(), index_text.clone(), cx);
+
+        let uncommitted_diff = BufferDiff::build_sync(buffer.clone(), head_text.clone(), cx);
+
+        let expected_hunks = vec![
+            (
+                2..3,
+                "two\n",
+                "TWO\n",
+                DiffHunkStatus::Modified(DiffHunkSecondaryStatus::None),
+            ),
+            (
+                4..6,
+                "four\nfive\n",
+                "FOUR\nFIVE\n",
+                DiffHunkStatus::Modified(DiffHunkSecondaryStatus::OverlapsWithSecondaryHunk),
+            ),
+            (
+                7..8,
+                "seven\n",
+                "SEVEN\n",
+                DiffHunkStatus::Modified(DiffHunkSecondaryStatus::HasSecondaryHunk),
+            ),
+        ];
+
+        assert_hunks(
+            uncommitted_diff.hunks_intersecting_range(
+                Anchor::MIN..Anchor::MAX,
+                &buffer,
+                Some(&unstaged_diff),
+            ),
+            &buffer,
+            &head_text,
+            &expected_hunks,
+        );
     }
 
     #[gpui::test]

crates/collab/Cargo.toml 🔗

@@ -33,7 +33,7 @@ clock.workspace = true
 collections.workspace = true
 dashmap.workspace = true
 derive_more.workspace = true
-diff.workspace = true
+buffer_diff.workspace = true
 envy = "0.4.2"
 futures.workspace = true
 google_ai.workspace = true

crates/collab/src/tests/integration_tests.rs 🔗

@@ -8,6 +8,7 @@ use crate::{
 use anyhow::{anyhow, Result};
 use assistant_context_editor::ContextStore;
 use assistant_slash_command::SlashCommandWorkingSet;
+use buffer_diff::{assert_hunks, DiffHunkSecondaryStatus, DiffHunkStatus};
 use call::{room, ActiveCall, ParticipantLocation, Room};
 use client::{User, RECEIVE_TIMEOUT};
 use collections::{HashMap, HashSet};
@@ -2613,11 +2614,11 @@ async fn test_git_diff_base_change(
             diff.base_text_string().as_deref(),
             Some(staged_text.as_str())
         );
-        diff::assert_hunks(
-            diff.snapshot.hunks_in_row_range(0..4, buffer),
+        assert_hunks(
+            diff.hunks_in_row_range(0..4, buffer, cx),
             buffer,
             &diff.base_text_string().unwrap(),
-            &[(1..2, "", "two\n")],
+            &[(1..2, "", "two\n", DiffHunkStatus::added())],
         );
     });
 
@@ -2641,11 +2642,11 @@ async fn test_git_diff_base_change(
             diff.base_text_string().as_deref(),
             Some(staged_text.as_str())
         );
-        diff::assert_hunks(
-            diff.snapshot.hunks_in_row_range(0..4, buffer),
+        assert_hunks(
+            diff.hunks_in_row_range(0..4, buffer, cx),
             buffer,
             &diff.base_text_string().unwrap(),
-            &[(1..2, "", "two\n")],
+            &[(1..2, "", "two\n", DiffHunkStatus::added())],
         );
     });
 
@@ -2663,11 +2664,16 @@ async fn test_git_diff_base_change(
             diff.base_text_string().as_deref(),
             Some(committed_text.as_str())
         );
-        diff::assert_hunks(
-            diff.snapshot.hunks_in_row_range(0..4, buffer),
+        assert_hunks(
+            diff.hunks_in_row_range(0..4, buffer, cx),
             buffer,
             &diff.base_text_string().unwrap(),
-            &[(1..2, "TWO\n", "two\n")],
+            &[(
+                1..2,
+                "TWO\n",
+                "two\n",
+                DiffHunkStatus::Modified(DiffHunkSecondaryStatus::HasSecondaryHunk),
+            )],
         );
     });
 
@@ -2689,11 +2695,11 @@ async fn test_git_diff_base_change(
             diff.base_text_string().as_deref(),
             Some(new_staged_text.as_str())
         );
-        diff::assert_hunks(
-            diff.snapshot.hunks_in_row_range(0..4, buffer),
+        assert_hunks(
+            diff.hunks_in_row_range(0..4, buffer, cx),
             buffer,
             &diff.base_text_string().unwrap(),
-            &[(2..3, "", "three\n")],
+            &[(2..3, "", "three\n", DiffHunkStatus::added())],
         );
     });
 
@@ -2703,11 +2709,11 @@ async fn test_git_diff_base_change(
             diff.base_text_string().as_deref(),
             Some(new_staged_text.as_str())
         );
-        diff::assert_hunks(
-            diff.snapshot.hunks_in_row_range(0..4, buffer),
+        assert_hunks(
+            diff.hunks_in_row_range(0..4, buffer, cx),
             buffer,
             &diff.base_text_string().unwrap(),
-            &[(2..3, "", "three\n")],
+            &[(2..3, "", "three\n", DiffHunkStatus::added())],
         );
     });
 
@@ -2717,11 +2723,16 @@ async fn test_git_diff_base_change(
             diff.base_text_string().as_deref(),
             Some(new_committed_text.as_str())
         );
-        diff::assert_hunks(
-            diff.snapshot.hunks_in_row_range(0..4, buffer),
+        assert_hunks(
+            diff.hunks_in_row_range(0..4, buffer, cx),
             buffer,
             &diff.base_text_string().unwrap(),
-            &[(1..2, "TWO_HUNDRED\n", "two\n")],
+            &[(
+                1..2,
+                "TWO_HUNDRED\n",
+                "two\n",
+                DiffHunkStatus::Modified(DiffHunkSecondaryStatus::OverlapsWithSecondaryHunk),
+            )],
         );
     });
 
@@ -2763,11 +2774,11 @@ async fn test_git_diff_base_change(
             diff.base_text_string().as_deref(),
             Some(staged_text.as_str())
         );
-        diff::assert_hunks(
-            diff.snapshot.hunks_in_row_range(0..4, buffer),
+        assert_hunks(
+            diff.hunks_in_row_range(0..4, buffer, cx),
             buffer,
             &diff.base_text_string().unwrap(),
-            &[(1..2, "", "two\n")],
+            &[(1..2, "", "two\n", DiffHunkStatus::added())],
         );
     });
 
@@ -2790,11 +2801,11 @@ async fn test_git_diff_base_change(
             diff.base_text_string().as_deref(),
             Some(staged_text.as_str())
         );
-        diff::assert_hunks(
-            diff.snapshot.hunks_in_row_range(0..4, buffer),
+        assert_hunks(
+            diff.hunks_in_row_range(0..4, buffer, cx),
             buffer,
             &staged_text,
-            &[(1..2, "", "two\n")],
+            &[(1..2, "", "two\n", DiffHunkStatus::added())],
         );
     });
 
@@ -2812,11 +2823,11 @@ async fn test_git_diff_base_change(
             diff.base_text_string().as_deref(),
             Some(new_staged_text.as_str())
         );
-        diff::assert_hunks(
-            diff.snapshot.hunks_in_row_range(0..4, buffer),
+        assert_hunks(
+            diff.hunks_in_row_range(0..4, buffer, cx),
             buffer,
             &new_staged_text,
-            &[(2..3, "", "three\n")],
+            &[(2..3, "", "three\n", DiffHunkStatus::added())],
         );
     });
 
@@ -2826,11 +2837,11 @@ async fn test_git_diff_base_change(
             diff.base_text_string().as_deref(),
             Some(new_staged_text.as_str())
         );
-        diff::assert_hunks(
-            diff.snapshot.hunks_in_row_range(0..4, buffer),
+        assert_hunks(
+            diff.hunks_in_row_range(0..4, buffer, cx),
             buffer,
             &new_staged_text,
-            &[(2..3, "", "three\n")],
+            &[(2..3, "", "three\n", DiffHunkStatus::added())],
         );
     });
 }

crates/command_palette/src/command_palette.rs 🔗

@@ -198,26 +198,29 @@ impl CommandPaletteDelegate {
     ) {
         self.updating_matches.take();
 
-        let mut intercept_result = CommandPaletteInterceptor::try_global(cx)
-            .and_then(|interceptor| interceptor.intercept(&query, cx));
+        let mut intercept_results = CommandPaletteInterceptor::try_global(cx)
+            .map(|interceptor| interceptor.intercept(&query, cx))
+            .unwrap_or_default();
 
         if parse_zed_link(&query, cx).is_some() {
-            intercept_result = Some(CommandInterceptResult {
+            intercept_results = vec![CommandInterceptResult {
                 action: OpenZedUrl { url: query.clone() }.boxed_clone(),
                 string: query.clone(),
                 positions: vec![],
-            })
+            }]
         }
 
-        if let Some(CommandInterceptResult {
+        let mut new_matches = Vec::new();
+
+        for CommandInterceptResult {
             action,
             string,
             positions,
-        }) = intercept_result
+        } in intercept_results
         {
             if let Some(idx) = matches
                 .iter()
-                .position(|m| commands[m.candidate_id].action.type_id() == action.type_id())
+                .position(|m| commands[m.candidate_id].action.partial_eq(&*action))
             {
                 matches.remove(idx);
             }
@@ -225,18 +228,16 @@ impl CommandPaletteDelegate {
                 name: string.clone(),
                 action,
             });
-            matches.insert(
-                0,
-                StringMatch {
-                    candidate_id: commands.len() - 1,
-                    string,
-                    positions,
-                    score: 0.0,
-                },
-            )
+            new_matches.push(StringMatch {
+                candidate_id: commands.len() - 1,
+                string,
+                positions,
+                score: 0.0,
+            })
         }
+        new_matches.append(&mut matches);
         self.commands = commands;
-        self.matches = matches;
+        self.matches = new_matches;
         if self.matches.is_empty() {
             self.selected_ix = 0;
         } else {

crates/command_palette_hooks/src/command_palette_hooks.rs 🔗

@@ -108,7 +108,7 @@ pub struct CommandInterceptResult {
 /// An interceptor for the command palette.
 #[derive(Default)]
 pub struct CommandPaletteInterceptor(
-    Option<Box<dyn Fn(&str, &App) -> Option<CommandInterceptResult>>>,
+    Option<Box<dyn Fn(&str, &App) -> Vec<CommandInterceptResult>>>,
 );
 
 #[derive(Default)]
@@ -132,10 +132,12 @@ impl CommandPaletteInterceptor {
     }
 
     /// Intercepts the given query from the command palette.
-    pub fn intercept(&self, query: &str, cx: &App) -> Option<CommandInterceptResult> {
-        let handler = self.0.as_ref()?;
-
-        (handler)(query, cx)
+    pub fn intercept(&self, query: &str, cx: &App) -> Vec<CommandInterceptResult> {
+        if let Some(handler) = self.0.as_ref() {
+            (handler)(query, cx)
+        } else {
+            Vec::new()
+        }
     }
 
     /// Clears the global interceptor.
@@ -146,7 +148,7 @@ impl CommandPaletteInterceptor {
     /// Sets the global interceptor.
     ///
     /// This will override the previous interceptor, if it exists.
-    pub fn set(&mut self, handler: Box<dyn Fn(&str, &App) -> Option<CommandInterceptResult>>) {
+    pub fn set(&mut self, handler: Box<dyn Fn(&str, &App) -> Vec<CommandInterceptResult>>) {
         self.0 = Some(handler);
     }
 }

crates/editor/Cargo.toml 🔗

@@ -38,7 +38,7 @@ clock.workspace = true
 collections.workspace = true
 convert_case.workspace = true
 db.workspace = true
-diff.workspace = true
+buffer_diff.workspace = true
 emojis.workspace = true
 file_icons.workspace = true
 futures.workspace = true

crates/editor/src/editor.rs 🔗

@@ -48,7 +48,7 @@ mod signature_help;
 pub mod test;
 
 pub(crate) use actions::*;
-pub use actions::{OpenExcerpts, OpenExcerptsSplit};
+pub use actions::{AcceptEditPrediction, OpenExcerpts, OpenExcerptsSplit};
 use aho_corasick::AhoCorasick;
 use anyhow::{anyhow, Context as _, Result};
 use blink_manager::BlinkManager;
@@ -73,17 +73,16 @@ use code_context_menus::{
     AvailableCodeAction, CodeActionContents, CodeActionsItem, CodeActionsMenu, CodeContextMenu,
     CompletionsMenu, ContextMenuOrigin,
 };
-use diff::DiffHunkStatus;
 use git::blame::GitBlame;
 use gpui::{
-    div, impl_actions, linear_color_stop, linear_gradient, point, prelude::*, pulsating_between,
-    px, relative, size, Action, Animation, AnimationExt, AnyElement, App, AsyncWindowContext,
-    AvailableSpace, Bounds, ClipboardEntry, ClipboardItem, Context, DispatchPhase, ElementId,
-    Entity, EntityInputHandler, EventEmitter, FocusHandle, FocusOutEvent, Focusable, FontId,
-    FontWeight, Global, HighlightStyle, Hsla, InteractiveText, KeyContext, Modifiers, MouseButton,
-    MouseDownEvent, PaintQuad, ParentElement, Pixels, Render, SharedString, Size, Styled,
-    StyledText, Subscription, Task, TextRun, TextStyle, TextStyleRefinement, UTF16Selection,
-    UnderlineStyle, UniformListScrollHandle, WeakEntity, WeakFocusHandle, Window,
+    div, impl_actions, point, prelude::*, pulsating_between, px, relative, size, Action, Animation,
+    AnimationExt, AnyElement, App, AsyncWindowContext, AvailableSpace, Background, Bounds,
+    ClipboardEntry, ClipboardItem, Context, DispatchPhase, ElementId, Entity, EntityInputHandler,
+    EventEmitter, FocusHandle, FocusOutEvent, Focusable, FontId, FontWeight, Global,
+    HighlightStyle, Hsla, InteractiveText, KeyContext, Modifiers, MouseButton, MouseDownEvent,
+    PaintQuad, ParentElement, Pixels, Render, SharedString, Size, Styled, StyledText, Subscription,
+    Task, TextStyle, TextStyleRefinement, UTF16Selection, UnderlineStyle, UniformListScrollHandle,
+    WeakEntity, WeakFocusHandle, Window,
 };
 use highlight_matching_bracket::refresh_matching_bracket_highlights;
 use hover_popover::{hide_hover, HoverState};
@@ -486,7 +485,6 @@ enum InlineCompletion {
     },
     Move {
         target: Anchor,
-        range_around_target: Range<text::Anchor>,
         snapshot: BufferSnapshot,
     },
 }
@@ -522,6 +520,296 @@ pub enum MenuInlineCompletionsPolicy {
     ByProvider,
 }
 
+// TODO az do we need this?
+#[derive(Clone)]
+pub enum EditPredictionPreview {
+    /// Modifier is not pressed
+    Inactive,
+    /// Modifier pressed, animating to active
+    MovingTo {
+        animation: Range<Instant>,
+        scroll_position_at_start: Option<gpui::Point<f32>>,
+        target_point: DisplayPoint,
+    },
+    Arrived {
+        scroll_position_at_start: Option<gpui::Point<f32>>,
+        scroll_position_at_arrival: Option<gpui::Point<f32>>,
+        target_point: Option<DisplayPoint>,
+    },
+    /// Modifier released, animating from active
+    MovingFrom {
+        animation: Range<Instant>,
+        target_point: DisplayPoint,
+    },
+}
+
+impl EditPredictionPreview {
+    fn start(
+        &mut self,
+        completion: &InlineCompletion,
+        snapshot: &EditorSnapshot,
+        cursor: DisplayPoint,
+    ) -> bool {
+        if matches!(self, Self::MovingTo { .. } | Self::Arrived { .. }) {
+            return false;
+        }
+        (*self, _) = Self::start_now(completion, snapshot, cursor);
+        true
+    }
+
+    fn restart(
+        &mut self,
+        completion: &InlineCompletion,
+        snapshot: &EditorSnapshot,
+        cursor: DisplayPoint,
+    ) -> bool {
+        match self {
+            Self::Inactive => false,
+            Self::MovingTo { target_point, .. }
+            | Self::Arrived {
+                target_point: Some(target_point),
+                ..
+            } => {
+                let (new_preview, new_target_point) = Self::start_now(completion, snapshot, cursor);
+
+                if new_target_point != Some(*target_point) {
+                    *self = new_preview;
+                    return true;
+                }
+
+                false
+            }
+            Self::Arrived {
+                target_point: None, ..
+            } => {
+                let (new_preview, _) = Self::start_now(completion, snapshot, cursor);
+
+                *self = new_preview;
+                true
+            }
+            Self::MovingFrom { .. } => false,
+        }
+    }
+
+    fn start_now(
+        completion: &InlineCompletion,
+        snapshot: &EditorSnapshot,
+        cursor: DisplayPoint,
+    ) -> (Self, Option<DisplayPoint>) {
+        let now = Instant::now();
+        match completion {
+            InlineCompletion::Edit { .. } => (
+                Self::Arrived {
+                    target_point: None,
+                    scroll_position_at_start: None,
+                    scroll_position_at_arrival: None,
+                },
+                None,
+            ),
+            InlineCompletion::Move { target, .. } => {
+                let target_point = target.to_display_point(&snapshot.display_snapshot);
+                let duration = Self::animation_duration(cursor, target_point);
+
+                (
+                    Self::MovingTo {
+                        animation: now..now + duration,
+                        scroll_position_at_start: Some(snapshot.scroll_position()),
+                        target_point,
+                    },
+                    Some(target_point),
+                )
+            }
+        }
+    }
+
+    fn animation_duration(a: DisplayPoint, b: DisplayPoint) -> Duration {
+        const SPEED: f32 = 8.0;
+
+        let row_diff = b.row().0.abs_diff(a.row().0);
+        let column_diff = b.column().abs_diff(a.column());
+        let distance = ((row_diff.pow(2) + column_diff.pow(2)) as f32).sqrt();
+        Duration::from_millis((distance * SPEED) as u64)
+    }
+
+    fn end(
+        &mut self,
+        cursor: DisplayPoint,
+        scroll_pixel_position: gpui::Point<Pixels>,
+        window: &mut Window,
+        cx: &mut Context<Editor>,
+    ) -> bool {
+        let (scroll_position, target_point) = match self {
+            Self::MovingTo {
+                scroll_position_at_start,
+                target_point,
+                ..
+            }
+            | Self::Arrived {
+                scroll_position_at_start,
+                scroll_position_at_arrival: None,
+                target_point: Some(target_point),
+                ..
+            } => (*scroll_position_at_start, target_point),
+            Self::Arrived {
+                scroll_position_at_start,
+                scroll_position_at_arrival: Some(scroll_at_arrival),
+                target_point: Some(target_point),
+            } => {
+                const TOLERANCE: f32 = 4.0;
+
+                let diff = *scroll_at_arrival - scroll_pixel_position.map(|p| p.0);
+
+                if diff.x.abs() < TOLERANCE && diff.y.abs() < TOLERANCE {
+                    (*scroll_position_at_start, target_point)
+                } else {
+                    (None, target_point)
+                }
+            }
+            Self::Arrived {
+                target_point: None, ..
+            } => {
+                *self = Self::Inactive;
+                return true;
+            }
+            Self::MovingFrom { .. } | Self::Inactive => return false,
+        };
+
+        let now = Instant::now();
+        let duration = Self::animation_duration(cursor, *target_point);
+        let target_point = *target_point;
+
+        *self = Self::MovingFrom {
+            animation: now..now + duration,
+            target_point,
+        };
+
+        if let Some(scroll_position) = scroll_position {
+            cx.spawn_in(window, |editor, mut cx| async move {
+                smol::Timer::after(duration).await;
+                editor
+                    .update_in(&mut cx, |editor, window, cx| {
+                        if let Self::MovingFrom { .. } | Self::Inactive =
+                            editor.edit_prediction_preview
+                        {
+                            editor.set_scroll_position(scroll_position, window, cx)
+                        }
+                    })
+                    .log_err();
+            })
+            .detach();
+        }
+
+        true
+    }
+
+    /// Whether the preview is active or we are animating to or from it.
+    fn is_active(&self) -> bool {
+        matches!(
+            self,
+            Self::MovingTo { .. } | Self::Arrived { .. } | Self::MovingFrom { .. }
+        )
+    }
+
+    /// Returns true if the preview is active, not cancelled, and the animation is settled.
+    fn is_active_settled(&self) -> bool {
+        matches!(self, Self::Arrived { .. })
+    }
+
+    #[allow(clippy::too_many_arguments)]
+    fn move_state(
+        &mut self,
+        snapshot: &EditorSnapshot,
+        visible_row_range: Range<DisplayRow>,
+        line_layouts: &[LineWithInvisibles],
+        scroll_pixel_position: gpui::Point<Pixels>,
+        line_height: Pixels,
+        target: Anchor,
+        cursor: Option<DisplayPoint>,
+    ) -> Option<EditPredictionMoveState> {
+        let delta = match self {
+            Self::Inactive => return None,
+            Self::Arrived { .. } => 1.,
+            Self::MovingTo {
+                animation,
+                scroll_position_at_start: original_scroll_position,
+                target_point,
+            } => {
+                let now = Instant::now();
+                if animation.end < now {
+                    *self = Self::Arrived {
+                        scroll_position_at_start: *original_scroll_position,
+                        scroll_position_at_arrival: Some(scroll_pixel_position.map(|p| p.0)),
+                        target_point: Some(*target_point),
+                    };
+                    1.0
+                } else {
+                    (now - animation.start).as_secs_f32()
+                        / (animation.end - animation.start).as_secs_f32()
+                }
+            }
+            Self::MovingFrom { animation, .. } => {
+                let now = Instant::now();
+                if animation.end < now {
+                    *self = Self::Inactive;
+                    return None;
+                } else {
+                    let delta = (now - animation.start).as_secs_f32()
+                        / (animation.end - animation.start).as_secs_f32();
+                    1.0 - delta
+                }
+            }
+        };
+
+        let cursor = cursor?;
+
+        if !visible_row_range.contains(&cursor.row()) {
+            return None;
+        }
+
+        let target_position = target.to_display_point(&snapshot.display_snapshot);
+
+        if !visible_row_range.contains(&target_position.row()) {
+            return None;
+        }
+
+        let target_row_layout =
+            &line_layouts[target_position.row().minus(visible_row_range.start) as usize];
+        let target_column = target_position.column() as usize;
+
+        let target_character_x = target_row_layout.x_for_index(target_column);
+
+        let target_x = target_character_x - scroll_pixel_position.x;
+        let target_y =
+            (target_position.row().as_f32() - scroll_pixel_position.y / line_height) * line_height;
+
+        let origin_x = line_layouts[cursor.row().minus(visible_row_range.start) as usize]
+            .x_for_index(cursor.column() as usize);
+        let origin_y =
+            (cursor.row().as_f32() - scroll_pixel_position.y / line_height) * line_height;
+
+        let delta = 1.0 - (-10.0 * delta).exp2();
+
+        let x = origin_x + (target_x - origin_x) * delta;
+        let y = origin_y + (target_y - origin_y) * delta;
+
+        Some(EditPredictionMoveState {
+            delta,
+            position: point(x, y),
+        })
+    }
+}
+
+pub(crate) struct EditPredictionMoveState {
+    delta: f32,
+    position: gpui::Point<Pixels>,
+}
+
+impl EditPredictionMoveState {
+    pub fn is_animation_completed(&self) -> bool {
+        self.delta >= 1.
+    }
+}
+
 #[derive(Copy, Clone, Eq, PartialEq, PartialOrd, Ord, Debug, Default)]
 struct EditorActionId(usize);
 
@@ -705,7 +993,7 @@ pub struct Editor {
     inline_completions_hidden_for_vim_mode: bool,
     show_inline_completions_override: Option<bool>,
     menu_inline_completions_policy: MenuInlineCompletionsPolicy,
-    previewing_inline_completion: bool,
+    edit_prediction_preview: EditPredictionPreview,
     inlay_hint_cache: InlayHintCache,
     next_inlay_id: usize,
     _subscriptions: Vec<Subscription>,
@@ -722,6 +1010,7 @@ pub struct Editor {
     show_git_blame_gutter: bool,
     show_git_blame_inline: bool,
     show_git_blame_inline_delay_task: Option<Task<()>>,
+    distinguish_unstaged_diff_hunks: bool,
     git_blame_inline_enabled: bool,
     serialize_dirty_buffers: bool,
     show_selection_menu: Option<bool>,
@@ -1397,7 +1686,7 @@ impl Editor {
             edit_prediction_provider: None,
             active_inline_completion: None,
             stale_inline_completion_in_menu: None,
-            previewing_inline_completion: false,
+            edit_prediction_preview: EditPredictionPreview::Inactive,
             inlay_hint_cache: InlayHintCache::new(inlay_hint_settings),
 
             gutter_hovered: false,
@@ -1418,6 +1707,7 @@ impl Editor {
             custom_context_menu: None,
             show_git_blame_gutter: false,
             show_git_blame_inline: false,
+            distinguish_unstaged_diff_hunks: false,
             show_selection_menu: None,
             show_git_blame_inline_delay_task: None,
             git_blame_inline_enabled: ProjectSettings::get_global(cx).git.inline_blame_enabled(),
@@ -1574,6 +1864,7 @@ impl Editor {
             window
                 .bindings_for_action_in_context(&AcceptEditPrediction, context)
                 .into_iter()
+                .rev()
                 .next(),
         )
     }
@@ -1939,15 +2230,6 @@ impl Editor {
         self.refresh_inline_completion(false, true, window, cx);
     }
 
-    pub fn inline_completion_start_anchor(&self) -> Option<Anchor> {
-        let active_completion = self.active_inline_completion.as_ref()?;
-        let result = match &active_completion.completion {
-            InlineCompletion::Edit { edits, .. } => edits.first()?.0.start,
-            InlineCompletion::Move { target, .. } => *target,
-        };
-        Some(result)
-    }
-
     fn inline_completions_disabled_in_scope(
         &self,
         buffer: &Entity<Buffer>,
@@ -5119,11 +5401,11 @@ impl Editor {
         true
     }
 
-    /// Returns true when we're displaying the inline completion popover below the cursor
+    /// Returns true when we're displaying the edit prediction popover below the cursor
     /// like we are not previewing and the LSP autocomplete menu is visible
     /// or we are in `when_holding_modifier` mode.
     pub fn edit_prediction_visible_in_cursor_popover(&self, has_completion: bool) -> bool {
-        if self.previewing_inline_completion
+        if self.edit_prediction_preview.is_active()
             || !self.show_edit_predictions_in_menu()
             || !self.edit_predictions_enabled()
         {
@@ -5145,15 +5427,7 @@ impl Editor {
         cx: &mut Context<Self>,
     ) {
         if self.show_edit_predictions_in_menu() {
-            let accept_binding = self.accept_edit_prediction_keybind(window, cx);
-            if let Some(accept_keystroke) = accept_binding.keystroke() {
-                let was_previewing_inline_completion = self.previewing_inline_completion;
-                self.previewing_inline_completion = modifiers == accept_keystroke.modifiers
-                    && accept_keystroke.modifiers.modified();
-                if self.previewing_inline_completion != was_previewing_inline_completion {
-                    self.update_visible_inline_completion(window, cx);
-                }
-            }
+            self.update_edit_prediction_preview(&modifiers, position_map, window, cx);
         }
 
         let mouse_position = window.mouse_position();
@@ -5170,9 +5444,50 @@ impl Editor {
         )
     }
 
+    fn update_edit_prediction_preview(
+        &mut self,
+        modifiers: &Modifiers,
+        position_map: &PositionMap,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let accept_keybind = self.accept_edit_prediction_keybind(window, cx);
+        let Some(accept_keystroke) = accept_keybind.keystroke() else {
+            return;
+        };
+
+        if &accept_keystroke.modifiers == modifiers {
+            if let Some(completion) = self.active_inline_completion.as_ref() {
+                if self.edit_prediction_preview.start(
+                    &completion.completion,
+                    &position_map.snapshot,
+                    self.selections
+                        .newest_anchor()
+                        .head()
+                        .to_display_point(&position_map.snapshot),
+                ) {
+                    self.request_autoscroll(Autoscroll::fit(), cx);
+                    self.update_visible_inline_completion(window, cx);
+                    cx.notify();
+                }
+            }
+        } else if self.edit_prediction_preview.end(
+            self.selections
+                .newest_anchor()
+                .head()
+                .to_display_point(&position_map.snapshot),
+            position_map.scroll_pixel_position,
+            window,
+            cx,
+        ) {
+            self.update_visible_inline_completion(window, cx);
+            cx.notify();
+        }
+    }
+
     fn update_visible_inline_completion(
         &mut self,
-        _window: &mut Window,
+        window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Option<()> {
         let selection = self.selections.newest_anchor();
@@ -5259,25 +5574,11 @@ impl Editor {
             invalidation_row_range =
                 move_invalidation_row_range.unwrap_or(edit_start_row..edit_end_row);
             let target = first_edit_start;
-            let target_point = text::ToPoint::to_point(&target.text_anchor, &snapshot);
-            // TODO: Base this off of TreeSitter or word boundaries?
-            let target_excerpt_begin = snapshot.anchor_before(snapshot.clip_point(
-                Point::new(target_point.row, target_point.column.saturating_sub(20)),
-                Bias::Left,
-            ));
-            let target_excerpt_end = snapshot.anchor_after(snapshot.clip_point(
-                Point::new(target_point.row, target_point.column + 20),
-                Bias::Right,
-            ));
-            let range_around_target = target_excerpt_begin..target_excerpt_end;
-            InlineCompletion::Move {
-                target,
-                range_around_target,
-                snapshot,
-            }
+            InlineCompletion::Move { target, snapshot }
         } else {
             let show_completions_in_buffer = !self.edit_prediction_visible_in_cursor_popover(true)
                 && !self.inline_completions_hidden_for_vim_mode;
+
             if show_completions_in_buffer {
                 if edits
                     .iter()
@@ -5336,6 +5637,15 @@ impl Editor {
             ));
 
         self.stale_inline_completion_in_menu = None;
+        let editor_snapshot = self.snapshot(window, cx);
+        if self.edit_prediction_preview.restart(
+            &completion,
+            &editor_snapshot,
+            cursor.to_display_point(&editor_snapshot),
+        ) {
+            self.request_autoscroll(Autoscroll::fit(), cx);
+        }
+
         self.active_inline_completion = Some(InlineCompletionState {
             inlay_ids,
             completion,
@@ -5563,7 +5873,7 @@ impl Editor {
     }
 
     pub fn context_menu_visible(&self) -> bool {
-        !self.previewing_inline_completion
+        !self.edit_prediction_preview.is_active()
             && self
                 .context_menu
                 .borrow()
@@ -5598,7 +5908,7 @@ impl Editor {
         cursor_point: Point,
         style: &EditorStyle,
         accept_keystroke: &gpui::Keystroke,
-        window: &Window,
+        _window: &Window,
         cx: &mut Context<Editor>,
     ) -> Option<AnyElement> {
         let provider = self.edit_prediction_provider.as_ref()?;
@@ -5653,20 +5963,51 @@ impl Editor {
         }
 
         let completion = match &self.active_inline_completion {
-            Some(completion) => self.render_edit_prediction_cursor_popover_preview(
-                completion,
-                cursor_point,
-                style,
-                window,
-                cx,
-            )?,
+            Some(completion) => match &completion.completion {
+                InlineCompletion::Move {
+                    target, snapshot, ..
+                } if !self.has_visible_completions_menu() => {
+                    use text::ToPoint as _;
+
+                    return Some(
+                        h_flex()
+                            .px_2()
+                            .py_1()
+                            .elevation_2(cx)
+                            .border_color(cx.theme().colors().border)
+                            .rounded_tl(px(0.))
+                            .gap_2()
+                            .child(
+                                if target.text_anchor.to_point(&snapshot).row > cursor_point.row {
+                                    Icon::new(IconName::ZedPredictDown)
+                                } else {
+                                    Icon::new(IconName::ZedPredictUp)
+                                },
+                            )
+                            .child(Label::new("Hold"))
+                            .children(ui::render_modifiers(
+                                &accept_keystroke.modifiers,
+                                PlatformStyle::platform(),
+                                Some(Color::Default),
+                                None,
+                                true,
+                            ))
+                            .into_any(),
+                    );
+                }
+                _ => self.render_edit_prediction_cursor_popover_preview(
+                    completion,
+                    cursor_point,
+                    style,
+                    cx,
+                )?,
+            },
 
             None if is_refreshing => match &self.stale_inline_completion_in_menu {
                 Some(stale_completion) => self.render_edit_prediction_cursor_popover_preview(
                     stale_completion,
                     cursor_point,
                     style,
-                    window,
                     cx,
                 )?,
 
@@ -5678,9 +6019,6 @@ impl Editor {
             None => pending_completion_container().child(Label::new("No Prediction")),
         };
 
-        let buffer_font = theme::ThemeSettings::get_global(cx).buffer_font.clone();
-        let completion = completion.font(buffer_font.clone());
-
         let completion = if is_refreshing {
             completion
                 .with_animation(
@@ -5705,6 +6043,7 @@ impl Editor {
                 .px_2()
                 .py_1()
                 .elevation_2(cx)
+                .border_color(cx.theme().colors().border)
                 .child(completion)
                 .child(ui::Divider::vertical())
                 .child(
@@ -5712,19 +6051,22 @@ impl Editor {
                         .h_full()
                         .gap_1()
                         .pl_2()
-                        .child(h_flex().font(buffer_font.clone()).gap_1().children(
-                            ui::render_modifiers(
-                                &accept_keystroke.modifiers,
-                                PlatformStyle::platform(),
-                                Some(if !has_completion {
-                                    Color::Muted
-                                } else {
-                                    Color::Default
-                                }),
-                                None,
-                                true,
-                            ),
-                        ))
+                        .child(
+                            h_flex()
+                                .font(theme::ThemeSettings::get_global(cx).buffer_font.clone())
+                                .gap_1()
+                                .children(ui::render_modifiers(
+                                    &accept_keystroke.modifiers,
+                                    PlatformStyle::platform(),
+                                    Some(if !has_completion {
+                                        Color::Muted
+                                    } else {
+                                        Color::Default
+                                    }),
+                                    None,
+                                    true,
+                                )),
+                        )
                         .child(Label::new("Preview").into_any_element())
                         .opacity(if has_completion { 1.0 } else { 0.4 }),
                 )
@@ -5737,7 +6079,6 @@ impl Editor {
         completion: &InlineCompletionState,
         cursor_point: Point,
         style: &EditorStyle,
-        window: &Window,
         cx: &mut Context<Editor>,
     ) -> Option<Div> {
         use text::ToPoint as _;
@@ -5763,6 +6104,23 @@ impl Editor {
         }
 
         match &completion.completion {
+            InlineCompletion::Move {
+                target, snapshot, ..
+            } => Some(
+                h_flex()
+                    .px_2()
+                    .gap_2()
+                    .flex_1()
+                    .child(
+                        if target.text_anchor.to_point(&snapshot).row > cursor_point.row {
+                            Icon::new(IconName::ZedPredictDown)
+                        } else {
+                            Icon::new(IconName::ZedPredictUp)
+                        },
+                    )
+                    .child(Label::new("Jump to Edit")),
+            ),
+
             InlineCompletion::Edit {
                 edits,
                 edit_preview,
@@ -5832,103 +6190,11 @@ impl Editor {
                         .gap_2()
                         .pr_1()
                         .overflow_x_hidden()
+                        .font(theme::ThemeSettings::get_global(cx).buffer_font.clone())
                         .child(left)
                         .child(preview),
                 )
             }
-
-            InlineCompletion::Move {
-                target,
-                range_around_target,
-                snapshot,
-            } => {
-                let highlighted_text = snapshot.highlighted_text_for_range(
-                    range_around_target.clone(),
-                    None,
-                    &style.syntax,
-                );
-                let base = h_flex().gap_3().flex_1().child(render_relative_row_jump(
-                    "Jump ",
-                    cursor_point.row,
-                    target.text_anchor.to_point(&snapshot).row,
-                ));
-
-                if highlighted_text.text.is_empty() {
-                    return Some(base);
-                }
-
-                let cursor_color = self.current_user_player_color(cx).cursor;
-
-                let start_point = range_around_target.start.to_point(&snapshot);
-                let end_point = range_around_target.end.to_point(&snapshot);
-                let target_point = target.text_anchor.to_point(&snapshot);
-
-                let styled_text = highlighted_text.to_styled_text(&style.text);
-                let text_len = highlighted_text.text.len();
-
-                let cursor_relative_position = window
-                    .text_system()
-                    .layout_line(
-                        highlighted_text.text,
-                        style.text.font_size.to_pixels(window.rem_size()),
-                        // We don't need to include highlights
-                        // because we are only using this for the cursor position
-                        &[TextRun {
-                            len: text_len,
-                            font: style.text.font(),
-                            color: style.text.color,
-                            background_color: None,
-                            underline: None,
-                            strikethrough: None,
-                        }],
-                    )
-                    .log_err()
-                    .map(|line| {
-                        line.x_for_index(
-                            target_point.column.saturating_sub(start_point.column) as usize
-                        )
-                    });
-
-                let fade_before = start_point.column > 0;
-                let fade_after = end_point.column < snapshot.line_len(end_point.row);
-
-                let background = cx.theme().colors().elevated_surface_background;
-
-                let preview = h_flex()
-                    .relative()
-                    .child(styled_text)
-                    .when(fade_before, |parent| {
-                        parent.child(div().absolute().top_0().left_0().w_4().h_full().bg(
-                            linear_gradient(
-                                90.,
-                                linear_color_stop(background, 0.),
-                                linear_color_stop(background.opacity(0.), 1.),
-                            ),
-                        ))
-                    })
-                    .when(fade_after, |parent| {
-                        parent.child(div().absolute().top_0().right_0().w_4().h_full().bg(
-                            linear_gradient(
-                                -90.,
-                                linear_color_stop(background, 0.),
-                                linear_color_stop(background.opacity(0.), 1.),
-                            ),
-                        ))
-                    })
-                    .when_some(cursor_relative_position, |parent, position| {
-                        parent.child(
-                            div()
-                                .w(px(2.))
-                                .h_full()
-                                .bg(cursor_color)
-                                .absolute()
-                                .top_0()
-                                .left(position),
-                        )
-                    });
-
-                Some(base.child(preview))
-            }
         }
     }
 
@@ -6886,8 +7152,7 @@ impl Editor {
         let buffer = buffer.read(cx);
         let original_text = diff
             .read(cx)
-            .snapshot
-            .base_text
+            .base_text()
             .as_ref()?
             .as_rope()
             .slice(hunk.diff_base_byte_range.clone());
@@ -12298,6 +12563,10 @@ impl Editor {
         });
     }
 
+    pub fn set_distinguish_unstaged_diff_hunks(&mut self) {
+        self.distinguish_unstaged_diff_hunks = true;
+    }
+
     pub fn expand_all_diff_hunks(
         &mut self,
         _: &ExpandAllHunkDiffs,
@@ -13340,14 +13609,14 @@ impl Editor {
         &self,
         window: &mut Window,
         cx: &mut App,
-    ) -> BTreeMap<DisplayRow, Hsla> {
+    ) -> BTreeMap<DisplayRow, Background> {
         let snapshot = self.snapshot(window, cx);
         let mut used_highlight_orders = HashMap::default();
         self.highlighted_rows
             .iter()
             .flat_map(|(_, highlighted_rows)| highlighted_rows.iter())
             .fold(
-                BTreeMap::<DisplayRow, Hsla>::new(),
+                BTreeMap::<DisplayRow, Background>::new(),
                 |mut unique_rows, highlight| {
                     let start = highlight.range.start.to_display_point(&snapshot);
                     let end = highlight.range.end.to_display_point(&snapshot);
@@ -13364,7 +13633,7 @@ impl Editor {
                             used_highlight_orders.entry(row).or_insert(highlight.index);
                         if highlight.index >= *used_index {
                             *used_index = highlight.index;
-                            unique_rows.insert(DisplayRow(row), highlight.color);
+                            unique_rows.insert(DisplayRow(row), highlight.color.into());
                         }
                     }
                     unique_rows
@@ -13744,6 +14013,23 @@ impl Editor {
         }
     }
 
+    pub fn previewing_edit_prediction_move(
+        &mut self,
+    ) -> Option<(Anchor, &mut EditPredictionPreview)> {
+        if !self.edit_prediction_preview.is_active() {
+            return None;
+        };
+
+        self.active_inline_completion
+            .as_ref()
+            .and_then(|completion| match completion.completion {
+                InlineCompletion::Move { target, .. } => {
+                    Some((target, &mut self.edit_prediction_preview))
+                }
+                _ => None,
+            })
+    }
+
     pub fn show_local_cursors(&self, window: &mut Window, cx: &mut App) -> bool {
         (self.read_only(cx) || self.blink_manager.read(cx).visible())
             && self.focus_handle.is_focused(window)
@@ -14576,7 +14862,7 @@ impl Editor {
     }
 
     pub fn has_visible_completions_menu(&self) -> bool {
-        !self.previewing_inline_completion
+        !self.edit_prediction_preview.is_active()
             && self.context_menu.borrow().as_ref().map_or(false, |menu| {
                 menu.visible() && matches!(menu, CodeContextMenu::Completions(_))
             })
@@ -15526,7 +15812,7 @@ impl EditorSnapshot {
             ) {
                 // 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 allow_adjacent = hunk.status().is_removed();
                 let related_to_selection = if allow_adjacent {
                     hunk.row_range.overlaps(&query_rows)
                         || hunk.row_range.start == query_rows.end

crates/editor/src/editor_tests.rs 🔗

@@ -7,7 +7,7 @@ use crate::{
     },
     JoinLines,
 };
-use diff::{BufferDiff, DiffHunkStatus};
+use buffer_diff::{BufferDiff, DiffHunkStatus};
 use futures::StreamExt;
 use gpui::{
     div, BackgroundExecutor, SemanticVersion, TestAppContext, UpdateGlobal, VisualTestContext,
@@ -11989,7 +11989,7 @@ async fn test_addition_reverts(cx: &mut gpui::TestAppContext) {
                    struct Row9.2;
                    struct Row9.3;
                    struct Row10;"#},
-        vec![DiffHunkStatus::Added, DiffHunkStatus::Added],
+        vec![DiffHunkStatus::added(), DiffHunkStatus::added()],
         indoc! {r#"struct Row;
                    struct Row1;
                    struct Row1.1;
@@ -12027,7 +12027,7 @@ async fn test_addition_reverts(cx: &mut gpui::TestAppContext) {
                    struct Row8;
                    struct Row9;
                    struct Row10;"#},
-        vec![DiffHunkStatus::Added, DiffHunkStatus::Added],
+        vec![DiffHunkStatus::added(), DiffHunkStatus::added()],
         indoc! {r#"struct Row;
                    struct Row1;
                    struct Row2;
@@ -12074,11 +12074,11 @@ async fn test_addition_reverts(cx: &mut gpui::TestAppContext) {
                    «ˇ// something on bottom»
                    struct Row10;"#},
         vec![
-            DiffHunkStatus::Added,
-            DiffHunkStatus::Added,
-            DiffHunkStatus::Added,
-            DiffHunkStatus::Added,
-            DiffHunkStatus::Added,
+            DiffHunkStatus::added(),
+            DiffHunkStatus::added(),
+            DiffHunkStatus::added(),
+            DiffHunkStatus::added(),
+            DiffHunkStatus::added(),
         ],
         indoc! {r#"struct Row;
                    ˇstruct Row1;
@@ -12126,7 +12126,7 @@ async fn test_modification_reverts(cx: &mut gpui::TestAppContext) {
                    struct Row99;
                    struct Row9;
                    struct Row10;"#},
-        vec![DiffHunkStatus::Modified, DiffHunkStatus::Modified],
+        vec![DiffHunkStatus::modified(), DiffHunkStatus::modified()],
         indoc! {r#"struct Row;
                    struct Row1;
                    struct Row33;
@@ -12153,7 +12153,7 @@ async fn test_modification_reverts(cx: &mut gpui::TestAppContext) {
                    struct Row99;
                    struct Row9;
                    struct Row10;"#},
-        vec![DiffHunkStatus::Modified, DiffHunkStatus::Modified],
+        vec![DiffHunkStatus::modified(), DiffHunkStatus::modified()],
         indoc! {r#"struct Row;
                    struct Row1;
                    struct Row33;
@@ -12182,12 +12182,12 @@ async fn test_modification_reverts(cx: &mut gpui::TestAppContext) {
                    struct Row9;
                    struct Row1011;ˇ"#},
         vec![
-            DiffHunkStatus::Modified,
-            DiffHunkStatus::Modified,
-            DiffHunkStatus::Modified,
-            DiffHunkStatus::Modified,
-            DiffHunkStatus::Modified,
-            DiffHunkStatus::Modified,
+            DiffHunkStatus::modified(),
+            DiffHunkStatus::modified(),
+            DiffHunkStatus::modified(),
+            DiffHunkStatus::modified(),
+            DiffHunkStatus::modified(),
+            DiffHunkStatus::modified(),
         ],
         indoc! {r#"struct Row;
                    ˇstruct Row1;
@@ -12265,7 +12265,7 @@ struct Row10;"#};
                    ˇ
                    struct Row8;
                    struct Row10;"#},
-        vec![DiffHunkStatus::Removed, DiffHunkStatus::Removed],
+        vec![DiffHunkStatus::removed(), DiffHunkStatus::removed()],
         indoc! {r#"struct Row;
                    struct Row2;
 
@@ -12288,7 +12288,7 @@ struct Row10;"#};
                    ˇ»
                    struct Row8;
                    struct Row10;"#},
-        vec![DiffHunkStatus::Removed, DiffHunkStatus::Removed],
+        vec![DiffHunkStatus::removed(), DiffHunkStatus::removed()],
         indoc! {r#"struct Row;
                    struct Row2;
 
@@ -12313,7 +12313,7 @@ struct Row10;"#};
 
                    struct Row8;ˇ
                    struct Row10;"#},
-        vec![DiffHunkStatus::Removed, DiffHunkStatus::Removed],
+        vec![DiffHunkStatus::removed(), DiffHunkStatus::removed()],
         indoc! {r#"struct Row;
                    struct Row1;
                    ˇstruct Row2;
@@ -12338,9 +12338,9 @@ struct Row10;"#};
                    struct Row8;ˇ»
                    struct Row10;"#},
         vec![
-            DiffHunkStatus::Removed,
-            DiffHunkStatus::Removed,
-            DiffHunkStatus::Removed,
+            DiffHunkStatus::removed(),
+            DiffHunkStatus::removed(),
+            DiffHunkStatus::removed(),
         ],
         indoc! {r#"struct Row;
                    struct Row1;

crates/editor/src/element.rs 🔗

@@ -16,30 +16,30 @@ use crate::{
     mouse_context_menu::{self, MenuPosition, MouseContextMenu},
     scroll::{axis_pair, scroll_amount::ScrollAmount, AxisPair},
     AcceptEditPrediction, BlockId, ChunkReplacement, CursorShape, CustomBlockId, DisplayPoint,
-    DisplayRow, DocumentHighlightRead, DocumentHighlightWrite, EditDisplayMode, Editor, EditorMode,
-    EditorSettings, EditorSnapshot, EditorStyle, ExpandExcerpts, FocusedBlock, GoToHunk,
-    GoToPrevHunk, GutterDimensions, HalfPageDown, HalfPageUp, HandleInput, HoveredCursor,
-    InlineCompletion, JumpData, LineDown, LineUp, OpenExcerpts, PageDown, PageUp, Point,
-    RevertSelectedHunks, RowExt, RowRangeExt, SelectPhase, Selection, SoftWrap,
-    StickyHeaderExcerpt, ToPoint, ToggleFold, CURSORS_VISIBLE_FOR,
+    DisplayRow, DocumentHighlightRead, DocumentHighlightWrite, EditDisplayMode,
+    EditPredictionPreview, Editor, EditorMode, EditorSettings, EditorSnapshot, EditorStyle,
+    ExpandExcerpts, FocusedBlock, GoToHunk, GoToPrevHunk, GutterDimensions, HalfPageDown,
+    HalfPageUp, HandleInput, HoveredCursor, InlineCompletion, JumpData, LineDown, LineUp,
+    OpenExcerpts, PageDown, PageUp, Point, RevertSelectedHunks, RowExt, RowRangeExt, SelectPhase,
+    Selection, SoftWrap, StickyHeaderExcerpt, ToPoint, ToggleFold, CURSORS_VISIBLE_FOR,
     EDIT_PREDICTION_REQUIRES_MODIFIER_KEY_CONTEXT, FILE_HEADER_HEIGHT,
     GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED, MAX_LINE_LEN, MULTI_BUFFER_EXCERPT_HEADER_HEIGHT,
 };
+use buffer_diff::{DiffHunkSecondaryStatus, DiffHunkStatus};
 use client::ParticipantIndex;
 use collections::{BTreeMap, HashMap, HashSet};
-use diff::DiffHunkStatus;
 use file_icons::FileIcons;
 use git::{blame::BlameEntry, Oid};
 use gpui::{
-    anchored, deferred, div, fill, linear_color_stop, linear_gradient, outline, point, px, quad,
-    relative, size, svg, transparent_black, Action, AnyElement, App, AvailableSpace, Axis, Bounds,
-    ClickEvent, ClipboardItem, ContentMask, Context, Corner, Corners, CursorStyle, DispatchPhase,
-    Edges, Element, ElementInputHandler, Entity, Focusable as _, FontId, GlobalElementId, Hitbox,
-    Hsla, InteractiveElement, IntoElement, KeyBindingContextPredicate, Keystroke, Length,
-    ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad,
-    ParentElement, Pixels, ScrollDelta, ScrollWheelEvent, ShapedLine, SharedString, Size,
-    StatefulInteractiveElement, Style, Styled, Subscription, TextRun, TextStyleRefinement,
-    WeakEntity, Window,
+    anchored, deferred, div, fill, linear_color_stop, linear_gradient, outline, pattern_slash,
+    point, px, quad, relative, size, svg, transparent_black, Action, AnyElement, App,
+    AvailableSpace, Axis, Bounds, ClickEvent, ClipboardItem, ContentMask, Context, Corner, Corners,
+    CursorStyle, DispatchPhase, Edges, Element, ElementInputHandler, Entity, Focusable as _,
+    FontId, GlobalElementId, Hitbox, Hsla, InteractiveElement, IntoElement,
+    KeyBindingContextPredicate, Keystroke, Length, ModifiersChangedEvent, MouseButton,
+    MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, ParentElement, Pixels, ScrollDelta,
+    ScrollWheelEvent, ShapedLine, SharedString, Size, StatefulInteractiveElement, Style, Styled,
+    Subscription, TextRun, TextStyleRefinement, WeakEntity, Window,
 };
 use itertools::Itertools;
 use language::{
@@ -85,7 +85,6 @@ enum DisplayDiffHunk {
     Folded {
         display_row: DisplayRow,
     },
-
     Unfolded {
         diff_base_byte_range: Range<usize>,
         display_row_range: Range<DisplayRow>,
@@ -1115,18 +1114,44 @@ impl EditorElement {
         em_width: Pixels,
         em_advance: Pixels,
         autoscroll_containing_element: bool,
+        newest_selection_head: Option<DisplayPoint>,
         window: &mut Window,
         cx: &mut App,
     ) -> Vec<CursorLayout> {
         let mut autoscroll_bounds = None;
         let cursor_layouts = self.editor.update(cx, |editor, cx| {
             let mut cursors = Vec::new();
+
+            let previewing_move =
+                if let Some((target, preview)) = editor.previewing_edit_prediction_move() {
+                    cursors.extend(self.layout_edit_prediction_preview_cursor(
+                        snapshot,
+                        visible_display_row_range.clone(),
+                        line_layouts,
+                        content_origin,
+                        scroll_pixel_position,
+                        line_height,
+                        em_advance,
+                        preview,
+                        target,
+                        newest_selection_head,
+                        window,
+                        cx,
+                    ));
+
+                    true
+                } else {
+                    false
+                };
+
+            let show_local_cursors = !previewing_move && editor.show_local_cursors(window, cx);
+
             for (player_color, selections) in selections {
                 for selection in selections {
                     let cursor_position = selection.head;
 
                     let in_range = visible_display_row_range.contains(&cursor_position.row());
-                    if (selection.is_local && !editor.show_local_cursors(window, cx))
+                    if (selection.is_local && !show_local_cursors)
                         || !in_range
                         || block_start_rows.contains(&cursor_position.row())
                     {
@@ -1250,6 +1275,7 @@ impl EditorElement {
                     cursors.push(cursor);
                 }
             }
+
             cursors
         });
 
@@ -1260,6 +1286,50 @@ impl EditorElement {
         cursor_layouts
     }
 
+    #[allow(clippy::too_many_arguments)]
+    fn layout_edit_prediction_preview_cursor(
+        &self,
+        snapshot: &EditorSnapshot,
+        visible_row_range: Range<DisplayRow>,
+        line_layouts: &[LineWithInvisibles],
+        content_origin: gpui::Point<Pixels>,
+        scroll_pixel_position: gpui::Point<Pixels>,
+        line_height: Pixels,
+        em_advance: Pixels,
+        preview: &mut EditPredictionPreview,
+        target: Anchor,
+        cursor: Option<DisplayPoint>,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> Option<CursorLayout> {
+        let state = preview.move_state(
+            snapshot,
+            visible_row_range,
+            line_layouts,
+            scroll_pixel_position,
+            line_height,
+            target,
+            cursor,
+        )?;
+
+        if !state.is_animation_completed() {
+            window.request_animation_frame();
+        }
+
+        let mut cursor = CursorLayout {
+            color: self.style.local_player.cursor,
+            block_width: em_advance,
+            origin: state.position,
+            line_height,
+            shape: CursorShape::Bar,
+            block_text: None,
+            cursor_name: None,
+        };
+
+        cursor.layout(content_origin, None, window, cx);
+        Some(cursor)
+    }
+
     fn layout_scrollbars(
         &self,
         snapshot: &EditorSnapshot,
@@ -2116,7 +2186,7 @@ impl EditorElement {
                     .get(&display_row)
                     .unwrap_or(&non_relative_number);
                 write!(&mut line_number, "{number}").unwrap();
-                if row_info.diff_status == Some(DiffHunkStatus::Removed) {
+                if matches!(row_info.diff_status, Some(DiffHunkStatus::Removed(_))) {
                     return None;
                 }
 
@@ -3532,7 +3602,7 @@ impl EditorElement {
     }
 
     #[allow(clippy::too_many_arguments)]
-    fn layout_inline_completion_popover(
+    fn layout_edit_prediction_popover(
         &self,
         text_bounds: &Bounds<Pixels>,
         editor_snapshot: &EditorSnapshot,
@@ -3560,6 +3630,49 @@ impl EditorElement {
 
         match &active_inline_completion.completion {
             InlineCompletion::Move { target, .. } => {
+                if editor.edit_prediction_requires_modifier() {
+                    let cursor_position =
+                        target.to_display_point(&editor_snapshot.display_snapshot);
+
+                    if !editor.edit_prediction_preview.is_active_settled()
+                        || !visible_row_range.contains(&cursor_position.row())
+                    {
+                        return None;
+                    }
+
+                    let accept_keybind = editor.accept_edit_prediction_keybind(window, cx);
+                    let accept_keystroke = accept_keybind.keystroke()?;
+
+                    let mut element = div()
+                        .px_2()
+                        .py_1()
+                        .elevation_2(cx)
+                        .border_color(cx.theme().colors().border)
+                        .rounded_br(px(0.))
+                        .child(Label::new(accept_keystroke.key.clone()).buffer_font(cx))
+                        .into_any();
+
+                    let size = element.layout_as_root(AvailableSpace::min_size(), window, cx);
+
+                    let cursor_row_layout = &line_layouts
+                        [cursor_position.row().minus(visible_row_range.start) as usize];
+                    let cursor_column = cursor_position.column() as usize;
+
+                    let cursor_character_x = cursor_row_layout.x_for_index(cursor_column);
+                    let target_y = (cursor_position.row().as_f32()
+                        - scroll_pixel_position.y / line_height)
+                        * line_height;
+
+                    let offset = point(
+                        cursor_character_x - size.width,
+                        target_y - size.height - PADDING_Y,
+                    );
+
+                    element.prepaint_at(text_bounds.origin + offset, window, cx);
+
+                    return Some(element);
+                }
+
                 let target_display_point = target.to_display_point(editor_snapshot);
                 if target_display_point.row().as_f32() < scroll_top {
                     let mut element = inline_completion_accept_indicator(
@@ -4007,8 +4120,10 @@ impl EditorElement {
                 if row_infos[row_ix].diff_status.is_none() {
                     continue;
                 }
-                if row_infos[row_ix].diff_status == Some(DiffHunkStatus::Added)
-                    && *status != DiffHunkStatus::Added
+                if matches!(
+                    row_infos[row_ix].diff_status,
+                    Some(DiffHunkStatus::Added(_))
+                ) && !matches!(*status, DiffHunkStatus::Added(_))
                 {
                     continue;
                 }
@@ -4191,26 +4306,26 @@ impl EditorElement {
                         window.paint_quad(fill(Bounds { origin, size }, color));
                     };
 
-                let mut current_paint: Option<(Hsla, Range<DisplayRow>)> = None;
-                for (&new_row, &new_color) in &layout.highlighted_rows {
+                let mut current_paint: Option<(gpui::Background, Range<DisplayRow>)> = None;
+                for (&new_row, &new_background) in &layout.highlighted_rows {
                     match &mut current_paint {
-                        Some((current_color, current_range)) => {
-                            let current_color = *current_color;
-                            let new_range_started = current_color != new_color
+                        Some((current_background, current_range)) => {
+                            let current_background = *current_background;
+                            let new_range_started = current_background != new_background
                                 || current_range.end.next_row() != new_row;
                             if new_range_started {
                                 paint_highlight(
                                     current_range.start,
                                     current_range.end,
-                                    current_color,
+                                    current_background,
                                 );
-                                current_paint = Some((new_color, new_row..new_row));
+                                current_paint = Some((new_background, new_row..new_row));
                                 continue;
                             } else {
                                 current_range.end = current_range.end.next_row();
                             }
                         }
-                        None => current_paint = Some((new_color, new_row..new_row)),
+                        None => current_paint = Some((new_background, new_row..new_row)),
                     };
                 }
                 if let Some((color, range)) = current_paint {
@@ -4409,6 +4524,7 @@ impl EditorElement {
                             hunk_bounds,
                             cx.theme().status().modified,
                             Corners::all(px(0.)),
+                            &DiffHunkSecondaryStatus::None,
                         ))
                     }
                     DisplayDiffHunk::Unfolded {
@@ -4416,22 +4532,29 @@ impl EditorElement {
                         display_row_range,
                         ..
                     } => hitbox.as_ref().map(|hunk_hitbox| match status {
-                        DiffHunkStatus::Added => (
+                        DiffHunkStatus::Added(secondary_status) => (
                             hunk_hitbox.bounds,
                             cx.theme().status().created,
                             Corners::all(px(0.)),
+                            secondary_status,
                         ),
-                        DiffHunkStatus::Modified => (
+                        DiffHunkStatus::Modified(secondary_status) => (
                             hunk_hitbox.bounds,
                             cx.theme().status().modified,
                             Corners::all(px(0.)),
+                            secondary_status,
                         ),
-                        DiffHunkStatus::Removed if !display_row_range.is_empty() => (
-                            hunk_hitbox.bounds,
-                            cx.theme().status().deleted,
-                            Corners::all(px(0.)),
-                        ),
-                        DiffHunkStatus::Removed => (
+                        DiffHunkStatus::Removed(secondary_status)
+                            if !display_row_range.is_empty() =>
+                        {
+                            (
+                                hunk_hitbox.bounds,
+                                cx.theme().status().deleted,
+                                Corners::all(px(0.)),
+                                secondary_status,
+                            )
+                        }
+                        DiffHunkStatus::Removed(secondary_status) => (
                             Bounds::new(
                                 point(
                                     hunk_hitbox.origin.x - hunk_hitbox.size.width,
@@ -4441,11 +4564,17 @@ impl EditorElement {
                             ),
                             cx.theme().status().deleted,
                             Corners::all(1. * line_height),
+                            secondary_status,
                         ),
                     }),
                 };
 
-                if let Some((hunk_bounds, background_color, corner_radii)) = hunk_to_paint {
+                if let Some((hunk_bounds, mut background_color, corner_radii, secondary_status)) =
+                    hunk_to_paint
+                {
+                    if *secondary_status != DiffHunkSecondaryStatus::None {
+                        background_color.a *= 0.6;
+                    }
                     window.paint_quad(quad(
                         hunk_bounds,
                         corner_radii,
@@ -4481,7 +4610,7 @@ impl EditorElement {
                 status,
                 ..
             } => {
-                if *status == DiffHunkStatus::Removed && display_row_range.is_empty() {
+                if status.is_removed() && display_row_range.is_empty() {
                     let row = display_row_range.start;
 
                     let offset = line_height / 2.;
@@ -5128,9 +5257,9 @@ impl EditorElement {
                                             end_display_row.0 -= 1;
                                         }
                                         let color = match &hunk.status() {
-                                            DiffHunkStatus::Added => theme.status().created,
-                                            DiffHunkStatus::Modified => theme.status().modified,
-                                            DiffHunkStatus::Removed => theme.status().deleted,
+                                            DiffHunkStatus::Added(_) => theme.status().created,
+                                            DiffHunkStatus::Modified(_) => theme.status().modified,
+                                            DiffHunkStatus::Removed(_) => theme.status().deleted,
                                         };
                                         ColoredRange {
                                             start: start_display_row,
@@ -5673,7 +5802,7 @@ fn inline_completion_accept_indicator(
         .text_size(TextSize::XSmall.rems(cx))
         .text_color(cx.theme().colors().text)
         .gap_1()
-        .when(!editor.previewing_inline_completion, |parent| {
+        .when(!editor.edit_prediction_preview.is_active(), |parent| {
             parent.children(ui::render_modifiers(
                 &accept_keystroke.modifiers,
                 PlatformStyle::platform(),
@@ -6798,19 +6927,46 @@ impl Element for EditorElement {
                         )
                     };
 
-                    let mut highlighted_rows = self
-                        .editor
-                        .update(cx, |editor, cx| editor.highlighted_display_rows(window, cx));
+                    let (mut highlighted_rows, distinguish_unstaged_hunks) =
+                        self.editor.update(cx, |editor, cx| {
+                            (
+                                editor.highlighted_display_rows(window, cx),
+                                editor.distinguish_unstaged_diff_hunks,
+                            )
+                        });
 
                     for (ix, row_info) in row_infos.iter().enumerate() {
-                        let color = match row_info.diff_status {
-                            Some(DiffHunkStatus::Added) => style.status.created_background,
-                            Some(DiffHunkStatus::Removed) => style.status.deleted_background,
+                        let background = match row_info.diff_status {
+                            Some(DiffHunkStatus::Added(secondary_status)) => {
+                                let color = style.status.created_background;
+                                match secondary_status {
+                                    DiffHunkSecondaryStatus::HasSecondaryHunk
+                                    | DiffHunkSecondaryStatus::OverlapsWithSecondaryHunk
+                                        if distinguish_unstaged_hunks =>
+                                    {
+                                        pattern_slash(color, line_height.0 / 4.0)
+                                    }
+                                    _ => color.into(),
+                                }
+                            }
+                            Some(DiffHunkStatus::Removed(secondary_status)) => {
+                                let color = style.status.deleted_background;
+                                match secondary_status {
+                                    DiffHunkSecondaryStatus::HasSecondaryHunk
+                                    | DiffHunkSecondaryStatus::OverlapsWithSecondaryHunk
+                                        if distinguish_unstaged_hunks =>
+                                    {
+                                        pattern_slash(color, line_height.0 / 4.0)
+                                    }
+                                    _ => color.into(),
+                                }
+                            }
                             _ => continue,
                         };
+
                         highlighted_rows
                             .entry(start_row + DisplayRow(ix as u32))
-                            .or_insert(color);
+                            .or_insert(background);
                     }
 
                     let highlighted_ranges = self.editor.read(cx).background_highlights_in_range(
@@ -7204,6 +7360,7 @@ impl Element for EditorElement {
                         em_width,
                         em_advance,
                         autoscroll_containing_element,
+                        newest_selection_head,
                         window,
                         cx,
                     );
@@ -7355,7 +7512,7 @@ impl Element for EditorElement {
                         );
                     }
 
-                    let inline_completion_popover = self.layout_inline_completion_popover(
+                    let inline_completion_popover = self.layout_edit_prediction_popover(
                         &text_hitbox.bounds,
                         &snapshot,
                         start_row..end_row,
@@ -7643,7 +7800,7 @@ pub struct EditorLayout {
     indent_guides: Option<Vec<IndentGuideLayout>>,
     visible_display_row_range: Range<DisplayRow>,
     active_rows: BTreeMap<DisplayRow, bool>,
-    highlighted_rows: BTreeMap<DisplayRow, Hsla>,
+    highlighted_rows: BTreeMap<DisplayRow, gpui::Background>,
     line_elements: SmallVec<[AnyElement; 1]>,
     line_numbers: Arc<HashMap<MultiBufferRow, LineNumberLayout>>,
     display_hunks: Vec<(DisplayDiffHunk, Option<Hitbox>)>,

crates/editor/src/proposed_changes_editor.rs 🔗

@@ -1,6 +1,6 @@
 use crate::{ApplyAllDiffHunks, Editor, EditorEvent, SemanticsProvider};
+use buffer_diff::BufferDiff;
 use collections::HashSet;
-use diff::BufferDiff;
 use futures::{channel::mpsc, future::join_all};
 use gpui::{App, Entity, EventEmitter, Focusable, Render, Subscription, Task};
 use language::{Buffer, BufferEvent, Capability};
@@ -185,7 +185,7 @@ impl ProposedChangesEditor {
             } else {
                 branch_buffer = location.buffer.update(cx, |buffer, cx| buffer.branch(cx));
                 new_diffs.push(cx.new(|cx| {
-                    let mut diff = BufferDiff::new(&branch_buffer, cx);
+                    let mut diff = BufferDiff::new(branch_buffer.read(cx));
                     let _ = diff.set_base_text(
                         location.buffer.clone(),
                         branch_buffer.read(cx).text_snapshot(),

crates/editor/src/scroll/autoscroll.rs 🔗

@@ -113,6 +113,7 @@ impl Editor {
             target_bottom = target_top + 1.;
         } else {
             let selections = self.selections.all::<Point>(cx);
+
             target_top = selections
                 .first()
                 .unwrap()
@@ -144,6 +145,29 @@ impl Editor {
                 target_top = newest_selection_top;
                 target_bottom = newest_selection_top + 1.;
             }
+
+            if self.edit_prediction_preview.is_active() {
+                if let Some(completion) = self.active_inline_completion.as_ref() {
+                    match completion.completion {
+                        crate::InlineCompletion::Edit { .. } => {}
+                        crate::InlineCompletion::Move { target, .. } => {
+                            let target_row = target.to_display_point(&display_map).row().as_f32();
+
+                            if target_row < target_top {
+                                target_top = target_row;
+                            } else if target_row >= target_bottom {
+                                target_bottom = target_row + 1.;
+                            }
+
+                            let selections_fit = target_bottom - target_top <= visible_lines;
+                            if !selections_fit {
+                                target_top = target_row;
+                                target_bottom = target_row + 1.;
+                            }
+                        }
+                    }
+                }
+            }
         }
 
         let margin = if matches!(self.mode, EditorMode::AutoHeight { .. }) {

crates/editor/src/test/editor_test_context.rs 🔗

@@ -2,8 +2,8 @@ use crate::{
     display_map::ToDisplayPoint, AnchorRangeExt, Autoscroll, DisplayPoint, Editor, MultiBuffer,
     RowExt,
 };
+use buffer_diff::DiffHunkStatus;
 use collections::BTreeMap;
-use diff::DiffHunkStatus;
 use futures::Future;
 
 use gpui::{
@@ -459,9 +459,9 @@ pub fn assert_state_with_diff(
         .zip(line_infos)
         .map(|(line, info)| {
             let mut marker = match info.diff_status {
-                Some(DiffHunkStatus::Added) => "+ ",
-                Some(DiffHunkStatus::Removed) => "- ",
-                Some(DiffHunkStatus::Modified) => unreachable!(),
+                Some(DiffHunkStatus::Added(_)) => "+ ",
+                Some(DiffHunkStatus::Removed(_)) => "- ",
+                Some(DiffHunkStatus::Modified(_)) => unreachable!(),
                 None => {
                     if has_diff {
                         "  "

crates/file_finder/src/open_path_prompt.rs 🔗

@@ -93,6 +93,8 @@ impl PickerDelegate for OpenPathDelegate {
         cx.notify();
     }
 
+    // todo(windows)
+    // Is this method woring correctly on Windows? This method uses `/` for path separator.
     fn update_matches(
         &mut self,
         query: String,

crates/git/src/repository.rs 🔗

@@ -571,10 +571,6 @@ impl RepoPath {
 
         RepoPath(path.into())
     }
-
-    pub fn to_proto(&self) -> String {
-        self.0.to_string_lossy().to_string()
-    }
 }
 
 impl std::fmt::Display for RepoPath {

crates/git_ui/Cargo.toml 🔗

@@ -16,7 +16,7 @@ path = "src/git_ui.rs"
 anyhow.workspace = true
 collections.workspace = true
 db.workspace = true
-diff.workspace = true
+buffer_diff.workspace = true
 editor.workspace = true
 feature_flags.workspace = true
 futures.workspace = true

crates/git_ui/src/project_diff.rs 🔗

@@ -1,8 +1,8 @@
 use std::any::{Any, TypeId};
 
 use anyhow::Result;
+use buffer_diff::BufferDiff;
 use collections::HashSet;
-use diff::BufferDiff;
 use editor::{scroll::Autoscroll, Editor, EditorEvent};
 use feature_flags::FeatureFlagViewExt;
 use futures::StreamExt;
@@ -126,6 +126,7 @@ impl ProjectDiff {
                 window,
                 cx,
             );
+            diff_display_editor.set_distinguish_unstaged_diff_hunks();
             diff_display_editor.set_expand_all_diff_hunks(cx);
             diff_display_editor.register_addon(GitPanelAddon {
                 git_panel: git_panel.clone(),
@@ -317,10 +318,10 @@ impl ProjectDiff {
 
         let snapshot = buffer.read(cx).snapshot();
         let diff = diff.read(cx);
-        let diff_hunk_ranges = if diff.snapshot.base_text.is_none() {
+        let diff_hunk_ranges = if diff.base_text().is_none() {
             vec![Point::zero()..snapshot.max_point()]
         } else {
-            diff.diff_hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot)
+            diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot, cx)
                 .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot))
                 .collect::<Vec<_>>()
         };

crates/gpui/Cargo.toml 🔗

@@ -202,11 +202,12 @@ windows-core = "0.58"
 backtrace = "0.3"
 collections = { workspace = true, features = ["test-support"] }
 env_logger.workspace = true
-rand.workspace = true
-util = { workspace = true, features = ["test-support"] }
 http_client = { workspace = true, features = ["test-support"] }
-unicode-segmentation.workspace = true
 lyon = { version = "1.0", features = ["extra"] }
+rand.workspace = true
+unicode-segmentation.workspace = true
+reqwest_client = { workspace = true, features = ["test-support"] }
+util = { workspace = true, features = ["test-support"] }
 
 [target.'cfg(target_os = "windows")'.build-dependencies]
 embed-resource = "3.0"

crates/gpui/examples/gif_viewer.rs 🔗

@@ -25,15 +25,8 @@ impl Render for GifViewer {
 fn main() {
     env_logger::init();
     Application::new().run(|cx: &mut App| {
-        let cwd = std::env::current_dir().expect("Failed to get current working directory");
-        let gif_path = cwd.join("crates/gpui/examples/image/black-cat-typing.gif");
-
-        if !gif_path.exists() {
-            eprintln!("Image file not found at {:?}", gif_path);
-            eprintln!("Make sure you're running this example from the root of the gpui crate");
-            cx.quit();
-            return;
-        }
+        let gif_path =
+            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples/image/black-cat-typing.gif");
 
         cx.open_window(
             WindowOptions {

crates/gpui/examples/image/image.rs 🔗

@@ -1,6 +1,5 @@
 use std::fs;
 use std::path::PathBuf;
-use std::str::FromStr;
 use std::sync::Arc;
 
 use anyhow::Result;
@@ -9,6 +8,7 @@ use gpui::{
     Bounds, Context, ImageSource, KeyBinding, Menu, MenuItem, Point, SharedString, SharedUri,
     TitlebarOptions, Window, WindowBounds, WindowOptions,
 };
+use reqwest_client::ReqwestClient;
 
 struct Assets {
     base: PathBuf,
@@ -127,11 +127,16 @@ actions!(image, [Quit]);
 fn main() {
     env_logger::init();
 
+    let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
+
     Application::new()
         .with_assets(Assets {
-            base: PathBuf::from("crates/gpui/examples"),
+            base: manifest_dir.join("examples"),
         })
-        .run(|cx: &mut App| {
+        .run(move |cx: &mut App| {
+            let http_client = ReqwestClient::user_agent("gpui example").unwrap();
+            cx.set_http_client(Arc::new(http_client));
+
             cx.activate(true);
             cx.on_action(|_: &Quit, cx| cx.quit());
             cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]);
@@ -158,9 +163,7 @@ fn main() {
             cx.open_window(window_options, |_, cx| {
                 cx.new(|_| ImageShowcase {
                     // Relative path to your root project path
-                    local_resource: PathBuf::from_str("crates/gpui/examples/image/app-icon.png")
-                        .unwrap()
-                        .into(),
+                    local_resource: manifest_dir.join("examples/image/app-icon.png").into(),
 
                     remote_resource: "https://picsum.photos/512/512".into(),
 

crates/gpui/examples/image_loading.rs 🔗

@@ -29,7 +29,7 @@ impl AssetSource for Assets {
     }
 }
 
-const IMAGE: &str = "examples/image/app-icon.png";
+const IMAGE: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/examples/image/app-icon.png");
 
 #[derive(Copy, Clone, Hash)]
 struct LoadImageParameters {

crates/gpui/examples/opacity.rs 🔗

@@ -159,7 +159,7 @@ impl Render for HelloWorld {
 fn main() {
     Application::new()
         .with_assets(Assets {
-            base: PathBuf::from("crates/gpui/examples"),
+            base: PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples"),
         })
         .run(|cx: &mut App| {
             let bounds = Bounds::centered(None, size(px(500.0), px(500.0)), cx);
@@ -171,5 +171,6 @@ fn main() {
                 |window, cx| cx.new(|cx| HelloWorld::new(window, cx)),
             )
             .unwrap();
+            cx.activate(true);
         });
 }

crates/gpui/examples/pattern.rs 🔗

@@ -25,10 +25,30 @@ impl Render for PatternExample {
                     .flex_col()
                     .border_1()
                     .border_color(gpui::blue())
-                    .child(div().w(px(54.0)).h(px(18.0)).bg(pattern_slash(gpui::red())))
-                    .child(div().w(px(54.0)).h(px(18.0)).bg(pattern_slash(gpui::red())))
-                    .child(div().w(px(54.0)).h(px(18.0)).bg(pattern_slash(gpui::red())))
-                    .child(div().w(px(54.0)).h(px(18.0)).bg(pattern_slash(gpui::red()))),
+                    .child(
+                        div()
+                            .w(px(54.0))
+                            .h(px(18.0))
+                            .bg(pattern_slash(gpui::red(), 18.0 / 2.0)),
+                    )
+                    .child(
+                        div()
+                            .w(px(54.0))
+                            .h(px(18.0))
+                            .bg(pattern_slash(gpui::red(), 18.0 / 2.0)),
+                    )
+                    .child(
+                        div()
+                            .w(px(54.0))
+                            .h(px(18.0))
+                            .bg(pattern_slash(gpui::red(), 18.0 / 2.0)),
+                    )
+                    .child(
+                        div()
+                            .w(px(54.0))
+                            .h(px(18.0))
+                            .bg(pattern_slash(gpui::red(), 18.0 / 2.0)),
+                    ),
             )
             .child(
                 div()
@@ -42,25 +62,25 @@ impl Render for PatternExample {
                         div()
                             .w(px(256.0))
                             .h(px(56.0))
-                            .bg(pattern_slash(gpui::red())),
+                            .bg(pattern_slash(gpui::red(), 56.0 / 3.0)),
                     )
                     .child(
                         div()
                             .w(px(256.0))
                             .h(px(56.0))
-                            .bg(pattern_slash(gpui::green())),
+                            .bg(pattern_slash(gpui::green(), 56.0 / 3.0)),
                     )
                     .child(
                         div()
                             .w(px(256.0))
                             .h(px(56.0))
-                            .bg(pattern_slash(gpui::blue())),
+                            .bg(pattern_slash(gpui::blue(), 56.0 / 3.0)),
                     )
                     .child(
                         div()
                             .w(px(256.0))
                             .h(px(26.0))
-                            .bg(pattern_slash(gpui::yellow())),
+                            .bg(pattern_slash(gpui::yellow(), 56.0 / 3.0)),
                     ),
             )
             .child(

crates/gpui/examples/svg/svg.rs 🔗

@@ -70,7 +70,7 @@ impl Render for SvgExample {
 fn main() {
     Application::new()
         .with_assets(Assets {
-            base: PathBuf::from("crates/gpui/examples"),
+            base: PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples"),
         })
         .run(|cx: &mut App| {
             let bounds = Bounds::centered(None, size(px(300.0), px(300.0)), cx);
@@ -82,5 +82,6 @@ fn main() {
                 |_, cx| cx.new(|_| SvgExample),
             )
             .unwrap();
+            cx.activate(true);
         });
 }

crates/gpui/src/color.rs 🔗

@@ -587,7 +587,7 @@ pub struct Background {
     pub(crate) tag: BackgroundTag,
     pub(crate) color_space: ColorSpace,
     pub(crate) solid: Hsla,
-    pub(crate) angle: f32,
+    pub(crate) gradient_angle_or_pattern_height: f32,
     pub(crate) colors: [LinearColorStop; 2],
     /// Padding for alignment for repr(C) layout.
     pad: u32,
@@ -600,7 +600,7 @@ impl Default for Background {
             tag: BackgroundTag::Solid,
             solid: Hsla::default(),
             color_space: ColorSpace::default(),
-            angle: 0.0,
+            gradient_angle_or_pattern_height: 0.0,
             colors: [LinearColorStop::default(), LinearColorStop::default()],
             pad: 0,
         }
@@ -608,10 +608,11 @@ impl Default for Background {
 }
 
 /// Creates a hash pattern background
-pub fn pattern_slash(color: Hsla) -> Background {
+pub fn pattern_slash(color: Hsla, thickness: f32) -> Background {
     Background {
         tag: BackgroundTag::PatternSlash,
         solid: color,
+        gradient_angle_or_pattern_height: thickness,
         ..Default::default()
     }
 }
@@ -630,7 +631,7 @@ pub fn linear_gradient(
 ) -> Background {
     Background {
         tag: BackgroundTag::LinearGradient,
-        angle,
+        gradient_angle_or_pattern_height: angle,
         colors: [from.into(), to.into()],
         ..Default::default()
     }

crates/gpui/src/platform/blade/shaders.wgsl 🔗

@@ -51,7 +51,7 @@ struct Background {
     // 1u is Oklab color
     color_space: u32,
     solid: Hsla,
-    angle: f32,
+    gradient_angle_or_pattern_height: f32,
     colors: array<LinearColorStop, 2>,
     pad: u32,
 }
@@ -310,17 +310,18 @@ fn prepare_gradient_color(tag: u32, color_space: u32,
 }
 
 fn gradient_color(background: Background, position: vec2<f32>, bounds: Bounds,
-    sold_color: vec4<f32>, color0: vec4<f32>, color1: vec4<f32>) -> vec4<f32> {
+    solid_color: vec4<f32>, color0: vec4<f32>, color1: vec4<f32>) -> vec4<f32> {
     var background_color = vec4<f32>(0.0);
 
     switch (background.tag) {
         default: {
-            return sold_color;
+            return solid_color;
         }
         case 1u: {
             // Linear gradient background.
             // -90 degrees to match the CSS gradient angle.
-            let radians = (background.angle % 360.0 - 90.0) * M_PI_F / 180.0;
+            let angle = background.gradient_angle_or_pattern_height;
+            let radians = (angle % 360.0 - 90.0) * M_PI_F / 180.0;
             var direction = vec2<f32>(cos(radians), sin(radians));
             let stop0_percentage = background.colors[0].percentage;
             let stop1_percentage = background.colors[1].percentage;
@@ -359,19 +360,18 @@ fn gradient_color(background: Background, position: vec2<f32>, bounds: Bounds,
             }
         }
         case 2u: {
-            let base_pattern_size = bounds.size.y / 5.0;
-            let width = base_pattern_size * 0.5;
-            let slash_spacing = 0.89;
-            let radians = M_PI_F / 4.0;
+            let pattern_height = background.gradient_angle_or_pattern_height;
+            let stripe_angle = M_PI_F / 4.0;
+            let pattern_period = pattern_height * sin(stripe_angle);
             let rotation = mat2x2<f32>(
-                cos(radians), -sin(radians),
-                sin(radians), cos(radians)
+                cos(stripe_angle), -sin(stripe_angle),
+                sin(stripe_angle), cos(stripe_angle)
             );
             let relative_position = position - bounds.origin;
             let rotated_point = rotation * relative_position;
-            let pattern = (rotated_point.x / slash_spacing) % (base_pattern_size * 2.0);
-            let distance = min(pattern, base_pattern_size * 2.0 - pattern) - width;
-            background_color = sold_color;
+            let pattern = rotated_point.x % pattern_period;
+            let distance = min(pattern, pattern_period - pattern) - pattern_period / 4;
+            background_color = solid_color;
             background_color.a *= saturate(0.5 - distance);
         }
     }

crates/gpui/src/platform/mac/shaders.metal 🔗

@@ -833,7 +833,8 @@ float4 fill_color(Background background,
       break;
     case 1: {
       // -90 degrees to match the CSS gradient angle.
-      float radians = (fmod(background.angle, 360.0) - 90.0) * (M_PI_F / 180.0);
+      float gradient_angle = background.gradient_angle_or_pattern_height;
+      float radians = (fmod(gradient_angle, 360.0) - 90.0) * (M_PI_F / 180.0);
       float2 direction = float2(cos(radians), sin(radians));
 
       // Expand the short side to be the same as the long side
@@ -874,19 +875,14 @@ float4 fill_color(Background background,
       break;
     }
     case 2: {
-        // This pattern is full of magic numbers to make it line up perfectly
-        // when vertically stacked. Make sure you know what you are doing
-        // if you change this!
-
-        float base_pattern_size = bounds.size.height / 5;
-        float width = base_pattern_size * 0.5;
-        float slash_spacing = .89;
-        float radians = M_PI_F / 4.0;
-        float2x2 rotation = rotate2d(radians);
+        float pattern_height = background.gradient_angle_or_pattern_height;
+        float stripe_angle = M_PI_F / 4.0;
+        float pattern_period = pattern_height * sin(stripe_angle);
+        float2x2 rotation = rotate2d(stripe_angle);
         float2 relative_position = position - float2(bounds.origin.x, bounds.origin.y);
         float2 rotated_point = rotation * relative_position;
-        float pattern = fmod(rotated_point.x / slash_spacing, base_pattern_size * 2.0);
-        float distance = min(pattern, base_pattern_size * 2.0 - pattern) - width;
+        float pattern = fmod(rotated_point.x, pattern_period);
+        float distance = min(pattern, pattern_period - pattern) - pattern_period / 4.0;
         color = solid_color;
         color.a *= saturate(0.5 - distance);
         break;

crates/inline_completion/src/inline_completion.rs 🔗

@@ -25,18 +25,30 @@ pub enum DataCollectionState {
     /// The provider doesn't support data collection.
     Unsupported,
     /// Data collection is enabled.
-    Enabled,
+    Enabled { is_project_open_source: bool },
     /// Data collection is disabled or unanswered.
-    Disabled,
+    Disabled { is_project_open_source: bool },
 }
 
 impl DataCollectionState {
     pub fn is_supported(&self) -> bool {
-        !matches!(self, DataCollectionState::Unsupported)
+        !matches!(self, DataCollectionState::Unsupported { .. })
     }
 
     pub fn is_enabled(&self) -> bool {
-        matches!(self, DataCollectionState::Enabled)
+        matches!(self, DataCollectionState::Enabled { .. })
+    }
+
+    pub fn is_project_open_source(&self) -> bool {
+        match self {
+            Self::Enabled {
+                is_project_open_source,
+            }
+            | Self::Disabled {
+                is_project_open_source,
+            } => *is_project_open_source,
+            _ => false,
+        }
     }
 }
 

crates/inline_completion_button/src/inline_completion_button.rs 🔗

@@ -36,9 +36,8 @@ use workspace::{
     Toast, Workspace,
 };
 use zed_actions::OpenBrowser;
-use zeta::RateCompletionModal;
+use zeta::RateCompletions;
 
-actions!(zeta, [RateCompletions]);
 actions!(edit_prediction, [ToggleMenu]);
 
 const COPILOT_SETTINGS_URL: &str = "https://github.com/settings/copilot";
@@ -54,7 +53,6 @@ pub struct InlineCompletionButton {
     file: Option<Arc<dyn File>>,
     edit_prediction_provider: Option<Arc<dyn inline_completion::InlineCompletionProviderHandle>>,
     fs: Arc<dyn Fs>,
-    workspace: WeakEntity<Workspace>,
     user_store: Entity<UserStore>,
     popover_menu_handle: PopoverMenuHandle<ContextMenu>,
 }
@@ -354,7 +352,6 @@ impl Render for InlineCompletionButton {
 
 impl InlineCompletionButton {
     pub fn new(
-        workspace: WeakEntity<Workspace>,
         fs: Arc<dyn Fs>,
         user_store: Entity<UserStore>,
         popover_menu_handle: PopoverMenuHandle<ContextMenu>,
@@ -376,7 +373,6 @@ impl InlineCompletionButton {
             file: None,
             edit_prediction_provider: None,
             popover_menu_handle,
-            workspace,
             fs,
             user_store,
         }
@@ -456,17 +452,56 @@ impl InlineCompletionButton {
             if data_collection.is_supported() {
                 let provider = provider.clone();
                 let enabled = data_collection.is_enabled();
+                let is_open_source = data_collection.is_project_open_source();
+                let is_collecting = data_collection.is_enabled();
 
                 menu = menu.item(
-                    // TODO: We want to add something later that communicates whether
-                    // the current project is open-source.
                     ContextMenuEntry::new("Share Training Data")
                         .toggleable(IconPosition::Start, data_collection.is_enabled())
-                        .documentation_aside(|_| {
-                            Label::new(indoc!{"
-                                Help us improve our open model by sharing data from open source repositories. \
-                                Zed must detect a license file in your repo for this setting to take effect.\
-                            "}).into_any_element()
+                        .icon_color(if is_open_source && is_collecting {
+                            Color::Success
+                        } else {
+                            Color::Accent
+                        })
+                        .documentation_aside(move |cx| {
+                            let (msg, label_color, icon_name, icon_color) = match (is_open_source, is_collecting) {
+                                (true, true) => (
+                                    "Project identified as open-source, and you're sharing data.",
+                                    Color::Default,
+                                    IconName::Check,
+                                    Color::Success,
+                                ),
+                                (true, false) => (
+                                    "Project identified as open-source, but you're not sharing data.",
+                                    Color::Muted,
+                                    IconName::XCircle,
+                                    Color::Muted,
+                                ),
+                                (false, _) => (
+                                    "Project not identified as open-source. No data captured.",
+                                    Color::Muted,
+                                    IconName::XCircle,
+                                    Color::Muted,
+                                ),
+                            };
+                            v_flex()
+                                .gap_2()
+                                .child(
+                                    Label::new(indoc!{
+                                        "Help us improve our open model by sharing data from open source repositories. \
+                                        Zed must detect a license file in your repo for this setting to take effect."
+                                    })
+                                )
+                                .child(
+                                    h_flex()
+                                        .pt_2()
+                                        .gap_1p5()
+                                        .border_t_1()
+                                        .border_color(cx.theme().colors().border_variant)
+                                        .child(Icon::new(icon_name).size(IconSize::XSmall).color(icon_color))
+                                        .child(div().child(Label::new(msg).size(LabelSize::Small).color(label_color)))
+                                )
+                                .into_any_element()
                         })
                         .handler(move |_, cx| {
                             provider.toggle_data_collection(cx);
@@ -483,7 +518,7 @@ impl InlineCompletionButton {
                                 );
                             }
                         })
-                )
+                );
             }
         }
 
@@ -574,23 +609,10 @@ impl InlineCompletionButton {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Entity<ContextMenu> {
-        let workspace = self.workspace.clone();
         ContextMenu::build(window, cx, |menu, _window, cx| {
             self.build_language_settings_menu(menu, cx).when(
                 cx.has_flag::<PredictEditsRateCompletionsFeatureFlag>(),
-                |this| {
-                    this.entry(
-                        "Rate Completions",
-                        Some(RateCompletions.boxed_clone()),
-                        move |window, cx| {
-                            workspace
-                                .update(cx, |workspace, cx| {
-                                    RateCompletionModal::toggle(workspace, window, cx)
-                                })
-                                .ok();
-                        },
-                    )
-                },
+                |this| this.action("Rate Completions", RateCompletions.boxed_clone()),
             )
         })
     }

crates/multi_buffer/Cargo.toml 🔗

@@ -14,7 +14,7 @@ doctest = false
 
 [features]
 test-support = [
-    "diff/test-support",
+    "buffer_diff/test-support",
     "gpui/test-support",
     "language/test-support",
     "text/test-support",
@@ -26,7 +26,7 @@ anyhow.workspace = true
 clock.workspace = true
 collections.workspace = true
 ctor.workspace = true
-diff.workspace = true
+buffer_diff.workspace = true
 env_logger.workspace = true
 futures.workspace = true
 gpui.workspace = true
@@ -47,7 +47,7 @@ tree-sitter.workspace = true
 util.workspace = true
 
 [dev-dependencies]
-diff = { workspace = true, features = ["test-support"] }
+buffer_diff = { workspace = true, features = ["test-support"] }
 gpui = { workspace = true, features = ["test-support"] }
 indoc.workspace = true
 language = { workspace = true, features = ["test-support"] }

crates/multi_buffer/src/anchor.rs 🔗

@@ -73,7 +73,7 @@ impl Anchor {
                 if let Some(base_text) = snapshot
                     .diffs
                     .get(&excerpt.buffer_id)
-                    .and_then(|diff| diff.base_text.as_ref())
+                    .and_then(|diff| diff.base_text())
                 {
                     let self_anchor = self.diff_base_anchor.filter(|a| base_text.can_resolve(a));
                     let other_anchor = other.diff_base_anchor.filter(|a| base_text.can_resolve(a));
@@ -110,7 +110,7 @@ impl Anchor {
                         if let Some(base_text) = snapshot
                             .diffs
                             .get(&excerpt.buffer_id)
-                            .and_then(|diff| diff.base_text.as_ref())
+                            .and_then(|diff| diff.base_text())
                         {
                             if a.buffer_id == Some(base_text.remote_id()) {
                                 return a.bias_left(base_text);
@@ -135,7 +135,7 @@ impl Anchor {
                         if let Some(base_text) = snapshot
                             .diffs
                             .get(&excerpt.buffer_id)
-                            .and_then(|diff| diff.base_text.as_ref())
+                            .and_then(|diff| diff.base_text())
                         {
                             if a.buffer_id == Some(base_text.remote_id()) {
                                 return a.bias_right(&base_text);

crates/multi_buffer/src/multi_buffer.rs 🔗

@@ -7,9 +7,11 @@ pub use anchor::{Anchor, AnchorRangeExt, Offset};
 pub use position::{TypedOffset, TypedPoint, TypedRow};
 
 use anyhow::{anyhow, Result};
+use buffer_diff::{
+    BufferDiff, BufferDiffEvent, BufferDiffSnapshot, DiffHunkSecondaryStatus, DiffHunkStatus,
+};
 use clock::ReplicaId;
 use collections::{BTreeMap, Bound, HashMap, HashSet};
-use diff::{BufferDiff, BufferDiffEvent, BufferDiffSnapshot, DiffHunkStatus};
 use futures::{channel::mpsc, SinkExt};
 use gpui::{App, Context, Entity, EntityId, EventEmitter, Task};
 use itertools::Itertools;
@@ -129,16 +131,18 @@ pub struct MultiBufferDiffHunk {
     pub excerpt_id: ExcerptId,
     /// The range within the buffer's diff base that this hunk corresponds to.
     pub diff_base_byte_range: Range<usize>,
+    /// Whether or not this hunk also appears in the 'secondary diff'.
+    pub secondary_status: DiffHunkSecondaryStatus,
 }
 
 impl MultiBufferDiffHunk {
     pub fn status(&self) -> DiffHunkStatus {
         if self.buffer_range.start == self.buffer_range.end {
-            DiffHunkStatus::Removed
+            DiffHunkStatus::Removed(self.secondary_status)
         } else if self.diff_base_byte_range.is_empty() {
-            DiffHunkStatus::Added
+            DiffHunkStatus::Added(self.secondary_status)
         } else {
-            DiffHunkStatus::Modified
+            DiffHunkStatus::Modified(self.secondary_status)
         }
     }
 }
@@ -225,7 +229,14 @@ impl DiffState {
         DiffState {
             _subscription: cx.subscribe(&diff, |this, diff, event, cx| match event {
                 BufferDiffEvent::DiffChanged { changed_range } => {
-                    this.buffer_diff_changed(diff, changed_range.clone(), cx)
+                    let changed_range = if let Some(changed_range) = changed_range {
+                        changed_range.clone()
+                    } else if diff.read(cx).base_text().is_none() && this.all_diff_hunks_expanded {
+                        text::Anchor::MIN..text::Anchor::MAX
+                    } else {
+                        return;
+                    };
+                    this.buffer_diff_changed(diff, changed_range, cx)
                 }
                 BufferDiffEvent::LanguageChanged => this.buffer_diff_language_changed(diff, cx),
             }),
@@ -241,7 +252,7 @@ pub struct MultiBufferSnapshot {
     excerpts: SumTree<Excerpt>,
     excerpt_ids: SumTree<ExcerptIdMapping>,
     diffs: TreeMap<BufferId, BufferDiffSnapshot>,
-    pub diff_transforms: SumTree<DiffTransform>,
+    diff_transforms: SumTree<DiffTransform>,
     trailing_excerpt_update_count: usize,
     non_text_state_update_count: usize,
     edit_count: usize,
@@ -252,20 +263,27 @@ pub struct MultiBufferSnapshot {
 }
 
 #[derive(Debug, Clone)]
-pub enum DiffTransform {
+enum DiffTransform {
     BufferContent {
         summary: TextSummary,
-        inserted_hunk_anchor: Option<(ExcerptId, text::Anchor)>,
+        inserted_hunk_info: Option<DiffTransformHunkInfo>,
     },
     DeletedHunk {
         summary: TextSummary,
         buffer_id: BufferId,
-        hunk_anchor: (ExcerptId, text::Anchor),
+        hunk_info: DiffTransformHunkInfo,
         base_text_byte_range: Range<usize>,
         has_trailing_newline: bool,
     },
 }
 
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
+struct DiffTransformHunkInfo {
+    excerpt_id: ExcerptId,
+    hunk_start_anchor: text::Anchor,
+    hunk_secondary_status: DiffHunkSecondaryStatus,
+}
+
 #[derive(Clone)]
 pub struct ExcerptInfo {
     pub id: ExcerptId,
@@ -310,7 +328,7 @@ pub struct RowInfo {
     pub buffer_id: Option<BufferId>,
     pub buffer_row: Option<u32>,
     pub multibuffer_row: Option<MultiBufferRow>,
-    pub diff_status: Option<diff::DiffHunkStatus>,
+    pub diff_status: Option<buffer_diff::DiffHunkStatus>,
 }
 
 /// A slice into a [`Buffer`] that is being edited in a [`MultiBuffer`].
@@ -431,7 +449,7 @@ struct MultiBufferCursor<'a, D: TextDimension> {
 struct MultiBufferRegion<'a, D: TextDimension> {
     buffer: &'a BufferSnapshot,
     is_main_buffer: bool,
-    is_inserted_hunk: bool,
+    diff_hunk_status: Option<DiffHunkStatus>,
     excerpt: &'a Excerpt,
     buffer_range: Range<D>,
     range: Range<D>,
@@ -2146,7 +2164,7 @@ impl MultiBuffer {
         let mut snapshot = self.snapshot.borrow_mut();
         let diff = diff.read(cx);
         let buffer_id = diff.buffer_id;
-        let diff = diff.snapshot.clone();
+        let diff = diff.snapshot(cx);
         snapshot.diffs.insert(buffer_id, diff);
     }
 
@@ -2160,36 +2178,29 @@ impl MultiBuffer {
 
         let diff = diff.read(cx);
         let buffer_id = diff.buffer_id;
-        let mut diff = diff.snapshot.clone();
-        if diff.base_text.is_none() && self.all_diff_hunks_expanded {
-            diff = BufferDiffSnapshot::new_with_single_insertion(cx);
-        }
-
-        let mut snapshot = self.snapshot.borrow_mut();
-        let base_text_changed =
-            snapshot
-                .diffs
-                .get(&buffer_id)
-                .map_or(true, |diff_snapshot| {
-                    match (&diff_snapshot.base_text, &diff.base_text) {
-                        (None, None) => false,
-                        (None, Some(_)) => true,
-                        (Some(_), None) => true,
-                        (Some(old), Some(new)) => {
-                            let (old_id, old_empty) = (old.remote_id(), old.is_empty());
-                            let (new_id, new_empty) = (new.remote_id(), new.is_empty());
-                            new_id != old_id && (!new_empty || !old_empty)
-                        }
-                    }
-                });
-        snapshot.diffs.insert(buffer_id, diff);
-
         let buffers = self.buffers.borrow();
         let Some(buffer_state) = buffers.get(&buffer_id) else {
             return;
         };
 
-        let diff_change_range = range.to_offset(buffer_state.buffer.read(cx));
+        let buffer = buffer_state.buffer.read(cx);
+        let diff_change_range = range.to_offset(buffer);
+
+        let mut new_diff = diff.snapshot(cx);
+        if new_diff.base_text().is_none() && self.all_diff_hunks_expanded {
+            let secondary_diff_insertion = new_diff
+                .secondary_diff()
+                .map_or(true, |secondary_diff| secondary_diff.base_text().is_none());
+            new_diff = BufferDiff::build_with_single_insertion(secondary_diff_insertion, cx);
+        }
+
+        let mut snapshot = self.snapshot.borrow_mut();
+        let base_text_changed = snapshot
+            .diffs
+            .get(&buffer_id)
+            .map_or(true, |old_diff| !new_diff.base_texts_eq(old_diff));
+
+        snapshot.diffs.insert(buffer_id, new_diff);
 
         let mut excerpt_edits = Vec::new();
         for locator in &buffer_state.excerpts {
@@ -2367,7 +2378,7 @@ impl MultiBuffer {
                 if *cursor.start() >= end {
                     break;
                 }
-                if item.hunk_anchor().is_some() {
+                if item.hunk_info().is_some() {
                     return true;
                 }
                 cursor.next(&());
@@ -2820,11 +2831,11 @@ impl MultiBuffer {
                 let keep_next_old_transform = (old_diff_transforms.start().0 >= edit.old.end)
                     && match old_diff_transforms.item() {
                         Some(DiffTransform::BufferContent {
-                            inserted_hunk_anchor: Some(hunk_anchor),
+                            inserted_hunk_info: Some(hunk),
                             ..
-                        }) => excerpts
-                            .item()
-                            .is_some_and(|excerpt| hunk_anchor.1.is_valid(&excerpt.buffer)),
+                        }) => excerpts.item().is_some_and(|excerpt| {
+                            hunk.hunk_start_anchor.is_valid(&excerpt.buffer)
+                        }),
                         _ => true,
                     };
 
@@ -2853,7 +2864,7 @@ impl MultiBuffer {
             new_diff_transforms.push(
                 DiffTransform::BufferContent {
                     summary: Default::default(),
-                    inserted_hunk_anchor: None,
+                    inserted_hunk_info: None,
                 },
                 &(),
             );
@@ -2876,8 +2887,8 @@ impl MultiBuffer {
         excerpts: &mut Cursor<Excerpt, TypedOffset<Excerpt>>,
         old_diff_transforms: &mut Cursor<DiffTransform, (TypedOffset<Excerpt>, usize)>,
         new_diff_transforms: &mut SumTree<DiffTransform>,
-        end_of_current_insert: &mut Option<(TypedOffset<Excerpt>, ExcerptId, text::Anchor)>,
-        old_expanded_hunks: &mut HashSet<(ExcerptId, text::Anchor)>,
+        end_of_current_insert: &mut Option<(TypedOffset<Excerpt>, DiffTransformHunkInfo)>,
+        old_expanded_hunks: &mut HashSet<DiffTransformHunkInfo>,
         snapshot: &MultiBufferSnapshot,
         change_kind: DiffChangeKind,
     ) -> bool {
@@ -2889,12 +2900,12 @@ impl MultiBuffer {
 
         // Record which hunks were previously expanded.
         while let Some(item) = old_diff_transforms.item() {
-            if let Some(hunk_anchor) = item.hunk_anchor() {
+            if let Some(hunk_info) = item.hunk_info() {
                 log::trace!(
                     "previously expanded hunk at {}",
                     old_diff_transforms.start().0
                 );
-                old_expanded_hunks.insert(hunk_anchor);
+                old_expanded_hunks.insert(hunk_info);
             }
             if old_diff_transforms.end(&()).0 > edit.old.end {
                 break;
@@ -2918,7 +2929,7 @@ impl MultiBuffer {
             if let Some((diff, base_text)) = snapshot
                 .diffs
                 .get(&excerpt.buffer_id)
-                .and_then(|diff| Some((diff, diff.base_text.as_ref()?)))
+                .and_then(|diff| Some((diff, diff.base_text()?)))
             {
                 let buffer = &excerpt.buffer;
                 let excerpt_start = *excerpts.start();
@@ -2936,7 +2947,11 @@ impl MultiBuffer {
                 for hunk in diff.hunks_intersecting_range(edit_anchor_range, buffer) {
                     let hunk_buffer_range = hunk.buffer_range.to_offset(buffer);
 
-                    let hunk_anchor = (excerpt.id, hunk.buffer_range.start);
+                    let hunk_info = DiffTransformHunkInfo {
+                        excerpt_id: excerpt.id,
+                        hunk_start_anchor: hunk.buffer_range.start,
+                        hunk_secondary_status: hunk.secondary_status,
+                    };
                     if hunk_buffer_range.start < excerpt_buffer_start {
                         log::trace!("skipping hunk that starts before excerpt");
                         continue;
@@ -2960,7 +2975,7 @@ impl MultiBuffer {
 
                     // For every existing hunk, determine if it was previously expanded
                     // and if it should currently be expanded.
-                    let was_previously_expanded = old_expanded_hunks.contains(&hunk_anchor);
+                    let was_previously_expanded = old_expanded_hunks.contains(&hunk_info);
                     let should_expand_hunk = match &change_kind {
                         DiffChangeKind::DiffUpdated { base_changed: true } => {
                             self.all_diff_hunks_expanded
@@ -3008,7 +3023,7 @@ impl MultiBuffer {
                                     base_text_byte_range: hunk.diff_base_byte_range.clone(),
                                     summary: base_text_summary,
                                     buffer_id: excerpt.buffer_id,
-                                    hunk_anchor,
+                                    hunk_info,
                                     has_trailing_newline,
                                 },
                                 &(),
@@ -3016,11 +3031,8 @@ impl MultiBuffer {
                         }
 
                         if !hunk_buffer_range.is_empty() {
-                            *end_of_current_insert = Some((
-                                hunk_excerpt_end.min(excerpt_end),
-                                hunk_anchor.0,
-                                hunk_anchor.1,
-                            ));
+                            *end_of_current_insert =
+                                Some((hunk_excerpt_end.min(excerpt_end), hunk_info));
                         }
                     }
                 }
@@ -3042,13 +3054,13 @@ impl MultiBuffer {
         subtree: SumTree<DiffTransform>,
     ) {
         if let Some(DiffTransform::BufferContent {
-            inserted_hunk_anchor,
+            inserted_hunk_info,
             summary,
         }) = subtree.first()
         {
             if self.extend_last_buffer_content_transform(
                 new_transforms,
-                *inserted_hunk_anchor,
+                *inserted_hunk_info,
                 *summary,
             ) {
                 let mut cursor = subtree.cursor::<()>(&());
@@ -3067,7 +3079,7 @@ impl MultiBuffer {
         transform: DiffTransform,
     ) {
         if let DiffTransform::BufferContent {
-            inserted_hunk_anchor,
+            inserted_hunk_info: inserted_hunk_anchor,
             summary,
         } = transform
         {
@@ -3087,19 +3099,14 @@ impl MultiBuffer {
         old_snapshot: &MultiBufferSnapshot,
         new_transforms: &mut SumTree<DiffTransform>,
         end_offset: ExcerptOffset,
-        current_inserted_hunk: Option<(ExcerptOffset, ExcerptId, text::Anchor)>,
+        current_inserted_hunk: Option<(ExcerptOffset, DiffTransformHunkInfo)>,
     ) {
-        let inserted_region =
-            current_inserted_hunk.map(|(insertion_end_offset, excerpt_id, anchor)| {
-                (
-                    end_offset.min(insertion_end_offset),
-                    Some((excerpt_id, anchor)),
-                )
-            });
+        let inserted_region = current_inserted_hunk.map(|(insertion_end_offset, hunk_info)| {
+            (end_offset.min(insertion_end_offset), Some(hunk_info))
+        });
         let unchanged_region = [(end_offset, None)];
 
-        for (end_offset, inserted_hunk_anchor) in
-            inserted_region.into_iter().chain(unchanged_region)
+        for (end_offset, inserted_hunk_info) in inserted_region.into_iter().chain(unchanged_region)
         {
             let start_offset = new_transforms.summary().excerpt_len();
             if end_offset <= start_offset {
@@ -3110,13 +3117,13 @@ impl MultiBuffer {
 
             if !self.extend_last_buffer_content_transform(
                 new_transforms,
-                inserted_hunk_anchor,
+                inserted_hunk_info,
                 summary_to_add,
             ) {
                 new_transforms.push(
                     DiffTransform::BufferContent {
                         summary: summary_to_add,
-                        inserted_hunk_anchor,
+                        inserted_hunk_info,
                     },
                     &(),
                 )
@@ -3127,7 +3134,7 @@ impl MultiBuffer {
     fn extend_last_buffer_content_transform(
         &self,
         new_transforms: &mut SumTree<DiffTransform>,
-        new_inserted_hunk_anchor: Option<(ExcerptId, text::Anchor)>,
+        new_inserted_hunk_info: Option<DiffTransformHunkInfo>,
         summary_to_add: TextSummary,
     ) -> bool {
         let mut did_extend = false;
@@ -3135,10 +3142,10 @@ impl MultiBuffer {
             |last_transform| {
                 if let DiffTransform::BufferContent {
                     summary,
-                    inserted_hunk_anchor,
+                    inserted_hunk_info: inserted_hunk_anchor,
                 } = last_transform
                 {
-                    if *inserted_hunk_anchor == new_inserted_hunk_anchor {
+                    if *inserted_hunk_anchor == new_inserted_hunk_info {
                         *summary += summary_to_add;
                         did_extend = true;
                     }
@@ -3469,6 +3476,7 @@ impl MultiBufferSnapshot {
                 excerpt_id: excerpt.id,
                 buffer_range: hunk.buffer_range.clone(),
                 diff_base_byte_range: hunk.diff_base_byte_range.clone(),
+                secondary_status: hunk.secondary_status,
             })
         })
     }
@@ -3837,6 +3845,7 @@ impl MultiBufferSnapshot {
                         excerpt_id: excerpt.id,
                         buffer_range: hunk.buffer_range.clone(),
                         diff_base_byte_range: hunk.diff_base_byte_range.clone(),
+                        secondary_status: hunk.secondary_status,
                     });
                 }
             }
@@ -4309,10 +4318,7 @@ impl MultiBufferSnapshot {
             } => {
                 let buffer_start = base_text_byte_range.start + start_overshoot;
                 let mut buffer_end = base_text_byte_range.start + end_overshoot;
-                let Some(base_text) = self
-                    .diffs
-                    .get(buffer_id)
-                    .and_then(|diff| diff.base_text.as_ref())
+                let Some(base_text) = self.diffs.get(buffer_id).and_then(|diff| diff.base_text())
                 else {
                     panic!("{:?} is in non-existent deleted hunk", range.start)
                 };
@@ -4361,10 +4367,7 @@ impl MultiBufferSnapshot {
                 ..
             } => {
                 let buffer_end = base_text_byte_range.start + overshoot;
-                let Some(base_text) = self
-                    .diffs
-                    .get(buffer_id)
-                    .and_then(|diff| diff.base_text.as_ref())
+                let Some(base_text) = self.diffs.get(buffer_id).and_then(|diff| diff.base_text())
                 else {
                     panic!("{:?} is in non-existent deleted hunk", range.end)
                 };
@@ -4469,10 +4472,8 @@ impl MultiBufferSnapshot {
                 }) => {
                     let mut in_deleted_hunk = false;
                     if let Some(diff_base_anchor) = &anchor.diff_base_anchor {
-                        if let Some(base_text) = self
-                            .diffs
-                            .get(buffer_id)
-                            .and_then(|diff| diff.base_text.as_ref())
+                        if let Some(base_text) =
+                            self.diffs.get(buffer_id).and_then(|diff| diff.base_text())
                         {
                             if base_text.can_resolve(&diff_base_anchor) {
                                 let base_text_offset = diff_base_anchor.to_offset(&base_text);
@@ -4809,7 +4810,7 @@ impl MultiBufferSnapshot {
             let base_text = self
                 .diffs
                 .get(buffer_id)
-                .and_then(|diff| diff.base_text.as_ref())
+                .and_then(|diff| diff.base_text())
                 .expect("missing diff base");
             if offset_in_transform > base_text_byte_range.len() {
                 debug_assert!(*has_trailing_newline);
@@ -5969,17 +5970,17 @@ impl MultiBufferSnapshot {
         for item in self.diff_transforms.iter() {
             if let DiffTransform::BufferContent {
                 summary,
-                inserted_hunk_anchor,
+                inserted_hunk_info,
             } = item
             {
                 if let Some(DiffTransform::BufferContent {
-                    inserted_hunk_anchor: prev_inserted_hunk_anchor,
+                    inserted_hunk_info: prev_inserted_hunk_info,
                     ..
                 }) = prev_transform
                 {
-                    if *inserted_hunk_anchor == *prev_inserted_hunk_anchor {
+                    if *inserted_hunk_info == *prev_inserted_hunk_info {
                         panic!(
-                            "multiple adjacent buffer content transforms with is_inserted_hunk = {inserted_hunk_anchor:?}. transforms: {:+?}",
+                            "multiple adjacent buffer content transforms with is_inserted_hunk = {inserted_hunk_info:?}. transforms: {:+?}",
                             self.diff_transforms.items(&()));
                     }
                 }
@@ -6149,10 +6150,11 @@ where
                 buffer_id,
                 base_text_byte_range,
                 has_trailing_newline,
+                hunk_info,
                 ..
             } => {
                 let diff = self.diffs.get(&buffer_id)?;
-                let buffer = diff.base_text.as_ref()?;
+                let buffer = diff.base_text()?;
                 let mut rope_cursor = buffer.as_rope().cursor(0);
                 let buffer_start = rope_cursor.summary::<D>(base_text_byte_range.start);
                 let buffer_range_len = rope_cursor.summary::<D>(base_text_byte_range.end);
@@ -6165,14 +6167,15 @@ where
                     excerpt,
                     has_trailing_newline: *has_trailing_newline,
                     is_main_buffer: false,
-                    is_inserted_hunk: false,
+                    diff_hunk_status: Some(DiffHunkStatus::Removed(
+                        hunk_info.hunk_secondary_status,
+                    )),
                     buffer_range: buffer_start..buffer_end,
                     range: start..end,
                 });
             }
             DiffTransform::BufferContent {
-                inserted_hunk_anchor,
-                ..
+                inserted_hunk_info, ..
             } => {
                 let buffer = &excerpt.buffer;
                 let buffer_context_start = excerpt.range.context.start.summary::<D>(buffer);
@@ -6209,7 +6212,8 @@ where
                     excerpt,
                     has_trailing_newline,
                     is_main_buffer: true,
-                    is_inserted_hunk: inserted_hunk_anchor.is_some(),
+                    diff_hunk_status: inserted_hunk_info
+                        .map(|info| DiffHunkStatus::Added(info.hunk_secondary_status)),
                     buffer_range: buffer_start..buffer_end,
                     range: start..end,
                 })
@@ -6717,13 +6721,12 @@ impl sum_tree::KeyedItem for ExcerptIdMapping {
 }
 
 impl DiffTransform {
-    fn hunk_anchor(&self) -> Option<(ExcerptId, text::Anchor)> {
+    fn hunk_info(&self) -> Option<DiffTransformHunkInfo> {
         match self {
-            DiffTransform::DeletedHunk { hunk_anchor, .. } => Some(*hunk_anchor),
+            DiffTransform::DeletedHunk { hunk_info, .. } => Some(*hunk_info),
             DiffTransform::BufferContent {
-                inserted_hunk_anchor,
-                ..
-            } => *inserted_hunk_anchor,
+                inserted_hunk_info, ..
+            } => *inserted_hunk_info,
         }
     }
 }
@@ -7020,13 +7023,9 @@ impl<'a> Iterator for MultiBufferRows<'a> {
             buffer_id: Some(region.buffer.remote_id()),
             buffer_row: Some(buffer_point.row),
             multibuffer_row: Some(MultiBufferRow(self.point.row)),
-            diff_status: if region.is_inserted_hunk && self.point < region.range.end {
-                Some(DiffHunkStatus::Added)
-            } else if !region.is_main_buffer {
-                Some(DiffHunkStatus::Removed)
-            } else {
-                None
-            },
+            diff_status: region
+                .diff_hunk_status
+                .filter(|_| self.point < region.range.end),
         });
         self.point += Point::new(1, 0);
         result
@@ -7194,7 +7193,7 @@ impl<'a> Iterator for MultiBufferChunks<'a> {
                     }
                     chunks
                 } else {
-                    let base_buffer = &self.diffs.get(&buffer_id)?.base_text.as_ref()?;
+                    let base_buffer = &self.diffs.get(&buffer_id)?.base_text()?;
                     base_buffer.chunks(base_text_start..base_text_end, self.language_aware)
                 };
 

crates/multi_buffer/src/multi_buffer_tests.rs 🔗

@@ -1,5 +1,5 @@
 use super::*;
-use diff::DiffHunkStatus;
+use buffer_diff::DiffHunkStatus;
 use gpui::{App, TestAppContext};
 use indoc::indoc;
 use language::{Buffer, Rope};
@@ -979,8 +979,6 @@ fn test_empty_diff_excerpt(cx: &mut TestAppContext) {
 
     let diff = cx.new(|cx| BufferDiff::new_with_base_text(base_text, &buffer, cx));
     multibuffer.update(cx, |multibuffer, cx| {
-        multibuffer.set_all_diff_hunks_expanded(cx);
-        multibuffer.add_diff(diff.clone(), cx);
         multibuffer.push_excerpts(
             buffer.clone(),
             [ExcerptRange {
@@ -989,6 +987,8 @@ fn test_empty_diff_excerpt(cx: &mut TestAppContext) {
             }],
             cx,
         );
+        multibuffer.set_all_diff_hunks_expanded(cx);
+        multibuffer.add_diff(diff.clone(), cx);
     });
     cx.run_until_parked();
 
@@ -1325,13 +1325,13 @@ fn test_basic_diff_hunks(cx: &mut TestAppContext) {
             .map(|info| (info.buffer_row, info.diff_status))
             .collect::<Vec<_>>(),
         vec![
-            (Some(0), Some(DiffHunkStatus::Added)),
+            (Some(0), Some(DiffHunkStatus::added())),
             (Some(1), None),
-            (Some(1), Some(DiffHunkStatus::Removed)),
-            (Some(2), Some(DiffHunkStatus::Added)),
+            (Some(1), Some(DiffHunkStatus::removed())),
+            (Some(2), Some(DiffHunkStatus::added())),
             (Some(3), None),
-            (Some(3), Some(DiffHunkStatus::Removed)),
-            (Some(4), Some(DiffHunkStatus::Removed)),
+            (Some(3), Some(DiffHunkStatus::removed())),
+            (Some(4), Some(DiffHunkStatus::removed())),
             (Some(4), None),
             (Some(5), None)
         ]
@@ -1999,12 +1999,8 @@ fn test_diff_hunks_with_multiple_excerpts(cx: &mut TestAppContext) {
 
     let id_1 = buffer_1.read_with(cx, |buffer, _| buffer.remote_id());
     let id_2 = buffer_2.read_with(cx, |buffer, _| buffer.remote_id());
-    let base_id_1 = diff_1.read_with(cx, |diff, _| {
-        diff.snapshot.base_text.as_ref().unwrap().remote_id()
-    });
-    let base_id_2 = diff_2.read_with(cx, |diff, _| {
-        diff.snapshot.base_text.as_ref().unwrap().remote_id()
-    });
+    let base_id_1 = diff_1.read_with(cx, |diff, _| diff.base_text().as_ref().unwrap().remote_id());
+    let base_id_2 = diff_2.read_with(cx, |diff, _| diff.base_text().as_ref().unwrap().remote_id());
 
     let buffer_lines = (0..=snapshot.max_row().0)
         .map(|row| {
@@ -2191,9 +2187,8 @@ impl ReferenceMultibuffer {
         let Some(diff) = self.diffs.get(&buffer_id) else {
             return;
         };
-        let diff = diff.read(cx).snapshot.clone();
         let excerpt_range = excerpt.range.to_offset(&buffer);
-        for hunk in diff.hunks_intersecting_range(range, &buffer) {
+        for hunk in diff.read(cx).hunks_intersecting_range(range, &buffer, cx) {
             let hunk_range = hunk.buffer_range.to_offset(&buffer);
             if hunk_range.start < excerpt_range.start || hunk_range.start > excerpt_range.end {
                 continue;
@@ -2226,12 +2221,12 @@ impl ReferenceMultibuffer {
             let buffer = excerpt.buffer.read(cx);
             let buffer_range = excerpt.range.to_offset(buffer);
             let diff = self.diffs.get(&buffer.remote_id()).unwrap().read(cx);
-            let diff = diff.snapshot.clone();
-            let base_buffer = diff.base_text.as_ref().unwrap();
+            // let diff = diff.snapshot.clone();
+            let base_buffer = diff.base_text().unwrap();
 
             let mut offset = buffer_range.start;
             let mut hunks = diff
-                .hunks_intersecting_range(excerpt.range.clone(), buffer)
+                .hunks_intersecting_range(excerpt.range.clone(), buffer, cx)
                 .peekable();
 
             while let Some(hunk) = hunks.next() {
@@ -2284,7 +2279,7 @@ impl ReferenceMultibuffer {
                             buffer_start: Some(
                                 base_buffer.offset_to_point(hunk.diff_base_byte_range.start),
                             ),
-                            status: Some(DiffHunkStatus::Removed),
+                            status: Some(DiffHunkStatus::Removed(hunk.secondary_status)),
                         });
                     }
 
@@ -2299,7 +2294,7 @@ impl ReferenceMultibuffer {
                         buffer_id: Some(buffer.remote_id()),
                         range: len..text.len(),
                         buffer_start: Some(buffer.offset_to_point(offset)),
-                        status: Some(DiffHunkStatus::Added),
+                        status: Some(DiffHunkStatus::Added(hunk.secondary_status)),
                     });
                     offset = hunk_range.end;
                 }
@@ -2365,8 +2360,8 @@ impl ReferenceMultibuffer {
             let buffer = excerpt.buffer.read(cx).snapshot();
             let excerpt_range = excerpt.range.to_offset(&buffer);
             let buffer_id = buffer.remote_id();
-            let diff = &self.diffs.get(&buffer_id).unwrap().read(cx).snapshot;
-            let mut hunks = diff.hunks_in_row_range(0..u32::MAX, &buffer).peekable();
+            let diff = self.diffs.get(&buffer_id).unwrap().read(cx);
+            let mut hunks = diff.hunks_in_row_range(0..u32::MAX, &buffer, cx).peekable();
             excerpt.expanded_diff_hunks.retain(|hunk_anchor| {
                 if !hunk_anchor.is_valid(&buffer) {
                     return false;
@@ -2670,7 +2665,7 @@ async fn test_random_multibuffer(cx: &mut TestAppContext, mut rng: StdRng) {
             expected_row_infos
                 .into_iter()
                 .filter_map(
-                    |info| if info.diff_status == Some(DiffHunkStatus::Removed) {
+                    |info| if matches!(info.diff_status, Some(DiffHunkStatus::Removed(_))) {
                         None
                     } else {
                         info.buffer_row
@@ -3027,9 +3022,9 @@ fn format_diff(
         .zip(row_infos)
         .map(|((ix, line), info)| {
             let marker = match info.diff_status {
-                Some(DiffHunkStatus::Added) => "+ ",
-                Some(DiffHunkStatus::Removed) => "- ",
-                Some(DiffHunkStatus::Modified) => unreachable!(),
+                Some(DiffHunkStatus::Added(_)) => "+ ",
+                Some(DiffHunkStatus::Removed(_)) => "- ",
+                Some(DiffHunkStatus::Modified(_)) => unreachable!(),
                 None => {
                     if has_diff && !line.is_empty() {
                         "  "

crates/project/Cargo.toml 🔗

@@ -30,7 +30,7 @@ async-trait.workspace = true
 client.workspace = true
 clock.workspace = true
 collections.workspace = true
-diff.workspace = true
+buffer_diff.workspace = true
 fs.workspace = true
 futures.workspace = true
 fuzzy.workspace = true
@@ -78,7 +78,7 @@ fancy-regex.workspace = true
 [dev-dependencies]
 client = { workspace = true, features = ["test-support"] }
 collections = { workspace = true, features = ["test-support"] }
-diff = { workspace = true, features = ["test-support"] }
+buffer_diff = { workspace = true, features = ["test-support"] }
 env_logger.workspace = true
 fs = { workspace = true, features = ["test-support"] }
 git2.workspace = true

crates/project/src/buffer_store.rs 🔗

@@ -6,9 +6,9 @@ use crate::{
 };
 use ::git::{parse_git_remote_url, BuildPermalinkParams, GitHostingProviderRegistry};
 use anyhow::{anyhow, bail, Context as _, Result};
+use buffer_diff::{BufferDiff, BufferDiffEvent};
 use client::Client;
 use collections::{hash_map, HashMap, HashSet};
-use diff::{BufferDiff, BufferDiffEvent, BufferDiffSnapshot};
 use fs::Fs;
 use futures::{channel::oneshot, future::Shared, Future, FutureExt as _, StreamExt};
 use git::{blame::Blame, repository::RepoPath};
@@ -23,7 +23,10 @@ use language::{
     },
     Buffer, BufferEvent, Capability, DiskState, File as _, Language, LanguageRegistry, Operation,
 };
-use rpc::{proto, AnyProtoClient, ErrorExt as _, TypedEnvelope};
+use rpc::{
+    proto::{self, ToProto},
+    AnyProtoClient, ErrorExt as _, TypedEnvelope,
+};
 use serde::Deserialize;
 use smol::channel::Receiver;
 use std::{
@@ -204,72 +207,74 @@ impl BufferDiffState {
             _ => false,
         };
         self.recalculate_diff_task = Some(cx.spawn(|this, mut cx| async move {
+            let mut unstaged_changed_range = None;
             if let Some(unstaged_diff) = &unstaged_diff {
-                let snapshot = if index_changed || language_changed {
-                    cx.update(|cx| {
-                        BufferDiffSnapshot::build(
-                            buffer.clone(),
-                            index,
-                            language.clone(),
-                            language_registry.clone(),
-                            cx,
-                        )
-                    })?
-                    .await
-                } else {
-                    unstaged_diff
-                        .read_with(&cx, |changes, cx| {
-                            BufferDiffSnapshot::build_with_base_buffer(
-                                buffer.clone(),
-                                index,
-                                changes.snapshot.base_text.clone(),
-                                cx,
-                            )
-                        })?
-                        .await
-                };
+                unstaged_changed_range = BufferDiff::update_diff(
+                    unstaged_diff.clone(),
+                    buffer.clone(),
+                    index,
+                    index_changed,
+                    language_changed,
+                    language.clone(),
+                    language_registry.clone(),
+                    &mut cx,
+                )
+                .await?;
 
-                unstaged_diff.update(&mut cx, |unstaged_diff, cx| {
-                    unstaged_diff.set_state(snapshot, &buffer, cx);
+                unstaged_diff.update(&mut cx, |_, cx| {
                     if language_changed {
                         cx.emit(BufferDiffEvent::LanguageChanged);
                     }
+                    if let Some(changed_range) = unstaged_changed_range.clone() {
+                        cx.emit(BufferDiffEvent::DiffChanged {
+                            changed_range: Some(changed_range),
+                        })
+                    }
                 })?;
             }
 
             if let Some(uncommitted_diff) = &uncommitted_diff {
-                let snapshot =
+                let uncommitted_changed_range =
                     if let (Some(unstaged_diff), true) = (&unstaged_diff, index_matches_head) {
-                        unstaged_diff.read_with(&cx, |diff, _| diff.snapshot.clone())?
-                    } else if head_changed || language_changed {
-                        cx.update(|cx| {
-                            BufferDiffSnapshot::build(
-                                buffer.clone(),
-                                head,
-                                language.clone(),
-                                language_registry.clone(),
-                                cx,
-                            )
+                        uncommitted_diff.update(&mut cx, |uncommitted_diff, cx| {
+                            uncommitted_diff.update_diff_from(&buffer, unstaged_diff, cx)
                         })?
-                        .await
                     } else {
-                        uncommitted_diff
-                            .read_with(&cx, |changes, cx| {
-                                BufferDiffSnapshot::build_with_base_buffer(
-                                    buffer.clone(),
-                                    head,
-                                    changes.snapshot.base_text.clone(),
-                                    cx,
-                                )
-                            })?
-                            .await
+                        BufferDiff::update_diff(
+                            uncommitted_diff.clone(),
+                            buffer.clone(),
+                            head,
+                            head_changed,
+                            language_changed,
+                            language.clone(),
+                            language_registry.clone(),
+                            &mut cx,
+                        )
+                        .await?
                     };
 
-                uncommitted_diff.update(&mut cx, |diff, cx| {
-                    diff.set_state(snapshot, &buffer, cx);
+                uncommitted_diff.update(&mut cx, |uncommitted_diff, cx| {
                     if language_changed {
                         cx.emit(BufferDiffEvent::LanguageChanged);
                     }
+                    let changed_range = match (unstaged_changed_range, uncommitted_changed_range) {
+                        (None, None) => None,
+                        (Some(unstaged_range), None) => {
+                            uncommitted_diff.range_to_hunk_range(unstaged_range, &buffer, cx)
+                        }
+                        (None, Some(uncommitted_range)) => Some(uncommitted_range),
+                        (Some(unstaged_range), Some(uncommitted_range)) => maybe!({
+                            let expanded_range = uncommitted_diff.range_to_hunk_range(
+                                unstaged_range,
+                                &buffer,
+                                cx,
+                            )?;
+                            let start = expanded_range.start.min(&uncommitted_range.start, &buffer);
+                            let end = expanded_range.end.max(&uncommitted_range.end, &buffer);
+                            Some(start..end)
+                        }),
+                    };
+                    cx.emit(BufferDiffEvent::DiffChanged { changed_range });
                 })?;
             }
 
@@ -277,6 +282,7 @@ impl BufferDiffState {
                 this.update(&mut cx, |this, _| {
                     this.index_changed = false;
                     this.head_changed = false;
+                    this.language_changed = false;
                     for tx in this.diff_updated_futures.drain(..) {
                         tx.send(()).ok();
                     }
@@ -580,13 +586,12 @@ impl RemoteBufferStore {
         let worktree_id = worktree.read(cx).id().to_proto();
         let project_id = self.project_id;
         let client = self.upstream_client.clone();
-        let path_string = path.clone().to_string_lossy().to_string();
         cx.spawn(move |this, mut cx| async move {
             let response = client
                 .request(proto::OpenBufferByPath {
                     project_id,
                     worktree_id,
-                    path: path_string,
+                    path: path.to_proto(),
                 })
                 .await?;
             let buffer_id = BufferId::new(response.buffer_id)?;
@@ -1476,29 +1481,19 @@ impl BufferStore {
                     diff_state.language = language;
                     diff_state.language_registry = language_registry;
 
-                    let diff = cx.new(|_| BufferDiff {
-                        buffer_id,
-                        snapshot: BufferDiffSnapshot::new(&text_snapshot),
-                        unstaged_diff: None,
-                    });
+                    let diff = cx.new(|_| BufferDiff::new(&text_snapshot));
                     match kind {
                         DiffKind::Unstaged => diff_state.unstaged_diff = Some(diff.downgrade()),
                         DiffKind::Uncommitted => {
                             let unstaged_diff = if let Some(diff) = diff_state.unstaged_diff() {
                                 diff
                             } else {
-                                let unstaged_diff = cx.new(|_| BufferDiff {
-                                    buffer_id,
-                                    snapshot: BufferDiffSnapshot::new(&text_snapshot),
-                                    unstaged_diff: None,
-                                });
+                                let unstaged_diff = cx.new(|_| BufferDiff::new(&text_snapshot));
                                 diff_state.unstaged_diff = Some(unstaged_diff.downgrade());
                                 unstaged_diff
                             };
 
-                            diff.update(cx, |diff, _| {
-                                diff.unstaged_diff = Some(unstaged_diff);
-                            });
+                            diff.update(cx, |diff, _| diff.set_secondary_diff(unstaged_diff));
                             diff_state.uncommitted_diff = Some(diff.downgrade())
                         }
                     };
@@ -2395,9 +2390,8 @@ impl BufferStore {
                 shared.diff = Some(diff.clone());
             }
         })?;
-        let staged_text = diff.read_with(&cx, |diff, _| {
-            diff.snapshot.base_text.as_ref().map(|buffer| buffer.text())
-        })?;
+        let staged_text =
+            diff.read_with(&cx, |diff, _| diff.base_text().map(|buffer| buffer.text()))?;
         Ok(proto::OpenUnstagedDiffResponse { staged_text })
     }
 
@@ -2428,14 +2422,13 @@ impl BufferStore {
             use proto::open_uncommitted_diff_response::Mode;
 
             let staged_buffer = diff
-                .unstaged_diff
-                .as_ref()
-                .and_then(|diff| diff.read(cx).snapshot.base_text.as_ref());
+                .secondary_diff()
+                .and_then(|diff| diff.read(cx).base_text());
 
             let mode;
             let staged_text;
             let committed_text;
-            if let Some(committed_buffer) = &diff.snapshot.base_text {
+            if let Some(committed_buffer) = diff.base_text() {
                 committed_text = Some(committed_buffer.text());
                 if let Some(staged_buffer) = staged_buffer {
                     if staged_buffer.remote_id() == committed_buffer.remote_id() {

crates/project/src/git.rs 🔗

@@ -13,6 +13,7 @@ use gpui::{
     App, AppContext, Context, Entity, EventEmitter, SharedString, Subscription, Task, WeakEntity,
 };
 use language::{Buffer, LanguageRegistry};
+use rpc::proto::ToProto;
 use rpc::{proto, AnyProtoClient};
 use settings::WorktreeId;
 use std::path::{Path, PathBuf};
@@ -222,7 +223,7 @@ impl GitState {
                                 work_directory_id: work_directory_id.to_proto(),
                                 paths: paths
                                     .into_iter()
-                                    .map(|repo_path| repo_path.to_proto())
+                                    .map(|repo_path| repo_path.as_ref().to_proto())
                                     .collect(),
                             })
                             .await
@@ -247,7 +248,7 @@ impl GitState {
                                 work_directory_id: work_directory_id.to_proto(),
                                 paths: paths
                                     .into_iter()
-                                    .map(|repo_path| repo_path.to_proto())
+                                    .map(|repo_path| repo_path.as_ref().to_proto())
                                     .collect(),
                             })
                             .await

crates/project/src/lsp_store.rs 🔗

@@ -55,7 +55,10 @@ use parking_lot::Mutex;
 use postage::watch;
 use rand::prelude::*;
 
-use rpc::AnyProtoClient;
+use rpc::{
+    proto::{FromProto, ToProto},
+    AnyProtoClient,
+};
 use serde::Serialize;
 use settings::{Settings, SettingsLocation, SettingsStore};
 use sha2::{Digest, Sha256};
@@ -5360,7 +5363,7 @@ impl LspStore {
                         project_id: *project_id,
                         worktree_id: worktree_id.to_proto(),
                         summary: Some(proto::DiagnosticSummary {
-                            path: worktree_path.to_string_lossy().to_string(),
+                            path: worktree_path.to_proto(),
                             language_server_id: server_id.0 as u64,
                             error_count: new_summary.error_count as u32,
                             warning_count: new_summary.warning_count as u32,
@@ -5848,10 +5851,8 @@ impl LspStore {
             .ok_or_else(|| anyhow!("worktree not found"))?;
         let (old_abs_path, new_abs_path) = {
             let root_path = worktree.update(&mut cx, |this, _| this.abs_path())?;
-            (
-                root_path.join(&old_path),
-                root_path.join(&envelope.payload.new_path),
-            )
+            let new_path = PathBuf::from_proto(envelope.payload.new_path.clone());
+            (root_path.join(&old_path), root_path.join(&new_path))
         };
 
         Self::will_rename_entry(
@@ -5881,7 +5882,7 @@ impl LspStore {
             if let Some(message) = envelope.payload.summary {
                 let project_path = ProjectPath {
                     worktree_id,
-                    path: Path::new(&message.path).into(),
+                    path: Arc::<Path>::from_proto(message.path),
                 };
                 let path = project_path.path.clone();
                 let server_id = LanguageServerId(message.language_server_id as usize);
@@ -5915,7 +5916,7 @@ impl LspStore {
                             project_id: *project_id,
                             worktree_id: worktree_id.to_proto(),
                             summary: Some(proto::DiagnosticSummary {
-                                path: project_path.path.to_string_lossy().to_string(),
+                                path: project_path.path.as_ref().to_proto(),
                                 language_server_id: server_id.0 as u64,
                                 error_count: summary.error_count as u32,
                                 warning_count: summary.warning_count as u32,
@@ -7114,7 +7115,7 @@ impl LspStore {
                                 project_id,
                                 worktree_id: worktree_id.to_proto(),
                                 summary: Some(proto::DiagnosticSummary {
-                                    path: path.to_string_lossy().to_string(),
+                                    path: path.as_ref().to_proto(),
                                     language_server_id: server_id.0 as u64,
                                     error_count: 0,
                                     warning_count: 0,
@@ -7768,7 +7769,7 @@ impl LspStore {
             language_server_name: symbol.language_server_name.0.to_string(),
             source_worktree_id: symbol.source_worktree_id.to_proto(),
             worktree_id: symbol.path.worktree_id.to_proto(),
-            path: symbol.path.path.to_string_lossy().to_string(),
+            path: symbol.path.path.as_ref().to_proto(),
             name: symbol.name.clone(),
             kind: unsafe { mem::transmute::<lsp::SymbolKind, i32>(symbol.kind) },
             start: Some(proto::PointUtf16 {
@@ -7789,7 +7790,7 @@ impl LspStore {
         let kind = unsafe { mem::transmute::<i32, lsp::SymbolKind>(serialized_symbol.kind) };
         let path = ProjectPath {
             worktree_id,
-            path: PathBuf::from(serialized_symbol.path).into(),
+            path: Arc::<Path>::from_proto(serialized_symbol.path),
         };
 
         let start = serialized_symbol
@@ -8263,7 +8264,7 @@ impl DiagnosticSummary {
         path: &Path,
     ) -> proto::DiagnosticSummary {
         proto::DiagnosticSummary {
-            path: path.to_string_lossy().to_string(),
+            path: path.to_proto(),
             language_server_id: language_server_id.0 as u64,
             error_count: self.error_count as u32,
             warning_count: self.warning_count as u32,

crates/project/src/project.rs 🔗

@@ -21,7 +21,7 @@ mod project_tests;
 
 mod direnv;
 mod environment;
-use diff::BufferDiff;
+use buffer_diff::BufferDiff;
 pub use environment::EnvironmentErrorMessage;
 use git::Repository;
 pub mod search_history;
@@ -73,7 +73,7 @@ pub use prettier_store::PrettierStore;
 use project_settings::{ProjectSettings, SettingsObserver, SettingsObserverEvent};
 use remote::{SshConnectionOptions, SshRemoteClient};
 use rpc::{
-    proto::{LanguageServerPromptResponse, SSH_PROJECT_ID},
+    proto::{FromProto, LanguageServerPromptResponse, ToProto, SSH_PROJECT_ID},
     AnyProtoClient, ErrorCode,
 };
 use search::{SearchInputKind, SearchQuery, SearchResult};
@@ -297,14 +297,14 @@ impl ProjectPath {
     pub fn from_proto(p: proto::ProjectPath) -> Self {
         Self {
             worktree_id: WorktreeId::from_proto(p.worktree_id),
-            path: Arc::from(PathBuf::from(p.path)),
+            path: Arc::<Path>::from_proto(p.path),
         }
     }
 
     pub fn to_proto(&self) -> proto::ProjectPath {
         proto::ProjectPath {
             worktree_id: self.worktree_id.to_proto(),
-            path: self.path.to_string_lossy().to_string(),
+            path: self.path.as_ref().to_proto(),
         }
     }
 
@@ -3360,18 +3360,19 @@ impl Project {
                 })
             })
         } else if let Some(ssh_client) = self.ssh_client.as_ref() {
+            let request_path = Path::new(path);
             let request = ssh_client
                 .read(cx)
                 .proto_client()
                 .request(proto::GetPathMetadata {
                     project_id: SSH_PROJECT_ID,
-                    path: path.to_string(),
+                    path: request_path.to_proto(),
                 });
             cx.background_executor().spawn(async move {
                 let response = request.await.log_err()?;
                 if response.exists {
                     Some(ResolvedPath::AbsPath {
-                        path: PathBuf::from(response.path),
+                        path: PathBuf::from_proto(response.path),
                         is_dir: response.is_dir,
                     })
                 } else {
@@ -3441,9 +3442,10 @@ impl Project {
         if self.is_local() {
             DirectoryLister::Local(self.fs.clone()).list_directory(query, cx)
         } else if let Some(session) = self.ssh_client.as_ref() {
+            let path_buf = PathBuf::from(query);
             let request = proto::ListRemoteDirectory {
                 dev_server_id: SSH_PROJECT_ID,
-                path: query,
+                path: path_buf.to_proto(),
             };
 
             let response = session.read(cx).proto_client().request(request);
@@ -3994,7 +3996,7 @@ impl Project {
             this.open_buffer(
                 ProjectPath {
                     worktree_id,
-                    path: PathBuf::from(envelope.payload.path).into(),
+                    path: Arc::<Path>::from_proto(envelope.payload.path),
                 },
                 cx,
             )

crates/project/src/project_settings.rs 🔗

@@ -7,18 +7,17 @@ use paths::{
     local_settings_file_relative_path, local_tasks_file_relative_path,
     local_vscode_tasks_file_relative_path, EDITORCONFIG_NAME,
 };
-use rpc::{proto, AnyProtoClient, TypedEnvelope};
+use rpc::{
+    proto::{self, FromProto, ToProto},
+    AnyProtoClient, TypedEnvelope,
+};
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use settings::{
     parse_json_with_comments, InvalidSettingsError, LocalSettingsKind, Settings, SettingsLocation,
     SettingsSources, SettingsStore,
 };
-use std::{
-    path::{Path, PathBuf},
-    sync::Arc,
-    time::Duration,
-};
+use std::{path::Path, sync::Arc, time::Duration};
 use task::{TaskTemplates, VsCodeTaskFile};
 use util::ResultExt;
 use worktree::{PathChange, UpdatedEntriesSet, Worktree, WorktreeId};
@@ -292,7 +291,7 @@ impl SettingsObserver {
                     .send(proto::UpdateWorktreeSettings {
                         project_id,
                         worktree_id,
-                        path: path.to_string_lossy().into(),
+                        path: path.to_proto(),
                         content: Some(content),
                         kind: Some(
                             local_settings_kind_to_proto(LocalSettingsKind::Settings).into(),
@@ -305,7 +304,7 @@ impl SettingsObserver {
                     .send(proto::UpdateWorktreeSettings {
                         project_id,
                         worktree_id,
-                        path: path.to_string_lossy().into(),
+                        path: path.to_proto(),
                         content: Some(content),
                         kind: Some(
                             local_settings_kind_to_proto(LocalSettingsKind::Editorconfig).into(),
@@ -343,7 +342,7 @@ impl SettingsObserver {
             this.update_settings(
                 worktree,
                 [(
-                    PathBuf::from(&envelope.payload.path).into(),
+                    Arc::<Path>::from_proto(envelope.payload.path.clone()),
                     local_settings_kind_from_proto(kind),
                     envelope.payload.content,
                 )],
@@ -551,7 +550,7 @@ impl SettingsObserver {
                     .send(proto::UpdateWorktreeSettings {
                         project_id: self.project_id,
                         worktree_id: remote_worktree_id.to_proto(),
-                        path: directory.to_string_lossy().into_owned(),
+                        path: directory.to_proto(),
                         content: file_content,
                         kind: Some(local_settings_kind_to_proto(kind).into()),
                     })

crates/project/src/project_tests.rs 🔗

@@ -1,5 +1,5 @@
 use crate::{Event, *};
-use diff::assert_hunks;
+use buffer_diff::{assert_hunks, DiffHunkSecondaryStatus, DiffHunkStatus};
 use fs::FakeFs;
 use futures::{future, StreamExt};
 use gpui::{App, SemanticVersion, UpdateGlobal};
@@ -5692,15 +5692,16 @@ async fn test_unstaged_diff_for_buffer(cx: &mut gpui::TestAppContext) {
     unstaged_diff.update(cx, |unstaged_diff, cx| {
         let snapshot = buffer.read(cx).snapshot();
         assert_hunks(
-            unstaged_diff.diff_hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot),
+            unstaged_diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot, cx),
             &snapshot,
             &unstaged_diff.base_text_string().unwrap(),
             &[
-                (0..1, "", "// print goodbye\n"),
+                (0..1, "", "// print goodbye\n", DiffHunkStatus::added()),
                 (
                     2..3,
                     "    println!(\"hello world\");\n",
                     "    println!(\"goodbye world\");\n",
+                    DiffHunkStatus::modified(),
                 ),
             ],
         );
@@ -5722,10 +5723,15 @@ async fn test_unstaged_diff_for_buffer(cx: &mut gpui::TestAppContext) {
     unstaged_diff.update(cx, |unstaged_diff, cx| {
         let snapshot = buffer.read(cx).snapshot();
         assert_hunks(
-            unstaged_diff.diff_hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot),
+            unstaged_diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot, cx),
             &snapshot,
-            &unstaged_diff.snapshot.base_text.as_ref().unwrap().text(),
-            &[(2..3, "", "    println!(\"goodbye world\");\n")],
+            &unstaged_diff.base_text().unwrap().text(),
+            &[(
+                2..3,
+                "",
+                "    println!(\"goodbye world\");\n",
+                DiffHunkStatus::added(),
+            )],
         );
     });
 }
@@ -5795,10 +5801,7 @@ async fn test_uncommitted_diff_for_buffer(cx: &mut gpui::TestAppContext) {
 
     uncommitted_diff.read_with(cx, |diff, _| {
         assert_eq!(
-            diff.snapshot
-                .base_text
-                .as_ref()
-                .and_then(|base| base.language().cloned()),
+            diff.base_text().and_then(|base| base.language().cloned()),
             Some(language)
         )
     });
@@ -5807,15 +5810,21 @@ async fn test_uncommitted_diff_for_buffer(cx: &mut gpui::TestAppContext) {
     uncommitted_diff.update(cx, |uncommitted_diff, cx| {
         let snapshot = buffer.read(cx).snapshot();
         assert_hunks(
-            uncommitted_diff.diff_hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot),
+            uncommitted_diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot, cx),
             &snapshot,
             &uncommitted_diff.base_text_string().unwrap(),
             &[
-                (0..1, "", "// print goodbye\n"),
+                (
+                    0..1,
+                    "",
+                    "// print goodbye\n",
+                    DiffHunkStatus::Added(DiffHunkSecondaryStatus::HasSecondaryHunk),
+                ),
                 (
                     2..3,
                     "    println!(\"hello world\");\n",
                     "    println!(\"goodbye world\");\n",
+                    DiffHunkStatus::modified(),
                 ),
             ],
         );
@@ -5837,10 +5846,15 @@ async fn test_uncommitted_diff_for_buffer(cx: &mut gpui::TestAppContext) {
     uncommitted_diff.update(cx, |uncommitted_diff, cx| {
         let snapshot = buffer.read(cx).snapshot();
         assert_hunks(
-            uncommitted_diff.diff_hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot),
+            uncommitted_diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot, cx),
             &snapshot,
-            &uncommitted_diff.snapshot.base_text.as_ref().unwrap().text(),
-            &[(2..3, "", "    println!(\"goodbye world\");\n")],
+            &uncommitted_diff.base_text().unwrap().text(),
+            &[(
+                2..3,
+                "",
+                "    println!(\"goodbye world\");\n",
+                DiffHunkStatus::added(),
+            )],
         );
     });
 }
@@ -5898,13 +5912,14 @@ async fn test_single_file_diffs(cx: &mut gpui::TestAppContext) {
     uncommitted_diff.update(cx, |uncommitted_diff, cx| {
         let snapshot = buffer.read(cx).snapshot();
         assert_hunks(
-            uncommitted_diff.diff_hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot),
+            uncommitted_diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot, cx),
             &snapshot,
-            &uncommitted_diff.snapshot.base_text.as_ref().unwrap().text(),
+            &uncommitted_diff.base_text_string().unwrap(),
             &[(
                 1..2,
                 "    println!(\"hello from HEAD\");\n",
                 "    println!(\"hello from the working copy\");\n",
+                DiffHunkStatus::modified(),
             )],
         );
     });

crates/project/src/toolchain_store.rs 🔗

@@ -1,4 +1,4 @@
-use std::{str::FromStr, sync::Arc};
+use std::{path::PathBuf, str::FromStr, sync::Arc};
 
 use anyhow::{bail, Result};
 
@@ -8,7 +8,10 @@ use gpui::{
     App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, Subscription, Task, WeakEntity,
 };
 use language::{LanguageName, LanguageRegistry, LanguageToolchainStore, Toolchain, ToolchainList};
-use rpc::{proto, AnyProtoClient, TypedEnvelope};
+use rpc::{
+    proto::{self, FromProto, ToProto},
+    AnyProtoClient, TypedEnvelope,
+};
 use settings::WorktreeId;
 use util::ResultExt as _;
 
@@ -120,7 +123,9 @@ impl ToolchainStore {
             };
             let toolchain = Toolchain {
                 name: toolchain.name.into(),
-                path: toolchain.path.into(),
+                // todo(windows)
+                // Do we need to convert path to native string?
+                path: PathBuf::from(toolchain.path).to_proto().into(),
                 as_json: serde_json::Value::from_str(&toolchain.raw_json)?,
                 language_name,
             };
@@ -144,10 +149,13 @@ impl ToolchainStore {
             .await;
 
         Ok(proto::ActiveToolchainResponse {
-            toolchain: toolchain.map(|toolchain| proto::Toolchain {
-                name: toolchain.name.into(),
-                path: toolchain.path.into(),
-                raw_json: toolchain.as_json.to_string(),
+            toolchain: toolchain.map(|toolchain| {
+                let path = PathBuf::from(toolchain.path.to_string());
+                proto::Toolchain {
+                    name: toolchain.name.into(),
+                    path: path.to_proto(),
+                    raw_json: toolchain.as_json.to_string(),
+                }
             }),
         })
     }
@@ -183,10 +191,13 @@ impl ToolchainStore {
             toolchains
                 .toolchains
                 .into_iter()
-                .map(|toolchain| proto::Toolchain {
-                    name: toolchain.name.to_string(),
-                    path: toolchain.path.to_string(),
-                    raw_json: toolchain.as_json.to_string(),
+                .map(|toolchain| {
+                    let path = PathBuf::from(toolchain.path.to_string());
+                    proto::Toolchain {
+                        name: toolchain.name.to_string(),
+                        path: path.to_proto(),
+                        raw_json: toolchain.as_json.to_string(),
+                    }
                 })
                 .collect::<Vec<_>>()
         } else {
@@ -354,6 +365,7 @@ impl RemoteToolchainStore {
         let project_id = self.project_id;
         let client = self.client.clone();
         cx.spawn(move |_| async move {
+            let path = PathBuf::from(toolchain.path.to_string());
             let _ = client
                 .request(proto::ActivateToolchain {
                     project_id,
@@ -361,7 +373,7 @@ impl RemoteToolchainStore {
                     language_name: toolchain.language_name.into(),
                     toolchain: Some(proto::Toolchain {
                         name: toolchain.name.into(),
-                        path: toolchain.path.into(),
+                        path: path.to_proto(),
                         raw_json: toolchain.as_json.to_string(),
                     }),
                 })
@@ -398,7 +410,12 @@ impl RemoteToolchainStore {
                     Some(Toolchain {
                         language_name: language_name.clone(),
                         name: toolchain.name.into(),
-                        path: toolchain.path.into(),
+                        // todo(windows)
+                        // Do we need to convert path to native string?
+                        path: PathBuf::from_proto(toolchain.path)
+                            .to_string_lossy()
+                            .to_string()
+                            .into(),
                         as_json: serde_json::Value::from_str(&toolchain.raw_json).ok()?,
                     })
                 })
@@ -439,7 +456,12 @@ impl RemoteToolchainStore {
                 Some(Toolchain {
                     language_name: language_name.clone(),
                     name: toolchain.name.into(),
-                    path: toolchain.path.into(),
+                    // todo(windows)
+                    // Do we need to convert path to native string?
+                    path: PathBuf::from_proto(toolchain.path)
+                        .to_string_lossy()
+                        .to_string()
+                        .into(),
                     as_json: serde_json::Value::from_str(&toolchain.raw_json).ok()?,
                 })
             })

crates/project/src/worktree_store.rs 🔗

@@ -15,7 +15,7 @@ use futures::{
 use gpui::{App, AsyncApp, Context, Entity, EntityId, EventEmitter, Task, WeakEntity};
 use postage::oneshot;
 use rpc::{
-    proto::{self, SSH_PROJECT_ID},
+    proto::{self, FromProto, ToProto, SSH_PROJECT_ID},
     AnyProtoClient, ErrorExt, TypedEnvelope,
 };
 use smol::{
@@ -268,10 +268,11 @@ impl WorktreeStore {
         cx.spawn(|this, mut cx| async move {
             let this = this.upgrade().context("Dropped worktree store")?;
 
+            let path = Path::new(abs_path.as_str());
             let response = client
                 .request(proto::AddWorktree {
                     project_id: SSH_PROJECT_ID,
-                    path: abs_path.clone(),
+                    path: path.to_proto(),
                     visible,
                 })
                 .await?;
@@ -282,10 +283,11 @@ impl WorktreeStore {
                 return Ok(existing_worktree);
             }
 
-            let root_name = PathBuf::from(&response.canonicalized_path)
+            let root_path_buf = PathBuf::from_proto(response.canonicalized_path.clone());
+            let root_name = root_path_buf
                 .file_name()
                 .map(|n| n.to_string_lossy().to_string())
-                .unwrap_or(response.canonicalized_path.to_string());
+                .unwrap_or(root_path_buf.to_string_lossy().to_string());
 
             let worktree = cx.update(|cx| {
                 Worktree::remote(
@@ -596,7 +598,7 @@ impl WorktreeStore {
                     id: worktree.id().to_proto(),
                     root_name: worktree.root_name().into(),
                     visible: worktree.is_visible(),
-                    abs_path: worktree.abs_path().to_string_lossy().into(),
+                    abs_path: worktree.abs_path().to_proto(),
                 }
             })
             .collect()
@@ -923,7 +925,7 @@ impl WorktreeStore {
                     project_id: remote_worktree.project_id(),
                     repository: Some(proto::ProjectPath {
                         worktree_id: project_path.worktree_id.to_proto(),
-                        path: project_path.path.to_string_lossy().to_string(), // Root path
+                        path: project_path.path.to_proto(), // Root path
                     }),
                 });
 
@@ -994,7 +996,7 @@ impl WorktreeStore {
                     project_id: remote_worktree.project_id(),
                     repository: Some(proto::ProjectPath {
                         worktree_id: repository.worktree_id.to_proto(),
-                        path: repository.path.to_string_lossy().to_string(), // Root path
+                        path: repository.path.to_proto(), // Root path
                     }),
                     branch_name: new_branch,
                 });
@@ -1116,7 +1118,7 @@ impl WorktreeStore {
             .context("Invalid GitBranches call")?;
         let project_path = ProjectPath {
             worktree_id: WorktreeId::from_proto(project_path.worktree_id),
-            path: Path::new(&project_path.path).into(),
+            path: Arc::<Path>::from_proto(project_path.path),
         };
 
         let branches = this
@@ -1147,7 +1149,7 @@ impl WorktreeStore {
             .context("Invalid GitBranches call")?;
         let project_path = ProjectPath {
             worktree_id: WorktreeId::from_proto(project_path.worktree_id),
-            path: Path::new(&project_path.path).into(),
+            path: Arc::<Path>::from_proto(project_path.path),
         };
         let new_branch = update_branch.payload.branch_name;
 

crates/proto/proto/zed.proto 🔗

@@ -2489,8 +2489,8 @@ message RefreshLlmToken {}
 // Remote FS
 
 message AddWorktree {
-    uint64 project_id = 2;
     string path = 1;
+    uint64 project_id = 2;
     bool visible = 3;
 }
 
@@ -2625,6 +2625,7 @@ message UpdateGitBranch {
     string branch_name = 2;
     ProjectPath repository = 3;
 }
+
 message GetPanicFiles {
 }
 

crates/proto/src/proto.rs 🔗

@@ -15,6 +15,8 @@ use std::{
     cmp,
     fmt::{self, Debug},
     iter, mem,
+    path::{Path, PathBuf},
+    sync::Arc,
     time::{Duration, SystemTime, UNIX_EPOCH},
 };
 
@@ -137,6 +139,62 @@ impl fmt::Display for PeerId {
     }
 }
 
+pub trait FromProto {
+    fn from_proto(proto: String) -> Self;
+}
+
+pub trait ToProto {
+    fn to_proto(self) -> String;
+}
+
+impl FromProto for PathBuf {
+    #[cfg(target_os = "windows")]
+    fn from_proto(proto: String) -> Self {
+        proto.split("/").collect()
+    }
+
+    #[cfg(not(target_os = "windows"))]
+    fn from_proto(proto: String) -> Self {
+        PathBuf::from(proto)
+    }
+}
+
+impl FromProto for Arc<Path> {
+    fn from_proto(proto: String) -> Self {
+        PathBuf::from_proto(proto).into()
+    }
+}
+
+impl ToProto for PathBuf {
+    #[cfg(target_os = "windows")]
+    fn to_proto(self) -> String {
+        self.components()
+            .map(|comp| comp.as_os_str().to_string_lossy().to_string())
+            .collect::<Vec<_>>()
+            .join("/")
+    }
+
+    #[cfg(not(target_os = "windows"))]
+    fn to_proto(self) -> String {
+        self.to_string_lossy().to_string()
+    }
+}
+
+impl ToProto for &Path {
+    #[cfg(target_os = "windows")]
+    fn to_proto(self) -> String {
+        self.components()
+            .map(|comp| comp.as_os_str().to_string_lossy().to_string())
+            .collect::<Vec<_>>()
+            .join("/")
+    }
+
+    #[cfg(not(target_os = "windows"))]
+    fn to_proto(self) -> String {
+        self.to_string_lossy().to_string()
+    }
+}
+
 messages!(
     (AcceptTermsOfService, Foreground),
     (AcceptTermsOfServiceResponse, Foreground),
@@ -757,4 +815,22 @@ mod tests {
         };
         assert_eq!(PeerId::from_u64(peer_id.as_u64()), peer_id);
     }
+
+    #[test]
+    #[cfg(target_os = "windows")]
+    fn test_proto() {
+        fn generate_proto_path(path: PathBuf) -> PathBuf {
+            let proto = path.to_proto();
+            PathBuf::from_proto(proto)
+        }
+
+        let path = PathBuf::from("C:\\foo\\bar");
+        assert_eq!(path, generate_proto_path(path.clone()));
+
+        let path = PathBuf::from("C:/foo/bar/");
+        assert_eq!(path, generate_proto_path(path.clone()));
+
+        let path = PathBuf::from("C:/foo\\bar\\");
+        assert_eq!(path, generate_proto_path(path.clone()));
+    }
 }

crates/remote_server/src/headless_project.rs 🔗

@@ -1,3 +1,4 @@
+use ::proto::{FromProto, ToProto};
 use anyhow::{anyhow, Context as _, Result};
 use extension::ExtensionHostProxy;
 use extension_host::headless_host::HeadlessExtensionStore;
@@ -325,10 +326,8 @@ impl HeadlessProject {
         mut cx: AsyncApp,
     ) -> Result<proto::AddWorktreeResponse> {
         use client::ErrorCodeExt;
-        let path = shellexpand::tilde(&message.payload.path).to_string();
-
         let fs = this.read_with(&mut cx, |this, _| this.fs.clone())?;
-        let path = PathBuf::from(path);
+        let path = PathBuf::from_proto(shellexpand::tilde(&message.payload.path).to_string());
 
         let canonicalized = match fs.canonicalize(&path).await {
             Ok(path) => path,
@@ -363,7 +362,7 @@ impl HeadlessProject {
         let response = this.update(&mut cx, |_, cx| {
             worktree.update(cx, |worktree, _| proto::AddWorktreeResponse {
                 worktree_id: worktree.id().to_proto(),
-                canonicalized_path: canonicalized.to_string_lossy().to_string(),
+                canonicalized_path: canonicalized.to_proto(),
             })
         })?;
 
@@ -418,7 +417,7 @@ impl HeadlessProject {
                 buffer_store.open_buffer(
                     ProjectPath {
                         worktree_id,
-                        path: PathBuf::from(message.payload.path).into(),
+                        path: Arc::<Path>::from_proto(message.payload.path),
                     },
                     cx,
                 )
@@ -559,11 +558,11 @@ impl HeadlessProject {
         envelope: TypedEnvelope<proto::ListRemoteDirectory>,
         cx: AsyncApp,
     ) -> Result<proto::ListRemoteDirectoryResponse> {
-        let expanded = shellexpand::tilde(&envelope.payload.path).to_string();
         let fs = cx.read_entity(&this, |this, _| this.fs.clone())?;
+        let expanded = PathBuf::from_proto(shellexpand::tilde(&envelope.payload.path).to_string());
 
         let mut entries = Vec::new();
-        let mut response = fs.read_dir(Path::new(&expanded)).await?;
+        let mut response = fs.read_dir(&expanded).await?;
         while let Some(path) = response.next().await {
             if let Some(file_name) = path?.file_name() {
                 entries.push(file_name.to_string_lossy().to_string());
@@ -578,15 +577,15 @@ impl HeadlessProject {
         cx: AsyncApp,
     ) -> Result<proto::GetPathMetadataResponse> {
         let fs = cx.read_entity(&this, |this, _| this.fs.clone())?;
-        let expanded = shellexpand::tilde(&envelope.payload.path).to_string();
+        let expanded = PathBuf::from_proto(shellexpand::tilde(&envelope.payload.path).to_string());
 
-        let metadata = fs.metadata(&PathBuf::from(expanded.clone())).await?;
+        let metadata = fs.metadata(&expanded).await?;
         let is_dir = metadata.map(|metadata| metadata.is_dir).unwrap_or(false);
 
         Ok(proto::GetPathMetadataResponse {
             exists: metadata.is_some(),
             is_dir,
-            path: expanded,
+            path: expanded.to_proto(),
         })
     }
 

crates/remote_server/src/remote_editing_tests.rs 🔗

@@ -859,7 +859,7 @@ async fn test_remote_resolve_path_in_buffer(
 async fn test_remote_resolve_abs_path(cx: &mut TestAppContext, server_cx: &mut TestAppContext) {
     let fs = FakeFs::new(server_cx.executor());
     fs.insert_tree(
-        "/code",
+        path!("/code"),
         json!({
             "project1": {
                 ".git": {},
@@ -876,7 +876,7 @@ async fn test_remote_resolve_abs_path(cx: &mut TestAppContext, server_cx: &mut T
 
     let path = project
         .update(cx, |project, cx| {
-            project.resolve_abs_path("/code/project1/README.md", cx)
+            project.resolve_abs_path(path!("/code/project1/README.md"), cx)
         })
         .await
         .unwrap();
@@ -884,12 +884,12 @@ async fn test_remote_resolve_abs_path(cx: &mut TestAppContext, server_cx: &mut T
     assert!(path.is_file());
     assert_eq!(
         path.abs_path().unwrap().to_string_lossy(),
-        "/code/project1/README.md"
+        path!("/code/project1/README.md")
     );
 
     let path = project
         .update(cx, |project, cx| {
-            project.resolve_abs_path("/code/project1/src", cx)
+            project.resolve_abs_path(path!("/code/project1/src"), cx)
         })
         .await
         .unwrap();
@@ -897,12 +897,12 @@ async fn test_remote_resolve_abs_path(cx: &mut TestAppContext, server_cx: &mut T
     assert!(path.is_dir());
     assert_eq!(
         path.abs_path().unwrap().to_string_lossy(),
-        "/code/project1/src"
+        path!("/code/project1/src")
     );
 
     let path = project
         .update(cx, |project, cx| {
-            project.resolve_abs_path("/code/project1/DOESNOTEXIST", cx)
+            project.resolve_abs_path(path!("/code/project1/DOESNOTEXIST"), cx)
         })
         .await;
     assert!(path.is_none());
@@ -958,7 +958,7 @@ async fn test_adding_then_removing_then_adding_worktrees(
 ) {
     let fs = FakeFs::new(server_cx.executor());
     fs.insert_tree(
-        "/code",
+        path!("/code"),
         json!({
             "project1": {
                 ".git": {},
@@ -977,14 +977,14 @@ async fn test_adding_then_removing_then_adding_worktrees(
     let (project, _headless) = init_test(&fs, cx, server_cx).await;
     let (_worktree, _) = project
         .update(cx, |project, cx| {
-            project.find_or_create_worktree("/code/project1", true, cx)
+            project.find_or_create_worktree(path!("/code/project1"), true, cx)
         })
         .await
         .unwrap();
 
     let (worktree_2, _) = project
         .update(cx, |project, cx| {
-            project.find_or_create_worktree("/code/project2", true, cx)
+            project.find_or_create_worktree(path!("/code/project2"), true, cx)
         })
         .await
         .unwrap();
@@ -994,7 +994,7 @@ async fn test_adding_then_removing_then_adding_worktrees(
 
     let (worktree_2, _) = project
         .update(cx, |project, cx| {
-            project.find_or_create_worktree("/code/project2", true, cx)
+            project.find_or_create_worktree(path!("/code/project2"), true, cx)
         })
         .await
         .unwrap();
@@ -1246,8 +1246,7 @@ async fn test_remote_git_diffs(cx: &mut TestAppContext, server_cx: &mut TestAppC
     diff.read_with(cx, |diff, cx| {
         assert_eq!(diff.base_text_string().unwrap(), text_1);
         assert_eq!(
-            diff.unstaged_diff
-                .as_ref()
+            diff.secondary_diff()
                 .unwrap()
                 .read(cx)
                 .base_text_string()
@@ -1266,8 +1265,7 @@ async fn test_remote_git_diffs(cx: &mut TestAppContext, server_cx: &mut TestAppC
     diff.read_with(cx, |diff, cx| {
         assert_eq!(diff.base_text_string().unwrap(), text_1);
         assert_eq!(
-            diff.unstaged_diff
-                .as_ref()
+            diff.secondary_diff()
                 .unwrap()
                 .read(cx)
                 .base_text_string()
@@ -1286,8 +1284,7 @@ async fn test_remote_git_diffs(cx: &mut TestAppContext, server_cx: &mut TestAppC
     diff.read_with(cx, |diff, cx| {
         assert_eq!(diff.base_text_string().unwrap(), text_2);
         assert_eq!(
-            diff.unstaged_diff
-                .as_ref()
+            diff.secondary_diff()
                 .unwrap()
                 .read(cx)
                 .base_text_string()

crates/settings/Cargo.toml 🔗

@@ -37,7 +37,6 @@ streaming-iterator.workspace = true
 tree-sitter-json.workspace = true
 tree-sitter.workspace = true
 util.workspace = true
-migrator.workspace = true
 
 [dev-dependencies]
 fs = { workspace = true, features = ["test-support"] }

crates/settings/src/keymap_file.rs 🔗

@@ -1,11 +1,10 @@
-use anyhow::{anyhow, Context as _, Result};
+use anyhow::{anyhow, Result};
 use collections::{BTreeMap, HashMap, IndexMap};
 use fs::Fs;
 use gpui::{
     Action, ActionBuildError, App, InvalidKeystrokeError, KeyBinding, KeyBindingContextPredicate,
     NoAction, SharedString, KEYSTROKE_PARSE_EXPECTED_MESSAGE,
 };
-use migrator::migrate_keymap;
 use schemars::{
     gen::{SchemaGenerator, SchemaSettings},
     schema::{ArrayValidation, InstanceType, Schema, SchemaObject, SubschemaValidation},
@@ -598,7 +597,7 @@ impl KeymapFile {
         self.0.iter()
     }
 
-    async fn load_keymap_file(fs: &Arc<dyn Fs>) -> Result<String> {
+    pub async fn load_keymap_file(fs: &Arc<dyn Fs>) -> Result<String> {
         match fs.load(paths::keymap_file()).await {
             result @ Ok(_) => result,
             Err(err) => {
@@ -611,41 +610,6 @@ impl KeymapFile {
             }
         }
     }
-
-    pub fn should_migrate_keymap(keymap_file: Self) -> bool {
-        let Ok(old_text) = serde_json::to_string(&keymap_file) else {
-            return false;
-        };
-        migrate_keymap(&old_text).is_some()
-    }
-
-    pub async fn migrate_keymap(fs: Arc<dyn Fs>) -> Result<()> {
-        let old_text = Self::load_keymap_file(&fs).await?;
-        let Some(new_text) = migrate_keymap(&old_text) else {
-            return Ok(());
-        };
-        let keymap_path = paths::keymap_file().as_path();
-        if fs.is_file(keymap_path).await {
-            fs.atomic_write(paths::keymap_backup_file().to_path_buf(), old_text)
-                .await
-                .with_context(|| {
-                    "Failed to create settings backup in home directory".to_string()
-                })?;
-            let resolved_path = fs
-                .canonicalize(keymap_path)
-                .await
-                .with_context(|| format!("Failed to canonicalize keymap path {:?}", keymap_path))?;
-            fs.atomic_write(resolved_path.clone(), new_text)
-                .await
-                .with_context(|| format!("Failed to write keymap to file {:?}", resolved_path))?;
-        } else {
-            fs.atomic_write(keymap_path.to_path_buf(), new_text)
-                .await
-                .with_context(|| format!("Failed to write keymap to file {:?}", keymap_path))?;
-        }
-
-        Ok(())
-    }
 }
 
 // Double quotes a string and wraps it in backticks for markdown inline code..

crates/settings/src/settings_store.rs 🔗

@@ -4,7 +4,7 @@ use ec4rs::{ConfigParser, PropertiesSource, Section};
 use fs::Fs;
 use futures::{channel::mpsc, future::LocalBoxFuture, FutureExt, StreamExt};
 use gpui::{App, AsyncApp, BorrowAppContext, Global, Task, UpdateGlobal};
-use migrator::migrate_settings;
+
 use paths::{local_settings_file_relative_path, EDITORCONFIG_NAME};
 use schemars::{gen::SchemaGenerator, schema::RootSchema, JsonSchema};
 use serde::{de::DeserializeOwned, Deserialize, Serialize};
@@ -390,7 +390,7 @@ impl SettingsStore {
         self.set_user_settings(&new_text, cx).unwrap();
     }
 
-    async fn load_settings(fs: &Arc<dyn Fs>) -> Result<String> {
+    pub async fn load_settings(fs: &Arc<dyn Fs>) -> Result<String> {
         match fs.load(paths::settings_file()).await {
             result @ Ok(_) => result,
             Err(err) => {
@@ -996,51 +996,6 @@ impl SettingsStore {
         properties.use_fallbacks();
         Some(properties)
     }
-
-    pub fn should_migrate_settings(settings: &serde_json::Value) -> bool {
-        let Ok(old_text) = serde_json::to_string(settings) else {
-            return false;
-        };
-        migrate_settings(&old_text).is_some()
-    }
-
-    pub fn migrate_settings(&self, fs: Arc<dyn Fs>) {
-        self.setting_file_updates_tx
-            .unbounded_send(Box::new(move |_: AsyncApp| {
-                async move {
-                    let old_text = Self::load_settings(&fs).await?;
-                    let Some(new_text) = migrate_settings(&old_text) else {
-                        return anyhow::Ok(());
-                    };
-                    let settings_path = paths::settings_file().as_path();
-                    if fs.is_file(settings_path).await {
-                        fs.atomic_write(paths::settings_backup_file().to_path_buf(), old_text)
-                            .await
-                            .with_context(|| {
-                                "Failed to create settings backup in home directory".to_string()
-                            })?;
-                        let resolved_path =
-                            fs.canonicalize(settings_path).await.with_context(|| {
-                                format!("Failed to canonicalize settings path {:?}", settings_path)
-                            })?;
-                        fs.atomic_write(resolved_path.clone(), new_text)
-                            .await
-                            .with_context(|| {
-                                format!("Failed to write settings to file {:?}", resolved_path)
-                            })?;
-                    } else {
-                        fs.atomic_write(settings_path.to_path_buf(), new_text)
-                            .await
-                            .with_context(|| {
-                                format!("Failed to write settings to file {:?}", settings_path)
-                            })?;
-                    }
-                    anyhow::Ok(())
-                }
-                .boxed_local()
-            }))
-            .ok();
-    }
 }
 
 #[derive(Debug, Clone, PartialEq)]

crates/theme/src/icon_theme.rs 🔗

@@ -94,6 +94,7 @@ const FILE_ICONS: &[(&str, &str)] = &[
     ("lock", "icons/file_icons/lock.svg"),
     ("log", "icons/file_icons/info.svg"),
     ("lua", "icons/file_icons/lua.svg"),
+    ("markdown", "icons/file_icons/book.svg"),
     ("metal", "icons/file_icons/metal.svg"),
     ("nim", "icons/file_icons/nim.svg"),
     ("nix", "icons/file_icons/nix.svg"),
@@ -112,6 +113,7 @@ const FILE_ICONS: &[(&str, &str)] = &[
     ("scala", "icons/file_icons/scala.svg"),
     ("settings", "icons/file_icons/settings.svg"),
     ("storage", "icons/file_icons/database.svg"),
+    ("svelte", "icons/file_icons/html.svg"),
     ("swift", "icons/file_icons/swift.svg"),
     ("tcl", "icons/file_icons/tcl.svg"),
     ("template", "icons/file_icons/html.svg"),

crates/ui/src/components/context_menu.rs 🔗

@@ -524,7 +524,7 @@ impl Render for ContextMenu {
                         .occlude()
                         .elevation_2(cx)
                         .p_2()
-                        .max_w_80()
+                        .max_w_96()
                         .child(aside(cx)),
                 )
             })
@@ -600,6 +600,8 @@ impl Render for ContextMenu {
                                             let menu = cx.entity().downgrade();
                                             let icon_color = if *disabled {
                                                 Color::Muted
+                                            } else if toggle.is_some() {
+                                                icon_color.unwrap_or(Color::Accent)
                                             } else {
                                                 icon_color.unwrap_or(Color::Default)
                                             };
@@ -674,7 +676,7 @@ impl Render for ContextMenu {
                                                                 let contents =
                                                                     div().flex_none().child(
                                                                         Icon::new(IconName::Check)
-                                                                            .color(Color::Accent)
+                                                                            .color(icon_color)
                                                                             .size(*icon_size)
                                                                     )
                                                                     .when(!toggled, |contents|

crates/ui/src/components/icon.rs 🔗

@@ -221,7 +221,6 @@ pub enum IconName {
     Hash,
     HistoryRerun,
     Indicator,
-    IndicatorX,
     Info,
     InlayHint,
     Keyboard,
@@ -325,6 +324,8 @@ pub enum IconName {
     ZedAssistant2,
     ZedAssistantFilled,
     ZedPredict,
+    ZedPredictUp,
+    ZedPredictDown,
     ZedPredictDisabled,
     ZedXCopilot,
 }

crates/vim/src/command.rs 🔗

@@ -8,6 +8,7 @@ use editor::{
     Bias, Editor, ToPoint,
 };
 use gpui::{actions, impl_internal_actions, Action, App, Context, Global, Window};
+use itertools::Itertools;
 use language::Point;
 use multi_buffer::MultiBufferRow;
 use regex::Regex;
@@ -64,6 +65,95 @@ pub struct WithCount {
     action: WrappedAction,
 }
 
+#[derive(Clone, Deserialize, JsonSchema, PartialEq)]
+pub enum VimOption {
+    Wrap(bool),
+    Number(bool),
+    RelativeNumber(bool),
+}
+
+impl VimOption {
+    fn possible_commands(query: &str) -> Vec<CommandInterceptResult> {
+        let mut prefix_of_options = Vec::new();
+        let mut options = query.split(" ").collect::<Vec<_>>();
+        let prefix = options.pop().unwrap_or_default();
+        for option in options {
+            if let Some(opt) = Self::from(option) {
+                prefix_of_options.push(opt)
+            } else {
+                return vec![];
+            }
+        }
+
+        Self::possibilities(&prefix)
+            .map(|possible| {
+                let mut options = prefix_of_options.clone();
+                options.push(possible);
+
+                CommandInterceptResult {
+                    string: format!(
+                        "set {}",
+                        options.iter().map(|opt| opt.to_string()).join(" ")
+                    ),
+                    action: VimSet { options }.boxed_clone(),
+                    positions: vec![],
+                }
+            })
+            .collect()
+    }
+
+    fn possibilities(query: &str) -> impl Iterator<Item = Self> + '_ {
+        [
+            (None, VimOption::Wrap(true)),
+            (None, VimOption::Wrap(false)),
+            (None, VimOption::Number(true)),
+            (None, VimOption::Number(false)),
+            (None, VimOption::RelativeNumber(true)),
+            (None, VimOption::RelativeNumber(false)),
+            (Some("rnu"), VimOption::RelativeNumber(true)),
+            (Some("nornu"), VimOption::RelativeNumber(false)),
+        ]
+        .into_iter()
+        .filter(move |(prefix, option)| prefix.unwrap_or(option.to_string()).starts_with(query))
+        .map(|(_, option)| option)
+    }
+
+    fn from(option: &str) -> Option<Self> {
+        match option {
+            "wrap" => Some(Self::Wrap(true)),
+            "nowrap" => Some(Self::Wrap(false)),
+
+            "number" => Some(Self::Number(true)),
+            "nu" => Some(Self::Number(true)),
+            "nonumber" => Some(Self::Number(false)),
+            "nonu" => Some(Self::Number(false)),
+
+            "relativenumber" => Some(Self::RelativeNumber(true)),
+            "rnu" => Some(Self::RelativeNumber(true)),
+            "norelativenumber" => Some(Self::RelativeNumber(false)),
+            "nornu" => Some(Self::RelativeNumber(false)),
+
+            _ => None,
+        }
+    }
+
+    fn to_string(&self) -> &'static str {
+        match self {
+            VimOption::Wrap(true) => "wrap",
+            VimOption::Wrap(false) => "nowrap",
+            VimOption::Number(true) => "number",
+            VimOption::Number(false) => "nonumber",
+            VimOption::RelativeNumber(true) => "relativenumber",
+            VimOption::RelativeNumber(false) => "norelativenumber",
+        }
+    }
+}
+
+#[derive(Clone, Deserialize, JsonSchema, PartialEq)]
+pub struct VimSet {
+    options: Vec<VimOption>,
+}
+
 #[derive(Debug)]
 struct WrappedAction(Box<dyn Action>);
 
@@ -76,7 +166,8 @@ impl_internal_actions!(
         WithRange,
         WithCount,
         OnMatchingLines,
-        ShellExec
+        ShellExec,
+        VimSet,
     ]
 );
 
@@ -100,6 +191,26 @@ impl Deref for WrappedAction {
 }
 
 pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
+    // Vim::action(editor, cx, |vim, action: &StartOfLine, window, cx| {
+    Vim::action(editor, cx, |vim, action: &VimSet, window, cx| {
+        for option in action.options.iter() {
+            vim.update_editor(window, cx, |_, editor, _, cx| match option {
+                VimOption::Wrap(true) => {
+                    editor
+                        .set_soft_wrap_mode(language::language_settings::SoftWrap::EditorWidth, cx);
+                }
+                VimOption::Wrap(false) => {
+                    editor.set_soft_wrap_mode(language::language_settings::SoftWrap::None, cx);
+                }
+                VimOption::Number(enabled) => {
+                    editor.set_show_line_numbers(*enabled, cx);
+                }
+                VimOption::RelativeNumber(enabled) => {
+                    editor.set_relative_line_number(Some(*enabled), cx);
+                }
+            });
+        }
+    });
     Vim::action(editor, cx, |vim, _: &VisualCommand, window, cx| {
         let Some(workspace) = vim.workspace(window) else {
             return;
@@ -808,7 +919,7 @@ fn wrap_count(action: Box<dyn Action>, range: &CommandRange) -> Option<Box<dyn A
     })
 }
 
-pub fn command_interceptor(mut input: &str, cx: &App) -> Option<CommandInterceptResult> {
+pub fn command_interceptor(mut input: &str, cx: &App) -> Vec<CommandInterceptResult> {
     // NOTE: We also need to support passing arguments to commands like :w
     // (ideally with filename autocompletion).
     while input.starts_with(':') {
@@ -834,6 +945,8 @@ pub fn command_interceptor(mut input: &str, cx: &App) -> Option<CommandIntercept
             }
             .boxed_clone(),
         )
+    } else if query.starts_with("se ") || query.starts_with("set ") {
+        return VimOption::possible_commands(query.split_once(" ").unwrap().1);
     } else if query.starts_with('s') {
         let mut substitute = "substitute".chars().peekable();
         let mut query = query.chars().peekable();
@@ -886,11 +999,11 @@ pub fn command_interceptor(mut input: &str, cx: &App) -> Option<CommandIntercept
     if let Some(action) = action {
         let string = input.to_string();
         let positions = generate_positions(&string, &(range_prefix + query));
-        return Some(CommandInterceptResult {
+        return vec![CommandInterceptResult {
             action,
             string,
             positions,
-        });
+        }];
     }
 
     for command in commands(cx).iter() {
@@ -901,14 +1014,14 @@ pub fn command_interceptor(mut input: &str, cx: &App) -> Option<CommandIntercept
             }
             let positions = generate_positions(&string, &(range_prefix + query));
 
-            return Some(CommandInterceptResult {
+            return vec![CommandInterceptResult {
                 action,
                 string,
                 positions,
-            });
+            }];
         }
     }
-    None
+    return Vec::default();
 }
 
 fn generate_positions(string: &str, query: &str) -> Vec<usize> {
@@ -982,7 +1095,12 @@ impl OnMatchingLines {
 
         let command: String = chars.collect();
 
-        let action = WrappedAction(command_interceptor(&command, cx)?.action);
+        let action = WrappedAction(
+            command_interceptor(&command, cx)
+                .first()?
+                .action
+                .boxed_clone(),
+        );
 
         Some(Self {
             range,

crates/vim/src/object.rs 🔗

@@ -422,7 +422,7 @@ impl Object {
 /// If the selection spans multiple lines and is preceded by an opening brace (`{`),
 /// this function will trim the selection to exclude the final newline
 /// in order to preserve a properly indented line.
-fn preserve_indented_newline(map: &DisplaySnapshot, selection: &mut Selection<DisplayPoint>) {
+pub fn preserve_indented_newline(map: &DisplaySnapshot, selection: &mut Selection<DisplayPoint>) {
     let (start_point, end_point) = (selection.start.to_point(map), selection.end.to_point(map));
 
     if start_point.row == end_point.row {
@@ -446,6 +446,7 @@ fn preserve_indented_newline(map: &DisplaySnapshot, selection: &mut Selection<Di
                         match ch {
                             '\n' => {
                                 selection.end = offset.to_display_point(map);
+                                selection.reversed = true;
                                 break;
                             }
                             ch if !ch.is_whitespace() => break,
@@ -1759,6 +1760,17 @@ mod test {
             Mode::Normal,
         );
         cx.simulate_keystrokes("v i {");
+        cx.assert_state(
+            indoc! {
+                "func empty(a string) bool {
+                   «ˇif a == \"\" {
+                      return true
+                   }
+                   return false»
+                }"
+            },
+            Mode::Visual,
+        );
 
         cx.set_state(
             indoc! {
@@ -1772,6 +1784,17 @@ mod test {
             Mode::Normal,
         );
         cx.simulate_keystrokes("v i {");
+        cx.assert_state(
+            indoc! {
+                "func empty(a string) bool {
+                     if a == \"\" {
+                         «ˇreturn true»
+                     }
+                     return false
+                }"
+            },
+            Mode::Visual,
+        );
 
         cx.set_state(
             indoc! {
@@ -1785,6 +1808,41 @@ mod test {
             Mode::Normal,
         );
         cx.simulate_keystrokes("v i {");
+        cx.assert_state(
+            indoc! {
+                "func empty(a string) bool {
+                     if a == \"\" {
+                         «ˇreturn true»
+                     }
+                     return false
+                }"
+            },
+            Mode::Visual,
+        );
+
+        cx.set_state(
+            indoc! {
+                "func empty(a string) bool {
+                     if a == \"\" {
+                         return true
+                     }
+                     return false
+                ˇ}"
+            },
+            Mode::Normal,
+        );
+        cx.simulate_keystrokes("v i {");
+        cx.assert_state(
+            indoc! {
+                "func empty(a string) bool {
+                     «ˇif a == \"\" {
+                         return true
+                     }
+                     return false»
+                }"
+            },
+            Mode::Visual,
+        );
     }
 
     #[gpui::test]

crates/vim/src/vim.rs 🔗

@@ -23,7 +23,6 @@ use anyhow::Result;
 use collections::HashMap;
 use editor::{
     movement::{self, FindRange},
-    scroll::Autoscroll,
     Anchor, Bias, Editor, EditorEvent, EditorMode, ToPoint,
 };
 use gpui::{
@@ -649,20 +648,24 @@ impl Vim {
                 vim.push_count_digit(n.0, window, cx);
             });
             Vim::action(editor, cx, |vim, _: &Tab, window, cx| {
-                let Some(anchor) = vim
-                    .editor()
-                    .and_then(|editor| editor.read(cx).inline_completion_start_anchor())
-                else {
-                    return;
-                };
-
-                vim.update_editor(window, cx, |_, editor, window, cx| {
-                    editor.change_selections(Some(Autoscroll::fit()), window, cx, |s| {
-                        s.select_anchor_ranges([anchor..anchor])
-                    });
-                });
-                vim.switch_mode(Mode::Insert, true, window, cx);
+                vim.input_ignored(" ".into(), window, cx)
             });
+            Vim::action(
+                editor,
+                cx,
+                |vim, action: &editor::AcceptEditPrediction, window, cx| {
+                    vim.update_editor(window, cx, |_, editor, window, cx| {
+                        editor.accept_edit_prediction(action, window, cx);
+                    });
+                    // In non-insertion modes, predictions will be hidden and instead a jump will be
+                    // displayed (and performed by `accept_edit_prediction`). This switches to
+                    // insert mode so that the prediction is displayed after the jump.
+                    match vim.mode {
+                        Mode::Replace => {}
+                        _ => vim.switch_mode(Mode::Insert, true, window, cx),
+                    };
+                },
+            );
             Vim::action(editor, cx, |vim, _: &Enter, window, cx| {
                 vim.input_ignored("\n".into(), window, cx)
             });

crates/vim/src/visual.rs 🔗

@@ -16,7 +16,7 @@ use workspace::searchable::Direction;
 
 use crate::{
     motion::{first_non_whitespace, next_line_end, start_of_line, Motion},
-    object::Object,
+    object::{self, Object},
     state::{Mode, Operator},
     Vim,
 };
@@ -375,6 +375,9 @@ impl Vim {
                                 } else {
                                     selection.end = range.end;
                                 }
+                                if !around && object.is_multiline() {
+                                    object::preserve_indented_newline(map, selection);
+                                }
                             }
 
                             // In the visual selection result of a paragraph object, the cursor is

crates/vim/test_data/test_multiline_surrounding_character_objects.json 🔗

@@ -1,15 +1,20 @@
-{"Put":{"state":"func empty(a string) bool {\n   if a == \"\" {\n      return true\n   }\n   ˇreturn false\n}"}}
+{"Put":{"state":"func empty(a string) bool {\n     if a == \"\" {\n      return true\n   }\n   ˇreturn false\n}"}}
 {"Key":"v"}
 {"Key":"i"}
 {"Key":"{"}
-{"Get":{"state":"func empty(a string) bool {\n«   if a == \"\" {\n      return true\n   }\n   return false\nˇ»}","mode":"Visual"}}
+{"Get":{"state":"func empty(a string) bool {\n     «ˇif a == \"\" {\n      return true\n   }\n   return false»\n}","mode":"Visual"}}
 {"Put":{"state":"func empty(a string) bool {\n     if a == \"\" {\n         ˇreturn true\n     }\n     return false\n}"}}
 {"Key":"v"}
 {"Key":"i"}
 {"Key":"{"}
-{"Get":{"state":"func empty(a string) bool {\n     if a == \"\" {\n«         return true\nˇ»     }\n     return false\n}","mode":"Visual"}}
+{"Get":{"state":"func empty(a string) bool {\n     if a == \"\" {\n         «ˇreturn true»\n     }\n     return false\n}","mode":"Visual"}}
 {"Put":{"state":"func empty(a string) bool {\n     if a == \"\" ˇ{\n         return true\n     }\n     return false\n}"}}
 {"Key":"v"}
 {"Key":"i"}
 {"Key":"{"}
-{"Get":{"state":"func empty(a string) bool {\n     if a == \"\" {\n«         return true\nˇ»     }\n     return false\n}","mode":"Visual"}}
+{"Get":{"state":"func empty(a string) bool {\n     if a == \"\" {\n         «ˇreturn true»\n     }\n     return false\n}","mode":"Visual"}}
+{"Put":{"state":"func empty(a string) bool {\n     if a == \"\" {\n         return true\n     }\n     return false\nˇ}"}}
+{"Key":"v"}
+{"Key":"i"}
+{"Key":"{"}
+{"Get":{"state":"func empty(a string) bool {\n     «ˇif a == \"\" {\n         return true\n     }\n     return false»\n}","mode":"Visual"}}

crates/worktree/src/worktree.rs 🔗

@@ -39,7 +39,7 @@ use postage::{
     watch,
 };
 use rpc::{
-    proto::{self, split_worktree_update},
+    proto::{self, split_worktree_update, FromProto, ToProto},
     AnyProtoClient,
 };
 pub use settings::WorktreeId;
@@ -283,13 +283,13 @@ impl RepositoryEntry {
                             current_new_entry = new_statuses.next();
                         }
                         Ordering::Greater => {
-                            removed_statuses.push(old_entry.repo_path.to_proto());
+                            removed_statuses.push(old_entry.repo_path.as_ref().to_proto());
                             current_old_entry = old_statuses.next();
                         }
                     }
                 }
                 (None, Some(old_entry)) => {
-                    removed_statuses.push(old_entry.repo_path.to_proto());
+                    removed_statuses.push(old_entry.repo_path.as_ref().to_proto());
                     current_old_entry = old_statuses.next();
                 }
                 (Some(new_entry), None) => {
@@ -308,7 +308,7 @@ impl RepositoryEntry {
             current_merge_conflicts: self
                 .current_merge_conflicts
                 .iter()
-                .map(RepoPath::to_proto)
+                .map(|path| path.as_ref().to_proto())
                 .collect(),
         }
     }
@@ -700,7 +700,7 @@ impl Worktree {
             let snapshot = Snapshot::new(
                 worktree.id,
                 worktree.root_name,
-                Arc::from(PathBuf::from(worktree.abs_path)),
+                Arc::<Path>::from_proto(worktree.abs_path),
             );
 
             let background_snapshot = Arc::new(Mutex::new((snapshot.clone(), Vec::new())));
@@ -849,7 +849,7 @@ impl Worktree {
             id: self.id().to_proto(),
             root_name: self.root_name().to_string(),
             visible: self.is_visible(),
-            abs_path: self.abs_path().as_os_str().to_string_lossy().into(),
+            abs_path: self.abs_path().to_proto(),
         }
     }
 
@@ -1007,7 +1007,7 @@ impl Worktree {
         is_directory: bool,
         cx: &Context<Worktree>,
     ) -> Task<Result<CreatedEntry>> {
-        let path = path.into();
+        let path: Arc<Path> = path.into();
         let worktree_id = self.id();
         match self {
             Worktree::Local(this) => this.create_entry(path, is_directory, cx),
@@ -1016,7 +1016,7 @@ impl Worktree {
                 let request = this.client.request(proto::CreateProjectEntry {
                     worktree_id: worktree_id.to_proto(),
                     project_id,
-                    path: path.to_string_lossy().into(),
+                    path: path.as_ref().to_proto(),
                     is_directory,
                 });
                 cx.spawn(move |this, mut cx| async move {
@@ -1101,21 +1101,19 @@ impl Worktree {
         new_path: impl Into<Arc<Path>>,
         cx: &Context<Self>,
     ) -> Task<Result<Option<Entry>>> {
-        let new_path = new_path.into();
+        let new_path: Arc<Path> = new_path.into();
         match self {
             Worktree::Local(this) => {
                 this.copy_entry(entry_id, relative_worktree_source_path, new_path, cx)
             }
             Worktree::Remote(this) => {
-                let relative_worktree_source_path =
-                    relative_worktree_source_path.map(|relative_worktree_source_path| {
-                        relative_worktree_source_path.to_string_lossy().into()
-                    });
+                let relative_worktree_source_path = relative_worktree_source_path
+                    .map(|relative_worktree_source_path| relative_worktree_source_path.to_proto());
                 let response = this.client.request(proto::CopyProjectEntry {
                     project_id: this.project_id,
                     entry_id: entry_id.to_proto(),
                     relative_worktree_source_path,
-                    new_path: new_path.to_string_lossy().into(),
+                    new_path: new_path.to_proto(),
                 });
                 cx.spawn(move |this, mut cx| async move {
                     let response = response.await?;
@@ -1214,7 +1212,11 @@ impl Worktree {
         let (scan_id, entry) = this.update(&mut cx, |this, cx| {
             (
                 this.scan_id(),
-                this.create_entry(PathBuf::from(request.path), request.is_directory, cx),
+                this.create_entry(
+                    Arc::<Path>::from_proto(request.path),
+                    request.is_directory,
+                    cx,
+                ),
             )
         })?;
         Ok(proto::ProjectEntryResponse {
@@ -1288,7 +1290,7 @@ impl Worktree {
                 this.scan_id(),
                 this.rename_entry(
                     ProjectEntryId::from_proto(request.entry_id),
-                    PathBuf::from(request.new_path),
+                    Arc::<Path>::from_proto(request.new_path),
                     cx,
                 ),
             )
@@ -1308,14 +1310,15 @@ impl Worktree {
         mut cx: AsyncApp,
     ) -> Result<proto::ProjectEntryResponse> {
         let (scan_id, task) = this.update(&mut cx, |this, cx| {
-            let relative_worktree_source_path =
-                request.relative_worktree_source_path.map(PathBuf::from);
+            let relative_worktree_source_path = request
+                .relative_worktree_source_path
+                .map(PathBuf::from_proto);
             (
                 this.scan_id(),
                 this.copy_entry(
                     ProjectEntryId::from_proto(request.entry_id),
                     relative_worktree_source_path,
-                    PathBuf::from(request.new_path),
+                    PathBuf::from_proto(request.new_path),
                     cx,
                 ),
             )
@@ -2368,11 +2371,11 @@ impl RemoteWorktree {
         new_path: impl Into<Arc<Path>>,
         cx: &Context<Worktree>,
     ) -> Task<Result<CreatedEntry>> {
-        let new_path = new_path.into();
+        let new_path: Arc<Path> = new_path.into();
         let response = self.client.request(proto::RenameProjectEntry {
             project_id: self.project_id,
             entry_id: entry_id.to_proto(),
-            new_path: new_path.to_string_lossy().into(),
+            new_path: new_path.as_ref().to_proto(),
         });
         cx.spawn(move |this, mut cx| async move {
             let response = response.await?;
@@ -2454,7 +2457,7 @@ impl Snapshot {
         proto::UpdateWorktree {
             project_id,
             worktree_id,
-            abs_path: self.abs_path().to_string_lossy().into(),
+            abs_path: self.abs_path().to_proto(),
             root_name: self.root_name().to_string(),
             updated_entries,
             removed_entries: Vec::new(),
@@ -2555,7 +2558,7 @@ impl Snapshot {
             update.removed_entries.len()
         );
         self.update_abs_path(
-            SanitizedPath::from(PathBuf::from(update.abs_path)),
+            SanitizedPath::from(PathBuf::from_proto(update.abs_path)),
             update.root_name,
         );
 
@@ -2617,7 +2620,7 @@ impl Snapshot {
                     let edits = repository
                         .removed_statuses
                         .into_iter()
-                        .map(|path| Edit::Remove(PathKey(Path::new(&path).into())))
+                        .map(|path| Edit::Remove(PathKey(FromProto::from_proto(path))))
                         .chain(repository.updated_statuses.into_iter().filter_map(
                             |updated_status| {
                                 Some(Edit::Insert(updated_status.try_into().log_err()?))
@@ -2952,7 +2955,7 @@ impl LocalSnapshot {
         proto::UpdateWorktree {
             project_id,
             worktree_id,
-            abs_path: self.abs_path().to_string_lossy().into(),
+            abs_path: self.abs_path().to_proto(),
             root_name: self.root_name().to_string(),
             updated_entries,
             removed_entries,
@@ -3635,7 +3638,7 @@ impl language::File for File {
         rpc::proto::File {
             worktree_id: self.worktree.read(cx).id().to_proto(),
             entry_id: self.entry_id.map(|id| id.to_proto()),
-            path: self.path.to_string_lossy().into(),
+            path: self.path.as_ref().to_proto(),
             mtime: self.disk_state.mtime().map(|time| time.into()),
             is_deleted: self.disk_state == DiskState::Deleted,
         }
@@ -3716,7 +3719,7 @@ impl File {
 
         Ok(Self {
             worktree,
-            path: Path::new(&proto.path).into(),
+            path: Arc::<Path>::from_proto(proto.path),
             disk_state,
             entry_id: proto.entry_id.map(ProjectEntryId::from_proto),
             is_local: false,
@@ -3835,8 +3838,9 @@ impl StatusEntry {
                 index_status
             }),
         };
+
         proto::StatusEntry {
-            repo_path: self.repo_path.to_proto(),
+            repo_path: self.repo_path.as_ref().to_proto(),
             simple_status,
             status: Some(status_to_proto(self.status)),
         }
@@ -3847,7 +3851,7 @@ impl TryFrom<proto::StatusEntry> for StatusEntry {
     type Error = anyhow::Error;
 
     fn try_from(value: proto::StatusEntry) -> Result<Self, Self::Error> {
-        let repo_path = RepoPath(Path::new(&value.repo_path).into());
+        let repo_path = RepoPath(Arc::<Path>::from_proto(value.repo_path));
         let status = status_from_proto(value.simple_status, value.status)?;
         Ok(Self { repo_path, status })
     }
@@ -6231,7 +6235,7 @@ impl<'a> From<&'a Entry> for proto::Entry {
         Self {
             id: entry.id.to_proto(),
             is_dir: entry.is_dir(),
-            path: entry.path.to_string_lossy().into(),
+            path: entry.path.as_ref().to_proto(),
             inode: entry.inode,
             mtime: entry.mtime.map(|time| time.into()),
             is_ignored: entry.is_ignored,
@@ -6241,7 +6245,7 @@ impl<'a> From<&'a Entry> for proto::Entry {
             canonical_path: entry
                 .canonical_path
                 .as_ref()
-                .map(|path| path.to_string_lossy().to_string()),
+                .map(|path| path.as_ref().to_proto()),
         }
     }
 }
@@ -6257,20 +6261,22 @@ impl<'a> TryFrom<(&'a CharBag, &PathMatcher, proto::Entry)> for Entry {
         } else {
             EntryKind::File
         };
-        let path: Arc<Path> = PathBuf::from(entry.path).into();
+
+        let path = Arc::<Path>::from_proto(entry.path);
         let char_bag = char_bag_for_path(*root_char_bag, &path);
+        let is_always_included = always_included.is_match(path.as_ref());
         Ok(Entry {
             id: ProjectEntryId::from_proto(entry.id),
             kind,
-            path: path.clone(),
+            path,
             inode: entry.inode,
             mtime: entry.mtime.map(|time| time.into()),
             size: entry.size.unwrap_or(0),
             canonical_path: entry
                 .canonical_path
-                .map(|path_string| Box::from(Path::new(&path_string))),
+                .map(|path_string| Box::from(PathBuf::from_proto(path_string))),
             is_ignored: entry.is_ignored,
-            is_always_included: always_included.is_match(path.as_ref()),
+            is_always_included,
             is_external: entry.is_external,
             is_private: false,
             char_bag,

crates/zed/Cargo.toml 🔗

@@ -77,6 +77,7 @@ log.workspace = true
 markdown.workspace = true
 markdown_preview.workspace = true
 menu.workspace = true
+migrator.workspace = true
 mimalloc = { version = "0.1", optional = true }
 nix = { workspace = true, features = ["pthread", "signal"] }
 node_runtime.workspace = true

crates/zed/src/zed.rs 🔗

@@ -4,6 +4,7 @@ pub mod inline_completion_registry;
 pub(crate) mod linux_prompts;
 #[cfg(target_os = "macos")]
 pub(crate) mod mac_only_instance;
+mod migrate;
 mod open_listener;
 mod quick_action_bar;
 #[cfg(target_os = "windows")]
@@ -176,7 +177,6 @@ pub fn initialize_workspace(
 
         let inline_completion_button = cx.new(|cx| {
             inline_completion_button::InlineCompletionButton::new(
-                workspace.weak_handle(),
                 app_state.fs.clone(),
                 app_state.user_store.clone(),
                 popover_menu_handle.clone(),
@@ -1214,7 +1214,7 @@ fn show_keymap_migration_notification_if_needed(
     notification_id: NotificationId,
     cx: &mut App,
 ) -> bool {
-    if !KeymapFile::should_migrate_keymap(keymap_file) {
+    if !migrate::should_migrate_keymap(keymap_file) {
         return false;
     }
     let message = MarkdownString(format!(
@@ -1229,7 +1229,7 @@ fn show_keymap_migration_notification_if_needed(
         move |_, cx| {
             let fs = <dyn Fs>::global(cx);
             cx.spawn(move |weak_notification, mut cx| async move {
-                KeymapFile::migrate_keymap(fs).await.ok();
+                migrate::migrate_keymap(fs).await.ok();
                 weak_notification
                     .update(&mut cx, |_, cx| {
                         cx.emit(DismissEvent);
@@ -1248,7 +1248,7 @@ fn show_settings_migration_notification_if_needed(
     settings: serde_json::Value,
     cx: &mut App,
 ) {
-    if !SettingsStore::should_migrate_settings(&settings) {
+    if !migrate::should_migrate_settings(&settings) {
         return;
     }
     let message = MarkdownString(format!(
@@ -1262,7 +1262,7 @@ fn show_settings_migration_notification_if_needed(
         "Backup and Migrate Settings".into(),
         move |_, cx| {
             let fs = <dyn Fs>::global(cx);
-            cx.update_global(|store: &mut SettingsStore, _| store.migrate_settings(fs));
+            migrate::migrate_settings(fs, cx);
             cx.emit(DismissEvent);
         },
         cx,

crates/zed/src/zed/migrate.rs 🔗

@@ -0,0 +1,79 @@
+use std::sync::Arc;
+
+use anyhow::Context;
+use fs::Fs;
+use settings::{KeymapFile, SettingsStore};
+
+pub fn should_migrate_settings(settings: &serde_json::Value) -> bool {
+    let Ok(old_text) = serde_json::to_string(settings) else {
+        return false;
+    };
+    migrator::migrate_settings(&old_text).is_some()
+}
+
+pub fn migrate_settings(fs: Arc<dyn Fs>, cx: &mut gpui::App) {
+    cx.background_executor()
+        .spawn(async move {
+            let old_text = SettingsStore::load_settings(&fs).await?;
+            let Some(new_text) = migrator::migrate_settings(&old_text) else {
+                return anyhow::Ok(());
+            };
+            let settings_path = paths::settings_file().as_path();
+            if fs.is_file(settings_path).await {
+                fs.atomic_write(paths::settings_backup_file().to_path_buf(), old_text)
+                    .await
+                    .with_context(|| {
+                        "Failed to create settings backup in home directory".to_string()
+                    })?;
+                let resolved_path = fs.canonicalize(settings_path).await.with_context(|| {
+                    format!("Failed to canonicalize settings path {:?}", settings_path)
+                })?;
+                fs.atomic_write(resolved_path.clone(), new_text)
+                    .await
+                    .with_context(|| {
+                        format!("Failed to write settings to file {:?}", resolved_path)
+                    })?;
+            } else {
+                fs.atomic_write(settings_path.to_path_buf(), new_text)
+                    .await
+                    .with_context(|| {
+                        format!("Failed to write settings to file {:?}", settings_path)
+                    })?;
+            }
+            Ok(())
+        })
+        .detach_and_log_err(cx);
+}
+
+pub fn should_migrate_keymap(keymap_file: KeymapFile) -> bool {
+    let Ok(old_text) = serde_json::to_string(&keymap_file) else {
+        return false;
+    };
+    migrator::migrate_keymap(&old_text).is_some()
+}
+
+pub async fn migrate_keymap(fs: Arc<dyn Fs>) -> anyhow::Result<()> {
+    let old_text = KeymapFile::load_keymap_file(&fs).await?;
+    let Some(new_text) = migrator::migrate_keymap(&old_text) else {
+        return Ok(());
+    };
+    let keymap_path = paths::keymap_file().as_path();
+    if fs.is_file(keymap_path).await {
+        fs.atomic_write(paths::keymap_backup_file().to_path_buf(), old_text)
+            .await
+            .with_context(|| "Failed to create settings backup in home directory".to_string())?;
+        let resolved_path = fs
+            .canonicalize(keymap_path)
+            .await
+            .with_context(|| format!("Failed to canonicalize keymap path {:?}", keymap_path))?;
+        fs.atomic_write(resolved_path.clone(), new_text)
+            .await
+            .with_context(|| format!("Failed to write keymap to file {:?}", resolved_path))?;
+    } else {
+        fs.atomic_write(keymap_path.to_path_buf(), new_text)
+            .await
+            .with_context(|| format!("Failed to write keymap to file {:?}", keymap_path))?;
+    }
+
+    Ok(())
+}

crates/zeta/src/init.rs 🔗

@@ -10,9 +10,9 @@ use settings::update_settings_file;
 use ui::App;
 use workspace::Workspace;
 
-use crate::{onboarding_modal::ZedPredictModal, RateCompletionModal, RateCompletions};
+use crate::{onboarding_modal::ZedPredictModal, RateCompletionModal};
 
-actions!(edit_predictions, [ResetOnboarding]);
+actions!(edit_prediction, [ResetOnboarding, RateCompletions]);
 
 pub fn init(cx: &mut App) {
     cx.observe_new(move |workspace: &mut Workspace, _, _cx| {

crates/zeta/src/zeta.rs 🔗

@@ -1024,7 +1024,7 @@ impl LicenseDetectionWatcher {
     }
 
     /// Answers false until we find out it's open source
-    pub fn is_open_source(&self) -> bool {
+    pub fn is_project_open_source(&self) -> bool {
         *self.is_open_source_rx.borrow()
     }
 }
@@ -1227,7 +1227,6 @@ impl ProviderDataCollection {
             let zeta = zeta.read(cx);
             let choice = zeta.data_collection_choice.clone();
 
-            // Unwrap safety: there should be a watcher for each worktree
             let license_detection_watcher = zeta
                 .license_detection_watchers
                 .get(&file.worktree_id(cx))
@@ -1249,20 +1248,20 @@ impl ProviderDataCollection {
         }
     }
 
-    pub fn user_data_collection_choice(&self, cx: &App) -> bool {
-        self.choice
-            .as_ref()
-            .map_or(false, |choice| choice.read(cx).is_enabled())
+    pub fn can_collect_data(&self, cx: &App) -> bool {
+        self.is_data_collection_enabled(cx) && self.is_project_open_source()
     }
 
-    pub fn can_collect_data(&self, cx: &App) -> bool {
+    pub fn is_data_collection_enabled(&self, cx: &App) -> bool {
         self.choice
             .as_ref()
             .is_some_and(|choice| choice.read(cx).is_enabled())
-            && self
-                .license_detection_watcher
-                .as_ref()
-                .is_some_and(|watcher| watcher.is_open_source())
+    }
+
+    fn is_project_open_source(&self) -> bool {
+        self.license_detection_watcher
+            .as_ref()
+            .is_some_and(|watcher| watcher.is_project_open_source())
     }
 
     pub fn toggle(&mut self, cx: &mut App) {
@@ -1326,13 +1325,16 @@ impl inline_completion::EditPredictionProvider for ZetaInlineCompletionProvider
     }
 
     fn data_collection_state(&self, cx: &App) -> DataCollectionState {
-        if self
-            .provider_data_collection
-            .user_data_collection_choice(cx)
-        {
-            DataCollectionState::Enabled
+        let is_project_open_source = self.provider_data_collection.is_project_open_source();
+
+        if self.provider_data_collection.is_data_collection_enabled(cx) {
+            DataCollectionState::Enabled {
+                is_project_open_source,
+            }
         } else {
-            DataCollectionState::Disabled
+            DataCollectionState::Disabled {
+                is_project_open_source,
+            }
         }
     }
 

docs/src/languages/lua.md 🔗

@@ -59,7 +59,13 @@ Alternative you can use [StyLua](https://github.com/JohnnyMorganz/StyLua):
       "formatter": {
         "external": {
           "command": "stylua",
-          "arguments": ["--syntax=Lua54", "-"]
+          "arguments": [
+            "--syntax=Lua54",
+            "--respect-ignores",
+            "--stdin-filepath",
+            "{buffer_path}",
+            "-"
+          ]
         }
       }
     }

docs/src/vim.md 🔗

@@ -380,17 +380,15 @@ But you cannot use the same shortcuts to move between all the editor docks (the
 Subword motion, which allows you to navigate and select individual words in camelCase or snake_case, is not enabled by default. To enable it, add these bindings to your keymap.
 
 ```json
-[
-  {
-    "context": "VimControl && !menu && vim_mode != operator",
-    "bindings": {
-      "w": "vim::NextSubwordStart",
-      "b": "vim::PreviousSubwordStart",
-      "e": "vim::NextSubwordEnd",
-      "g e": "vim::PreviousSubwordEnd"
-    }
+{
+  "context": "VimControl && !menu && vim_mode != operator",
+  "bindings": {
+    "w": "vim::NextSubwordStart",
+    "b": "vim::PreviousSubwordStart",
+    "e": "vim::NextSubwordEnd",
+    "g e": "vim::PreviousSubwordEnd"
   }
-]
+}
 ```
 
 Vim mode comes with shortcuts to surround the selection in normal mode (`ys`), but it doesn't have a shortcut to add surrounds in visual mode. By default, `shift-s` substitutes the selection (erases the text and enters insert mode). To use `shift-s` to add surrounds in visual mode, you can add the following object to your keymap.
@@ -407,15 +405,13 @@ Vim mode comes with shortcuts to surround the selection in normal mode (`ys`), b
 The [Sneak motion](https://github.com/justinmk/vim-sneak) feature allows for quick navigation to any two-character sequence in your text. You can enable it by adding the following keybindings to your keymap. By default, the `s` key is mapped to `vim::Substitute`. Adding these bindings will override that behavior, so ensure this change aligns with your workflow preferences.
 
 ```json
-[
-  {
-    "context": "vim_mode == normal || vim_mode == visual",
-    "bindings": {
-      "s": ["vim::PushSneak", {}],
-      "S": ["vim::PushSneakBackward", {}]
-    }
+{
+  "context": "vim_mode == normal || vim_mode == visual",
+  "bindings": {
+    "s": ["vim::PushSneak", {}],
+    "S": ["vim::PushSneakBackward", {}]
   }
-]
+}
 ```
 
 ### Restoring common text editing keybindings

extensions/EXTRACTION.md 🔗

@@ -112,11 +112,19 @@ OLD_VERSION=$(grep '^version = ' extension.toml | cut -d'"' -f2)
 NEW_VERSION=$(echo "$OLD_VERSION" | awk -F. '{$NF = $NF + 1;} 1' OFS=.)
 echo $OLD_VERSION $NEW_VERSION
 perl -i -pe "s/$OLD_VERSION/$NEW_VERSION/" extension.toml
+perl -i -pe "s#https://github.com/zed-industries/zed#https://github.com/zed-extensions/${LANGNAME}#g" extension.toml
 
 # if there's rust code, update this too.
-test -f Cargo.toml && perl -i -pe "s/$OLD_VERSION/$NEW_VERSION/" cargo.toml
+test -f Cargo.toml && perl -i -pe "s/$OLD_VERSION/$NEW_VERSION/" Cargo.toml
+# remove workspace Cargo.toml lines
+test -f Cargo.toml && perl -ni -e 'print unless /^.*(workspace\s*=\s*true|\[lints\])\s*$/' Cargo.toml
 test -f Cargo.toml && cargo check
 
+# add a .gitignore
+echo "target/
+grammars/
+*.wasm" > .gitignore
+
 # commit and push
 git add -u
 git checkout -b "bump_${NEW_VERSION}"