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}