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 image 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",