Add line ending selector (#35392)

Matin Aniss and Conrad Irwin created

Partially addresses this issue #5294

Adds a selector between `LF` and `CRLF` for the buffer's line endings,
the checkmark denotes the currently selected line ending.

Selector
<img width="487" height="66" alt="image"
src="https://github.com/user-attachments/assets/13f2480f-4d2d-4afe-adf5-385aeb421393"
/>

Release Notes:

- Added line ending selector.

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>

Change summary

Cargo.lock                                              |  16 
Cargo.toml                                              |   2 
crates/language/src/buffer.rs                           |  35 +
crates/language/src/buffer_tests.rs                     |  72 ++++
crates/language/src/proto.rs                            |  25 +
crates/line_ending_selector/Cargo.toml                  |  24 +
crates/line_ending_selector/LICENSE-GPL                 |   1 
crates/line_ending_selector/src/line_ending_selector.rs | 192 +++++++++++
crates/proto/proto/buffer.proto                         |   7 
crates/zed/Cargo.toml                                   |   1 
crates/zed/src/main.rs                                  |   1 
crates/zed/src/zed.rs                                   |   1 
12 files changed, 376 insertions(+), 1 deletion(-)

Detailed changes

Cargo.lock πŸ”—

@@ -9518,6 +9518,21 @@ dependencies = [
  "vcpkg",
 ]
 
+[[package]]
+name = "line_ending_selector"
+version = "0.1.0"
+dependencies = [
+ "editor",
+ "gpui",
+ "language",
+ "picker",
+ "project",
+ "ui",
+ "util",
+ "workspace",
+ "workspace-hack",
+]
+
 [[package]]
 name = "link-cplusplus"
 version = "1.0.10"
@@ -20492,6 +20507,7 @@ dependencies = [
  "language_tools",
  "languages",
  "libc",
+ "line_ending_selector",
  "livekit_client",
  "log",
  "markdown",

Cargo.toml πŸ”—

@@ -97,6 +97,7 @@ members = [
     "crates/language_selector",
     "crates/language_tools",
     "crates/languages",
+    "crates/line_ending_selector",
     "crates/livekit_api",
     "crates/livekit_client",
     "crates/lmstudio",
@@ -323,6 +324,7 @@ language_models = { path = "crates/language_models" }
 language_selector = { path = "crates/language_selector" }
 language_tools = { path = "crates/language_tools" }
 languages = { path = "crates/languages" }
+line_ending_selector = { path = "crates/line_ending_selector" }
 livekit_api = { path = "crates/livekit_api" }
 livekit_client = { path = "crates/livekit_client" }
 lmstudio = { path = "crates/lmstudio" }

crates/language/src/buffer.rs πŸ”—

@@ -284,6 +284,14 @@ pub enum Operation {
         /// The language server ID.
         server_id: LanguageServerId,
     },
+
+    /// An update to the line ending type of this buffer.
+    UpdateLineEnding {
+        /// The line ending type.
+        line_ending: LineEnding,
+        /// The buffer's lamport timestamp.
+        lamport_timestamp: clock::Lamport,
+    },
 }
 
 /// An event that occurs in a buffer.
@@ -1240,6 +1248,21 @@ impl Buffer {
         self.syntax_map.lock().language_registry()
     }
 
+    /// Assign the line ending type to the buffer.
+    pub fn set_line_ending(&mut self, line_ending: LineEnding, cx: &mut Context<Self>) {
+        self.text.set_line_ending(line_ending);
+
+        let lamport_timestamp = self.text.lamport_clock.tick();
+        self.send_operation(
+            Operation::UpdateLineEnding {
+                line_ending,
+                lamport_timestamp,
+            },
+            true,
+            cx,
+        );
+    }
+
     /// Assign the buffer a new [`Capability`].
     pub fn set_capability(&mut self, capability: Capability, cx: &mut Context<Self>) {
         if self.capability != capability {
@@ -2557,7 +2580,7 @@ impl Buffer {
             Operation::UpdateSelections { selections, .. } => selections
                 .iter()
                 .all(|s| self.can_resolve(&s.start) && self.can_resolve(&s.end)),
-            Operation::UpdateCompletionTriggers { .. } => true,
+            Operation::UpdateCompletionTriggers { .. } | Operation::UpdateLineEnding { .. } => true,
         }
     }
 
@@ -2623,6 +2646,13 @@ impl Buffer {
                 }
                 self.text.lamport_clock.observe(lamport_timestamp);
             }
+            Operation::UpdateLineEnding {
+                line_ending,
+                lamport_timestamp,
+            } => {
+                self.text.set_line_ending(line_ending);
+                self.text.lamport_clock.observe(lamport_timestamp);
+            }
         }
     }
 
