go_to_line.rs

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