Skip to repository content1358 lines · 45.8 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T04:01:46.425Z 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
command_palette.rs
1mod persistence;
2
3use std::{
4 cmp::{self, Reverse},
5 collections::{HashMap, VecDeque},
6 sync::Arc,
7 time::Duration,
8};
9
10use client::parse_zed_link;
11use command_palette_hooks::{
12 CommandInterceptItem, CommandInterceptResult, CommandPaletteFilter,
13 GlobalCommandPaletteInterceptor,
14};
15
16use fuzzy_nucleo::{StringMatch, StringMatchCandidate};
17use gpui::{
18 Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
19 ParentElement, Render, Styled, Task, TaskExt, WeakEntity, Window,
20};
21use persistence::CommandPaletteDB;
22use picker::Direction;
23use picker::{Picker, PickerDelegate};
24use postage::{sink::Sink, stream::Stream};
25use settings::Settings;
26use ui::{HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, prelude::*};
27use util::ResultExt;
28use workspace::{ModalView, Workspace, WorkspaceSettings};
29use zed_actions::{OpenAppUrl, command_palette::Toggle};
30
31pub fn init(cx: &mut App) {
32 command_palette_hooks::init(cx);
33 cx.observe_new(CommandPalette::register).detach();
34}
35
36impl ModalView for CommandPalette {}
37
38pub struct CommandPalette {
39 picker: Entity<Picker<CommandPaletteDelegate>>,
40}
41
42/// Removes subsequent whitespace characters and double colons from the query, and converts
43/// underscores to spaces.
44///
45/// This improves the likelihood of a match by either humanized name or keymap-style name.
46/// Underscores are converted to spaces because `humanize_action_name` converts them to spaces
47/// when building the search candidates (e.g. `terminal_panel::Toggle` -> `terminal panel: toggle`).
48pub fn normalize_action_query(input: &str) -> String {
49 let mut result = String::with_capacity(input.len());
50 let mut last_char = None;
51
52 for char in input.trim().chars() {
53 let normalized_char = if char == '_' { ' ' } else { char };
54 match (last_char, normalized_char) {
55 (Some(':'), ':') => continue,
56 (Some(last_char), c) if last_char.is_whitespace() && c.is_whitespace() => {
57 continue;
58 }
59 _ => {
60 last_char = Some(normalized_char);
61 }
62 }
63 result.push(normalized_char);
64 }
65
66 result
67}
68
69impl CommandPalette {
70 fn register(
71 workspace: &mut Workspace,
72 _window: Option<&mut Window>,
73 _: &mut Context<Workspace>,
74 ) {
75 workspace.register_action(|workspace, _: &Toggle, window, cx| {
76 Self::toggle(workspace, "", window, cx)
77 });
78 }
79
80 pub fn toggle(
81 workspace: &mut Workspace,
82 query: &str,
83 window: &mut Window,
84 cx: &mut Context<Workspace>,
85 ) {
86 if workspace.active_modal::<CommandPalette>(cx).is_some() {
87 workspace.hide_modal(window, cx);
88 return;
89 }
90
91 if workspace.has_active_modal(window, cx) && !workspace.hide_modal(window, cx) {
92 return;
93 }
94
95 let Some(previous_focus_handle) = window.focused(cx) else {
96 return;
97 };
98
99 let entity = cx.weak_entity();
100 workspace.toggle_modal(window, cx, move |window, cx| {
101 CommandPalette::new(previous_focus_handle, query, entity, window, cx)
102 });
103 }
104
105 fn new(
106 previous_focus_handle: FocusHandle,
107 query: &str,
108 entity: WeakEntity<Workspace>,
109 window: &mut Window,
110 cx: &mut Context<Self>,
111 ) -> Self {
112 let filter = CommandPaletteFilter::try_global(cx);
113
114 let commands = window
115 .available_actions(cx)
116 .into_iter()
117 .filter_map(|action| {
118 if filter.is_some_and(|filter| filter.is_hidden(&*action)) {
119 return None;
120 }
121
122 Some(Command {
123 name: humanize_action_name(action.name()),
124 action,
125 })
126 })
127 .collect();
128
129 let delegate = CommandPaletteDelegate::new(
130 cx.entity().downgrade(),
131 entity,
132 commands,
133 previous_focus_handle,
134 );
135
136 let picker = cx.new(|cx| {
137 // One-shot action; there's nothing to reopen.
138 let picker = Picker::uniform_list(delegate, window, cx)
139 .reopenable(false, cx)
140 .show_scrollbar(true);
141 picker.set_query(query, window, cx);
142 picker
143 });
144 Self { picker }
145 }
146
147 pub fn set_query(&mut self, query: &str, window: &mut Window, cx: &mut Context<Self>) {
148 self.picker
149 .update(cx, |picker, cx| picker.set_query(query, window, cx))
150 }
151}
152
153impl EventEmitter<DismissEvent> for CommandPalette {}
154
155impl Focusable for CommandPalette {
156 fn focus_handle(&self, cx: &App) -> FocusHandle {
157 self.picker.focus_handle(cx)
158 }
159}
160
161impl Render for CommandPalette {
162 fn render(&mut self, _window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
163 v_flex()
164 .key_context("CommandPalette")
165 .child(self.picker.clone())
166 }
167}
168
169pub struct CommandPaletteDelegate {
170 latest_query: String,
171 command_palette: WeakEntity<CommandPalette>,
172 workspace: WeakEntity<Workspace>,
173 all_commands: Vec<Command>,
174 commands: Vec<Command>,
175 matches: Vec<StringMatch>,
176 selected_ix: usize,
177 previous_focus_handle: FocusHandle,
178 updating_matches: Option<(
179 Task<()>,
180 postage::dispatch::Receiver<(Vec<Command>, Vec<StringMatch>, CommandInterceptResult)>,
181 )>,
182 query_history: QueryHistory,
183}
184
185struct Command {
186 name: String,
187 action: Box<dyn Action>,
188}
189
190#[derive(Default)]
191struct QueryHistory {
192 history: Option<VecDeque<String>>,
193 cursor: Option<usize>,
194 prefix: Option<String>,
195}
196
197impl QueryHistory {
198 fn history(&mut self, cx: &App) -> &mut VecDeque<String> {
199 self.history.get_or_insert_with(|| {
200 CommandPaletteDB::global(cx)
201 .list_recent_queries()
202 .unwrap_or_default()
203 .into_iter()
204 .collect()
205 })
206 }
207
208 fn add(&mut self, query: String, cx: &App) {
209 if let Some(pos) = self.history(cx).iter().position(|h| h == &query) {
210 self.history(cx).remove(pos);
211 }
212 self.history(cx).push_back(query);
213 self.cursor = None;
214 self.prefix = None;
215 }
216
217 fn validate_cursor(&mut self, current_query: &str, cx: &App) -> Option<usize> {
218 if let Some(pos) = self.cursor {
219 if self.history(cx).get(pos).map(|s| s.as_str()) != Some(current_query) {
220 self.cursor = None;
221 self.prefix = None;
222 }
223 }
224 self.cursor
225 }
226
227 fn previous(&mut self, current_query: &str, cx: &App) -> Option<&str> {
228 if self.validate_cursor(current_query, cx).is_none() {
229 self.prefix = Some(current_query.to_string());
230 }
231
232 let prefix = self.prefix.clone().unwrap_or_default();
233 let start_index = self.cursor.unwrap_or(self.history(cx).len());
234
235 for i in (0..start_index).rev() {
236 if self
237 .history(cx)
238 .get(i)
239 .is_some_and(|e| e.starts_with(&prefix))
240 {
241 self.cursor = Some(i);
242 return self.history(cx).get(i).map(|s| s.as_str());
243 }
244 }
245 None
246 }
247
248 fn next(&mut self, current_query: &str, cx: &App) -> Option<&str> {
249 let selected = self.validate_cursor(current_query, cx)?;
250 let prefix = self.prefix.clone().unwrap_or_default();
251
252 for i in (selected + 1)..self.history(cx).len() {
253 if self
254 .history(cx)
255 .get(i)
256 .is_some_and(|e| e.starts_with(&prefix))
257 {
258 self.cursor = Some(i);
259 return self.history(cx).get(i).map(|s| s.as_str());
260 }
261 }
262 None
263 }
264
265 fn reset_cursor(&mut self) {
266 self.cursor = None;
267 self.prefix = None;
268 }
269
270 fn is_navigating(&self) -> bool {
271 self.cursor.is_some()
272 }
273}
274
275impl Clone for Command {
276 fn clone(&self) -> Self {
277 Self {
278 name: self.name.clone(),
279 action: self.action.boxed_clone(),
280 }
281 }
282}
283
284impl CommandPaletteDelegate {
285 fn new(
286 command_palette: WeakEntity<CommandPalette>,
287 workspace: WeakEntity<Workspace>,
288 commands: Vec<Command>,
289 previous_focus_handle: FocusHandle,
290 ) -> Self {
291 Self {
292 command_palette,
293 workspace,
294 all_commands: commands.clone(),
295 matches: vec![],
296 commands,
297 selected_ix: 0,
298 previous_focus_handle,
299 latest_query: String::new(),
300 updating_matches: None,
301 query_history: Default::default(),
302 }
303 }
304
305 fn matches_updated(
306 &mut self,
307 query: String,
308 mut commands: Vec<Command>,
309 mut matches: Vec<StringMatch>,
310 intercept_result: CommandInterceptResult,
311 _: &mut Context<Picker<Self>>,
312 ) {
313 self.updating_matches.take();
314 self.latest_query = query;
315
316 let mut new_matches = Vec::new();
317
318 for CommandInterceptItem {
319 action,
320 string,
321 positions,
322 } in intercept_result.results
323 {
324 if let Some(idx) = matches
325 .iter()
326 .position(|m| commands[m.candidate_id].action.partial_eq(&*action))
327 {
328 matches.remove(idx);
329 }
330 commands.push(Command {
331 name: string.clone(),
332 action,
333 });
334 new_matches.push(StringMatch {
335 candidate_id: commands.len() - 1,
336 string: string.into(),
337 positions,
338 score: 0.0,
339 })
340 }
341 if !intercept_result.exclusive {
342 new_matches.append(&mut matches);
343 }
344 self.commands = commands;
345 self.matches = new_matches;
346 if self.matches.is_empty() {
347 self.selected_ix = 0;
348 } else {
349 self.selected_ix = cmp::min(self.selected_ix, self.matches.len() - 1);
350 }
351 }
352
353 /// Hit count for each command in the palette.
354 /// We only account for commands triggered directly via command palette and not by e.g. keystrokes because
355 /// if a user already knows a keystroke for a command, they are unlikely to use a command palette to look for it.
356 fn hit_counts(&self, cx: &App) -> HashMap<String, u16> {
357 if let Ok(commands) = CommandPaletteDB::global(cx).list_commands_used() {
358 commands
359 .into_iter()
360 .map(|command| (command.command_name, command.invocations))
361 .collect()
362 } else {
363 HashMap::new()
364 }
365 }
366
367 fn selected_command(&self) -> Option<&Command> {
368 if self.matches.is_empty() {
369 return None;
370 }
371 let action_ix = self
372 .matches
373 .get(self.selected_ix)
374 .map(|m| m.candidate_id)
375 .unwrap_or(self.selected_ix);
376 // this gets called in headless tests where there are no commands loaded
377 // so we need to return an Option here
378 self.commands.get(action_ix)
379 }
380
381 #[cfg(any(test, feature = "test-support"))]
382 pub fn seed_history(&mut self, queries: &[&str]) {
383 self.query_history.history = Some(queries.iter().map(|s| s.to_string()).collect());
384 }
385}
386
387impl PickerDelegate for CommandPaletteDelegate {
388 type ListItem = ListItem;
389
390 fn name() -> &'static str {
391 "command palette"
392 }
393
394 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
395 "Execute a command...".into()
396 }
397
398 fn select_history(
399 &mut self,
400 direction: Direction,
401 query: &str,
402 _window: &mut Window,
403 cx: &mut App,
404 ) -> Option<String> {
405 match direction {
406 Direction::Up => {
407 let should_use_history =
408 self.selected_ix == 0 || self.query_history.is_navigating();
409 if should_use_history {
410 if let Some(query) = self
411 .query_history
412 .previous(query, cx)
413 .map(|s| s.to_string())
414 {
415 return Some(query);
416 }
417 }
418 }
419 Direction::Down => {
420 if self.query_history.is_navigating() {
421 if let Some(query) = self.query_history.next(query, cx).map(|s| s.to_string()) {
422 return Some(query);
423 } else {
424 let prefix = self.query_history.prefix.take().unwrap_or_default();
425 self.query_history.reset_cursor();
426 return Some(prefix);
427 }
428 }
429 }
430 }
431 None
432 }
433
434 fn match_count(&self) -> usize {
435 self.matches.len()
436 }
437
438 fn selected_index(&self) -> usize {
439 self.selected_ix
440 }
441
442 fn set_selected_index(
443 &mut self,
444 ix: usize,
445 _window: &mut Window,
446 _: &mut Context<Picker<Self>>,
447 ) {
448 self.selected_ix = ix;
449 }
450
451 fn update_matches(
452 &mut self,
453 mut query: String,
454 window: &mut Window,
455 cx: &mut Context<Picker<Self>>,
456 ) -> gpui::Task<()> {
457 let settings = WorkspaceSettings::get_global(cx);
458 if let Some(alias) = settings.command_aliases.get(&query) {
459 query = alias.as_ref().to_owned();
460 }
461
462 let workspace = self.workspace.clone();
463
464 let intercept_task = GlobalCommandPaletteInterceptor::intercept(&query, workspace, cx);
465
466 let (mut tx, mut rx) = postage::dispatch::channel(1);
467
468 let query_str = query.as_str();
469 let is_zed_link = parse_zed_link(query_str, cx).is_some();
470
471 let task = cx.background_spawn({
472 let mut commands = self.all_commands.clone();
473 let hit_counts = self.hit_counts(cx);
474 let executor = cx.background_executor().clone();
475 let query = normalize_action_query(query_str);
476 let query_for_link = query_str.to_string();
477 async move {
478 commands.sort_by_key(|action| {
479 (
480 Reverse(hit_counts.get(&action.name).cloned()),
481 action.name.clone(),
482 )
483 });
484
485 let candidates = commands
486 .iter()
487 .enumerate()
488 .map(|(ix, command)| StringMatchCandidate::new(ix, &command.name))
489 .collect::<Vec<_>>();
490
491 let matches = fuzzy_nucleo::match_strings_async(
492 &candidates,
493 &query,
494 fuzzy_nucleo::Case::Smart,
495 fuzzy_nucleo::LengthPenalty::On,
496 10000,
497 &Default::default(),
498 executor,
499 )
500 .await;
501
502 let intercept_result = if is_zed_link {
503 CommandInterceptResult {
504 results: vec![CommandInterceptItem {
505 action: OpenAppUrl {
506 url: query_for_link.clone().into(),
507 }
508 .boxed_clone(),
509 string: query_for_link,
510 positions: vec![],
511 }],
512 exclusive: false,
513 }
514 } else if let Some(task) = intercept_task {
515 task.await
516 } else {
517 CommandInterceptResult::default()
518 };
519
520 tx.send((commands, matches, intercept_result))
521 .await
522 .log_err();
523 }
524 });
525
526 self.updating_matches = Some((task, rx.clone()));
527
528 cx.spawn_in(window, async move |picker, cx| {
529 let Some((commands, matches, intercept_result)) = rx.recv().await else {
530 return;
531 };
532
533 picker
534 .update(cx, |picker, cx| {
535 picker
536 .delegate
537 .matches_updated(query, commands, matches, intercept_result, cx)
538 })
539 .ok();
540 })
541 }
542
543 fn finalize_update_matches(
544 &mut self,
545 query: String,
546 duration: Duration,
547 _: &mut Window,
548 cx: &mut Context<Picker<Self>>,
549 ) -> bool {
550 let Some((task, rx)) = self.updating_matches.take() else {
551 return true;
552 };
553
554 match cx
555 .foreground_executor()
556 .block_with_timeout(duration, rx.clone().recv())
557 {
558 Ok(Some((commands, matches, interceptor_result))) => {
559 self.matches_updated(query, commands, matches, interceptor_result, cx);
560 true
561 }
562 _ => {
563 self.updating_matches = Some((task, rx));
564 false
565 }
566 }
567 }
568
569 fn dismissed(&mut self, _window: &mut Window, cx: &mut Context<Picker<Self>>) {
570 self.command_palette
571 .update(cx, |_, cx| cx.emit(DismissEvent))
572 .ok();
573 }
574
575 fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
576 if secondary {
577 if self.matches.is_empty() {
578 return;
579 }
580 let Some(selected_command) = self.selected_command() else {
581 return;
582 };
583 let action_name = selected_command.action.name();
584 let open_keymap = Box::new(zed_actions::ChangeKeybinding {
585 action: action_name.to_string(),
586 });
587 window.dispatch_action(open_keymap, cx);
588 self.dismissed(window, cx);
589 return;
590 }
591
592 if self.matches.is_empty() {
593 self.dismissed(window, cx);
594 return;
595 }
596
597 if !self.latest_query.is_empty() {
598 self.query_history.add(self.latest_query.clone(), cx);
599 self.query_history.reset_cursor();
600 }
601
602 let action_ix = self.matches[self.selected_ix].candidate_id;
603 let command = self.commands.swap_remove(action_ix);
604 telemetry::event!(
605 "Action Invoked",
606 source = "command palette",
607 action = command.name
608 );
609 self.matches.clear();
610 self.commands.clear();
611 let command_name = command.name.clone();
612 let latest_query = self.latest_query.clone();
613 let db = CommandPaletteDB::global(cx);
614 cx.background_spawn(async move {
615 db.write_command_invocation(command_name, latest_query)
616 .await
617 })
618 .detach_and_log_err(cx);
619 let action = command.action;
620 window.focus(&self.previous_focus_handle, cx);
621 self.dismissed(window, cx);
622 window.dispatch_action(action, cx);
623 }
624
625 fn render_match(
626 &self,
627 ix: usize,
628 selected: bool,
629 _: &mut Window,
630 cx: &mut Context<Picker<Self>>,
631 ) -> Option<Self::ListItem> {
632 let matching_command = self.matches.get(ix)?;
633 let command = self.commands.get(matching_command.candidate_id)?;
634
635 Some(
636 ListItem::new(ix)
637 .inset(true)
638 .spacing(ListItemSpacing::Sparse)
639 .toggle_state(selected)
640 .child(
641 h_flex()
642 .w_full()
643 .py_px()
644 .justify_between()
645 .child(HighlightedLabel::new(
646 command.name.clone(),
647 matching_command.positions.clone(),
648 ))
649 .child(KeyBinding::for_action_in(
650 &*command.action,
651 &self.previous_focus_handle,
652 cx,
653 )),
654 ),
655 )
656 }
657
658 fn render_footer(
659 &self,
660 window: &mut Window,
661 cx: &mut Context<Picker<Self>>,
662 ) -> Option<AnyElement> {
663 let selected_command = self.selected_command()?;
664 let keybind =
665 KeyBinding::for_action_in(&*selected_command.action, &self.previous_focus_handle, cx);
666
667 let focus_handle = &self.previous_focus_handle;
668 let keybinding_buttons = if keybind.has_binding(window) {
669 Button::new("change", "Change Keybinding…")
670 .key_binding(
671 KeyBinding::for_action_in(&menu::SecondaryConfirm, focus_handle, cx)
672 .map(|kb| kb.size(rems_from_px(12.))),
673 )
674 .on_click(move |_, window, cx| {
675 window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx);
676 })
677 } else {
678 Button::new("add", "Add Keybinding…")
679 .key_binding(
680 KeyBinding::for_action_in(&menu::SecondaryConfirm, focus_handle, cx)
681 .map(|kb| kb.size(rems_from_px(12.))),
682 )
683 .on_click(move |_, window, cx| {
684 window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx);
685 })
686 };
687
688 Some(
689 h_flex()
690 .w_full()
691 .p_1p5()
692 .gap_1()
693 .justify_end()
694 .border_t_1()
695 .border_color(cx.theme().colors().border_variant)
696 .child(keybinding_buttons)
697 .child(
698 Button::new("run-action", "Run")
699 .key_binding(
700 KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx)
701 .map(|kb| kb.size(rems_from_px(12.))),
702 )
703 .on_click(|_, window, cx| {
704 window.dispatch_action(menu::Confirm.boxed_clone(), cx)
705 }),
706 )
707 .into_any(),
708 )
709 }
710}
711
712pub fn humanize_action_name(name: &str) -> String {
713 let chars = name.chars().collect::<Vec<_>>();
714 let capacity = name.len() + chars.iter().filter(|c| c.is_uppercase()).count();
715 let mut result = String::with_capacity(capacity);
716 let mut index = 0;
717
718 while index < chars.len() {
719 let char = chars[index];
720 if char == ':' {
721 if result.ends_with(':') {
722 result.push(' ');
723 } else {
724 result.push(':');
725 }
726 index += 1;
727 } else if char == '_' {
728 result.push(' ');
729 index += 1;
730 } else if char.is_uppercase() {
731 let start = index;
732 index += 1;
733 while chars
734 .get(index)
735 .is_some_and(|next_char| next_char.is_uppercase())
736 {
737 index += 1;
738 }
739
740 let uppercase_run = &chars[start..index];
741 if uppercase_run.len() > 1 {
742 let split_before_last = chars
743 .get(index)
744 .is_some_and(|next_char| next_char.is_lowercase());
745 let acronym_end = if split_before_last {
746 uppercase_run.len() - 1
747 } else {
748 uppercase_run.len()
749 };
750
751 if acronym_end > 0 {
752 if !result.ends_with(' ') {
753 result.push(' ');
754 }
755 result.extend(&uppercase_run[..acronym_end]);
756 }
757
758 if split_before_last {
759 if !result.ends_with(' ') {
760 result.push(' ');
761 }
762 result.extend(uppercase_run[acronym_end].to_lowercase());
763 }
764 } else {
765 if !result.ends_with(' ') {
766 result.push(' ');
767 }
768 result.extend(char.to_lowercase());
769 }
770 } else {
771 result.push(char);
772 index += 1;
773 }
774 }
775
776 result
777}
778
779impl std::fmt::Debug for Command {
780 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
781 f.debug_struct("Command")
782 .field("name", &self.name)
783 .finish_non_exhaustive()
784 }
785}
786
787#[cfg(test)]
788mod tests {
789 use std::sync::Arc;
790
791 use super::*;
792 use editor::Editor;
793 use go_to_line::GoToLine;
794 use gpui::{TestAppContext, VisualTestContext};
795 use language::Point;
796 use project::Project;
797 use settings::KeymapFile;
798 use workspace::{AppState, MultiWorkspace, Workspace};
799
800 #[test]
801 fn test_humanize_action_name() {
802 assert_eq!(
803 humanize_action_name("editor::GoToDefinition"),
804 "editor: go to definition"
805 );
806 assert_eq!(
807 humanize_action_name("editor::Backspace"),
808 "editor: backspace"
809 );
810 assert_eq!(
811 humanize_action_name("go_to_line::Deploy"),
812 "go to line: deploy"
813 );
814 assert_eq!(
815 humanize_action_name("agent::OpenGlobalAGENTS.mdRules"),
816 "agent: open global AGENTS.md rules"
817 );
818 assert_eq!(
819 humanize_action_name("agent::OpenProjectAGENTS.mdRules"),
820 "agent: open project AGENTS.md rules"
821 );
822 assert_eq!(humanize_action_name("editor::OpenURL"), "editor: open URL");
823 assert_eq!(
824 humanize_action_name("editor::OpenURLParser"),
825 "editor: open URL parser"
826 );
827 }
828
829 #[test]
830 fn test_normalize_query() {
831 assert_eq!(
832 normalize_action_query("editor: backspace"),
833 "editor: backspace"
834 );
835 assert_eq!(
836 normalize_action_query("editor: backspace"),
837 "editor: backspace"
838 );
839 assert_eq!(
840 normalize_action_query("editor: backspace"),
841 "editor: backspace"
842 );
843 assert_eq!(
844 normalize_action_query("editor::GoToDefinition"),
845 "editor:GoToDefinition"
846 );
847 assert_eq!(
848 normalize_action_query("editor::::GoToDefinition"),
849 "editor:GoToDefinition"
850 );
851 assert_eq!(
852 normalize_action_query("editor: :GoToDefinition"),
853 "editor: :GoToDefinition"
854 );
855 assert_eq!(
856 normalize_action_query("terminal_panel::Toggle"),
857 "terminal panel:Toggle"
858 );
859 assert_eq!(
860 normalize_action_query("project_panel::ToggleFocus"),
861 "project panel:ToggleFocus"
862 );
863 }
864
865 #[gpui::test]
866 async fn test_command_palette(cx: &mut TestAppContext) {
867 let app_state = init_test(cx);
868 let db = cx.update(|cx| persistence::CommandPaletteDB::global(cx));
869 db.clear_all().await.unwrap();
870 let project = Project::test(app_state.fs.clone(), [], cx).await;
871 let (multi_workspace, cx) =
872 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
873 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
874
875 let editor = cx.new_window_entity(|window, cx| {
876 let mut editor = Editor::single_line(window, cx);
877 editor.set_text("abc", window, cx);
878 editor
879 });
880
881 workspace.update_in(cx, |workspace, window, cx| {
882 workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx);
883 editor.update(cx, |editor, cx| window.focus(&editor.focus_handle(cx), cx))
884 });
885
886 cx.simulate_keystrokes("cmd-shift-p");
887
888 let palette = workspace.update(cx, |workspace, cx| {
889 workspace
890 .active_modal::<CommandPalette>(cx)
891 .unwrap()
892 .read(cx)
893 .picker
894 .clone()
895 });
896
897 palette.read_with(cx, |palette, _| {
898 assert!(palette.delegate.commands.len() > 5);
899 let is_sorted =
900 |actions: &[Command]| actions.windows(2).all(|pair| pair[0].name <= pair[1].name);
901 assert!(is_sorted(&palette.delegate.commands));
902 });
903
904 cx.simulate_input("bcksp");
905
906 palette.read_with(cx, |palette, _| {
907 assert_eq!(palette.delegate.matches[0].string, "editor: backspace");
908 });
909
910 cx.simulate_keystrokes("enter");
911
912 workspace.update(cx, |workspace, cx| {
913 assert!(workspace.active_modal::<CommandPalette>(cx).is_none());
914 assert_eq!(editor.read(cx).text(cx), "ab")
915 });
916
917 // Add namespace filter, and redeploy the palette
918 cx.update(|_window, cx| {
919 CommandPaletteFilter::update_global(cx, |filter, _| {
920 filter.hide_namespace("editor");
921 });
922 });
923
924 cx.simulate_keystrokes("cmd-shift-p");
925 cx.simulate_input("bcksp");
926
927 let palette = workspace.update(cx, |workspace, cx| {
928 workspace
929 .active_modal::<CommandPalette>(cx)
930 .unwrap()
931 .read(cx)
932 .picker
933 .clone()
934 });
935 palette.read_with(cx, |palette, _| {
936 assert!(palette.delegate.matches.is_empty())
937 });
938 }
939
940 #[gpui::test]
941 async fn test_selected_command_none_when_no_matches(cx: &mut TestAppContext) {
942 let app_state = init_test(cx);
943 let project = Project::test(app_state.fs.clone(), [], cx).await;
944 let (multi_workspace, cx) =
945 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
946 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
947
948 cx.simulate_keystrokes("cmd-shift-p");
949 let picker = workspace.update(cx, |workspace, cx| {
950 workspace
951 .active_modal::<CommandPalette>(cx)
952 .unwrap()
953 .read(cx)
954 .picker
955 .clone()
956 });
957
958 cx.simulate_input("definitely-no-command-should-match-this");
959 cx.background_executor.run_until_parked();
960
961 picker.read_with(cx, |picker, _cx| {
962 assert!(picker.delegate.matches.is_empty());
963 assert!(picker.delegate.selected_command().is_none());
964 });
965 }
966 #[gpui::test]
967 async fn test_normalized_matches(cx: &mut TestAppContext) {
968 let app_state = init_test(cx);
969 let project = Project::test(app_state.fs.clone(), [], cx).await;
970 let (multi_workspace, cx) =
971 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
972 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
973
974 let editor = cx.new_window_entity(|window, cx| {
975 let mut editor = Editor::single_line(window, cx);
976 editor.set_text("abc", window, cx);
977 editor
978 });
979
980 workspace.update_in(cx, |workspace, window, cx| {
981 workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx);
982 editor.update(cx, |editor, cx| window.focus(&editor.focus_handle(cx), cx))
983 });
984
985 // Test normalize (trimming whitespace and double colons)
986 cx.simulate_keystrokes("cmd-shift-p");
987
988 let palette = workspace.update(cx, |workspace, cx| {
989 workspace
990 .active_modal::<CommandPalette>(cx)
991 .unwrap()
992 .read(cx)
993 .picker
994 .clone()
995 });
996
997 cx.simulate_input("Editor:: Backspace");
998 palette.read_with(cx, |palette, _| {
999 assert_eq!(palette.delegate.matches[0].string, "editor: backspace");
1000 });
1001 }
1002
1003 #[gpui::test]
1004 async fn test_go_to_line(cx: &mut TestAppContext) {
1005 let app_state = init_test(cx);
1006 let project = Project::test(app_state.fs.clone(), [], cx).await;
1007 let (multi_workspace, cx) =
1008 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1009 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
1010
1011 cx.simulate_keystrokes("cmd-n");
1012
1013 let editor = workspace.update(cx, |workspace, cx| {
1014 workspace.active_item_as::<Editor>(cx).unwrap()
1015 });
1016 editor.update_in(cx, |editor, window, cx| {
1017 editor.set_text("1\n2\n3\n4\n5\n6\n", window, cx)
1018 });
1019
1020 cx.simulate_keystrokes("cmd-shift-p");
1021 cx.simulate_input("go to line: Toggle");
1022 cx.simulate_keystrokes("enter");
1023
1024 workspace.update(cx, |workspace, cx| {
1025 assert!(workspace.active_modal::<GoToLine>(cx).is_some())
1026 });
1027
1028 cx.simulate_keystrokes("3 enter");
1029
1030 editor.update_in(cx, |editor, window, cx| {
1031 assert!(editor.focus_handle(cx).is_focused(window));
1032 assert_eq!(
1033 editor
1034 .selections
1035 .last::<Point>(&editor.display_snapshot(cx))
1036 .range()
1037 .start,
1038 Point::new(2, 0)
1039 );
1040 });
1041 }
1042
1043 #[gpui::test]
1044 async fn test_reopen_command_palette_over_another_modal(cx: &mut TestAppContext) {
1045 let app_state = init_test(cx);
1046 let project = Project::test(app_state.fs.clone(), [], cx).await;
1047 let (multi_workspace, cx) =
1048 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1049 let workspace =
1050 multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone());
1051
1052 cx.simulate_keystrokes("cmd-n");
1053
1054 for _ in 0..2 {
1055 cx.simulate_keystrokes("cmd-shift-p");
1056 cx.simulate_input("go to line: Toggle");
1057 cx.simulate_keystrokes("enter");
1058
1059 workspace.update(cx, |workspace, cx| {
1060 assert!(workspace.active_modal::<GoToLine>(cx).is_some());
1061 });
1062 }
1063 }
1064
1065 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
1066 cx.update(|cx| {
1067 let app_state = AppState::test(cx);
1068 theme_settings::init(theme::LoadThemes::JustBase, cx);
1069 editor::init(cx);
1070 menu::init();
1071 go_to_line::init(cx);
1072 workspace::init(app_state.clone(), cx);
1073 init(cx);
1074 cx.bind_keys(KeymapFile::load_panic_on_failure(
1075 r#"[
1076 {
1077 "bindings": {
1078 "cmd-n": "workspace::NewFile",
1079 "enter": "menu::Confirm",
1080 "cmd-shift-p": "command_palette::Toggle",
1081 "up": "menu::SelectPrevious",
1082 "down": "menu::SelectNext"
1083 }
1084 }
1085 ]"#,
1086 cx,
1087 ));
1088 app_state
1089 })
1090 }
1091
1092 fn open_palette_with_history(
1093 workspace: &Entity<Workspace>,
1094 history: &[&str],
1095 cx: &mut VisualTestContext,
1096 ) -> Entity<Picker<CommandPaletteDelegate>> {
1097 cx.simulate_keystrokes("cmd-shift-p");
1098 cx.run_until_parked();
1099
1100 let palette = workspace.update(cx, |workspace, cx| {
1101 workspace
1102 .active_modal::<CommandPalette>(cx)
1103 .unwrap()
1104 .read(cx)
1105 .picker
1106 .clone()
1107 });
1108
1109 palette.update(cx, |palette, _cx| {
1110 palette.delegate.seed_history(history);
1111 });
1112
1113 palette
1114 }
1115
1116 #[gpui::test]
1117 async fn test_history_navigation_basic(cx: &mut TestAppContext) {
1118 let app_state = init_test(cx);
1119 let project = Project::test(app_state.fs.clone(), [], cx).await;
1120 let (multi_workspace, cx) =
1121 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1122 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
1123
1124 let palette = open_palette_with_history(&workspace, &["backspace", "select all"], cx);
1125
1126 // Query should be empty initially
1127 palette.read_with(cx, |palette, cx| {
1128 assert_eq!(palette.query(cx), "");
1129 });
1130
1131 // Press up - should load most recent query "select all"
1132 cx.simulate_keystrokes("up");
1133 cx.background_executor.run_until_parked();
1134 palette.read_with(cx, |palette, cx| {
1135 assert_eq!(palette.query(cx), "select all");
1136 });
1137
1138 // Press up again - should load "backspace"
1139 cx.simulate_keystrokes("up");
1140 cx.background_executor.run_until_parked();
1141 palette.read_with(cx, |palette, cx| {
1142 assert_eq!(palette.query(cx), "backspace");
1143 });
1144
1145 // Press down - should go back to "select all"
1146 cx.simulate_keystrokes("down");
1147 cx.background_executor.run_until_parked();
1148 palette.read_with(cx, |palette, cx| {
1149 assert_eq!(palette.query(cx), "select all");
1150 });
1151
1152 // Press down again - should clear query (exit history mode)
1153 cx.simulate_keystrokes("down");
1154 cx.background_executor.run_until_parked();
1155 palette.read_with(cx, |palette, cx| {
1156 assert_eq!(palette.query(cx), "");
1157 });
1158 }
1159
1160 #[gpui::test]
1161 async fn test_history_mode_exit_on_typing(cx: &mut TestAppContext) {
1162 let app_state = init_test(cx);
1163 let project = Project::test(app_state.fs.clone(), [], cx).await;
1164 let (multi_workspace, cx) =
1165 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1166 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
1167
1168 let palette = open_palette_with_history(&workspace, &["backspace"], cx);
1169
1170 // Press up to enter history mode
1171 cx.simulate_keystrokes("up");
1172 cx.background_executor.run_until_parked();
1173 palette.read_with(cx, |palette, cx| {
1174 assert_eq!(palette.query(cx), "backspace");
1175 });
1176
1177 // Type something - should append to the history query
1178 cx.simulate_input("x");
1179 cx.background_executor.run_until_parked();
1180 palette.read_with(cx, |palette, cx| {
1181 assert_eq!(palette.query(cx), "backspacex");
1182 });
1183 }
1184
1185 #[gpui::test]
1186 async fn test_history_navigation_with_suggestions(cx: &mut TestAppContext) {
1187 let app_state = init_test(cx);
1188 let project = Project::test(app_state.fs.clone(), [], cx).await;
1189 let (multi_workspace, cx) =
1190 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1191 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
1192
1193 let palette = open_palette_with_history(&workspace, &["editor: close", "editor: open"], cx);
1194
1195 // Open palette with a query that has multiple matches
1196 cx.simulate_input("editor");
1197 cx.background_executor.run_until_parked();
1198
1199 // Should have multiple matches, selected_ix should be 0
1200 palette.read_with(cx, |palette, _| {
1201 assert!(palette.delegate.matches.len() > 1);
1202 assert_eq!(palette.delegate.selected_ix, 0);
1203 });
1204
1205 // Press down - should navigate to next suggestion (not history)
1206 cx.simulate_keystrokes("down");
1207 cx.background_executor.run_until_parked();
1208 palette.read_with(cx, |palette, _| {
1209 assert_eq!(palette.delegate.selected_ix, 1);
1210 });
1211
1212 // Press up - should go back to first suggestion
1213 cx.simulate_keystrokes("up");
1214 cx.background_executor.run_until_parked();
1215 palette.read_with(cx, |palette, _| {
1216 assert_eq!(palette.delegate.selected_ix, 0);
1217 });
1218
1219 // Press up again at top - should enter history mode and show previous query
1220 // that matches the "editor" prefix
1221 cx.simulate_keystrokes("up");
1222 cx.background_executor.run_until_parked();
1223 palette.read_with(cx, |palette, cx| {
1224 assert_eq!(palette.query(cx), "editor: open");
1225 });
1226 }
1227
1228 #[gpui::test]
1229 async fn test_history_prefix_search(cx: &mut TestAppContext) {
1230 let app_state = init_test(cx);
1231 let project = Project::test(app_state.fs.clone(), [], cx).await;
1232 let (multi_workspace, cx) =
1233 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1234 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
1235
1236 let palette = open_palette_with_history(
1237 &workspace,
1238 &["open file", "select all", "select line", "backspace"],
1239 cx,
1240 );
1241
1242 // Type "sel" as a prefix
1243 cx.simulate_input("sel");
1244 cx.background_executor.run_until_parked();
1245
1246 // Press up - should get "select line" (most recent matching "sel")
1247 cx.simulate_keystrokes("up");
1248 cx.background_executor.run_until_parked();
1249 palette.read_with(cx, |palette, cx| {
1250 assert_eq!(palette.query(cx), "select line");
1251 });
1252
1253 // Press up again - should get "select all" (next matching "sel")
1254 cx.simulate_keystrokes("up");
1255 cx.background_executor.run_until_parked();
1256 palette.read_with(cx, |palette, cx| {
1257 assert_eq!(palette.query(cx), "select all");
1258 });
1259
1260 // Press up again - should stay at "select all" (no more matches for "sel")
1261 cx.simulate_keystrokes("up");
1262 cx.background_executor.run_until_parked();
1263 palette.read_with(cx, |palette, cx| {
1264 assert_eq!(palette.query(cx), "select all");
1265 });
1266
1267 // Press down - should go back to "select line"
1268 cx.simulate_keystrokes("down");
1269 cx.background_executor.run_until_parked();
1270 palette.read_with(cx, |palette, cx| {
1271 assert_eq!(palette.query(cx), "select line");
1272 });
1273
1274 // Press down again - should return to original prefix "sel"
1275 cx.simulate_keystrokes("down");
1276 cx.background_executor.run_until_parked();
1277 palette.read_with(cx, |palette, cx| {
1278 assert_eq!(palette.query(cx), "sel");
1279 });
1280 }
1281
1282 #[gpui::test]
1283 async fn test_history_prefix_search_no_matches(cx: &mut TestAppContext) {
1284 let app_state = init_test(cx);
1285 let project = Project::test(app_state.fs.clone(), [], cx).await;
1286 let (multi_workspace, cx) =
1287 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1288 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
1289
1290 let palette =
1291 open_palette_with_history(&workspace, &["open file", "backspace", "select all"], cx);
1292
1293 // Type "xyz" as a prefix that doesn't match anything
1294 cx.simulate_input("xyz");
1295 cx.background_executor.run_until_parked();
1296
1297 // Press up - should stay at "xyz" (no matches)
1298 cx.simulate_keystrokes("up");
1299 cx.background_executor.run_until_parked();
1300 palette.read_with(cx, |palette, cx| {
1301 assert_eq!(palette.query(cx), "xyz");
1302 });
1303 }
1304
1305 #[gpui::test]
1306 async fn test_history_empty_prefix_searches_all(cx: &mut TestAppContext) {
1307 let app_state = init_test(cx);
1308 let project = Project::test(app_state.fs.clone(), [], cx).await;
1309 let (multi_workspace, cx) =
1310 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1311 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
1312
1313 let palette = open_palette_with_history(&workspace, &["alpha", "beta", "gamma"], cx);
1314
1315 // With empty query, press up - should get "gamma" (most recent)
1316 cx.simulate_keystrokes("up");
1317 cx.background_executor.run_until_parked();
1318 palette.read_with(cx, |palette, cx| {
1319 assert_eq!(palette.query(cx), "gamma");
1320 });
1321
1322 // Press up - should get "beta"
1323 cx.simulate_keystrokes("up");
1324 cx.background_executor.run_until_parked();
1325 palette.read_with(cx, |palette, cx| {
1326 assert_eq!(palette.query(cx), "beta");
1327 });
1328
1329 // Press up - should get "alpha"
1330 cx.simulate_keystrokes("up");
1331 cx.background_executor.run_until_parked();
1332 palette.read_with(cx, |palette, cx| {
1333 assert_eq!(palette.query(cx), "alpha");
1334 });
1335
1336 // Press down - should get "beta"
1337 cx.simulate_keystrokes("down");
1338 cx.background_executor.run_until_parked();
1339 palette.read_with(cx, |palette, cx| {
1340 assert_eq!(palette.query(cx), "beta");
1341 });
1342
1343 // Press down - should get "gamma"
1344 cx.simulate_keystrokes("down");
1345 cx.background_executor.run_until_parked();
1346 palette.read_with(cx, |palette, cx| {
1347 assert_eq!(palette.query(cx), "gamma");
1348 });
1349
1350 // Press down - should return to empty string (exit history mode)
1351 cx.simulate_keystrokes("down");
1352 cx.background_executor.run_until_parked();
1353 palette.read_with(cx, |palette, cx| {
1354 assert_eq!(palette.query(cx), "");
1355 });
1356 }
1357}
1358