@@ -4814,6 +4844,9 @@ impl operation_queue::Operation for Operation {
             }
             | Operation::UpdateCompletionTriggers {
                 lamport_timestamp, ..
+            }
+            | Operation::UpdateLineEnding {
+                lamport_timestamp, ..
             } => *lamport_timestamp,
         }
     }

crates/language/src/buffer_tests.rs πŸ”—

@@ -67,6 +67,78 @@ fn test_line_endings(cx: &mut gpui::App) {
     });
 }
 
+#[gpui::test]
+fn test_set_line_ending(cx: &mut TestAppContext) {
+    let base = cx.new(|cx| Buffer::local("one\ntwo\nthree\n", cx));
+    let base_replica = cx.new(|cx| {
+        Buffer::from_proto(1, Capability::ReadWrite, base.read(cx).to_proto(cx), None).unwrap()
+    });
+    base.update(cx, |_buffer, cx| {
+        cx.subscribe(&base_replica, |this, _, event, cx| {
+            if let BufferEvent::Operation {
+                operation,
+                is_local: true,
+            } = event
+            {
+                this.apply_ops([operation.clone()], cx);
+            }
+        })
+        .detach();
+    });
+    base_replica.update(cx, |_buffer, cx| {
+        cx.subscribe(&base, |this, _, event, cx| {
+            if let BufferEvent::Operation {
+                operation,
+                is_local: true,
+            } = event
+            {
+                this.apply_ops([operation.clone()], cx);
+            }
+        })
+        .detach();
+    });
+
+    // Base
+    base_replica.read_with(cx, |buffer, _| {
+        assert_eq!(buffer.line_ending(), LineEnding::Unix);
+    });
+    base.update(cx, |buffer, cx| {
+        assert_eq!(buffer.line_ending(), LineEnding::Unix);
+        buffer.set_line_ending(LineEnding::Windows, cx);
+        assert_eq!(buffer.line_ending(), LineEnding::Windows);
+    });
+    base_replica.read_with(cx, |buffer, _| {
+        assert_eq!(buffer.line_ending(), LineEnding::Windows);
+    });
+    base.update(cx, |buffer, cx| {
+        buffer.set_line_ending(LineEnding::Unix, cx);
+        assert_eq!(buffer.line_ending(), LineEnding::Unix);
+    });
+    base_replica.read_with(cx, |buffer, _| {
+        assert_eq!(buffer.line_ending(), LineEnding::Unix);
+    });
+
+    // Replica
+    base.read_with(cx, |buffer, _| {
+        assert_eq!(buffer.line_ending(), LineEnding::Unix);
+    });
+    base_replica.update(cx, |buffer, cx| {
+        assert_eq!(buffer.line_ending(), LineEnding::Unix);
+        buffer.set_line_ending(LineEnding::Windows, cx);
+        assert_eq!(buffer.line_ending(), LineEnding::Windows);
+    });
+    base.read_with(cx, |buffer, _| {
+        assert_eq!(buffer.line_ending(), LineEnding::Windows);
+    });
+    base_replica.update(cx, |buffer, cx| {
+        buffer.set_line_ending(LineEnding::Unix, cx);
+        assert_eq!(buffer.line_ending(), LineEnding::Unix);
+    });
+    base.read_with(cx, |buffer, _| {
+        assert_eq!(buffer.line_ending(), LineEnding::Unix);
+    });
+}
+
 #[gpui::test]
 fn test_select_language(cx: &mut App) {
     init_settings(cx, |_| {});

crates/language/src/proto.rs πŸ”—

@@ -90,6 +90,15 @@ pub fn serialize_operation(operation: &crate::Operation) -> proto::Operation {
                     language_server_id: server_id.to_proto(),
                 },
             ),
+
+            crate::Operation::UpdateLineEnding {
+                line_ending,
+                lamport_timestamp,
+            } => proto::operation::Variant::UpdateLineEnding(proto::operation::UpdateLineEnding {
+                replica_id: lamport_timestamp.replica_id as u32,
+                lamport_timestamp: lamport_timestamp.value,
+                line_ending: serialize_line_ending(*line_ending) as i32,
+            }),
         }),
     }
 }
@@ -341,6 +350,18 @@ pub fn deserialize_operation(message: proto::Operation) -> Result<crate::Operati
                     server_id: LanguageServerId::from_proto(message.language_server_id),
                 }
             }
