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}