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