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 collections::HashSet;
225 use gpui::{TestAppContext, VisualTestContext};
226 use indoc::indoc;
227 use project::{FakeFs, Project};
228 use serde_json::json;
229 use workspace::{AppState, Workspace};
230
231 use super::*;
232
233 #[gpui::test]
234 async fn test_go_to_line_view_row_highlights(cx: &mut TestAppContext) {
235 init_test(cx);
236 let fs = FakeFs::new(cx.executor());
237 fs.insert_tree(
238 "/dir",
239 json!({
240 "a.rs": indoc!{"
241 struct SingleLine; // display line 0
242 // display line 1
243 struct MultiLine { // display line 2
244 field_1: i32, // display line 3
245 field_2: i32, // display line 4
246 } // display line 5
247 // display line 7
248 struct Another { // display line 8
249 field_1: i32, // display line 9
250 field_2: i32, // display line 10
251 field_3: i32, // display line 11
252 field_4: i32, // display line 12
253 } // display line 13
254 "}
255 }),
256 )
257 .await;
258
259 let project = Project::test(fs, ["/dir".as_ref()], cx).await;
260 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx));
261 let worktree_id = workspace.update(cx, |workspace, cx| {
262 workspace.project().update(cx, |project, cx| {
263 project.worktrees().next().unwrap().read(cx).id()
264 })
265 });
266 let _buffer = project
267 .update(cx, |project, cx| project.open_local_buffer("/dir/a.rs", cx))
268 .await
269 .unwrap();
270 let editor = workspace
271 .update(cx, |workspace, cx| {
272 workspace.open_path((worktree_id, "a.rs"), None, true, cx)
273 })
274 .await
275 .unwrap()
276 .downcast::<Editor>()
277 .unwrap();
278
279 let go_to_line_view = open_go_to_line_view(&workspace, cx);
280 assert_eq!(
281 highlighted_display_rows(&editor, cx),
282 Vec::<u32>::new(),
283 "Initially opened go to line modal should not highlight any rows"
284 );
285 assert_single_caret_at_row(&editor, 0, cx);
286
287 cx.simulate_input("1");
288 assert_eq!(
289 highlighted_display_rows(&editor, cx),
290 vec![0],
291 "Go to line modal should highlight a row, corresponding to the query"
292 );
293 assert_single_caret_at_row(&editor, 0, cx);
294
295 cx.simulate_input("8");
296 assert_eq!(
297 highlighted_display_rows(&editor, cx),
298 vec![13],
299 "If the query is too large, the last row should be highlighted"
300 );
301 assert_single_caret_at_row(&editor, 0, cx);
302
303 cx.dispatch_action(menu::Cancel);
304 drop(go_to_line_view);
305 editor.update(cx, |_, _| {});
306 assert_eq!(
307 highlighted_display_rows(&editor, cx),
308 Vec::<u32>::new(),
309 "After cancelling and closing the modal, no rows should be highlighted"
310 );
311 assert_single_caret_at_row(&editor, 0, cx);
312
313 let go_to_line_view = open_go_to_line_view(&workspace, cx);
314 assert_eq!(
315 highlighted_display_rows(&editor, cx),
316 Vec::<u32>::new(),
317 "Reopened modal should not highlight any rows"
318 );
319 assert_single_caret_at_row(&editor, 0, cx);
320
321 let expected_highlighted_row = 4;
322 cx.simulate_input("5");
323 assert_eq!(
324 highlighted_display_rows(&editor, cx),
325 vec![expected_highlighted_row]
326 );
327 assert_single_caret_at_row(&editor, 0, cx);
328 cx.dispatch_action(menu::Confirm);
329 drop(go_to_line_view);
330 editor.update(cx, |_, _| {});
331 assert_eq!(
332 highlighted_display_rows(&editor, cx),
333 Vec::<u32>::new(),
334 "After confirming and closing the modal, no rows should be highlighted"
335 );
336 // On confirm, should place the caret on the highlighted row.
337 assert_single_caret_at_row(&editor, expected_highlighted_row, cx);
338 }
339
340 fn open_go_to_line_view(
341 workspace: &View<Workspace>,
342 cx: &mut VisualTestContext,
343 ) -> View<GoToLine> {
344 cx.dispatch_action(Toggle);
345 workspace.update(cx, |workspace, cx| {
346 workspace.active_modal::<GoToLine>(cx).unwrap().clone()
347 })
348 }
349
350 fn highlighted_display_rows(editor: &View<Editor>, cx: &mut VisualTestContext) -> Vec<u32> {
351 editor.update(cx, |editor, cx| {
352 editor
353 .highlighted_display_rows(HashSet::default(), cx)
354 .into_keys()
355 .map(|r| r.0)
356 .collect()
357 })
358 }
359
360 #[track_caller]
361 fn assert_single_caret_at_row(
362 editor: &View<Editor>,
363 buffer_row: u32,
364 cx: &mut VisualTestContext,
365 ) {
366 let selections = editor.update(cx, |editor, cx| {
367 editor
368 .selections
369 .all::<rope::Point>(cx)
370 .into_iter()
371 .map(|s| s.start..s.end)
372 .collect::<Vec<_>>()
373 });
374 assert!(
375 selections.len() == 1,
376 "Expected one caret selection but got: {selections:?}"
377 );
378 let selection = &selections[0];
379 assert!(
380 selection.start == selection.end,
381 "Expected a single caret selection, but got: {selection:?}"
382 );
383 assert_eq!(selection.start.row, buffer_row);
384 }
385
386 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
387 cx.update(|cx| {
388 let state = AppState::test(cx);
389 language::init(cx);
390 crate::init(cx);
391 editor::init(cx);
392 workspace::init_settings(cx);
393 Project::init_settings(cx);
394 state
395 })
396 }
397}