Vectrum
Sign In
347 lines · 15.4 KB · Rust
//! The Keybindings page, laid out like T3 Code's: a search field, then the
//! bindings in T3 Code's groups (Navigation, Threads, Composer, …), each
//! row the command, when it applies and its keys. The rows come from the
//! app's live keymap, so they show whatever is bound; keys the composer
//! handles itself are listed from the send shortcut setting.

use gpui_kit::component::{h_flex, tooltip::Tooltip, v_flex};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;

use super::search::Item;
use super::widgets::{Section, SettingRow, empty_row, search_field};
use super::{Page, SettingsView};
use crate::settings::{self, QuitShortcut, SendShortcut};
use crate::ui::metal;

/// A command the app binds, as T3 Code names it.
struct Command {
    /// T3 Code's command id, for the group and the tooltip.
    id: &'static str,
    label: &'static str,
    /// The GPUI actions that run it.
    actions: &'static [&'static str],
}

const COMMANDS: &[Command] = &[
    Command { id: "sidebar.toggle", label: "Sidebar: Toggle", actions: &["vectrum_code::ToggleSidebar"] },
    Command { id: "rightPanel.toggle", label: "Right Panel: Toggle", actions: &["vectrum_code::ToggleRightPanel"] },
    Command { id: "threadPanel.toggle", label: "Thread Panel: Toggle", actions: &["vectrum_code::ToggleProjectPanel"] },
    Command { id: "subagentsPanel.toggle", label: "Subagents Panel: Toggle", actions: &["vectrum_code::ToggleSubagents"] },
    Command {
        id: "commandPalette.toggle",
        label: "Command Palette: Toggle",
        actions: &["vectrum_code::FocusSearch", "vectrum_code::ToggleCommandPalette"],
    },
    Command { id: "editor.openFavorite", label: "Editor: Open Favorite", actions: &["vectrum_code::OpenInEditor"] },
    Command { id: "chat.new", label: "Chat: New", actions: &["vectrum_code::NewThread"] },
    Command { id: "chat.newLocal", label: "Chat: New Local", actions: &["vectrum_code::NewThreadLocal"] },
    Command {
        id: "chat.newWithoutProject",
        label: "Chat: New Without Project",
        actions: &["vectrum_code::NewThreadWithoutProject"],
    },
    Command { id: "chat.find", label: "Chat: Find", actions: &["vectrum_code::FindInThread"] },
    Command { id: "thread.stop", label: "Thread: Stop", actions: &["vectrum_code::Interrupt"] },
    Command { id: "thread.previous", label: "Thread: Previous", actions: &["vectrum_code::PreviousThread"] },
    Command { id: "thread.next", label: "Thread: Next", actions: &["vectrum_code::NextThread"] },
    Command { id: "thread.settle", label: "Thread: Settle", actions: &["vectrum_code::SettleThread"] },
    Command { id: "thread.pin", label: "Thread: Pin", actions: &["vectrum_code::PinThread"] },
    Command { id: "thread.undo", label: "Thread: Undo", actions: &["vectrum_code::UndoThreadAction"] },
    Command {
        id: "thread.copyReference",
        label: "Pull Request: Copy Link or Thread ID",
        actions: &["vectrum_code::CopyThreadReference"],
    },
    Command { id: "terminal.toggle", label: "Terminal: Toggle", actions: &["vectrum_code::ToggleTerminal"] },
    Command { id: "terminal.split", label: "Terminal: Split", actions: &["vectrum_code::SplitTerminal"] },
    Command {
        id: "terminal.splitVertical",
        label: "Terminal: Split Vertical",
        actions: &["vectrum_code::SplitTerminalVertical"],
    },
    Command { id: "terminal.new", label: "Terminal: New", actions: &["vectrum_code::NewTerminal"] },
    Command { id: "terminal.close", label: "Terminal: Close", actions: &["vectrum_code::CloseTerminal"] },
    Command { id: "terminal.copy", label: "Terminal: Copy", actions: &["vectrum_code::TerminalCopy"] },
    Command { id: "terminal.paste", label: "Terminal: Paste", actions: &["vectrum_code::TerminalPaste"] },
    Command { id: "modelPicker.toggle", label: "Model Picker: Toggle", actions: &["vectrum_code::ToggleModelPicker"] },
    Command { id: "composer.effort", label: "Composer: Effort", actions: &["vectrum_code::ComposerEffort"] },
    Command { id: "composer.mode", label: "Composer: Mode", actions: &["vectrum_code::ComposerMode"] },
    Command { id: "composer.host", label: "Composer: Host", actions: &["vectrum_code::ComposerHost"] },
    Command { id: "composer.workspace", label: "Composer: Workspace", actions: &["vectrum_code::ComposerWorkspace"] },
    Command { id: "composer.branch", label: "Composer: Branch", actions: &["vectrum_code::ComposerBranch"] },
    Command {
        id: "composer.previousWorktree",
        label: "Composer: Previous Worktree",
        actions: &["vectrum_code::ComposerPreviousWorktree"],
    },
    Command { id: "composer.stash", label: "Composer: Stash", actions: &["vectrum_code::ComposerStash"] },
    Command { id: "preview.toggle", label: "Preview: Toggle", actions: &["vectrum_code::ToggleBrowser"] },
    Command { id: "theme.select", label: "Theme: Select", actions: &["vectrum_code::SelectTheme"] },
    Command { id: "appearance.cycle", label: "Appearance: Cycle", actions: &["vectrum_code::CycleAppearance"] },
    Command { id: "usage.open", label: "Usage: Open", actions: &["vectrum_code::OpenUsage"] },
    Command { id: "project.add", label: "Project: Add", actions: &["vectrum_code::OpenProject"] },
    Command { id: "settings.open", label: "Settings: Toggle", actions: &["vectrum_code::OpenSettings"] },
    Command { id: "settings.close", label: "Settings: Close", actions: &["vectrum_code::CloseSettings"] },
    Command { id: "app.quit", label: "App: Quit", actions: &["vectrum_code::Quit"] },
];

