ui: Add buttons to switch diff view when diffing buffers (#64529)

# Objective

- This PR enables users to switch between unified/split diff views when
comparing buffers.
- Fixes #64419 .

## Solution

- The unified/split toggle (`DiffStyleControls`) is normally surfaced
automatically by `BufferSearchBar`, which detects any active item
exposing a `SplittableEditor`. That auto-detection is gated to
multi-buffer views, since it's paired with an expand/collapse-all-hunks
control that only makes sense across multiple files. `FileDiffView`
(used by `zed --diff <old> <new>`) is a singleton buffer, so it's
intentionally excluded from that generic path.
- Git's own single-file diff view,
[`SoloDiffView`](https://github.com/zed-industries/zed/blob/f2e33599724432f329f9e23b353a8fb28cd6ece3/crates/git_ui/src/solo_diff_view.rs#L52),
hits the same limitation and works around it with a small, dedicated
`ToolbarItemView` (`SoloDiffStyleToolbar`) that renders
`DiffStyleControls` directly, independent of `BufferSearchBar`.
- This PR follows the same pattern: a new `FileDiffStyleToolbar` (in
`git_ui_core::file_diff_view`) activates whenever a `FileDiffView` is
the focused pane item, and renders just the unified/split toggle, no
expand/collapse or file-status controls, since neither concept applies
to a plain two-file diff. It's registered in `initialize_pane` alongside
the existing git toolbars.


## Testing

- Did you test these changes? If so, how?
Ran `cargo test -p git_ui_core`, all passed. I've also attached the
results of manual testing in the Showcase section below.
- Are there any parts that need more testing?
I think more tests can be added.

## Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content adheres to Zed's UI standards
([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
and
[icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md)
guidelines)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

## Showcase

Adds a unified/split toggle to the diff view opened via `zed --diff
<old> <new>`, matching the toggle already available in git's diff views.

<details>
  <summary>Click to view before/after</summary>

**Before**:
<img width="1920" height="1080" alt="screenshot_20260921_020610"
src="https://github.com/user-attachments/assets/92cbcf75-0cac-4555-8d2d-ce927064515f"
/>

**After**:
<img width="1920" height="1080" alt="screenshot_20260921_020355"
src="https://github.com/user-attachments/assets/56f9ebc6-11df-41cc-a44d-99bfa8caa130"
/>

</details>

---

Release Notes:

- Added a unified/split toggle to the diff view opened via `zed --diff
<old> <new>` command.

---------

Co-authored-by: Kirill Bulatov <mail4score@gmail.com>
Co-authored-by: Kirill Bulatov <kirill@zed.dev>
This commit is contained in:
authored and GitHub committed 2026-09-21 11:17:46 +00:00
1 parent c1053ff5fd
commit 14d3c2b61c
3 files changed
+111 -9

No files matched your search

+102 -7
View File
@@ -3,13 +3,13 @@
use anyhow::Result;
use buffer_diff::BufferDiff;
use editor::{
Editor, EditorEvent, EditorSettings, HiddenUnstagedDiffHunkRenderer, MultiBuffer,
SplittableEditor,
DiffStyleControls, Editor, EditorEvent, EditorSettings, HiddenUnstagedDiffHunkRenderer,
MultiBuffer, SplittableEditor,
};
use futures::{FutureExt, select_biased};
use gpui::{
App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, FocusHandle, Focusable, Font,
IntoElement, Render, Task, WeakEntity, Window,
App, AppContext as _, AsyncApp, Context, Empty, Entity, EventEmitter, FocusHandle, Focusable,
Font, IntoElement, Render, Task, WeakEntity, Window,
};
use language::{Buffer, HighlightedText, Point};
use project::{Project, ProjectPath};
@@ -21,10 +21,11 @@ use std::{
sync::Arc,
time::Duration,
};
use ui::{Color, Icon, IconName, SharedString};
use ui::{Color, Icon, IconName, SharedString, prelude::*};
use util::paths::PathExt as _;
use workspace::{
Item, ItemHandle as _, ItemNavHistory, ToolbarItemLocation, Workspace,
Item, ItemHandle, ItemNavHistory, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView,
Workspace,
item::{ItemEvent, SaveOptions},
searchable::SearchableItemHandle,
};
@@ -128,6 +129,22 @@ impl FileDiffView {
splittable
});
let include_root = project.read(cx).visible_worktrees(cx).count() > 1;
let breadcrumb_path = |buffer: &Entity<Buffer>| {
buffer
.read(cx)
.snapshot()
.resolve_file_path(include_root, cx)
.unwrap_or_else(|| MultiBuffer::DEFAULT_TITLE.to_string())
};
let old_path = breadcrumb_path(&old_buffer);
let new_path = breadcrumb_path(&new_buffer);
editor.update(cx, |editor, cx| {
editor.rhs_editor().update(cx, |editor, _| {
editor.set_breadcrumb_header(format!("{old_path} ↔ {new_path}"));
});
});
let (buffer_changes_tx, mut buffer_changes_rx) = watch::channel(());
// The buffers' languages may load after the diff was built, e.g. when
@@ -394,6 +411,57 @@ impl Render for FileDiffView {
}
}
pub struct FileDiffStyleToolbar {
file_diff: Option<WeakEntity<FileDiffView>>,
}
impl FileDiffStyleToolbar {
pub fn new(_: &mut Context<Self>) -> Self {
Self { file_diff: None }
}
fn file_diff(&self) -> Option<Entity<FileDiffView>> {
self.file_diff.as_ref()?.upgrade()
}
}
impl EventEmitter<ToolbarItemEvent> for FileDiffStyleToolbar {}
impl ToolbarItemView for FileDiffStyleToolbar {
fn set_active_pane_item(
&mut self,
active_pane_item: Option<&dyn ItemHandle>,
_: &mut Window,
cx: &mut Context<Self>,
) -> ToolbarItemLocation {
self.file_diff = active_pane_item
.and_then(|item| item.act_as::<FileDiffView>(cx))
.map(|entity| entity.downgrade());
if self.file_diff.is_some() {
ToolbarItemLocation::PrimaryLeft
} else {
ToolbarItemLocation::Hidden
}
}
}
impl Render for FileDiffStyleToolbar {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let Some(file_diff) = self.file_diff() else {
return Empty.into_any_element();
};
let editor = file_diff.read(cx).editor.clone();
h_flex()
.pl_0p5()
.gap_1()
.child(DiffStyleControls::new(editor))
.into_any_element()
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -560,7 +628,34 @@ mod tests {
path!("test/new_file.txt")
)
);
})
let (breadcrumbs, _) = diff_view.breadcrumbs(cx).unwrap();
assert_eq!(
breadcrumbs
.iter()
.map(|crumb| crumb.text.as_ref())
.collect::<Vec<_>>(),
vec!["old_file.txt ↔ new_file.txt"]
);
});
let toolbar = cx.new(FileDiffStyleToolbar::new);
let location = toolbar.update_in(cx, |toolbar, window, cx| {
toolbar.set_active_pane_item(Some(&diff_view), window, cx)
});
assert_eq!(location, ToolbarItemLocation::PrimaryLeft);
assert_eq!(
toolbar.read_with(cx, |toolbar, _| toolbar.file_diff()),
Some(diff_view.clone())
);
let location = toolbar.update_in(cx, |toolbar, window, cx| {
toolbar.set_active_pane_item(None, window, cx)
});
assert_eq!(location, ToolbarItemLocation::Hidden);
assert_eq!(
toolbar.read_with(cx, |toolbar, _| toolbar.file_diff()),
None
);
}
#[gpui::test]
+3
View File
@@ -38,6 +38,7 @@ use git_ui::project_diff::ProjectDiffToolbar;
use git_ui::solo_diff_view::{SoloDiffGitToolbar, SoloDiffStyleToolbar};
use git_ui::staged_diff::StagedDiffToolbar;
use git_ui::unstaged_diff::UnstagedDiffToolbar;
use git_ui_core::file_diff_view::FileDiffStyleToolbar;
use gpui::{
Action, App, AppContext as _, AsyncWindowContext, ClipboardItem, Context, DismissEvent,
Element, Entity, FocusHandle, Focusable, Image, ImageFormat, KeyBinding, ParentElement,
@@ -1466,6 +1467,8 @@ fn initialize_pane(
toolbar.add_item(multibuffer_hint, window, cx);
let solo_diff_style_toolbar = cx.new(SoloDiffStyleToolbar::new);
toolbar.add_item(solo_diff_style_toolbar, window, cx);
let file_diff_style_toolbar = cx.new(FileDiffStyleToolbar::new);
toolbar.add_item(file_diff_style_toolbar, window, cx);
let breadcrumbs = cx.new(|_| Breadcrumbs::new());
toolbar.add_item(breadcrumbs, window, cx);
let buffer_search_bar = cx.new(|cx| {
+6 -2
View File
@@ -4,6 +4,8 @@ set -euo pipefail
source script/lib/blob-store.sh
build_flag="--release"
zed_features=""
remote_server_features=""
target_dir="release"
open_result=false
local_install=false
@@ -34,6 +36,8 @@ do
d)
export CARGO_INCREMENTAL=true
build_flag="";
zed_features="--features util/debug-embed"
remote_server_features="--features debug-embed"
target_dir="debug"
;;
i) local_install=true;;
@@ -87,10 +91,10 @@ script/generate-licenses
rustup target add $target_triple
echo "Compiling zed binaries"
cargo --config .cargo/bundle-config.toml build ${build_flag} --package zed --package cli --target $target_triple
cargo --config .cargo/bundle-config.toml build ${build_flag} ${zed_features} --package zed --package cli --target $target_triple
# Build remote_server in separate invocation to prevent feature unification from other crates
# from influencing dynamic libraries required by it.
cargo --config .cargo/bundle-config.toml build ${build_flag} --package remote_server --target $target_triple
cargo --config .cargo/bundle-config.toml build ${build_flag} ${remote_server_features} --package remote_server --target $target_triple
echo "Creating application bundle"
pushd crates/zed