go_to_line.rs

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