Skip to repository content765 lines · 22.3 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T04:36:15.476Z 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
change.rs
1use crate::{
2 Vim,
3 motion::{self, Motion, MotionKind},
4 object::Object,
5 state::Mode,
6};
7use editor::{
8 Bias, DisplayPoint, EditPredictionRequestTrigger,
9 display_map::{DisplaySnapshot, ToDisplayPoint},
10 movement::TextLayoutDetails,
11};
12use gpui::{Context, Window};
13use language::Selection;
14
15impl Vim {
16 pub fn change_motion(
17 &mut self,
18 motion: Motion,
19 times: Option<usize>,
20 forced_motion: bool,
21 window: &mut Window,
22 cx: &mut Context<Self>,
23 ) {
24 // Some motions ignore failure when switching to normal mode
25 let mut motion_kind = if matches!(
26 motion,
27 Motion::Left
28 | Motion::Right
29 | Motion::EndOfLine { .. }
30 | Motion::WrappingLeft
31 | Motion::StartOfLine { .. }
32 ) {
33 Some(MotionKind::Exclusive)
34 } else {
35 None
36 };
37 self.update_editor(cx, |vim, editor, cx| {
38 let text_layout_details = editor.text_layout_details(window, cx);
39 editor.transact(window, cx, |editor, window, cx| {
40 // We are swapping to insert mode anyway. Just set the line end clipping behavior now
41 editor.set_clip_at_line_ends(false, cx);
42 editor.change_selections(Default::default(), window, cx, |s| {
43 s.move_with(&mut |map, selection| {
44 let kind = match motion {
45 Motion::NextWordStart { ignore_punctuation }
46 | Motion::NextSubwordStart { ignore_punctuation } => {
47 expand_changed_word_selection(
48 map,
49 selection,
50 times,
51 ignore_punctuation,
52 &text_layout_details,
53 motion == Motion::NextSubwordStart { ignore_punctuation },
54 !matches!(motion, Motion::NextWordStart { .. }),
55 )
56 }
57 _ => {
58 let kind = motion.expand_selection(
59 map,
60 selection,
61 times,
62 &text_layout_details,
63 forced_motion,
64 );
65 if matches!(
66 motion,
67 Motion::CurrentLine | Motion::Down { .. } | Motion::Up { .. }
68 ) {
69 let mut start_offset =
70 selection.start.to_offset(map, Bias::Left);
71 let classifier = map
72 .buffer_snapshot()
73 .char_classifier_at(selection.start.to_point(map));
74 for (ch, offset) in map.buffer_chars_at(start_offset) {
75 if ch == '\n' || !classifier.is_whitespace(ch) {
76 break;
77 }
78 start_offset = offset + ch.len_utf8();
79 }
80 selection.start = start_offset.to_display_point(map);
81 }
82 kind
83 }
84 };
85 if let Some(kind) = kind {
86 motion_kind.get_or_insert(kind);
87 }
88 });
89 });
90 if let Some(kind) = motion_kind {
91 vim.copy_selections_content(editor, kind, window, cx);
92 editor.delete_selections_with_linked_edits(window, cx);
93 editor.refresh_edit_prediction(
94 true,
95 false,
96 EditPredictionRequestTrigger::BufferEdit,
97 window,
98 cx,
99 );
100 }
101 });
102 });
103
104 if motion_kind.is_some() {
105 self.switch_mode(Mode::Insert, false, window, cx)
106 } else {
107 self.switch_mode(Mode::Normal, false, window, cx)
108 }
109 }
110
111 pub fn change_object(
112 &mut self,
113 object: Object,
114 around: bool,
115 times: Option<usize>,
116 window: &mut Window,
117 cx: &mut Context<Self>,
118 ) {
119 let mut objects_found = false;
120 self.update_editor(cx, |vim, editor, cx| {
121 // We are swapping to insert mode anyway. Just set the line end clipping behavior now
122 editor.set_clip_at_line_ends(false, cx);
123 editor.transact(window, cx, |editor, window, cx| {
124 editor.change_selections(Default::default(), window, cx, |s| {
125 s.move_with(&mut |map, selection| {
126 objects_found |= object.expand_selection(map, selection, around, times);
127 });
128 });
129 if objects_found {
130 let kind = match object.target_visual_mode(vim.mode, around) {
131 Mode::VisualLine => MotionKind::Linewise,
132 _ => MotionKind::Exclusive,
133 };
134 vim.copy_selections_content(editor, kind, window, cx);
135 editor.delete_selections_with_linked_edits(window, cx);
136 editor.refresh_edit_prediction(
137 true,
138 false,
139 EditPredictionRequestTrigger::BufferEdit,
140 window,
141 cx,
142 );
143 }
144 });
145 });
146
147 if objects_found {
148 self.switch_mode(Mode::Insert, false, window, cx);
149 } else {
150 self.switch_mode(Mode::Normal, false, window, cx);
151 }
152 }
153}
154
155// From the docs https://vimdoc.sourceforge.net/htmldoc/motion.html
156// Special case: "cw" and "cW" are treated like "ce" and "cE" if the cursor is
157// on a non-blank. This is because "cw" is interpreted as change-word, and a
158// word does not include the following white space. {Vi: "cw" when on a blank
159// followed by other blanks changes only the first blank; this is probably a
160// bug, because "dw" deletes all the blanks}
161fn expand_changed_word_selection(
162 map: &DisplaySnapshot,
163 selection: &mut Selection<DisplayPoint>,
164 times: Option<usize>,
165 ignore_punctuation: bool,
166 text_layout_details: &TextLayoutDetails,
167 use_subword: bool,
168 always_advance: bool,
169) -> Option<MotionKind> {
170 let classifier = map
171 .buffer_snapshot()
172 .char_classifier_at(selection.start.to_point(map));
173
174 let is_in_word = map
175 .buffer_chars_at(selection.head().to_offset(map, Bias::Left))
176 .next()
177 .map(|(c, _)| !classifier.is_whitespace(c))
178 .unwrap_or_default();
179
180 if is_in_word {
181 let advance_end = |point, times, always_advance| {
182 if use_subword {
183 motion::next_subword_end(map, point, ignore_punctuation, times, false)
184 } else {
185 motion::next_word_end(map, point, ignore_punctuation, times, false, always_advance)
186 }
187 };
188
189 let next_char = map
190 .buffer_chars_at(
191 motion::next_char(map, selection.end, false).to_offset(map, Bias::Left),
192 )
193 .next();
194
195 if let Some((next, _)) = next_char
196 && next != ' '
197 {
198 selection.end = advance_end(selection.end, 1, always_advance);
199 }
200
201 if let Some(times) = times
202 && times > 1
203 {
204 selection.end = advance_end(selection.end, times - 1, true);
205 }
206
207 selection.end = motion::next_char(map, selection.end, false);
208 Some(MotionKind::Inclusive)
209 } else {
210 let motion = if use_subword {
211 Motion::NextSubwordStart { ignore_punctuation }
212 } else {
213 Motion::NextWordStart { ignore_punctuation }
214 };
215 motion.expand_selection(map, selection, times, text_layout_details, false)
216 }
217}
218
219#[cfg(test)]
220mod test {
221 use indoc::indoc;
222
223 use crate::state::Mode;
224 use crate::test::{NeovimBackedTestContext, VimTestContext};
225
226 #[gpui::test]
227 async fn test_change_h(cx: &mut gpui::TestAppContext) {
228 let mut cx = NeovimBackedTestContext::new(cx).await;
229 cx.simulate("c h", "Teˇst").await.assert_matches();
230 cx.simulate("c h", "Tˇest").await.assert_matches();
231 cx.simulate("c h", "ˇTest").await.assert_matches();
232 cx.simulate(
233 "c h",
234 indoc! {"
235 Test
236 ˇtest"},
237 )
238 .await
239 .assert_matches();
240 }
241
242 #[gpui::test]
243 async fn test_change_backspace(cx: &mut gpui::TestAppContext) {
244 let mut cx = NeovimBackedTestContext::new(cx).await;
245 cx.simulate("c backspace", "Teˇst").await.assert_matches();
246 cx.simulate("c backspace", "Tˇest").await.assert_matches();
247 cx.simulate("c backspace", "ˇTest").await.assert_matches();
248 cx.simulate(
249 "c backspace",
250 indoc! {"
251 Test
252 ˇtest"},
253 )
254 .await
255 .assert_matches();
256 }
257
258 #[gpui::test]
259 async fn test_change_l(cx: &mut gpui::TestAppContext) {
260 let mut cx = NeovimBackedTestContext::new(cx).await;
261 cx.simulate("c l", "Teˇst").await.assert_matches();
262 cx.simulate("c l", "Tesˇt").await.assert_matches();
263 }
264
265 #[gpui::test]
266 async fn test_change_w(cx: &mut gpui::TestAppContext) {
267 let mut cx = NeovimBackedTestContext::new(cx).await;
268 cx.simulate("c w", "Teˇst").await.assert_matches();
269 cx.simulate("c w", "Tˇest test").await.assert_matches();
270 cx.simulate("c w", "Testˇ test").await.assert_matches();
271 cx.simulate("c w", "Tesˇt test").await.assert_matches();
272 cx.simulate(
273 "c w",
274 indoc! {"
275 Test teˇst
276 test"},
277 )
278 .await
279 .assert_matches();
280 cx.simulate(
281 "c w",
282 indoc! {"
283 Test tesˇt
284 test"},
285 )
286 .await
287 .assert_matches();
288 cx.simulate(
289 "c w",
290 indoc! {"
291 Test test
292 ˇ
293 test"},
294 )
295 .await
296 .assert_matches();
297
298 cx.simulate("c shift-w", "Test teˇst-test test")
299 .await
300 .assert_matches();
301
302 // on last character of word, `cw` doesn't eat subsequent punctuation
303 // see https://github.com/zed-industries/zed/issues/35269
304 cx.simulate("c w", "tesˇt-test").await.assert_matches();
305
306 cx.simulate("c 2 w", "ˇTest test test")
307 .await
308 .assert_matches();
309 cx.simulate("c 2 w", "Tˇest test test")
310 .await
311 .assert_matches();
312 cx.simulate("c 2 w", "tesˇt-test").await.assert_matches();
313
314 cx.simulate("c 2 shift-w", "Test teˇst-test test Test")
315 .await
316 .assert_matches();
317 }
318
319 #[gpui::test]
320 async fn test_change_e(cx: &mut gpui::TestAppContext) {
321 let mut cx = NeovimBackedTestContext::new(cx).await;
322 cx.simulate("c e", "Teˇst Test").await.assert_matches();
323 cx.simulate("c e", "Tˇest test").await.assert_matches();
324 cx.simulate(
325 "c e",
326 indoc! {"
327 Test teˇst
328 test"},
329 )
330 .await
331 .assert_matches();
332 cx.simulate(
333 "c e",
334 indoc! {"
335 Test tesˇt
336 test"},
337 )
338 .await
339 .assert_matches();
340 cx.simulate(
341 "c e",
342 indoc! {"
343 Test test
344 ˇ
345 test"},
346 )
347 .await
348 .assert_matches();
349
350 cx.simulate("c shift-e", "Test teˇst-test test")
351 .await
352 .assert_matches();
353 }
354
355 #[gpui::test]
356 async fn test_change_b(cx: &mut gpui::TestAppContext) {
357 let mut cx = NeovimBackedTestContext::new(cx).await;
358 cx.simulate("c b", "Teˇst Test").await.assert_matches();
359 cx.simulate("c b", "Test ˇtest").await.assert_matches();
360 cx.simulate("c b", "Test1 test2 ˇtest3")
361 .await
362 .assert_matches();
363 cx.simulate(
364 "c b",
365 indoc! {"
366 Test test
367 ˇtest"},
368 )
369 .await
370 .assert_matches();
371 cx.simulate(
372 "c b",
373 indoc! {"
374 Test test
375 ˇ
376 test"},
377 )
378 .await
379 .assert_matches();
380
381 cx.simulate("c shift-b", "Test test-test ˇtest")
382 .await
383 .assert_matches();
384 }
385
386 #[gpui::test]
387 async fn test_change_end_of_line(cx: &mut gpui::TestAppContext) {
388 let mut cx = NeovimBackedTestContext::new(cx).await;
389 cx.simulate(
390 "c $",
391 indoc! {"
392 The qˇuick
393 brown fox"},
394 )
395 .await
396 .assert_matches();
397 cx.simulate(
398 "c $",
399 indoc! {"
400 The quick
401 ˇ
402 brown fox"},
403 )
404 .await
405 .assert_matches();
406 }
407
408 #[gpui::test]
409 async fn test_change_0(cx: &mut gpui::TestAppContext) {
410 let mut cx = NeovimBackedTestContext::new(cx).await;
411
412 cx.simulate(
413 "c 0",
414 indoc! {"
415 The qˇuick
416 brown fox"},
417 )
418 .await
419 .assert_matches();
420 cx.simulate(
421 "c 0",
422 indoc! {"
423 The quick
424 ˇ
425 brown fox"},
426 )
427 .await
428 .assert_matches();
429 }
430
431 #[gpui::test]
432 async fn test_change_k(cx: &mut gpui::TestAppContext) {
433 let mut cx = NeovimBackedTestContext::new(cx).await;
434
435 cx.simulate(
436 "c k",
437 indoc! {"
438 The quick
439 brown ˇfox
440 jumps over"},
441 )
442 .await
443 .assert_matches();
444 cx.simulate(
445 "c k",
446 indoc! {"
447 The quick
448 brown fox
449 jumps ˇover"},
450 )
451 .await
452 .assert_matches();
453 cx.simulate(
454 "c k",
455 indoc! {"
456 The qˇuick
457 brown fox
458 jumps over"},
459 )
460 .await
461 .assert_matches();
462 cx.simulate(
463 "c k",
464 indoc! {"
465 ˇ
466 brown fox
467 jumps over"},
468 )
469 .await
470 .assert_matches();
471 cx.simulate(
472 "c k",
473 indoc! {"
474 The quick
475 brown fox
476 ˇjumps over"},
477 )
478 .await
479 .assert_matches();
480 }
481
482 #[gpui::test]
483 async fn test_change_j(cx: &mut gpui::TestAppContext) {
484 let mut cx = NeovimBackedTestContext::new(cx).await;
485 cx.simulate(
486 "c j",
487 indoc! {"
488 The quick
489 brown ˇfox
490 jumps over"},
491 )
492 .await
493 .assert_matches();
494 cx.simulate(
495 "c j",
496 indoc! {"
497 The quick
498 brown fox
499 jumps ˇover"},
500 )
501 .await
502 .assert_matches();
503 cx.simulate(
504 "c j",
505 indoc! {"
506 The qˇuick
507 brown fox
508 jumps over"},
509 )
510 .await
511 .assert_matches();
512 cx.simulate(
513 "c j",
514 indoc! {"
515 The quick
516 brown fox
517 ˇ"},
518 )
519 .await
520 .assert_matches();
521 cx.simulate(
522 "c j",
523 indoc! {"
524 The quick
525 ˇbrown fox
526 jumps over"},
527 )
528 .await
529 .assert_matches();
530 }
531
532 #[gpui::test]
533 async fn test_change_end_of_document(cx: &mut gpui::TestAppContext) {
534 let mut cx = NeovimBackedTestContext::new(cx).await;
535 cx.simulate(
536 "c shift-g",
537 indoc! {"
538 The quick
539 brownˇ fox
540 jumps over
541 the lazy"},
542 )
543 .await
544 .assert_matches();
545 cx.simulate(
546 "c shift-g",
547 indoc! {"
548 The quick
549 brownˇ fox
550 jumps over
551 the lazy"},
552 )
553 .await
554 .assert_matches();
555 cx.simulate(
556 "c shift-g",
557 indoc! {"
558 The quick
559 brown fox
560 jumps over
561 the lˇazy"},
562 )
563 .await
564 .assert_matches();
565 cx.simulate(
566 "c shift-g",
567 indoc! {"
568 The quick
569 brown fox
570 jumps over
571 ˇ"},
572 )
573 .await
574 .assert_matches();
575 }
576
577 #[gpui::test]
578 async fn test_change_cc(cx: &mut gpui::TestAppContext) {
579 let mut cx = NeovimBackedTestContext::new(cx).await;
580 cx.simulate(
581 "c c",
582 indoc! {"
583 The quick
584 brownˇ fox
585 jumps over
586 the lazy"},
587 )
588 .await
589 .assert_matches();
590
591 cx.simulate(
592 "c c",
593 indoc! {"
594 ˇThe quick
595 brown fox
596 jumps over
597 the lazy"},
598 )
599 .await
600 .assert_matches();
601
602 cx.simulate(
603 "c c",
604 indoc! {"
605 The quick
606 broˇwn fox
607 jumps over
608 the lazy"},
609 )
610 .await
611 .assert_matches();
612 }
613
614 #[gpui::test]
615 async fn test_change_gg(cx: &mut gpui::TestAppContext) {
616 let mut cx = NeovimBackedTestContext::new(cx).await;
617 cx.simulate(
618 "c g g",
619 indoc! {"
620 The quick
621 brownˇ fox
622 jumps over
623 the lazy"},
624 )
625 .await
626 .assert_matches();
627 cx.simulate(
628 "c g g",
629 indoc! {"
630 The quick
631 brown fox
632 jumps over
633 the lˇazy"},
634 )
635 .await
636 .assert_matches();
637 cx.simulate(
638 "c g g",
639 indoc! {"
640 The qˇuick
641 brown fox
642 jumps over
643 the lazy"},
644 )
645 .await
646 .assert_matches();
647 cx.simulate(
648 "c g g",
649 indoc! {"
650 ˇ
651 brown fox
652 jumps over
653 the lazy"},
654 )
655 .await
656 .assert_matches();
657 }
658
659 #[gpui::test]
660 async fn test_repeated_cj(cx: &mut gpui::TestAppContext) {
661 let mut cx = NeovimBackedTestContext::new(cx).await;
662
663 for count in 1..=5 {
664 cx.simulate_at_each_offset(
665 &format!("c {count} j"),
666 indoc! {"
667 ˇThe quˇickˇ browˇn
668 ˇ
669 ˇfox ˇjumpsˇ-ˇoˇver
670 ˇthe lazy dog
671 "},
672 )
673 .await
674 .assert_matches();
675 }
676 }
677
678 #[gpui::test]
679 async fn test_repeated_cl(cx: &mut gpui::TestAppContext) {
680 let mut cx = NeovimBackedTestContext::new(cx).await;
681
682 for count in 1..=5 {
683 cx.simulate_at_each_offset(
684 &format!("c {count} l"),
685 indoc! {"
686 ˇThe quˇickˇ browˇn
687 ˇ
688 ˇfox ˇjumpsˇ-ˇoˇver
689 ˇthe lazy dog
690 "},
691 )
692 .await
693 .assert_matches();
694 }
695 }
696
697 #[gpui::test]
698 async fn test_repeated_cb(cx: &mut gpui::TestAppContext) {
699 let mut cx = NeovimBackedTestContext::new(cx).await;
700
701 for count in 1..=5 {
702 cx.simulate_at_each_offset(
703 &format!("c {count} b"),
704 indoc! {"
705 ˇThe quˇickˇ browˇn
706 ˇ
707 ˇfox ˇjumpsˇ-ˇoˇver
708 ˇthe lazy dog
709 "},
710 )
711 .await
712 .assert_matches()
713 }
714 }
715
716 #[gpui::test]
717 async fn test_repeated_ce(cx: &mut gpui::TestAppContext) {
718 let mut cx = NeovimBackedTestContext::new(cx).await;
719
720 for count in 1..=5 {
721 cx.simulate_at_each_offset(
722 &format!("c {count} e"),
723 indoc! {"
724 ˇThe quˇickˇ browˇn
725 ˇ
726 ˇfox ˇjumpsˇ-ˇoˇver
727 ˇthe lazy dog
728 "},
729 )
730 .await
731 .assert_matches();
732 }
733 }
734
735 #[gpui::test]
736 async fn test_change_with_selection_spanning_expanded_diff_hunk(cx: &mut gpui::TestAppContext) {
737 let mut cx = VimTestContext::new(cx, true).await;
738
739 let diff_base = indoc! {"
740 fn main() {
741 println!(\"old\");
742 }
743 "};
744
745 cx.set_state(
746 indoc! {"
747 fn main() {
748 ˇprintln!(\"new\");
749 }
750 "},
751 Mode::Normal,
752 );
753 cx.set_head_text(diff_base);
754 cx.update_editor(|editor, window, cx| {
755 editor.expand_all_diff_hunks(&editor::actions::ExpandAllDiffHunks, window, cx);
756 });
757
758 // Enter visual mode and move up so the selection spans from the
759 // insertion (current line) into the deletion (diff base line).
760 // Then press `c` which in visual mode dispatches `vim::Substitute`,
761 // performing the change operation across the insertion/deletion boundary.
762 cx.simulate_keystrokes("v k c");
763 }
764}
765