multi image drop

This commit is contained in:
Daniel Edrisian
2025-08-20 15:10:38 -07:00
parent 817ac79367
commit 1b0ef70b53
2 changed files with 140 additions and 51 deletions

View File

@@ -760,11 +760,7 @@ impl ChatWidget<'_> {
}
pub(crate) fn handle_paste(&mut self, text: String) {
// First, attempt to interpret the pasted text as a file path to an image
// and attach it. This mirrors the logic previously handled at the app level.
let mut handled = false;
// Helper to attach an image if the path looks valid, returning true if handled.
// Helper: attempt to attach image and return true if attached.
fn try_attach_image(widget: &mut ChatWidget<'_>, path: std::path::PathBuf) -> bool {
if path.is_file() {
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
@@ -784,9 +780,9 @@ impl ChatWidget<'_> {
false
}
// Trim and strip quotes for the most direct case.
let mut s = text.trim().to_string();
if !s.is_empty() {
// Normalize a candidate string into possible paths to try.
fn candidate_paths(candidate: &str) -> Vec<std::path::PathBuf> {
let mut s = candidate.trim().to_string();
if s.len() >= 2
&& ((s.starts_with('"') && s.ends_with('"'))
|| (s.starts_with('\'') && s.ends_with('\'')))
@@ -800,56 +796,73 @@ impl ChatWidget<'_> {
s = p.to_string_lossy().into_owned();
}
}
handled = try_attach_image(self, std::path::PathBuf::from(&s));
let mut try_paths: Vec<std::path::PathBuf> = Vec::new();
if let Some(p) = file_url_to_path(&s) {
try_paths.push(p);
}
try_paths.push(std::path::PathBuf::from(&s));
let unescaped = unescape_backslashes(&s);
if unescaped != s {
try_paths.push(std::path::PathBuf::from(unescaped));
}
try_paths
}
// If not handled yet, try multiple candidate interpretations: shlex tokens,
// URL-style paths, and unescaped variants.
if !handled {
let candidates: Vec<String> = if let Some(tokens) = shlex::split(&text) {
tokens
} else {
vec![text.clone()]
};
let mut any_attached = false;
'outer: for raw in candidates {
let mut s = raw.trim().to_string();
if s.len() >= 2
&& ((s.starts_with('"') && s.ends_with('"'))
|| (s.starts_with('\'') && s.ends_with('\'')))
{
s = s[1..s.len() - 1].to_string();
}
if let Some(rest) = s.strip_prefix("~/") {
if let Ok(home) = std::env::var("HOME") {
let mut p = std::path::PathBuf::from(home);
p.push(rest);
s = p.to_string_lossy().into_owned();
// Strategy:
// - If multiple lines were pasted (drag-and-drop often does this),
// treat each line as a single candidate path. Attach images for lines
// that resolve to valid image files; keep other lines as leftover text.
// - Otherwise, try tokenizing the single line using shlex and attach
// images for any token that resolves to a valid image file; paste
// any remaining tokens as text.
if text.contains('\n') {
let mut leftover_lines: Vec<&str> = Vec::new();
for line in text.lines() {
let mut attached_this_line = false;
for p in candidate_paths(line) {
if try_attach_image(self, p) {
any_attached = true;
attached_this_line = true;
break;
}
}
let mut try_paths: Vec<std::path::PathBuf> = Vec::new();
if let Some(p) = file_url_to_path(&s) {
try_paths.push(p);
}
try_paths.push(std::path::PathBuf::from(&s));
let unescaped = unescape_backslashes(&s);
if unescaped != s {
try_paths.push(std::path::PathBuf::from(unescaped));
}
for path in try_paths {
if try_attach_image(self, path) {
handled = true;
break 'outer;
}
if !attached_this_line {
leftover_lines.push(line);
}
}
}
// If still not handled, treat it as a normal textual paste.
if !handled {
self.bottom_pane.handle_paste(text);
let leftover = leftover_lines.join("\n");
if !leftover.trim().is_empty() {
self.bottom_pane.handle_paste(leftover);
} else if !any_attached {
// Nothing attached and no leftover: forward original text.
self.bottom_pane.handle_paste(text);
}
} else {
// Single line: consider shlex tokens to support quoted paths.
let tokens: Vec<String> = shlex::split(&text).unwrap_or_else(|| vec![text.clone()]);
let mut leftover_tokens: Vec<String> = Vec::new();
for tok in tokens {
let mut attached_this_token = false;
for p in candidate_paths(&tok) {
if try_attach_image(self, p) {
any_attached = true;
attached_this_token = true;
break;
}
}
if !attached_this_token {
leftover_tokens.push(tok);
}
}
let leftover = leftover_tokens.join(" ");
if !leftover.trim().is_empty() {
self.bottom_pane.handle_paste(leftover);
} else if !any_attached {
self.bottom_pane.handle_paste(text);
}
}
}

View File

@@ -198,6 +198,82 @@ fn extract_trailing_unc_path() {
assert!(cand.ends_with("img.jpg"));
}
#[test]
fn paste_multiple_lines_attaches_multiple_images_and_no_text() {
use image::ImageBuffer;
use image::Rgba;
// Create two temp images
let dir = std::env::temp_dir();
let p1 = dir.join(format!("codex_multi_a_{}.png", uuid::Uuid::new_v4()));
let p2 = dir.join(format!("codex_multi_b_{}.png", uuid::Uuid::new_v4()));
let img1: ImageBuffer<Rgba<u8>, Vec<u8>> =
ImageBuffer::from_fn(2, 3, |_x, _y| Rgba([255, 0, 0, 255]));
image::DynamicImage::ImageRgba8(img1)
.save_with_format(&p1, image::ImageFormat::Png)
.expect("write png");
let img2: ImageBuffer<Rgba<u8>, Vec<u8>> =
ImageBuffer::from_fn(4, 1, |_x, _y| Rgba([0, 255, 0, 255]));
image::DynamicImage::ImageRgba8(img2)
.save_with_format(&p2, image::ImageFormat::Png)
.expect("write png");
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();
let paste = format!("{}\n{}\n", p1.display(), p2.display());
chat.handle_paste(paste);
// Expect composer text to contain two image placeholders and no literal paths.
let composer_text = chat.bottom_pane.current_input_text();
assert!(
composer_text.contains("[image 2x3 PNG]"),
"missing png placeholder: {composer_text}"
);
assert!(
composer_text.contains("[image 4x1 PNG]"),
"missing png placeholder (second): {composer_text}"
);
assert!(!composer_text.contains("a.png"));
assert!(!composer_text.contains("b.jpg"));
}
#[test]
fn paste_single_line_two_quoted_paths_attaches_both_and_keeps_other_text() {
use image::ImageBuffer;
use image::Rgba;
let dir = std::env::temp_dir();
let p1 = dir.join(format!("codex_multi_c_{}.png", uuid::Uuid::new_v4()));
let p2 = dir.join(format!("codex_multi_d_{}.png", uuid::Uuid::new_v4()));
let img1: ImageBuffer<Rgba<u8>, Vec<u8>> =
ImageBuffer::from_fn(1, 1, |_x, _y| Rgba([1, 2, 3, 255]));
image::DynamicImage::ImageRgba8(img1)
.save_with_format(&p1, image::ImageFormat::Png)
.expect("write png");
let img2: ImageBuffer<Rgba<u8>, Vec<u8>> =
ImageBuffer::from_fn(3, 2, |_x, _y| Rgba([4, 5, 6, 255]));
image::DynamicImage::ImageRgba8(img2)
.save_with_format(&p2, image::ImageFormat::Png)
.expect("write png");
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();
let paste = format!(
"Please see \"{}\" and \"{}\" now",
p1.display(),
p2.display()
);
chat.handle_paste(paste);
let composer_text = chat.bottom_pane.current_input_text();
assert!(
composer_text.contains("Please see"),
"leftover text missing: {composer_text}"
);
assert!(
composer_text.contains("now"),
"leftover text missing: {composer_text}"
);
assert!(composer_text.contains("[image 1x1 PNG]"));
assert!(composer_text.contains("[image 3x2 PNG]"));
}
fn open_fixture(name: &str) -> std::fs::File {
// 1) Prefer fixtures within this crate
{