use std::collections::BTreeMap;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::config::{Chord, KeyPress, KeysConfig, parse_chord, single_press};
use crate::transient::TransientKind;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
MoveDown,
MoveUp,
MoveLeft,
MoveRight,
GoTop,
GoBottom,
ZoomIn,
ZoomOut,
NextSection,
PrevSection,
NextFile,
PrevFile,
NextUnviewed,
UnviewAll,
CycleSidebarMode,
ToggleFold,
ToggleSideBySide,
Stage,
Unstage,
StageAll,
UnstageAll,
Discard,
Refresh,
Open,
OpenReviewDiff,
DiffBase,
DiffLastCommit,
DiffBranch,
DiffCommit,
DiffWorkingTree,
OpenRuns,
OpenPrs,
CreatePr,
CommentsOverview,
SubmitReview,
CommitFlow,
CommitExtend,
CommitAmend,
CommitReword,
BranchCheckout,
BranchCreateCheckout,
BranchCreate,
BranchDelete,
LogView,
Push,
PushSetUpstream,
Pull,
Fetch,
FetchAll,
StashPush,
StashPop,
NextHunk,
PrevHunk,
NextFunction,
PrevFunction,
DeleteComment,
DeleteAllComments,
NextComment,
PrevComment,
HalfPageDown,
HalfPageUp,
FullPageDown,
FullPageUp,
CenterCursor,
CursorTop,
CursorBottom,
ExpandContext,
CollapseContext,
ExpandWholeFile,
SwitchTheme,
Comment,
VisualSelect,
Reply,
Resolve,
MarkViewed,
CopyFileFeedback,
CopyAllFeedback,
CopyUrl,
Search,
SearchNext,
SearchPrev,
OpenEditor,
OpenFilePicker,
Blame,
ToggleBlame,
SendFeedback,
Help,
Palette,
Quit,
Back,
}
impl Action {
pub fn name(self) -> &'static str {
match self {
Self::MoveDown => "move_down",
Self::MoveUp => "move_up",
Self::MoveLeft => "move_left",
Self::MoveRight => "move_right",
Self::ZoomIn => "zoom_in",
Self::ZoomOut => "zoom_out",
Self::GoTop => "go_top",
Self::GoBottom => "go_bottom",
Self::NextSection => "next_section",
Self::PrevSection => "prev_section",
Self::NextFile => "next_file",
Self::PrevFile => "prev_file",
Self::NextUnviewed => "next_unviewed",
Self::UnviewAll => "unview_all",
Self::CycleSidebarMode => "cycle_sidebar_mode",
Self::ToggleFold => "toggle_fold",
Self::ToggleSideBySide => "toggle_side_by_side",
Self::Stage => "stage",
Self::Unstage => "unstage",
Self::StageAll => "stage_all",
Self::UnstageAll => "unstage_all",
Self::Discard => "discard",
Self::Refresh => "refresh",
Self::Open => "open",
Self::OpenReviewDiff => "open_review_diff",
Self::DiffBase => "diff_base",
Self::DiffLastCommit => "diff_last_commit",
Self::DiffBranch => "diff_branch",
Self::DiffCommit => "diff_commit",
Self::DiffWorkingTree => "diff_working_tree",
Self::OpenRuns => "open_runs",
Self::OpenPrs => "open_prs",
Self::CreatePr => "create_pr",
Self::CommentsOverview => "comments_overview",
Self::SubmitReview => "submit_review",
Self::CommitFlow => "commit_flow",
Self::CommitExtend => "commit_extend",
Self::CommitAmend => "commit_amend",
Self::CommitReword => "commit_reword",
Self::BranchCheckout => "branch_checkout",
Self::BranchCreateCheckout => "branch_create_checkout",
Self::BranchCreate => "branch_create",
Self::BranchDelete => "branch_delete",
Self::LogView => "log_view",
Self::Push => "push",
Self::PushSetUpstream => "push_set_upstream",
Self::Pull => "pull",
Self::Fetch => "fetch",
Self::FetchAll => "fetch_all",
Self::StashPush => "stash_push",
Self::StashPop => "stash_pop",
Self::NextHunk => "next_hunk",
Self::NextFunction => "next_function",
Self::PrevFunction => "prev_function",
Self::DeleteComment => "delete_comment",
Self::DeleteAllComments => "delete_all_comments",
Self::PrevHunk => "prev_hunk",
Self::NextComment => "next_comment",
Self::PrevComment => "prev_comment",
Self::HalfPageDown => "half_page_down",
Self::HalfPageUp => "half_page_up",
Self::FullPageDown => "full_page_down",
Self::FullPageUp => "full_page_up",
Self::CenterCursor => "center_cursor",
Self::CursorTop => "cursor_top",
Self::CursorBottom => "cursor_bottom",
Self::ExpandContext => "expand_context",
Self::CollapseContext => "collapse_context",
Self::ExpandWholeFile => "expand_whole_file",
Self::SwitchTheme => "switch_theme",
Self::Comment => "comment",
Self::VisualSelect => "visual_select",
Self::Reply => "reply",
Self::Resolve => "resolve",
Self::MarkViewed => "mark_viewed",
Self::CopyFileFeedback => "copy_file_feedback",
Self::CopyAllFeedback => "copy_all_feedback",
Self::CopyUrl => "copy_url",
Self::Search => "search",
Self::SearchNext => "search_next",
Self::SearchPrev => "search_prev",
Self::OpenEditor => "open_editor",
Self::OpenFilePicker => "open_file_picker",
Self::Blame => "blame",
Self::ToggleBlame => "toggle_blame",
Self::SendFeedback => "send_feedback",
Self::Help => "help",
Self::Palette => "palette",
Self::Quit => "quit",
Self::Back => "back",
}
}
pub fn label(self) -> &'static str {
match self {
Self::MoveDown => "move down",
Self::MoveUp => "move up",
Self::MoveLeft => "move left",
Self::MoveRight => "move right",
Self::GoTop => "go to top",
Self::GoBottom => "go to bottom",
Self::ZoomIn => "zoom in",
Self::ZoomOut => "zoom out",
Self::NextSection => "next section",
Self::PrevSection => "previous section",
Self::NextFile => "next file",
Self::PrevFile => "previous file",
Self::NextUnviewed => "jump to the next unviewed file",
Self::UnviewAll => "clear all viewed marks",
Self::CycleSidebarMode => "cycle sidebar: tree, review buckets",
Self::ToggleFold => "fold / unfold",
Self::ToggleSideBySide => "toggle side-by-side diff",
Self::Stage => "stage file or hunk",
Self::Unstage => "unstage file or hunk",
Self::StageAll => "stage everything",
Self::UnstageAll => "unstage everything",
Self::Discard => "discard changes",
Self::Refresh => "refresh",
Self::Open => "open the item under the cursor",
Self::OpenReviewDiff => "open the full working-tree diff",
Self::DiffBase => "review everything since the base branch",
Self::DiffLastCommit => "review the last commit plus uncommitted work",
Self::DiffBranch => "review everything since a branch",
Self::DiffCommit => "review everything since a commit",
Self::DiffWorkingTree => "review the working tree against HEAD",
Self::OpenRuns => "open CI runs",
Self::OpenPrs => "list open pull requests",
Self::CreatePr => "open a pull request for this branch",
Self::CommentsOverview => "comments sidebar",
Self::SubmitReview => "submit stacked PR comments as one review",
Self::CommitFlow => "commit",
Self::CommitExtend => "extend HEAD with the staged index",
Self::CommitAmend => "amend HEAD (message and content)",
Self::CommitReword => "reword the HEAD commit message",
Self::BranchCheckout => "checkout a branch",
Self::BranchCreateCheckout => "create a branch and check it out",
Self::BranchCreate => "create a branch",
Self::BranchDelete => "delete a branch",
Self::LogView => "open the commit log",
Self::Push => "push to upstream",
Self::PushSetUpstream => "push and set upstream",
Self::Pull => "pull from upstream",
Self::Fetch => "fetch",
Self::FetchAll => "fetch all remotes",
Self::StashPush => "stash the working tree",
Self::StashPop => "pop the latest stash",
Self::NextHunk => "next hunk",
Self::PrevHunk => "previous hunk",
Self::NextFunction => "next function",
Self::PrevFunction => "previous function",
Self::DeleteComment => "delete the comment under the cursor",
Self::DeleteAllComments => "delete every local comment of this review",
Self::NextComment => "next comment",
Self::PrevComment => "previous comment",
Self::HalfPageDown => "half page down",
Self::HalfPageUp => "half page up",
Self::FullPageDown => "full page down",
Self::FullPageUp => "full page up",
Self::CenterCursor => "center the cursor line",
Self::CursorTop => "cursor line to top",
Self::CursorBottom => "cursor line to bottom",
Self::ExpandContext => "expand context around the diff",
Self::CollapseContext => "collapse diff context",
Self::ExpandWholeFile => "expand to the whole file",
Self::SwitchTheme => "switch theme",
Self::Comment => "comment the diff line",
Self::VisualSelect => "select a line range",
Self::Reply => "reply to the comment",
Self::Resolve => "resolve the comment",
Self::MarkViewed => "mark the file or folder viewed",
Self::CopyFileFeedback => "copy this file's feedback as markdown",
Self::CopyAllFeedback => "copy all feedback as markdown",
Self::CopyUrl => "copy the web URL",
Self::Search => "search",
Self::SearchNext => "next search match",
Self::SearchPrev => "previous search match",
Self::OpenEditor => "open in $EDITOR",
Self::OpenFilePicker => "find a file in the repository",
Self::Blame => "blame this file",
Self::ToggleBlame => "toggle the blame column",
Self::SendFeedback => "send feedback to waiting agents",
Self::Help => "help",
Self::Palette => "command palette",
Self::Quit => "quit",
Self::Back => "back",
}
}
pub(crate) const ALL: [Self; 90] = [
Self::CenterCursor,
Self::CursorTop,
Self::CursorBottom,
Self::ExpandContext,
Self::CollapseContext,
Self::ExpandWholeFile,
Self::SwitchTheme,
Self::NextFunction,
Self::PrevFunction,
Self::DeleteComment,
Self::DeleteAllComments,
Self::OpenPrs,
Self::CreatePr,
Self::CommentsOverview,
Self::SubmitReview,
Self::MoveDown,
Self::MoveUp,
Self::MoveLeft,
Self::MoveRight,
Self::GoTop,
Self::GoBottom,
Self::ZoomIn,
Self::ZoomOut,
Self::NextSection,
Self::PrevSection,
Self::NextFile,
Self::PrevFile,
Self::NextUnviewed,
Self::UnviewAll,
Self::CycleSidebarMode,
Self::ToggleFold,
Self::ToggleSideBySide,
Self::Stage,
Self::Unstage,
Self::StageAll,
Self::UnstageAll,
Self::Discard,
Self::Refresh,
Self::Open,
Self::OpenReviewDiff,
Self::DiffBase,
Self::DiffLastCommit,
Self::DiffBranch,
Self::DiffCommit,
Self::DiffWorkingTree,
Self::OpenRuns,
Self::CommitFlow,
Self::CommitExtend,
Self::CommitAmend,
Self::CommitReword,
Self::BranchCheckout,
Self::BranchCreateCheckout,
Self::BranchCreate,
Self::BranchDelete,
Self::LogView,
Self::Push,
Self::PushSetUpstream,
Self::Pull,
Self::Fetch,
Self::FetchAll,
Self::StashPush,
Self::StashPop,
Self::NextHunk,
Self::PrevHunk,
Self::NextComment,
Self::PrevComment,
Self::HalfPageDown,
Self::HalfPageUp,
Self::FullPageDown,
Self::FullPageUp,
Self::Comment,
Self::VisualSelect,
Self::Reply,
Self::Resolve,
Self::MarkViewed,
Self::CopyFileFeedback,
Self::CopyAllFeedback,
Self::CopyUrl,
Self::Search,
Self::SearchNext,
Self::SearchPrev,
Self::OpenEditor,
Self::OpenFilePicker,
Self::Blame,
Self::ToggleBlame,
Self::SendFeedback,
Self::Help,
Self::Palette,
Self::Quit,
Self::Back,
];
fn from_name(name: &str) -> Option<Self> {
Self::ALL.into_iter().find(|a| a.name() == name)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Context {
Status,
Diff,
Log,
CiLog,
Graph,
Prs,
File,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Resolved {
Action(Action),
Transient(TransientKind),
Pending,
Unbound,
}
#[derive(Debug, Clone)]
pub struct Keymap {
bindings: Vec<(Chord, Action)>,
prefixes: Vec<(KeyPress, TransientKind)>,
}
const STATUS_DEFAULTS: &[(&str, Action)] = &[
("j", Action::MoveDown),
("k", Action::MoveUp),
("gg", Action::GoTop),
("G", Action::GoBottom),
("<c-d>", Action::HalfPageDown),
("<c-u>", Action::HalfPageUp),
("v", Action::MarkViewed),
("<c-n>", Action::NextSection),
("<c-p>", Action::PrevSection),
("<tab>", Action::ToggleFold),
("s", Action::Stage),
("u", Action::Unstage),
("S", Action::StageAll),
("U", Action::UnstageAll),
("x", Action::Discard),
("<c-r>", Action::Refresh),
("<cr>", Action::Open),
("D", Action::OpenReviewDiff),
("T", Action::SwitchTheme),
("o", Action::OpenRuns),
("[", Action::PrevSection),
("]", Action::NextSection),
("e", Action::OpenEditor),
("<c-t>", Action::OpenFilePicker),
("B", Action::Blame),
("y", Action::CopyUrl),
("Z", Action::SendFeedback),
("/", Action::Search),
("n", Action::SearchNext),
("N", Action::SearchPrev),
("<c-k>", Action::Palette),
("?", Action::Help),
("q", Action::Back),
];
const STATUS_PREFIXES: &[(&str, TransientKind)] = &[
("c", TransientKind::Commit),
("b", TransientKind::Branch),
("d", TransientKind::Diff),
("l", TransientKind::Log),
("P", TransientKind::Push),
("p", TransientKind::Pull),
("f", TransientKind::Fetch),
("z", TransientKind::Stash),
];
const NO_PREFIXES: &[(&str, TransientKind)] = &[];
const DIFF_DEFAULTS: &[(&str, Action)] = &[
("j", Action::MoveDown),
("k", Action::MoveUp),
("gg", Action::GoTop),
("G", Action::GoBottom),
("<c-d>", Action::HalfPageDown),
("<c-u>", Action::HalfPageUp),
("<c-f>", Action::FullPageDown),
("<c-b>", Action::FullPageUp),
("<c-n>", Action::NextFile),
("<c-p>", Action::PrevFile),
("J", Action::NextFile),
("K", Action::PrevFile),
("u", Action::NextUnviewed),
("U", Action::UnviewAll),
("t", Action::CycleSidebarMode),
("T", Action::SwitchTheme),
("h", Action::MoveLeft),
("l", Action::MoveRight),
("<left>", Action::MoveLeft),
("<right>", Action::MoveRight),
("zz", Action::CenterCursor),
("zt", Action::CursorTop),
("zb", Action::CursorBottom),
("za", Action::ToggleFold),
("<tab>", Action::ToggleFold),
("+", Action::ExpandContext),
("-", Action::CollapseContext),
("=", Action::ExpandWholeFile),
("|", Action::ToggleSideBySide),
("<cr>", Action::Open),
("<c-r>", Action::Refresh),
("[", Action::PrevHunk),
("]", Action::NextHunk),
("(", Action::PrevFunction),
(")", Action::NextFunction),
("{", Action::PrevComment),
("}", Action::NextComment),
("c", Action::Comment),
("V", Action::VisualSelect),
("r", Action::Reply),
("R", Action::Resolve),
("d", Action::DeleteComment),
("D", Action::DeleteAllComments),
("v", Action::MarkViewed),
("y", Action::CopyFileFeedback),
("Y", Action::CopyAllFeedback),
("e", Action::OpenEditor),
("<c-t>", Action::OpenFilePicker),
("B", Action::Blame),
("Z", Action::SendFeedback),
("C", Action::CommentsOverview),
("S", Action::SubmitReview),
("/", Action::Search),
("n", Action::SearchNext),
("N", Action::SearchPrev),
("<c-k>", Action::Palette),
("?", Action::Help),
("q", Action::Back),
];
const FILE_DEFAULTS: &[(&str, Action)] = &[
("j", Action::MoveDown),
("k", Action::MoveUp),
("gg", Action::GoTop),
("G", Action::GoBottom),
("<c-d>", Action::HalfPageDown),
("<c-u>", Action::HalfPageUp),
("<c-f>", Action::FullPageDown),
("<c-b>", Action::FullPageUp),
("zz", Action::CenterCursor),
("zt", Action::CursorTop),
("zb", Action::CursorBottom),
("b", Action::ToggleBlame),
("[", Action::PrevSection),
("]", Action::NextSection),
("<cr>", Action::Open),
("e", Action::OpenEditor),
("T", Action::SwitchTheme),
("<c-t>", Action::OpenFilePicker),
("/", Action::Search),
("n", Action::SearchNext),
("N", Action::SearchPrev),
("<c-k>", Action::Palette),
("?", Action::Help),
("q", Action::Back),
];
const LOG_DEFAULTS: &[(&str, Action)] = &[
("j", Action::MoveDown),
("k", Action::MoveUp),
("gg", Action::GoTop),
("G", Action::GoBottom),
("<c-d>", Action::HalfPageDown),
("<c-u>", Action::HalfPageUp),
("<c-f>", Action::FullPageDown),
("<c-b>", Action::FullPageUp),
("V", Action::VisualSelect),
("<cr>", Action::Open),
("<c-r>", Action::Refresh),
("<c-t>", Action::OpenFilePicker),
("/", Action::Search),
("n", Action::SearchNext),
("N", Action::SearchPrev),
("<c-k>", Action::Palette),
("?", Action::Help),
("q", Action::Back),
];
const CI_LOG_DEFAULTS: &[(&str, Action)] = &[
("j", Action::MoveDown),
("k", Action::MoveUp),
("gg", Action::GoTop),
("G", Action::GoBottom),
("<c-d>", Action::HalfPageDown),
("<c-u>", Action::HalfPageUp),
("<c-f>", Action::FullPageDown),
("<c-b>", Action::FullPageUp),
("<tab>", Action::ToggleFold),
("<cr>", Action::ToggleFold),
("V", Action::VisualSelect),
("y", Action::CopyFileFeedback),
("Y", Action::CopyAllFeedback),
("/", Action::Search),
("n", Action::SearchNext),
("N", Action::SearchPrev),
("<c-k>", Action::Palette),
("?", Action::Help),
("q", Action::Back),
];
const PRS_DEFAULTS: &[(&str, Action)] = &[
("j", Action::MoveDown),
("k", Action::MoveUp),
("gg", Action::GoTop),
("G", Action::GoBottom),
("<c-d>", Action::HalfPageDown),
("<c-u>", Action::HalfPageUp),
("<c-f>", Action::FullPageDown),
("<c-b>", Action::FullPageUp),
("<cr>", Action::Open),
("b", Action::BranchCheckout),
("y", Action::CopyUrl),
("<c-r>", Action::Refresh),
("/", Action::Search),
("n", Action::SearchNext),
("N", Action::SearchPrev),
("<c-k>", Action::Palette),
("?", Action::Help),
("q", Action::Back),
];
const GRAPH_DEFAULTS: &[(&str, Action)] = &[
("j", Action::MoveDown),
("k", Action::MoveUp),
("h", Action::MoveLeft),
("l", Action::MoveRight),
("gg", Action::GoTop),
("G", Action::GoBottom),
("+", Action::ZoomIn),
("=", Action::ZoomIn),
("-", Action::ZoomOut),
("_", Action::ZoomOut),
("<cr>", Action::Open),
("/", Action::Search),
("n", Action::SearchNext),
("N", Action::SearchPrev),
("<c-k>", Action::Palette),
("?", Action::Help),
("q", Action::Back),
];
impl Keymap {
pub fn for_context(context: Context, keys: &KeysConfig) -> (Self, Vec<String>) {
let (defaults, prefixes, overrides, section) = match context {
Context::Status => (STATUS_DEFAULTS, STATUS_PREFIXES, &keys.status, "status"),
Context::Diff => (DIFF_DEFAULTS, NO_PREFIXES, &keys.diff, "diff"),
Context::Log => (LOG_DEFAULTS, NO_PREFIXES, &keys.log, "log"),
Context::CiLog => (CI_LOG_DEFAULTS, NO_PREFIXES, &keys.ci_log, "ci_log"),
Context::Graph => (GRAPH_DEFAULTS, NO_PREFIXES, &keys.graph, "graph"),
Context::Prs => (PRS_DEFAULTS, NO_PREFIXES, &keys.prs, "prs"),
Context::File => (FILE_DEFAULTS, NO_PREFIXES, &keys.file, "file"),
};
let mut keymap = Self {
bindings: defaults
.iter()
.filter_map(|(chord, action)| Some((parse_chord(chord).ok()?, *action)))
.collect(),
prefixes: prefixes
.iter()
.filter_map(|(chord, kind)| Some((single_press(chord)?, *kind)))
.collect(),
};
let mut warnings = keymap.apply_overrides(overrides, section, defaults);
warnings.extend(keymap.apply_prefix_overrides(overrides, section));
warnings.extend(keymap.enforce_leaf_prefix(section));
(keymap, warnings)
}
fn apply_overrides(
&mut self,
overrides: &BTreeMap<String, String>,
section: &str,
defaults: &[(&str, Action)],
) -> Vec<String> {
let mut warnings = Vec::new();
for (name, chord_str) in overrides {
if self.prefixes.iter().any(|(_, kind)| kind.name() == name) {
continue;
}
let Some(action) = Action::from_name(name) else {
warnings.push(format!("unknown action `{name}` in [keys.{section}]"));
continue;
};
let chord = match parse_chord(chord_str) {
Ok(chord) => chord,
Err(err) => {
warnings.push(format!("invalid chord for keys.{section}.{name}: {err}"));
continue;
}
};
self.bindings.retain(|(c, a)| *a != action && *c != chord);
self.bindings.push((chord, action));
}
for (chord, action) in &self.bindings {
for (other, other_action) in &self.bindings {
if other.len() > chord.len() && other.starts_with(chord) {
warnings.push(format!(
"binding {} for {} shadows chord {} ({}) in [keys.{section}]",
render_chord(chord),
action.name(),
render_chord(other),
other_action.name(),
));
}
}
}
let new_bindings_that_clash: Vec<(Chord, Action)> = self
.bindings
.iter()
.filter(|(chord, _)| {
chord.len() > 1
&& chord.first().is_some_and(|first| {
self.prefixes
.iter()
.any(|(prefix_key, _)| first == prefix_key)
})
})
.cloned()
.collect();
for (chord, action) in new_bindings_that_clash {
let prefix_kind = chord.first().and_then(|first| {
self.prefixes
.iter()
.find(|(k, _)| k == first)
.map(|(_, kind)| *kind)
});
if let Some(kind) = prefix_kind {
warnings.push(format!(
"binding {} for {} is shadowed by {} prefix in [keys.{section}]; using default",
render_chord(&chord),
action.name(),
kind.name(),
));
self.bindings.retain(|(c, _)| *c != chord);
if let Some(def_chord) = defaults
.iter()
.find(|(_, a)| *a == action)
.and_then(|(s, _)| parse_chord(s).ok())
{
if !self.bindings.iter().any(|(c, _)| *c == def_chord) {
self.bindings.push((def_chord, action));
}
}
}
}
warnings
}
fn apply_prefix_overrides(
&mut self,
overrides: &BTreeMap<String, String>,
section: &str,
) -> Vec<String> {
let mut warnings = Vec::new();
for (key, kind) in &mut self.prefixes {
let Some(chord_str) = overrides.get(kind.name()) else {
continue;
};
match single_press(chord_str) {
Some(new_key) => *key = new_key,
None => warnings.push(format!(
"[keys.{section}] {}: prefix chord {chord_str:?} must be a single key; using default",
kind.name()
)),
}
}
warnings
}
fn enforce_leaf_prefix(&mut self, section: &str) -> Vec<String> {
let mut warnings = Vec::new();
let leaf_singletons: Vec<(KeyPress, Action)> = self
.bindings
.iter()
.filter_map(|(chord, action)| match chord.as_slice() {
[key] => Some((key.clone(), *action)),
_ => None,
})
.collect();
let chord_firsts: Vec<(KeyPress, Action)> = self
.bindings
.iter()
.filter_map(|(chord, action)| {
if chord.len() > 1 {
chord.first().map(|k| (k.clone(), *action))
} else {
None
}
})
.collect();
let mut kept: Vec<(KeyPress, TransientKind)> = Vec::new();
for (key, kind) in std::mem::take(&mut self.prefixes) {
if let Some((_, action)) = leaf_singletons.iter().find(|(k, _)| *k == key) {
warnings.push(format!(
"[keys.{section}] {} prefix on {} clashes with leaf {}; prefix dropped",
kind.name(),
render_chord(std::slice::from_ref(&key)),
action.name(),
));
continue;
}
if let Some((_, action)) = chord_firsts.iter().find(|(k, _)| *k == key) {
let full_chord = self
.bindings
.iter()
.find(|(_, a)| *a == *action)
.map_or(String::new(), |(c, _)| render_chord(c));
warnings.push(format!(
"[keys.{section}] {} prefix on {} shadows chord {} ({}); prefix dropped",
kind.name(),
render_chord(std::slice::from_ref(&key)),
full_chord,
action.name(),
));
continue;
}
if let Some((_, other)) = kept.iter().find(|(k, _)| *k == key) {
warnings.push(format!(
"[keys.{section}] {} prefix on {} clashes with {} prefix; dropped",
kind.name(),
render_chord(std::slice::from_ref(&key)),
other.name(),
));
continue;
}
kept.push((key, kind));
}
self.prefixes = kept;
warnings
}
pub fn prefix_for(&self, press: &KeyPress) -> Option<TransientKind> {
self.prefixes
.iter()
.find(|(key, _)| key == press)
.map(|(_, kind)| *kind)
}
pub fn prefix_chord(&self, kind: TransientKind) -> Option<String> {
self.prefixes
.iter()
.find(|(_, k)| *k == kind)
.map(|(key, _)| render_chord(std::slice::from_ref(key)))
}
pub fn resolve(&self, pending: &mut Vec<KeyPress>, press: KeyPress) -> Resolved {
if pending.is_empty()
&& let Some(kind) = self.prefix_for(&press)
{
return Resolved::Transient(kind);
}
pending.push(press.clone());
match self.lookup(pending) {
Lookup::Exact(action) => {
pending.clear();
Resolved::Action(action)
}
Lookup::Prefix => Resolved::Pending,
Lookup::None if pending.len() > 1 => {
pending.clear();
match self.lookup(std::slice::from_ref(&press)) {
Lookup::Exact(action) => Resolved::Action(action),
Lookup::Prefix => {
pending.push(press);
Resolved::Pending
}
Lookup::None => Resolved::Unbound,
}
}
Lookup::None => {
pending.clear();
Resolved::Unbound
}
}
}
pub fn bindings(&self) -> &[(Chord, Action)] {
&self.bindings
}
pub fn chord_for(&self, action: Action) -> Option<String> {
self.bindings
.iter()
.find(|(_, a)| *a == action)
.map(|(chord, _)| render_chord(chord))
}
fn lookup(&self, seq: &[KeyPress]) -> Lookup {
if let Some((_, action)) = self.bindings.iter().find(|(chord, _)| chord == seq) {
return Lookup::Exact(*action);
}
let is_prefix = self
.bindings
.iter()
.any(|(chord, _)| chord.len() > seq.len() && chord.starts_with(seq));
if is_prefix {
Lookup::Prefix
} else {
Lookup::None
}
}
}
enum Lookup {
Exact(Action),
Prefix,
None,
}
pub fn render_chord(chord: &[KeyPress]) -> String {
chord.iter().map(render_press).collect()
}
fn render_press(press: &KeyPress) -> String {
let key = match press.code {
KeyCode::Char(' ') => "space".to_owned(),
KeyCode::Char(c) => c.to_string(),
KeyCode::Enter => "cr".to_owned(),
KeyCode::Tab => "tab".to_owned(),
KeyCode::Esc => "esc".to_owned(),
other => format!("{other:?}").to_ascii_lowercase(),
};
let mut mods = String::new();
if press.ctrl {
mods.push_str("c-");
}
if press.alt {
mods.push_str("a-");
}
if press.shift && !matches!(press.code, KeyCode::Char(c) if c.is_alphabetic()) {
mods.push_str("s-");
}
if mods.is_empty() && matches!(press.code, KeyCode::Char(c) if c != ' ') {
key
} else {
format!("<{mods}{key}>")
}
}
pub fn press_from_event(event: &KeyEvent) -> KeyPress {
let mods = event.modifiers;
let (code, shift) = match event.code {
KeyCode::Char(c) if c.is_alphabetic() => (KeyCode::Char(c), c.is_uppercase()),
KeyCode::Char(c) => (KeyCode::Char(c), false),
KeyCode::BackTab => (KeyCode::Tab, true),
code => (code, mods.contains(KeyModifiers::SHIFT)),
};
KeyPress {
code,
ctrl: mods.contains(KeyModifiers::CONTROL),
alt: mods.contains(KeyModifiers::ALT),
shift,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn keymap(context: Context) -> Keymap {
let (keymap, warnings) = Keymap::for_context(context, &KeysConfig::default());
assert!(warnings.is_empty(), "default keymap warned: {warnings:?}");
keymap
}
fn press(chord: &str) -> KeyPress {
let mut presses = parse_chord(chord).expect("valid chord");
assert_eq!(presses.len(), 1, "single press expected");
presses.remove(0)
}
#[test]
fn all_defaults_parse() {
for (defaults, context) in [
(STATUS_DEFAULTS, Context::Status),
(DIFF_DEFAULTS, Context::Diff),
(LOG_DEFAULTS, Context::Log),
(CI_LOG_DEFAULTS, Context::CiLog),
(GRAPH_DEFAULTS, Context::Graph),
(PRS_DEFAULTS, Context::Prs),
(FILE_DEFAULTS, Context::File),
] {
let (keymap, _) = Keymap::for_context(context, &KeysConfig::default());
assert_eq!(keymap.bindings.len(), defaults.len(), "{context:?}");
}
}
#[test]
fn defaults_resolve() {
let keymap = keymap(Context::Status);
let mut pending = Vec::new();
assert_eq!(
keymap.resolve(&mut pending, press("j")),
Resolved::Action(Action::MoveDown)
);
assert_eq!(
keymap.resolve(&mut pending, press("<c-r>")),
Resolved::Action(Action::Refresh)
);
assert_eq!(
keymap.resolve(&mut pending, press("q")),
Resolved::Action(Action::Back)
);
assert_eq!(
keymap.resolve(&mut pending, press("?")),
Resolved::Action(Action::Help)
);
assert_eq!(
keymap.resolve(&mut pending, press("D")),
Resolved::Action(Action::OpenReviewDiff)
);
assert!(pending.is_empty());
}
#[test]
fn chord_for_renders_defaults_and_overrides() {
let keymap = keymap(Context::Status);
assert_eq!(
keymap.chord_for(Action::ToggleFold).as_deref(),
Some("<tab>")
);
assert_eq!(keymap.chord_for(Action::CommitFlow), None);
assert_eq!(keymap.chord_for(Action::Comment), None, "unbound in status");
let mut keys = KeysConfig::default();
keys.status.insert("stage".to_owned(), "<c-s>".to_owned());
let (keymap, _) = Keymap::for_context(Context::Status, &keys);
assert_eq!(keymap.chord_for(Action::Stage).as_deref(), Some("<c-s>"));
}
#[test]
fn status_prefixes_open_transients() {
let keymap = keymap(Context::Status);
let mut pending = Vec::new();
assert_eq!(
keymap.resolve(&mut pending, press("c")),
Resolved::Transient(TransientKind::Commit)
);
assert_eq!(
keymap.resolve(&mut pending, press("b")),
Resolved::Transient(TransientKind::Branch)
);
assert_eq!(
keymap.resolve(&mut pending, press("l")),
Resolved::Transient(TransientKind::Log)
);
assert_eq!(
keymap.resolve(&mut pending, press("P")),
Resolved::Transient(TransientKind::Push)
);
assert_eq!(
keymap.resolve(&mut pending, press("p")),
Resolved::Transient(TransientKind::Pull)
);
assert_eq!(
keymap.resolve(&mut pending, press("f")),
Resolved::Transient(TransientKind::Fetch)
);
assert_eq!(
keymap.resolve(&mut pending, press("z")),
Resolved::Transient(TransientKind::Stash)
);
assert!(pending.is_empty(), "a prefix never leaves a pending buffer");
assert_eq!(
keymap.prefix_chord(TransientKind::Commit).as_deref(),
Some("c")
);
assert_eq!(
keymap.prefix_chord(TransientKind::Branch).as_deref(),
Some("b")
);
assert_eq!(
keymap.prefix_chord(TransientKind::Push).as_deref(),
Some("P")
);
}
#[test]
fn a_prefix_key_is_not_a_leaf() {
let keymap = keymap(Context::Status);
assert!(keymap.bindings.iter().all(|(chord, _)| {
!matches!(chord.as_slice(), [k] if matches!(k.code, KeyCode::Char('c' | 'b' | 'l')) && !k.ctrl && !k.alt)
}));
}
#[test]
fn prefix_override_remaps_the_transient_key() {
let mut keys = KeysConfig::default();
keys.status.insert("commit".to_owned(), "<c-c>".to_owned());
let (keymap, warnings) = Keymap::for_context(Context::Status, &keys);
assert!(warnings.is_empty(), "{warnings:?}");
let mut pending = Vec::new();
assert_eq!(
keymap.resolve(&mut pending, press("<c-c>")),
Resolved::Transient(TransientKind::Commit)
);
assert_eq!(keymap.prefix_for(&press("c")), None);
}
#[test]
fn a_prefix_clashing_with_a_leaf_is_dropped_with_a_warning() {
let mut keys = KeysConfig::default();
keys.status.insert("commit".to_owned(), "s".to_owned());
let (keymap, warnings) = Keymap::for_context(Context::Status, &keys);
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert!(warnings[0].contains("commit"), "{warnings:?}");
assert!(warnings[0].contains("stage"), "{warnings:?}");
let mut pending = Vec::new();
assert_eq!(
keymap.resolve(&mut pending, press("s")),
Resolved::Action(Action::Stage)
);
assert_eq!(keymap.prefix_for(&press("s")), None);
}
#[test]
fn diff_context_binds_review_keys() {
let keymap = keymap(Context::Diff);
let mut pending = Vec::new();
assert_eq!(
keymap.resolve(&mut pending, press("c")),
Resolved::Action(Action::Comment)
);
assert_eq!(
keymap.resolve(&mut pending, press("V")),
Resolved::Action(Action::VisualSelect)
);
assert_eq!(
keymap.resolve(&mut pending, press("Y")),
Resolved::Action(Action::CopyAllFeedback)
);
}
#[test]
fn config_override_wins() {
let mut keys = KeysConfig::default();
keys.status.insert("refresh".to_owned(), "R".to_owned());
let (keymap, warnings) = Keymap::for_context(Context::Status, &keys);
assert!(warnings.is_empty());
let mut pending = Vec::new();
assert_eq!(
keymap.resolve(&mut pending, press("R")),
Resolved::Action(Action::Refresh)
);
assert_eq!(
keymap.resolve(&mut pending, press("<c-r>")),
Resolved::Unbound
);
}
#[test]
fn override_steals_key_from_other_action() {
let mut keys = KeysConfig::default();
keys.status.insert("stage".to_owned(), "j".to_owned());
let (keymap, _) = Keymap::for_context(Context::Status, &keys);
let mut pending = Vec::new();
assert_eq!(
keymap.resolve(&mut pending, press("j")),
Resolved::Action(Action::Stage)
);
}
#[test]
fn unknown_action_name_warns() {
let mut keys = KeysConfig::default();
keys.status.insert("warp_speed".to_owned(), "w".to_owned());
let (_, warnings) = Keymap::for_context(Context::Status, &keys);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("warp_speed"));
assert!(warnings[0].contains("keys.status"));
}
#[test]
fn override_creating_a_prefix_collision_warns() {
let mut keys = KeysConfig::default();
keys.status.insert("stage".to_owned(), "g".to_owned());
let (keymap, warnings) = Keymap::for_context(Context::Status, &keys);
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert!(
warnings[0].contains("binding g for stage shadows chord gg (go_top)"),
"{warnings:?}"
);
assert!(warnings[0].contains("[keys.status]"), "{warnings:?}");
let mut pending = Vec::new();
assert_eq!(
keymap.resolve(&mut pending, press("g")),
Resolved::Action(Action::Stage)
);
}
#[test]
fn overriding_to_a_longer_chord_warns_about_the_shadowing_default() {
let mut keys = KeysConfig::default();
keys.diff.insert("resolve".to_owned(), "cV".to_owned());
let (_, warnings) = Keymap::for_context(Context::Diff, &keys);
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert!(
warnings[0].contains("binding c for comment shadows chord cV (resolve)"),
"{warnings:?}"
);
}
#[test]
fn non_colliding_overrides_stay_silent() {
let mut keys = KeysConfig::default();
keys.status.insert("stage".to_owned(), "<c-s>".to_owned());
let (_, warnings) = Keymap::for_context(Context::Status, &keys);
assert!(warnings.is_empty(), "{warnings:?}");
}
#[test]
fn two_key_sequence_resolves() {
let keymap = keymap(Context::Status);
let mut pending = Vec::new();
assert_eq!(keymap.resolve(&mut pending, press("g")), Resolved::Pending);
assert_eq!(pending.len(), 1);
assert_eq!(
keymap.resolve(&mut pending, press("g")),
Resolved::Action(Action::GoTop)
);
assert!(pending.is_empty());
}
#[test]
fn dangling_prefix_stays_pending() {
let keymap = keymap(Context::Status);
let mut pending = Vec::new();
assert_eq!(keymap.resolve(&mut pending, press("g")), Resolved::Pending);
assert_eq!(pending.len(), 1);
assert_eq!(
keymap.resolve(&mut pending, press("g")),
Resolved::Action(Action::GoTop)
);
}
#[test]
fn unknown_key_clears_pending() {
let keymap = keymap(Context::Status);
let mut pending = Vec::new();
assert_eq!(keymap.resolve(&mut pending, press("g")), Resolved::Pending);
assert_eq!(keymap.resolve(&mut pending, press("z")), Resolved::Unbound);
assert!(pending.is_empty());
}
#[test]
fn dead_sequence_retries_new_key_alone() {
let keymap = keymap(Context::Status);
let mut pending = Vec::new();
assert_eq!(keymap.resolve(&mut pending, press("g")), Resolved::Pending);
assert_eq!(
keymap.resolve(&mut pending, press("j")),
Resolved::Action(Action::MoveDown)
);
assert!(pending.is_empty());
}
#[test]
fn shifted_symbol_event_matches_unshifted_chord() {
let event = KeyEvent::new(KeyCode::Char('{'), KeyModifiers::SHIFT);
assert_eq!(press_from_event(&event), press("{"));
}
#[test]
fn uppercase_letter_event_carries_shift() {
let event = KeyEvent::new(KeyCode::Char('Z'), KeyModifiers::SHIFT);
assert_eq!(press_from_event(&event), press("Z"));
}
#[test]
fn ctrl_event_matches_ctrl_chord() {
let event = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL);
assert_eq!(press_from_event(&event), press("<c-r>"));
}
#[test]
fn prefix_override_onto_first_key_of_chord_warns_and_falls_back() {
let mut keys = KeysConfig::default();
keys.status.insert("commit".to_owned(), "g".to_owned());
let (keymap, warnings) = Keymap::for_context(Context::Status, &keys);
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert!(warnings[0].contains("commit"), "{warnings:?}");
assert!(warnings[0].contains("gg"), "{warnings:?}");
assert!(warnings[0].contains("go_top"), "{warnings:?}");
assert_eq!(keymap.prefix_for(&press("g")), None);
let mut pending = Vec::new();
assert_eq!(keymap.resolve(&mut pending, press("g")), Resolved::Pending);
assert_eq!(
keymap.resolve(&mut pending, press("g")),
Resolved::Action(Action::GoTop)
);
}
#[test]
fn leaf_override_onto_chord_starting_with_live_prefix_warns_and_falls_back() {
let mut keys = KeysConfig::default();
keys.status.insert("discard".to_owned(), "cx".to_owned());
let (keymap, warnings) = Keymap::for_context(Context::Status, &keys);
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert!(warnings[0].contains("cx"), "{warnings:?}");
assert!(warnings[0].contains("discard"), "{warnings:?}");
assert!(warnings[0].contains("commit"), "{warnings:?}");
let mut pending = Vec::new();
assert_eq!(
keymap.resolve(&mut pending, press("x")),
Resolved::Action(Action::Discard)
);
assert_eq!(
keymap.resolve(&mut pending, press("c")),
Resolved::Transient(TransientKind::Commit)
);
}
#[test]
fn defaults_remain_conflict_free_under_stricter_check() {
for context in [
Context::Status,
Context::Diff,
Context::Log,
Context::CiLog,
Context::Graph,
Context::Prs,
] {
let (_, warnings) = Keymap::for_context(context, &KeysConfig::default());
assert!(
warnings.is_empty(),
"default {context:?} keymap emitted unexpected warnings: {warnings:?}"
);
}
}
}