go_to_line.rs

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