/// Keys the composer and pickers handle themselves rather than through the
/// keymap.
const COMPOSER_COMMANDS: &[(&str, &str)] = &[
    ("composer.send", "Composer: Send"),
    ("composer.newLine", "Composer: New Line"),
    ("composer.sendAlternate", "Composer: Opposite Queue or Steer Action"),
    ("composer.slashMenu", "Composer: Commands and Skills"),
    ("composer.skills", "Composer: Skills"),
    ("modelPicker.jump", "Model Picker: Jump"),
];

/// T3 Code's groups, in page order; a command joins the first group
/// listing its id's prefix.
const GROUPS: &[(&str, &[&str])] = &[
    ("Navigation", &["sidebar", "rightPanel", "threadPanel", "subagentsPanel", "commandPalette", "filePicker", "projectSearch", "editor"]),
    ("Threads", &["thread", "chat", "pullRequest"]),
    ("Composer", &["composer", "modelPicker"]),
    ("Terminal", &["terminal"]),
    ("Preview & diff", &["preview", "diff"]),
    ("Appearance", &["theme", "appearance", "themeEditor"]),
    ("Project scripts", &["script"]),
];

fn group_of(id: &str) -> &'static str {
    let prefix = id.split('.').next().unwrap_or_default();
    GROUPS.iter().find(|(_, prefixes)| prefixes.contains(&prefix)).map_or("Other", |(title, _)| title)
}

/// The search results for the commands, after every other setting.
pub(super) fn search_items() -> impl Iterator<Item = Item> {
    COMMANDS.iter().map(|command| (command.id, command.label)).chain(COMPOSER_COMMANDS.iter().copied()).map(|(id, label)| Item {
        id: "keybindings",
        title: label,
        page: Page::Keybindings,
        terms: id,
        fold: None,
        secondary: true,
        fallback: None,
    })
}

/// `vectrum_code::ToggleCommandPalette` → `Toggle Command Palette`.
fn title_case(action: &str) -> String {
    let name = action.rsplit("::").next().unwrap_or(action);
    let mut words = String::new();
    for (ix, c) in name.chars().enumerate() {
        if ix > 0 && c.is_uppercase() {
            words.push(' ');
        }
        words.push(c);
    }
    words
}

