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 | |
| parent | 0facd819b4747c7634040b561509137c2608b024 (diff) | |
fix: support paste in modal inputs
refs #302
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/input/mod.rs | 231 | ||||
| -rw-r--r-- | src/app/input/modal.rs | 72 | ||||
| -rw-r--r-- | src/app/mod.rs | 79 | ||||
| -rw-r--r-- | src/app/worktrees.rs | 133 | ||||
| -rw-r--r-- | src/platform/fallback.rs | 5 | ||||
| -rw-r--r-- | src/platform/linux.rs | 113 | ||||
| -rw-r--r-- | src/platform/macos.rs | 34 | ||||
| -rw-r--r-- | src/platform/windows.rs | 4 |
8 files changed, 618 insertions, 53 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); diff --git a/src/app/mod.rs b/src/app/mod.rs index 936b082f..35ab92b1 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1326,7 +1326,7 @@ impl App { self.handle_terminal_key_headless(key); } else { self.suppressed_repeat_keys.insert(key_id); - self.handle_non_terminal_key(key); + self.handle_non_terminal_key_headless(key); } } crossterm::event::KeyEventKind::Repeat => { @@ -1352,7 +1352,9 @@ impl App { } } crate::raw_input::RawInputEvent::Paste(text) => { - if self.state.mode == Mode::Terminal { + if self.state.mode != Mode::Terminal { + self.paste_into_active_text_input(&text); + } else { if let Some(ws_idx) = self.state.active { if let Some(ws) = self.state.workspaces.get(ws_idx) { if let Some(focused) = ws.focused_pane_id() { @@ -1395,8 +1397,17 @@ impl App { /// /// Uses the standalone handler functions that work on `&mut AppState` /// since the server doesn't have the async context of the monolithic App. - fn handle_non_terminal_key(&mut self, key: crate::input::TerminalKey) { + fn handle_non_terminal_key_headless(&mut self, key: crate::input::TerminalKey) { let key_event = key.as_key_event(); + if input::modal_paste_target_active(&self.state) + && input::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::Prefix => { self.handle_prefix_key(key); @@ -3969,6 +3980,68 @@ last_pane = "prefix+tab" } #[test] + fn route_client_input_pastes_bracketed_text_into_rename_modal() { + let mut app = test_app(); + app.state.workspaces = vec![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.route_client_input(b"\x1b[200~feature/logs\x1b[201~".to_vec()); + + assert_eq!(app.state.name_input, "feature/logs"); + assert!(!app.state.name_input_replace_on_type); + } + + #[test] + fn raw_ctrl_v_decodes_as_modal_paste_shortcut() { + let events = crate::raw_input::parse_raw_input_bytes_sync(&[0x16]); + let Some(crate::raw_input::RawInputEvent::Key(key)) = events.first() else { + panic!("expected ctrl-v key event"); + }; + + assert!(input::is_modal_paste_shortcut(&key.as_key_event())); + } + + #[test] + fn route_client_events_pastes_text_into_new_linked_worktree_modal() { + 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(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.route_client_events( + vec![crate::raw_input::RawInputEvent::Paste( + "feature/linear-302".into(), + )], + true, + ); + + 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 route_client_input_closes_release_notes_modal() { let mut app = test_app(); app.state.workspaces = vec![Workspace::test_new("test")]; diff --git a/src/app/worktrees.rs b/src/app/worktrees.rs index f6635c23..bcfaf6d8 100644 --- a/src/app/worktrees.rs +++ b/src/app/worktrees.rs @@ -256,17 +256,21 @@ impl App { self.sync_worktree_branch_from_input(); } KeyCode::Char(c) => { - if self.state.name_input_replace_on_type { - self.state.name_input.clear(); - self.state.name_input_replace_on_type = false; - } - self.state.name_input.push(c); - self.sync_worktree_branch_from_input(); + self.insert_worktree_create_text(&c.to_string()); } _ => {} } } + pub(crate) fn insert_worktree_create_text(&mut self, text: &str) { + if self.state.name_input_replace_on_type { + self.state.name_input.clear(); + self.state.name_input_replace_on_type = false; + } + self.state.name_input.push_str(text); + self.sync_worktree_branch_from_input(); + } + pub(crate) fn handle_worktree_open_key(&mut self, key: KeyEvent) { match key.code { KeyCode::Esc => { @@ -303,14 +307,10 @@ impl App { .worktree_open .as_ref() .is_some_and(|open| open.search_focused) - && (key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT) => + && (key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT) + && !ch.is_control() => { - if let Some(open) = &mut self.state.worktree_open { - if !ch.is_control() { - open.query.push(ch); - open.normalize_selection(); - } - } + self.insert_worktree_open_search_text(&ch.to_string()); } KeyCode::Backspace if self @@ -329,6 +329,17 @@ impl App { } } + pub(crate) fn insert_worktree_open_search_text(&mut self, text: &str) { + let Some(open) = &mut self.state.worktree_open else { + return; + }; + if !open.search_focused { + return; + } + open.query.push_str(text); + open.normalize_selection(); + } + pub(crate) fn open_selected_existing_worktree(&mut self) { let Some(open) = self.state.worktree_open.as_ref() else { return; @@ -800,6 +811,102 @@ mod tests { } #[test] + fn worktree_create_replaces_prefilled_branch_on_paste_and_syncs_state() { + let mut app = app_for_worktree_tests(); + app.state.name_input = "generated-branch".into(); + app.state.name_input_replace_on_type = true; + app.state.worktree_create = Some(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.insert_worktree_create_text("feature/linear-302"); + + assert_eq!(app.state.name_input, "feature/linear-302"); + assert!(!app.state.name_input_replace_on_type); + assert_eq!( + app.state + .worktree_create + .as_ref() + .map(|create| create.branch.as_str()), + Some("feature/linear-302") + ); + } + + #[test] + fn worktree_open_search_accepts_pasted_text_when_focused() { + let mut app = app_for_worktree_tests(); + app.state.worktree_open = Some(WorktreeOpenState { + source_workspace_id: "source".into(), + source_existing_membership: None, + source_checkout_path: "/repo/herdr".into(), + source_repo_root: "/repo/herdr".into(), + repo_key: "repo-key".into(), + repo_name: "herdr".into(), + entries: vec![ + WorktreeOpenEntry { + path: "/repo/herdr-main".into(), + branch: Some("main".into()), + is_linked_worktree: false, + already_open_ws_idx: None, + }, + WorktreeOpenEntry { + path: "/repo/feature-linear-302".into(), + branch: Some("feature/linear-302".into()), + is_linked_worktree: true, + already_open_ws_idx: None, + }, + ], + selected: 0, + query: String::new(), + search_focused: true, + error: None, + }); + + app.insert_worktree_open_search_text("linear-302"); + + let open = app.state.worktree_open.as_ref().unwrap(); + assert_eq!(open.query, "linear-302"); + assert_eq!(open.selected_entry_index(), Some(1)); + } + + #[test] + fn worktree_open_search_ignores_paste_when_search_is_not_focused() { + let mut app = app_for_worktree_tests(); + app.state.worktree_open = Some(WorktreeOpenState { + source_workspace_id: "source".into(), + source_existing_membership: None, + source_checkout_path: "/repo/herdr".into(), + source_repo_root: "/repo/herdr".into(), + repo_key: "repo-key".into(), + repo_name: "herdr".into(), + entries: Vec::new(), + selected: 0, + query: String::new(), + search_focused: false, + error: None, + }); + + app.insert_worktree_open_search_text("linear-302"); + + assert_eq!( + app.state + .worktree_open + .as_ref() + .map(|open| open.query.as_str()), + Some("") + ); + } + + #[test] fn open_selected_existing_worktree_focuses_already_open_workspace() { let mut app = app_for_worktree_tests(); app.state.workspaces = vec![ diff --git a/src/platform/fallback.rs b/src/platform/fallback.rs index 0f48d32d..b27f181e 100644 --- a/src/platform/fallback.rs +++ b/src/platform/fallback.rs @@ -44,6 +44,11 @@ pub fn write_clipboard(_bytes: &[u8]) -> bool { } /// Unsupported platform stub. +pub fn read_clipboard_text() -> Option<String> { + None +} + +/// Unsupported platform stub. pub fn open_url(_url: &str) -> std::io::Result<()> { Err(std::io::Error::new( std::io::ErrorKind::Unsupported, diff --git a/src/platform/linux.rs b/src/platform/linux.rs index d1ae07f1..5b6a6d3c 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -187,6 +187,15 @@ pub fn write_clipboard(bytes: &[u8]) -> bool { false } +pub fn read_clipboard_text() -> Option<String> { + for command in read_clipboard_text_commands() { + if let Some(text) = read_clipboard_text_with_command(&command) { + return Some(text); + } + } + None +} + pub fn open_url(url: &str) -> std::io::Result<()> { Command::new("xdg-open") .arg(url) @@ -337,6 +346,72 @@ fn clipboard_commands() -> Vec<ClipboardCommand> { commands } +fn read_clipboard_text_commands() -> Vec<ClipboardCommand> { + let mut commands = Vec::new(); + + if std::env::var_os("WAYLAND_DISPLAY").is_some() { + commands.push(ClipboardCommand { + program: "wl-paste", + args: &["--type", "text/plain;charset=utf-8"], + }); + commands.push(ClipboardCommand { + program: "wl-paste", + args: &["--type", "text/plain"], + }); + } + + if std::env::var_os("DISPLAY").is_some() { + commands.push(ClipboardCommand { + program: "xclip", + args: &["-selection", "clipboard", "-out"], + }); + commands.push(ClipboardCommand { + program: "xsel", + args: &["--clipboard", "--output"], + }); + } + + commands +} + +fn read_clipboard_text_with_command(command: &ClipboardCommand) -> Option<String> { + const MAX_CLIPBOARD_TEXT_BYTES: usize = 1024 * 1024; + + let mut child = Command::new(command.program) + .args(command.args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .ok()?; + + let stdout = child.stdout.take()?; + let read = match read_limited_reader(stdout, MAX_CLIPBOARD_TEXT_BYTES) { + Ok(LimitedRead::Oversized) => { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + Ok(read) => read, + Err(_) => { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + }; + + let status = child.wait().ok()?; + if !status.success() { + return None; + } + + match read { + LimitedRead::Complete(bytes) => String::from_utf8(bytes).ok(), + LimitedRead::Empty => None, + LimitedRead::Oversized => unreachable!("oversized clipboard text is handled before wait"), + } +} + fn run_clipboard_command(command: &ClipboardCommand, bytes: &[u8]) -> bool { let mut child = match Command::new(command.program) .args(command.args) @@ -408,6 +483,44 @@ mod tests { } #[test] + fn read_clipboard_text_commands_include_session_backends() { + let _guard = env_lock().lock().unwrap(); + unsafe { + std::env::set_var("WAYLAND_DISPLAY", "wayland-0"); + std::env::set_var("DISPLAY", ":0"); + } + + let commands = read_clipboard_text_commands(); + assert_eq!(commands[0].program, "wl-paste"); + assert_eq!(commands[1].program, "wl-paste"); + assert_eq!(commands[2].program, "xclip"); + assert_eq!(commands[3].program, "xsel"); + } + + #[test] + fn read_clipboard_text_with_command_reads_utf8() { + let command = ClipboardCommand { + program: "printf", + args: &["feature/linear-302"], + }; + + assert_eq!( + read_clipboard_text_with_command(&command).as_deref(), + Some("feature/linear-302") + ); + } + + #[test] + fn read_clipboard_text_with_command_rejects_oversized_output() { + let command = ClipboardCommand { + program: "sh", + args: &["-c", "yes x | head -c 1048578"], + }; + + assert_eq!(read_clipboard_text_with_command(&command), None); + } + + #[test] fn read_clipboard_image_with_spawned_command_reads_under_limit() { let mut command = Command::new("sh"); command.arg("-c").arg("printf image"); diff --git a/src/platform/macos.rs b/src/platform/macos.rs index 652b7a41..df957652 100644 --- a/src/platform/macos.rs +++ b/src/platform/macos.rs @@ -463,6 +463,40 @@ pub fn write_clipboard(bytes: &[u8]) -> bool { ) } +pub fn read_clipboard_text() -> Option<String> { + const MAX_CLIPBOARD_TEXT_BYTES: usize = 1024 * 1024; + + let mut child = Command::new("pbpaste") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .ok()?; + let stdout = child.stdout.take()?; + let read = match read_limited_reader(stdout, MAX_CLIPBOARD_TEXT_BYTES) { + Ok(LimitedRead::Oversized) => { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + Ok(read) => read, + Err(_) => { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + }; + let status = child.wait().ok()?; + if !status.success() { + return None; + } + match read { + LimitedRead::Complete(bytes) => String::from_utf8(bytes).ok(), + LimitedRead::Empty => None, + LimitedRead::Oversized => unreachable!("oversized clipboard text is handled before wait"), + } +} + pub fn open_url(url: &str) -> std::io::Result<()> { Command::new("open") .arg(url) diff --git a/src/platform/windows.rs b/src/platform/windows.rs index d92095a1..12a8345f 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -345,6 +345,10 @@ pub fn write_clipboard(_bytes: &[u8]) -> bool { false } +pub fn read_clipboard_text() -> Option<String> { + None +} + pub fn open_url(url: &str) -> std::io::Result<()> { let operation = wide_null("open"); let url = wide_null(url); |
