diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-10 02:26:25 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-10 02:27:41 +0300 |
| commit | 77fcde0a2736c3927d1bc589bd9b4d0173a9df46 (patch) | |
| tree | 209c704342fb52a7ecbb5fe0cdbc291f7650e720 /src/app/input | |
| parent | 0facd819b4747c7634040b561509137c2608b024 (diff) | |
fix: support paste in modal inputs
refs #302
Diffstat (limited to 'src/app/input')
| -rw-r--r-- | src/app/input/mod.rs | 231 | ||||
| -rw-r--r-- | src/app/input/modal.rs | 72 |
2 files changed, 266 insertions, 37 deletions
diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs index 1fc7b0f9..1b5dfefe 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -57,6 +57,7 @@ pub(crate) use self::{ modal::{ handle_confirm_close_key, handle_context_menu_key, handle_global_menu_key, handle_keybind_help_key, handle_navigator_key, handle_rename_key, handle_resize_key, + insert_navigator_search_text, insert_rename_input_text, }, navigate::terminal_direct_navigation_action, settings::open_settings_at, @@ -76,49 +77,56 @@ use super::App; impl App { pub(super) async fn handle_key(&mut self, key: TerminalKey) { + let key_event = key.as_key_event(); + if modal_paste_target_active(&self.state) && is_modal_paste_shortcut(&key_event) { + if let Some(text) = crate::platform::read_clipboard_text() { + self.paste_into_active_text_input(&text); + } + return; + } + match self.state.mode { Mode::Terminal => self.handle_terminal_key(key).await, Mode::Prefix => self.handle_prefix_key(key), Mode::Navigate => self.handle_navigate_key(key), Mode::Copy => self.handle_copy_mode_key(key), - _ => { - let key_event = key.as_key_event(); - match self.state.mode { - Mode::Onboarding => self.handle_onboarding_key(key_event), - Mode::ReleaseNotes => self.handle_release_notes_key(key_event), - Mode::ProductAnnouncement => self.handle_product_announcement_key(key_event), - Mode::Prefix | Mode::Navigate | Mode::Copy => unreachable!(), - Mode::RenameWorkspace | Mode::RenameTab | Mode::RenamePane => { - handle_rename_key(&mut self.state, key_event) - } - Mode::NewLinkedWorktree => self.handle_worktree_create_key(key_event), - Mode::OpenExistingWorktree => self.handle_worktree_open_key(key_event), - Mode::ConfirmRemoveWorktree => self.handle_worktree_remove_key(key_event), - Mode::Resize => handle_resize_key(&mut self.state, key), - Mode::ConfirmClose => handle_confirm_close_key(&mut self.state, key_event), - Mode::ContextMenu => { - handle_context_menu_key( - &mut self.state, - &mut self.terminal_runtimes, - key_event, - ); - } - Mode::Settings => self.handle_settings_key(key_event), - Mode::GlobalMenu => handle_global_menu_key(&mut self.state, key_event), - Mode::KeybindHelp => handle_keybind_help_key(&mut self.state, key_event), - Mode::Navigator => { - handle_navigator_key(&mut self.state, &self.terminal_runtimes, key_event) - } - Mode::Terminal => unreachable!(), + _ => match self.state.mode { + Mode::Onboarding => self.handle_onboarding_key(key_event), + Mode::ReleaseNotes => self.handle_release_notes_key(key_event), + Mode::ProductAnnouncement => self.handle_product_announcement_key(key_event), + Mode::Prefix | Mode::Navigate | Mode::Copy => unreachable!(), + Mode::RenameWorkspace | Mode::RenameTab | Mode::RenamePane => { + handle_rename_key(&mut self.state, key_event) } - } + Mode::NewLinkedWorktree => self.handle_worktree_create_key(key_event), + Mode::OpenExistingWorktree => self.handle_worktree_open_key(key_event), + Mode::ConfirmRemoveWorktree => self.handle_worktree_remove_key(key_event), + Mode::Resize => handle_resize_key(&mut self.state, key), + Mode::ConfirmClose => handle_confirm_close_key(&mut self.state, key_event), + Mode::ContextMenu => { + handle_context_menu_key( + &mut self.state, + &mut self.terminal_runtimes, + key_event, + ); + } + Mode::Settings => self.handle_settings_key(key_event), + Mode::GlobalMenu => handle_global_menu_key(&mut self.state, key_event), + Mode::KeybindHelp => handle_keybind_help_key(&mut self.state, key_event), + Mode::Navigator => { + handle_navigator_key(&mut self.state, &self.terminal_runtimes, key_event) + } + Mode::Terminal => unreachable!(), + }, } } pub(super) async fn handle_paste(&mut self, text: String) { if self.state.mode != Mode::Terminal { + self.paste_into_active_text_input(&text); return; } + if let Some(ws_idx) = self.state.active { if let Some(rt) = self .state @@ -129,6 +137,39 @@ impl App { } } + pub(crate) fn paste_into_active_text_input(&mut self, text: &str) -> bool { + match self.state.mode { + Mode::RenameWorkspace | Mode::RenameTab | Mode::RenamePane => { + insert_rename_input_text(&mut self.state, text); + true + } + Mode::NewLinkedWorktree => { + self.insert_worktree_create_text(text); + true + } + Mode::OpenExistingWorktree => { + if !self + .state + .worktree_open + .as_ref() + .is_some_and(|open| open.search_focused) + { + return false; + } + self.insert_worktree_open_search_text(text); + true + } + Mode::Navigator => { + if !self.state.navigator.search_focused { + return false; + } + insert_navigator_search_text(&mut self.state, &self.terminal_runtimes, text); + true + } + _ => false, + } + } + pub(crate) fn handle_onboarding_key(&mut self, key: KeyEvent) { match key.code { KeyCode::Right | KeyCode::Char('l') => self.open_settings_from_onboarding(), @@ -400,6 +441,36 @@ impl App { } } +pub(crate) fn is_modal_paste_shortcut(key: &KeyEvent) -> bool { + if !matches!(key.code, KeyCode::Char('v' | 'V')) { + return false; + } + + #[cfg(target_os = "macos")] + { + key.modifiers.contains(KeyModifiers::SUPER) || key.modifiers.contains(KeyModifiers::CONTROL) + } + + #[cfg(not(target_os = "macos"))] + { + key.modifiers.contains(KeyModifiers::CONTROL) + } +} + +pub(crate) fn modal_paste_target_active(state: &AppState) -> bool { + match state.mode { + Mode::RenameWorkspace | Mode::RenameTab | Mode::RenamePane | Mode::NewLinkedWorktree => { + true + } + Mode::OpenExistingWorktree => state + .worktree_open + .as_ref() + .is_some_and(|open| open.search_focused), + Mode::Navigator => state.navigator.search_focused, + _ => false, + } +} + // --------------------------------------------------------------------------- // Mouse handling // --------------------------------------------------------------------------- @@ -557,3 +628,103 @@ fn wait_for_file(path: &std::path::Path) -> String { } panic!("timed out waiting for {}", path.display()); } + +#[cfg(test)] +mod tests { + use super::*; + + fn test_app() -> App { + App::new( + &crate::config::Config::default(), + true, + None, + tokio::sync::mpsc::unbounded_channel().1, + crate::api::EventHub::default(), + ) + } + + #[tokio::test] + async fn paste_routes_to_rename_modal_input() { + let mut app = test_app(); + app.state.workspaces = vec![crate::workspace::Workspace::test_new("test")]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::RenameTab; + app.state.name_input = "2".into(); + app.state.name_input_replace_on_type = true; + + app.handle_paste("feature/logs".into()).await; + + assert_eq!(app.state.name_input, "feature/logs"); + assert!(!app.state.name_input_replace_on_type); + } + + #[tokio::test] + async fn paste_routes_to_new_linked_worktree_input() { + let mut app = test_app(); + app.state.mode = Mode::NewLinkedWorktree; + app.state.name_input = "generated-branch".into(); + app.state.name_input_replace_on_type = true; + app.state.worktree_create = Some(crate::app::state::WorktreeCreateState { + source_workspace_id: "source".into(), + source_checkout_path: "/repo/herdr".into(), + source_existing_membership: None, + source_repo_root: "/repo/herdr".into(), + repo_key: "repo-key".into(), + repo_name: "herdr".into(), + branch: "generated-branch".into(), + checkout_path: "/repo/herdr-generated-branch".into(), + error: None, + creating: false, + }); + + app.handle_paste("feature/linear-302".into()).await; + + assert_eq!(app.state.name_input, "feature/linear-302"); + assert_eq!( + app.state + .worktree_create + .as_ref() + .map(|create| create.branch.as_str()), + Some("feature/linear-302") + ); + } + + #[test] + fn modal_paste_shortcut_matches_platform_primary_v() { + #[cfg(target_os = "macos")] + let modifiers = KeyModifiers::SUPER; + #[cfg(not(target_os = "macos"))] + let modifiers = KeyModifiers::CONTROL; + + assert!(is_modal_paste_shortcut(&KeyEvent::new( + KeyCode::Char('v'), + modifiers + ))); + assert!(is_modal_paste_shortcut(&KeyEvent::new( + KeyCode::Char('V'), + modifiers | KeyModifiers::SHIFT + ))); + assert!(!is_modal_paste_shortcut(&KeyEvent::new( + KeyCode::Char('v'), + KeyModifiers::ALT + ))); + } + + #[test] + fn modal_paste_target_is_active_only_for_text_inputs() { + let mut state = AppState::test_new(); + + state.mode = Mode::RenameTab; + assert!(modal_paste_target_active(&state)); + + state.mode = Mode::Navigator; + state.navigator.search_focused = false; + assert!(!modal_paste_target_active(&state)); + state.navigator.search_focused = true; + assert!(modal_paste_target_active(&state)); + + state.mode = Mode::ConfirmClose; + assert!(!modal_paste_target_active(&state)); + } +} diff --git a/src/app/input/modal.rs b/src/app/input/modal.rs index 5b179e9b..79d066f9 100644 --- a/src/app/input/modal.rs +++ b/src/app/input/modal.rs @@ -194,9 +194,7 @@ pub(crate) fn handle_navigator_key( KeyCode::Char(c) if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT => { - state.navigator.state_filter = None; - state.navigator.query.push(c); - state.clamp_navigator_selection_from(terminal_runtimes); + insert_navigator_search_text(state, terminal_runtimes, &c.to_string()); } _ => {} } @@ -283,6 +281,19 @@ pub(crate) fn handle_navigator_key( } } +pub(crate) fn insert_navigator_search_text( + state: &mut AppState, + terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, + text: &str, +) { + if !state.navigator.search_focused { + return; + } + state.navigator.state_filter = None; + state.navigator.query.push_str(text); + state.clamp_navigator_selection_from(terminal_runtimes); +} + pub(crate) fn handle_keybind_help_key(state: &mut AppState, key: KeyEvent) { match key.code { KeyCode::Up | KeyCode::Char('k') => state.scroll_keybind_help(-1), @@ -498,6 +509,13 @@ fn clear_rename_input(state: &mut AppState) { state.name_input_replace_on_type = false; } +pub(crate) fn insert_rename_input_text(state: &mut AppState, text: &str) { + if state.name_input_replace_on_type { + clear_rename_input(state); + } + state.name_input.push_str(text); +} + fn delete_rename_input_char(state: &mut AppState) { if state.name_input_replace_on_type { clear_rename_input(state); @@ -578,10 +596,7 @@ pub(crate) fn handle_rename_key(state: &mut AppState, key: KeyEvent) { } KeyCode::Backspace => delete_rename_input_char(state), KeyCode::Char(c) if key.modifiers.difference(KeyModifiers::SHIFT).is_empty() => { - if state.name_input_replace_on_type { - clear_rename_input(state); - } - state.name_input.push(c); + insert_rename_input_text(state, &c.to_string()); } _ => {} } @@ -1094,6 +1109,23 @@ mod tests { } #[test] + fn rename_modal_replaces_prefilled_text_on_paste() { + let mut state = state_with_workspaces(&["test"]); + state.mode = Mode::RenameTab; + state.name_input = "2".into(); + state.name_input_replace_on_type = true; + + insert_rename_input_text(&mut state, "feature/logs"); + + assert_eq!(state.name_input, "feature/logs"); + assert!(!state.name_input_replace_on_type); + + insert_rename_input_text(&mut state, "-copy"); + + assert_eq!(state.name_input, "feature/logs-copy"); + } + + #[test] fn rename_modal_handles_line_editing_shortcuts() { let mut state = state_with_workspaces(&["test"]); state.mode = Mode::RenameWorkspace; @@ -1166,6 +1198,32 @@ mod tests { } #[test] + fn navigator_search_accepts_pasted_text_when_focused() { + let mut state = state_with_workspaces(&["alpha", "beta"]); + let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); + state.mode = Mode::Navigator; + state.navigator.search_focused = true; + state.navigator.state_filter = Some(NavigatorStateFilter::Working); + + insert_navigator_search_text(&mut state, &terminal_runtimes, "beta"); + + assert_eq!(state.navigator.query, "beta"); + assert_eq!(state.navigator.state_filter, None); + } + + #[test] + fn navigator_search_ignores_paste_when_search_is_not_focused() { + let mut state = state_with_workspaces(&["alpha", "beta"]); + let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); + state.mode = Mode::Navigator; + state.navigator.search_focused = false; + + insert_navigator_search_text(&mut state, &terminal_runtimes, "beta"); + + assert!(state.navigator.query.is_empty()); + } + + #[test] fn open_rename_active_tab_can_prefill_default_new_tab_name() { let mut state = state_with_workspaces(&["test"]); state.workspaces[0].test_add_tab(None); |
