git_ui: Show full files by default in solo diffs (#60989)

# Objective

Make solo diff views show the full file by default while preserving an
easy way to focus on changed hunks. This addresses a recurring request
across issues and pull requests for full-file context in single-file Git
diffs.

## Solution

- Initialize the solo diff with a singleton multibuffer so the entire
file is visible on open.
- Keep the existing toolbar toggle available for switching to
changes-only excerpts.
- Use the singleton path key when replacing excerpts so toggling remains
reliable.
- Preserve Git change indicators in the scrollbar; the editor generates
those markers for singleton buffers.

## Testing

- Ran `cargo fmt -p git_ui`.
- Ran `git diff --check`.
- Ran `cargo check -p git_ui --no-default-features` successfully.
- Manually reviewed the full-file and changes-only state transitions in
`SoloDiffView`.

## 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

Before, solo diffs opened with only changed hunks and surrounding
context. They now open with the complete file, retain Git change
indicators in the scrollbar, and can still be toggled to changes-only
from the toolbar.

---

Release Notes:

- Improved solo diffs to show the full file by default while retaining
Git change indicators in the scrollbar.
This commit is contained in:
Sathwik Chirivelli authored and GitHub committed 2026-07-20 21:31:42 +00:00
1 parent 1b67fdc7f4
commit fcd85e1bfb
8 files changed
+178 -33

No files matched your search

+4
View File
@@ -1690,6 +1690,10 @@
"branch_picker": {
"show_author_name": true,
},
"file_diff": {
// Whether newly opened file diffs show the full file instead of changes only.
"show_full_file": true,
},
// How git hunks are displayed visually in the editor.
// This setting can take two values:
//
+10
View File
@@ -198,6 +198,16 @@ impl Editor {
cx.notify();
}
pub fn set_allow_git_diff_scrollbar_markers(&mut self, allow: bool, cx: &mut Context<Self>) {
if self.allow_git_diff_scrollbar_markers != allow {
self.allow_git_diff_scrollbar_markers = allow;
self.scrollbar_marker_state.dirty = true;
self.scrollbar_marker_state.markers = Default::default();
self.scrollbar_marker_state.pending_refresh = None;
cx.notify();
}
}
pub fn set_show_code_actions(&mut self, show_code_actions: bool, cx: &mut Context<Self>) {
self.show_code_actions = Some(show_code_actions);
cx.notify();
+2
View File
@@ -999,6 +999,7 @@ pub struct Editor {
background_highlights: HashMap<HighlightKey, BackgroundHighlight>,
navigation_overlays: HashMap<NavigationOverlayKey, Arc<[NavigationTargetOverlay]>>,
gutter_highlights: TypeIdHashMap<GutterHighlight>,
allow_git_diff_scrollbar_markers: bool,
scrollbar_marker_state: ScrollbarMarkerState,
active_indent_guides_state: ActiveIndentGuidesState,
nav_history: Option<ItemNavHistory>,
@@ -2302,6 +2303,7 @@ impl Editor {
background_highlights: HashMap::default(),
navigation_overlays: HashMap::default(),
gutter_highlights: Default::default(),
allow_git_diff_scrollbar_markers: false,
scrollbar_marker_state: ScrollbarMarkerState::default(),
active_indent_guides_state: ActiveIndentGuidesState::default(),
nav_history: None,
+13
View File
@@ -69,6 +69,15 @@ pub struct EditorSettings {
pub completion_menu_item_kind: CompletionMenuItemKind,
pub diff_view_style: DiffViewStyle,
pub minimum_split_diff_width: f32,
pub file_diff: FileDiffSettings,
}
#[derive(Clone, Copy, Debug)]
pub struct FileDiffSettings {
/// Whether newly opened file diffs show the full file instead of changes only.
///
/// Default: true
pub show_full_file: bool,
}
#[derive(Debug, Clone)]
pub struct Jupyter {
@@ -204,6 +213,7 @@ impl Settings for EditorSettings {
let search = editor.search.unwrap();
let drag_and_drop_selection = editor.drag_and_drop_selection.unwrap();
let sticky_scroll = editor.sticky_scroll.unwrap();
let file_diff = content.git.as_ref().unwrap().file_diff.unwrap();
Self {
cursor_blink: editor.cursor_blink.unwrap(),
cursor_shape: editor.cursor_shape.map(Into::into),
@@ -313,6 +323,9 @@ impl Settings for EditorSettings {
completion_menu_item_kind: editor.completion_menu_item_kind.unwrap(),
diff_view_style: editor.diff_view_style.unwrap(),
minimum_split_diff_width: editor.minimum_split_diff_width.unwrap(),
file_diff: FileDiffSettings {
show_full_file: file_diff.show_full_file.unwrap(),
},
}
}
}
+21 -9
View File
@@ -1315,8 +1315,10 @@ impl EditorElement {
ShowScrollbar::Auto => {
let editor = self.editor.read(cx);
let is_singleton = editor.buffer_kind(cx) == ItemBufferKind::Singleton;
let supports_git_diff_markers =
is_singleton || editor.allow_git_diff_scrollbar_markers;
// Git
(is_singleton && scrollbar_settings.git_diff && snapshot.buffer_snapshot().has_diff_hunks())
(supports_git_diff_markers && scrollbar_settings.git_diff && snapshot.buffer_snapshot().has_diff_hunks())
||
// Buffer Search Results
(is_singleton && scrollbar_settings.search_results && editor.has_background_highlights(HighlightKey::BufferSearchHighlights))
@@ -6001,10 +6003,19 @@ impl EditorElement {
cx: &mut App,
) {
self.editor.update(cx, |editor, cx| {
if editor.buffer_kind(cx) != ItemBufferKind::Singleton
|| !editor
.scrollbar_marker_state
.should_refresh(scrollbar_layout.hitbox.size)
let is_singleton = editor.buffer_kind(cx) == ItemBufferKind::Singleton;
let scrollbar_settings = EditorSettings::get_global(cx).scrollbar;
let show_git_diff_markers = scrollbar_settings.git_diff
&& (is_singleton || editor.allow_git_diff_scrollbar_markers);
if !is_singleton && !show_git_diff_markers {
editor.scrollbar_marker_state.dirty = true;
editor.scrollbar_marker_state.markers = Default::default();
editor.scrollbar_marker_state.pending_refresh = None;
return;
}
if !editor
.scrollbar_marker_state
.should_refresh(scrollbar_layout.hitbox.size)
{
return;
}
@@ -6013,7 +6024,6 @@ impl EditorElement {
let background_highlights = editor.background_highlights.clone();
let snapshot = layout.position_map.snapshot.clone();
let theme = cx.theme().clone();
let scrollbar_settings = EditorSettings::get_global(cx).scrollbar;
editor.scrollbar_marker_state.dirty = false;
editor.scrollbar_marker_state.pending_refresh =
@@ -6023,7 +6033,7 @@ impl EditorElement {
.background_spawn(async move {
let max_point = snapshot.display_snapshot.buffer_snapshot().max_point();
let mut marker_quads = Vec::new();
if scrollbar_settings.git_diff {
if show_git_diff_markers {
let marker_row_ranges =
snapshot.buffer_snapshot().diff_hunks().map(|hunk| {
let start_display_row =
@@ -6062,7 +6072,7 @@ impl EditorElement {
}
for (background_highlight_id, (_, background_ranges)) in
background_highlights.iter()
background_highlights.iter().filter(|_| is_singleton)
{
let is_search_highlights = *background_highlight_id
== HighlightKey::BufferSearchHighlights;
@@ -6099,7 +6109,9 @@ impl EditorElement {
}
}
if scrollbar_settings.diagnostics != ScrollbarDiagnostics::None {
if is_singleton
&& scrollbar_settings.diagnostics != ScrollbarDiagnostics::None
{
let diagnostics = snapshot
.buffer_snapshot()
.diagnostics_in_range::<Point>(Point::zero()..max_point)
+82 -24
View File
@@ -129,18 +129,9 @@ impl SoloDiffView {
cx: &mut Context<Self>,
) -> Self {
let repository_id = repository.read(cx).id;
let multibuffer = cx.new(|cx| {
let mut multibuffer = MultiBuffer::without_headers(buffer.read(cx).capability());
multibuffer.set_excerpts_for_path(
PathKey::for_buffer(&buffer, cx),
buffer.clone(),
Self::hunk_ranges(&buffer, &diff, cx),
excerpt_context_lines(cx),
cx,
);
multibuffer.add_diff(diff.clone(), cx);
multibuffer
});
let showing_full_file = EditorSettings::get_global(cx).file_diff.show_full_file;
let multibuffer = cx
.new(|cx| Self::build_multibuffer(buffer.clone(), diff.clone(), showing_full_file, cx));
let editor = cx.new(|cx| {
let editor = SplittableEditor::new(
EditorSettings::get_global(cx).diff_view_style,
@@ -152,6 +143,7 @@ impl SoloDiffView {
);
editor.rhs_editor().update(cx, |editor, cx| {
editor.set_should_serialize(false, cx);
editor.set_allow_git_diff_scrollbar_markers(showing_full_file, cx);
let snapshot = editor.snapshot(window, cx);
editor.go_to_hunk_before_or_after_position(
&snapshot,
@@ -188,11 +180,42 @@ impl SoloDiffView {
diff,
editor,
workspace: workspace.downgrade(),
showing_full_file: false,
showing_full_file,
_settings_subscription: settings_subscription,
}
}
fn build_multibuffer(
buffer: Entity<Buffer>,
diff: Entity<buffer_diff::BufferDiff>,
showing_full_file: bool,
cx: &mut Context<MultiBuffer>,
) -> MultiBuffer {
let (ranges, context_line_count) =
Self::excerpt_ranges(&buffer, &diff, showing_full_file, cx);
let mut multibuffer = MultiBuffer::without_headers(buffer.read(cx).capability());
multibuffer.set_excerpts_for_buffer(buffer, ranges, context_line_count, cx);
multibuffer.add_diff(diff, cx);
multibuffer
}
fn excerpt_ranges(
buffer: &Entity<Buffer>,
diff: &Entity<buffer_diff::BufferDiff>,
showing_full_file: bool,
cx: &App,
) -> (Vec<Range<Point>>, u32) {
if showing_full_file {
(vec![Point::zero()..buffer.read(cx).max_point()], 0)
} else {
(
Self::hunk_ranges(buffer, diff, cx),
excerpt_context_lines(cx),
)
}
}
fn hunk_ranges(
buffer: &Entity<Buffer>,
diff: &Entity<buffer_diff::BufferDiff>,
@@ -215,17 +238,8 @@ impl SoloDiffView {
return;
}
let ranges = if showing_full_file {
let buffer = self.buffer.read(cx);
vec![Point::zero()..buffer.max_point()]
} else {
Self::hunk_ranges(&self.buffer, &self.diff, cx)
};
let context_line_count = if showing_full_file {
0
} else {
excerpt_context_lines(cx)
};
let (ranges, context_line_count) =
Self::excerpt_ranges(&self.buffer, &self.diff, showing_full_file, cx);
self.editor.update(cx, |editor, cx| {
let path = PathKey::for_buffer(&self.buffer, cx);
@@ -238,6 +252,9 @@ impl SoloDiffView {
self.diff.clone(),
cx,
);
editor.rhs_editor().update(cx, |editor, cx| {
editor.set_allow_git_diff_scrollbar_markers(showing_full_file, cx);
});
});
self.showing_full_file = showing_full_file;
@@ -703,6 +720,47 @@ struct SoloDiffButtonStates {
unstage_file: bool,
}
#[cfg(test)]
mod tests {
use super::*;
use gpui::TestAppContext;
use multi_buffer::MultiBufferRow;
#[gpui::test]
fn test_changes_only_multibuffer_has_one_buffer_and_expand_controls(cx: &mut TestAppContext) {
let base_text = (0..20)
.map(|line| format!("line {line}"))
.collect::<Vec<_>>()
.join("\n");
let current_text = base_text.replace("line 10", "changed line 10");
let buffer = cx.new(|cx| Buffer::local(current_text, cx));
let diff = cx.new(|cx| {
buffer_diff::BufferDiff::new_with_base_text(
&base_text,
&buffer.read(cx).text_snapshot(),
cx,
)
});
let multibuffer = cx.new(|cx| SoloDiffView::build_multibuffer(buffer, diff, false, cx));
let (is_singleton, buffer_count, has_expand_controls) =
multibuffer.update(cx, |multibuffer, cx| {
(
multibuffer.is_singleton(),
multibuffer.snapshot(cx).all_buffer_ids().count(),
multibuffer
.snapshot(cx)
.row_infos(MultiBufferRow(0))
.any(|row| row.expand_info.is_some()),
)
});
assert!(!is_singleton);
assert_eq!(buffer_count, 1);
assert!(has_expand_controls);
}
}
impl Render for SoloDiffGitToolbar {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let Some(solo_diff) = self.solo_diff() else {
+12
View File
@@ -540,6 +540,8 @@ pub struct GitSettings {
///
/// Default: on
pub branch_picker: Option<BranchPickerSettingsContent>,
/// File diff settings.
pub file_diff: Option<FileDiffSettingsContent>,
/// How hunks are displayed visually in the editor.
///
/// Default: staged_hollow
@@ -691,6 +693,16 @@ pub struct BranchPickerSettingsContent {
pub show_author_name: Option<bool>,
}
#[with_fallible_options]
#[derive(Clone, Copy, PartialEq, Debug, Default, Serialize, Deserialize, JsonSchema, MergeFrom)]
#[serde(rename_all = "snake_case")]
pub struct FileDiffSettingsContent {
/// Whether newly opened file diffs show the full file instead of changes only.
///
/// Default: true
pub show_full_file: Option<bool>,
}
#[derive(
Clone,
Copy,
+34
View File
@@ -7875,6 +7875,39 @@ fn version_control_page() -> SettingsPage {
]
}
fn file_diff_section() -> [SettingsPageItem; 2] {
[
SettingsPageItem::SectionHeader("File Diff"),
SettingsPageItem::SettingItem(SettingItem {
title: "Show Full File by Default",
description: "Whether newly opened file diffs show the full file instead of changes only.",
field: Box::new(SettingField {
organization_override: None,
json_path: Some("git.file_diff.show_full_file"),
pick: |settings_content| {
settings_content
.git
.as_ref()?
.file_diff
.as_ref()?
.show_full_file
.as_ref()
},
write: |settings_content, value, _| {
settings_content
.git
.get_or_insert_default()
.file_diff
.get_or_insert_default()
.show_full_file = value;
},
}),
metadata: None,
files: USER,
}),
]
}
SettingsPage {
title: "Version Control",
items: concat_sections![
@@ -7883,6 +7916,7 @@ fn version_control_page() -> SettingsPage {
inline_git_blame_section(),
git_blame_view_section(),
branch_picker_section(),
file_diff_section(),
git_hunks_section(),
],
}