Automatically switch to unified diffs when diff view is narrower than a configurable "minimum split diff width" (#52781)

Max Brunsfeld created

Release Notes:

- The git diff diff view now automatically switches from split mode to
unified mode when the pane is narrower than a configurable minimum
column count. You can configure this via the new
`minimum_split_diff_width` setting.

Change summary

assets/settings/default.json          |   7 +
crates/editor/src/editor_settings.rs  |   2 
crates/editor/src/split.rs            | 104 +++++++++++++---
crates/search/src/buffer_search.rs    | 176 ++++++++++++++--------------
crates/settings/src/vscode_import.rs  |   1 
crates/settings_content/src/editor.rs |   8 +
crates/settings_ui/src/page_data.rs   |  17 ++
7 files changed, 208 insertions(+), 107 deletions(-)

Detailed changes

assets/settings/default.json 🔗

@@ -299,6 +299,13 @@
   //
   // Default: split
   "diff_view_style": "split",
+  // The minimum width (in em-widths) at which the split diff view is used.
+  // When the editor is narrower than this, the diff view automatically
+  // switches to unified mode and switches back when the editor is wide
+  // enough. Set to 0 to disable automatic switching.
+  //
+  // Default: 100
+  "minimum_split_diff_width": 100,
   // Show method signatures in the editor, when inside parentheses.
   "auto_signature_help": false,
   // Whether to show the signature help after completion or a bracket pair inserted.

crates/editor/src/editor_settings.rs 🔗

@@ -60,6 +60,7 @@ pub struct EditorSettings {
     pub completion_menu_scrollbar: ShowScrollbar,
     pub completion_detail_alignment: CompletionDetailAlignment,
     pub diff_view_style: DiffViewStyle,
+    pub minimum_split_diff_width: f32,
 }
 #[derive(Debug, Clone)]
 pub struct Jupyter {
@@ -294,6 +295,7 @@ impl Settings for EditorSettings {
                 .unwrap(),
             completion_detail_alignment: editor.completion_detail_alignment.unwrap(),
             diff_view_style: editor.diff_view_style.unwrap(),
+            minimum_split_diff_width: editor.minimum_split_diff_width.unwrap(),
         }
     }
 }

crates/editor/src/split.rs 🔗

@@ -7,7 +7,8 @@ use buffer_diff::{BufferDiff, BufferDiffSnapshot};
 use collections::HashMap;
 
 use gpui::{
-    Action, AppContext as _, Entity, EventEmitter, Focusable, Font, Subscription, WeakEntity,
+    Action, AppContext as _, Entity, EventEmitter, Focusable, Font, Pixels, Subscription,
+    WeakEntity, canvas,
 };
 use itertools::Itertools;
 use language::{Buffer, Capability, HighlightedText};
@@ -17,7 +18,7 @@ use multi_buffer::{
 };
 use project::Project;
 use rope::Point;
-use settings::DiffViewStyle;
+use settings::{DiffViewStyle, Settings};
 use text::{Bias, BufferId, OffsetRangeExt as _, Patch, ToPoint as _};
 use ui::{
     App, Context, InteractiveElement as _, IntoElement as _, ParentElement as _, Render,
@@ -36,7 +37,7 @@ use workspace::{
 };
 
 use crate::{
-    Autoscroll, Editor, EditorEvent, RenderDiffHunkControlsFn, ToggleSoftWrap,
+    Autoscroll, Editor, EditorEvent, EditorSettings, RenderDiffHunkControlsFn, ToggleSoftWrap,
     actions::{DisableBreakpoint, EditLogBreakpoint, EnableBreakpoint, ToggleBreakpoint},
     display_map::Companion,
 };
@@ -377,6 +378,12 @@ pub struct SplittableEditor {
     workspace: WeakEntity<Workspace>,
     split_state: Entity<SplitEditorState>,
     searched_side: Option<SplitSide>,
+    /// The preferred diff style.
+    diff_view_style: DiffViewStyle,
+    /// True when the current width is below the minimum threshold for split
+    /// mode, regardless of the current diff view style setting.
+    too_narrow_for_split: bool,
+    last_width: Option<Pixels>,
     _subscriptions: Vec<Subscription>,
 }
 
@@ -396,6 +403,10 @@ impl SplittableEditor {
         self.lhs.as_ref().map(|s| &s.editor)
     }
 
+    pub fn diff_view_style(&self) -> DiffViewStyle {
+        self.diff_view_style
+    }
+
     pub fn is_split(&self) -> bool {
         self.lhs.is_some()
     }
@@ -499,12 +510,15 @@ impl SplittableEditor {
         });
         let split_state = cx.new(|cx| SplitEditorState::new(cx));
         Self {
+            diff_view_style: style,
             rhs_editor,
             rhs_multibuffer,
             lhs: None,
             workspace: workspace.downgrade(),
             split_state,
             searched_side: None,
+            too_narrow_for_split: false,
+            last_width: None,
             _subscriptions: subscriptions,
         }
     }
