Copying / Dragging image files (MacOS Terminal + iTerm) (#2567)
In this PR: - [x] Add support for dragging / copying image files into chat. - [x] Don't remove image placeholders when submitting. - [x] Add tests. Works for: - Image Files - Dragging MacOS Screenshots (Terminal, iTerm) Todos: - [ ] In some terminals (VSCode, WIndows Powershell, and remote SSH-ing), copy-pasting a file streams the escaped filepath as individual key events rather than a single Paste event. We'll need to have a function (in a separate PR) for detecting these paste events.
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::Builder;
|
||||
|
||||
@@ -24,12 +25,16 @@ impl std::error::Error for PasteImageError {}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EncodedImageFormat {
|
||||
Png,
|
||||
Jpeg,
|
||||
Other,
|
||||
}
|
||||
|
||||
impl EncodedImageFormat {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
EncodedImageFormat::Png => "PNG",
|
||||
EncodedImageFormat::Jpeg => "JPEG",
|
||||
EncodedImageFormat::Other => "IMG",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,3 +100,185 @@ pub fn paste_image_to_temp_png() -> Result<(PathBuf, PastedImageInfo), PasteImag
|
||||
.map_err(|e| PasteImageError::IoError(e.error.to_string()))?;
|
||||
Ok((path, info))
|
||||
}
|
||||
|
||||
/// Normalize pasted text that may represent a filesystem path.
|
||||
///
|
||||
/// Supports:
|
||||
/// - `file://` URLs (converted to local paths)
|
||||
/// - Windows/UNC paths
|
||||
/// - shell-escaped single paths (via `shlex`)
|
||||
pub fn normalize_pasted_path(pasted: &str) -> Option<PathBuf> {
|
||||
let pasted = pasted.trim();
|
||||
|
||||
// file:// URL → filesystem path
|
||||
if let Ok(url) = url::Url::parse(pasted)
|
||||
&& url.scheme() == "file"
|
||||
{
|
||||
return url.to_file_path().ok();
|
||||
}
|
||||
|
||||
// TODO: We'll improve the implementation/unit tests over time, as appropriate.
|
||||
// Possibly use typed-path: https://github.com/openai/codex/pull/2567/commits/3cc92b78e0a1f94e857cf4674d3a9db918ed352e
|
||||
//
|
||||
// Detect unquoted Windows paths and bypass POSIX shlex which
|
||||
// treats backslashes as escapes (e.g., C:\Users\Alice\file.png).
|
||||
// Also handles UNC paths (\\server\share\path).
|
||||
let looks_like_windows_path = {
|
||||
// Drive letter path: C:\ or C:/
|
||||
let drive = pasted
|
||||
.chars()
|
||||
.next()
|
||||
.map(|c| c.is_ascii_alphabetic())
|
||||
.unwrap_or(false)
|
||||
&& pasted.get(1..2) == Some(":")
|
||||
&& pasted
|
||||
.get(2..3)
|
||||
.map(|s| s == "\\" || s == "/")
|
||||
.unwrap_or(false);
|
||||
// UNC path: \\server\share
|
||||
let unc = pasted.starts_with("\\\\");
|
||||
drive || unc
|
||||
};
|
||||
if looks_like_windows_path {
|
||||
return Some(PathBuf::from(pasted));
|
||||
}
|
||||
|
||||
// shell-escaped single path → unescaped
|
||||
let parts: Vec<String> = shlex::Shlex::new(pasted).collect();
|
||||
if parts.len() == 1 {
|
||||
return parts.into_iter().next().map(PathBuf::from);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Infer an image format for the provided path based on its extension.
|
||||
pub fn pasted_image_format(path: &Path) -> EncodedImageFormat {
|
||||
match path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|s| s.to_ascii_lowercase())
|
||||
.as_deref()
|
||||
{
|
||||
Some("png") => EncodedImageFormat::Png,
|
||||
Some("jpg") | Some("jpeg") => EncodedImageFormat::Jpeg,
|
||||
_ => EncodedImageFormat::Other,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pasted_paths_tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[test]
|
||||
fn normalize_file_url() {
|
||||
let input = "file:///tmp/example.png";
|
||||
let result = normalize_pasted_path(input).expect("should parse file URL");
|
||||
assert_eq!(result, PathBuf::from("/tmp/example.png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_file_url_windows() {
|
||||
let input = r"C:\Temp\example.png";
|
||||
let result = normalize_pasted_path(input).expect("should parse file URL");
|
||||
assert_eq!(result, PathBuf::from(r"C:\Temp\example.png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_shell_escaped_single_path() {
|
||||
let input = "/home/user/My\\ File.png";
|
||||
let result = normalize_pasted_path(input).expect("should unescape shell-escaped path");
|
||||
assert_eq!(result, PathBuf::from("/home/user/My File.png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_simple_quoted_path_fallback() {
|
||||
let input = "\"/home/user/My File.png\"";
|
||||
let result = normalize_pasted_path(input).expect("should trim simple quotes");
|
||||
assert_eq!(result, PathBuf::from("/home/user/My File.png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_single_quoted_unix_path() {
|
||||
let input = "'/home/user/My File.png'";
|
||||
let result = normalize_pasted_path(input).expect("should trim single quotes via shlex");
|
||||
assert_eq!(result, PathBuf::from("/home/user/My File.png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_multiple_tokens_returns_none() {
|
||||
// Two tokens after shell splitting → not a single path
|
||||
let input = "/home/user/a\\ b.png /home/user/c.png";
|
||||
let result = normalize_pasted_path(input);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasted_image_format_png_jpeg_unknown() {
|
||||
assert_eq!(
|
||||
pasted_image_format(Path::new("/a/b/c.PNG")),
|
||||
EncodedImageFormat::Png
|
||||
);
|
||||
assert_eq!(
|
||||
pasted_image_format(Path::new("/a/b/c.jpg")),
|
||||
EncodedImageFormat::Jpeg
|
||||
);
|
||||
assert_eq!(
|
||||
pasted_image_format(Path::new("/a/b/c.JPEG")),
|
||||
EncodedImageFormat::Jpeg
|
||||
);
|
||||
assert_eq!(
|
||||
pasted_image_format(Path::new("/a/b/c")),
|
||||
EncodedImageFormat::Other
|
||||
);
|
||||
assert_eq!(
|
||||
pasted_image_format(Path::new("/a/b/c.webp")),
|
||||
EncodedImageFormat::Other
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_single_quoted_windows_path() {
|
||||
let input = r"'C:\\Users\\Alice\\My File.jpeg'";
|
||||
let result =
|
||||
normalize_pasted_path(input).expect("should trim single quotes on windows path");
|
||||
assert_eq!(result, PathBuf::from(r"C:\\Users\\Alice\\My File.jpeg"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_unquoted_windows_path_with_spaces() {
|
||||
let input = r"C:\\Users\\Alice\\My Pictures\\example image.png";
|
||||
let result = normalize_pasted_path(input).expect("should accept unquoted windows path");
|
||||
assert_eq!(
|
||||
result,
|
||||
PathBuf::from(r"C:\\Users\\Alice\\My Pictures\\example image.png")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_unc_windows_path() {
|
||||
let input = r"\\\\server\\share\\folder\\file.jpg";
|
||||
let result = normalize_pasted_path(input).expect("should accept UNC windows path");
|
||||
assert_eq!(
|
||||
result,
|
||||
PathBuf::from(r"\\\\server\\share\\folder\\file.jpg")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasted_image_format_with_windows_style_paths() {
|
||||
assert_eq!(
|
||||
pasted_image_format(Path::new(r"C:\\a\\b\\c.PNG")),
|
||||
EncodedImageFormat::Png
|
||||
);
|
||||
assert_eq!(
|
||||
pasted_image_format(Path::new(r"C:\\a\\b\\c.jpeg")),
|
||||
EncodedImageFormat::Jpeg
|
||||
);
|
||||
assert_eq!(
|
||||
pasted_image_format(Path::new(r"C:\\a\\b\\noext")),
|
||||
EncodedImageFormat::Other
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user