go_to_line.rs

  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}