@@ -826,10 +840,19 @@ impl SplittableEditor {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        if self.lhs.is_some() {
-            self.unsplit(window, cx);
-        } else {
-            self.split(window, cx);
+        match self.diff_view_style {
+            DiffViewStyle::Unified => {
+                self.diff_view_style = DiffViewStyle::Split;
+                if !self.too_narrow_for_split {
+                    self.split(window, cx);
+                }
+            }
+            DiffViewStyle::Split => {
+                self.diff_view_style = DiffViewStyle::Unified;
+                if self.is_split() {
+                    self.unsplit(window, cx);
+                }
+            }
         }
     }
 
@@ -1249,6 +1272,35 @@ impl SplittableEditor {
             }
         });
     }
+
+    fn width_changed(&mut self, width: Pixels, window: &mut Window, cx: &mut Context<Self>) {
+        self.last_width = Some(width);
+
+        let min_ems = EditorSettings::get_global(cx).minimum_split_diff_width;
+
+        let style = self.rhs_editor.read(cx).create_style(cx);
+        let font_id = window.text_system().resolve_font(&style.text.font());
+        let font_size = style.text.font_size.to_pixels(window.rem_size());
+        let em_advance = window
+            .text_system()
+            .em_advance(font_id, font_size)
+            .unwrap_or(font_size);
+        let min_width = em_advance * min_ems;
+        let is_split = self.lhs.is_some();
+
+        self.too_narrow_for_split = min_ems > 0.0 && width < min_width;
+
+        match self.diff_view_style {
+            DiffViewStyle::Unified => {}
+            DiffViewStyle::Split => {
+                if self.too_narrow_for_split && is_split {
+                    self.unsplit(window, cx);
+                } else if !self.too_narrow_for_split && !is_split {
+                    self.split(window, cx);
+                }
+            }
+        }
+    }
 }
 
 #[cfg(test)]
@@ -2042,30 +2094,23 @@ impl Focusable for SplittableEditor {
     }
 }
 
