go_to_line.rs

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