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