From d7dddc7b6405d621f74d7a11e82c6aef9e446f1c Mon Sep 17 00:00:00 2001 From: wuyangfan <1102042793@qq.com> Date: Sun, 17 May 2026 17:41:45 +0800 Subject: [PATCH] feat(status): debounce arrow-key edge navigation (#2898) Add KeyRepeatGuard so panel switches at scroll edges only fire if the same arrow key was idle for 500ms, reducing accidental jumps from autorepeat. Applies to status tab focus changes and diff horizontal exit. Co-authored-by: Cursor --- src/components/diff.rs | 11 +++-- src/keys/key_repeat_guard.rs | 90 ++++++++++++++++++++++++++++++++++++ src/keys/mod.rs | 2 + src/tabs/status.rs | 61 ++++++++++++++++++++++-- 4 files changed, 158 insertions(+), 6 deletions(-) create mode 100644 src/keys/key_repeat_guard.rs diff --git a/src/components/diff.rs b/src/components/diff.rs index 04779caada..f7285ffb95 100644 --- a/src/components/diff.rs +++ b/src/components/diff.rs @@ -870,9 +870,14 @@ impl Component for DiffComponent { Ok(EventState::Consumed) } else if key_match(e, self.key_config.keys.move_left) { - self.horizontal_scroll - .move_right(HorizontalScrollType::Left); - Ok(EventState::Consumed) + if self + .horizontal_scroll + .move_right(HorizontalScrollType::Left) + { + Ok(EventState::Consumed) + } else { + Ok(EventState::NotConsumed) + } } else if key_match( e, self.key_config.keys.diff_hunk_next, diff --git a/src/keys/key_repeat_guard.rs b/src/keys/key_repeat_guard.rs new file mode 100644 index 0000000000..ff14169317 --- /dev/null +++ b/src/keys/key_repeat_guard.rs @@ -0,0 +1,90 @@ +//! Debounce arrow-key edge navigation against key autorepeat. + +use super::key_list::GituiKeyEvent; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use std::{ + collections::HashMap, + time::{Duration, Instant}, +}; + +const DEFAULT_COOLDOWN: Duration = Duration::from_millis(500); + +#[derive(Hash, Eq, PartialEq, Clone, Copy)] +struct KeyId { + code: KeyCode, + modifiers: KeyModifiers, +} + +impl From for KeyId { + fn from(key: GituiKeyEvent) -> Self { + Self { + code: key.code, + modifiers: key.modifiers, + } + } +} + +/// +pub struct KeyRepeatGuard { + last: HashMap, + cooldown: Duration, +} + +impl KeyRepeatGuard { + /// + pub fn new() -> Self { + Self { + last: HashMap::new(), + cooldown: DEFAULT_COOLDOWN, + } + } + + #[cfg(test)] + pub fn with_cooldown(cooldown: Duration) -> Self { + Self { + last: HashMap::new(), + cooldown, + } + } + + /// + pub fn record(&mut self, key: GituiKeyEvent) { + self.last.insert(key.into(), Instant::now()); + } + + /// + pub fn record_key_event(&mut self, key: &KeyEvent) { + self.record(GituiKeyEvent { + code: key.code, + modifiers: key.modifiers, + }); + } + + /// Whether edge navigation (leaving a scrollable view) should run now. + pub fn allow_edge_navigation(&self, key: GituiKeyEvent) -> bool { + self.last + .get(&key.into()) + .is_none_or(|t| Instant::now().duration_since(*t) >= self.cooldown) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::{KeyCode, KeyModifiers}; + use std::thread::sleep; + + #[test] + fn test_blocks_rapid_repeats() { + let mut guard = KeyRepeatGuard::with_cooldown(Duration::from_millis(50)); + let key = + GituiKeyEvent::new(KeyCode::Up, KeyModifiers::empty()); + + assert!(guard.allow_edge_navigation(key)); + guard.record(key); + assert!(!guard.allow_edge_navigation(key)); + + sleep(Duration::from_millis(60)); + assert!(guard.allow_edge_navigation(key)); + } +} diff --git a/src/keys/mod.rs b/src/keys/mod.rs index a770087fcf..9180dd9074 100644 --- a/src/keys/mod.rs +++ b/src/keys/mod.rs @@ -1,6 +1,8 @@ mod key_config; mod key_list; +mod key_repeat_guard; mod symbols; pub use key_config::{KeyConfig, SharedKeyConfig}; pub use key_list::key_match; +pub use key_repeat_guard::KeyRepeatGuard; diff --git a/src/tabs/status.rs b/src/tabs/status.rs index 135cf18e4e..4eca1de507 100644 --- a/src/tabs/status.rs +++ b/src/tabs/status.rs @@ -7,7 +7,7 @@ use crate::{ DiffComponent, DrawableComponent, EventState, FileTreeItemKind, }, - keys::{key_match, SharedKeyConfig}, + keys::{key_match, KeyRepeatGuard, SharedKeyConfig}, options::SharedOptions, queue::{Action, InternalEvent, NeedsUpdate, Queue, ResetItem}, strings, try_or_popup, @@ -81,6 +81,7 @@ pub struct Status { git_action_executed: bool, options: SharedOptions, key_config: SharedKeyConfig, + key_repeat_guard: KeyRepeatGuard, } impl DrawableComponent for Status { @@ -198,6 +199,7 @@ impl Status { env.repo.clone(), ), key_config: env.key_config.clone(), + key_repeat_guard: KeyRepeatGuard::new(), options: env.options.clone(), repo: env.repo.clone(), } @@ -581,6 +583,17 @@ impl Status { } } + fn record_navigation_key(&mut self, key: &crossterm::event::KeyEvent) { + let keys = &self.key_config.keys; + if key_match(key, keys.move_up) + || key_match(key, keys.move_down) + || key_match(key, keys.move_left) + || key_match(key, keys.move_right) + { + self.key_repeat_guard.record_key_event(key); + } + } + fn fetch(&self) { if self.can_fetch() { self.queue.push(InternalEvent::FetchRemotes); @@ -820,6 +833,9 @@ impl Component for Status { if event_pump(ev, self.components_mut().as_mut_slice())? .is_consumed() { + if let Event::Key(k) = ev { + self.record_navigation_key(k); + } self.git_action_executed = true; return Ok(EventState::Consumed); } @@ -845,6 +861,25 @@ impl Component for Status { ) && self.can_focus_diff() { self.switch_focus(Focus::Diff).map(Into::into) + } else if key_match( + k, + self.key_config.keys.move_left, + ) && self.is_focus_on_diff() + { + let binding = self.key_config.keys.move_left; + let allow = self + .key_repeat_guard + .allow_edge_navigation(binding); + self.key_repeat_guard.record(binding); + if allow { + return self + .switch_focus(match self.diff_target { + DiffTarget::Stage => Focus::Stage, + DiffTarget::WorkingDir => Focus::WorkDir, + }) + .map(Into::into); + } + return Ok(EventState::Consumed); } else if key_match( k, self.key_config.keys.exit_popup, @@ -858,12 +893,32 @@ impl Component for Status { && self.focus == Focus::WorkDir && !self.index.is_empty() { - self.switch_focus(Focus::Stage).map(Into::into) + let binding = self.key_config.keys.move_down; + let allow = self + .key_repeat_guard + .allow_edge_navigation(binding); + self.key_repeat_guard.record(binding); + if allow { + return self + .switch_focus(Focus::Stage) + .map(Into::into); + } + return Ok(EventState::Consumed); } else if key_match(k, self.key_config.keys.move_up) && self.focus == Focus::Stage && !self.index_wd.is_empty() { - self.switch_focus(Focus::WorkDir).map(Into::into) + let binding = self.key_config.keys.move_up; + let allow = self + .key_repeat_guard + .allow_edge_navigation(binding); + self.key_repeat_guard.record(binding); + if allow { + return self + .switch_focus(Focus::WorkDir) + .map(Into::into); + } + return Ok(EventState::Consumed); } else if key_match( k, self.key_config.keys.select_branch,