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