//! 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<String>),
/// Add project: a folder on the device, browsed from the field.
Directories(Option<String>),
/// Add project: the name of a new repository.
NewProject(Option<String>),
/// Add project: what to clone, from a Git URL or a forge.
CloneRepo(Option<String>, Option<Forge>),
}
/// 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<dyn Fn(&mut Workspace, &mut Window, &mut Context<Workspace>)>;
/// 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<SharedString>,
description: Option<SharedString>,
/// Shown before the description: the laptop of `XPS · ~/Work/app`.
description_icon: Option<Icon>,
glyph: Glyph,
shortcut: Option<SharedString>,
timestamp: Option<String>,
/// An engraved tag: `Current` on the theme in use, `git` on a repository.
tag: Option<SharedString>,
/// An amber tag: `Setup Required`.
warning: Option<SharedString>,
action: Action,
/// What a search matches, most important first.
terms: Vec<String>,
}
impl PaletteItem {
fn new(group: &'static str, title: impl Into<SharedString>, icon: impl Into<Icon>) -> 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<Workspace>) + '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<SharedString>) -> Self {
self.action = Action::Hint(hint.into());
self
}
fn badge(mut self, initials: impl Into<SharedString>, 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<SharedString>) -> Self {
self = self.description(description);
self.description_icon = Some(Icon::new(Lucide::Laptop));
self
}
fn shortcut(mut self, shortcut: impl Into<SharedString>) -> Self {
self.shortcut = Some(shortcut.into());
self
}
fn description(mut self, description: impl Into<SharedString>) -> 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<u32> {
let terms: Vec<String> = 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"),
("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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
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<PaletteItem> {
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<PaletteItem> {
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_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<PaletteItem> {
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<PaletteItem> {
let scratch = super::scratch_dir();
let current = (!self.projects.is_empty()).then(|| self.contextual_project());
let mut order: Vec<usize> = 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<PaletteItem> {
let mut threads: Vec<(usize, Entity<crate::session::Session>)> = 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<PaletteItem> {
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<String>) -> Vec<PaletteItem> {
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>) -> 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<String>, query: &str) -> Vec<PaletteItem> {
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<String>, forge: Option<Forge>, query: &str) -> Vec<PaletteItem> {
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<Self>) {
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<Workspace>,
input: Entity<InputState>,
stack: Vec<PaletteView>,
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<PaletteEvent> for CommandPalette {}
impl CommandPalette {
fn new(workspace: WeakEntity<Workspace>, view: PaletteView, window: &mut Window, cx: &mut Context<Self>) -> 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<Self>) {
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<Self>) {
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<Self>) {
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>) {
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<Self>) {
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<Self>) {
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<PaletteItem> {
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<String> = query.split_whitespace().map(str::to_string).collect();
let items = workspace.read(cx).palette_items(&view, &query, cx);
let items: Vec<PaletteItem> = 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<PaletteItem> {
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
let keystroke = &event.keystroke;
if !keystroke.modifiers.control || keystroke.modifiers.shift || keystroke.modifiers.alt {
return;
}
let Ok(digit) = keystroke.key.parse::<usize>() 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<Self>) -> 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<Self>) -> 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<AnyElement> = 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<AnyElement>, 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")),
)
}
}