949 lines
38 KiB
Rust
949 lines
38 KiB
Rust
use std::borrow::Cow;
|
|
use std::cmp::Ordering;
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::sync::Arc;
|
|
|
|
use enum_iterator::{all, Sequence};
|
|
use fuzzy_match::match_indices_case_insensitive;
|
|
use galaxyui::actions::StandardAction;
|
|
use galaxyui::keymap::{
|
|
BindingDescription, BindingId, BindingLens, CustomTag, DescriptionContext, EditableBindingLens,
|
|
IsBindingValid, Keystroke, Trigger,
|
|
};
|
|
use galaxyui::platform::OperatingSystem;
|
|
use galaxyui::{Action, AppContext, SingletonEntity};
|
|
use itertools::Itertools;
|
|
use lazy_static::lazy_static;
|
|
use regex::Regex;
|
|
|
|
use crate::keyboard::{remove_custom_keybinding, write_custom_keybinding, UserDefinedKeybinding};
|
|
use crate::settings_view::keybindings::{KeybindingChangedEvent, KeybindingChangedNotifier};
|
|
|
|
pub const MAC_MENUS_CONTEXT: DescriptionContext = DescriptionContext::Custom("mac_menus");
|
|
|
|
// CustomActions are attached to menu items, and may be attached to Bindings.
|
|
#[derive(Copy, Clone, Debug, PartialEq, Eq, Sequence)]
|
|
#[repr(isize)]
|
|
pub enum CustomAction {
|
|
NewTab,
|
|
NewFile,
|
|
ShowAboutGalaxy,
|
|
ShowSettings,
|
|
ConfigureKeybindings,
|
|
ShowAccount,
|
|
ShowAppearance,
|
|
ReferAFriend,
|
|
ViewChangelog,
|
|
FocusInput,
|
|
ClearBlocks,
|
|
AddNextOccurrence,
|
|
AddCursorAbove,
|
|
AddCursorBelow,
|
|
CycleNextSession,
|
|
CyclePrevSession,
|
|
Cut,
|
|
Copy,
|
|
Paste,
|
|
Undo,
|
|
Redo,
|
|
CommandPalette,
|
|
AISearch,
|
|
ClearEditor,
|
|
Find,
|
|
SelectAll,
|
|
Workflows,
|
|
HistorySearch,
|
|
SaveCurrentConfig,
|
|
History,
|
|
IncreaseFontSize,
|
|
DecreaseFontSize,
|
|
ResetFontSize,
|
|
IncreaseZoom,
|
|
DecreaseZoom,
|
|
ResetZoom,
|
|
RenameTab,
|
|
SplitPaneRight,
|
|
SplitPaneLeft,
|
|
SplitPaneUp,
|
|
SplitPaneDown,
|
|
MoveTabLeft,
|
|
MoveTabRight,
|
|
ActivateNextTab,
|
|
ActivatePreviousTab,
|
|
ActivateNextPane,
|
|
ActivatePreviousPane,
|
|
NavigationPalette,
|
|
SelectBlockAbove,
|
|
SelectBlockBelow,
|
|
SelectAllBlocks,
|
|
CreateBlockPermalink,
|
|
ToggleBookmarkBlock,
|
|
FindWithinBlock,
|
|
CopyBlock,
|
|
CopyBlockCommand,
|
|
CopyBlockOutput,
|
|
ViewSharedBlocks,
|
|
CloseTab,
|
|
CloseOtherTabs,
|
|
CloseTabsRight,
|
|
ToggleMaximizePane,
|
|
LaunchConfigPalette,
|
|
FilesPalette,
|
|
TriggerWelcomeBlock,
|
|
CommandSearch,
|
|
ToggleResourceCenter,
|
|
ToggleKeybindingsPage,
|
|
ScrollToTopOfSelectedBlocks,
|
|
ScrollToBottomOfSelectedBlocks,
|
|
ToggleSyncAllTerminalInputsInAllTabs,
|
|
ToggleSyncTerminalInputsInCurrentTab,
|
|
DisableSyncTerminalInputs,
|
|
ReopenClosedSession,
|
|
ToggleGalaxyDrive,
|
|
AddWindow,
|
|
CloseCurrentSession,
|
|
CloseWindow,
|
|
NewPersonalWorkflow,
|
|
NewPersonalNotebook,
|
|
NewPersonalEnvVars,
|
|
NewTeamWorkflow,
|
|
NewTeamNotebook,
|
|
NewTeamEnvVars,
|
|
SearchDrive,
|
|
OpenTeamSettings,
|
|
ShareCurrentSession,
|
|
SharePaneContents,
|
|
#[cfg(windows)]
|
|
WindowsPaste,
|
|
#[cfg(windows)]
|
|
WindowsCopy,
|
|
/// Also applies to legacy Warp AI (toggles the panel)
|
|
NewAgentModePane,
|
|
/// Also applies to legacy Warp AI (attaches the selection to the panel editor)
|
|
AttachSelectionAsAgentModeContext,
|
|
OpenAIFactCollection,
|
|
OpenMCPServerCollection,
|
|
ToggleProjectExplorer,
|
|
NewPersonalAIPrompt,
|
|
NewTeamAIPrompt,
|
|
OpenRepository,
|
|
NewTerminalTab,
|
|
NewAgentTab,
|
|
GoToLine,
|
|
ToggleGlobalSearch,
|
|
ToggleConversationListView,
|
|
}
|
|
|
|
lazy_static! {
|
|
/// Maps for converting from custom tags back to the action enum
|
|
/// This layer of indirection is necessary because the UI framework can't
|
|
/// know about particular Warp specific actions, so it deals with all actions
|
|
/// as plain isizes. Within Warp though we want to deal with them as the enum type.
|
|
pub static ref CUSTOM_TAG_TO_ACTION: HashMap<isize, CustomAction> = HashMap::from_iter(all::<CustomAction>().map(|action| {
|
|
(action as isize, action)
|
|
}));
|
|
|
|
/// Regex that matches whether the the normalized form of a [`Keystroke`] matches a control
|
|
/// character. ASCII control characters constitute the first 31 values of ASCII characters.
|
|
/// Though they have their own ASCII codepoints, they are typed into the keyboard using
|
|
/// `ctrl-XX`, see <https://en.wikipedia.org/wiki/Caret_notation>.
|
|
///
|
|
/// As an example, the ETX character (represented as `^C` in caret notation) is sent to
|
|
/// the PTY when the user presses `ctrl-c`.
|
|
///
|
|
/// ## Control Characters List
|
|
/// The full list of these control characters (and their corresponding name) are documented
|
|
/// below:
|
|
/// * `^@`: Null
|
|
/// * `^A`: Start of Header
|
|
/// * `^B`: Start of Text
|
|
/// * `^C`: End of Text
|
|
/// * `^D`: End of Transmission
|
|
/// * `^E`: Enquiry
|
|
/// * `^F`: Acknowledge
|
|
/// * `^G`: Bell
|
|
/// * `^H`: BackSpace
|
|
/// * `^I`: Horizontal Tabulation
|
|
/// * `^J`: Line Feed
|
|
/// * `^K`: Vertical Tabulation
|
|
/// * `^L`: Form Feed
|
|
/// * `^M`: Carriage Return
|
|
/// * `^N`: Shift Out
|
|
/// * `^O`: Shift In
|
|
/// * `^P`: Data Link Escape
|
|
/// * `^Q`: Device Control 1 (XON)
|
|
/// * `^R`: Device Control 2
|
|
/// * `^S`: Device Control 3 (XOFF)
|
|
/// * `^T`: Device Control 4
|
|
/// * `^U`: Negative acknowledge
|
|
/// * `^V`: Synchronous Idle
|
|
/// * `^W`: End of Transmission Block
|
|
/// * `^X`: Cancel
|
|
/// * `^Y`: End of Medium
|
|
/// * `^Z`: Substitute
|
|
/// * `^[`: Escape
|
|
/// * `^\`: File Separator
|
|
/// * `^]`: Group Separator
|
|
/// * `^^`: Record Separator
|
|
/// * `^_`: Unit Separator
|
|
/// * `^?`: Delete
|
|
///
|
|
/// ## Note
|
|
/// Though caret notation uses uppercase letters (`^C` instead of `^c`), we validate using
|
|
/// _lowercase_ characters because it is impossible to create a [`Keystroke`] of the form
|
|
/// `ctrl-[A-Z]`. See [`Keystroke::parse`].
|
|
pub static ref CONTROL_CHARACTER_KEY_REGEX: Regex = Regex::new(r"^ctrl-[a-z@\[\\\]^_?]$").expect("should be able to construct regex");
|
|
|
|
/// Set of actions on Mac that should be considered valid bindings even though they aren't PTY
|
|
/// compliant. We weren't always diligent about avoiding bindings that could conflict with
|
|
/// character codes, unfortunately some bindings on Mac currently conflict with the PTY. We have
|
|
/// this allowlist to special case these legacy actions for the purposes of binding validation.
|
|
pub static ref MAC_PTY_NON_COMPLIANT_ACTIONS: HashSet<&'static str> = HashSet::from_iter(["terminal:warpify_subshell", "terminal:open_block_list_context_menu_via_keybinding"]);
|
|
|
|
/// Set of actions on Windows that should be considered valid bindings even though they aren't
|
|
/// PTY compliant. Windows users expect pasting to work using both `ctrl-v` and `ctrl-shift-v`,
|
|
/// so we allowlist the terminal paste action for the purposes of binding validation.
|
|
pub static ref WINDOWS_PTY_NON_COMPLIANT_KEYSTROKES: HashSet<Keystroke> = HashSet::from_iter([Keystroke::parse("ctrl-v").expect("should be able to construct ctrl-v keystroke")]);
|
|
|
|
/// Set of keystrokes that should be considered valid bindings on all platforms even though
|
|
/// they aren't PTY compliant.
|
|
pub static ref PTY_NON_COMPLIANT_KEYSTROKES: HashSet<Keystroke> = HashSet::from_iter([
|
|
// Windows users expect ctrl-c to copy any selected text to the clipboard. To avoid
|
|
// introducing multiple codepaths for handling ctrl-c, we register ctrl-c as a binding
|
|
// on TerminalView on all platforms.
|
|
Keystroke::parse("ctrl-c").expect("should be able to construct ctrl-c keystroke"),
|
|
// The resume conversation binding uses cmd-shift-R on Mac and should be allowed
|
|
Keystroke::parse("cmd-shift-R").expect("should be able to construct cmd-shift-R keystroke")
|
|
]);
|
|
}
|
|
|
|
impl From<CustomAction> for CustomTag {
|
|
fn from(action: CustomAction) -> Self {
|
|
action as CustomTag
|
|
}
|
|
}
|
|
|
|
impl From<CustomTag> for CustomAction {
|
|
fn from(tag: CustomTag) -> Self {
|
|
*CUSTOM_TAG_TO_ACTION
|
|
.get(&tag)
|
|
.expect("All custom actions are handled.")
|
|
}
|
|
}
|
|
|
|
pub fn trigger_to_keystroke(trigger: &Trigger) -> Option<Keystroke> {
|
|
match trigger {
|
|
Trigger::Keystrokes(keys) => keys.first().cloned(),
|
|
// Custom actions don't have keyboard shortcuts associated with the actions themselves,
|
|
// they are set separately in app/src/lib.rs as part of creating the Menu. As a result,
|
|
// we need to map those to the appropriate keyboard shortcut.
|
|
Trigger::Custom(custom) => custom_tag_to_keystroke(*custom),
|
|
// Similarly, Standard Actions have their keyboard shortcuts set when creating the menu
|
|
Trigger::Standard(standard) => match standard {
|
|
StandardAction::Close => mac_only_keystroke("cmd-shift-W"),
|
|
// "cmd-q" to quit and "cmd-h" to hide are the standard bindings for these actions on
|
|
// Mac.
|
|
StandardAction::Quit => mac_only_keystroke("cmd-q"),
|
|
StandardAction::Hide => mac_only_keystroke("cmd-h"),
|
|
StandardAction::HideOtherApps => Keystroke::parse("cmdorctrl-alt-h").ok(),
|
|
StandardAction::ToggleFullScreen => mac_only_keystroke("cmd-ctrl-f"),
|
|
StandardAction::Paste => Keystroke::parse(cmd_or_ctrl_shift("v")).ok(),
|
|
StandardAction::ShowAllApps
|
|
| StandardAction::BringAllToFront
|
|
| StandardAction::Minimize
|
|
| StandardAction::Zoom => None,
|
|
},
|
|
Trigger::Empty => None,
|
|
}
|
|
}
|
|
|
|
/// Returns the corresponding [`Keystroke`], if any, of a [`CustomTag`].
|
|
pub fn custom_tag_to_keystroke(custom: CustomTag) -> Option<Keystroke> {
|
|
match custom.into() {
|
|
CustomAction::FocusInput => Keystroke::parse(cmd_or_ctrl_shift("l")).ok(),
|
|
CustomAction::NewTab => Keystroke::parse(cmd_or_ctrl_shift("t")).ok(),
|
|
CustomAction::Cut => Keystroke::parse("cmdorctrl-x").ok(),
|
|
CustomAction::Copy => Keystroke::parse(cmd_or_ctrl_shift("c")).ok(),
|
|
CustomAction::Paste => Keystroke::parse(cmd_or_ctrl_shift("v")).ok(),
|
|
#[cfg(windows)]
|
|
CustomAction::WindowsPaste => Keystroke::parse("ctrl-v").ok(),
|
|
#[cfg(windows)]
|
|
CustomAction::WindowsCopy => Keystroke::parse("ctrl-c").ok(),
|
|
CustomAction::Undo => Keystroke::parse("cmdorctrl-z").ok(),
|
|
CustomAction::Redo => Keystroke::parse("cmdorctrl-shift-Z").ok(),
|
|
CustomAction::ClearEditor => Keystroke::parse("ctrl-c").ok(),
|
|
CustomAction::CycleNextSession => Keystroke::parse("ctrl-tab").ok(),
|
|
CustomAction::CyclePrevSession => Keystroke::parse("ctrl-shift-tab").ok(),
|
|
CustomAction::ShowSettings => Keystroke::parse("cmdorctrl-,").ok(),
|
|
CustomAction::AddNextOccurrence => Keystroke::parse("ctrl-g").ok(),
|
|
CustomAction::AddCursorAbove => Keystroke::parse("ctrl-shift-up").ok(),
|
|
CustomAction::AddCursorBelow => Keystroke::parse("ctrl-shift-down").ok(),
|
|
CustomAction::CommandPalette => Keystroke::parse(cmd_or_ctrl_shift("p")).ok(),
|
|
CustomAction::AISearch => Keystroke::parse("ctrl-`").ok(),
|
|
CustomAction::Find => Keystroke::parse(cmd_or_ctrl_shift("f")).ok(),
|
|
CustomAction::SelectAll => Keystroke::parse("cmdorctrl-a").ok(),
|
|
CustomAction::CommandSearch => Keystroke::parse("ctrl-r").ok(),
|
|
CustomAction::Workflows => Keystroke::parse("ctrl-shift-R").ok(),
|
|
CustomAction::History => Keystroke::parse("up").ok(),
|
|
CustomAction::IncreaseFontSize => Keystroke::parse("shift-cmdorctrl-+").ok(),
|
|
CustomAction::DecreaseFontSize => Keystroke::parse("shift-cmdorctrl-_").ok(),
|
|
CustomAction::ResetFontSize => Keystroke::parse("cmdorctrl-0").ok(),
|
|
CustomAction::IncreaseZoom => Keystroke::parse("cmdorctrl-=").ok(),
|
|
CustomAction::DecreaseZoom => Keystroke::parse("cmdorctrl--").ok(),
|
|
CustomAction::ResetZoom => Keystroke::parse("cmdorctrl-0").ok(),
|
|
CustomAction::SplitPaneRight => Keystroke::parse(cmd_or_ctrl_shift("d")).ok(),
|
|
CustomAction::SplitPaneDown => {
|
|
if OperatingSystem::get().is_mac() {
|
|
Keystroke::parse("cmd-shift-D").ok()
|
|
} else {
|
|
// On non-Mac platforms, we can't use `ctrl-shift-D` for `SplitPaneRight` since
|
|
// we already use that for `SplitPaneRight` above. Instead we use
|
|
// `ctrl-shift-E`, which matches what Hyper uses. See https://github.com/vercel/hyper/blob/9c72409f5138c03a5a74fcc4dba9109217b4524a/app/keymaps/linux.json#L32.
|
|
Keystroke::parse("ctrl-shift-E").ok()
|
|
}
|
|
}
|
|
CustomAction::MoveTabLeft => {
|
|
if OperatingSystem::get().is_mac() {
|
|
Keystroke::parse("shift-ctrl-left").ok()
|
|
} else {
|
|
Keystroke::parse("shift-ctrl-pageup").ok()
|
|
}
|
|
}
|
|
CustomAction::MoveTabRight => {
|
|
if OperatingSystem::get().is_mac() {
|
|
Keystroke::parse("shift-ctrl-right").ok()
|
|
} else {
|
|
Keystroke::parse("shift-ctrl-pagedown").ok()
|
|
}
|
|
}
|
|
CustomAction::ActivateNextTab => {
|
|
if OperatingSystem::get().is_mac() {
|
|
Keystroke::parse("shift-cmd-}").ok()
|
|
} else {
|
|
Keystroke::parse("ctrl-pagedown").ok()
|
|
}
|
|
}
|
|
CustomAction::ActivatePreviousTab => {
|
|
if OperatingSystem::get().is_mac() {
|
|
Keystroke::parse("shift-cmd-{").ok()
|
|
} else {
|
|
Keystroke::parse("ctrl-pageup").ok()
|
|
}
|
|
}
|
|
CustomAction::ActivateNextPane => {
|
|
if OperatingSystem::get().is_mac() {
|
|
Keystroke::parse("cmd-]").ok()
|
|
} else {
|
|
Keystroke::parse("ctrl-shift-}").ok()
|
|
}
|
|
}
|
|
CustomAction::ActivatePreviousPane => {
|
|
if OperatingSystem::get().is_mac() {
|
|
Keystroke::parse("cmd-[").ok()
|
|
} else {
|
|
Keystroke::parse("ctrl-shift-{").ok()
|
|
}
|
|
}
|
|
CustomAction::NavigationPalette => mac_only_keystroke("cmd-shift-P"),
|
|
CustomAction::LaunchConfigPalette => mac_only_keystroke("ctrl-cmd-l"),
|
|
CustomAction::FilesPalette => Keystroke::parse(cmd_or_ctrl_shift("o")).ok(),
|
|
CustomAction::ClearBlocks => Keystroke::parse(cmd_or_ctrl_shift("k")).ok(),
|
|
CustomAction::SelectBlockAbove => Keystroke::parse("cmdorctrl-up").ok(),
|
|
CustomAction::SelectBlockBelow => Keystroke::parse("cmdorctrl-down").ok(),
|
|
// Set this to mac-only. On Linux this conflicts with the binding to save a workflow.
|
|
CustomAction::CreateBlockPermalink => mac_only_keystroke("cmd-shift-S"),
|
|
CustomAction::ToggleBookmarkBlock => Keystroke::parse(cmd_or_ctrl_shift("b")).ok(),
|
|
CustomAction::CopyBlockOutput => Keystroke::parse("cmdorctrl-alt-shift-C").ok(),
|
|
// Set this to mac-only. On Linux this conflicts with the general binding to copy.
|
|
CustomAction::CopyBlockCommand => mac_only_keystroke("cmd-shift-C"),
|
|
// Set this to mac-only. On Linux this conflicts with the cmd-enter keybindings
|
|
// (used for actions on the input suggestions menu, and for accepting passive code diffs).
|
|
CustomAction::ToggleMaximizePane => mac_only_keystroke("cmd-shift-enter"),
|
|
// Note: The base character '/' is used instead of '?' as mac registers keybindings
|
|
// differently compared to the app which saves the resulting character used with shift
|
|
// TODO: resolve these keybinding differences
|
|
CustomAction::ToggleResourceCenter => Keystroke::parse("ctrl-shift-/").ok(),
|
|
CustomAction::ToggleKeybindingsPage => Keystroke::parse("cmdorctrl-/").ok(),
|
|
CustomAction::ScrollToTopOfSelectedBlocks => Keystroke::parse("cmdorctrl-shift-up").ok(),
|
|
CustomAction::ScrollToBottomOfSelectedBlocks => {
|
|
Keystroke::parse("cmdorctrl-shift-down").ok()
|
|
}
|
|
CustomAction::CopyBlock => Keystroke::parse(cmd_or_ctrl_shift("c")).ok(),
|
|
CustomAction::FindWithinBlock => Keystroke::parse(cmd_or_ctrl_shift("f")).ok(),
|
|
CustomAction::ToggleSyncTerminalInputsInCurrentTab => {
|
|
Keystroke::parse("alt-cmdorctrl-i").ok()
|
|
}
|
|
CustomAction::ReopenClosedSession => {
|
|
if OperatingSystem::get().is_mac() {
|
|
Keystroke::parse("cmd-shift-T").ok()
|
|
} else {
|
|
// Use a custom keybinding for linux/windows since the binding would otherwise
|
|
// conflict with the binding for creating a new tab.
|
|
Keystroke::parse("ctrl-alt-t").ok()
|
|
}
|
|
}
|
|
|
|
// This is one of the app's hardcoded keybindings.
|
|
CustomAction::AddWindow => Keystroke::parse(cmd_or_ctrl_shift("n")).ok(),
|
|
CustomAction::ToggleGalaxyDrive => {
|
|
if OperatingSystem::get().is_mac() {
|
|
Keystroke::parse("cmd-\\").ok()
|
|
} else {
|
|
Keystroke::parse("ctrl-shift-|").ok()
|
|
}
|
|
}
|
|
CustomAction::CloseWindow => mac_only_keystroke("cmd-shift-W"),
|
|
CustomAction::CloseCurrentSession => Keystroke::parse(cmd_or_ctrl_shift("w")).ok(),
|
|
CustomAction::ViewChangelog => Keystroke::parse(cmd_or_ctrl_shift("alt-o")).ok(),
|
|
CustomAction::NewAgentModePane => Keystroke::parse("ctrl-space").ok(),
|
|
CustomAction::AttachSelectionAsAgentModeContext => {
|
|
Keystroke::parse("ctrl-shift-space").ok()
|
|
}
|
|
CustomAction::ToggleProjectExplorer => {
|
|
if OperatingSystem::get().is_mac() {
|
|
Keystroke::parse("ctrl-1").ok()
|
|
} else {
|
|
Keystroke::parse("alt-1").ok()
|
|
}
|
|
}
|
|
CustomAction::OpenRepository => {
|
|
if OperatingSystem::get().is_mac() {
|
|
Keystroke::parse("cmd-shift-O").ok()
|
|
} else {
|
|
Keystroke::parse("alt-shift-O").ok()
|
|
}
|
|
}
|
|
CustomAction::GoToLine => Keystroke::parse("ctrl-g").ok(),
|
|
CustomAction::ToggleGlobalSearch => {
|
|
if OperatingSystem::get().is_mac() {
|
|
Keystroke::parse("ctrl-3").ok()
|
|
} else {
|
|
Keystroke::parse("alt-3").ok()
|
|
}
|
|
}
|
|
CustomAction::ToggleConversationListView => {
|
|
if OperatingSystem::get().is_mac() {
|
|
Keystroke::parse("ctrl-2").ok()
|
|
} else {
|
|
Keystroke::parse("alt-2").ok()
|
|
}
|
|
}
|
|
CustomAction::NewTerminalTab
|
|
| CustomAction::NewFile
|
|
| CustomAction::ShowAboutGalaxy
|
|
| CustomAction::SplitPaneLeft
|
|
| CustomAction::SelectAllBlocks
|
|
| CustomAction::SplitPaneUp
|
|
| CustomAction::ConfigureKeybindings
|
|
| CustomAction::RenameTab
|
|
| CustomAction::CloseTab
|
|
| CustomAction::CloseOtherTabs
|
|
| CustomAction::CloseTabsRight
|
|
| CustomAction::ReferAFriend
|
|
| CustomAction::ViewSharedBlocks
|
|
| CustomAction::ShowAccount
|
|
| CustomAction::ShowAppearance
|
|
| CustomAction::SaveCurrentConfig
|
|
| CustomAction::TriggerWelcomeBlock
|
|
| CustomAction::HistorySearch
|
|
| CustomAction::DisableSyncTerminalInputs
|
|
| CustomAction::ToggleSyncAllTerminalInputsInAllTabs
|
|
| CustomAction::NewPersonalWorkflow
|
|
| CustomAction::NewPersonalNotebook
|
|
| CustomAction::NewPersonalEnvVars
|
|
| CustomAction::NewTeamWorkflow
|
|
| CustomAction::NewTeamNotebook
|
|
| CustomAction::NewTeamEnvVars
|
|
| CustomAction::SearchDrive
|
|
| CustomAction::OpenTeamSettings
|
|
| CustomAction::ShareCurrentSession
|
|
| CustomAction::SharePaneContents
|
|
| CustomAction::OpenAIFactCollection
|
|
| CustomAction::OpenMCPServerCollection
|
|
| CustomAction::NewPersonalAIPrompt
|
|
| CustomAction::NewTeamAIPrompt
|
|
| CustomAction::NewAgentTab => None,
|
|
}
|
|
}
|
|
|
|
/// Get the keystroke currently assigned to a binding. Returns `None` if the binding does not exist
|
|
/// or is unassigned.
|
|
pub fn keybinding_name_to_keystroke(binding_name: &str, ctx: &AppContext) -> Option<Keystroke> {
|
|
ctx.get_binding_by_name(binding_name)
|
|
.and_then(|binding| trigger_to_keystroke(binding.trigger))
|
|
}
|
|
|
|
/// Get keybinding display string from binding name. Unset keybindings will return None.
|
|
pub fn keybinding_name_to_display_string(binding_name: &str, ctx: &AppContext) -> Option<String> {
|
|
keybinding_name_to_keystroke(binding_name, ctx).map(|keystroke| keystroke.displayed())
|
|
}
|
|
|
|
/// Get normalized keybinding string from binding name. Unset keybindings will return None.
|
|
pub fn keybinding_name_to_normalized_string(
|
|
binding_name: &str,
|
|
ctx: &AppContext,
|
|
) -> Option<String> {
|
|
keybinding_name_to_keystroke(binding_name, ctx).map(|keystroke| keystroke.normalized())
|
|
}
|
|
|
|
/// Sets a custom keybinding for an editable binding using the given keystroke. Will
|
|
/// persist the keybinding to the user's config file and emit a KeybindingChangedEvent.
|
|
pub fn set_custom_keybinding(binding_name: &str, keystroke: &Keystroke, ctx: &mut AppContext) {
|
|
ctx.set_custom_trigger(
|
|
binding_name.into(),
|
|
Trigger::Keystrokes(vec![keystroke.clone()]),
|
|
);
|
|
write_custom_keybinding(
|
|
binding_name.into(),
|
|
UserDefinedKeybinding::keystroke(keystroke.clone()),
|
|
);
|
|
KeybindingChangedNotifier::handle(ctx).update(ctx, |_, ctx| {
|
|
ctx.emit(KeybindingChangedEvent::BindingChanged {
|
|
binding_name: binding_name.into(),
|
|
new_trigger: Some(keystroke.clone()),
|
|
})
|
|
});
|
|
}
|
|
|
|
/// Reset an editable binding back to its default trigger. Will persist this change to
|
|
/// the user's config file and emit a KeybindingChangedEvent with the default trigger.
|
|
/// Returns the default keystroke for the binding.
|
|
pub fn reset_keybinding_to_default(binding_name: &str, ctx: &mut AppContext) -> Option<Keystroke> {
|
|
ctx.remove_custom_trigger(binding_name);
|
|
remove_custom_keybinding(binding_name);
|
|
|
|
let default_keystroke = ctx
|
|
.editable_bindings()
|
|
.find(|binding| binding.name == binding_name)
|
|
.and_then(|binding| trigger_to_keystroke(binding.trigger));
|
|
|
|
KeybindingChangedNotifier::handle(ctx).update(ctx, |_, ctx| {
|
|
ctx.emit(KeybindingChangedEvent::BindingChanged {
|
|
binding_name: binding_name.into(),
|
|
new_trigger: default_keystroke.clone(),
|
|
})
|
|
});
|
|
|
|
default_keystroke
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct CommandBinding {
|
|
pub name: String,
|
|
pub description: BindingDescription,
|
|
pub trigger: Option<Keystroke>,
|
|
pub action: Option<Arc<dyn Action>>,
|
|
pub group: Option<BindingGroup>,
|
|
/// The ID of the binding. If the [`CommandBinding`] was created from an
|
|
/// [`EditableBindingLens`] or [`BindingLens`] this is the id of the lens. Otherwise a new ID
|
|
/// is constructed.
|
|
pub id: BindingId,
|
|
}
|
|
|
|
/// SearchScore is a helper struct for ranking the result of the search.
|
|
/// `keystroke_score` represents the proximity between search term keystroke with
|
|
/// the candidate keystroke. If the score is None, it means the candidate keystroke is not
|
|
/// a valid subset of the search term keystroke.
|
|
/// The fuzzy_search_score helps on the secondary ranking -- if two candidate keystrokes are
|
|
/// both not valid subset of the search term keystroke, then we rank these by the score
|
|
/// of the fuzzy_search.
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
|
struct SearchScore {
|
|
keystroke_score: Option<usize>,
|
|
fuzzy_search_score: i64,
|
|
}
|
|
|
|
impl Ord for SearchScore {
|
|
fn cmp(&self, other: &Self) -> Ordering {
|
|
match self.keystroke_score.cmp(&other.keystroke_score) {
|
|
Ordering::Equal => self.fuzzy_search_score.cmp(&other.fuzzy_search_score),
|
|
ordering => ordering,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl PartialOrd for SearchScore {
|
|
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
|
Some(self.cmp(other))
|
|
}
|
|
}
|
|
|
|
fn convert_search_term_to_keystroke(search_term: &str) -> Option<Keystroke> {
|
|
let mut search_keystroke: Keystroke = Default::default();
|
|
let mut key_set = false;
|
|
|
|
for element in search_term.split_whitespace() {
|
|
match element.to_ascii_lowercase().as_str() {
|
|
"command" | "cmd" => {
|
|
if search_keystroke.cmd {
|
|
return None;
|
|
}
|
|
search_keystroke.cmd = true
|
|
}
|
|
"control" | "ctrl" => {
|
|
if search_keystroke.ctrl {
|
|
return None;
|
|
}
|
|
search_keystroke.ctrl = true
|
|
}
|
|
"alt" | "option" => {
|
|
if search_keystroke.alt {
|
|
return None;
|
|
}
|
|
search_keystroke.alt = true
|
|
}
|
|
"shift" => {
|
|
if search_keystroke.shift {
|
|
return None;
|
|
}
|
|
search_keystroke.shift = true
|
|
}
|
|
"meta" => {
|
|
if search_keystroke.meta {
|
|
return None;
|
|
}
|
|
search_keystroke.meta = true
|
|
}
|
|
key => {
|
|
if key_set {
|
|
return None;
|
|
}
|
|
key_set = true;
|
|
search_keystroke.key = key.to_string()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Internally we uppercase the key when shift modifier is true.
|
|
if search_keystroke.shift && search_keystroke.key.len() == 1 {
|
|
search_keystroke.key = search_keystroke.key.to_ascii_uppercase();
|
|
}
|
|
|
|
Some(search_keystroke)
|
|
}
|
|
|
|
pub fn filter_bindings_including_keystroke<'a>(
|
|
bindings_iter: impl Iterator<Item = &'a CommandBinding>,
|
|
search_term: &str,
|
|
description_for: DescriptionContext,
|
|
) -> impl Iterator<Item = (Option<Vec<usize>>, &'a CommandBinding)> {
|
|
let search_keystroke = convert_search_term_to_keystroke(search_term);
|
|
|
|
bindings_iter
|
|
.filter_map(move |binding| {
|
|
if search_term.is_empty() {
|
|
Some((Default::default(), None, binding))
|
|
} else {
|
|
let keystroke_search_score = if let Some(search_keystroke) = &search_keystroke {
|
|
binding.trigger.as_ref().and_then(|candidate_keystroke| {
|
|
let score = keystroke_includes(search_keystroke, candidate_keystroke);
|
|
if score > 0 {
|
|
Some(score)
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let fuzzy_search_result = match_indices_case_insensitive(
|
|
binding.description.in_context(description_for),
|
|
search_term,
|
|
);
|
|
|
|
match (keystroke_search_score, fuzzy_search_result) {
|
|
// If keystroke matched, don't include fuzzy search highlights.
|
|
(Some(keystroke_score), Some(fuzzy_search_result)) => Some((
|
|
SearchScore {
|
|
keystroke_score: Some(keystroke_score),
|
|
fuzzy_search_score: fuzzy_search_result.score,
|
|
},
|
|
None,
|
|
binding,
|
|
)),
|
|
(None, Some(fuzzy_search_result)) => Some((
|
|
SearchScore {
|
|
fuzzy_search_score: fuzzy_search_result.score,
|
|
..Default::default()
|
|
},
|
|
None,
|
|
binding,
|
|
)),
|
|
(Some(keystroke_score), None) => Some((
|
|
SearchScore {
|
|
keystroke_score: Some(keystroke_score),
|
|
..Default::default()
|
|
},
|
|
None,
|
|
binding,
|
|
)),
|
|
_ => None,
|
|
}
|
|
}
|
|
})
|
|
.sorted_by(|(score1, _, _), (score2, _, _)| score2.cmp(score1))
|
|
.map(|(_, indices, binding)| (indices, binding))
|
|
}
|
|
|
|
/// Check if the keystroke could be a possible candidate of the search keystroke and give a score
|
|
/// based on proximity.
|
|
/// The scores are generated as follows: for each field of the keystroke (alt, cmd, etc), the comparison
|
|
/// between the search and candidate keystroke could yield three possible results - a strict
|
|
/// match (candidate and search has the same value), a potential match (candidate has the value
|
|
/// set to true but search is missing the value), a mismatch (candidate has the value set to
|
|
/// false but search is set to true).
|
|
/// For a strict match, we increase the score by multiplying it by two. For a potential match,
|
|
/// we keep the original score by multiplying it by one. For a mismatch, we multiply by zero
|
|
/// to mark that the candidate keystroke is not a valid subset of the search keystroke.
|
|
fn keystroke_includes(search_keystroke: &Keystroke, candidate_keystroke: &Keystroke) -> usize {
|
|
fn modifier_match(
|
|
search_keystroke_condition: bool,
|
|
candidate_keystroke_condition: bool,
|
|
) -> usize {
|
|
match (search_keystroke_condition, candidate_keystroke_condition) {
|
|
(false, false) | (true, true) => 2, // match gives a score of 2.
|
|
(false, true) => 1, // keep the same score if the keystroke term is true but search_keystroke does not include the term.
|
|
(true, false) => 0, // if the keystroke term is false but search_keystorke is true, return 0 score
|
|
}
|
|
}
|
|
|
|
let key_match_score = if search_keystroke.key == candidate_keystroke.key {
|
|
2
|
|
} else {
|
|
usize::from(search_keystroke.key.is_empty())
|
|
};
|
|
|
|
modifier_match(search_keystroke.alt, candidate_keystroke.alt)
|
|
* modifier_match(search_keystroke.cmd, candidate_keystroke.cmd)
|
|
* modifier_match(search_keystroke.ctrl, candidate_keystroke.ctrl)
|
|
* modifier_match(search_keystroke.meta, candidate_keystroke.meta)
|
|
* modifier_match(search_keystroke.shift, candidate_keystroke.shift)
|
|
* key_match_score
|
|
}
|
|
|
|
impl CommandBinding {
|
|
pub fn new(name: String, description: String, trigger: Option<Keystroke>) -> Self {
|
|
CommandBinding {
|
|
name,
|
|
description: BindingDescription::new(description),
|
|
trigger,
|
|
action: None,
|
|
group: None,
|
|
id: BindingId::new(),
|
|
}
|
|
}
|
|
|
|
/// Materializes a [`CommandBinding`] from a [`BindingLens`], resolving
|
|
/// any dynamic description against `ctx` so downstream consumers that
|
|
/// have no `&AppContext` (fuzzy/full-text search indices, accessibility
|
|
/// labels, render paths that only see an `Appearance`) observe a plain
|
|
/// string.
|
|
///
|
|
/// This is intentionally the only way to build a `CommandBinding` from
|
|
/// a lens; taking `&AppContext` by value here forces every cache-
|
|
/// population site to thread context through, which in turn guarantees
|
|
/// that a future dynamic description cannot silently go unresolved.
|
|
///
|
|
/// Returns `None` when the source binding has no description.
|
|
pub fn from_lens(lens: BindingLens<'_>, ctx: &AppContext) -> Option<Self> {
|
|
lens.description.map(|desc| CommandBinding {
|
|
description: materialize_description(desc, ctx),
|
|
trigger: trigger_to_keystroke(lens.trigger),
|
|
action: Some(lens.action.clone()),
|
|
name: lens.name.to_string(),
|
|
group: lens.group.and_then(BindingGroup::from_str),
|
|
id: lens.id,
|
|
})
|
|
}
|
|
|
|
/// Materializes a [`CommandBinding`] from an [`EditableBindingLens`].
|
|
/// See [`Self::from_lens`] for why this takes `&AppContext`.
|
|
pub fn from_editable_lens(lens: EditableBindingLens<'_>, ctx: &AppContext) -> Self {
|
|
Self {
|
|
description: materialize_description(lens.description, ctx),
|
|
trigger: trigger_to_keystroke(lens.trigger),
|
|
action: Some(lens.action.clone()),
|
|
name: lens.name.into(),
|
|
group: lens.group.and_then(BindingGroup::from_str),
|
|
id: lens.id,
|
|
}
|
|
}
|
|
|
|
pub fn placeholder(placeholder: String) -> Self {
|
|
CommandBinding {
|
|
name: Default::default(),
|
|
description: placeholder.into(),
|
|
trigger: None,
|
|
action: None,
|
|
group: None,
|
|
id: BindingId::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn materialize_description(desc: &BindingDescription, ctx: &AppContext) -> BindingDescription {
|
|
if desc.has_dynamic_override() {
|
|
desc.materialized(ctx)
|
|
} else {
|
|
desc.clone()
|
|
}
|
|
}
|
|
|
|
/// Possible groups a Binding can be part of. The string representation (produced in
|
|
/// [`BindingGroup::as_str`]) is used as the group identifier within
|
|
/// [`galaxyui::keymap::FixedBinding`] or [`EditableBinding`].
|
|
#[derive(Copy, Clone, Debug, Sequence)]
|
|
pub enum BindingGroup {
|
|
Settings,
|
|
Close,
|
|
Navigation,
|
|
WarpAi,
|
|
Workflow,
|
|
Notebooks,
|
|
Folders,
|
|
KeyboardShortcuts,
|
|
AutoUpdate,
|
|
Notifications,
|
|
EnvVarCollection,
|
|
Terminal,
|
|
}
|
|
|
|
impl BindingGroup {
|
|
/// Returns a string representation of the [`BindingGroup`].
|
|
pub fn as_str(&self) -> &'static str {
|
|
match self {
|
|
Self::Settings => "settings",
|
|
Self::WarpAi => "warp_ai",
|
|
Self::Navigation => "navigation",
|
|
Self::Workflow => "workflows",
|
|
Self::Notebooks => "notebooks",
|
|
Self::Folders => "folders",
|
|
Self::KeyboardShortcuts => "keyboard_shortcuts",
|
|
Self::Close => "close",
|
|
Self::AutoUpdate => "autoupdate",
|
|
Self::Notifications => "notifications",
|
|
Self::EnvVarCollection => "env_var_collections",
|
|
Self::Terminal => "terminal",
|
|
}
|
|
}
|
|
|
|
/// Creates a [`BindingGroup`] from a str. Returns `None` if there is no group that corresponds
|
|
/// to the `str`.
|
|
fn from_str(str: &'static str) -> Option<Self> {
|
|
all::<Self>().find(|&item| item.as_str() == str)
|
|
}
|
|
}
|
|
|
|
/// Constructs a keybinding that is the `cmd-key` on Mac or `ctrl-shift-key` otherwise. This is
|
|
/// useful when constructing a binding that needs to be compliant with the PTY. The typical pattern
|
|
/// of using `cmdorctrl-XX` to construct a platform agnostic keybinding does not work here because
|
|
/// `ctrl-XX` would conflict with the PTY because it is reserved as a control character.
|
|
///
|
|
/// Bindings of the form `ctrl-[a-z@[\]^_?]` are reserved as control characters. We don't want to
|
|
/// create bindings for in-app actions that would conflict with these control characters because we
|
|
/// would end up preventing the user from sending these control characters to the PTY. To avoid
|
|
/// this, we follow other terminals and use `ctrl-shift-XX` for in-app bindings if the binding would
|
|
/// otherwise conflict with the PTY.
|
|
///
|
|
/// ## Panics
|
|
/// Panics if debug assertions are enabled and a non "A-Z" key was passed in an environment where `ctrl-shift` would be
|
|
/// used. This is because the passed key needs to modified by the shift character in order to be valid in our UI
|
|
/// framework and we can't easily produce the shift-modified version of the key ourselves. In this case the recommended
|
|
/// solution is to to create separate [`Keystroke`]s for the Mac and non-Mac cases. For example:
|
|
/// ```
|
|
/// use galaxyui::keymap::Keystroke;
|
|
/// use galaxyui::platform::OperatingSystem;
|
|
/// let keystroke = if OperatingSystem::get().is_mac() {
|
|
/// Keystroke::parse("cmd-[")
|
|
/// } else {
|
|
/// Keystroke::parse("ctrl-shift-{")
|
|
/// };
|
|
/// ```
|
|
pub fn cmd_or_ctrl_shift(key: &str) -> String {
|
|
if OperatingSystem::get().is_mac() {
|
|
format!("cmd-{key}")
|
|
} else {
|
|
let key = if Keystroke::is_valid_special_key(key) {
|
|
// Valid keys don't need to be uppercase (we don't want to create a binding that looks
|
|
// like `ctrl-shift-ENTER`).
|
|
Cow::Borrowed(key)
|
|
} else {
|
|
if cfg!(debug_assertions) {
|
|
let stroke = key.chars().next().expect("Character should exist");
|
|
|
|
if !stroke.is_ascii_lowercase() {
|
|
panic!(
|
|
"Tried to register a ctrl-shift-{key} shortcut which is invalid because the {key} character needs to be modified by the shift character."
|
|
);
|
|
}
|
|
}
|
|
// The need to uppercase the key because of the addition of the `shift`.
|
|
// Keystroke::parse debug asserts if this the modifier is lowercase:
|
|
// https://github.com/warpdotdev/warp-internal/blob/c225b8cedd94fdba33e957cf1efb99d84768d193/ui/src/keymap.rs#L637/
|
|
key.to_ascii_uppercase().into()
|
|
};
|
|
format!("ctrl-shift-{key}")
|
|
}
|
|
}
|
|
|
|
/// Returns whether the given [`BindingLens`] is compliant with the PTY.
|
|
/// A binding is considered PTY compliant if it does not interfere with a control character that
|
|
/// needs to be sent to the PTY. A binding is considered to be a control character if the only
|
|
/// modifier set is `ctrl` and the key is one of `a-z@[\]^_?`.
|
|
pub fn is_binding_pty_compliant(binding: BindingLens) -> IsBindingValid {
|
|
let trigger = binding.original_trigger.unwrap_or(binding.trigger);
|
|
let Some(keystroke) = trigger_to_keystroke(trigger) else {
|
|
return IsBindingValid::Yes;
|
|
};
|
|
if CONTROL_CHARACTER_KEY_REGEX.is_match(keystroke.normalized().as_str())
|
|
&& !is_pty_non_compliant_binding_allowed(binding.name, &keystroke)
|
|
{
|
|
// The binding interferes with a control character so it is not valid.
|
|
IsBindingValid::No
|
|
} else {
|
|
IsBindingValid::Yes
|
|
}
|
|
}
|
|
|
|
fn is_pty_non_compliant_binding_allowed(binding_name: &str, keystroke: &Keystroke) -> bool {
|
|
(OperatingSystem::get().is_mac() && MAC_PTY_NON_COMPLIANT_ACTIONS.contains(binding_name))
|
|
|| (OperatingSystem::get().is_windows()
|
|
&& WINDOWS_PTY_NON_COMPLIANT_KEYSTROKES.contains(keystroke))
|
|
|| PTY_NON_COMPLIANT_KEYSTROKES.contains(keystroke)
|
|
}
|
|
|
|
/// Validates all that bindings are cross-platform by returning [`IsBindingValid::No`] if a `cmd-*`
|
|
/// binding is used on non-mac platforms.
|
|
pub fn is_binding_cross_platform(binding: BindingLens) -> IsBindingValid {
|
|
if OperatingSystem::get().is_mac() {
|
|
return IsBindingValid::Yes;
|
|
};
|
|
|
|
let trigger = binding.original_trigger.unwrap_or(binding.trigger);
|
|
let Some(keystroke) = trigger_to_keystroke(trigger) else {
|
|
return IsBindingValid::Yes;
|
|
};
|
|
|
|
if keystroke.cmd {
|
|
IsBindingValid::No
|
|
} else {
|
|
IsBindingValid::Yes
|
|
}
|
|
}
|
|
|
|
/// Attempts to construct a [`Keystroke`] from the given source string if the current
|
|
/// [`OperatingSystem`] is mac. Returns `None` if not on Mac or if a [`Keystroke`] was unable to be
|
|
/// constructed from the source string.
|
|
fn mac_only_keystroke(source: &str) -> Option<Keystroke> {
|
|
if OperatingSystem::get().is_mac() {
|
|
Keystroke::parse(source).ok()
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "bindings_tests.rs"]
|
|
mod tests;
|