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