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