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