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