use super::{Button, CloneWizard, Focus, Screen};
use crate::action::Action;
use crate::components::list_view::{Boundary, FilterOutcome, ListMovement, ScrollMovement};
use crate::keymap::{ListCommand, ListContext, list_command};
use ratatui::crossterm::event::KeyEvent;
impl CloneWizard {
pub(super) fn key_context(&self) -> ListContext {
match (self.focus, self.screen) {
(Focus::Buttons, _) => ListContext::CloneButtons,
(_, Screen::Repos) => ListContext::CloneRepos,
(_, Screen::Destination) => ListContext::CloneDestination,
(_, Screen::Summary) => ListContext::CloneSummary,
}
}
pub fn handle_key(&mut self, key: KeyEvent) -> Action {
if self.filter.active() {
if self.filter.handle_key(key) != FilterOutcome::Unchanged {
self.selection.reset();
self.destination_selection.reset();
self.viewport.reset();
}
return Action::Noop;
}
let command = list_command(self.key_context(), key);
if command == Some(ListCommand::Filter) {
self.filter.begin();
return Action::Noop;
}
if command == Some(ListCommand::Cancel) {
if self.filter.clear() {
self.selection.reset();
self.destination_selection.reset();
self.viewport.reset();
return Action::Noop;
}
return Action::ClosePopup;
}
match self.focus {
Focus::Buttons => match command {
Some(ListCommand::Back) => self.button = Button::Back,
Some(ListCommand::Forward) => self.button = Button::Next,
Some(ListCommand::Focus) => self.focus = Focus::List,
Some(ListCommand::Accept)
if self.screen == Screen::Summary && self.button == Button::Next =>
{
return Action::RunClone {
repos: self
.checked()
.map(|repository| repository.name.clone())
.collect(),
dest: self.dest.clone(),
};
}
Some(ListCommand::Accept) => self.go(self.button == Button::Next),
_ => {}
},
Focus::List => match command {
Some(ListCommand::Next) => self.move_list(ListMovement::Next),
Some(ListCommand::Previous) => self.move_list(ListMovement::Previous),
Some(ListCommand::First) => self.move_list(ListMovement::First),
Some(ListCommand::Last) => self.move_list(ListMovement::Last),
Some(ListCommand::Focus) => self.focus = Focus::Buttons,
Some(ListCommand::Toggle) => {
if let Some(&source) = self.visible_repos().get(self.selection.selected().get())
&& let Some((_, checked)) = self.repos.get_mut(source)
{
*checked = !*checked;
}
}
Some(ListCommand::Descend) => {
if let Some(&source) = self
.visible_dest()
.get(self.destination_selection.selected().get())
&& let Some(entry) = self.dest_entries.get(source)
{
let next = if entry == ".." {
self.dest.parent().map(|parent| parent.to_path_buf())
} else {
Some(self.dest.join(entry))
};
if let Some(next) = next {
self.dest = next;
self.refresh_dest();
}
}
}
Some(ListCommand::Parent) => {
if let Some(parent) = self.dest.parent().map(|parent| parent.to_path_buf()) {
self.dest = parent;
self.refresh_dest();
}
}
Some(ListCommand::Accept) => self.go(true),
_ => {}
},
}
Action::Noop
}
fn move_list(&mut self, movement: ListMovement) {
match self.screen {
Screen::Repos => {
self.selection
.advance(movement, self.visible_repos().len(), Boundary::Clamp)
}
Screen::Destination => self.destination_selection.advance(
movement,
self.visible_dest().len(),
Boundary::Clamp,
),
Screen::Summary => self.viewport.scroll(match movement {
ListMovement::Next => ScrollMovement::Down,
ListMovement::Previous => ScrollMovement::Up,
ListMovement::First => ScrollMovement::Top,
ListMovement::Last => ScrollMovement::Bottom,
}),
}
}
fn go(&mut self, forward: bool) {
self.screen = match (self.screen, forward) {
(Screen::Repos, true) => Screen::Destination,
(Screen::Destination, true) => Screen::Summary,
(Screen::Destination, false) => Screen::Repos,
(Screen::Summary, false) => Screen::Destination,
(s, _) => s, };
self.focus = Focus::List;
self.button = Button::Next;
self.viewport.reset();
self.filter.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::crossterm::event::{KeyCode, KeyEventKind, KeyEventState, KeyModifiers};
use std::path::PathBuf;
fn key(code: KeyCode) -> KeyEvent {
KeyEvent {
code,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
}
}
fn wizard() -> CloneWizard {
CloneWizard::new(
vec![
rootle_provider::RepoInfo::bare("ratatui/ratatui"),
rootle_provider::RepoInfo::bare("ratatui/comfy-table"),
],
PathBuf::from("/tmp"),
)
}
#[test]
fn repos_sort_by_pushed_at_then_name() {
let w = CloneWizard::new(
vec![
rootle_provider::RepoInfo::bare("org/zeta"),
rootle_provider::RepoInfo {
name: "org/old".into(),
pushed_at: Some("2026-01-05T09:00:00Z".into()),
..Default::default()
},
rootle_provider::RepoInfo {
name: "org/fresh".into(),
pushed_at: Some("2026-08-20T10:11:12Z".into()),
..Default::default()
},
rootle_provider::RepoInfo::bare("org/alpha"),
],
PathBuf::from("/tmp"),
);
let order: Vec<&str> = w.repos.iter().map(|(r, _)| r.name.as_str()).collect();
assert_eq!(order, ["org/fresh", "org/old", "org/alpha", "org/zeta"]);
}
#[test]
fn screens_walk_forward_and_back() {
let mut w = wizard();
assert_eq!(w.screen, Screen::Repos);
w.handle_key(key(KeyCode::Tab)); w.handle_key(key(KeyCode::Enter));
assert_eq!(w.screen, Screen::Destination);
w.handle_key(key(KeyCode::Tab));
w.handle_key(key(KeyCode::Enter));
assert_eq!(w.screen, Screen::Summary);
w.handle_key(key(KeyCode::Tab));
w.handle_key(key(KeyCode::Char('h'))); w.handle_key(key(KeyCode::Enter));
assert_eq!(w.screen, Screen::Destination);
}
#[test]
fn space_toggles_repos_and_esc_closes_anywhere() {
let mut w = wizard();
w.handle_key(key(KeyCode::Char(' ')));
assert!(!w.repos[0].1);
assert_eq!(w.checked().count(), 1);
w.handle_key(key(KeyCode::Tab));
w.handle_key(key(KeyCode::Enter)); assert_eq!(w.handle_key(key(KeyCode::Esc)), Action::ClosePopup);
}
#[test]
fn slash_filter_narrows_repos_and_destination() {
let mut w = wizard(); w.handle_key(key(KeyCode::Char('/')));
for c in "comfy".chars() {
w.handle_key(key(KeyCode::Char(c)));
}
assert_eq!(w.visible_repos().len(), 1);
w.handle_key(key(KeyCode::Enter)); assert_eq!(w.visible_repos().len(), 1);
w.handle_key(key(KeyCode::Esc)); assert_eq!(w.visible_repos().len(), 2);
assert_eq!(w.handle_key(key(KeyCode::Esc)), Action::ClosePopup);
}
}