Add line endings indicator in status bar (#39609)

kitt created

Closes #5294

This PR adds a line ending indicator to the status bar, hidden by
default as discussed in
https://github.com/zed-industries/zed/issues/5294.

### Changes

- 8b063a22d8700bed9c93989b9e0f6a064b2e86cf add the indicator and
`status_bar.line_endings_button` setting.

- ~~9926237b709dd4e25ce58d558fd385d63b405f3b changes
`status_bar.line_endings_button` from a boolean to an enum:~~
  <details> <summary> show details </summary>

   - `always`     Always show line endings indicator.
- `non_native` Indicate when line endings do not match the current
platform.
   - `lf_only`    Indicate when using unix-style (LF) line endings only.
- `crlf_only` Indicate when using windows-style (CRLF) line endings
only.
   - `never`      Do not show line endings indicator.
   
I know this many options might be overdoing it, but I was torn between
the pleasant default of `non_native` and the simplicity of `lf_only` /
`crlf_only`.

My thinking was if one is developing on a project which exclusively uses
one line-ending style or the other, it would be nice to be able to
configure no-indicator-in-the-happy-case behavior regardless of the
platform zed is running on. But I'm not really familiar with any
projects that use exclusively CRLF line endings in practice. Is this a
scenario worth supporting or just something I dreamed up?

   </details>

- 01174191e4cf337069e7a31b0f0432ae94c52515 rename the action context for
`line ending: Toggle` -> `line ending selector: Toggle`.
When running the action in the command palette with the old name I felt
surprised to be greeted with an additional menu, with the new name it
feels more predictable (plus now it matches
`language_selector::Toggle`!)

### Future work

Hidden status bar items still get padding, creating inconsistent spacing
(and it kind of stands out where I placed the line-endings button):

<img alt="the gap after the indicator is larger than for other buttons"
src="https://github.com/user-attachments/assets/24a346d4-3ff6-4f7f-bd87-64d453c2441a"
/>

I started a new follow-up PR to address that:
https://github.com/zed-industries/zed/pull/39992

Release Notes:

- Added line ending indicator to the status bar (disabled by default;
enabled by setting `status_bar.line_endings_button` to `true`)

Change summary

assets/settings/default.json                             |  4 
crates/line_ending_selector/src/line_ending_indicator.rs | 70 ++++++++++
crates/line_ending_selector/src/line_ending_selector.rs  | 10 
crates/settings/src/settings_content/workspace.rs        |  4 
crates/text/src/text.rs                                  |  7 +
crates/workspace/src/workspace_settings.rs               |  2 
crates/zed/src/zed.rs                                    |  5 
docs/src/configuring-zed.md                              |  3 
docs/src/visual-customization.md                         |  4 
9 files changed, 101 insertions(+), 8 deletions(-)

Detailed changes

assets/settings/default.json 🔗

@@ -1350,7 +1350,9 @@
     // Whether to show the active language button in the status bar.
     "active_language_button": true,
     // Whether to show the cursor position button in the status bar.
-    "cursor_position_button": true
+    "cursor_position_button": true,
+    // Whether to show active line endings button in the status bar.
+    "line_endings_button": false
   },
   // Settings specific to the terminal
   "terminal": {

crates/line_ending_selector/src/line_ending_indicator.rs 🔗

@@ -0,0 +1,70 @@
+use editor::Editor;
+use gpui::{Entity, Subscription, WeakEntity};
+use language::LineEnding;
+use ui::{Tooltip, prelude::*};
+use workspace::{StatusBarSettings, StatusItemView, item::ItemHandle, item::Settings};
+
+use crate::{LineEndingSelector, Toggle};
+
+#[derive(Default)]
+pub struct LineEndingIndicator {
+    line_ending: Option<LineEnding>,
+    active_editor: Option<WeakEntity<Editor>>,
+    _observe_active_editor: Option<Subscription>,
+}
+
+impl LineEndingIndicator {
+    fn update(&mut self, editor: Entity<Editor>, _: &mut Window, cx: &mut Context<Self>) {
+        self.line_ending = None;
+        self.active_editor = None;
+
+        if let Some((_, buffer, _)) = editor.read(cx).active_excerpt(cx) {
+            let line_ending = buffer.read(cx).line_ending();
+            self.line_ending = Some(line_ending);
+            self.active_editor = Some(editor.downgrade());
+        }
+
+        cx.notify();
+    }
+}
+
+impl Render for LineEndingIndicator {
+    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        if !StatusBarSettings::get_global(cx).line_endings_button {
+            return div();
+        }
+
+        div().when_some(self.line_ending.as_ref(), |el, line_ending| {
+            el.child(
+                Button::new("change-line-ending", line_ending.label())
+                    .label_size(LabelSize::Small)
+                    .on_click(cx.listener(|this, _, window, cx| {
+                        if let Some(editor) = this.active_editor.as_ref() {
+                            LineEndingSelector::toggle(editor, window, cx);
+                        }
+                    }))
+                    .tooltip(|window, cx| {
+                        Tooltip::for_action("Select Line Ending", &Toggle, window, cx)
+                    }),
+            )
+        })
+    }
+}
+
+impl StatusItemView for LineEndingIndicator {
+    fn set_active_pane_item(
+        &mut self,
+        active_pane_item: Option<&dyn ItemHandle>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        if let Some(editor) = active_pane_item.and_then(|item| item.downcast::<Editor>()) {
+            self._observe_active_editor = Some(cx.observe_in(&editor, window, Self::update));
+            self.update(editor, window, cx);
+        } else {
+            self.line_ending = None;
+            self._observe_active_editor = None;
+        }
+        cx.notify();
+    }
+}