/// What a binding's context means, in T3 Code's when-clause words.
fn when_text(context: &str) -> String {
    context
        .replace("ChatView", "composerFocus")
        .replace("SettingsView", "settingsOpen")
        .replace("Terminal", "terminalFocus")
        .replace("Workspace", "always")
}

/// One row on the page.
struct Binding {
    id: String,
    label: String,
    when: String,
    keys: Vec<Vec<String>>,
}

impl SettingsView {
    pub(super) fn render_keybindings(&self, cx: &mut Context<Self>) -> Vec<AnyElement> {
        let p = metal::palette(cx);
        let query = self.keybinding_search.read(cx).value().trim().to_lowercase();
        let mut bindings = keymap_bindings(cx);
        bindings.extend(composer_bindings());
        bindings.sort_by(|a, b| a.label.cmp(&b.label));
        let shown: Vec<Binding> = bindings
            .into_iter()
            .filter(|binding| {
                query.is_empty()
                    || binding.label.to_lowercase().contains(&query)
                    || binding.when.to_lowercase().contains(&query)
                    || binding.id.to_lowercase().contains(&query)
                    || binding.keys.iter().any(|keys| keys.join("+").to_lowercase().contains(&query))
            })
            .collect();

        let toolbar = h_flex()
            .px(px(16.))
            .py(px(12.))
            .gap(px(8.))
            .child(search_field(&self.keybinding_search, 0., cx).flex_1())
            .into_any_element();
        let mut pages = vec![
            Section::new("Keybindings")
                .id("keybindings")
                .row(toolbar)
                .when(shown.is_empty(), |section| section.row(empty_row("No keybindings match your search.", p)))
                .render(cx),
        ];
        let mut groups: Vec<&'static str> = GROUPS.iter().map(|(title, _)| *title).collect();
        groups.push("Other");
        for group in groups {
            let rows: Vec<AnyElement> = shown
                .iter()
                .filter(|binding| group_of(&binding.id) == group)
                .map(|binding| binding_row(binding, cx))
                .collect();
            if !rows.is_empty() {
                pages.push(Section::new(group).rows(rows).render(cx));
            }
        }
        pages
    }
}

/// The app's own bindings in the live keymap, one row per command and
/// context, with every key that runs it.
fn keymap_bindings(cx: &App) -> Vec<Binding> {
    let keymap = cx.key_bindings();
    let keymap = keymap.borrow();
    let mut rows: Vec<Binding> = Vec::new();
    for binding in keymap.bindings() {
        let action = binding.action().name();
        if !action.starts_with("vectrum_code::") {
            continue;
        }
        let command = COMMANDS.iter().find(|command| command.actions.contains(&action));
        let (id, label) = match command {
            Some(command) => (command.id.to_string(), command.label.to_string()),
            None => (format!("app.{action}"), title_case(action)),
        };
        let when = binding.predicate().map(|predicate| when_text(&predicate.to_string())).unwrap_or_else(|| "Always".into());
        let keys: Vec<String> = binding
            .keystrokes()
            .iter()
            .flat_map(|keystroke| {
                let modifiers = keystroke.modifiers();
                let key = &keystroke.inner().key;
                let mut caps = Vec::new();
                if modifiers.control {
                    caps.push("Ctrl".to_string());
                }
                if modifiers.alt {
                    caps.push("Alt".to_string());
                }
                if modifiers.shift {
                    caps.push("Shift".to_string());
                }
                if modifiers.platform {
                    caps.push("Super".to_string());
                }
                caps.push(key_label(key));
                caps
            })
            .collect();
        match rows.iter_mut().find(|row| row.id == id && row.when == when) {
            Some(row) => {
                if !row.keys.contains(&keys) {
                    row.keys.push(keys);
                }
            }
            None => rows.push(Binding { id, label, when, keys: vec![keys] }),
        }
    }
    let hold = settings::get(|settings| settings.quit_shortcut);
    if let Some(quit) = rows.iter_mut().find(|row| row.id == "app.quit") {
        quit.label = match hold {
            QuitShortcut::Press => "App: Quit",
            QuitShortcut::Hold => "App: Quit (hold, or press twice)",
            QuitShortcut::Double => "App: Quit (press twice)",
        }
        .into();
    }
    rows
}

