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 <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>
This commit is contained in:
co-authored by
Conrad Irwin
parent
638320b21e
commit
b3405c3bd1
Generated
+16
@@ -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",
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, |_| {});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
+1
@@ -0,0 +1 @@
|
||||
../../LICENSE-GPL
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -4480,6 +4480,7 @@ mod tests {
|
||||
"keymap_editor",
|
||||
"keystroke_input",
|
||||
"language_selector",
|
||||
"line_ending",
|
||||
"lsp_tool",
|
||||
"markdown",
|
||||
"menu",
|
||||
|
||||
Reference in New Issue
Block a user