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