/// `escape` → `Esc`, `,` → `,`, `n` → `N`.
fn key_label(key: &str) -> String {
    match key {
        "escape" => "Esc".into(),
        "enter" => "Enter".into(),
        "space" => "Space".into(),
        "tab" => "Tab".into(),
        "backspace" => "Backspace".into(),
        "up" => "↑".into(),
        "down" => "↓".into(),
        "left" => "←".into(),
        "right" => "→".into(),
        key if key.chars().count() == 1 => key.to_uppercase(),
        key => {
            let mut chars = key.chars();
            chars.next().map(|first| first.to_uppercase().chain(chars).collect()).unwrap_or_default()
        }
    }
}

/// The composer's keys, which follow the send shortcut setting.
fn composer_bindings() -> Vec<Binding> {
    let send = settings::get(|settings| settings.send_shortcut);
    let caps = |keys: &[&str]| keys.iter().map(|key| key.to_string()).collect::<Vec<_>>();
    let row = |id: &str, label: &str, when: &str, keys: Vec<Vec<String>>| Binding {
        id: id.into(),
        label: label.into(),
        when: when.into(),
        keys,
    };
    let (send_keys, new_line, opposite) = match send {
        SendShortcut::Enter => (vec![caps(&["Enter"])], vec![caps(&["Shift", "Enter"])], caps(&["Ctrl", "Enter"])),
        SendShortcut::ModEnterMultiline => (
            vec![caps(&["Enter"]), caps(&["Ctrl", "Enter"])],
            vec![caps(&["Shift", "Enter"])],
            caps(&["Ctrl", "Shift", "Enter"]),
        ),
        SendShortcut::ModEnter => (vec![caps(&["Ctrl", "Enter"])], vec![caps(&["Enter"])], caps(&["Ctrl", "Shift", "Enter"])),
    };
    vec![
        row("composer.send", "Composer: Send", "composerFocus", send_keys),
        row("composer.newLine", "Composer: New Line", "composerFocus", new_line),
        row("composer.sendAlternate", "Composer: Opposite Queue or Steer Action", "composerFocus && turnRunning", vec![opposite]),
        row("composer.slashMenu", "Composer: Commands and Skills", "composerFocus", vec![caps(&["/"])]),
        row("composer.skills", "Composer: Skills", "composerFocus", vec![caps(&["$"])]),
        row("modelPicker.jump", "Model Picker: Jump", "modelPickerOpen", vec![caps(&["Ctrl", "1–9"])]),
    ]
}

fn binding_row(binding: &Binding, cx: &App) -> AnyElement {
    let p = metal::palette(cx);
    let id = SharedString::from(binding.id.clone());
    let when = binding.when.clone();
    let keys = h_flex().gap(px(8.)).children(binding.keys.iter().map(|keys| {
        h_flex().gap(px(3.)).children(keys.iter().map(|key| metal::keycap(key.clone(), p)))
    }));
    let title = div()
        .id(SharedString::from(format!("keybinding-{}-{}", binding.id, binding.when)))
        .tooltip(move |window, cx| Tooltip::new(id.clone()).build(window, cx))
        .child(binding.label.clone());
    SettingRow::new("")
        .title_suffix(title.text_size(px(14.)).font_weight(FontWeight::MEDIUM).text_color(p.ink))
        .status_element(
            h_flex()
                .gap(px(6.))
                .child("When")
                .child(
                    div()
                        .px(px(6.))
                        .rounded(px(6.))
                        .bg(p.ink.opacity(0.05))
                        .font_family(metal::MONO)
                        .text_size(px(11.))
                        .text_color(p.ink2)
                        .child(when),
                ),
        )
        .control(v_flex().items_end().child(keys))
        .into_any_element()
}