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