-// impl Item for SplittableEditor {
-//     type Event = EditorEvent;
-
-//     fn tab_content_text(&self, detail: usize, cx: &App) -> ui::SharedString {
-//         self.rhs_editor().tab_content_text(detail, cx)
-//     }
-
-//     fn as_searchable(&self, _this: &Entity<Self>, cx: &App) -> Option<Box<dyn workspace::searchable::SearchableItemHandle>> {
-//         Some(Box::new(self.last_selected_editor().clone()))
-//     }
-// }
-
 impl Render for SplittableEditor {
     fn render(
         &mut self,
         _window: &mut ui::Window,
         cx: &mut ui::Context<Self>,
     ) -> impl ui::IntoElement {
-        let inner = if self.lhs.is_some() {
+        let is_split = self.lhs.is_some();
+        let inner = if is_split {
             let style = self.rhs_editor.read(cx).create_style(cx);
             SplitEditorView::new(cx.entity(), style, self.split_state.clone()).into_any_element()
         } else {
             self.rhs_editor.clone().into_any_element()
         };
+
+        let this = cx.entity().downgrade();
+        let last_width = self.last_width;
+
         div()
             .id("splittable-editor")
             .on_action(cx.listener(Self::toggle_split))
@@ -2079,6 +2124,25 @@ impl Render for SplittableEditor {
             .capture_action(cx.listener(Self::toggle_soft_wrap))
             .size_full()
             .child(inner)
+            .child(
+                canvas(
+                    move |bounds, window, cx| {
+                        let width = bounds.size.width;
+                        if last_width == Some(width) {
+                            return;
+                        }
+                        window.defer(cx, move |window, cx| {
+                            this.update(cx, |this, cx| {
+                                this.width_changed(width, window, cx);
+                            })
+                            .ok();
+                        });
+                    },
+                    |_, _, _, _| {},
+                )
+                .absolute()
+                .size_full(),
+            )
     }
 }
 

crates/search/src/buffer_search.rs 🔗

@@ -20,9 +20,9 @@ use editor::{
 };
 use futures::channel::oneshot;
 use gpui::{
-    App, ClickEvent, Context, Entity, EventEmitter, Focusable, InteractiveElement as _,
-    IntoElement, KeyContext, ParentElement as _, Render, ScrollHandle, Styled, Subscription, Task,
-    WeakEntity, Window, div,
+    Action as _, App, ClickEvent, Context, Entity, EventEmitter, Focusable,
+    InteractiveElement as _, IntoElement, KeyContext, ParentElement as _, Render, ScrollHandle,
+    Styled, Subscription, Task, WeakEntity, Window, div,
 };
 use language::{Language, LanguageRegistry};
 use project::{
@@ -33,7 +33,9 @@ use project::{
 use fs::Fs;
 use settings::{DiffViewStyle, Settings, update_settings_file};
 use std::{any::TypeId, sync::Arc};
-use zed_actions::{outline::ToggleOutline, workspace::CopyPath, workspace::CopyRelativePath};
+use zed_actions::{
+    OpenSettingsAt, outline::ToggleOutline, workspace::CopyPath, workspace::CopyRelativePath,
+};
 
 use ui::{
     BASE_REM_SIZE_IN_PX, IconButtonShape, PlatformStyle, TextSize, Tooltip, prelude::*,
@@ -110,96 +112,97 @@ impl Render for BufferSearchBar {
                 .as_ref()
                 .and_then(|weak| weak.upgrade())
                 .map(|splittable_editor| {
-                    let is_split = splittable_editor.read(cx).is_split();
+                    let editor_ref = splittable_editor.read(cx);
+                    let diff_view_style = editor_ref.diff_view_style();
+                    let is_split = editor_ref.is_split();
+                    let min_columns =
+                        EditorSettings::get_global(cx).minimum_split_diff_width as u32;
+
+                    let mut split_button = IconButton::new("diff-split", IconName::DiffSplit)
+                        .shape(IconButtonShape::Square)
+                        .tooltip(Tooltip::element(move |_, cx| {
+                            let message = if min_columns == 0 {
+                                SharedString::from("Split")
+                            } else {
+                                format!("Split when wider than {} columns", min_columns).into()
+                            };
+
+                            v_flex()
+                                .child(message)
+                                .child(
+                                    h_flex()
+                                        .gap_0p5()
+                                        .text_ui_sm(cx)
+                                        .text_color(Color::Muted.color(cx))
+                                        .children(render_modifiers(
+                                            &gpui::Modifiers::secondary_key(),
+                                            PlatformStyle::platform(),
+                                            None,
+                                            Some(TextSize::Small.rems(cx).into()),
+                                            false,
+                                        ))
+                                        .child("click to change min width"),
+                                )
+                                .into_any()
+                        }))
+                        .on_click({
+                            let splittable_editor = splittable_editor.downgrade();
+                            move |_, window, cx| {
+                                if window.modifiers().secondary() {
+                                    window.dispatch_action(
+                                        OpenSettingsAt {
+                                            path: "minimum_split_diff_width".to_string(),
+                                        }
+                                        .boxed_clone(),
+                                        cx,
+                                    );
+                                } else {
+                                    update_settings_file(
+                                        <dyn Fs>::global(cx),
+                                        cx,
+                                        |settings, _| {
+                                            settings.editor.diff_view_style =
+                                                Some(DiffViewStyle::Split);
+                                        },
+                                    );
+                                    if diff_view_style == DiffViewStyle::Unified {
+                                        splittable_editor
+                                            .update(cx, |editor, cx| {
+                                                editor.toggle_split(&ToggleSplitDiff, window, cx);
+                                            })
+                                            .ok();
+                                    }
+                                }
+                            }
+                        });
+
+                    if diff_view_style == DiffViewStyle::Split {
+                        if !is_split {
+                            split_button = split_button.icon_color(Color::Disabled)
+                        } else {
+                            split_button = split_button.toggle_state(true)
+                        }
+                    }
+
                     h_flex()
                         .gap_1()
                         .child(
                             IconButton::new("diff-unified", IconName::DiffUnified)
                                 .shape(IconButtonShape::Square)
-                                .toggle_state(!is_split)
-                                .tooltip(Tooltip::element(move |_, cx| {
-                                    v_flex()
-                                        .child("Unified")
-                                        .child(
-                                            h_flex()
-                                                .gap_0p5()
-                                                .text_ui_sm(cx)
-                                                .text_color(Color::Muted.color(cx))
-                                                .children(render_modifiers(
-                                                    &gpui::Modifiers::secondary_key(),
-                                                    PlatformStyle::platform(),
-                                                    None,
-                                                    Some(TextSize::Small.rems(cx).into()),
-                                                    false,
-                                                ))
-                                                .child("click to set as default"),
-                                        )
-                                        .into_any()
-                                }))
-                                .on_click({
-                                    let splittable_editor = splittable_editor.downgrade();
-                                    move |_, window, cx| {
-                                        if window.modifiers().secondary() {
-                                            update_settings_file(
-                                                <dyn Fs>::global(cx),
-                                                cx,
-                                                |settings, _| {
-                                                    settings.editor.diff_view_style =
-                                                        Some(DiffViewStyle::Unified);
-                                                },
-                                            );
-                                        }
-                                        if is_split {
-                                            splittable_editor
-                                                .update(cx, |editor, cx| {
-                                                    editor.toggle_split(
-                                                        &ToggleSplitDiff,
-                                                        window,
-                                                        cx,
-                                                    );
-                                                })
-                                                .ok();
-                                        }
-                                    }
-                                }),
-                        )
-                        .child(
-                            IconButton::new("diff-split", IconName::DiffSplit)
-                                .shape(IconButtonShape::Square)
-                                .toggle_state(is_split)
-                                .tooltip(Tooltip::element(move |_, cx| {
-                                    v_flex()
-                                        .child("Split")
-                                        .child(
-                                            h_flex()
-                                                .gap_0p5()
-                                                .text_ui_sm(cx)
-                                                .text_color(Color::Muted.color(cx))
-                                                .children(render_modifiers(
-                                                    &gpui::Modifiers::secondary_key(),
-                                                    PlatformStyle::platform(),
-                                                    None,
-                                                    Some(TextSize::Small.rems(cx).into()),
-                                                    false,
-                                                ))
-                                                .child("click to set as default"),
-                                        )
-                                        .into_any()
-                                }))
+                                .toggle_state(diff_view_style == DiffViewStyle::Unified)
+                                .tooltip(Tooltip::text("Unified"))
                                 .on_click({
                                     let splittable_editor = splittable_editor.downgrade();
                                     move |_, window, cx| {
-                                        if window.modifiers().secondary() {
-                                            update_settings_file(
-                                                <dyn Fs>::global(cx),
-                                                cx,
-                                                |settings, _| {
-                                                    settings.editor.diff_view_style =
-                                                        Some(DiffViewStyle::Split);
-                                                },
-                                            );
-                                        }
-                                        if !is_split {
+                                        update_settings_file(
+                                            <dyn Fs>::global(cx),
+                                            cx,
+                                            |settings, _| {
+                                                settings.editor.diff_view_style =
+                                                    Some(DiffViewStyle::Unified);
+                                            },
+                                        );
+                                        if diff_view_style == DiffViewStyle::Split {
                                             splittable_editor
                                                 .update(cx, |editor, cx| {
                                                     editor.toggle_split(
@@ -213,6 +216,7 @@ impl Render for BufferSearchBar {
                                     }
                                 }),
                         )
+                        .child(split_button)
                 })
         } else {
             None

crates/settings/src/vscode_import.rs 🔗

@@ -308,6 +308,7 @@ impl VsCodeSettings {
             completion_menu_scrollbar: None,
             completion_detail_alignment: None,
             diff_view_style: None,
+            minimum_split_diff_width: None,
         }
     }
 

crates/settings_content/src/editor.rs 🔗

@@ -226,6 +226,14 @@ pub struct EditorSettingsContent {
     ///
     /// Default: split
     pub diff_view_style: Option<DiffViewStyle>,
+
+    /// The minimum width (in em-widths) at which the split diff view is used.
+    /// When the editor is narrower than this, the diff view automatically
+    /// switches to unified mode and switches back when the editor is wide
+    /// enough. Set to 0 to disable automatic switching.
+    ///
+    /// Default: 100
+    pub minimum_split_diff_width: Option<f32>,
 }
 
 #[derive(

crates/settings_ui/src/page_data.rs 🔗

@@ -1474,7 +1474,7 @@ fn editor_page() -> SettingsPage {
         ]
     }
 
-    fn multibuffer_section() -> [SettingsPageItem; 6] {
+    fn multibuffer_section() -> [SettingsPageItem; 7] {
         [
             SettingsPageItem::SectionHeader("Multibuffer"),
             SettingsPageItem::SettingItem(SettingItem {
@@ -1554,6 +1554,21 @@ fn editor_page() -> SettingsPage {
                 metadata: None,
                 files: USER,
             }),
+            SettingsPageItem::SettingItem(SettingItem {
+                title: "Minimum Split Diff Width",
+                description: "The minimum width (in columns) at which the split diff view is used. When the editor is narrower, the diff view automatically switches to unified mode. Set to 0 to disable.",
+                field: Box::new(SettingField {
+                    json_path: Some("minimum_split_diff_width"),
+                    pick: |settings_content| {
+                        settings_content.editor.minimum_split_diff_width.as_ref()
+                    },
+                    write: |settings_content, value| {
+                        settings_content.editor.minimum_split_diff_width = value;
+                    },
+                }),
+                metadata: None,
+                files: USER,
+            }),
         ]
     }