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