command.rs

  1use std::sync::OnceLock;
  2
  3use command_palette_hooks::CommandInterceptResult;
  4use editor::actions::{SortLinesCaseInsensitive, SortLinesCaseSensitive};
  5use gpui::{impl_actions, Action, AppContext, Global, ViewContext};
  6use serde_derive::Deserialize;
  7use util::ResultExt;
  8use workspace::{SaveIntent, Workspace};
  9
 10use crate::{
 11    motion::{EndOfDocument, Motion, StartOfDocument},
 12    normal::{
 13        move_cursor,
 14        search::{range_regex, FindCommand, ReplaceCommand},
 15        JoinLines,
 16    },
 17    state::Mode,
 18    Vim,
 19};
 20
 21#[derive(Debug, Clone, PartialEq, Deserialize)]
 22pub struct GoToLine {
 23    pub line: u32,
 24}
 25
 26impl_actions!(vim, [GoToLine]);
 27
 28pub fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
 29    workspace.register_action(|_: &mut Workspace, action: &GoToLine, cx| {
 30        Vim::update(cx, |vim, cx| {
 31            vim.switch_mode(Mode::Normal, false, cx);
 32            move_cursor(vim, Motion::StartOfDocument, Some(action.line as usize), cx);
 33        });
 34    });
 35}
 36
 37struct VimCommand {
 38    prefix: &'static str,
 39    suffix: &'static str,
 40    action: Option<Box<dyn Action>>,
 41    action_name: Option<&'static str>,
 42    bang_action: Option<Box<dyn Action>>,
 43}
 44
 45impl VimCommand {
 46    fn new(pattern: (&'static str, &'static str), action: impl Action) -> Self {
 47        Self {
 48            prefix: pattern.0,
 49            suffix: pattern.1,
 50            action: Some(action.boxed_clone()),
 51            action_name: None,
 52            bang_action: None,
 53        }
 54    }
 55
 56    // from_str is used for actions in other crates.
 57    fn str(pattern: (&'static str, &'static str), action_name: &'static str) -> Self {
 58        Self {
 59            prefix: pattern.0,
 60            suffix: pattern.1,
 61            action: None,
 62            action_name: Some(action_name),
 63            bang_action: None,
 64        }
 65    }
 66
 67    fn bang(mut self, bang_action: impl Action) -> Self {
 68        self.bang_action = Some(bang_action.boxed_clone());
 69        self
 70    }
 71
 72    fn parse(&self, mut query: &str, cx: &AppContext) -> Option<Box<dyn Action>> {
 73        let has_bang = query.ends_with('!');
 74        if has_bang {
 75            query = &query[..query.len() - 1];
 76        }
 77
 78        let Some(suffix) = query.strip_prefix(self.prefix) else {
 79            return None;
 80        };
 81        if !self.suffix.starts_with(suffix) {
 82            return None;
 83        }
 84
 85        if has_bang && self.bang_action.is_some() {
 86            Some(self.bang_action.as_ref().unwrap().boxed_clone())
 87        } else if let Some(action) = self.action.as_ref() {
 88            Some(action.boxed_clone())
 89        } else if let Some(action_name) = self.action_name {
 90            cx.build_action(action_name, None).log_err()
 91        } else {
 92            None
 93        }
 94    }
 95}
 96
 97fn generate_commands(_: &AppContext) -> Vec<VimCommand> {
 98    vec![
 99        VimCommand::new(
100            ("w", "rite"),
101            workspace::Save {
102                save_intent: Some(SaveIntent::Save),
103            },
104        )
105        .bang(workspace::Save {
106            save_intent: Some(SaveIntent::Overwrite),
107        }),
108        VimCommand::new(
109            ("q", "uit"),
110            workspace::CloseActiveItem {
111                save_intent: Some(SaveIntent::Close),
112            },
113        )
114        .bang(workspace::CloseActiveItem {
115            save_intent: Some(SaveIntent::Skip),
116        }),
117        VimCommand::new(
118            ("wq", ""),
119            workspace::CloseActiveItem {
120                save_intent: Some(SaveIntent::Save),
121            },
122        )
123        .bang(workspace::CloseActiveItem {
124            save_intent: Some(SaveIntent::Overwrite),
125        }),
126        VimCommand::new(
127            ("x", "it"),
128            workspace::CloseActiveItem {
129                save_intent: Some(SaveIntent::SaveAll),
130            },
131        )
132        .bang(workspace::CloseActiveItem {
133            save_intent: Some(SaveIntent::Overwrite),
134        }),
135        VimCommand::new(
136            ("ex", "it"),
137            workspace::CloseActiveItem {
138                save_intent: Some(SaveIntent::SaveAll),
139            },
140        )
141        .bang(workspace::CloseActiveItem {
142            save_intent: Some(SaveIntent::Overwrite),
143        }),
144        VimCommand::new(
145            ("up", "date"),
146            workspace::Save {
147                save_intent: Some(SaveIntent::SaveAll),
148            },
149        ),
150        VimCommand::new(
151            ("wa", "ll"),
152            workspace::SaveAll {
153                save_intent: Some(SaveIntent::SaveAll),
154            },
155        )
156        .bang(workspace::SaveAll {
157            save_intent: Some(SaveIntent::Overwrite),
158        }),
159        VimCommand::new(
160            ("qa", "ll"),
161            workspace::CloseAllItemsAndPanes {
162                save_intent: Some(SaveIntent::Close),
163            },
164        )
165        .bang(workspace::CloseAllItemsAndPanes {
166            save_intent: Some(SaveIntent::Skip),
167        }),
168        VimCommand::new(
169            ("quita", "ll"),
170            workspace::CloseAllItemsAndPanes {
171                save_intent: Some(SaveIntent::Close),
172            },
173        )
174        .bang(workspace::CloseAllItemsAndPanes {
175            save_intent: Some(SaveIntent::Skip),
176        }),
177        VimCommand::new(
178            ("xa", "ll"),
179            workspace::CloseAllItemsAndPanes {
180                save_intent: Some(SaveIntent::SaveAll),
181            },
182        )
183        .bang(workspace::CloseAllItemsAndPanes {
184            save_intent: Some(SaveIntent::Overwrite),
185        }),
186        VimCommand::new(
187            ("wqa", "ll"),
188            workspace::CloseAllItemsAndPanes {
189                save_intent: Some(SaveIntent::SaveAll),
190            },
191        )
192        .bang(workspace::CloseAllItemsAndPanes {
193            save_intent: Some(SaveIntent::Overwrite),
194        }),
195        VimCommand::new(("cq", "uit"), zed_actions::Quit),
196        VimCommand::new(("sp", "lit"), workspace::SplitUp),
197        VimCommand::new(("vs", "plit"), workspace::SplitLeft),
198        VimCommand::new(
199            ("bd", "elete"),
200            workspace::CloseActiveItem {
201                save_intent: Some(SaveIntent::Close),
202            },
203        )
204        .bang(workspace::CloseActiveItem {
205            save_intent: Some(SaveIntent::Skip),
206        }),
207        VimCommand::new(("bn", "ext"), workspace::ActivateNextItem),
208        VimCommand::new(("bN", "ext"), workspace::ActivatePrevItem),
209        VimCommand::new(("bp", "revious"), workspace::ActivatePrevItem),
210        VimCommand::new(("bf", "irst"), workspace::ActivateItem(0)),
211        VimCommand::new(("br", "ewind"), workspace::ActivateItem(0)),
212        VimCommand::new(("bl", "ast"), workspace::ActivateLastItem),
213        VimCommand::new(
214            ("new", ""),
215            workspace::NewFileInDirection(workspace::SplitDirection::Up),
216        ),
217        VimCommand::new(
218            ("vne", "w"),
219            workspace::NewFileInDirection(workspace::SplitDirection::Left),
220        ),
221        VimCommand::new(("tabe", "dit"), workspace::NewFile),
222        VimCommand::new(("tabnew", ""), workspace::NewFile),
223        VimCommand::new(("tabn", "ext"), workspace::ActivateNextItem),
224        VimCommand::new(("tabp", "revious"), workspace::ActivatePrevItem),
225        VimCommand::new(("tabN", "ext"), workspace::ActivatePrevItem),
226        VimCommand::new(
227            ("tabc", "lose"),
228            workspace::CloseActiveItem {
229                save_intent: Some(SaveIntent::Close),
230            },
231        ),
232        VimCommand::new(
233            ("tabo", "nly"),
234            workspace::CloseInactiveItems {
235                save_intent: Some(SaveIntent::Close),
236            },
237        )
238        .bang(workspace::CloseInactiveItems {
239            save_intent: Some(SaveIntent::Skip),
240        }),
241        VimCommand::new(
242            ("on", "ly"),
243            workspace::CloseInactiveTabsAndPanes {
244                save_intent: Some(SaveIntent::Close),
245            },
246        )
247        .bang(workspace::CloseInactiveTabsAndPanes {
248            save_intent: Some(SaveIntent::Skip),
249        }),
250        VimCommand::str(("cl", "ist"), "diagnostics::Deploy"),
251        VimCommand::new(("cc", ""), editor::actions::Hover),
252        VimCommand::new(("ll", ""), editor::actions::Hover),
253        VimCommand::new(("cn", "ext"), editor::actions::GoToDiagnostic),
254        VimCommand::new(("cp", "revious"), editor::actions::GoToPrevDiagnostic),
255        VimCommand::new(("cN", "ext"), editor::actions::GoToPrevDiagnostic),
256        VimCommand::new(("lp", "revious"), editor::actions::GoToPrevDiagnostic),
257        VimCommand::new(("lN", "ext"), editor::actions::GoToPrevDiagnostic),
258        VimCommand::new(("j", "oin"), JoinLines),
259        VimCommand::new(("d", "elete"), editor::actions::DeleteLine),
260        VimCommand::new(("sor", "t"), SortLinesCaseSensitive),
261        VimCommand::new(("sort i", ""), SortLinesCaseInsensitive),
262        VimCommand::str(("E", "xplore"), "project_panel::ToggleFocus"),
263        VimCommand::str(("H", "explore"), "project_panel::ToggleFocus"),
264        VimCommand::str(("L", "explore"), "project_panel::ToggleFocus"),
265        VimCommand::str(("S", "explore"), "project_panel::ToggleFocus"),
266        VimCommand::str(("Ve", "xplore"), "project_panel::ToggleFocus"),
267        VimCommand::str(("te", "rm"), "terminal_panel::ToggleFocus"),
268        VimCommand::str(("T", "erm"), "terminal_panel::ToggleFocus"),
269        VimCommand::str(("C", "ollab"), "collab_panel::ToggleFocus"),
270        VimCommand::str(("Ch", "at"), "chat_panel::ToggleFocus"),
271        VimCommand::str(("No", "tifications"), "notification_panel::ToggleFocus"),
272        VimCommand::str(("A", "I"), "assistant::ToggleFocus"),
273        VimCommand::new(("$", ""), EndOfDocument),
274        VimCommand::new(("%", ""), EndOfDocument),
275        VimCommand::new(("0", ""), StartOfDocument),
276    ]
277}
278
279struct VimCommands(Vec<VimCommand>);
280// safety: we only ever access this from the main thread (as ensured by the cx argument)
281// actions are not Sync so we can't otherwise use a OnceLock.
282unsafe impl Sync for VimCommands {}
283impl Global for VimCommands {}
284
285fn commands(cx: &AppContext) -> &Vec<VimCommand> {
286    static COMMANDS: OnceLock<VimCommands> = OnceLock::new();
287    &COMMANDS
288        .get_or_init(|| VimCommands(generate_commands(cx)))
289        .0
290}
291
292pub fn command_interceptor(mut query: &str, cx: &AppContext) -> Option<CommandInterceptResult> {
293    // Note: this is a very poor simulation of vim's command palette.
294    // In the future we should adjust it to handle parsing range syntax,
295    // and then calling the appropriate commands with/without ranges.
296    //
297    // We also need to support passing arguments to commands like :w
298    // (ideally with filename autocompletion).
299    while query.starts_with(':') {
300        query = &query[1..];
301    }
302
303    for command in commands(cx).iter() {
304        if let Some(action) = command.parse(query, cx) {
305            let string = ":".to_owned() + command.prefix + command.suffix;
306            let positions = generate_positions(&string, query);
307
308            return Some(CommandInterceptResult {
309                action,
310                string,
311                positions,
312            });
313        }
314    }
315
316    let (name, action) = if query.starts_with('/') || query.starts_with('?') {
317        (
318            query,
319            FindCommand {
320                query: query[1..].to_string(),
321                backwards: query.starts_with('?'),
322            }
323            .boxed_clone(),
324        )
325    } else if query.starts_with('%') {
326        (
327            query,
328            ReplaceCommand {
329                query: query.to_string(),
330            }
331            .boxed_clone(),
332        )
333    } else if let Ok(line) = query.parse::<u32>() {
334        (query, GoToLine { line }.boxed_clone())
335    } else if range_regex().is_match(query) {
336        (
337            query,
338            ReplaceCommand {
339                query: query.to_string(),
340            }
341            .boxed_clone(),
342        )
343    } else {
344        return None;
345    };
346
347    let string = ":".to_owned() + name;
348    let positions = generate_positions(&string, query);
349
350    Some(CommandInterceptResult {
351        action,
352        string,
353        positions,
354    })
355}
356
357fn generate_positions(string: &str, query: &str) -> Vec<usize> {
358    let mut positions = Vec::new();
359    let mut chars = query.chars();
360
361    let Some(mut current) = chars.next() else {
362        return positions;
363    };
364
365    for (i, c) in string.char_indices() {
366        if c == current {
367            positions.push(i);
368            if let Some(c) = chars.next() {
369                current = c;
370            } else {
371                break;
372            }
373        }
374    }
375
376    positions
377}
378
379#[cfg(test)]
380mod test {
381    use std::path::Path;
382
383    use crate::test::{NeovimBackedTestContext, VimTestContext};
384    use gpui::TestAppContext;
385    use indoc::indoc;
386
387    #[gpui::test]
388    async fn test_command_basics(cx: &mut TestAppContext) {
389        let mut cx = NeovimBackedTestContext::new(cx).await;
390
391        cx.set_shared_state(indoc! {"
392            ˇa
393            b
394            c"})
395            .await;
396
397        cx.simulate_shared_keystrokes(": j enter").await;
398
399        // hack: our cursor positionining after a join command is wrong
400        cx.simulate_shared_keystrokes("^").await;
401        cx.shared_state().await.assert_eq(indoc! {
402            "ˇa b
403            c"
404        });
405    }
406
407    #[gpui::test]
408    async fn test_command_goto(cx: &mut TestAppContext) {
409        let mut cx = NeovimBackedTestContext::new(cx).await;
410
411        cx.set_shared_state(indoc! {"
412            ˇa
413            b
414            c"})
415            .await;
416        cx.simulate_shared_keystrokes(": 3 enter").await;
417        cx.shared_state().await.assert_eq(indoc! {"
418            a
419            b
420            ˇc"});
421    }
422
423    #[gpui::test]
424    async fn test_command_replace(cx: &mut TestAppContext) {
425        let mut cx = NeovimBackedTestContext::new(cx).await;
426
427        cx.set_shared_state(indoc! {"
428            ˇa
429            b
430            c"})
431            .await;
432        cx.simulate_shared_keystrokes(": % s / b / d enter").await;
433        cx.shared_state().await.assert_eq(indoc! {"
434            a
435            ˇd
436            c"});
437        cx.simulate_shared_keystrokes(": % s : . : \\ 0 \\ 0 enter")
438            .await;
439        cx.shared_state().await.assert_eq(indoc! {"
440            aa
441            dd
442            ˇcc"});
443    }
444
445    #[gpui::test]
446    async fn test_command_search(cx: &mut TestAppContext) {
447        let mut cx = NeovimBackedTestContext::new(cx).await;
448
449        cx.set_shared_state(indoc! {"
450                ˇa
451                b
452                a
453                c"})
454            .await;
455        cx.simulate_shared_keystrokes(": / b enter").await;
456        cx.shared_state().await.assert_eq(indoc! {"
457                a
458                ˇb
459                a
460                c"});
461        cx.simulate_shared_keystrokes(": ? a enter").await;
462        cx.shared_state().await.assert_eq(indoc! {"
463                ˇa
464                b
465                a
466                c"});
467    }
468
469    #[gpui::test]
470    async fn test_command_write(cx: &mut TestAppContext) {
471        let mut cx = VimTestContext::new(cx, true).await;
472        let path = Path::new("/root/dir/file.rs");
473        let fs = cx.workspace(|workspace, cx| workspace.project().read(cx).fs().clone());
474
475        cx.simulate_keystrokes("i @ escape");
476        cx.simulate_keystrokes(": w enter");
477
478        assert_eq!(fs.load(&path).await.unwrap(), "@\n");
479
480        fs.as_fake().insert_file(path, b"oops\n".to_vec()).await;
481
482        // conflict!
483        cx.simulate_keystrokes("i @ escape");
484        cx.simulate_keystrokes(": w enter");
485        assert!(cx.has_pending_prompt());
486        // "Cancel"
487        cx.simulate_prompt_answer(0);
488        assert_eq!(fs.load(&path).await.unwrap(), "oops\n");
489        assert!(!cx.has_pending_prompt());
490        // force overwrite
491        cx.simulate_keystrokes(": w ! enter");
492        assert!(!cx.has_pending_prompt());
493        assert_eq!(fs.load(&path).await.unwrap(), "@@\n");
494    }
495
496    #[gpui::test]
497    async fn test_command_quit(cx: &mut TestAppContext) {
498        let mut cx = VimTestContext::new(cx, true).await;
499
500        cx.simulate_keystrokes(": n e w enter");
501        cx.workspace(|workspace, cx| assert_eq!(workspace.items(cx).count(), 2));
502        cx.simulate_keystrokes(": q enter");
503        cx.workspace(|workspace, cx| assert_eq!(workspace.items(cx).count(), 1));
504        cx.simulate_keystrokes(": n e w enter");
505        cx.workspace(|workspace, cx| assert_eq!(workspace.items(cx).count(), 2));
506        cx.simulate_keystrokes(": q a enter");
507        cx.workspace(|workspace, cx| assert_eq!(workspace.items(cx).count(), 0));
508    }
509}