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