go_to_line.rs

  1pub mod cursor_position;
  2
  3use cursor_position::LineIndicatorFormat;
  4use editor::{scroll::Autoscroll, Editor};
  5use gpui::{
  6    div, prelude::*, AnyWindowHandle, AppContext, DismissEvent, EventEmitter, FocusHandle,
  7    FocusableView, Render, SharedString, Styled, Subscription, View, ViewContext, VisualContext,
  8};
  9use settings::Settings;
 10use text::{Bias, Point};
 11use theme::ActiveTheme;
 12use ui::{h_flex, prelude::*, v_flex, Label};
 13use util::paths::FILE_ROW_COLUMN_DELIMITER;
 14use workspace::ModalView;
 15
 16pub fn init(cx: &mut AppContext) {
 17    LineIndicatorFormat::register(cx);
 18    cx.observe_new_views(GoToLine::register).detach();
 19}
 20
 21pub struct GoToLine {
 22    line_editor: View<Editor>,
 23    active_editor: View<Editor>,
 24    current_text: SharedString,
 25    prev_scroll_position: Option<gpui::Point<f32>>,
 26    _subscriptions: Vec<Subscription>,
 27}
 28
 29impl ModalView for GoToLine {}
 30
 31impl FocusableView for GoToLine {
 32    fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
 33        self.line_editor.focus_handle(cx)
 34    }
 35}
 36impl EventEmitter<DismissEvent> for GoToLine {}
 37
 38enum GoToLineRowHighlights {}
 39
 40impl GoToLine {
 41    fn register(editor: &mut Editor, cx: &mut ViewContext<Editor>) {
 42        let handle = cx.view().downgrade();
 43        editor
 44            .register_action(move |_: &editor::actions::ToggleGoToLine, cx| {
 45                let Some(editor) = handle.upgrade() else {
 46                    return;
 47                };
 48                let Some(workspace) = editor.read(cx).workspace() else {
 49                    return;
 50                };
 51                workspace.update(cx, |workspace, cx| {
 52                    workspace.toggle_modal(cx, move |cx| GoToLine::new(editor, cx));
 53                })
 54            })
 55            .detach();
 56    }
 57
 58    pub fn new(active_editor: View<Editor>, cx: &mut ViewContext<Self>) -> Self {
 59        let editor = active_editor.read(cx);
 60        let cursor = editor.selections.last::<Point>(cx).head();
 61
 62        let line = cursor.row + 1;
 63        let column = cursor.column + 1;
 64
 65        let line_editor = cx.new_view(|cx| {
 66            let mut editor = Editor::single_line(cx);
 67            editor.set_placeholder_text(format!("{line}{FILE_ROW_COLUMN_DELIMITER}{column}"), cx);
 68            editor
 69        });
 70        let line_editor_change = cx.subscribe(&line_editor, Self::on_line_editor_event);
 71
 72        let editor = active_editor.read(cx);
 73        let last_line = editor.buffer().read(cx).snapshot(cx).max_point().row;
 74        let scroll_position = active_editor.update(cx, |editor, cx| editor.scroll_position(cx));
 75
 76        let current_text = format!("line {} of {} (column {})", line, last_line + 1, column);
 77
 78        Self {
 79            line_editor,
 80            active_editor,
 81            current_text: current_text.into(),
 82            prev_scroll_position: Some(scroll_position),
 83            _subscriptions: vec![line_editor_change, cx.on_release(Self::release)],
 84        }
 85    }
 86
 87    fn release(&mut self, window: AnyWindowHandle, cx: &mut AppContext) {
 88        window
 89            .update(cx, |_, cx| {
 90                let scroll_position = self.prev_scroll_position.take();
 91                self.active_editor.update(cx, |editor, cx| {
 92                    editor.clear_row_highlights::<GoToLineRowHighlights>();
 93                    if let Some(scroll_position) = scroll_position {
 94                        editor.set_scroll_position(scroll_position, cx);
 95                    }
 96                    cx.notify();
 97                })
 98            })
 99            .ok();
100    }
101
102    fn on_line_editor_event(
103        &mut self,
104        _: View<Editor>,
105        event: &editor::EditorEvent,
106        cx: &mut ViewContext<Self>,
107    ) {
108        match event {
109            editor::EditorEvent::Blurred => cx.emit(DismissEvent),
110            editor::EditorEvent::BufferEdited { .. } => self.highlight_current_line(cx),
111            _ => {}
112        }
113    }
114
115    fn highlight_current_line(&mut self, cx: &mut ViewContext<Self>) {
116        if let Some(point) = self.point_from_query(cx) {
117            self.active_editor.update(cx, |active_editor, cx| {
118                let snapshot = active_editor.snapshot(cx).display_snapshot;
119                let start = snapshot.buffer_snapshot.clip_point(point, Bias::Left);
120                let end = start + Point::new(1, 0);
121                let start = snapshot.buffer_snapshot.anchor_before(start);
122                let end = snapshot.buffer_snapshot.anchor_after(end);
123                active_editor.clear_row_highlights::<GoToLineRowHighlights>();
124                active_editor.highlight_rows::<GoToLineRowHighlights>(
125                    start..end,
126                    cx.theme().colors().editor_highlighted_line_background,
127                    true,
128                    cx,
129                );
130                active_editor.request_autoscroll(Autoscroll::center(), cx);
131            });
132            cx.notify();
133        }
134    }
135
136    fn point_from_query(&self, cx: &ViewContext<Self>) -> Option<Point> {
137        let (row, column) = self.line_column_from_query(cx);
138        Some(Point::new(
139            row?.saturating_sub(1),
140            column.unwrap_or(0).saturating_sub(1),
141        ))
142    }
143
144    fn line_column_from_query(&self, cx: &ViewContext<Self>) -> (Option<u32>, Option<u32>) {
145        let input = self.line_editor.read(cx).text(cx);
146        let mut components = input
147            .splitn(2, FILE_ROW_COLUMN_DELIMITER)
148            .map(str::trim)
149            .fuse();
150        let row = components.next().and_then(|row| row.parse::<u32>().ok());
151        let column = components.next().and_then(|col| col.parse::<u32>().ok());
152        (row, column)
153    }
154
155    fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
156        cx.emit(DismissEvent);
157    }
158
159    fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
160        if let Some(point) = self.point_from_query(cx) {
161            self.active_editor.update(cx, |editor, cx| {
162                let snapshot = editor.snapshot(cx).display_snapshot;
163                let point = snapshot.buffer_snapshot.clip_point(point, Bias::Left);
164                editor.change_selections(Some(Autoscroll::center()), cx, |s| {
165                    s.select_ranges([point..point])
166                });
167                editor.focus(cx);
168                cx.notify();
169            });
170            self.prev_scroll_position.take();
171        }
172
173        cx.emit(DismissEvent);
174    }
175}
176
177impl Render for GoToLine {
178    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
179        let mut help_text = self.current_text.clone();
180        let query = self.line_column_from_query(cx);
181        if let Some(line) = query.0 {
182            if let Some(column) = query.1 {
183                help_text = format!("Go to line {line}, column {column}").into();
184            } else {
185                help_text = format!("Go to line {line}").into();
186            }
187        }
188
189        div()
190            .elevation_2(cx)
191            .key_context("GoToLine")
192            .on_action(cx.listener(Self::cancel))
193            .on_action(cx.listener(Self::confirm))
194            .w_96()
195            .child(
196                v_flex()
197                    .px_1()
198                    .pt_0p5()
199                    .gap_px()
200                    .child(
201                        v_flex()
202                            .py_0p5()
203                            .px_1()
204                            .child(div().px_1().py_0p5().child(self.line_editor.clone())),
205                    )
206                    .child(
207                        div()
208                            .h_px()
209                            .w_full()
210                            .bg(cx.theme().colors().element_background),
211                    )
212                    .child(
213                        h_flex()
214                            .justify_between()
215                            .px_2()
216                            .py_1()
217                            .child(Label::new(help_text).color(Color::Muted)),
218                    ),
219            )
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use cursor_position::{CursorPosition, SelectionStats};
227    use editor::actions::SelectAll;
228    use gpui::{TestAppContext, VisualTestContext};
229    use indoc::indoc;
230    use project::{FakeFs, Project};
231    use serde_json::json;
232    use std::sync::Arc;
233    use workspace::{AppState, Workspace};
234
235    #[gpui::test]
236    async fn test_go_to_line_view_row_highlights(cx: &mut TestAppContext) {
237        init_test(cx);
238        let fs = FakeFs::new(cx.executor());
239        fs.insert_tree(
240            "/dir",
241            json!({
242                "a.rs": indoc!{"
243                    struct SingleLine; // display line 0
244                                       // display line 1
245                    struct MultiLine { // display line 2
246                        field_1: i32,  // display line 3
247                        field_2: i32,  // display line 4
248                    }                  // display line 5
249                                       // display line 6
250                    struct Another {   // display line 7
251                        field_1: i32,  // display line 8
252                        field_2: i32,  // display line 9
253                        field_3: i32,  // display line 10
254                        field_4: i32,  // display line 11
255                    }                  // display line 12
256                "}
257            }),
258        )
259        .await;
260
261        let project = Project::test(fs, ["/dir".as_ref()], cx).await;
262        let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx));
263        let worktree_id = workspace.update(cx, |workspace, cx| {
264            workspace.project().update(cx, |project, cx| {
265                project.worktrees(cx).next().unwrap().read(cx).id()
266            })
267        });
268        let _buffer = project
269            .update(cx, |project, cx| project.open_local_buffer("/dir/a.rs", cx))
270            .await
271            .unwrap();
272        let editor = workspace
273            .update(cx, |workspace, cx| {
274                workspace.open_path((worktree_id, "a.rs"), None, true, cx)
275            })
276            .await
277            .unwrap()
278            .downcast::<Editor>()
279            .unwrap();
280
281        let go_to_line_view = open_go_to_line_view(&workspace, cx);
282        assert_eq!(
283            highlighted_display_rows(&editor, cx),
284            Vec::<u32>::new(),
285            "Initially opened go to line modal should not highlight any rows"
286        );
287        assert_single_caret_at_row(&editor, 0, cx);
288
289        cx.simulate_input("1");
290        assert_eq!(
291            highlighted_display_rows(&editor, cx),
292            vec![0],
293            "Go to line modal should highlight a row, corresponding to the query"
294        );
295        assert_single_caret_at_row(&editor, 0, cx);
296
297        cx.simulate_input("8");
298        assert_eq!(
299            highlighted_display_rows(&editor, cx),
300            vec![13],
301            "If the query is too large, the last row should be highlighted"
302        );
303        assert_single_caret_at_row(&editor, 0, cx);
304
305        cx.dispatch_action(menu::Cancel);
306        drop(go_to_line_view);
307        editor.update(cx, |_, _| {});
308        assert_eq!(
309            highlighted_display_rows(&editor, cx),
310            Vec::<u32>::new(),
311            "After cancelling and closing the modal, no rows should be highlighted"
312        );
313        assert_single_caret_at_row(&editor, 0, cx);
314
315        let go_to_line_view = open_go_to_line_view(&workspace, cx);
316        assert_eq!(
317            highlighted_display_rows(&editor, cx),
318            Vec::<u32>::new(),
319            "Reopened modal should not highlight any rows"
320        );
321        assert_single_caret_at_row(&editor, 0, cx);
322
323        let expected_highlighted_row = 4;
324        cx.simulate_input("5");
325        assert_eq!(
326            highlighted_display_rows(&editor, cx),
327            vec![expected_highlighted_row]
328        );
329        assert_single_caret_at_row(&editor, 0, cx);
330        cx.dispatch_action(menu::Confirm);
331        drop(go_to_line_view);
332        editor.update(cx, |_, _| {});
333        assert_eq!(
334            highlighted_display_rows(&editor, cx),
335            Vec::<u32>::new(),
336            "After confirming and closing the modal, no rows should be highlighted"
337        );
338        // On confirm, should place the caret on the highlighted row.
339        assert_single_caret_at_row(&editor, expected_highlighted_row, cx);
340    }
341
342    #[gpui::test]
343    async fn test_unicode_characters_selection(cx: &mut TestAppContext) {
344        init_test(cx);
345
346        let fs = FakeFs::new(cx.executor());
347        fs.insert_tree(
348            "/dir",
349            json!({
350                "a.rs": "ēlo"
351            }),
352        )
353        .await;
354
355        let project = Project::test(fs, ["/dir".as_ref()], cx).await;
356        let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx));
357        workspace.update(cx, |workspace, cx| {
358            let cursor_position = cx.new_view(|_| CursorPosition::new(workspace));
359            workspace.status_bar().update(cx, |status_bar, cx| {
360                status_bar.add_right_item(cursor_position, cx);
361            });
362        });
363
364        let worktree_id = workspace.update(cx, |workspace, cx| {
365            workspace.project().update(cx, |project, cx| {
366                project.worktrees(cx).next().unwrap().read(cx).id()
367            })
368        });
369        let _buffer = project
370            .update(cx, |project, cx| project.open_local_buffer("/dir/a.rs", cx))
371            .await
372            .unwrap();
373        let editor = workspace
374            .update(cx, |workspace, cx| {
375                workspace.open_path((worktree_id, "a.rs"), None, true, cx)
376            })
377            .await
378            .unwrap()
379            .downcast::<Editor>()
380            .unwrap();
381
382        workspace.update(cx, |workspace, cx| {
383            assert_eq!(
384                &SelectionStats {
385                    lines: 0,
386                    characters: 0,
387                    selections: 1,
388                },
389                workspace
390                    .status_bar()
391                    .read(cx)
392                    .item_of_type::<CursorPosition>()
393                    .expect("missing cursor position item")
394                    .read(cx)
395                    .selection_stats(),
396                "No selections should be initially"
397            );
398        });
399        editor.update(cx, |editor, cx| editor.select_all(&SelectAll, cx));
400        workspace.update(cx, |workspace, cx| {
401            assert_eq!(
402                &SelectionStats {
403                    lines: 1,
404                    characters: 3,
405                    selections: 1,
406                },
407                workspace
408                    .status_bar()
409                    .read(cx)
410                    .item_of_type::<CursorPosition>()
411                    .expect("missing cursor position item")
412                    .read(cx)
413                    .selection_stats(),
414                "After selecting a text with multibyte unicode characters, the character count should be correct"
415            );
416        });
417    }
418
419    fn open_go_to_line_view(
420        workspace: &View<Workspace>,
421        cx: &mut VisualTestContext,
422    ) -> View<GoToLine> {
423        cx.dispatch_action(editor::actions::ToggleGoToLine);
424        workspace.update(cx, |workspace, cx| {
425            workspace.active_modal::<GoToLine>(cx).unwrap().clone()
426        })
427    }
428
429    fn highlighted_display_rows(editor: &View<Editor>, cx: &mut VisualTestContext) -> Vec<u32> {
430        editor.update(cx, |editor, cx| {
431            editor
432                .highlighted_display_rows(cx)
433                .into_keys()
434                .map(|r| r.0)
435                .collect()
436        })
437    }
438
439    #[track_caller]
440    fn assert_single_caret_at_row(
441        editor: &View<Editor>,
442        buffer_row: u32,
443        cx: &mut VisualTestContext,
444    ) {
445        let selections = editor.update(cx, |editor, cx| {
446            editor
447                .selections
448                .all::<rope::Point>(cx)
449                .into_iter()
450                .map(|s| s.start..s.end)
451                .collect::<Vec<_>>()
452        });
453        assert!(
454            selections.len() == 1,
455            "Expected one caret selection but got: {selections:?}"
456        );
457        let selection = &selections[0];
458        assert!(
459            selection.start == selection.end,
460            "Expected a single caret selection, but got: {selection:?}"
461        );
462        assert_eq!(selection.start.row, buffer_row);
463    }
464
465    fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
466        cx.update(|cx| {
467            let state = AppState::test(cx);
468            language::init(cx);
469            crate::init(cx);
470            editor::init(cx);
471            workspace::init_settings(cx);
472            Project::init_settings(cx);
473            state
474        })
475    }
476}