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, MultiWorkspace, 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 (multi_workspace, cx) =
411 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
412 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
413 let worktree_id = workspace.update(cx, |workspace, cx| {
414 workspace.project().update(cx, |project, cx| {
415 project.worktrees(cx).next().unwrap().read(cx).id()
416 })
417 });
418 let _buffer = project
419 .update(cx, |project, cx| {
420 project.open_local_buffer(path!("/dir/a.rs"), cx)
421 })
422 .await
423 .unwrap();
424 let editor = workspace
425 .update_in(cx, |workspace, window, cx| {
426 workspace.open_path((worktree_id, rel_path("a.rs")), None, true, window, cx)
427 })
428 .await
429 .unwrap()
430 .downcast::<Editor>()
431 .unwrap();
432
433 let go_to_line_view = open_go_to_line_view(&workspace, cx);
434 assert_eq!(
435 highlighted_display_rows(&editor, cx),
436 Vec::<u32>::new(),
437 "Initially opened go to line modal should not highlight any rows"
438 );
439 assert_single_caret_at_row(&editor, 0, cx);
440
441 cx.simulate_input("1");
442 assert_eq!(
443 highlighted_display_rows(&editor, cx),
444 vec![0],
445 "Go to line modal should highlight a row, corresponding to the query"
446 );
447 assert_single_caret_at_row(&editor, 0, cx);
448
449 cx.simulate_input("8");
450 assert_eq!(
451 highlighted_display_rows(&editor, cx),
452 vec![13],
453 "If the query is too large, the last row should be highlighted"
454 );
455 assert_single_caret_at_row(&editor, 0, cx);
456
457 cx.dispatch_action(menu::Cancel);
458 drop(go_to_line_view);
459 editor.update(cx, |_, _| {});
460 assert_eq!(
461 highlighted_display_rows(&editor, cx),
462 Vec::<u32>::new(),
463 "After cancelling and closing the modal, no rows should be highlighted"
464 );
465 assert_single_caret_at_row(&editor, 0, cx);
466
467 let go_to_line_view = open_go_to_line_view(&workspace, cx);
468 assert_eq!(
469 highlighted_display_rows(&editor, cx),
470 Vec::<u32>::new(),
471 "Reopened modal should not highlight any rows"
472 );
473 assert_single_caret_at_row(&editor, 0, cx);
474
475 let expected_highlighted_row = 4;
476 cx.simulate_input("5");
477 assert_eq!(
478 highlighted_display_rows(&editor, cx),
479 vec![expected_highlighted_row]
480 );
481 assert_single_caret_at_row(&editor, 0, cx);
482 cx.dispatch_action(menu::Confirm);
483 drop(go_to_line_view);
484 editor.update(cx, |_, _| {});
485 assert_eq!(
486 highlighted_display_rows(&editor, cx),
487 Vec::<u32>::new(),
488 "After confirming and closing the modal, no rows should be highlighted"
489 );
490 // On confirm, should place the caret on the highlighted row.
491 assert_single_caret_at_row(&editor, expected_highlighted_row, cx);
492 }
493
494 #[gpui::test]
495 async fn test_unicode_characters_selection(cx: &mut TestAppContext) {
496 init_test(cx);
497
498 let fs = FakeFs::new(cx.executor());
499 fs.insert_tree(
500 path!("/dir"),
501 json!({
502 "a.rs": "ēlo"
503 }),
504 )
505 .await;
506
507 let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
508 let (multi_workspace, cx) =
509 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
510 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
511 workspace.update_in(cx, |workspace, window, cx| {
512 let cursor_position = cx.new(|_| CursorPosition::new(workspace));
513 workspace.status_bar().update(cx, |status_bar, cx| {
514 status_bar.add_right_item(cursor_position, window, cx);
515 });
516 });
517
518 let worktree_id = workspace.update(cx, |workspace, cx| {
519 workspace.project().update(cx, |project, cx| {
520 project.worktrees(cx).next().unwrap().read(cx).id()
521 })
522 });
523 let _buffer = project
524 .update(cx, |project, cx| {
525 project.open_local_buffer(path!("/dir/a.rs"), cx)
526 })
527 .await
528 .unwrap();
529 let editor = workspace
530 .update_in(cx, |workspace, window, cx| {
531 workspace.open_path((worktree_id, rel_path("a.rs")), None, true, window, cx)
532 })
533 .await
534 .unwrap()
535 .downcast::<Editor>()
536 .unwrap();
537
538 cx.executor().advance_clock(Duration::from_millis(200));
539 workspace.update(cx, |workspace, cx| {
540 assert_eq!(
541 &SelectionStats {
542 lines: 0,
543 characters: 0,
544 selections: 1,
545 },
546 workspace
547 .status_bar()
548 .read(cx)
549 .item_of_type::<CursorPosition>()
550 .expect("missing cursor position item")
551 .read(cx)
552 .selection_stats(),
553 "No selections should be initially"
554 );
555 });
556 editor.update_in(cx, |editor, window, cx| {
557 editor.select_all(&SelectAll, window, cx)
558 });
559 cx.executor().advance_clock(Duration::from_millis(200));
560 workspace.update(cx, |workspace, cx| {
561 assert_eq!(
562 &SelectionStats {
563 lines: 1,
564 characters: 3,
565 selections: 1,
566 },
567 workspace
568 .status_bar()
569 .read(cx)
570 .item_of_type::<CursorPosition>()
571 .expect("missing cursor position item")
572 .read(cx)
573 .selection_stats(),
574 "After selecting a text with multibyte unicode characters, the character count should be correct"
575 );
576 });
577 }
578
579 #[gpui::test]
580 async fn test_unicode_line_numbers(cx: &mut TestAppContext) {
581 init_test(cx);
582
583 let text = "ēlo你好";
584 let fs = FakeFs::new(cx.executor());
585 fs.insert_tree(
586 path!("/dir"),
587 json!({
588 "a.rs": text
589 }),
590 )
591 .await;
592
593 let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
594 let (multi_workspace, cx) =
595 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
596 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
597 workspace.update_in(cx, |workspace, window, cx| {
598 let cursor_position = cx.new(|_| CursorPosition::new(workspace));
599 workspace.status_bar().update(cx, |status_bar, cx| {
600 status_bar.add_right_item(cursor_position, window, cx);
601 });
602 });
603
604 let worktree_id = workspace.update(cx, |workspace, cx| {
605 workspace.project().update(cx, |project, cx| {
606 project.worktrees(cx).next().unwrap().read(cx).id()
607 })
608 });
609 let _buffer = project
610 .update(cx, |project, cx| {
611 project.open_local_buffer(path!("/dir/a.rs"), cx)
612 })
613 .await
614 .unwrap();
615 let editor = workspace
616 .update_in(cx, |workspace, window, cx| {
617 workspace.open_path((worktree_id, rel_path("a.rs")), None, true, window, cx)
618 })
619 .await
620 .unwrap()
621 .downcast::<Editor>()
622 .unwrap();
623
624 editor.update_in(cx, |editor, window, cx| {
625 editor.move_to_beginning(&MoveToBeginning, window, cx)
626 });
627 cx.executor().advance_clock(Duration::from_millis(200));
628 assert_eq!(
629 user_caret_position(1, 1),
630 current_position(&workspace, cx),
631 "Beginning of the line should be at first line, before any characters"
632 );
633
634 for (i, c) in text.chars().enumerate() {
635 let i = i as u32 + 1;
636 editor.update_in(cx, |editor, window, cx| {
637 editor.move_right(&MoveRight, window, cx)
638 });
639 cx.executor().advance_clock(Duration::from_millis(200));
640 assert_eq!(
641 user_caret_position(1, i + 1),
642 current_position(&workspace, cx),
643 "Wrong position for char '{c}' in string '{text}'",
644 );
645 }
646
647 editor.update_in(cx, |editor, window, cx| {
648 editor.move_right(&MoveRight, window, cx)
649 });
650 cx.executor().advance_clock(Duration::from_millis(200));
651 assert_eq!(
652 user_caret_position(1, text.chars().count() as u32 + 1),
653 current_position(&workspace, cx),
654 "After reaching the end of the text, position should not change when moving right"
655 );
656 }
657
658 #[gpui::test]
659 async fn test_go_into_unicode(cx: &mut TestAppContext) {
660 init_test(cx);
661
662 let text = "ēlo你好";
663 let fs = FakeFs::new(cx.executor());
664 fs.insert_tree(
665 path!("/dir"),
666 json!({
667 "a.rs": text
668 }),
669 )
670 .await;
671
672 let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
673 let (multi_workspace, cx) =
674 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
675 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
676 workspace.update_in(cx, |workspace, window, cx| {
677 let cursor_position = cx.new(|_| CursorPosition::new(workspace));
678 workspace.status_bar().update(cx, |status_bar, cx| {
679 status_bar.add_right_item(cursor_position, window, cx);
680 });
681 });
682
683 let worktree_id = workspace.update(cx, |workspace, cx| {
684 workspace.project().update(cx, |project, cx| {
685 project.worktrees(cx).next().unwrap().read(cx).id()
686 })
687 });
688 let _buffer = project
689 .update(cx, |project, cx| {
690 project.open_local_buffer(path!("/dir/a.rs"), cx)
691 })
692 .await
693 .unwrap();
694 let editor = workspace
695 .update_in(cx, |workspace, window, cx| {
696 workspace.open_path((worktree_id, rel_path("a.rs")), None, true, window, cx)
697 })
698 .await
699 .unwrap()
700 .downcast::<Editor>()
701 .unwrap();
702
703 editor.update_in(cx, |editor, window, cx| {
704 editor.move_to_beginning(&MoveToBeginning, window, cx)
705 });
706 cx.executor().advance_clock(Duration::from_millis(200));
707 assert_eq!(user_caret_position(1, 1), current_position(&workspace, cx));
708
709 for (i, c) in text.chars().enumerate() {
710 let i = i as u32 + 1;
711 let point = user_caret_position(1, i + 1);
712 go_to_point(point, user_caret_position(1, i), &workspace, cx);
713 cx.executor().advance_clock(Duration::from_millis(200));
714 assert_eq!(
715 point,
716 current_position(&workspace, cx),
717 "When going to {point:?}, expecting the cursor to be at char '{c}' in string '{text}'",
718 );
719 }
720
721 go_to_point(
722 user_caret_position(111, 222),
723 user_caret_position(1, text.chars().count() as u32 + 1),
724 &workspace,
725 cx,
726 );
727 cx.executor().advance_clock(Duration::from_millis(200));
728 assert_eq!(
729 user_caret_position(1, text.chars().count() as u32 + 1),
730 current_position(&workspace, cx),
731 "When going into too large point, should go to the end of the text"
732 );
733 }
734
735 fn current_position(
736 workspace: &Entity<Workspace>,
737 cx: &mut VisualTestContext,
738 ) -> UserCaretPosition {
739 workspace.update(cx, |workspace, cx| {
740 workspace
741 .status_bar()
742 .read(cx)
743 .item_of_type::<CursorPosition>()
744 .expect("missing cursor position item")
745 .read(cx)
746 .position()
747 .expect("No position found")
748 })
749 }
750
751 fn user_caret_position(line: u32, character: u32) -> UserCaretPosition {
752 UserCaretPosition {
753 line: NonZeroU32::new(line).unwrap(),
754 character: NonZeroU32::new(character).unwrap(),
755 }
756 }
757
758 fn go_to_point(
759 new_point: UserCaretPosition,
760 expected_placeholder: UserCaretPosition,
761 workspace: &Entity<Workspace>,
762 cx: &mut VisualTestContext,
763 ) {
764 let go_to_line_view = open_go_to_line_view(workspace, cx);
765 go_to_line_view.update(cx, |go_to_line_view, cx| {
766 assert_eq!(
767 go_to_line_view.line_editor.update(cx, |line_editor, cx| {
768 line_editor
769 .placeholder_text(cx)
770 .expect("No placeholder text")
771 }),
772 format!(
773 "{}:{}",
774 expected_placeholder.line, expected_placeholder.character
775 )
776 );
777 });
778 cx.simulate_input(&format!("{}:{}", new_point.line, new_point.character));
779 cx.dispatch_action(menu::Confirm);
780 }
781
782 fn open_go_to_line_view(
783 workspace: &Entity<Workspace>,
784 cx: &mut VisualTestContext,
785 ) -> Entity<GoToLine> {
786 cx.dispatch_action(editor::actions::ToggleGoToLine);
787 workspace.update(cx, |workspace, cx| {
788 workspace.active_modal::<GoToLine>(cx).unwrap()
789 })
790 }
791
792 fn highlighted_display_rows(editor: &Entity<Editor>, cx: &mut VisualTestContext) -> Vec<u32> {
793 editor.update_in(cx, |editor, window, cx| {
794 editor
795 .highlighted_display_rows(window, cx)
796 .into_keys()
797 .map(|r| r.0)
798 .collect()
799 })
800 }
801
802 #[track_caller]
803 fn assert_single_caret_at_row(
804 editor: &Entity<Editor>,
805 buffer_row: u32,
806 cx: &mut VisualTestContext,
807 ) {
808 let selections = editor.update(cx, |editor, cx| {
809 editor
810 .selections
811 .all::<rope::Point>(&editor.display_snapshot(cx))
812 .into_iter()
813 .map(|s| s.start..s.end)
814 .collect::<Vec<_>>()
815 });
816 assert!(
817 selections.len() == 1,
818 "Expected one caret selection but got: {selections:?}"
819 );
820 let selection = &selections[0];
821 assert!(
822 selection.start == selection.end,
823 "Expected a single caret selection, but got: {selection:?}"
824 );
825 assert_eq!(selection.start.row, buffer_row);
826 }
827
828 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
829 cx.update(|cx| {
830 let state = AppState::test(cx);
831 crate::init(cx);
832 editor::init(cx);
833 state
834 })
835 }
836
837 #[gpui::test]
838 async fn test_scroll_position_on_outside_click(cx: &mut TestAppContext) {
839 init_test(cx);
840
841 let fs = FakeFs::new(cx.executor());
842 let file_content = (0..100)
843 .map(|i| format!("struct Line{};", i))
844 .collect::<Vec<_>>()
845 .join("\n");
846 fs.insert_tree(path!("/dir"), json!({"a.rs": file_content}))
847 .await;
848
849 let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
850 let (multi_workspace, cx) =
851 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
852 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
853 let worktree_id = workspace.update(cx, |workspace, cx| {
854 workspace.project().update(cx, |project, cx| {
855 project.worktrees(cx).next().unwrap().read(cx).id()
856 })
857 });
858 let _buffer = project
859 .update(cx, |project, cx| {
860 project.open_local_buffer(path!("/dir/a.rs"), cx)
861 })
862 .await
863 .unwrap();
864 let editor = workspace
865 .update_in(cx, |workspace, window, cx| {
866 workspace.open_path((worktree_id, rel_path("a.rs")), None, true, window, cx)
867 })
868 .await
869 .unwrap()
870 .downcast::<Editor>()
871 .unwrap();
872 let go_to_line_view = open_go_to_line_view(&workspace, cx);
873
874 let scroll_position_before_input =
875 editor.update(cx, |editor, cx| editor.scroll_position(cx));
876 cx.simulate_input("47");
877 let scroll_position_after_input =
878 editor.update(cx, |editor, cx| editor.scroll_position(cx));
879 assert_ne!(scroll_position_before_input, scroll_position_after_input);
880
881 drop(go_to_line_view);
882 workspace.update_in(cx, |workspace, window, cx| {
883 workspace.hide_modal(window, cx);
884 });
885 cx.run_until_parked();
886
887 let scroll_position_after_auto_dismiss =
888 editor.update(cx, |editor, cx| editor.scroll_position(cx));
889 assert_eq!(
890 scroll_position_after_auto_dismiss, scroll_position_after_input,
891 "Dismissing via outside click should maintain new scroll position"
892 );
893 }
894
895 #[gpui::test]
896 async fn test_scroll_position_on_cancel(cx: &mut TestAppContext) {
897 init_test(cx);
898
899 let fs = FakeFs::new(cx.executor());
900 let file_content = (0..100)
901 .map(|i| format!("struct Line{};", i))
902 .collect::<Vec<_>>()
903 .join("\n");
904 fs.insert_tree(path!("/dir"), json!({"a.rs": file_content}))
905 .await;
906
907 let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
908 let (multi_workspace, cx) =
909 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
910 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
911 let worktree_id = workspace.update(cx, |workspace, cx| {
912 workspace.project().update(cx, |project, cx| {
913 project.worktrees(cx).next().unwrap().read(cx).id()
914 })
915 });
916 let _buffer = project
917 .update(cx, |project, cx| {
918 project.open_local_buffer(path!("/dir/a.rs"), cx)
919 })
920 .await
921 .unwrap();
922 let editor = workspace
923 .update_in(cx, |workspace, window, cx| {
924 workspace.open_path((worktree_id, rel_path("a.rs")), None, true, window, cx)
925 })
926 .await
927 .unwrap()
928 .downcast::<Editor>()
929 .unwrap();
930 let go_to_line_view = open_go_to_line_view(&workspace, cx);
931
932 let scroll_position_before_input =
933 editor.update(cx, |editor, cx| editor.scroll_position(cx));
934 cx.simulate_input("47");
935 let scroll_position_after_input =
936 editor.update(cx, |editor, cx| editor.scroll_position(cx));
937 assert_ne!(scroll_position_before_input, scroll_position_after_input);
938
939 cx.dispatch_action(menu::Cancel);
940 drop(go_to_line_view);
941 cx.run_until_parked();
942
943 let scroll_position_after_cancel =
944 editor.update(cx, |editor, cx| editor.scroll_position(cx));
945 assert_eq!(
946 scroll_position_after_cancel, scroll_position_after_input,
947 "Cancel should maintain new scroll position"
948 );
949 }
950
951 #[gpui::test]
952 async fn test_scroll_position_on_confirm(cx: &mut TestAppContext) {
953 init_test(cx);
954
955 let fs = FakeFs::new(cx.executor());
956 let file_content = (0..100)
957 .map(|i| format!("struct Line{};", i))
958 .collect::<Vec<_>>()
959 .join("\n");
960 fs.insert_tree(path!("/dir"), json!({"a.rs": file_content}))
961 .await;
962
963 let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
964 let (multi_workspace, cx) =
965 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
966 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
967 let worktree_id = workspace.update(cx, |workspace, cx| {
968 workspace.project().update(cx, |project, cx| {
969 project.worktrees(cx).next().unwrap().read(cx).id()
970 })
971 });
972 let _buffer = project
973 .update(cx, |project, cx| {
974 project.open_local_buffer(path!("/dir/a.rs"), cx)
975 })
976 .await
977 .unwrap();
978 let editor = workspace
979 .update_in(cx, |workspace, window, cx| {
980 workspace.open_path((worktree_id, rel_path("a.rs")), None, true, window, cx)
981 })
982 .await
983 .unwrap()
984 .downcast::<Editor>()
985 .unwrap();
986 let go_to_line_view = open_go_to_line_view(&workspace, cx);
987
988 let scroll_position_before_input =
989 editor.update(cx, |editor, cx| editor.scroll_position(cx));
990 cx.simulate_input("47");
991 let scroll_position_after_input =
992 editor.update(cx, |editor, cx| editor.scroll_position(cx));
993 assert_ne!(scroll_position_before_input, scroll_position_after_input);
994
995 cx.dispatch_action(menu::Confirm);
996 drop(go_to_line_view);
997 cx.run_until_parked();
998
999 let scroll_position_after_confirm =
1000 editor.update(cx, |editor, cx| editor.scroll_position(cx));
1001 assert_eq!(
1002 scroll_position_after_confirm, scroll_position_after_input,
1003 "Confirm should maintain new scroll position"
1004 );
1005 }
1006}