go_to_line.rs

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