aboutsummaryrefslogtreecommitdiff
path: root/src
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
parent0facd819b4747c7634040b561509137c2608b024 (diff)
fix: support paste in modal inputs
refs #302
Diffstat (limited to 'src')
-rw-r--r--src/app/input/mod.rs231
-rw-r--r--src/app/input/modal.rs72
-rw-r--r--src/app/mod.rs79
-rw-r--r--src/app/worktrees.rs133
-rw-r--r--src/platform/fallback.rs5
-rw-r--r--src/platform/linux.rs113
-rw-r--r--src/platform/macos.rs34
-rw-r--r--src/platform/windows.rs4
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);