+            proto::operation::Variant::UpdateLineEnding(message) => {
+                crate::Operation::UpdateLineEnding {
+                    lamport_timestamp: clock::Lamport {
+                        replica_id: message.replica_id as ReplicaId,
+                        value: message.lamport_timestamp,
+                    },
+                    line_ending: deserialize_line_ending(
+                        proto::LineEnding::from_i32(message.line_ending)
+                            .context("missing line_ending")?,
+                    ),
+                }
+            }
         },
     )
 }
@@ -496,6 +517,10 @@ pub fn lamport_timestamp_for_operation(operation: &proto::Operation) -> Option<c
             replica_id = op.replica_id;
             value = op.lamport_timestamp;
         }
+        proto::operation::Variant::UpdateLineEnding(op) => {
+            replica_id = op.replica_id;
+            value = op.lamport_timestamp;
+        }
     }
 
     Some(clock::Lamport {

crates/line_ending_selector/Cargo.toml πŸ”—

@@ -0,0 +1,24 @@
+[package]
+name = "line_ending_selector"
+version = "0.1.0"
+edition.workspace = true
+publish.workspace = true
+license = "GPL-3.0-or-later"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/line_ending_selector.rs"
+doctest = false
+
+[dependencies]
+editor.workspace = true
+gpui.workspace = true
+language.workspace = true
+picker.workspace = true
+project.workspace = true
+ui.workspace = true
+util.workspace = true
+workspace.workspace = true
+workspace-hack.workspace = true

crates/line_ending_selector/src/line_ending_selector.rs πŸ”—

@@ -0,0 +1,192 @@
+use editor::Editor;
+use gpui::{DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Task, WeakEntity, actions};
+use language::{Buffer, LineEnding};
+use picker::{Picker, PickerDelegate};
+use project::Project;
+use std::sync::Arc;
+use ui::{ListItem, ListItemSpacing, prelude::*};
+use util::ResultExt;
+use workspace::ModalView;
+
+actions!(
+    line_ending,
+    [
+        /// Toggles the line ending selector modal.
+        Toggle
+    ]
+);
+
+pub fn init(cx: &mut App) {
+    cx.observe_new(LineEndingSelector::register).detach();
+}
+
+pub struct LineEndingSelector {
+    picker: Entity<Picker<LineEndingSelectorDelegate>>,
+}
+
+impl LineEndingSelector {
+    fn register(editor: &mut Editor, _window: Option<&mut Window>, cx: &mut Context<Editor>) {
+        let editor_handle = cx.weak_entity();
+        editor
+            .register_action(move |_: &Toggle, window, cx| {
+                Self::toggle(&editor_handle, window, cx);
+            })
+            .detach();
+    }
+
+    fn toggle(editor: &WeakEntity<Editor>, window: &mut Window, cx: &mut App) {
+        let Some((workspace, buffer)) = editor
+            .update(cx, |editor, cx| {
+                Some((editor.workspace()?, editor.active_excerpt(cx)?.1))
+            })
+            .ok()
+            .flatten()
+        else {
+            return;
+        };
+
+        workspace.update(cx, |workspace, cx| {
+            let project = workspace.project().clone();
+            workspace.toggle_modal(window, cx, move |window, cx| {
+                LineEndingSelector::new(buffer, project, window, cx)
+            });
+        })
+    }
+
+    fn new(
+        buffer: Entity<Buffer>,
+        project: Entity<Project>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Self {
+        let line_ending = buffer.read(cx).line_ending();
+        let delegate =
+            LineEndingSelectorDelegate::new(cx.entity().downgrade(), buffer, project, line_ending);
+        let picker = cx.new(|cx| Picker::nonsearchable_uniform_list(delegate, window, cx));
+        Self { picker }
+    }
+}
+
+impl Render for LineEndingSelector {
+    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
+        v_flex().w(rems(34.)).child(self.picker.clone())
+    }
+}
+
+impl Focusable for LineEndingSelector {
+    fn focus_handle(&self, cx: &App) -> FocusHandle {
+        self.picker.focus_handle(cx)
+    }
+}
+
+impl EventEmitter<DismissEvent> for LineEndingSelector {}
+impl ModalView for LineEndingSelector {}
+
+struct LineEndingSelectorDelegate {
+    line_ending_selector: WeakEntity<LineEndingSelector>,
+    buffer: Entity<Buffer>,
+    project: Entity<Project>,
+    line_ending: LineEnding,
+    matches: Vec<LineEnding>,
+    selected_index: usize,
+}
+
+impl LineEndingSelectorDelegate {
+    fn new(
+        line_ending_selector: WeakEntity<LineEndingSelector>,
+        buffer: Entity<Buffer>,
+        project: Entity<Project>,
+        line_ending: LineEnding,
+    ) -> Self {
+        Self {
+            line_ending_selector,
+            buffer,
+            project,
+            line_ending,
+            matches: vec![LineEnding::Unix, LineEnding::Windows],
+            selected_index: 0,
+        }
+    }
+}
+
+impl PickerDelegate for LineEndingSelectorDelegate {
+    type ListItem = ListItem;
+
+    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
+        "Select a line ending…".into()
+    }
+
+    fn match_count(&self) -> usize {
+        self.matches.len()
+    }
+
+    fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
+        if let Some(line_ending) = self.matches.get(self.selected_index) {
+            self.buffer.update(cx, |this, cx| {
+                this.set_line_ending(*line_ending, cx);
+            });
+            let buffer = self.buffer.clone();
+            let project = self.project.clone();
+            cx.defer(move |cx| {
+                project.update(cx, |this, cx| {
+                    this.save_buffer(buffer, cx).detach();
+                });
+            });
+        }
+        self.dismissed(window, cx);
+    }
+
+    fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
+        self.line_ending_selector
+            .update(cx, |_, cx| cx.emit(DismissEvent))
+            .log_err();
+    }
+
+    fn selected_index(&self) -> usize {
+        self.selected_index
+    }
+
+    fn set_selected_index(
+        &mut self,
+        ix: usize,
+        _window: &mut Window,
+        _: &mut Context<Picker<Self>>,
+    ) {
+        self.selected_index = ix;
+    }
+
+    fn update_matches(
+        &mut self,
+        _query: String,
+        _window: &mut Window,
+        _cx: &mut Context<Picker<Self>>,
+    ) -> gpui::Task<()> {
+        return Task::ready(());
+    }
+
+    fn render_match(
+        &self,
+        ix: usize,
+        selected: bool,
+        _: &mut Window,
+        _: &mut Context<Picker<Self>>,
+    ) -> Option<Self::ListItem> {
+        let line_ending = self.matches[ix];
+        let label = match line_ending {
+            LineEnding::Unix => "LF",
+            LineEnding::Windows => "CRLF",
+        };
+
+        let mut list_item = ListItem::new(ix)
+            .inset(true)
+            .spacing(ListItemSpacing::Sparse)
+            .toggle_state(selected)
+            .child(Label::new(label));
+
+        if self.line_ending == line_ending {
+            list_item = list_item.end_slot(Icon::new(IconName::Check).color(Color::Muted));
+        }
+
+        Some(list_item)
+    }
+}

