Skip to repository content1065 lines · 38.6 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T05:21:56.386Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
go_to_line.rs
1pub mod cursor_position;
2
3use cursor_position::UserCaretPosition;
4use editor::{
5 Anchor, Editor, RowHighlightOptions, SelectionEffects, ToPoint,
6 actions::Tab,
7 scroll::{Autoscroll, ScrollOffset},
8};
9use gpui::{
10 App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString, Styled,
11 Subscription, div, prelude::*,
12};
13use language::Buffer;
14use text::{Bias, Point};
15use theme::ActiveTheme;
16use ui::prelude::*;
17use util::paths::FILE_ROW_COLUMN_DELIMITER;
18use workspace::{DismissDecision, ModalView};
19
20pub fn init(cx: &mut App) {
21 cx.observe_new(GoToLine::register).detach();
22}
23
24pub struct GoToLine {
25 line_editor: Entity<Editor>,
26 active_editor: Entity<Editor>,
27 active_buffer: Entity<Buffer>,
28 current_text: SharedString,
29 prev_scroll_position: Option<gpui::Point<ScrollOffset>>,
30 current_line: u32,
31 _subscriptions: Vec<Subscription>,
32}
33
34impl ModalView for GoToLine {
35 fn on_before_dismiss(
36 &mut self,
37 _window: &mut Window,
38 _cx: &mut Context<Self>,
39 ) -> DismissDecision {
40 self.prev_scroll_position.take();
41 DismissDecision::Dismiss(true)
42 }
43}
44
45impl Focusable for GoToLine {
46 fn focus_handle(&self, cx: &App) -> FocusHandle {
47 self.line_editor.focus_handle(cx)
48 }
49}
50impl EventEmitter<DismissEvent> for GoToLine {}
51
52enum GoToLineRowHighlights {}
53
54impl GoToLine {
55 fn register(editor: &mut Editor, _window: Option<&mut Window>, cx: &mut Context<Editor>) {
56 let handle = cx.entity().downgrade();
57 editor
58 .register_action(move |_: &editor::actions::ToggleGoToLine, window, cx| {
59 let Some(editor_handle) = handle.upgrade() else {
60 return;
61 };
62 let Some(workspace) = editor_handle.read(cx).workspace() else {
63 return;
64 };
65 let editor = editor_handle.read(cx);
66 let Some(buffer) = editor.active_buffer(cx) else {
67 return;
68 };
69 workspace.update(cx, |workspace, cx| {
70 workspace.toggle_modal(window, cx, move |window, cx| {
71 GoToLine::new(editor_handle, buffer, window, cx)
72 });
73 })
74 })
75 .detach();
76 }
77
78 pub fn new(
79 active_editor: Entity<Editor>,
80 active_buffer: Entity<Buffer>,
81 window: &mut Window,
82 cx: &mut Context<Self>,
83 ) -> Self {
84 let (user_caret, last_line, scroll_position) = active_editor.update(cx, |editor, cx| {
85 let user_caret = UserCaretPosition::at_selection_end(
86 &editor
87 .selections
88 .last::<Point>(&editor.display_snapshot(cx)),
89 &editor.buffer().read(cx).snapshot(cx),
90 );
91
92 let snapshot = active_buffer.read(cx).snapshot();
93 let last_line = editor
94 .buffer()
95 .read(cx)
96 .snapshot(cx)
97 .excerpts_for_buffer(snapshot.remote_id())
98 .map(move |range| text::ToPoint::to_point(&range.context.end, &snapshot).row)
99 .max()
100 .unwrap_or(0);
101
102 (user_caret, last_line, editor.scroll_position(cx))
103 });
104
105 let line = user_caret.line.get();
106 let column = user_caret.character.get();
107
108 let line_editor = cx.new(|cx| {
109 let mut editor = Editor::single_line(window, cx);
110 let editor_handle = cx.entity().downgrade();
111 editor
112 .register_action::<Tab>({
113 move |_, window, cx| {
114 let Some(editor) = editor_handle.upgrade() else {
115 return;
116 };
117 editor.update(cx, |editor, cx| {
118 if let Some(placeholder_text) = editor.placeholder_text(cx)
119 && editor.text(cx).is_empty()
120 {
121 editor.set_text(placeholder_text, window, cx);
122 }
123 });
124 }
125 })
126 .detach();
127 editor.set_placeholder_text(
128 &format!("{line}{FILE_ROW_COLUMN_DELIMITER}{column}"),
129 window,
130 cx,
131 );
132 editor
133 });
134 let line_editor_change = cx.subscribe_in(&line_editor, window, Self::on_line_editor_event);
135
136 let current_text = format!(
137 "Current Line: {} of {} (column {})",
138 line,
139 last_line + 1,
140 column
141 );
142
143 Self {
144 line_editor,
145 active_editor,
146 active_buffer,
147 current_text: current_text.into(),
148 prev_scroll_position: Some(scroll_position),
149 current_line: line,
150 _subscriptions: vec![line_editor_change, cx.on_release_in(window, Self::release)],
151 }
152 }
153
154 fn release(&mut self, window: &mut Window, cx: &mut App) {
155 let scroll_position = self.prev_scroll_position.take();
156 self.active_editor.update(cx, |editor, cx| {
157 editor.clear_row_highlights::<GoToLineRowHighlights>();
158 if let Some(scroll_position) = scroll_position {
159 editor.set_scroll_position(scroll_position, window, cx);
160 }
161 cx.notify();
162 })
163 }
164
165 fn on_line_editor_event(
166 &mut self,
167 _: &Entity<Editor>,
168 event: &editor::EditorEvent,
169 _window: &mut Window,
170 cx: &mut Context<Self>,
171 ) {
172 match event {
173 editor::EditorEvent::Blurred => {
174 self.prev_scroll_position.take();
175 cx.emit(DismissEvent)
176 }
177 editor::EditorEvent::BufferEdited => self.highlight_current_line(cx),
178 _ => {}
179 }
180 }
181
182 fn highlight_current_line(&mut self, cx: &mut Context<Self>) {
183 self.active_editor.update(cx, |editor, cx| {
184 editor.clear_row_highlights::<GoToLineRowHighlights>();
185 let snapshot = editor.buffer().read(cx).snapshot(cx);
186 let Some(start) = self.anchor_from_query(editor, cx) else {
187 return;
188 };
189 let mut start_point = start.to_point(&snapshot);
190 start_point.column = 0;
191 // Force non-empty range to ensure the line is highlighted.
192 let mut end_point = snapshot.clip_point(start_point + Point::new(0, 1), Bias::Left);
193 if start_point == end_point {
194 end_point = snapshot.clip_point(start_point + Point::new(1, 0), Bias::Left);
195 }
196
197 let end = snapshot.anchor_after(end_point);
198 editor.highlight_rows::<GoToLineRowHighlights>(
199 start..end,
200 |cx| cx.theme().colors().editor_highlighted_line_background,
201 RowHighlightOptions {
202 autoscroll: true,
203 ..Default::default()
204 },
205 cx,
206 );
207 editor.request_autoscroll(Autoscroll::center(), cx);
208 });
209 cx.notify();
210 }
211
212 fn anchor_from_query(&self, editor: &Editor, cx: &Context<Editor>) -> Option<Anchor> {
213 let (query_row, query_char) = if let Some(offset) = self.relative_line_from_query(cx) {
214 let target = if offset >= 0 {
215 self.current_line.saturating_add(offset as u32)
216 } else {
217 self.current_line.saturating_sub(offset.unsigned_abs())
218 };
219 (target, None)
220 } else {
221 self.line_and_char_from_query(cx)?
222 };
223
224 let row = query_row.saturating_sub(1);
225 let character = query_char.unwrap_or(0).saturating_sub(1);
226 let target_point = {
227 let buffer_snapshot = self.active_buffer.read(cx).snapshot();
228 let row = row.min(buffer_snapshot.max_point().row);
229 buffer_snapshot.point_from_external_input(row, character)
230 };
231
232 editor
233 .buffer()
234 .read(cx)
235 .buffer_point_to_anchor(&self.active_buffer, target_point, cx)
236 }
237
238 fn relative_line_from_query(&self, cx: &App) -> Option<i32> {
239 let input = self.line_editor.read(cx).text(cx);
240 let trimmed = input.trim();
241
242 let mut last_direction_char: Option<char> = None;
243 let mut number_start_index = 0;
244
245 for (i, c) in trimmed.char_indices() {
246 match c {
247 '+' | 'f' | 'F' | '-' | 'b' | 'B' => {
248 last_direction_char = Some(c);
249 number_start_index = i + c.len_utf8();
250 }
251 _ => break,
252 }
253 }
254
255 let direction = last_direction_char?;
256
257 let number_part = &trimmed[number_start_index..];
258 let line_part = number_part
259 .split(FILE_ROW_COLUMN_DELIMITER)
260 .next()
261 .unwrap_or(number_part)
262 .trim();
263
264 let value = line_part.parse::<u32>().ok()?;
265
266 match direction {
267 '+' | 'f' | 'F' => Some(value as i32),
268 '-' | 'b' | 'B' => Some(-(value as i32)),
269 _ => None,
270 }
271 }
272
273 fn line_and_char_from_query(&self, cx: &App) -> Option<(u32, Option<u32>)> {
274 let input = self.line_editor.read(cx).text(cx);
275 let mut components = input
276 .splitn(2, FILE_ROW_COLUMN_DELIMITER)
277 .map(str::trim)
278 .fuse();
279 let row = components.next().and_then(|row| row.parse::<u32>().ok())?;
280 let column = components.next().and_then(|col| col.parse::<u32>().ok());
281 Some((row, column))
282 }
283
284 fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
285 cx.emit(DismissEvent);
286 }
287
288 fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
289 self.active_editor.update(cx, |editor, cx| {
290 let Some(start) = self.anchor_from_query(editor, cx) else {
291 return;
292 };
293 editor.change_selections(
294 SelectionEffects::scroll(Autoscroll::center()),
295 window,
296 cx,
297 |s| s.select_anchor_ranges([start..start]),
298 );
299 editor.focus_handle(cx).focus(window, cx);
300 cx.notify()
301 });
302 self.prev_scroll_position.take();
303
304 cx.emit(DismissEvent);
305 }
306}
307
308impl Render for GoToLine {
309 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
310 let help_text = if let Some(offset) = self.relative_line_from_query(cx) {
311 let target_line = if offset >= 0 {
312 self.current_line.saturating_add(offset as u32)
313 } else {
314 self.current_line.saturating_sub(offset.unsigned_abs())
315 };
316 format!("Go to line {target_line} ({offset:+} from current)").into()
317 } else {
318 match self.line_and_char_from_query(cx) {
319 Some((line, Some(character))) => {
320 format!("Go to line {line}, character {character}").into()
321 }
322 Some((line, None)) => format!("Go to line {line}").into(),
323 None => self.current_text.clone(),
324 }
325 };
326
327 v_flex()
328 .w(rems(24.))
329 .elevation_2(cx)
330 .key_context("GoToLine")
331 .on_action(cx.listener(Self::cancel))
332 .on_action(cx.listener(Self::confirm))
333 .child(
334 div()
335 .border_b_1()
336 .border_color(cx.theme().colors().border_variant)
337 .px_2()
338 .py_1()
339 .child(self.line_editor.clone()),
340 )
341 .child(
342 h_flex()
343 .px_2()
344 .py_1()
345 .gap_1()
346 .child(Label::new(help_text).color(Color::Muted)),
347 )
348 }
349}
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354 use cursor_position::{CursorPosition, SelectionStats, UserCaretPosition};
355 use editor::actions::{MoveRight, MoveToBeginning, SelectAll};
356 use gpui::{TestAppContext, VisualTestContext};
357 use indoc::indoc;
358 use language::Capability;
359 use multi_buffer::{MultiBuffer, PathKey};
360 use project::{FakeFs, Project};
361 use serde_json::json;
362 use std::{num::NonZeroU32, sync::Arc, time::Duration};
363 use util::{path, rel_path::rel_path};
364 use workspace::{AppState, MultiWorkspace, Workspace};
365
366 #[gpui::test]
367 async fn test_go_to_line_view_row_highlights(cx: &mut TestAppContext) {
368 init_test(cx);
369 let fs = FakeFs::new(cx.executor());
370 fs.insert_tree(
371 path!("/dir"),
372 json!({
373 "a.rs": indoc!{"
374 struct SingleLine; // display line 0
375 // display line 1
376 struct MultiLine { // display line 2
377 field_1: i32, // display line 3
378 field_2: i32, // display line 4
379 } // display line 5
380 // display line 6
381 struct Another { // display line 7
382 field_1: i32, // display line 8
383 field_2: i32, // display line 9
384 field_3: i32, // display line 10
385 field_4: i32, // display line 11
386 } // display line 12
387 "}
388 }),
389 )
390 .await;
391
392 let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
393 let (multi_workspace, cx) =
394 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
395 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
396 let worktree_id = workspace.update(cx, |workspace, cx| {
397 workspace.project().update(cx, |project, cx| {
398 project.worktrees(cx).next().unwrap().read(cx).id()
399 })
400 });
401 let _buffer = project
402 .update(cx, |project, cx| {
403 project.open_local_buffer(path!("/dir/a.rs"), cx)
404 })
405 .await
406 .unwrap();
407 let editor = workspace
408 .update_in(cx, |workspace, window, cx| {
409 workspace.open_path((worktree_id, rel_path("a.rs")), None, true, window, cx)
410 })
411 .await
412 .unwrap()
413 .downcast::<Editor>()
414 .unwrap();
415
416 let go_to_line_view = open_go_to_line_view(&workspace, cx);
417 assert_eq!(
418 highlighted_display_rows(&editor, cx),
419 Vec::<u32>::new(),
420 "Initially opened go to line modal should not highlight any rows"
421 );
422 assert_single_caret_at_row(&editor, 0, cx);
423
424 cx.simulate_input("1");
425 assert_eq!(
426 highlighted_display_rows(&editor, cx),
427 vec![0],
428 "Go to line modal should highlight a row, corresponding to the query"
429 );
430 assert_single_caret_at_row(&editor, 0, cx);
431
432 cx.simulate_input("8");
433 assert_eq!(
434 highlighted_display_rows(&editor, cx),
435 vec![13],
436 "If the query is too large, the last row should be highlighted"
437 );
438 assert_single_caret_at_row(&editor, 0, cx);
439
440 cx.dispatch_action(menu::Cancel);
441 drop(go_to_line_view);
442 editor.update(cx, |_, _| {});
443 assert_eq!(
444 highlighted_display_rows(&editor, cx),
445 Vec::<u32>::new(),
446 "After cancelling and closing the modal, no rows should be highlighted"
447 );
448 assert_single_caret_at_row(&editor, 0, cx);
449
450 let go_to_line_view = open_go_to_line_view(&workspace, cx);
451 assert_eq!(
452 highlighted_display_rows(&editor, cx),
453 Vec::<u32>::new(),
454 "Reopened modal should not highlight any rows"
455 );
456 assert_single_caret_at_row(&editor, 0, cx);
457
458 let expected_highlighted_row = 4;
459 cx.simulate_input("5");
460 assert_eq!(
461 highlighted_display_rows(&editor, cx),
462 vec![expected_highlighted_row]
463 );
464 assert_single_caret_at_row(&editor, 0, cx);
465 cx.dispatch_action(menu::Confirm);
466 drop(go_to_line_view);
467 editor.update(cx, |_, _| {});
468 assert_eq!(
469 highlighted_display_rows(&editor, cx),
470 Vec::<u32>::new(),
471 "After confirming and closing the modal, no rows should be highlighted"
472 );
473 // On confirm, should place the caret on the highlighted row.
474 assert_single_caret_at_row(&editor, expected_highlighted_row, cx);
475 }
476
477 #[gpui::test]
478 async fn test_go_to_line_uses_buffer_rows_in_multibuffers(cx: &mut TestAppContext) {
479 init_test(cx);
480 let cx = cx.add_empty_window();
481 let file_content = (1..=60)
482 .map(|line| format!("line {line}"))
483 .collect::<Vec<_>>()
484 .join("\n");
485 let buffer = cx.new(|cx| Buffer::local(file_content, cx));
486 let multibuffer = cx.new(|cx| {
487 let mut multibuffer = MultiBuffer::new(Capability::ReadWrite);
488 multibuffer.set_excerpts_for_path(
489 PathKey::for_buffer(&buffer, cx),
490 buffer.clone(),
491 [
492 Point::new(10, 0)..Point::new(13, 0),
493 Point::new(50, 0)..Point::new(53, 0),
494 ],
495 0,
496 cx,
497 );
498 multibuffer
499 });
500 let editor = cx.new_window_entity(|window, cx| {
501 Editor::for_multibuffer(multibuffer.clone(), None, window, cx)
502 });
503 let go_to_line_view = cx.new_window_entity(|window, cx| {
504 GoToLine::new(editor.clone(), buffer.clone(), window, cx)
505 });
506
507 go_to_line_view.update_in(cx, |go_to_line_view, window, cx| {
508 go_to_line_view.line_editor.update(cx, |line_editor, cx| {
509 line_editor.set_text("52", window, cx);
510 });
511 go_to_line_view.confirm(&menu::Confirm, window, cx);
512 });
513 assert_single_caret_at_buffer_row(&editor, 51, cx);
514
515 go_to_line_view.update_in(cx, |go_to_line_view, window, cx| {
516 go_to_line_view.line_editor.update(cx, |line_editor, cx| {
517 line_editor.set_text("30", window, cx);
518 });
519 go_to_line_view.confirm(&menu::Confirm, window, cx);
520 });
521 assert_single_caret_at_buffer_row(&editor, 50, cx);
522 }
523
524 #[gpui::test]
525 async fn test_unicode_characters_selection(cx: &mut TestAppContext) {
526 init_test(cx);
527
528 let fs = FakeFs::new(cx.executor());
529 fs.insert_tree(
530 path!("/dir"),
531 json!({
532 "a.rs": "ēlo"
533 }),
534 )
535 .await;
536
537 let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
538 let (multi_workspace, cx) =
539 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
540 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
541 workspace.update_in(cx, |workspace, window, cx| {
542 let cursor_position = cx.new(|_| CursorPosition::new(workspace));
543 workspace.status_bar().update(cx, |status_bar, cx| {
544 status_bar.add_right_item(cursor_position, window, cx);
545 });
546 });
547
548 let worktree_id = workspace.update(cx, |workspace, cx| {
549 workspace.project().update(cx, |project, cx| {
550 project.worktrees(cx).next().unwrap().read(cx).id()
551 })
552 });
553 let _buffer = project
554 .update(cx, |project, cx| {
555 project.open_local_buffer(path!("/dir/a.rs"), cx)
556 })
557 .await
558 .unwrap();
559 let editor = workspace
560 .update_in(cx, |workspace, window, cx| {
561 workspace.open_path((worktree_id, rel_path("a.rs")), None, true, window, cx)
562 })
563 .await
564 .unwrap()
565 .downcast::<Editor>()
566 .unwrap();
567
568 cx.executor().advance_clock(Duration::from_millis(200));
569 workspace.update(cx, |workspace, cx| {
570 assert_eq!(
571 &SelectionStats {
572 lines: 0,
573 characters: 0,
574 selections: 1,
575 },
576 workspace
577 .status_bar()
578 .read(cx)
579 .item_of_type::<CursorPosition>()
580 .expect("missing cursor position item")
581 .read(cx)
582 .selection_stats(),
583 "No selections should be initially"
584 );
585 });
586 editor.update_in(cx, |editor, window, cx| {
587 editor.select_all(&SelectAll, window, cx)
588 });
589 cx.executor().advance_clock(Duration::from_millis(200));
590 workspace.update(cx, |workspace, cx| {
591 assert_eq!(
592 &SelectionStats {
593 lines: 1,
594 characters: 3,
595 selections: 1,
596 },
597 workspace
598 .status_bar()
599 .read(cx)
600 .item_of_type::<CursorPosition>()
601 .expect("missing cursor position item")
602 .read(cx)
603 .selection_stats(),
604 "After selecting a text with multibyte unicode characters, the character count should be correct"
605 );
606 });
607 }
608
609 #[gpui::test]
610 async fn test_unicode_line_numbers(cx: &mut TestAppContext) {
611 init_test(cx);
612
613 let text = "ēlo你好";
614 let fs = FakeFs::new(cx.executor());
615 fs.insert_tree(
616 path!("/dir"),
617 json!({
618 "a.rs": text
619 }),
620 )
621 .await;
622
623 let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
624 let (multi_workspace, cx) =
625 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
626 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
627 workspace.update_in(cx, |workspace, window, cx| {
628 let cursor_position = cx.new(|_| CursorPosition::new(workspace));
629 workspace.status_bar().update(cx, |status_bar, cx| {
630 status_bar.add_right_item(cursor_position, window, cx);
631 });
632 });
633
634 let worktree_id = workspace.update(cx, |workspace, cx| {
635 workspace.project().update(cx, |project, cx| {
636 project.worktrees(cx).next().unwrap().read(cx).id()
637 })
638 });
639 let _buffer = project
640 .update(cx, |project, cx| {
641 project.open_local_buffer(path!("/dir/a.rs"), cx)
642 })
643 .await
644 .unwrap();
645 let editor = workspace
646 .update_in(cx, |workspace, window, cx| {
647 workspace.open_path((worktree_id, rel_path("a.rs")), None, true, window, cx)
648 })
649 .await
650 .unwrap()
651 .downcast::<Editor>()
652 .unwrap();
653
654 editor.update_in(cx, |editor, window, cx| {
655 editor.move_to_beginning(&MoveToBeginning, window, cx)
656 });
657 cx.executor().advance_clock(Duration::from_millis(200));
658 assert_eq!(
659 user_caret_position(1, 1),
660 current_position(&workspace, cx),
661 "Beginning of the line should be at first line, before any characters"
662 );
663
664 for (i, c) in text.chars().enumerate() {
665 let i = i as u32 + 1;
666 editor.update_in(cx, |editor, window, cx| {
667 editor.move_right(&MoveRight, window, cx)
668 });
669 cx.executor().advance_clock(Duration::from_millis(200));
670 assert_eq!(
671 user_caret_position(1, i + 1),
672 current_position(&workspace, cx),
673 "Wrong position for char '{c}' in string '{text}'",
674 );
675 }
676
677 editor.update_in(cx, |editor, window, cx| {
678 editor.move_right(&MoveRight, window, cx)
679 });
680 cx.executor().advance_clock(Duration::from_millis(200));
681 assert_eq!(
682 user_caret_position(1, text.chars().count() as u32 + 1),
683 current_position(&workspace, cx),
684 "After reaching the end of the text, position should not change when moving right"
685 );
686 }
687
688 #[gpui::test]
689 async fn test_go_into_unicode(cx: &mut TestAppContext) {
690 init_test(cx);
691
692 let text = "ēlo你好";
693 let fs = FakeFs::new(cx.executor());
694 fs.insert_tree(
695 path!("/dir"),
696 json!({
697 "a.rs": text
698 }),
699 )
700 .await;
701
702 let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
703 let (multi_workspace, cx) =
704 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
705 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
706 workspace.update_in(cx, |workspace, window, cx| {
707 let cursor_position = cx.new(|_| CursorPosition::new(workspace));
708 workspace.status_bar().update(cx, |status_bar, cx| {
709 status_bar.add_right_item(cursor_position, window, cx);
710 });
711 });
712
713 let worktree_id = workspace.update(cx, |workspace, cx| {
714 workspace.project().update(cx, |project, cx| {
715 project.worktrees(cx).next().unwrap().read(cx).id()
716 })
717 });
718 let _buffer = project
719 .update(cx, |project, cx| {
720 project.open_local_buffer(path!("/dir/a.rs"), cx)
721 })
722 .await
723 .unwrap();
724 let editor = workspace
725 .update_in(cx, |workspace, window, cx| {
726 workspace.open_path((worktree_id, rel_path("a.rs")), None, true, window, cx)
727 })
728 .await
729 .unwrap()
730 .downcast::<Editor>()
731 .unwrap();
732
733 editor.update_in(cx, |editor, window, cx| {
734 editor.move_to_beginning(&MoveToBeginning, window, cx)
735 });
736 cx.executor().advance_clock(Duration::from_millis(200));
737 assert_eq!(user_caret_position(1, 1), current_position(&workspace, cx));
738
739 for (i, c) in text.chars().enumerate() {
740 let i = i as u32 + 1;
741 let point = user_caret_position(1, i + 1);
742 go_to_point(point, user_caret_position(1, i), &workspace, cx);
743 cx.executor().advance_clock(Duration::from_millis(200));
744 assert_eq!(
745 point,
746 current_position(&workspace, cx),
747 "When going to {point:?}, expecting the cursor to be at char '{c}' in string '{text}'",
748 );
749 }
750
751 go_to_point(
752 user_caret_position(111, 222),
753 user_caret_position(1, text.chars().count() as u32 + 1),
754 &workspace,
755 cx,
756 );
757 cx.executor().advance_clock(Duration::from_millis(200));
758 assert_eq!(
759 user_caret_position(1, text.chars().count() as u32 + 1),
760 current_position(&workspace, cx),
761 "When going into too large point, should go to the end of the text"
762 );
763 }
764
765 fn current_position(
766 workspace: &Entity<Workspace>,
767 cx: &mut VisualTestContext,
768 ) -> UserCaretPosition {
769 workspace.update(cx, |workspace, cx| {
770 workspace
771 .status_bar()
772 .read(cx)
773 .item_of_type::<CursorPosition>()
774 .expect("missing cursor position item")
775 .read(cx)
776 .position()
777 .expect("No position found")
778 })
779 }
780
781 fn user_caret_position(line: u32, character: u32) -> UserCaretPosition {
782 UserCaretPosition {
783 line: NonZeroU32::new(line).unwrap(),
784 character: NonZeroU32::new(character).unwrap(),
785 }
786 }
787
788 fn go_to_point(
789 new_point: UserCaretPosition,
790 expected_placeholder: UserCaretPosition,
791 workspace: &Entity<Workspace>,
792 cx: &mut VisualTestContext,
793 ) {
794 let go_to_line_view = open_go_to_line_view(workspace, cx);
795 go_to_line_view.update(cx, |go_to_line_view, cx| {
796 assert_eq!(
797 go_to_line_view.line_editor.update(cx, |line_editor, cx| {
798 line_editor
799 .placeholder_text(cx)
800 .expect("No placeholder text")
801 }),
802 format!(
803 "{}:{}",
804 expected_placeholder.line, expected_placeholder.character
805 )
806 );
807 });
808 cx.simulate_input(&format!("{}:{}", new_point.line, new_point.character));
809 cx.dispatch_action(menu::Confirm);
810 }
811
812 fn open_go_to_line_view(
813 workspace: &Entity<Workspace>,
814 cx: &mut VisualTestContext,
815 ) -> Entity<GoToLine> {
816 cx.dispatch_action(editor::actions::ToggleGoToLine);
817 workspace.update(cx, |workspace, cx| {
818 workspace.active_modal::<GoToLine>(cx).unwrap()
819 })
820 }
821
822 fn highlighted_display_rows(editor: &Entity<Editor>, cx: &mut VisualTestContext) -> Vec<u32> {
823 editor.update_in(cx, |editor, window, cx| {
824 editor
825 .highlighted_display_rows(window, cx)
826 .into_keys()
827 .map(|r| r.0)
828 .collect()
829 })
830 }
831
832 #[track_caller]
833 fn assert_single_caret_at_row(
834 editor: &Entity<Editor>,
835 buffer_row: u32,
836 cx: &mut VisualTestContext,
837 ) {
838 let selection = single_caret_selection(editor, cx);
839 assert_eq!(selection.start.row, buffer_row);
840 }
841
842 #[track_caller]
843 fn assert_single_caret_at_buffer_row(
844 editor: &Entity<Editor>,
845 buffer_row: u32,
846 cx: &mut VisualTestContext,
847 ) {
848 let selection = single_caret_selection(editor, cx);
849 let buffer_point = editor.update(cx, |editor, cx| {
850 let snapshot = editor.buffer().read(cx).snapshot(cx);
851 snapshot
852 .point_to_buffer_point(selection.start)
853 .map(|(_, buffer_point)| buffer_point)
854 });
855
856 assert_eq!(buffer_point.map(|point| point.row), Some(buffer_row));
857 }
858
859 fn single_caret_selection(
860 editor: &Entity<Editor>,
861 cx: &mut VisualTestContext,
862 ) -> std::ops::Range<rope::Point> {
863 let selections = editor.update(cx, |editor, cx| {
864 editor
865 .selections
866 .all::<rope::Point>(&editor.display_snapshot(cx))
867 .into_iter()
868 .map(|s| s.start..s.end)
869 .collect::<Vec<_>>()
870 });
871 assert!(
872 selections.len() == 1,
873 "Expected one caret selection but got: {selections:?}"
874 );
875 let selection = selections
876 .into_iter()
877 .next()
878 .expect("checked selection count");
879 assert!(
880 selection.start == selection.end,
881 "Expected a single caret selection, but got: {selection:?}"
882 );
883 selection
884 }
885
886 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
887 cx.update(|cx| {
888 let state = AppState::test(cx);
889 crate::init(cx);
890 editor::init(cx);
891 state
892 })
893 }
894
895 #[gpui::test]
896 async fn test_scroll_position_on_outside_click(cx: &mut TestAppContext) {
897 init_test(cx);
898
899 let fs = FakeFs::new(cx.executor());
900 let file_content = (0..100)
901 .map(|i| format!("struct Line{};", i))
902 .collect::<Vec<_>>()
903 .join("\n");
904 fs.insert_tree(path!("/dir"), json!({"a.rs": file_content}))
905 .await;
906
907 let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
908 let (multi_workspace, cx) =
909 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
910 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
911 let worktree_id = workspace.update(cx, |workspace, cx| {
912 workspace.project().update(cx, |project, cx| {
913 project.worktrees(cx).next().unwrap().read(cx).id()
914 })
915 });
916 let _buffer = project
917 .update(cx, |project, cx| {
918 project.open_local_buffer(path!("/dir/a.rs"), cx)
919 })
920 .await
921 .unwrap();
922 let editor = workspace
923 .update_in(cx, |workspace, window, cx| {
924 workspace.open_path((worktree_id, rel_path("a.rs")), None, true, window, cx)
925 })
926 .await
927 .unwrap()
928 .downcast::<Editor>()
929 .unwrap();
930 let go_to_line_view = open_go_to_line_view(&workspace, cx);
931
932 let scroll_position_before_input =
933 editor.update(cx, |editor, cx| editor.scroll_position(cx));
934 cx.simulate_input("47");
935 let scroll_position_after_input =
936 editor.update(cx, |editor, cx| editor.scroll_position(cx));
937 assert_ne!(scroll_position_before_input, scroll_position_after_input);
938
939 drop(go_to_line_view);
940 workspace.update_in(cx, |workspace, window, cx| {
941 workspace.hide_modal(window, cx);
942 });
943 cx.run_until_parked();
944
945 let scroll_position_after_auto_dismiss =
946 editor.update(cx, |editor, cx| editor.scroll_position(cx));
947 assert_eq!(
948 scroll_position_after_auto_dismiss, scroll_position_after_input,
949 "Dismissing via outside click should maintain new scroll position"
950 );
951 }
952
953 #[gpui::test]
954 async fn test_scroll_position_on_cancel(cx: &mut TestAppContext) {
955 init_test(cx);
956
957 let fs = FakeFs::new(cx.executor());
958 let file_content = (0..100)
959 .map(|i| format!("struct Line{};", i))
960 .collect::<Vec<_>>()
961 .join("\n");
962 fs.insert_tree(path!("/dir"), json!({"a.rs": file_content}))
963 .await;
964
965 let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
966 let (multi_workspace, cx) =
967 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
968 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
969 let worktree_id = workspace.update(cx, |workspace, cx| {
970 workspace.project().update(cx, |project, cx| {
971 project.worktrees(cx).next().unwrap().read(cx).id()
972 })
973 });
974 let _buffer = project
975 .update(cx, |project, cx| {
976 project.open_local_buffer(path!("/dir/a.rs"), cx)
977 })
978 .await
979 .unwrap();
980 let editor = workspace
981 .update_in(cx, |workspace, window, cx| {
982 workspace.open_path((worktree_id, rel_path("a.rs")), None, true, window, cx)
983 })
984 .await
985 .unwrap()
986 .downcast::<Editor>()
987 .unwrap();
988 let go_to_line_view = open_go_to_line_view(&workspace, cx);
989
990 let scroll_position_before_input =
991 editor.update(cx, |editor, cx| editor.scroll_position(cx));
992 cx.simulate_input("47");
993 let scroll_position_after_input =
994 editor.update(cx, |editor, cx| editor.scroll_position(cx));
995 assert_ne!(scroll_position_before_input, scroll_position_after_input);
996
997 cx.dispatch_action(menu::Cancel);
998 drop(go_to_line_view);
999 cx.run_until_parked();
1000
1001 let scroll_position_after_cancel =
1002 editor.update(cx, |editor, cx| editor.scroll_position(cx));
1003 assert_eq!(
1004 scroll_position_after_cancel, scroll_position_after_input,
1005 "Cancel should maintain new scroll position"
1006 );
1007 }
1008
1009 #[gpui::test]
1010 async fn test_scroll_position_on_confirm(cx: &mut TestAppContext) {
1011 init_test(cx);
1012
1013 let fs = FakeFs::new(cx.executor());
1014 let file_content = (0..100)
1015 .map(|i| format!("struct Line{};", i))
1016 .collect::<Vec<_>>()
1017 .join("\n");
1018 fs.insert_tree(path!("/dir"), json!({"a.rs": file_content}))
1019 .await;
1020
1021 let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
1022 let (multi_workspace, cx) =
1023 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1024 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
1025 let worktree_id = workspace.update(cx, |workspace, cx| {
1026 workspace.project().update(cx, |project, cx| {
1027 project.worktrees(cx).next().unwrap().read(cx).id()
1028 })
1029 });
1030 let _buffer = project
1031 .update(cx, |project, cx| {
1032 project.open_local_buffer(path!("/dir/a.rs"), cx)
1033 })
1034 .await
1035 .unwrap();
1036 let editor = workspace
1037 .update_in(cx, |workspace, window, cx| {
1038 workspace.open_path((worktree_id, rel_path("a.rs")), None, true, window, cx)
1039 })
1040 .await
1041 .unwrap()
1042 .downcast::<Editor>()
1043 .unwrap();
1044 let go_to_line_view = open_go_to_line_view(&workspace, cx);
1045
1046 let scroll_position_before_input =
1047 editor.update(cx, |editor, cx| editor.scroll_position(cx));
1048 cx.simulate_input("47");
1049 let scroll_position_after_input =
1050 editor.update(cx, |editor, cx| editor.scroll_position(cx));
1051 assert_ne!(scroll_position_before_input, scroll_position_after_input);
1052
1053 cx.dispatch_action(menu::Confirm);
1054 drop(go_to_line_view);
1055 cx.run_until_parked();
1056
1057 let scroll_position_after_confirm =
1058 editor.update(cx, |editor, cx| editor.scroll_position(cx));
1059 assert_eq!(
1060 scroll_position_after_confirm, scroll_position_after_input,
1061 "Confirm should maintain new scroll position"
1062 );
1063 }
1064}
1065