go_to_line.rs

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