crates/proto/proto/buffer.proto πŸ”—

@@ -143,6 +143,7 @@ message Operation {
         UpdateSelections update_selections = 3;
         UpdateDiagnostics update_diagnostics = 4;
         UpdateCompletionTriggers update_completion_triggers = 5;
+        UpdateLineEnding update_line_ending = 6;
     }
 
     message Edit {
@@ -174,6 +175,12 @@ message Operation {
         repeated string triggers = 3;
         uint64 language_server_id = 4;
     }
+
+    message UpdateLineEnding {
+        uint32 replica_id = 1;
+        uint32 lamport_timestamp = 2;
+        LineEnding line_ending = 3;
+    }
 }
 
 message ProjectTransaction {

crates/zed/Cargo.toml πŸ”—

@@ -93,6 +93,7 @@ language_models.workspace = true
 language_selector.workspace = true
 language_tools.workspace = true
 languages = { workspace = true, features = ["load-grammars"] }
+line_ending_selector.workspace = true
 libc.workspace = true
 log.workspace = true
 markdown.workspace = true

crates/zed/src/main.rs πŸ”—

@@ -620,6 +620,7 @@ pub fn main() {
         terminal_view::init(cx);
         journal::init(app_state.clone(), cx);
         language_selector::init(cx);
+        line_ending_selector::init(cx);
         toolchain_selector::init(cx);
         theme_selector::init(cx);
         settings_profile_selector::init(cx);

crates/zed/src/zed.rs πŸ”—

@@ -4480,6 +4480,7 @@ mod tests {
                 "keymap_editor",
                 "keystroke_input",
                 "language_selector",
+                "line_ending",
                 "lsp_tool",
                 "markdown",
                 "menu",