1pub mod cursor_position;
2
3use cursor_position::UserCaretPosition;
4use editor::{
5 Anchor, Editor, MultiBufferSnapshot, RowHighlightOptions, SelectionEffects, ToPoint,
6 actions::Tab,
7 scroll::{Autoscroll, ScrollOffset},
8};
9use gpui::{
10 App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString, Styled,
11 Subscription, div, prelude::*,
12};
13use language::Buffer;
14use multi_buffer::MultiBufferRow;
15use text::{Bias, Point};
16use theme::ActiveTheme;
17use ui::prelude::*;
18use util::paths::FILE_ROW_COLUMN_DELIMITER;
19use workspace::{DismissDecision, ModalView};
20
21pub fn init(cx: &mut App) {
22 cx.observe_new(GoToLine::register).detach();
23}
24
25pub struct GoToLine {
26 line_editor: Entity<Editor>,
27 active_editor: Entity<Editor>,
28 current_text: SharedString,
29 prev_scroll_position: Option<gpui::Point<ScrollOffset>>,
30 current_line: u32,
31 _subscriptions: Vec<Subscription>,
32}
33
34impl ModalView for GoToLine {
35 fn on_before_dismiss(
36 &mut self,
37 _window: &mut Window,
38 _cx: &mut Context<Self>,
39 ) -> DismissDecision {
40 self.prev_scroll_position.take();
41 DismissDecision::Dismiss(true)
42 }
43}
44
45impl Focusable for GoToLine {
46 fn focus_handle(&self, cx: &App) -> FocusHandle {
47 self.line_editor.focus_handle(cx)
48 }
49}
50impl EventEmitter<DismissEvent> for GoToLine {}
51
52enum GoToLineRowHighlights {}
53
54impl GoToLine {
55 fn register(editor: &mut Editor, _window: Option<&mut Window>, cx: &mut Context<Editor>) {
56 let handle = cx.entity().downgrade();
57 editor
58 .register_action(move |_: &editor::actions::ToggleGoToLine, window, cx| {
59 let Some(editor_handle) = handle.upgrade() else {
60 return;
61 };
62 let Some(workspace) = editor_handle.read(cx).workspace() else {
63 return;
64 };
65 let editor = editor_handle.read(cx);
66 let Some((_, buffer, _)) = editor.active_excerpt(cx) else {
67 return;
68 };
69 workspace.update(cx, |workspace, cx| {
70 workspace.toggle_modal(window, cx, move |window, cx| {
71 GoToLine::new(editor_handle, buffer, window, cx)
72 });
73 })
74 })
75 .detach();
76 }
77
78 pub fn new(
79 active_editor: Entity<Editor>,
80 active_buffer: Entity<Buffer>,
81 window: &mut Window,
82 cx: &mut Context<Self>,
83 ) -> Self {
84 let (user_caret, last_line, scroll_position) = active_editor.update(cx, |editor, cx| {
85 let user_caret = UserCaretPosition::at_selection_end(
86 &editor
87 .selections
88 .last::<Point>(&editor.display_snapshot(cx)),
89 &editor.buffer().read(cx).snapshot(cx),
90 );
91
92 let snapshot = active_buffer.read(cx).snapshot();
93 let last_line = editor
94 .buffer()
95 .read(cx)
96 .excerpts_for_buffer(snapshot.remote_id(), cx)
97 .into_iter()
98 .map(move |(_, _, range)| {
99 text::ToPoint::to_point(&range.context.end, &snapshot).row
100 })
101 .max()
102 .unwrap_or(0);
103
104 (user_caret, last_line, editor.scroll_position(cx))
105 });
106
107 let line = user_caret.line.get();
108 let column = user_caret.character.get();
109
110 let line_editor = cx.new(|cx| {
111 let mut editor = Editor::single_line(window, cx);
112 let editor_handle = cx.entity().downgrade();
113 editor
114 .register_action::<Tab>({
115 move |_, window, cx| {
116 let Some(editor) = editor_handle.upgrade() else {
117 return;
118 };
119 editor.update(cx, |editor, cx| {
120 if let Some(placeholder_text) = editor.placeholder_text(cx)
121 && editor.text(cx).is_empty()
122 {
123 editor.set_text(placeholder_text, window, cx);
124 }
125 });
126 }
127 })
128 .detach();
129 editor.set_placeholder_text(
130 &format!("{line}{FILE_ROW_COLUMN_DELIMITER}{column}"),
131 window,
132 cx,
133 );
134 editor
135 });
136 let line_editor_change = cx.subscribe_in(&line_editor, window, Self::on_line_editor_event);
137
138 let current_text = format!(
139 "Current Line: {} of {} (column {})",
140 line,
141 last_line + 1,
142 column
143 );
144
145 Self {
146 line_editor,
147 active_editor,
148 current_text: current_text.into(),
149 prev_scroll_position: Some(scroll_position),
150 current_line: line,
151 _subscriptions: vec![line_editor_change, cx.on_release_in(window, Self::release)],
152 }
153 }
154
155 fn release(&mut self, window: &mut Window, cx: &mut App) {
156 let scroll_position = self.prev_scroll_position.take();
157 self.active_editor.update(cx, |editor, cx| {
158 editor.clear_row_highlights::<GoToLineRowHighlights>();
159 if let Some(scroll_position) = scroll_position {
160 editor.set_scroll_position(scroll_position, window, cx);
161 }
162 cx.notify();
163 })
164 }
165
166 fn on_line_editor_event(
167 &mut self,
168 _: &Entity<Editor>,
169 event: &editor::EditorEvent,
170 _window: &mut Window,
171 cx: &mut Context<Self>,
172 ) {
173 match event {
174 editor::EditorEvent::Blurred => {
175 self.prev_scroll_position.take();
176 cx.emit(DismissEvent)
177 }
178 editor::EditorEvent::BufferEdited => self.highlight_current_line(cx),
179 _ => {}
180 }
181 }
182
183 fn highlight_current_line(&mut self, cx: &mut Context<Self>) {
184 self.active_editor.update(cx, |editor, cx| {
185 editor.clear_row_highlights::<GoToLineRowHighlights>();
186 let snapshot = editor.buffer().read(cx).snapshot(cx);
187 let Some(start) = self.anchor_from_query(&snapshot, cx) else {
188 return;
189 };
190 let mut start_point = start.to_point(&snapshot);
191 start_point.column = 0;
192 // Force non-empty range to ensure the line is highlighted.
193 let mut end_point = snapshot.clip_point(start_point + Point::new(0, 1), Bias::Left);
194 if start_point == end_point {
195 end_point = snapshot.clip_point(start_point + Point::new(1, 0), Bias::Left);
196 }
197
198 let end = snapshot.anchor_after(end_point);
199 editor.highlight_rows::<GoToLineRowHighlights>(
200 start..end,
201 cx.theme().colors().editor_highlighted_line_background,
202 RowHighlightOptions {
203 autoscroll: true,
204 ..Default::default()
205 },
206 cx,
207 );
208 editor.request_autoscroll(Autoscroll::center(), cx);
209 });
210 cx.notify();
211 }
212
213 fn anchor_from_query(
214 &self,
215 snapshot: &MultiBufferSnapshot,
216 cx: &Context<Editor>,
217 ) -> Option<Anchor> {
218 let (query_row, query_char) = if let Some(offset) = self.relative_line_from_query(cx) {
219 let target = if offset >= 0 {
220 self.current_line.saturating_add(offset as u32)
221 } else {
222 self.current_line.saturating_sub(offset.unsigned_abs())
223 };
224 (target, None)
225 } else {
226 self.line_and_char_from_query(cx)?
227 };
228
229 let row = query_row.saturating_sub(1);
230 let character = query_char.unwrap_or(0).saturating_sub(1);
231
232 let target_multi_buffer_row = MultiBufferRow(row);
233 let (buffer_snapshot, target_in_buffer, _) = snapshot.point_to_buffer_point(Point::new(
234 target_multi_buffer_row.min(snapshot.max_row()).0,
235 0,
236 ))?;
237 let target_point =
238 buffer_snapshot.point_from_external_input(target_in_buffer.row, character);
239 Some(snapshot.anchor_before(target_point))
240 }
241
242 fn relative_line_from_query(&self, cx: &App) -> Option<i32> {
243 let input = self.line_editor.read(cx).text(cx);
244 let trimmed = input.trim();
245
246 let mut last_direction_char: Option<char> = None;
247 let mut number_start_index = 0;
248
249 for (i, c) in trimmed.char_indices() {
250 match c {
251 '+' | 'f' | 'F' | '-' | 'b' | 'B' => {
252 last_direction_char = Some(c);
253 number_start_index = i + c.len_utf8();
254 }
255 _ => break,
256 }
257 }
258
259 let direction = last_direction_char?;
260
261 let number_part = &trimmed[number_start_index..];
262 let line_part = number_part
263 .split(FILE_ROW_COLUMN_DELIMITER)
264 .next()
265 .unwrap_or(number_part)
266 .trim();
267
268 let value = line_part.parse::<u32>().ok()?;
269
270 match direction {
271 '+' | 'f' | 'F' => Some(value as i32),
272 '-' | 'b' | 'B' => Some(-(value as i32)),
273 _ => None,
274 }
275 }
276
277 fn line_and_char_from_query(&self, cx: &App) -> Option<(u32, Option<u32>)> {
278 let input = self.line_editor.read(cx).text(cx);
279 let mut components = input
280 .splitn(2, FILE_ROW_COLUMN_DELIMITER)
281 .map(str::trim)
282 .fuse();
283 let row = components.next().and_then(|row| row.parse::<u32>().ok())?;
284 let column = components.next().and_then(|col| col.parse::<u32>().ok());
285 Some((row, column))
286 }
287
288 fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
289 cx.emit(DismissEvent);
290 }
291
292 fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
293 self.active_editor.update(cx, |editor, cx| {
294 let snapshot = editor.buffer().read(cx).snapshot(cx);
295 let Some(start) = self.anchor_from_query(&snapshot, cx) else {
296 return;
297 };
298 editor.change_selections(
299 SelectionEffects::scroll(Autoscroll::center()),
300 window,
301 cx,
302 |s| s.select_anchor_ranges([start..start]),
303 );
304 editor.focus_handle(cx).focus(window, cx);
305 cx.notify()
306 });
307 self.prev_scroll_position.take();
308
309 cx.emit(DismissEvent);
310 }
311}
312
313impl Render for GoToLine {
314 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
315 let help_text = if let Some(offset) = self.relative_line_from_query(cx) {
316 let target_line = if offset >= 0 {
317 self.current_line.saturating_add(offset as u32)
318 } else {
319 self.current_line.saturating_sub(offset.unsigned_abs())
320 };
321 format!("Go to line {target_line} ({offset:+} from current)").into()
322 } else {
323 match self.line_and_char_from_query(cx) {
324 Some((line, Some(character))) => {
325 format!("Go to line {line}, character {character}").into()
326 }
327 Some((line, None)) => format!("Go to line {line}").into(),
328 None => self.current_text.clone(),
329 }
330 };
331
332 v_flex()
333 .w(rems(24.))
334 .elevation_2(cx)
335 .key_context("GoToLine")
336 .on_action(cx.listener(Self::cancel))
337 .on_action(cx.listener(Self::confirm))
338 .child(
339 div()
340 .border_b_1()
341 .border_color(cx.theme().colors().border_variant)
342 .px_2()
343 .py_1()
344 .child(self.line_editor.clone()),
345 )
346 .child(
347 h_flex()
348 .px_2()
349 .py_1()
350 .gap_1()
351 .child(Label::new(help_text).color(Color::Muted)),
352 )
353 }
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359 use cursor_position::{CursorPosition, SelectionStats, UserCaretPosition};
360 use editor::actions::{MoveRight, MoveToBeginning, SelectAll};
361 use gpui::{TestAppContext, VisualTestContext};
362 use indoc::indoc;
363 use project::{FakeFs, Project};
364 use serde_json::json;
365 use std::{num::NonZeroU32, sync::Arc, time::Duration};
366 use util::{path, rel_path::rel_path};
367 use workspace::{AppState, MultiWorkspace, Workspace};
368
369 #[gpui::test]
370 async fn test_go_to_line_view_row_highlights(cx: &mut TestAppContext) {
371 init_test(cx);
372 let fs = FakeFs::new(cx.executor());
373 fs.insert_tree(
374 path!("/dir"),
375 json!({
376 "a.rs": indoc!{"
377 struct SingleLine; // display line 0
378 // display line 1
379 struct MultiLine { // display line 2
380 field_1: i32, // display line 3
381 field_2: i32, // display line 4
382 } // display line 5
383 // display line 6
384 struct Another { // display line 7
385 field_1: i32, // display line 8
386 field_2: i32, // display line 9
387 field_3: i32, // display line 10
388 field_4: i32, // display line 11
389 } // display line 12
390 "}
391 }),
392 )
393 .await;
394
395 let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
396 let (multi_workspace, cx) =
397 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
398 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
399 let worktree_id = workspace.update(cx, |workspace, cx| {
400 workspace.project().update(cx, |project, cx| {
401 project.worktrees(cx).next().unwrap().read(cx).id()
402 })
403 });
404 let _buffer = project
405 .update(cx, |project, cx| {
406 project.open_local_buffer(path!("/dir/a.rs"), cx)
407 })
408 .await
409 .unwrap();
410 let editor = workspace
411 .update_in(cx, |workspace, window, cx| {
412 workspace.open_path((worktree_id, rel_path("a.rs")), None, true, window, cx)
413 })
414 .await
415 .unwrap()
416 .downcast::<Editor>()
417 .unwrap();
418
419 let go_to_line_view = open_go_to_line_view(&workspace, cx);
420 assert_eq!(
421 highlighted_display_rows(&editor, cx),
422 Vec::<u32>::new(),
423 "Initially opened go to line modal should not highlight any rows"
424 );
425 assert_single_caret_at_row(&editor, 0, cx);
426
427 cx.simulate_input("1");
428 assert_eq!(
429 highlighted_display_rows(&editor, cx),
430 vec![0],
431 "Go to line modal should highlight a row, corresponding to the query"
432 );
433 assert_single_caret_at_row(&editor, 0, cx);
434
435 cx.simulate_input("8");
436 assert_eq!(
437 highlighted_display_rows(&editor, cx),
438 vec![13],
439 "If the query is too large, the last row should be highlighted"
440 );
441 assert_single_caret_at_row(&editor, 0, cx);
442
443 cx.dispatch_action(menu::Cancel);
444 drop(go_to_line_view);
445 editor.update(cx, |_, _| {});
446 assert_eq!(
447 highlighted_display_rows(&editor, cx),
448 Vec::<u32>::new(),
449 "After cancelling and closing the modal, no rows should be highlighted"
450 );
451 assert_single_caret_at_row(&editor, 0, cx);
452
453 let go_to_line_view = open_go_to_line_view(&workspace, cx);
454 assert_eq!(
455 highlighted_display_rows(&editor, cx),
456 Vec::<u32>::new(),
457 "Reopened modal should not highlight any rows"
458 );
459 assert_single_caret_at_row(&editor, 0, cx);
460
461 let expected_highlighted_row = 4;
462 cx.simulate_input("5");
463 assert_eq!(
464 highlighted_display_rows(&editor, cx),
465 vec![expected_highlighted_row]
466 );
467 assert_single_caret_at_row(&editor, 0, cx);
468 cx.dispatch_action(menu::Confirm);
469 drop(go_to_line_view);
470 editor.update(cx, |_, _| {});
471 assert_eq!(
472 highlighted_display_rows(&editor, cx),
473 Vec::<u32>::new(),
474 "After confirming and closing the modal, no rows should be highlighted"
475 );
476 // On confirm, should place the caret on the highlighted row.
477 assert_single_caret_at_row(&editor, expected_highlighted_row, cx);
478 }
479
480 #[gpui::test]
481 async fn test_unicode_characters_selection(cx: &mut TestAppContext) {
482 init_test(cx);
483
484 let fs = FakeFs::new(cx.executor());
485 fs.insert_tree(
486 path!("/dir"),
487 json!({
488 "a.rs": "ēlo"
489 }),
490 )
491 .await;
492
493 let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
494 let (multi_workspace, cx) =
495 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
496 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
497 workspace.update_in(cx, |workspace, window, cx| {
498 let cursor_position = cx.new(|_| CursorPosition::new(workspace));
499 workspace.status_bar().update(cx, |status_bar, cx| {
500 status_bar.add_right_item(cursor_position, window, cx);
501 });
502 });
503
504 let worktree_id = workspace.update(cx, |workspace, cx| {
505 workspace.project().update(cx, |project, cx| {
506 project.worktrees(cx).next().unwrap().read(cx).id()
507 })
508 });
509 let _buffer = project
510 .update(cx, |project, cx| {
511 project.open_local_buffer(path!("/dir/a.rs"), cx)
512 })
513 .await
514 .unwrap();
515 let editor = workspace
516 .update_in(cx, |workspace, window, cx| {
517 workspace.open_path((worktree_id, rel_path("a.rs")), None, true, window, cx)
518 })
519 .await
520 .unwrap()
521 .downcast::<Editor>()
522 .unwrap();
523
524 cx.executor().advance_clock(Duration::from_millis(200));
525 workspace.update(cx, |workspace, cx| {
526 assert_eq!(
527 &SelectionStats {
528 lines: 0,
529 characters: 0,
530 selections: 1,
531 },
532 workspace
533 .status_bar()
534 .read(cx)
535 .item_of_type::<CursorPosition>()
536 .expect("missing cursor position item")
537 .read(cx)
538 .selection_stats(),
539 "No selections should be initially"
540 );
541 });
542 editor.update_in(cx, |editor, window, cx| {
543 editor.select_all(&SelectAll, window, cx)
544 });
545 cx.executor().advance_clock(Duration::from_millis(200));
546 workspace.update(cx, |workspace, cx| {
547 assert_eq!(
548 &SelectionStats {
549 lines: 1,
550 characters: 3,
551 selections: 1,
552 },
553 workspace
554 .status_bar()
555 .read(cx)
556 .item_of_type::<CursorPosition>()
557 .expect("missing cursor position item")
558 .read(cx)
559 .selection_stats(),
560 "After selecting a text with multibyte unicode characters, the character count should be correct"
561 );
562 });
563 }
564
565 #[gpui::test]
566 async fn test_unicode_line_numbers(cx: &mut TestAppContext) {
567 init_test(cx);
568
569 let text = "ēlo你好";
570 let fs = FakeFs::new(cx.executor());
571 fs.insert_tree(
572 path!("/dir"),
573 json!({
574 "a.rs": text
575 }),
576 )
577 .await;
578
579 let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
580 let (multi_workspace, cx) =
581 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
582 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
583 workspace.update_in(cx, |workspace, window, cx| {
584 let cursor_position = cx.new(|_| CursorPosition::new(workspace));
585 workspace.status_bar().update(cx, |status_bar, cx| {
586 status_bar.add_right_item(cursor_position, window, cx);
587 });
588 });
589
590 let worktree_id = workspace.update(cx, |workspace, cx| {
591 workspace.project().update(cx, |project, cx| {
592 project.worktrees(cx).next().unwrap().read(cx).id()
593 })
594 });
595 let _buffer = project
596 .update(cx, |project, cx| {
597 project.open_local_buffer(path!("/dir/a.rs"), cx)
598 })
599 .await
600 .unwrap();
601 let editor = workspace
602 .update_in(cx, |workspace, window, cx| {
603 workspace.open_path((worktree_id, rel_path("a.rs")), None, true, window, cx)
604 })
605 .await
606 .unwrap()
607 .downcast::<Editor>()
608 .unwrap();
609
610 editor.update_in(cx, |editor, window, cx| {
611 editor.move_to_beginning(&MoveToBeginning, window, cx)
612 });
613 cx.executor().advance_clock(Duration::from_millis(200));
614 assert_eq!(
615 user_caret_position(1, 1),
616 current_position(&workspace, cx),
617 "Beginning of the line should be at first line, before any characters"
618 );
619
620 for (i, c) in text.chars().enumerate() {
621 let i = i as u32 + 1;
622 editor.update_in(cx, |editor, window, cx| {
623 editor.move_right(&MoveRight, window, cx)
624 });
625 cx.executor().advance_clock(Duration::from_millis(200));
626 assert_eq!(
627 user_caret_position(1, i + 1),
628 current_position(&workspace, cx),
629 "Wrong position for char '{c}' in string '{text}'",
630 );
631 }
632
633 editor.update_in(cx, |editor, window, cx| {
634 editor.move_right(&MoveRight, window, cx)
635 });
636 cx.executor().advance_clock(Duration::from_millis(200));
637 assert_eq!(
638 user_caret_position(1, text.chars().count() as u32 + 1),
639 current_position(&workspace, cx),
640 "After reaching the end of the text, position should not change when moving right"
641 );
642 }
643
644 #[gpui::test]
645 async fn test_go_into_unicode(cx: &mut TestAppContext) {
646 init_test(cx);
647
648 let text = "ēlo你好";
649 let fs = FakeFs::new(cx.executor());
650 fs.insert_tree(
651 path!("/dir"),
652 json!({
653 "a.rs": text
654 }),
655 )
656 .await;
657
658 let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
659 let (multi_workspace, cx) =
660 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
661 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
662 workspace.update_in(cx, |workspace, window, cx| {
663 let cursor_position = cx.new(|_| CursorPosition::new(workspace));
664 workspace.status_bar().update(cx, |status_bar, cx| {
665 status_bar.add_right_item(cursor_position, window, cx);
666 });
667 });
668
669 let worktree_id = workspace.update(cx, |workspace, cx| {
670 workspace.project().update(cx, |project, cx| {
671 project.worktrees(cx).next().unwrap().read(cx).id()
672 })
673 });
674 let _buffer = project
675 .update(cx, |project, cx| {
676 project.open_local_buffer(path!("/dir/a.rs"), cx)
677 })
678 .await
679 .unwrap();
680 let editor = workspace
681 .update_in(cx, |workspace, window, cx| {
682 workspace.open_path((worktree_id, rel_path("a.rs")), None, true, window, cx)
683 })
684 .await
685 .unwrap()
686 .downcast::<Editor>()
687 .unwrap();
688
689 editor.update_in(cx, |editor, window, cx| {
690 editor.move_to_beginning(&MoveToBeginning, window, cx)
691 });
692 cx.executor().advance_clock(Duration::from_millis(200));
693 assert_eq!(user_caret_position(1, 1), current_position(&workspace, cx));
694
695 for (i, c) in text.chars().enumerate() {
696 let i = i as u32 + 1;
697 let point = user_caret_position(1, i + 1);
698 go_to_point(point, user_caret_position(1, i), &workspace, cx);
699 cx.executor().advance_clock(Duration::from_millis(200));
700 assert_eq!(
701 point,
702 current_position(&workspace, cx),
703 "When going to {point:?}, expecting the cursor to be at char '{c}' in string '{text}'",
704 );
705 }
706
707 go_to_point(
708 user_caret_position(111, 222),
709 user_caret_position(1, text.chars().count() as u32 + 1),
710 &workspace,
711 cx,
712 );
713 cx.executor().advance_clock(Duration::from_millis(200));
714 assert_eq!(
715 user_caret_position(1, text.chars().count() as u32 + 1),
716 current_position(&workspace, cx),
717 "When going into too large point, should go to the end of the text"
718 );
719 }
720
721 fn current_position(
722 workspace: &Entity<Workspace>,
723 cx: &mut VisualTestContext,
724 ) -> UserCaretPosition {
725 workspace.update(cx, |workspace, cx| {
726 workspace
727 .status_bar()
728 .read(cx)
729 .item_of_type::<CursorPosition>()
730 .expect("missing cursor position item")
731 .read(cx)
732 .position()
733 .expect("No position found")
734 })
735 }
736
737 fn user_caret_position(line: u32, character: u32) -> UserCaretPosition {
738 UserCaretPosition {
739 line: NonZeroU32::new(line).unwrap(),
740 character: NonZeroU32::new(character).unwrap(),
741 }
742 }
743
744 fn go_to_point(
745 new_point: UserCaretPosition,
746 expected_placeholder: UserCaretPosition,
747 workspace: &Entity<Workspace>,
748 cx: &mut VisualTestContext,
749 ) {
750 let go_to_line_view = open_go_to_line_view(workspace, cx);
751 go_to_line_view.update(cx, |go_to_line_view, cx| {
752 assert_eq!(
753 go_to_line_view.line_editor.update(cx, |line_editor, cx| {
754 line_editor
755 .placeholder_text(cx)
756 .expect("No placeholder text")
757 }),
758 format!(
759 "{}:{}",
760 expected_placeholder.line, expected_placeholder.character
761 )
762 );
763 });
764 cx.simulate_input(&format!("{}:{}", new_point.line, new_point.character));
765 cx.dispatch_action(menu::Confirm);
766 }
767
768 fn open_go_to_line_view(
769 workspace: &Entity<Workspace>,
770 cx: &mut VisualTestContext,
771 ) -> Entity<GoToLine> {
772 cx.dispatch_action(editor::actions::ToggleGoToLine);
773 workspace.update(cx, |workspace, cx| {
774 workspace.active_modal::<GoToLine>(cx).unwrap()
775 })
776 }
777
778 fn highlighted_display_rows(editor: &Entity<Editor>, cx: &mut VisualTestContext) -> Vec<u32> {
779 editor.update_in(cx, |editor, window, cx| {
780 editor
781 .highlighted_display_rows(window, cx)
782 .into_keys()
783 .map(|r| r.0)
784 .collect()
785 })
786 }
787
788 #[track_caller]
789 fn assert_single_caret_at_row(
790 editor: &Entity<Editor>,
791 buffer_row: u32,
792 cx: &mut VisualTestContext,
793 ) {
794 let selections = editor.update(cx, |editor, cx| {
795 editor
796 .selections
797 .all::<rope::Point>(&editor.display_snapshot(cx))
798 .into_iter()
799 .map(|s| s.start..s.end)
800 .collect::<Vec<_>>()
801 });
802 assert!(
803 selections.len() == 1,
804 "Expected one caret selection but got: {selections:?}"
805 );
806 let selection = &selections[0];
807 assert!(
808 selection.start == selection.end,
809 "Expected a single caret selection, but got: {selection:?}"
810 );
811 assert_eq!(selection.start.row, buffer_row);
812 }
813
814 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
815 cx.update(|cx| {
816 let state = AppState::test(cx);
817 crate::init(cx);
818 editor::init(cx);
819 state
820 })
821 }
822
823 #[gpui::test]
824 async fn test_scroll_position_on_outside_click(cx: &mut TestAppContext) {
825 init_test(cx);
826
827 let fs = FakeFs::new(cx.executor());
828 let file_content = (0..100)
829 .map(|i| format!("struct Line{};", i))
830 .collect::<Vec<_>>()
831 .join("\n");
832 fs.insert_tree(path!("/dir"), json!({"a.rs": file_content}))
833 .await;
834
835 let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
836 let (multi_workspace, cx) =
837 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
838 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
839 let worktree_id = workspace.update(cx, |workspace, cx| {
840 workspace.project().update(cx, |project, cx| {
841 project.worktrees(cx).next().unwrap().read(cx).id()
842 })
843 });
844 let _buffer = project
845 .update(cx, |project, cx| {
846 project.open_local_buffer(path!("/dir/a.rs"), cx)
847 })
848 .await
849 .unwrap();
850 let editor = workspace
851 .update_in(cx, |workspace, window, cx| {
852 workspace.open_path((worktree_id, rel_path("a.rs")), None, true, window, cx)
853 })
854 .await
855 .unwrap()
856 .downcast::<Editor>()
857 .unwrap();
858 let go_to_line_view = open_go_to_line_view(&workspace, cx);
859
860 let scroll_position_before_input =
861 editor.update(cx, |editor, cx| editor.scroll_position(cx));
862 cx.simulate_input("47");
863 let scroll_position_after_input =
864 editor.update(cx, |editor, cx| editor.scroll_position(cx));
865 assert_ne!(scroll_position_before_input, scroll_position_after_input);
866
867 drop(go_to_line_view);
868 workspace.update_in(cx, |workspace, window, cx| {
869 workspace.hide_modal(window, cx);
870 });
871 cx.run_until_parked();
872
873 let scroll_position_after_auto_dismiss =
874 editor.update(cx, |editor, cx| editor.scroll_position(cx));
875 assert_eq!(
876 scroll_position_after_auto_dismiss, scroll_position_after_input,
877 "Dismissing via outside click should maintain new scroll position"
878 );
879 }
880
881 #[gpui::test]
882 async fn test_scroll_position_on_cancel(cx: &mut TestAppContext) {
883 init_test(cx);
884
885 let fs = FakeFs::new(cx.executor());
886 let file_content = (0..100)
887 .map(|i| format!("struct Line{};", i))
888 .collect::<Vec<_>>()
889 .join("\n");
890 fs.insert_tree(path!("/dir"), json!({"a.rs": file_content}))
891 .await;
892
893 let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
894 let (multi_workspace, cx) =
895 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
896 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
897 let worktree_id = workspace.update(cx, |workspace, cx| {
898 workspace.project().update(cx, |project, cx| {
899 project.worktrees(cx).next().unwrap().read(cx).id()
900 })
901 });
902 let _buffer = project
903 .update(cx, |project, cx| {
904 project.open_local_buffer(path!("/dir/a.rs"), cx)
905 })
906 .await
907 .unwrap();
908 let editor = workspace
909 .update_in(cx, |workspace, window, cx| {
910 workspace.open_path((worktree_id, rel_path("a.rs")), None, true, window, cx)
911 })
912 .await
913 .unwrap()
914 .downcast::<Editor>()
915 .unwrap();
916 let go_to_line_view = open_go_to_line_view(&workspace, cx);
917
918 let scroll_position_before_input =
919 editor.update(cx, |editor, cx| editor.scroll_position(cx));
920 cx.simulate_input("47");
921 let scroll_position_after_input =
922 editor.update(cx, |editor, cx| editor.scroll_position(cx));
923 assert_ne!(scroll_position_before_input, scroll_position_after_input);
924
925 cx.dispatch_action(menu::Cancel);
926 drop(go_to_line_view);
927 cx.run_until_parked();
928
929 let scroll_position_after_cancel =
930 editor.update(cx, |editor, cx| editor.scroll_position(cx));
931 assert_eq!(
932 scroll_position_after_cancel, scroll_position_after_input,
933 "Cancel should maintain new scroll position"
934 );
935 }
936
937 #[gpui::test]
938 async fn test_scroll_position_on_confirm(cx: &mut TestAppContext) {
939 init_test(cx);
940
941 let fs = FakeFs::new(cx.executor());
942 let file_content = (0..100)
943 .map(|i| format!("struct Line{};", i))
944 .collect::<Vec<_>>()
945 .join("\n");
946 fs.insert_tree(path!("/dir"), json!({"a.rs": file_content}))
947 .await;
948
949 let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
950 let (multi_workspace, cx) =
951 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
952 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
953 let worktree_id = workspace.update(cx, |workspace, cx| {
954 workspace.project().update(cx, |project, cx| {
955 project.worktrees(cx).next().unwrap().read(cx).id()
956 })
957 });
958 let _buffer = project
959 .update(cx, |project, cx| {
960 project.open_local_buffer(path!("/dir/a.rs"), cx)
961 })
962 .await
963 .unwrap();
964 let editor = workspace
965 .update_in(cx, |workspace, window, cx| {
966 workspace.open_path((worktree_id, rel_path("a.rs")), None, true, window, cx)
967 })
968 .await
969 .unwrap()
970 .downcast::<Editor>()
971 .unwrap();
972 let go_to_line_view = open_go_to_line_view(&workspace, cx);
973
974 let scroll_position_before_input =
975 editor.update(cx, |editor, cx| editor.scroll_position(cx));
976 cx.simulate_input("47");
977 let scroll_position_after_input =
978 editor.update(cx, |editor, cx| editor.scroll_position(cx));
979 assert_ne!(scroll_position_before_input, scroll_position_after_input);
980
981 cx.dispatch_action(menu::Confirm);
982 drop(go_to_line_view);
983 cx.run_until_parked();
984
985 let scroll_position_after_confirm =
986 editor.update(cx, |editor, cx| editor.scroll_position(cx));
987 assert_eq!(
988 scroll_position_after_confirm, scroll_position_after_input,
989 "Confirm should maintain new scroll position"
990 );
991 }
992}