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