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}