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