Allow paste in searchable selection menus (#25400)

## Summary

I frequently want to be able to paste into the searchable menu -- the
most common use-case here is when specifying an upstream for a
`/review`, where I copy the upstream from an open terminal.
This commit is contained in:
Charlie Marsh
2026-06-01 18:01:52 +02:00
committed by GitHub
Unverified
parent 13edafb6ed
commit 12c37a6b5c
3 changed files with 89 additions and 14 deletions
@@ -15,6 +15,7 @@ use ratatui::widgets::Widget;
use super::selection_popup_common::render_menu_surface;
use super::selection_popup_common::wrap_styled_line;
use crate::app_event_sender::AppEventSender;
use crate::clipboard_paste::normalize_pasted_search_query;
use crate::key_hint::KeyBinding;
use crate::key_hint::KeyBindingListExt;
use crate::key_hint::is_plain_text_key_event;
@@ -1016,6 +1017,18 @@ impl BottomPaneView for ListSelectionView {
}
}
fn handle_paste(&mut self, pasted: String) -> bool {
if !self.is_searchable {
return false;
}
let Some(pasted) = normalize_pasted_search_query(&pasted) else {
return false;
};
self.search_query.push_str(&pasted);
self.apply_filter();
true
}
fn is_complete(&self) -> bool {
self.completion.is_some()
}
@@ -1672,6 +1685,61 @@ mod tests {
);
}
#[test]
fn paste_appends_to_search_query_and_filters_items() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut view = new_view(
SelectionViewParams {
items: vec![
SelectionItem {
name: "main -> feature/other".to_string(),
search_value: Some("feature/other".to_string()),
..Default::default()
},
SelectionItem {
name: "main -> feature/paste-support".to_string(),
search_value: Some("feature/paste-support".to_string()),
..Default::default()
},
],
is_searchable: true,
..Default::default()
},
tx,
);
view.handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE));
assert!(view.handle_paste("eature/paste-support\n".to_string()));
assert_eq!(view.search_query, "feature/paste-support");
assert_eq!(view.filtered_indices, vec![1]);
assert_eq!(view.selected_actual_idx(), Some(1));
}
#[test]
fn whitespace_only_paste_is_ignored() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut view = new_view(
SelectionViewParams {
items: vec![SelectionItem {
name: "main".to_string(),
search_value: Some("main".to_string()),
..Default::default()
}],
is_searchable: true,
..Default::default()
},
tx,
);
assert!(!view.handle_paste(" \n\t ".to_string()));
assert_eq!(view.search_query, "");
assert_eq!(view.filtered_indices, vec![0]);
}
#[test]
fn switching_tabs_changes_visible_items_and_clears_search() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
+19
View File
@@ -236,6 +236,12 @@ pub fn paste_image_to_temp_png() -> Result<(PathBuf, PastedImageInfo), PasteImag
))
}
/// Normalize pasted text for a single-line search query.
pub(crate) fn normalize_pasted_search_query(pasted: &str) -> Option<String> {
let normalized = pasted.split_whitespace().collect::<Vec<_>>().join(" ");
(!normalized.is_empty()).then_some(normalized)
}
/// Normalize pasted text that may represent a filesystem path.
///
/// Supports:
@@ -368,6 +374,19 @@ pub fn pasted_image_format(path: &Path) -> EncodedImageFormat {
}
}
#[cfg(test)]
mod pasted_search_query_tests {
use super::*;
#[test]
fn collapses_whitespace() {
assert_eq!(
normalize_pasted_search_query(" alpha\n\tbeta\r\n gamma "),
Some(String::from("alpha beta gamma"))
);
}
}
#[cfg(test)]
mod pasted_paths_tests {
use super::*;
+2 -14
View File
@@ -7,6 +7,7 @@ use std::sync::Arc;
mod transcript;
use crate::app_server_session::AppServerSession;
use crate::clipboard_paste::normalize_pasted_search_query;
use crate::color::blend;
use crate::color::is_light;
use crate::git_action_directives::parse_assistant_markdown;
@@ -548,11 +549,6 @@ fn picker_cwd_filter(
}
}
fn normalize_pasted_query(pasted: &str) -> Option<String> {
let normalized = pasted.split_whitespace().collect::<Vec<_>>().join(" ");
(!normalized.is_empty()).then_some(normalized)
}
fn spawn_app_server_page_loader(
app_server: AppServerSession,
include_non_interactive: bool,
@@ -1237,7 +1233,7 @@ impl PickerState {
if self.is_transcript_loading() {
return;
}
let Some(pasted) = normalize_pasted_query(&pasted) else {
let Some(pasted) = normalize_pasted_search_query(&pasted) else {
return;
};
let mut new_query = self.query.clone();
@@ -6228,14 +6224,6 @@ session_picker_view = "dense"
assert_eq!(state.query, "resize results");
}
#[test]
fn normalize_pasted_query_collapses_whitespace() {
assert_eq!(
normalize_pasted_query(" alpha\n\tbeta\r\n gamma "),
Some(String::from("alpha beta gamma"))
);
}
#[tokio::test]
async fn whitespace_only_paste_is_ignored() {
let loader = page_only_loader(|_| {});