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