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