1pub mod cursor_position;
2
3use cursor_position::LineIndicatorFormat;
4use editor::{scroll::Autoscroll, Editor};
5use gpui::{
6 div, prelude::*, AnyWindowHandle, AppContext, DismissEvent, EventEmitter, FocusHandle,
7 FocusableView, Render, SharedString, Styled, Subscription, View, ViewContext, VisualContext,
8};
9use settings::Settings;
10use text::{Bias, Point};
11use theme::ActiveTheme;
12use ui::prelude::*;
13use util::paths::FILE_ROW_COLUMN_DELIMITER;
14use workspace::ModalView;
15
16pub fn init(cx: &mut AppContext) {
17 LineIndicatorFormat::register(cx);
18 cx.observe_new_views(GoToLine::register).detach();
19}
20
21pub struct GoToLine {
22 line_editor: View<Editor>,
23 active_editor: View<Editor>,
24 current_text: SharedString,
25 prev_scroll_position: Option<gpui::Point<f32>>,
26 _subscriptions: Vec<Subscription>,
27}
28
29impl ModalView for GoToLine {}
30
31impl FocusableView for GoToLine {
32 fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
33 self.line_editor.focus_handle(cx)
34 }
35}
36impl EventEmitter<DismissEvent> for GoToLine {}
37
38enum GoToLineRowHighlights {}
39
40impl GoToLine {
41 fn register(editor: &mut Editor, cx: &mut ViewContext<Editor>) {
42 let handle = cx.view().downgrade();
43 editor
44 .register_action(move |_: &editor::actions::ToggleGoToLine, cx| {
45 let Some(editor) = handle.upgrade() else {
46 return;
47 };
48 let Some(workspace) = editor.read(cx).workspace() else {
49 return;
50 };
51 workspace.update(cx, |workspace, cx| {
52 workspace.toggle_modal(cx, move |cx| GoToLine::new(editor, cx));
53 })
54 })
55 .detach();
56 }
57
58 pub fn new(active_editor: View<Editor>, cx: &mut ViewContext<Self>) -> Self {
59 let cursor =
60 active_editor.update(cx, |editor, cx| editor.selections.last::<Point>(cx).head());
61
62 let line = cursor.row + 1;
63 let column = cursor.column + 1;
64
65 let line_editor = cx.new_view(|cx| {
66 let mut editor = Editor::single_line(cx);
67 editor.set_placeholder_text(format!("{line}{FILE_ROW_COLUMN_DELIMITER}{column}"), cx);
68 editor
69 });
70 let line_editor_change = cx.subscribe(&line_editor, Self::on_line_editor_event);
71
72 let editor = active_editor.read(cx);
73 let last_line = editor.buffer().read(cx).snapshot(cx).max_point().row;
74 let scroll_position = active_editor.update(cx, |editor, cx| editor.scroll_position(cx));
75
76 let current_text = format!("{} of {} (column {})", line, last_line + 1, column);
77
78 Self {
79 line_editor,
80 active_editor,
81 current_text: current_text.into(),
82 prev_scroll_position: Some(scroll_position),
83 _subscriptions: vec![line_editor_change, cx.on_release(Self::release)],
84 }
85 }
86
87 fn release(&mut self, window: AnyWindowHandle, cx: &mut AppContext) {
88 window
89 .update(cx, |_, cx| {
90 let scroll_position = self.prev_scroll_position.take();
91 self.active_editor.update(cx, |editor, cx| {
92 editor.clear_row_highlights::<GoToLineRowHighlights>();
93 if let Some(scroll_position) = scroll_position {
94 editor.set_scroll_position(scroll_position, cx);
95 }
96 cx.notify();
97 })
98 })
99 .ok();
100 }
101
102 fn on_line_editor_event(
103 &mut self,
104 _: View<Editor>,
105 event: &editor::EditorEvent,
106 cx: &mut ViewContext<Self>,
107 ) {
108 match event {
109 editor::EditorEvent::Blurred => cx.emit(DismissEvent),
110 editor::EditorEvent::BufferEdited { .. } => self.highlight_current_line(cx),
111 _ => {}
112 }
113 }
114
115 fn highlight_current_line(&mut self, cx: &mut ViewContext<Self>) {
116 if let Some(point) = self.point_from_query(cx) {
117 self.active_editor.update(cx, |active_editor, cx| {
118 let snapshot = active_editor.snapshot(cx).display_snapshot;
119 let start = snapshot.buffer_snapshot.clip_point(point, Bias::Left);
120 let end = start + Point::new(1, 0);
121 let start = snapshot.buffer_snapshot.anchor_before(start);
122 let end = snapshot.buffer_snapshot.anchor_after(end);
123 active_editor.clear_row_highlights::<GoToLineRowHighlights>();
124 active_editor.highlight_rows::<GoToLineRowHighlights>(
125 start..end,
126 cx.theme().colors().editor_highlighted_line_background,
127 true,
128 cx,
129 );
130 active_editor.request_autoscroll(Autoscroll::center(), cx);
131 });
132 cx.notify();
133 }
134 }
135
136 fn point_from_query(&self, cx: &ViewContext<Self>) -> Option<Point> {
137 let (row, column) = self.line_column_from_query(cx);
138 Some(Point::new(
139 row?.saturating_sub(1),
140 column.unwrap_or(0).saturating_sub(1),
141 ))
142 }
143
144 fn line_column_from_query(&self, cx: &ViewContext<Self>) -> (Option<u32>, Option<u32>) {
145 let input = self.line_editor.read(cx).text(cx);
146 let mut components = input
147 .splitn(2, FILE_ROW_COLUMN_DELIMITER)
148 .map(str::trim)
149 .fuse();
150 let row = components.next().and_then(|row| row.parse::<u32>().ok());
151 let column = components.next().and_then(|col| col.parse::<u32>().ok());
152 (row, column)
153 }
154
155 fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
156 cx.emit(DismissEvent);
157 }
158
159 fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
160 if let Some(point) = self.point_from_query(cx) {
161 self.active_editor.update(cx, |editor, cx| {
162 let snapshot = editor.snapshot(cx).display_snapshot;
163 let point = snapshot.buffer_snapshot.clip_point(point, Bias::Left);
164 editor.change_selections(Some(Autoscroll::center()), cx, |s| {
165 s.select_ranges([point..point])
166 });
167 editor.focus(cx);
168 cx.notify();
169 });
170 self.prev_scroll_position.take();
171 }
172
173 cx.emit(DismissEvent);
174 }
175}
176
177impl Render for GoToLine {
178 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
179 let mut help_text = self.current_text.clone();
180 let query = self.line_column_from_query(cx);
181 if let Some(line) = query.0 {
182 if let Some(column) = query.1 {
183 help_text = format!("Go to line {line}, column {column}").into();
184 } else {
185 help_text = format!("Go to line {line}").into();
186 }
187 }
188
189 v_flex()
190 .w(rems(24.))
191 .elevation_2(cx)
192 .key_context("GoToLine")
193 .on_action(cx.listener(Self::cancel))
194 .on_action(cx.listener(Self::confirm))
195 .child(
196 div()
197 .border_b_1()
198 .border_color(cx.theme().colors().border_variant)
199 .px_2()
200 .py_1()
201 .child(self.line_editor.clone()),
202 )
203 .child(
204 h_flex()
205 .px_2()
206 .py_1()
207 .gap_1()
208 .child(Label::new("Current Line:").color(Color::Muted))
209 .child(Label::new(help_text).color(Color::Muted)),
210 )
211 }
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217 use cursor_position::{CursorPosition, SelectionStats};
218 use editor::actions::SelectAll;
219 use gpui::{TestAppContext, VisualTestContext};
220 use indoc::indoc;
221 use project::{FakeFs, Project};
222 use serde_json::json;
223 use std::{sync::Arc, time::Duration};
224 use workspace::{AppState, Workspace};
225
226 #[gpui::test]
227 async fn test_go_to_line_view_row_highlights(cx: &mut TestAppContext) {
228 init_test(cx);
229 let fs = FakeFs::new(cx.executor());
230 fs.insert_tree(
231 "/dir",
232 json!({
233 "a.rs": indoc!{"
234 struct SingleLine; // display line 0
235 // display line 1
236 struct MultiLine { // display line 2
237 field_1: i32, // display line 3
238 field_2: i32, // display line 4
239 } // display line 5
240 // display line 6
241 struct Another { // display line 7
242 field_1: i32, // display line 8
243 field_2: i32, // display line 9
244 field_3: i32, // display line 10
245 field_4: i32, // display line 11
246 } // display line 12
247 "}
248 }),
249 )
250 .await;
251
252 let project = Project::test(fs, ["/dir".as_ref()], cx).await;
253 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx));
254 let worktree_id = workspace.update(cx, |workspace, cx| {
255 workspace.project().update(cx, |project, cx| {
256 project.worktrees(cx).next().unwrap().read(cx).id()
257 })
258 });
259 let _buffer = project
260 .update(cx, |project, cx| project.open_local_buffer("/dir/a.rs", cx))
261 .await
262 .unwrap();
263 let editor = workspace
264 .update(cx, |workspace, cx| {
265 workspace.open_path((worktree_id, "a.rs"), None, true, cx)
266 })
267 .await
268 .unwrap()
269 .downcast::<Editor>()
270 .unwrap();
271
272 let go_to_line_view = open_go_to_line_view(&workspace, cx);
273 assert_eq!(
274 highlighted_display_rows(&editor, cx),
275 Vec::<u32>::new(),
276 "Initially opened go to line modal should not highlight any rows"
277 );
278 assert_single_caret_at_row(&editor, 0, cx);
279
280 cx.simulate_input("1");
281 assert_eq!(
282 highlighted_display_rows(&editor, cx),
283 vec![0],
284 "Go to line modal should highlight a row, corresponding to the query"
285 );
286 assert_single_caret_at_row(&editor, 0, cx);
287
288 cx.simulate_input("8");
289 assert_eq!(
290 highlighted_display_rows(&editor, cx),
291 vec![13],
292 "If the query is too large, the last row should be highlighted"
293 );
294 assert_single_caret_at_row(&editor, 0, cx);
295
296 cx.dispatch_action(menu::Cancel);
297 drop(go_to_line_view);
298 editor.update(cx, |_, _| {});
299 assert_eq!(
300 highlighted_display_rows(&editor, cx),
301 Vec::<u32>::new(),
302 "After cancelling and closing the modal, no rows should be highlighted"
303 );
304 assert_single_caret_at_row(&editor, 0, cx);
305
306 let go_to_line_view = open_go_to_line_view(&workspace, cx);
307 assert_eq!(
308 highlighted_display_rows(&editor, cx),
309 Vec::<u32>::new(),
310 "Reopened modal should not highlight any rows"
311 );
312 assert_single_caret_at_row(&editor, 0, cx);
313
314 let expected_highlighted_row = 4;
315 cx.simulate_input("5");
316 assert_eq!(
317 highlighted_display_rows(&editor, cx),
318 vec![expected_highlighted_row]
319 );
320 assert_single_caret_at_row(&editor, 0, cx);
321 cx.dispatch_action(menu::Confirm);
322 drop(go_to_line_view);
323 editor.update(cx, |_, _| {});
324 assert_eq!(
325 highlighted_display_rows(&editor, cx),
326 Vec::<u32>::new(),
327 "After confirming and closing the modal, no rows should be highlighted"
328 );
329 // On confirm, should place the caret on the highlighted row.
330 assert_single_caret_at_row(&editor, expected_highlighted_row, cx);
331 }
332
333 #[gpui::test]
334 async fn test_unicode_characters_selection(cx: &mut TestAppContext) {
335 init_test(cx);
336
337 let fs = FakeFs::new(cx.executor());
338 fs.insert_tree(
339 "/dir",
340 json!({
341 "a.rs": "ēlo"
342 }),
343 )
344 .await;
345
346 let project = Project::test(fs, ["/dir".as_ref()], cx).await;
347 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx));
348 workspace.update(cx, |workspace, cx| {
349 let cursor_position = cx.new_view(|_| CursorPosition::new(workspace));
350 workspace.status_bar().update(cx, |status_bar, cx| {
351 status_bar.add_right_item(cursor_position, cx);
352 });
353 });
354
355 let worktree_id = workspace.update(cx, |workspace, cx| {
356 workspace.project().update(cx, |project, cx| {
357 project.worktrees(cx).next().unwrap().read(cx).id()
358 })
359 });
360 let _buffer = project
361 .update(cx, |project, cx| project.open_local_buffer("/dir/a.rs", cx))
362 .await
363 .unwrap();
364 let editor = workspace
365 .update(cx, |workspace, cx| {
366 workspace.open_path((worktree_id, "a.rs"), None, true, cx)
367 })
368 .await
369 .unwrap()
370 .downcast::<Editor>()
371 .unwrap();
372
373 cx.executor().advance_clock(Duration::from_millis(200));
374 workspace.update(cx, |workspace, cx| {
375 assert_eq!(
376 &SelectionStats {
377 lines: 0,
378 characters: 0,
379 selections: 1,
380 },
381 workspace
382 .status_bar()
383 .read(cx)
384 .item_of_type::<CursorPosition>()
385 .expect("missing cursor position item")
386 .read(cx)
387 .selection_stats(),
388 "No selections should be initially"
389 );
390 });
391 editor.update(cx, |editor, cx| editor.select_all(&SelectAll, cx));
392 cx.executor().advance_clock(Duration::from_millis(200));
393 workspace.update(cx, |workspace, cx| {
394 assert_eq!(
395 &SelectionStats {
396 lines: 1,
397 characters: 3,
398 selections: 1,
399 },
400 workspace
401 .status_bar()
402 .read(cx)
403 .item_of_type::<CursorPosition>()
404 .expect("missing cursor position item")
405 .read(cx)
406 .selection_stats(),
407 "After selecting a text with multibyte unicode characters, the character count should be correct"
408 );
409 });
410 }
411
412 fn open_go_to_line_view(
413 workspace: &View<Workspace>,
414 cx: &mut VisualTestContext,
415 ) -> View<GoToLine> {
416 cx.dispatch_action(editor::actions::ToggleGoToLine);
417 workspace.update(cx, |workspace, cx| {
418 workspace.active_modal::<GoToLine>(cx).unwrap().clone()
419 })
420 }
421
422 fn highlighted_display_rows(editor: &View<Editor>, cx: &mut VisualTestContext) -> Vec<u32> {
423 editor.update(cx, |editor, cx| {
424 editor
425 .highlighted_display_rows(cx)
426 .into_keys()
427 .map(|r| r.0)
428 .collect()
429 })
430 }
431
432 #[track_caller]
433 fn assert_single_caret_at_row(
434 editor: &View<Editor>,
435 buffer_row: u32,
436 cx: &mut VisualTestContext,
437 ) {
438 let selections = editor.update(cx, |editor, cx| {
439 editor
440 .selections
441 .all::<rope::Point>(cx)
442 .into_iter()
443 .map(|s| s.start..s.end)
444 .collect::<Vec<_>>()
445 });
446 assert!(
447 selections.len() == 1,
448 "Expected one caret selection but got: {selections:?}"
449 );
450 let selection = &selections[0];
451 assert!(
452 selection.start == selection.end,
453 "Expected a single caret selection, but got: {selection:?}"
454 );
455 assert_eq!(selection.start.row, buffer_row);
456 }
457
458 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
459 cx.update(|cx| {
460 let state = AppState::test(cx);
461 language::init(cx);
462 crate::init(cx);
463 editor::init(cx);
464 workspace::init_settings(cx);
465 Project::init_settings(cx);
466 state
467 })
468 }
469}