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 workspace::{AppState, Workspace};
302
303    #[gpui::test]
304    async fn test_go_to_line_view_row_highlights(cx: &mut TestAppContext) {
305        init_test(cx);
306        let fs = FakeFs::new(cx.executor());
307        fs.insert_tree(
308            "/dir",
309            json!({
310                "a.rs": indoc!{"
311                    struct SingleLine; // display line 0
312                                       // display line 1
313                    struct MultiLine { // display line 2
314                        field_1: i32,  // display line 3
315                        field_2: i32,  // display line 4
316                    }                  // display line 5
317                                       // display line 6
318                    struct Another {   // display line 7
319                        field_1: i32,  // display line 8
320                        field_2: i32,  // display line 9
321                        field_3: i32,  // display line 10
322                        field_4: i32,  // display line 11
323                    }                  // display line 12
324                "}
325            }),
326        )
327        .await;
328
329        let project = Project::test(fs, ["/dir".as_ref()], cx).await;
330        let (workspace, cx) =
331            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
332        let worktree_id = workspace.update(cx, |workspace, cx| {
333            workspace.project().update(cx, |project, cx| {
334                project.worktrees(cx).next().unwrap().read(cx).id()
335            })
336        });
337        let _buffer = project
338            .update(cx, |project, cx| project.open_local_buffer("/dir/a.rs", cx))
339            .await
340            .unwrap();
341        let editor = workspace
342            .update_in(cx, |workspace, window, cx| {
343                workspace.open_path((worktree_id, "a.rs"), None, true, window, cx)
344            })
345            .await
346            .unwrap()
347            .downcast::<Editor>()
348            .unwrap();
349
350        let go_to_line_view = open_go_to_line_view(&workspace, cx);
351        assert_eq!(
352            highlighted_display_rows(&editor, cx),
353            Vec::<u32>::new(),
354            "Initially opened go to line modal should not highlight any rows"
355        );
356        assert_single_caret_at_row(&editor, 0, cx);
357
358        cx.simulate_input("1");
359        assert_eq!(
360            highlighted_display_rows(&editor, cx),
361            vec![0],
362            "Go to line modal should highlight a row, corresponding to the query"
363        );
364        assert_single_caret_at_row(&editor, 0, cx);
365
366        cx.simulate_input("8");
367        assert_eq!(
368            highlighted_display_rows(&editor, cx),
369            vec![13],
370            "If the query is too large, the last row should be highlighted"
371        );
372        assert_single_caret_at_row(&editor, 0, cx);
373
374        cx.dispatch_action(menu::Cancel);
375        drop(go_to_line_view);
376        editor.update(cx, |_, _| {});
377        assert_eq!(
378            highlighted_display_rows(&editor, cx),
379            Vec::<u32>::new(),
380            "After cancelling and closing the modal, no rows should be highlighted"
381        );
382        assert_single_caret_at_row(&editor, 0, cx);
383
384        let go_to_line_view = open_go_to_line_view(&workspace, cx);
385        assert_eq!(
386            highlighted_display_rows(&editor, cx),
387            Vec::<u32>::new(),
388            "Reopened modal should not highlight any rows"
389        );
390        assert_single_caret_at_row(&editor, 0, cx);
391
392        let expected_highlighted_row = 4;
393        cx.simulate_input("5");
394        assert_eq!(
395            highlighted_display_rows(&editor, cx),
396            vec![expected_highlighted_row]
397        );
398        assert_single_caret_at_row(&editor, 0, cx);
399        cx.dispatch_action(menu::Confirm);
400        drop(go_to_line_view);
401        editor.update(cx, |_, _| {});
402        assert_eq!(
403            highlighted_display_rows(&editor, cx),
404            Vec::<u32>::new(),
405            "After confirming and closing the modal, no rows should be highlighted"
406        );
407        // On confirm, should place the caret on the highlighted row.
408        assert_single_caret_at_row(&editor, expected_highlighted_row, cx);
409    }
410
411    #[gpui::test]
412    async fn test_unicode_characters_selection(cx: &mut TestAppContext) {
413        init_test(cx);
414
415        let fs = FakeFs::new(cx.executor());
416        fs.insert_tree(
417            "/dir",
418            json!({
419                "a.rs": "ēlo"
420            }),
421        )
422        .await;
423
424        let project = Project::test(fs, ["/dir".as_ref()], cx).await;
425        let (workspace, cx) =
426            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
427        workspace.update_in(cx, |workspace, window, cx| {
428            let cursor_position = cx.new(|_| CursorPosition::new(workspace));
429            workspace.status_bar().update(cx, |status_bar, cx| {
430                status_bar.add_right_item(cursor_position, window, cx);
431            });
432        });
433
434        let worktree_id = workspace.update(cx, |workspace, cx| {
435            workspace.project().update(cx, |project, cx| {
436                project.worktrees(cx).next().unwrap().read(cx).id()
437            })
438        });
439        let _buffer = project
440            .update(cx, |project, cx| project.open_local_buffer("/dir/a.rs", cx))
441            .await
442            .unwrap();
443        let editor = workspace
444            .update_in(cx, |workspace, window, cx| {
445                workspace.open_path((worktree_id, "a.rs"), None, true, window, cx)
446            })
447            .await
448            .unwrap()
449            .downcast::<Editor>()
450            .unwrap();
451
452        cx.executor().advance_clock(Duration::from_millis(200));
453        workspace.update(cx, |workspace, cx| {
454            assert_eq!(
455                &SelectionStats {
456                    lines: 0,
457                    characters: 0,
458                    selections: 1,
459                },
460                workspace
461                    .status_bar()
462                    .read(cx)
463                    .item_of_type::<CursorPosition>()
464                    .expect("missing cursor position item")
465                    .read(cx)
466                    .selection_stats(),
467                "No selections should be initially"
468            );
469        });
470        editor.update_in(cx, |editor, window, cx| {
471            editor.select_all(&SelectAll, window, cx)
472        });
473        cx.executor().advance_clock(Duration::from_millis(200));
474        workspace.update(cx, |workspace, cx| {
475            assert_eq!(
476                &SelectionStats {
477                    lines: 1,
478                    characters: 3,
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                "After selecting a text with multibyte unicode characters, the character count should be correct"
489            );
490        });
491    }
492
493    #[gpui::test]
494    async fn test_unicode_line_numbers(cx: &mut TestAppContext) {
495        init_test(cx);
496
497        let text = "ēlo你好";
498        let fs = FakeFs::new(cx.executor());
499        fs.insert_tree(
500            "/dir",
501            json!({
502                "a.rs": text
503            }),
504        )
505        .await;
506
507        let project = Project::test(fs, ["/dir".as_ref()], cx).await;
508        let (workspace, cx) =
509            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
510        workspace.update_in(cx, |workspace, window, cx| {
511            let cursor_position = cx.new(|_| CursorPosition::new(workspace));
512            workspace.status_bar().update(cx, |status_bar, cx| {
513                status_bar.add_right_item(cursor_position, window, cx);
514            });
515        });
516
517        let worktree_id = workspace.update(cx, |workspace, cx| {
518            workspace.project().update(cx, |project, cx| {
519                project.worktrees(cx).next().unwrap().read(cx).id()
520            })
521        });
522        let _buffer = project
523            .update(cx, |project, cx| project.open_local_buffer("/dir/a.rs", cx))
524            .await
525            .unwrap();
526        let editor = workspace
527            .update_in(cx, |workspace, window, cx| {
528                workspace.open_path((worktree_id, "a.rs"), None, true, window, cx)
529            })
530            .await
531            .unwrap()
532            .downcast::<Editor>()
533            .unwrap();
534
535        editor.update_in(cx, |editor, window, cx| {
536            editor.move_to_beginning(&MoveToBeginning, window, cx)
537        });
538        cx.executor().advance_clock(Duration::from_millis(200));
539        assert_eq!(
540            user_caret_position(1, 1),
541            current_position(&workspace, cx),
542            "Beginning of the line should be at first line, before any characters"
543        );
544
545        for (i, c) in text.chars().enumerate() {
546            let i = i as u32 + 1;
547            editor.update_in(cx, |editor, window, cx| {
548                editor.move_right(&MoveRight, window, cx)
549            });
550            cx.executor().advance_clock(Duration::from_millis(200));
551            assert_eq!(
552                user_caret_position(1, i + 1),
553                current_position(&workspace, cx),
554                "Wrong position for char '{c}' in string '{text}'",
555            );
556        }
557
558        editor.update_in(cx, |editor, window, cx| {
559            editor.move_right(&MoveRight, window, cx)
560        });
561        cx.executor().advance_clock(Duration::from_millis(200));
562        assert_eq!(
563            user_caret_position(1, text.chars().count() as u32 + 1),
564            current_position(&workspace, cx),
565            "After reaching the end of the text, position should not change when moving right"
566        );
567    }
568
569    #[gpui::test]
570    async fn test_go_into_unicode(cx: &mut TestAppContext) {
571        init_test(cx);
572
573        let text = "ēlo你好";
574        let fs = FakeFs::new(cx.executor());
575        fs.insert_tree(
576            "/dir",
577            json!({
578                "a.rs": text
579            }),
580        )
581        .await;
582
583        let project = Project::test(fs, ["/dir".as_ref()], cx).await;
584        let (workspace, cx) =
585            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
586        workspace.update_in(cx, |workspace, window, cx| {
587            let cursor_position = cx.new(|_| CursorPosition::new(workspace));
588            workspace.status_bar().update(cx, |status_bar, cx| {
589                status_bar.add_right_item(cursor_position, window, cx);
590            });
591        });
592
593        let worktree_id = workspace.update(cx, |workspace, cx| {
594            workspace.project().update(cx, |project, cx| {
595                project.worktrees(cx).next().unwrap().read(cx).id()
596            })
597        });
598        let _buffer = project
599            .update(cx, |project, cx| project.open_local_buffer("/dir/a.rs", cx))
600            .await
601            .unwrap();
602        let editor = workspace
603            .update_in(cx, |workspace, window, cx| {
604                workspace.open_path((worktree_id, "a.rs"), None, true, window, cx)
605            })
606            .await
607            .unwrap()
608            .downcast::<Editor>()
609            .unwrap();
610
611        editor.update_in(cx, |editor, window, cx| {
612            editor.move_to_beginning(&MoveToBeginning, window, cx)
613        });
614        cx.executor().advance_clock(Duration::from_millis(200));
615        assert_eq!(user_caret_position(1, 1), current_position(&workspace, cx));
616
617        for (i, c) in text.chars().enumerate() {
618            let i = i as u32 + 1;
619            let point = user_caret_position(1, i + 1);
620            go_to_point(point, user_caret_position(1, i), &workspace, cx);
621            cx.executor().advance_clock(Duration::from_millis(200));
622            assert_eq!(
623                point,
624                current_position(&workspace, cx),
625                "When going to {point:?}, expecting the cursor to be at char '{c}' in string '{text}'",
626            );
627        }
628
629        go_to_point(
630            user_caret_position(111, 222),
631            user_caret_position(1, text.chars().count() as u32 + 1),
632            &workspace,
633            cx,
634        );
635        cx.executor().advance_clock(Duration::from_millis(200));
636        assert_eq!(
637            user_caret_position(1, text.chars().count() as u32 + 1),
638            current_position(&workspace, cx),
639            "When going into too large point, should go to the end of the text"
640        );
641    }
642
643    fn current_position(
644        workspace: &Entity<Workspace>,
645        cx: &mut VisualTestContext,
646    ) -> UserCaretPosition {
647        workspace.update(cx, |workspace, cx| {
648            workspace
649                .status_bar()
650                .read(cx)
651                .item_of_type::<CursorPosition>()
652                .expect("missing cursor position item")
653                .read(cx)
654                .position()
655                .expect("No position found")
656        })
657    }
658
659    fn user_caret_position(line: u32, character: u32) -> UserCaretPosition {
660        UserCaretPosition {
661            line: NonZeroU32::new(line).unwrap(),
662            character: NonZeroU32::new(character).unwrap(),
663        }
664    }
665
666    fn go_to_point(
667        new_point: UserCaretPosition,
668        expected_placeholder: UserCaretPosition,
669        workspace: &Entity<Workspace>,
670        cx: &mut VisualTestContext,
671    ) {
672        let go_to_line_view = open_go_to_line_view(workspace, cx);
673        go_to_line_view.update(cx, |go_to_line_view, cx| {
674            assert_eq!(
675                go_to_line_view
676                    .line_editor
677                    .read(cx)
678                    .placeholder_text()
679                    .expect("No placeholder text"),
680                format!(
681                    "{}:{}",
682                    expected_placeholder.line, expected_placeholder.character
683                )
684            );
685        });
686        cx.simulate_input(&format!("{}:{}", new_point.line, new_point.character));
687        cx.dispatch_action(menu::Confirm);
688    }
689
690    fn open_go_to_line_view(
691        workspace: &Entity<Workspace>,
692        cx: &mut VisualTestContext,
693    ) -> Entity<GoToLine> {
694        cx.dispatch_action(editor::actions::ToggleGoToLine);
695        workspace.update(cx, |workspace, cx| {
696            workspace.active_modal::<GoToLine>(cx).unwrap().clone()
697        })
698    }
699
700    fn highlighted_display_rows(editor: &Entity<Editor>, cx: &mut VisualTestContext) -> Vec<u32> {
701        editor.update_in(cx, |editor, window, cx| {
702            editor
703                .highlighted_display_rows(window, cx)
704                .into_keys()
705                .map(|r| r.0)
706                .collect()
707        })
708    }
709
710    #[track_caller]
711    fn assert_single_caret_at_row(
712        editor: &Entity<Editor>,
713        buffer_row: u32,
714        cx: &mut VisualTestContext,
715    ) {
716        let selections = editor.update(cx, |editor, cx| {
717            editor
718                .selections
719                .all::<rope::Point>(cx)
720                .into_iter()
721                .map(|s| s.start..s.end)
722                .collect::<Vec<_>>()
723        });
724        assert!(
725            selections.len() == 1,
726            "Expected one caret selection but got: {selections:?}"
727        );
728        let selection = &selections[0];
729        assert!(
730            selection.start == selection.end,
731            "Expected a single caret selection, but got: {selection:?}"
732        );
733        assert_eq!(selection.start.row, buffer_row);
734    }
735
736    fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
737        cx.update(|cx| {
738            let state = AppState::test(cx);
739            language::init(cx);
740            crate::init(cx);
741            editor::init(cx);
742            workspace::init_settings(cx);
743            Project::init_settings(cx);
744            state
745        })
746    }
747}