//! The command palette (Ctrl+K), as T3 Code has it: Actions and Recent //! Threads at rest, plus Projects, Settings and Threads while searching, //! with submenus for starting a thread in a project, the theme and the //! appearance. A leading ">" searches actions only. Add project walks //! through submenus too: the device, then the source, then a folder browsed //! from the field, a name for a new repository, or what to clone. use std::path::PathBuf; use std::rc::Rc; use gpui_kit::component::{ Icon, IconName, WindowExt as _, h_flex, input::{IndentInline, Input, InputEvent, InputState}, notification::Notification, v_flex, }; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; use super::actions::relative_time_long; use super::Workspace; use crate::projects::{self, Forge, ProjectSource}; use crate::remote::client::ConnectionStatus; use crate::settings::{self, ThemeMode}; use crate::ui::folder_browser::{self, FolderListing, Folders}; use crate::ui::icons::{self, Lucide}; use crate::ui::metal::{self, MetalButton, MetalStyled as _}; use crate::ui::themes; use crate::{Dismiss, PaletteBack, SelectNext, SelectPrevious}; const RECENT_THREADS: usize = 12; /// Where the palette is: its root, or one of its submenus. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum PaletteView { Root, NewThreadIn, Themes, Appearance, /// Add project: the device it goes on. AddProject, /// Add project: where it comes from, on a device (`None` for this one). ProjectSources(Option), /// Add project: a folder on the device, browsed from the field. Directories(Option), /// Add project: the name of a new repository. NewProject(Option), /// Add project: what to clone, from a Git URL or a forge. CloneRepo(Option, Option), } /// What the field is for. #[derive(Clone, Copy, PartialEq, Eq)] enum Field { /// It filters the rows. Search, /// It's a path; the rows are the folders in it. Path, /// It's text the rows act on: a name, a URL. Text, } impl PaletteView { fn field(&self) -> Field { match self { PaletteView::Directories(_) => Field::Path, PaletteView::NewProject(_) | PaletteView::CloneRepo(..) => Field::Text, _ => Field::Search, } } fn placeholder(&self) -> &'static str { match self { PaletteView::Root => "Search commands, projects, and threads...", PaletteView::Directories(_) => "~/", PaletteView::NewProject(_) => "Project name", PaletteView::CloneRepo(_, None) => "https://github.com/owner/repo.git", PaletteView::CloneRepo(_, Some(forge)) => forge.placeholder(), _ => "Search...", } } } pub(super) enum PaletteEvent { Dismiss, } type Run = Rc)>; /// What leads a row. #[derive(Clone)] enum Glyph { Icon(Icon), /// A project's initials in its tint. Badge(SharedString, usize), /// An icon in its own color, like a forge's logo. Brand(Icon, Hsla), } /// What choosing a row does. #[derive(Clone)] enum Action { /// Nothing: the row only explains. None, Run(Run), Submenu(PaletteView), /// Goes into a folder: the path field's new text. Browse(String), /// Says what's missing, in a toast, and stays open. Hint(SharedString), } /// One row of the palette. #[derive(Clone)] pub(super) struct PaletteItem { group: &'static str, title: SharedString, /// Set in bold after the title: "New thread in **project**". emphasis: Option, description: Option, /// Shown before the description: the laptop of `XPS · ~/Work/app`. description_icon: Option, glyph: Glyph, shortcut: Option, timestamp: Option, /// An engraved tag: `Current` on the theme in use, `git` on a repository. tag: Option, /// An amber tag: `Setup Required`. warning: Option, action: Action, /// What a search matches, most important first. terms: Vec, } impl PaletteItem { fn new(group: &'static str, title: impl Into, icon: impl Into) -> Self { let title = title.into(); Self { group, terms: vec![title.to_string()], title, emphasis: None, description: None, description_icon: None, glyph: Glyph::Icon(icon.into()), shortcut: None, timestamp: None, tag: None, warning: None, action: Action::None, } } fn run(mut self, run: impl Fn(&mut Workspace, &mut Window, &mut Context) + 'static) -> Self { self.action = Action::Run(Rc::new(run)); self } fn submenu(mut self, view: PaletteView) -> Self { self.action = Action::Submenu(view); self } fn hint(mut self, hint: impl Into) -> Self { self.action = Action::Hint(hint.into()); self } fn badge(mut self, initials: impl Into, tint: usize) -> Self { self.glyph = Glyph::Badge(initials.into(), tint); self } /// A description that names a device: `XPS · ~/Work/app`. fn on_device(mut self, description: impl Into) -> Self { self = self.description(description); self.description_icon = Some(Icon::new(Lucide::Laptop)); self } fn shortcut(mut self, shortcut: impl Into) -> Self { self.shortcut = Some(shortcut.into()); self } fn description(mut self, description: impl Into) -> Self { let description = description.into(); self.terms.push(description.to_string()); self.description = Some(description); self } fn terms(mut self, terms: &[&str]) -> Self { self.terms.extend(terms.iter().map(|term| term.to_string())); self } /// How well the query matches: every word must appear in a term; an /// exact match beats a prefix beats a substring, and earlier terms win. fn score(&self, words: &[String]) -> Option { let terms: Vec = self.terms.iter().map(|term| term.to_lowercase()).collect(); let mut total = 0; for word in words { let best = terms .iter() .enumerate() .filter_map(|(ix, term)| { let rank = if term == word { 0 } else if term.starts_with(word.as_str()) { 1 } else if term.contains(word.as_str()) { 2 } else { return None; }; Some(rank * 10 + ix.min(9) as u32) }) .min()?; total += best; } Some(total) } } /// Settings the palette finds by name, with the page they're on. const SETTINGS: &[(&str, &str)] = &[ ("Model", "General"), ("Permissions", "General"), ("Workspace", "General"), ("Submodules", "General"), ("Text generation model", "General"), ("Start from origin", "General"), ("Add project starts in", "General"), ("Project grouping", "General"), ("Project order", "General"), ("Auto-resume limited threads", "General"), ("Snooze limited threads", "General"), ("Working section (beta)", "General"), ("Auto-settle merged threads", "General"), ("Auto-settle inactive threads", "General"), ("Unpin confirmation", "General"), ("Settle confirmation", "General"), ("Delete confirmation", "General"), ("Quit shortcut", "General"), ("Close confirmation", "General"), ("Thread notifications", "Behavior"), ("In-app notifications", "Behavior"), ("Time format", "Behavior"), ("Response streaming", "Behavior"), ("Fold finished turns", "Behavior"), ("Show costs", "Behavior"), ("Hide whitespace changes", "Behavior"), ("Diff layout", "Behavior"), ("Send shortcut", "Behavior"), ("Follow-up behavior", "Behavior"), ("Background activity", "Behavior"), ("Thread cards", "Appearance"), ("Theme", "Themes"), ("Providers", "Providers"), ("Usage", "Usage"), ("Allow remote connections", "Connections"), ("Keybindings", "Keybindings"), ("Archived threads", "Archive"), ("About", "About"), ]; impl Workspace { /// Opens the palette, or closes it when it's open at the root. pub(super) fn toggle_palette(&mut self, window: &mut Window, cx: &mut Context) { if self.palette.is_some() { self.close_palette(window, cx); } else { self.open_palette(PaletteView::Root, window, cx); } } pub(super) fn open_palette(&mut self, view: PaletteView, window: &mut Window, cx: &mut Context) { let workspace = cx.weak_entity(); let palette = cx.new(|cx| CommandPalette::new(workspace, view, window, cx)); let subscription = cx.subscribe_in(&palette, window, |this, _, event: &PaletteEvent, window, cx| match event { PaletteEvent::Dismiss => this.close_palette(window, cx), }); let input = palette.read(cx).input.clone(); input.update(cx, |input, cx| input.focus(window, cx)); self.palette = Some((palette, subscription)); cx.notify(); } pub(super) fn close_palette(&mut self, window: &mut Window, cx: &mut Context) { if self.palette.take().is_some() { if let Some(chat) = self.active.as_ref().and_then(|session| self.chats.get(&session.entity_id())) { chat.update(cx, |chat, cx| chat.focus_composer(window, cx)); } cx.notify(); } } /// Switches between System, Light and Dark, saying which in a toast. pub(super) fn cycle_appearance(&mut self, window: &mut Window, cx: &mut Context) { let next = match settings::get(|settings| settings.theme_mode) { ThemeMode::System => ThemeMode::Light, ThemeMode::Light => ThemeMode::Dark, ThemeMode::Dark => ThemeMode::System, }; themes::set_mode(next, window, cx); window.push_notification(Notification::info(format!("Appearance: {}", mode_label(next))), cx); self.settings_changed(window, cx); } /// The palette's rows for a view, given what's in the field. fn palette_items(&self, view: &PaletteView, query: &str, cx: &App) -> Vec { let searching = !query.trim().is_empty(); match view { PaletteView::Root => { let mut items = self.palette_actions(cx); if searching { items.extend(self.palette_projects(cx)); items.extend(SETTINGS.iter().map(|(title, page)| { let page = *page; PaletteItem::new("Settings", *title, IconName::Settings) .description(format!("Settings · {page}")) .run(move |this, window, cx| this.open_settings_page(page, window, cx)) })); items.extend(self.palette_threads(usize::MAX, "Threads", cx)); } else { items.extend(self.palette_threads(RECENT_THREADS, "Recent Threads", cx)); } items } PaletteView::NewThreadIn => self.palette_new_thread_in(cx), PaletteView::Themes => { let (mode, light, dark) = settings::get(|settings| (settings.theme_mode, settings.light_theme.clone(), settings.dark_theme.clone())); let showing_dark = metal::palette(cx).dark; themes::all() .iter() .enumerate() .map(|(ix, theme)| { let current = (theme.palette.dark && theme.id == dark && (showing_dark || mode == ThemeMode::Dark)) || (!theme.palette.dark && theme.id == light && (!showing_dark || mode == ThemeMode::Light)); let mut item = PaletteItem::new("Themes", theme.name, IconName::Palette) .description(if theme.palette.dark { "Dark" } else { "Light" }) .run(move |this, window, cx| { themes::pick(&themes::all()[ix], window, cx); this.settings_changed(window, cx); }); item.tag = current.then(|| "Current".into()); item }) .collect() } PaletteView::Appearance => { let mode = settings::get(|settings| settings.theme_mode); [ThemeMode::System, ThemeMode::Light, ThemeMode::Dark] .into_iter() .map(|option| { let mut item = PaletteItem::new("Appearance", mode_label(option), Lucide::Monitor).run(move |this, window, cx| { themes::set_mode(option, window, cx); this.settings_changed(window, cx); }); item.tag = (option == mode).then(|| "Current".into()); item }) .collect() } PaletteView::AddProject => self.palette_environments(), PaletteView::ProjectSources(env) => self.palette_sources(env), // The palette lists the folders itself. PaletteView::Directories(_) => Vec::new(), PaletteView::NewProject(env) => self.palette_new_project(env, query), PaletteView::CloneRepo(env, forge) => self.palette_clone(env, *forge, query), } } fn palette_actions(&self, cx: &App) -> Vec { let mut items = Vec::new(); let contextual = (!self.projects.is_empty()).then(|| self.contextual_project()); if let Some(ix) = contextual { let name = self.projects[ix].name.clone(); let mut item = PaletteItem::new("Actions", "New thread in ", Lucide::SquarePen) .shortcut("Ctrl+N") .terms(&[&format!("New thread in {name}"), "new chat"]) .run(move |this, window, cx| this.new_thread(ix, window, cx)); item.emphasis = Some(name); items.push(item); items.push( PaletteItem::new("Actions", "New thread in...", Lucide::SquarePen) .terms(&["project", "new chat"]) .submenu(PaletteView::NewThreadIn), ); } items.push( PaletteItem::new("Actions", "New thread without a project", Lucide::MessageSquareDashed) .shortcut("Ctrl+Alt+N") .terms(&["scratch", "no project"]) .run(|this, window, cx| this.new_thread_without_project(window, cx)), ); if let Some(session) = self.active.clone().filter(|session| !session.read(cx).is_blank()) { let state = session.read(cx); let (title, value) = match &state.pull_request { Some(pr) => ("Copy PR link", pr.url.clone()), None => ("Copy thread ID", state.id.clone()), }; let copy_session = session.clone(); items.push( PaletteItem::new("Actions", title, Lucide::Link) .description(value) .shortcut("Ctrl+Shift+C") .run(move |_, window, cx| Workspace::copy_thread_reference(©_session, window, cx)), ); items.push( PaletteItem::new("Actions", "Restart agent session", Lucide::RotateCcw).terms(&["reset", "provider"]).run( move |_, window, cx| { let restarted = session.update(cx, |session, cx| session.restart_agent(cx)); if restarted { window.push_notification( Notification::success("Your next message starts a fresh session.").title("Agent session will restart"), cx, ); } else { window.push_notification( Notification::error("The agent is busy or not running.").title("Unable to run command"), cx, ); } }, ), ); } items.push( PaletteItem::new("Actions", "Add project", Lucide::FolderPlus) .terms(&["open folder", "local folder", "clone", "new project"]) .submenu(self.add_project_view()), ); items.push( PaletteItem::new("Actions", "Change theme", IconName::Palette) .shortcut("Ctrl+Alt+A") .terms(&["color scheme", "colors"]) .submenu(PaletteView::Themes), ); items.push( PaletteItem::new("Actions", "Change appearance", Lucide::Monitor) .shortcut("Ctrl+Alt+Shift+A") .terms(&["light", "dark", "system", "mode"]) .submenu(PaletteView::Appearance), ); items.push( PaletteItem::new("Actions", "Open usage", Lucide::ChartNoAxesColumn) .shortcut("Ctrl+U") .terms(&["cost", "tokens", "limits"]) .run(|this, window, cx| this.open_settings_page("Usage", window, cx)), ); items.push( PaletteItem::new("Actions", "Open settings", IconName::Settings) .shortcut("Ctrl+,") .terms(&["preferences"]) .run(|this, window, cx| this.open_settings(window, cx)), ); if let Some(ix) = contextual { items.push( PaletteItem::new("Actions", "Project settings", IconName::Folder) .description(self.projects[ix].name.clone()) .terms(&["rename", "remove"]) .run(move |this, window, cx| this.open_project_settings(ix, window, cx)), ); } items } /// `{machine} · {path}`, the description of a project row. fn project_description(&self, ix: usize) -> String { let project = &self.projects[ix]; let machine = match &project.environment { Some(env) => self.environment_name(env), None => self.hostname.to_string(), }; format!("{machine} · {}", super::display_path(&project.path)) } /// A project's row, which starts a thread in it. fn project_item(&self, ix: usize) -> PaletteItem { let project = &self.projects[ix]; PaletteItem::new("Projects", project.name.clone(), IconName::Folder) .badge(project.initials.clone(), project.tint) .on_device(self.project_description(ix)) .run(move |this, window, cx| this.new_thread(ix, window, cx)) } fn palette_projects(&self, cx: &App) -> Vec { let scratch = super::scratch_dir(); self.ordered_projects(cx) .into_iter() .filter(|&ix| Some(&self.projects[ix].path) != scratch.as_ref()) .map(|ix| self.project_item(ix)) .collect() } fn palette_new_thread_in(&self, cx: &App) -> Vec { let scratch = super::scratch_dir(); let current = (!self.projects.is_empty()).then(|| self.contextual_project()); let mut order: Vec = self .ordered_projects(cx) .into_iter() .filter(|&ix| Some(&self.projects[ix].path) != scratch.as_ref()) .collect(); if let Some(current) = current && let Some(position) = order.iter().position(|&ix| ix == current) { order.remove(position); order.insert(0, current); } let mut items = Vec::new(); let mut number = 0; for (row, ix) in order.into_iter().enumerate() { if row == 1 { items.push(self.no_project_item()); } number += 1; let mut item = self.project_item(ix); if number <= 9 { item = item.shortcut(format!("Ctrl+{number}")); } items.push(item); } if items.len() <= 1 { items.push(self.no_project_item()); } items } fn no_project_item(&self) -> PaletteItem { PaletteItem::new("Projects", "No project", Lucide::SquareDashed) .shortcut("Ctrl+Alt+N") .run(|this, window, cx| this.new_thread_without_project(window, cx)) } fn palette_threads(&self, limit: usize, group: &'static str, cx: &App) -> Vec { let mut threads: Vec<(usize, Entity)> = self .projects .iter() .enumerate() .flat_map(|(ix, project)| project.sessions.iter().map(move |session| (ix, session.clone()))) .filter(|(_, session)| { let state = session.read(cx); !state.archived && !state.is_blank() && !state.is_subagent() }) .collect(); threads.sort_by_key(|(_, session)| std::cmp::Reverse(session.read(cx).updated)); threads .into_iter() .take(limit) .map(|(ix, session)| { let state = session.read(cx); let project = &self.projects[ix]; let mut description = project.name.to_string(); if let Some(branch) = self.thread_branch(&session, cx) { description.push_str(&format!(" · #{branch}")); } if self.active.as_ref() == Some(&session) { description.push_str(" · Current thread"); } let mut item = PaletteItem::new(group, state.title.clone(), Lucide::MessageSquare).description(description); item.timestamp = Some(relative_time_long(state.updated)); item.run(move |this, window, cx| { if let Some(ix) = this.project_of(&session) { this.select(ix, session.clone(), window, cx); } }) }) .collect() } /// Where Add project starts: the devices, when there are others to /// add one on, or this device's sources. pub(super) fn add_project_view(&self) -> PaletteView { if self.remote.environments.is_empty() { PaletteView::ProjectSources(None) } else { PaletteView::AddProject } } fn palette_environments(&self) -> Vec { let mut items = vec![ PaletteItem::new("Environments", self.hostname.clone(), Lucide::Laptop) .description("This device") .submenu(PaletteView::ProjectSources(None)), ]; for environment in &self.remote.environments { let label = environment.host.label(); let item = PaletteItem::new("Environments", label.clone(), Lucide::Laptop); items.push(match &environment.status { ConnectionStatus::Connected(_) => item .description(environment.route.as_ref().map_or_else(|| environment.host.address.clone(), crate::remote::Route::describe)) .submenu(PaletteView::ProjectSources(Some(environment.host.id.clone()))), ConnectionStatus::Connecting => item.description("Connecting…").hint(format!("{label} isn't connected yet.")), ConnectionStatus::Failed(_) | ConnectionStatus::Denied(_) => { item.description("Not connected").hint(format!("{label} isn't connected.")) } }); } items } fn palette_sources(&self, env: &Option) -> Vec { let browse = match env { Some(env) => format!("Browse a folder on {}", self.environment_name(env)), None => "Browse a folder on disk".into(), }; let mut items = vec![ PaletteItem::new("Sources", "New project", Lucide::FolderPlus) .description("Start a new Git repository from a name") .terms(&["create", "init"]) .submenu(PaletteView::NewProject(env.clone())), PaletteItem::new("Sources", "Local folder", IconName::FolderOpen) .description(browse) .terms(&["open folder", "directory"]) .submenu(PaletteView::Directories(env.clone())), PaletteItem::new("Sources", "Git URL", Lucide::Link) .description("Clone from a remote URL") .terms(&["clone"]) .submenu(PaletteView::CloneRepo(env.clone(), None)), ]; for forge in Forge::ALL { let mut item = PaletteItem::new("Sources", forge.label(), IconName::Folder) .description(forge.description()) .terms(&["clone"]); item.glyph = forge_glyph(forge); // Another device checks its own tools when it clones. item = if env.is_none() && !forge.ready() { item.warning = Some("Setup Required".into()); item.hint(forge.setup_hint()) } else { item.submenu(PaletteView::CloneRepo(env.clone(), Some(forge))) }; items.push(item); } items } /// Where new projects and clones go, as rows describe it. fn project_base(&self, env: &Option) -> String { match env { Some(env) => format!("the base folder on {}", self.environment_name(env)), None => { let saved = settings::get(|settings| settings.add_project_dir.clone()); let home = folder_browser::home_dir(); folder_browser::tilde(&folder_browser::start_dir(Some(&saved)), &home) } } } fn palette_new_project(&self, env: &Option, query: &str) -> Vec { let name = query.trim(); let base = self.project_base(env); let item = match projects::check_name(name) { Some(problem) => PaletteItem::new("New project", problem, Lucide::FolderPlus).description(format!("It goes in {base}")), None => { let (env, source) = (env.clone(), ProjectSource::New { name: name.to_string() }); let place = if env.is_some() { format!("In {base}") } else { format!("{base}/{name}") }; PaletteItem::new("New project", format!("Create {name}"), Lucide::FolderPlus) .description(format!("{place} · a new Git repository")) .run(move |this, window, cx| this.add_project_from(env.clone(), source.clone(), window, cx)) } }; vec![item] } fn palette_clone(&self, env: &Option, forge: Option, query: &str) -> Vec { let input = query.trim(); let base = self.project_base(env); let row = |title: String| { let mut item = PaletteItem::new("Clone", title, Lucide::Link); if let Some(forge) = forge { item.glyph = forge_glyph(forge); } item }; let item = match projects::repo_name(input) { _ if input.is_empty() => row(match forge { Some(forge) => format!("Type {}", forge.placeholder()), None => "Paste a Git URL".into(), }) .description(format!("It clones into {base}")), None => row("That doesn't look like a repository.".into()), Some(name) => { let source = match forge { Some(forge) => ProjectSource::Forge { forge, repo: input.to_string() }, None => ProjectSource::Url { url: input.to_string() }, }; let env = env.clone(); let place = if env.is_some() { format!("Into {base}") } else { format!("Into {base}/{name}") }; row(format!("Clone {name}")) .description(place) .run(move |this, window, cx| this.add_project_from(env.clone(), source.clone(), window, cx)) } }; vec![item] } /// Starts a thread without a project, in the scratch folder. pub(super) fn new_thread_without_project(&mut self, window: &mut Window, cx: &mut Context) { let Some(path) = super::scratch_dir() else { return }; let ix = self.ensure_project(path, Some("No project")); self.new_thread(ix, window, cx); } } /// A forge's logo in its color, bright enough to read on dark menus. fn forge_glyph(forge: Forge) -> Glyph { let (path, color) = match forge { Forge::GitHub => return Glyph::Icon(Icon::new(Lucide::Github)), Forge::GitLab => (icons::GITLAB, 0xfc6d26), Forge::AzureDevOps => (icons::AZURE_DEVOPS, 0x2f8fe8), Forge::Bitbucket => (icons::BITBUCKET, 0x2684ff), Forge::Forgejo => (icons::FORGEJO, 0xfb923c), }; Glyph::Brand(Icon::default().path(path), rgb(color).into()) } fn mode_label(mode: ThemeMode) -> &'static str { match mode { ThemeMode::System => "System", ThemeMode::Light => "Light", ThemeMode::Dark => "Dark", } } /// The palette itself. pub(super) struct CommandPalette { workspace: WeakEntity, input: Entity, stack: Vec, highlight: usize, /// In Directories, whether the arrows picked a folder; until they do, /// Enter adds the folder in the field. browsing: bool, listing: FolderListing, home: PathBuf, scroll: ScrollHandle, _subscription: Subscription, } impl EventEmitter for CommandPalette {} impl CommandPalette { fn new(workspace: WeakEntity, view: PaletteView, window: &mut Window, cx: &mut Context) -> Self { let input = cx.new(|cx| InputState::new(window, cx)); let subscription = cx.subscribe_in(&input, window, |this, _, event: &InputEvent, window, cx| match event { InputEvent::Change => { this.highlight = 0; this.browsing = false; this.sync_listing(cx); cx.notify(); } InputEvent::PressEnter { .. } => this.confirm(window, cx), _ => {} }); let mut palette = Self { workspace, input, stack: vec![PaletteView::Root], highlight: 0, browsing: false, listing: FolderListing::default(), home: folder_browser::home_dir(), scroll: ScrollHandle::new(), _subscription: subscription, }; if view != PaletteView::Root { palette.stack.push(view); } palette.sync_placeholder(window, cx); palette } fn view(&self) -> PaletteView { self.stack.last().cloned().unwrap_or(PaletteView::Root) } fn sync_placeholder(&self, window: &mut Window, cx: &mut Context) { let placeholder = self.view().placeholder(); self.input.update(cx, |input, cx| input.set_placeholder(placeholder, window, cx)); } /// Opens a submenu, with the field emptied, or set to the folder to /// start browsing in. fn push_view(&mut self, view: PaletteView, window: &mut Window, cx: &mut Context) { let text = match &view { PaletteView::Directories(None) => { let saved = settings::get(|settings| settings.add_project_dir.clone()); folder_browser::field_text(&folder_browser::start_dir(Some(&saved)), &self.home) } PaletteView::Directories(Some(_)) => "~/".into(), _ => String::new(), }; self.stack.push(view); self.listing = FolderListing::default(); self.show_view(text, window, cx); } fn pop_view(&mut self, window: &mut Window, cx: &mut Context) { if self.stack.len() > 1 { self.stack.pop(); self.show_view(String::new(), window, cx); } } fn show_view(&mut self, text: String, window: &mut Window, cx: &mut Context) { self.highlight = 0; self.browsing = false; self.input.update(cx, |input, cx| input.set_value(text, window, cx)); self.sync_placeholder(window, cx); self.sync_listing(cx); cx.notify(); } /// In Directories, lists the folder the field points at: here right /// away, or by asking the other device. fn sync_listing(&mut self, cx: &mut Context) { let PaletteView::Directories(env) = self.view() else { return }; let text = self.input.read(cx).value().to_string(); let Some(dir) = self.listing.set_text(&text) else { return }; match env { None => { let folders = folder_browser::list(&dir, &self.home); self.listing.receive(&dir, folders); } Some(env) => { let _ = self.workspace.update(cx, |workspace, _| workspace.request_folders(&env, dir)); } } } /// Another device answered a listing. pub(super) fn receive_folders(&mut self, env: &str, dir: &str, folders: Folders, cx: &mut Context) { if self.view() == PaletteView::Directories(Some(env.to_string())) { self.listing.receive(dir, folders); cx.notify(); } } /// The rows that match the query, in their groups, best first. fn results(&self, cx: &App) -> Vec { let Some(workspace) = self.workspace.upgrade() else { return Vec::new() }; let view = self.view(); let raw = self.input.read(cx).value().to_string(); match view.field() { Field::Path => return self.directory_items(), Field::Text => return workspace.read(cx).palette_items(&view, &raw, cx), Field::Search => {} } let actions_only = view == PaletteView::Root && raw.trim_start().starts_with('>'); let query = raw.trim_start().trim_start_matches('>').trim().to_lowercase(); let words: Vec = query.split_whitespace().map(str::to_string).collect(); let items = workspace.read(cx).palette_items(&view, &query, cx); let items: Vec = items.into_iter().filter(|item| !actions_only || item.group == "Actions").collect(); if words.is_empty() { return items; } let mut groups: Vec<&'static str> = Vec::new(); for item in &items { if !groups.contains(&item.group) { groups.push(item.group); } } let mut scored: Vec<(usize, u32, usize, PaletteItem)> = items .into_iter() .enumerate() .filter_map(|(order, item)| { let score = item.score(&words)?; let group = groups.iter().position(|group| *group == item.group).unwrap_or(0); // Thread titles tie, keeping the most recent first. let score = if item.group == "Threads" { 0 } else { score }; Some((group, score, order, item)) }) .collect(); scored.sort_by_key(|(group, score, order, _)| (*group, *score, *order)); scored.into_iter().map(|(_, _, _, item)| item).collect() } /// The folders in the path field's folder that match it. fn directory_items(&self) -> Vec { self.listing .matches() .map(|folder| { let mut item = PaletteItem::new("Directories", folder.name.clone(), IconName::Folder); item.tag = folder.git.then(|| "git".into()); item.action = Action::Browse(self.listing.open(&folder.name)); item }) .collect() } fn move_highlight(&mut self, delta: isize, cx: &mut Context) { let results = self.results(cx); if results.is_empty() { return; } if self.view().field() == Field::Path && !self.browsing { // The first press lands on the first or last folder. self.browsing = true; self.highlight = if delta > 0 { 0 } else { results.len() - 1 }; } else { self.highlight = (self.highlight as isize + delta).rem_euclid(results.len() as isize) as usize; } // The list's children include a heading per group. let headings = results[..=self.highlight] .iter() .enumerate() .filter(|(ix, item)| *ix == 0 || results[ix - 1].group != item.group) .count(); self.scroll.scroll_to_item(self.highlight + headings); cx.notify(); } /// Enter. In Directories it adds the folder in the field, unless the /// arrows picked one to go into, or the field only starts a name, which /// it goes into the best match of. fn confirm(&mut self, window: &mut Window, cx: &mut Context) { if self.view().field() == Field::Path && !self.browsing && self.listing.names_folder() { self.add_folder(window, cx); return; } let results = self.results(cx); if let Some(item) = results.get(self.highlight).cloned() { self.execute(item, window, cx); } } /// Adds the folder the path field names as a project. fn add_folder(&mut self, window: &mut Window, cx: &mut Context) { let PaletteView::Directories(env) = self.view() else { return }; let path = self.input.read(cx).value().trim().to_string(); cx.emit(PaletteEvent::Dismiss); let _ = self.workspace.update(cx, |this, cx| this.add_project_from(env, ProjectSource::Folder { path }, window, cx)); } /// Tab in Directories completes the folder name. fn complete(&mut self, window: &mut Window, cx: &mut Context) { let Some(text) = self.listing.complete() else { return }; self.input.update(cx, |input, cx| input.set_value(text, window, cx)); self.sync_listing(cx); cx.notify(); } fn execute(&mut self, item: PaletteItem, window: &mut Window, cx: &mut Context) { match item.action { Action::None => {} Action::Submenu(view) => self.push_view(view, window, cx), Action::Browse(text) => { self.input.update(cx, |input, cx| { input.set_value(text, window, cx); input.focus(window, cx); }); self.highlight = 0; self.browsing = false; self.sync_listing(cx); cx.notify(); } Action::Hint(hint) => window.push_notification(Notification::info(hint).title(item.title), cx), Action::Run(run) => { cx.emit(PaletteEvent::Dismiss); let _ = self.workspace.update(cx, |this, cx| run(this, window, cx)); } } } /// Backspace on an empty query leaves a submenu. fn back(&mut self, window: &mut Window, cx: &mut Context) { if self.input.read(cx).value().is_empty() && self.stack.len() > 1 { self.pop_view(window, cx); } else { cx.propagate(); } } /// Ctrl+1–9 runs the row with that number. fn on_key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context) { let keystroke = &event.keystroke; if !keystroke.modifiers.control || keystroke.modifiers.shift || keystroke.modifiers.alt { return; } let Ok(digit) = keystroke.key.parse::() else { return }; let label = format!("Ctrl+{digit}"); let results = self.results(cx); if let Some(item) = results.into_iter().find(|item| item.shortcut.as_deref() == Some(label.as_str())) { cx.stop_propagation(); self.execute(item, window, cx); } } fn render_row(&self, ix: usize, item: &PaletteItem, selected: bool, cx: &mut Context) -> AnyElement { let p = metal::palette(cx); let run_item = item.clone(); let glyph = match item.glyph.clone() { Glyph::Icon(icon) => icon.size(px(16.)).text_color(p.icon).into_any_element(), Glyph::Brand(icon, color) => icon.size(px(16.)).text_color(color).into_any_element(), Glyph::Badge(initials, tint) => metal::badge(initials, tint, p).into_any_element(), }; let inert = matches!(item.action, Action::None); h_flex() .id(("palette-row", ix)) .flex_none() .min_h(px(36.)) .px(px(10.)) .py(px(6.)) .gap(px(10.)) .rounded(px(10.)) .when(!inert, |this| this.cursor_pointer().hover(|style| style.bg(p.menu_row_hover))) .when(selected && !inert, |this| this.bg(p.menu_row_hover)) .on_click(cx.listener(move |this, _, window, cx| this.execute(run_item.clone(), window, cx))) .child(div().flex().flex_none().w(px(20.)).justify_center().child(glyph)) .child( v_flex() .flex_1() .min_w_0() .child( h_flex() .min_w_0() .text_size(px(13.5)) .text_color(if inert { p.ink2 } else { p.ink }) .child(div().flex_none().whitespace_nowrap().child(item.title.clone())) .when_some(item.emphasis.clone(), |this, emphasis| { this.child(div().min_w_0().truncate().font_weight(FontWeight::SEMIBOLD).child(emphasis)) }), ) .when_some(item.description.clone(), |this, description| { this.child( h_flex() .min_w_0() .gap(px(4.)) .text_size(px(12.)) .text_color(p.ink3) .when_some(item.description_icon.clone(), |this, icon| { this.child(icon.size(px(12.)).text_color(p.ink3)) }) .child(div().min_w_0().truncate().child(description)), ) }), ) .when_some(item.tag.clone(), |this, tag| this.child(metal::tag(tag, p))) .when_some(item.warning.clone(), |this, warning| { this.child( div() .flex_none() .px(px(6.)) .py(px(1.)) .rounded(px(5.)) .bg(p.amber.opacity(0.14)) .text_size(px(11.)) .text_color(p.amber_ink) .child(warning), ) }) .when_some(item.timestamp.clone(), |this, timestamp| { this.child(div().flex_none().min_w(px(48.)).text_right().text_size(px(12.)).text_color(p.ink3).child(timestamp)) }) .when_some(item.shortcut.clone(), |this, shortcut| this.child(metal::keycap(shortcut, p))) .when(matches!(item.action, Action::Submenu(_)), |this| { this.child(Icon::new(IconName::ChevronRight).size(px(16.)).text_color(p.ink3)) }) .into_any_element() } } impl Render for CommandPalette { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let p = metal::palette(cx); let view = self.view(); let field = view.field(); let results = self.results(cx); let highlight = self.highlight.min(results.len().saturating_sub(1)); let in_submenu = self.stack.len() > 1; let actions_only = field == Field::Search && self.input.read(cx).value().trim_start().starts_with('>'); let mut rows: Vec = Vec::new(); let mut last_group = None; for (ix, item) in results.iter().enumerate() { if last_group != Some(item.group) { last_group = Some(item.group); rows.push( div() .flex_none() .px(px(10.)) .pt(px(if ix == 0 { 4. } else { 10. })) .pb(px(4.)) .engraved(p) .child(item.group.to_uppercase()) .into_any_element(), ); } let selected = ix == highlight && (field != Field::Path || self.browsing); rows.push(self.render_row(ix, item, selected, cx)); } let empty = match field { _ if !results.is_empty() => None, Field::Path => self.listing.message(), _ if actions_only => Some("No matching actions.".into()), _ if view == PaletteView::Root => Some("No matching commands, projects, or threads.".into()), _ => Some("No matches.".into()), }; let can_add = field == Field::Path && self.listing.names_folder(); let hint = |keys: Vec, label: &'static str| { h_flex().gap(px(4.)).children(keys).child(div().text_size(px(12.)).text_color(p.ink3).child(label)) }; v_flex() .key_context("CommandPalette") .on_action(cx.listener(|this, _: &SelectPrevious, _, cx| this.move_highlight(-1, cx))) .on_action(cx.listener(|this, _: &SelectNext, _, cx| this.move_highlight(1, cx))) .on_action(cx.listener(|_, _: &Dismiss, _, cx| cx.emit(PaletteEvent::Dismiss))) .on_action(cx.listener(|this, _: &PaletteBack, window, cx| this.back(window, cx))) .when(field == Field::Path, |this| { this.capture_action(cx.listener(|this, _: &IndentInline, window, cx| { cx.stop_propagation(); this.complete(window, cx); })) }) .on_key_down(cx.listener(Self::on_key_down)) .w(px(600.)) .max_w_full() .rounded(px(18.)) .bg(p.menu_bg) .shadow(p.raise.clone()) .occlude() .child( h_flex() .h(px(48.)) .px(px(14.)) .gap(px(10.)) .child(if in_submenu { div() .id("palette-back") .cursor_pointer() .on_click(cx.listener(|this, _, window, cx| this.pop_view(window, cx))) .child(Icon::new(IconName::ArrowLeft).size(px(16.)).text_color(p.ink2)) .into_any_element() } else { Icon::new(IconName::Search).size(px(16.)).text_color(p.field_ink).into_any_element() }) .child(div().flex_1().min_w_0().text_size(px(14.)).child(Input::new(&self.input).appearance(false))) .when(field == Field::Path, |this| { this.child( h_flex() .flex_none() .gap(px(6.)) .child( MetalButton::new("palette-add-folder") .mini() .label("Add") .tooltip("Add the folder in the field as a project") .disabled(!can_add) .on_click(cx.listener(|this, _, window, cx| this.add_folder(window, cx))), ) .child(metal::keycap("Enter", p)), ) }), ) .child(metal::rule(p)) .child( v_flex() .id("palette-results") .track_scroll(&self.scroll) .max_h(px(448.)) .overflow_y_scroll() .p(px(6.)) .when(field == Field::Path, |this| this.h(px(360.))) .when_some(empty, |this, message| { this.child(div().py(px(40.)).flex().justify_center().text_size(px(13.)).text_color(p.ink3).child(message)) }) .children(rows), ) .child(metal::rule(p)) .child( h_flex() .h(px(40.)) .px(px(14.)) .gap(px(14.)) .child(hint( vec![ metal::keycap("↑", p).into_any_element(), metal::keycap("↓", p).into_any_element(), ], "Navigate", )) .map(|this| match field { Field::Path => this.child(hint(vec![metal::keycap("Tab", p).into_any_element()], "Complete")), _ => this.child(hint(vec![metal::keycap("Enter", p).into_any_element()], "Select")), }) .when(in_submenu, |this| this.child(hint(vec![metal::keycap("Backspace", p).into_any_element()], "Back"))) .child(hint(vec![metal::keycap("Esc", p).into_any_element()], "Close")), ) } }