use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::app::helpers::revision::short_id;
use super::state::{App, View};
use crate::keys;
use crate::ui::views::{
BlameAction, BookmarkAction, CommandHistoryAction, DiffAction, EvologAction, InputMode,
LogAction, OperationAction, RenameState, ResolveAction, StatusAction, StatusInputMode,
TagAction,
};
impl App {
pub fn on_key_event(&mut self, key: KeyEvent) {
if let Some(ref mut dialog) = self.active_dialog {
if let Some(result) = dialog.handle_key(key) {
self.handle_dialog_result(result);
}
return;
}
self.error_message = None;
self.clear_expired_notification();
if key.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'))
{
self.quit();
return;
}
if key.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(key.code, KeyCode::Char('r') | KeyCode::Char('R'))
&& self.current_view == View::Log
&& matches!(self.log_view.input_mode, InputMode::Normal)
{
self.notification = None; self.execute_redo();
return;
}
if keys::is_refresh_key(&key) {
let in_special_mode = match self.current_view {
View::Log => !matches!(self.log_view.input_mode, InputMode::Normal),
View::Status => self.status_view.input_mode != StatusInputMode::Normal,
View::Help => self.help_search_input,
_ => false,
};
if !in_special_mode {
self.execute_refresh();
return;
}
}
if self.current_view == View::Log && !matches!(self.log_view.input_mode, InputMode::Normal)
{
let action = self.log_view.handle_key(key);
self.handle_log_action(action);
return;
}
if self.current_view == View::Status
&& self.status_view.input_mode != StatusInputMode::Normal
{
let action = self.status_view.handle_key(key);
self.handle_status_action(action);
return;
}
if self.current_view == View::Help && self.help_search_input {
self.handle_view_key(key);
return;
}
if self.handle_global_key(key) {
return;
}
self.handle_view_key(key);
}
fn handle_global_key(&mut self, key: KeyEvent) -> bool {
match key.code {
keys::QUIT => {
self.handle_quit();
true
}
keys::ESC => {
if self.current_view == View::Bookmark && self.bookmark_view.rename_state.is_some()
{
return false;
}
self.handle_back();
true
}
keys::HELP => {
self.go_to_view(View::Help);
true
}
keys::TAB => {
self.next_view();
true
}
keys::STATUS_VIEW if self.current_view == View::Log => {
self.go_to_view(View::Status);
true
}
keys::UNDO if matches!(self.current_view, View::Log | View::Bookmark) => {
self.notification = None; self.execute_undo();
true
}
keys::OPERATION_HISTORY if self.current_view == View::Log => {
self.open_operation_history();
true
}
_ => false,
}
}
fn handle_quit(&mut self) {
if self.current_view == View::Log {
self.quit();
} else {
self.go_back();
}
}
fn handle_back(&mut self) {
if self.current_view != View::Log {
self.go_back();
}
}
fn handle_view_key(&mut self, key: KeyEvent) {
match self.current_view {
View::Log => {
if key.code == keys::PREVIEW
&& matches!(self.log_view.input_mode, InputMode::Normal)
{
self.preview_enabled = !self.preview_enabled;
if self.preview_enabled {
self.update_preview_if_needed();
self.resolve_pending_preview();
} else {
self.preview_pending_id = None;
}
return;
}
let action = self.log_view.handle_key(key);
self.handle_log_action(action);
if self.preview_enabled && self.current_view == View::Log {
self.update_preview_if_needed();
}
}
View::Diff => {
if let Some(ref mut diff_view) = self.diff_view {
let visible_height = self.last_frame_height.get() as usize;
let action = diff_view.handle_key_with_height(key, visible_height);
self.handle_diff_action(action);
}
}
View::Status => {
let visible_height = self.last_frame_height.get() as usize;
let action = self.status_view.handle_key_with_height(key, visible_height);
self.handle_status_action(action);
}
View::Operation => {
let action = self.operation_view.handle_key(key);
self.handle_operation_action(action);
}
View::Blame => {
if let Some(ref mut blame_view) = self.blame_view {
let action = blame_view.handle_key(key);
self.handle_blame_action(action);
}
}
View::Bookmark => {
let action = self.bookmark_view.handle_key(key);
self.handle_bookmark_action(action);
}
View::Tag => {
let action = self.tag_view.handle_key(key);
self.handle_tag_action(action);
}
View::Resolve => {
if let Some(ref mut resolve_view) = self.resolve_view {
let action = resolve_view.handle_key(key);
self.handle_resolve_action(action);
}
}
View::Evolog => {
if let Some(ref mut evolog_view) = self.evolog_view {
let action = evolog_view.handle_key(key);
self.handle_evolog_action(action);
}
}
View::CommandHistory => {
let total = self.command_history.len();
let action = self.command_history_view.handle_key(key, total);
self.handle_command_history_action(action);
}
View::Help => {
if self.help_search_input {
match key.code {
KeyCode::Esc => {
self.help_search_input = false;
self.help_input_buffer.clear();
}
KeyCode::Enter => {
let query = std::mem::take(&mut self.help_input_buffer);
self.help_search_input = false;
if query.is_empty() {
self.help_search_query = None;
} else {
self.help_search_query = Some(query.clone());
let indices = crate::ui::widgets::matching_line_indices(&query);
if let Some(&first) = indices.first() {
self.help_scroll = first;
}
}
}
KeyCode::Char(c)
if !key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
{
self.help_input_buffer.push(c);
}
KeyCode::Backspace => {
self.help_input_buffer.pop();
}
_ => {}
}
} else {
if keys::is_move_down(key.code) {
self.help_scroll = self.help_scroll.saturating_add(1);
} else if keys::is_move_up(key.code) {
self.help_scroll = self.help_scroll.saturating_sub(1);
} else if key.code == keys::GO_BOTTOM {
self.help_scroll = u16::MAX; } else if key.code == keys::GO_TOP {
self.help_scroll = 0;
} else if key.code == keys::SEARCH_INPUT {
self.help_search_input = true;
self.help_input_buffer.clear();
} else if key.code == keys::SEARCH_NEXT {
if let Some(ref query) = self.help_search_query {
let indices = crate::ui::widgets::matching_line_indices(query);
if let Some(next) = indices.iter().find(|&&i| i > self.help_scroll) {
self.help_scroll = *next;
} else if let Some(&first) = indices.first() {
self.help_scroll = first;
}
}
} else if key.code == keys::SEARCH_PREV
&& let Some(ref query) = self.help_search_query
{
let indices = crate::ui::widgets::matching_line_indices(query);
if let Some(prev) = indices.iter().rev().find(|&&i| i < self.help_scroll) {
self.help_scroll = *prev;
} else if let Some(&last) = indices.last() {
self.help_scroll = last;
}
}
}
}
}
}
fn handle_log_action(&mut self, action: LogAction) {
match action {
LogAction::None => {}
LogAction::OpenDiff(_)
| LogAction::ExecuteRevset(_)
| LogAction::ClearRevset
| LogAction::OpenBookmarkView
| LogAction::OpenTagView
| LogAction::OpenCommandHistory
| LogAction::OpenEvolog(_)
| LogAction::OpenResolveList { .. } => {
self.handle_log_navigation(action);
}
LogAction::StartDescribe(_)
| LogAction::Describe { .. }
| LogAction::DescribeExternal(_)
| LogAction::Edit(_)
| LogAction::NewChange
| LogAction::NewChangeFrom { .. }
| LogAction::NewChangeFromCurrent
| LogAction::SquashInto { .. }
| LogAction::Abandon(_)
| LogAction::Split(_)
| LogAction::Duplicate(_)
| LogAction::DiffEdit(_)
| LogAction::Revert(_)
| LogAction::SimplifyParents(_)
| LogAction::Fix { .. } => {
self.handle_log_editing(action);
}
LogAction::CreateBookmark { .. }
| LogAction::StartBookmarkDelete
| LogAction::StartBookmarkJump => {
self.handle_log_bookmark(action);
}
LogAction::Fetch | LogAction::StartPush | LogAction::StartTrack => {
self.handle_log_git(action);
}
LogAction::Rebase { .. }
| LogAction::Absorb
| LogAction::StartParallelize(_)
| LogAction::Parallelize { .. }
| LogAction::ParallelizeSameRevision => {
self.handle_log_rebase(action);
}
LogAction::StartCompare(_)
| LogAction::Compare { .. }
| LogAction::CompareSameRevision => {
self.handle_log_compare(action);
}
LogAction::NextChange | LogAction::PrevChange | LogAction::ToggleReversed => {
self.handle_log_misc(action);
}
}
}
fn handle_log_navigation(&mut self, action: LogAction) {
match action {
LogAction::OpenDiff(change_id) => self.open_diff(&change_id),
LogAction::ExecuteRevset(revset) => self.refresh_log(Some(&revset)),
LogAction::ClearRevset => self.refresh_log(None),
LogAction::OpenBookmarkView => self.open_bookmark_view(),
LogAction::OpenTagView => self.open_tag_view(),
LogAction::OpenCommandHistory => self.go_to_view(View::CommandHistory),
LogAction::OpenEvolog(change_id) => self.open_evolog(&change_id),
LogAction::OpenResolveList {
revision,
is_working_copy,
} => self.open_resolve_view(&revision, is_working_copy),
_ => {}
}
}
fn handle_log_editing(&mut self, action: LogAction) {
use crate::ui::components::{Dialog, DialogCallback};
match action {
LogAction::StartDescribe(revision) => self.start_describe_input(&revision),
LogAction::Describe { revision, message } => {
self.execute_describe(&revision, &message);
}
LogAction::DescribeExternal(revision) => self.execute_describe_external(&revision),
LogAction::Edit(revision) => self.execute_edit(&revision),
LogAction::NewChange => self.execute_new_change(),
LogAction::NewChangeFrom {
revision,
display_name,
} => self.execute_new_change_from(&revision, &display_name),
LogAction::NewChangeFromCurrent => {
self.notify_info("Use 'c' to create from current change");
}
LogAction::SquashInto {
source,
destination,
} => self.execute_squash_into(&source, &destination),
LogAction::Abandon(revision) => self.execute_abandon(&revision),
LogAction::Split(revision) => self.execute_split(&revision),
LogAction::Duplicate(revision) => self.duplicate(&revision),
LogAction::DiffEdit(revision) => self.execute_diffedit(&revision, None),
LogAction::Revert(revision) => {
let short_id = short_id(&revision);
self.active_dialog = Some(Dialog::confirm(
"Revert Change",
format!("Revert changes from {}?", short_id),
Some(
"Creates a new commit that undoes these changes. Undo with 'u' if needed."
.to_string(),
),
DialogCallback::Revert { revision },
));
}
LogAction::SimplifyParents(revision) => {
let short_id = short_id(&revision);
self.active_dialog = Some(Dialog::confirm(
"Simplify Parents",
format!("Simplify parents for {}?", short_id),
None,
DialogCallback::SimplifyParents { revision },
));
}
LogAction::Fix {
revision,
change_id,
} => {
if self.jj.is_immutable(&revision) {
self.set_error("Cannot fix: commit is immutable");
return;
}
let short_id = short_id(&revision);
self.active_dialog = Some(Dialog::confirm(
"Fix",
format!("Apply code formatters to {} and descendants?", short_id),
None,
DialogCallback::Fix {
revision,
change_id,
},
));
}
_ => {}
}
}
fn handle_log_bookmark(&mut self, action: LogAction) {
match action {
LogAction::CreateBookmark { revision, name } => {
self.execute_bookmark_create(&revision, &name);
}
LogAction::StartBookmarkDelete => self.start_bookmark_delete(),
LogAction::StartBookmarkJump => self.start_bookmark_jump(),
_ => {}
}
}
fn handle_log_git(&mut self, action: LogAction) {
match action {
LogAction::Fetch => self.start_fetch(),
LogAction::StartPush => self.start_push(),
LogAction::StartTrack => self.start_track(),
_ => {}
}
}
fn handle_log_rebase(&mut self, action: LogAction) {
use crate::ui::components::{Dialog, DialogCallback};
match action {
LogAction::Rebase {
source,
destination,
mode,
skip_emptied,
use_revset,
simplify_parents,
} => self.execute_rebase(
&source,
&destination,
mode,
skip_emptied,
simplify_parents,
use_revset,
),
LogAction::Absorb => self.execute_absorb(),
LogAction::StartParallelize(from_id) => {
self.notify_info(format!("From: {}. Select end and press Enter", from_id));
}
LogAction::Parallelize { from, to } => {
let from_short = &from[..8.min(from.len())];
let to_short = &to[..8.min(to.len())];
self.active_dialog = Some(Dialog::confirm(
"Parallelize",
format!("Parallelize {}::{}?", from_short, to_short),
None,
DialogCallback::Parallelize { from, to },
));
}
LogAction::ParallelizeSameRevision => {
self.notify_info("Cannot parallelize single revision");
}
_ => {}
}
}
fn handle_log_compare(&mut self, action: LogAction) {
match action {
LogAction::StartCompare(from_id) => {
self.notify_info(format!("From: {}. Select 'To' and press Enter", from_id));
}
LogAction::Compare { ref from, ref to } => {
let msg = format!("Comparing {} -> {}", from, to);
self.open_compare_diff(from, to);
if self.error_message.is_none() {
self.notify_info(&msg);
}
}
LogAction::CompareSameRevision => {
self.notify_info("Cannot compare revision with itself");
}
_ => {}
}
}
fn handle_log_misc(&mut self, action: LogAction) {
match action {
LogAction::NextChange => self.execute_next(),
LogAction::PrevChange => self.execute_prev(),
LogAction::ToggleReversed => {
let selected_id = self
.log_view
.selected_change()
.map(|c| c.change_id.to_string());
self.log_view.reversed = !self.log_view.reversed;
let revset = self.log_view.current_revset.clone();
self.refresh_log(revset.as_deref());
if let Some(ref id) = selected_id
&& !self.log_view.select_change_by_id(id)
{
self.log_view.select_working_copy();
}
let label = if self.log_view.reversed {
"oldest first"
} else {
"newest first"
};
self.notify_info(format!("Log order: {}", label));
}
_ => {}
}
}
fn handle_bookmark_action(&mut self, action: BookmarkAction) {
match action {
BookmarkAction::None => {}
BookmarkAction::Jump(change_id) => {
self.execute_bookmark_jump(&change_id);
self.go_to_view(View::Log);
}
BookmarkAction::Track(full_name) => {
self.execute_track(&[full_name]);
}
BookmarkAction::Untrack(full_name) => {
self.execute_untrack(&full_name);
}
BookmarkAction::Delete(name) => {
self.execute_bookmark_delete(&[name]);
}
BookmarkAction::StartRename(old_name) => {
self.bookmark_view.rename_state = Some(RenameState::new(old_name));
}
BookmarkAction::ConfirmRename { old_name, new_name } => {
self.execute_bookmark_rename(&old_name, &new_name);
}
BookmarkAction::CancelRename => {
}
BookmarkAction::Forget(name) => {
use crate::ui::components::{Dialog, DialogCallback};
self.active_dialog = Some(Dialog::confirm(
"Forget Bookmark",
format!(
"Forget bookmark '{}'?\n\n\
This removes remote tracking.\n\
Use 'D' for local delete only.\n\
Undo with 'u' if needed.",
name
),
None,
DialogCallback::BookmarkForget,
));
self.pending_forget_bookmark = Some(name);
}
BookmarkAction::Move(name) => {
self.start_bookmark_move(&name);
}
BookmarkAction::MoveUnavailable => {
self.notify_info("Move is available only for local bookmarks");
}
}
}
fn handle_tag_action(&mut self, action: TagAction) {
match action {
TagAction::None => {}
TagAction::Jump(change_id) => {
self.jump_to_log(&change_id);
}
TagAction::StartCreate => {
use crate::ui::components::{Dialog, DialogCallback};
self.active_dialog = Some(Dialog::input(
"Create Tag",
"Create tag on @ (working copy)",
DialogCallback::TagCreate,
));
}
TagAction::Delete(name) => {
use crate::ui::components::{Dialog, DialogCallback};
self.active_dialog = Some(Dialog::confirm(
"Delete Tag",
format!("Delete tag '{}'?", name),
None,
DialogCallback::TagDelete { name: name.clone() },
));
}
}
}
fn handle_diff_action(&mut self, action: DiffAction) {
match action {
DiffAction::None => {}
DiffAction::Back => {
self.go_back();
}
DiffAction::OpenBlame { file_path } => {
let revision = self.diff_view.as_ref().map(|v| v.revision.clone());
self.open_blame(&file_path, revision.as_deref());
}
DiffAction::ShowNotification(message) => {
self.notify_info(&message);
}
DiffAction::CopyToClipboard { full } => {
self.copy_diff_to_clipboard(full);
}
DiffAction::ExportToFile => {
self.export_diff_to_file();
}
DiffAction::CycleFormat => {
self.cycle_diff_format();
}
}
}
fn handle_status_action(&mut self, action: StatusAction) {
match action {
StatusAction::None => {}
StatusAction::ShowFileDiff {
change_id,
file_path,
} => {
self.open_diff_at_file(&change_id, &file_path);
}
StatusAction::OpenBlame { file_path } => {
self.open_blame(&file_path, None);
}
StatusAction::Commit { message } => {
self.execute_commit(&message);
}
StatusAction::JumpToConflict => {
}
StatusAction::RestoreFile { file_path } => {
use crate::ui::components::{Dialog, DialogCallback};
self.active_dialog = Some(Dialog::confirm(
"Restore File",
format!(
"Restore '{}'?\nThis discards your changes to this file.",
file_path
),
Some("Undo with 'u' if needed.".to_string()),
DialogCallback::RestoreFile {
file_path: file_path.clone(),
},
));
}
StatusAction::RestoreAll => {
use crate::ui::components::{Dialog, DialogCallback};
self.active_dialog = Some(Dialog::confirm(
"Restore All Files",
"Restore all files?\nThis discards ALL your changes in the working copy.",
Some("Undo with 'u' if needed.".to_string()),
DialogCallback::RestoreAll,
));
}
StatusAction::DiffEdit { file_path } => {
self.execute_diffedit("@", Some(&file_path));
}
}
}
fn handle_operation_action(&mut self, action: OperationAction) {
match action {
OperationAction::None => {}
OperationAction::Back => {
self.go_back();
}
OperationAction::Restore(operation_id) => {
self.execute_op_restore(&operation_id);
}
}
}
fn handle_resolve_action(&mut self, action: ResolveAction) {
match action {
ResolveAction::None => {}
ResolveAction::Back => {
self.resolve_view = None;
self.dirty.log = true;
self.dirty.op_log = true;
self.go_back();
}
ResolveAction::ResolveExternal(file_path) => {
self.execute_resolve_external(&file_path);
}
ResolveAction::ResolveOurs(file_path) => {
self.execute_resolve_ours(&file_path);
}
ResolveAction::ResolveTheirs(file_path) => {
self.execute_resolve_theirs(&file_path);
}
ResolveAction::ShowDiff(file_path) => {
let revision = self
.resolve_view
.as_ref()
.map(|v| v.revision.clone())
.unwrap_or_default();
self.open_diff_at_file(&revision, &file_path);
}
}
}
fn handle_evolog_action(&mut self, action: EvologAction) {
match action {
EvologAction::None => {}
EvologAction::Back => {
self.go_back();
}
EvologAction::OpenDiff(change_id) => {
self.open_diff(&change_id);
}
}
}
fn handle_command_history_action(&mut self, action: CommandHistoryAction) {
match action {
CommandHistoryAction::None => {}
CommandHistoryAction::Back => {
self.go_back();
}
CommandHistoryAction::ToggleDetail(_) => {
}
}
}
fn handle_blame_action(&mut self, action: BlameAction) {
match action {
BlameAction::None => {}
BlameAction::Back => {
self.go_back();
}
BlameAction::OpenDiff(change_id) => {
self.open_diff(&change_id);
}
BlameAction::JumpToLog(change_id) => {
self.jump_to_log(&change_id);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::KeyEvent;
fn press(app: &mut App, code: KeyCode) {
app.on_key_event(KeyEvent::from(code));
}
fn enter_help_search(app: &mut App) {
app.current_view = View::Help;
app.help_search_input = true;
app.help_input_buffer.clear();
}
#[test]
fn help_search_esc_cancels_search_not_back() {
let mut app = App::new_for_test();
enter_help_search(&mut app);
press(&mut app, KeyCode::Esc);
assert!(!app.help_search_input);
assert_eq!(app.current_view, View::Help);
}
#[test]
fn help_search_q_types_character_not_quit() {
let mut app = App::new_for_test();
enter_help_search(&mut app);
press(&mut app, KeyCode::Char('q'));
assert_eq!(app.help_input_buffer, "q");
assert!(app.help_search_input);
assert_eq!(app.current_view, View::Help);
assert!(app.running);
}
#[test]
fn help_search_tab_stays_in_help_not_switch() {
let mut app = App::new_for_test();
enter_help_search(&mut app);
press(&mut app, KeyCode::Tab);
assert_eq!(app.current_view, View::Help);
assert!(app.help_search_input);
}
#[test]
fn help_search_question_mark_stays_in_search() {
let mut app = App::new_for_test();
enter_help_search(&mut app);
press(&mut app, KeyCode::Char('?'));
assert_eq!(app.help_input_buffer, "?");
assert!(app.help_search_input);
assert_eq!(app.current_view, View::Help);
}
#[test]
fn help_search_enter_confirms_and_exits_input() {
let mut app = App::new_for_test();
enter_help_search(&mut app);
app.help_input_buffer = "quit".to_string();
press(&mut app, KeyCode::Enter);
assert!(!app.help_search_input);
assert_eq!(app.help_search_query, Some("quit".to_string()));
assert_eq!(app.current_view, View::Help);
}
#[test]
fn help_search_typing_multiple_chars() {
let mut app = App::new_for_test();
enter_help_search(&mut app);
press(&mut app, KeyCode::Char('h'));
press(&mut app, KeyCode::Char('e'));
press(&mut app, KeyCode::Char('l'));
press(&mut app, KeyCode::Char('p'));
assert_eq!(app.help_input_buffer, "help");
assert!(app.help_search_input);
}
#[test]
fn help_search_backspace_removes_char() {
let mut app = App::new_for_test();
enter_help_search(&mut app);
app.help_input_buffer = "test".to_string();
press(&mut app, KeyCode::Backspace);
assert_eq!(app.help_input_buffer, "tes");
assert!(app.help_search_input);
}
#[test]
fn help_search_ctrl_l_suppressed() {
let mut app = App::new_for_test();
enter_help_search(&mut app);
app.on_key_event(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL));
assert!(app.help_search_input);
assert_eq!(app.current_view, View::Help);
}
#[test]
fn help_normal_mode_esc_goes_back() {
let mut app = App::new_for_test();
app.go_to_view(View::Help); assert!(!app.help_search_input);
press(&mut app, KeyCode::Esc);
assert_ne!(app.current_view, View::Help);
}
#[test]
fn help_normal_mode_q_goes_back() {
let mut app = App::new_for_test();
app.go_to_view(View::Help);
assert!(!app.help_search_input);
press(&mut app, KeyCode::Char('q'));
assert_ne!(app.current_view, View::Help);
}
}