go_to_line.rs

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