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 <cursoragent@cursor.com>
This commit is contained in:
wuyangfan 2026-05-17 17:41:45 +08:00
parent 8619c07f3f
commit d7dddc7b64
4 changed files with 158 additions and 6 deletions

View file

@ -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,

View file

@ -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<GituiKeyEvent> for KeyId {
fn from(key: GituiKeyEvent) -> Self {
Self {
code: key.code,
modifiers: key.modifiers,
}
}
}
///
pub struct KeyRepeatGuard {
last: HashMap<KeyId, Instant>,
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));
}
}

View file

@ -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;

View file

@ -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,