Vectrum
Sign In
1189 lines · 49.4 KB · Rust
//! 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(&copy_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")),
            )
    }
}