crates/line_ending_selector/src/line_ending_selector.rs 🔗

@@ -1,6 +1,9 @@
+mod line_ending_indicator;
+
 use editor::Editor;
 use gpui::{DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Task, WeakEntity, actions};
 use language::{Buffer, LineEnding};
+pub use line_ending_indicator::LineEndingIndicator;
 use picker::{Picker, PickerDelegate};
 use project::Project;
 use std::sync::Arc;
@@ -9,7 +12,7 @@ use util::ResultExt;
 use workspace::ModalView;
 
 actions!(
-    line_ending,
+    line_ending_selector,
     [
         /// Toggles the line ending selector modal.
         Toggle
@@ -172,10 +175,7 @@ impl PickerDelegate for LineEndingSelectorDelegate {
         _: &mut Context<Picker<Self>>,
     ) -> Option<Self::ListItem> {
         let line_ending = self.matches.get(ix)?;
-        let label = match line_ending {
-            LineEnding::Unix => "LF",
-            LineEnding::Windows => "CRLF",
-        };
+        let label = line_ending.label();
 
         let mut list_item = ListItem::new(ix)
             .inset(true)

crates/settings/src/settings_content/workspace.rs 🔗

@@ -380,6 +380,10 @@ pub struct StatusBarSettingsContent {
     ///
     /// Default: true
     pub cursor_position_button: Option<bool>,
+    /// Whether to show active line endings button in the status bar.
+    ///
+    /// Default: false
+    pub line_endings_button: Option<bool>,
 }
 
 #[derive(

crates/text/src/text.rs 🔗

@@ -3267,6 +3267,13 @@ impl LineEnding {
         }
     }
 
+    pub fn label(&self) -> &'static str {
+        match self {
+            LineEnding::Unix => "LF",
+            LineEnding::Windows => "CRLF",
+        }
+    }
+
     pub fn detect(text: &str) -> Self {
         let mut max_ix = cmp::min(text.len(), 1000);
         while !text.is_char_boundary(max_ix) {

crates/workspace/src/workspace_settings.rs 🔗

@@ -126,6 +126,7 @@ pub struct StatusBarSettings {
     pub show: bool,
     pub active_language_button: bool,
     pub cursor_position_button: bool,
+    pub line_endings_button: bool,
 }
 
 impl Settings for StatusBarSettings {
@@ -135,6 +136,7 @@ impl Settings for StatusBarSettings {
             show: status_bar.show.unwrap(),
             active_language_button: status_bar.active_language_button.unwrap(),
             cursor_position_button: status_bar.cursor_position_button.unwrap(),
+            line_endings_button: status_bar.line_endings_button.unwrap(),
         }
     }
 }

crates/zed/src/zed.rs 🔗

@@ -417,6 +417,8 @@ pub fn initialize_workspace(
 
         let cursor_position =
             cx.new(|_| go_to_line::cursor_position::CursorPosition::new(workspace));
+        let line_ending_indicator =
+            cx.new(|_| line_ending_selector::LineEndingIndicator::default());
         workspace.status_bar().update(cx, |status_bar, cx| {
             status_bar.add_left_item(search_button, window, cx);
             status_bar.add_left_item(lsp_button, window, cx);
@@ -425,6 +427,7 @@ pub fn initialize_workspace(
             status_bar.add_right_item(edit_prediction_button, window, cx);
             status_bar.add_right_item(active_buffer_language, window, cx);
             status_bar.add_right_item(active_toolchain_language, window, cx);
+            status_bar.add_right_item(line_ending_indicator, window, cx);
             status_bar.add_right_item(vim_mode_indicator, window, cx);
             status_bar.add_right_item(cursor_position, window, cx);
             status_bar.add_right_item(image_info, window, cx);
@@ -4669,7 +4672,7 @@ mod tests {
                 "keymap_editor",
                 "keystroke_input",
                 "language_selector",
-                "line_ending",
+                "line_ending_selector",
                 "lsp_tool",
                 "markdown",
                 "menu",

docs/src/configuring-zed.md 🔗

@@ -1498,7 +1498,8 @@ Positive `integer` value between 1 and 32. Values outside of this range will be
 ```json [settings]
 "status_bar": {
   "active_language_button": true,
-  "cursor_position_button": true
+  "cursor_position_button": true,
+  "line_endings_button": false
 },
 ```
 

docs/src/visual-customization.md 🔗

@@ -319,6 +319,10 @@ TBD: Centered layout related settings
     // Clicking the button brings up an input for jumping to a line and column.
     // Defaults to true.
     "cursor_position_button": true,
+    // Show/hide a button that displays the buffer's line-ending mode.
+    // Clicking the button brings up the line-ending selector.
+    // Defaults to false.
+    "line_endings_button": false
   },
   "global_lsp_settings": {
     // Show/hide the LSP button in the status bar.