From b3405c3bd18749f3f7acda52670ee03528d655b8 Mon Sep 17 00:00:00 2001
From: Matin Aniss <76515905+MatinAniss@users.noreply.github.com>
Date: Sat, 6 Sep 2025 02:52:57 +1000
Subject: [PATCH] Add line ending selector (#35392)
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
Release Notes:
- Added line ending selector.
---------
Co-authored-by: Conrad Irwin
---
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 +
.../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(-)
create mode 100644 crates/line_ending_selector/Cargo.toml
create mode 120000 crates/line_ending_selector/LICENSE-GPL
create mode 100644 crates/line_ending_selector/src/line_ending_selector.rs
diff --git a/Cargo.lock b/Cargo.lock
index 975e762dddefa6d2c67f8957e4356a69c903f187..fbdf0e848c356620f2a2cca800cf40ef850c3b13 100644
--- a/Cargo.lock
+++ b/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",
diff --git a/Cargo.toml b/Cargo.toml
index 1de877334fe6cf7c5d4c84649e27b0633579723e..d8e8040cd920e1f6b5a561c80a4a205d030cbb49 100644
--- a/Cargo.toml
+++ b/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" }
diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs
index c86787e1f9de8cf31037187dc667e2a7e428cea9..2a303bb9a0ff44981def92f593595e92629be1e5 100644
--- a/crates/language/src/buffer.rs
+++ b/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.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) {
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,
}
}
diff --git a/crates/language/src/buffer_tests.rs b/crates/language/src/buffer_tests.rs
index 5b88112c956e5466748fc349825a78f6232e540e..050ec457dfe6e83d420206b381d5524b9c583441 100644
--- a/crates/language/src/buffer_tests.rs
+++ b/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, |_| {});
diff --git a/crates/language/src/proto.rs b/crates/language/src/proto.rs
index 0d5a8e916c8712733dcc7a26faa984453cdd30fd..bc85b10859632fc3e2cf61c663b7159a023f4f3a 100644
--- a/crates/language/src/proto.rs
+++ b/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::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 {
+ replica_id = op.replica_id;
+ value = op.lamport_timestamp;
+ }
}
Some(clock::Lamport {
diff --git a/crates/line_ending_selector/Cargo.toml b/crates/line_ending_selector/Cargo.toml
new file mode 100644
index 0000000000000000000000000000000000000000..7c5c8f6d8f3996771f832c28d5d71b857bb0b3b6
--- /dev/null
+++ b/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
diff --git a/crates/line_ending_selector/LICENSE-GPL b/crates/line_ending_selector/LICENSE-GPL
new file mode 120000
index 0000000000000000000000000000000000000000..89e542f750cd3860a0598eff0dc34b56d7336dc4
--- /dev/null
+++ b/crates/line_ending_selector/LICENSE-GPL
@@ -0,0 +1 @@
+../../LICENSE-GPL
\ No newline at end of file
diff --git a/crates/line_ending_selector/src/line_ending_selector.rs b/crates/line_ending_selector/src/line_ending_selector.rs
new file mode 100644
index 0000000000000000000000000000000000000000..532f0b051d79e25229d7cb72419ca557edd5b477
--- /dev/null
+++ b/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>,
+}
+
+impl LineEndingSelector {
+ fn register(editor: &mut Editor, _window: Option<&mut Window>, cx: &mut Context) {
+ let editor_handle = cx.weak_entity();
+ editor
+ .register_action(move |_: &Toggle, window, cx| {
+ Self::toggle(&editor_handle, window, cx);
+ })
+ .detach();
+ }
+
+ fn toggle(editor: &WeakEntity, 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,
+ project: Entity,
+ window: &mut Window,
+ cx: &mut Context,
+ ) -> 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) -> 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 for LineEndingSelector {}
+impl ModalView for LineEndingSelector {}
+
+struct LineEndingSelectorDelegate {
+ line_ending_selector: WeakEntity,
+ buffer: Entity,
+ project: Entity,
+ line_ending: LineEnding,
+ matches: Vec,
+ selected_index: usize,
+}
+
+impl LineEndingSelectorDelegate {
+ fn new(
+ line_ending_selector: WeakEntity,
+ buffer: Entity,
+ project: Entity,
+ 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 {
+ "Select a line ending…".into()
+ }
+
+ fn match_count(&self) -> usize {
+ self.matches.len()
+ }
+
+ fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context>) {
+ 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>) {
+ 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>,
+ ) {
+ self.selected_index = ix;
+ }
+
+ fn update_matches(
+ &mut self,
+ _query: String,
+ _window: &mut Window,
+ _cx: &mut Context>,
+ ) -> gpui::Task<()> {
+ return Task::ready(());
+ }
+
+ fn render_match(
+ &self,
+ ix: usize,
+ selected: bool,
+ _: &mut Window,
+ _: &mut Context>,
+ ) -> Option {
+ 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)
+ }
+}
diff --git a/crates/proto/proto/buffer.proto b/crates/proto/proto/buffer.proto
index f4dacf2fdca97bf9766c8de348a67cd18f8fb973..4580fd8e9db80e7dc54b1c997f8df108e3bf9330 100644
--- a/crates/proto/proto/buffer.proto
+++ b/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 {
diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml
index f2295d5fa732d9e36e2b37cf346199f35cabc803..bee6c87670c87a08945918a3dd49b26463a3a3ef 100644
--- a/crates/zed/Cargo.toml
+++ b/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
diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs
index 9582e7a2ab541243a768370eb08ed1f4f1c465a3..3287e866e48058a763c7db6633c1db4252fc0bec 100644
--- a/crates/zed/src/main.rs
+++ b/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);
diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs
index 864f6badeb6941aa2d6bd17a43977f84a77461b1..fda43a10bad9acc6ae2864519cac5def08fb2f84 100644
--- a/crates/zed/src/zed.rs
+++ b/crates/zed/src/zed.rs
@@ -4480,6 +4480,7 @@ mod tests {
"keymap_editor",
"keystroke_input",
"language_selector",
+ "line_ending",
"lsp_tool",
"markdown",
"menu",