Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T04:36:15.476Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

change.rs

765 lines · 22.3 KB · rust
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
Served at tenant.openagents/omega Member data and write actions are omitted.