aboutsummaryrefslogtreecommitdiff
path: root/src/app/input
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-10 02:26:25 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-10 02:27:41 +0300
commit77fcde0a2736c3927d1bc589bd9b4d0173a9df46 (patch)
tree209c704342fb52a7ecbb5fe0cdbc291f7650e720 /src/app/input
parent0facd819b4747c7634040b561509137c2608b024 (diff)
fix: support paste in modal inputs
refs #302
Diffstat (limited to 'src/app/input')
-rw-r--r--src/app/input/mod.rs231
-rw-r--r--src/app/input/modal.rs72
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);