Introduce a full codex-tui source snapshot under the new codex-tui2 crate so viewport work can be replayed in isolation. This change copies the entire codex-rs/tui/src tree into codex-rs/tui2/src in one atomic step, rather than piecemeal, to keep future diffs vs the original viewport bookmark easy to reason about. The goal is for codex-tui2 to render identically to the existing TUI behind the `features.tui2` flag while we gradually port the viewport/history commits from the joshka/viewport bookmark onto this forked tree. While on this baseline change, we also ran the codex-tui2 snapshot test suite and accepted all insta snapshots for the new crate, so the snapshot files now use the codex-tui2 naming scheme and encode the unmodified legacy TUI behavior. This keeps later viewport commits focused on intentional behavior changes (and their snapshots) rather than on mechanical snapshot renames.
504 lines
17 KiB
Rust
504 lines
17 KiB
Rust
use std::path::Path;
|
|
use std::path::PathBuf;
|
|
use tempfile::Builder;
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum PasteImageError {
|
|
ClipboardUnavailable(String),
|
|
NoImage(String),
|
|
EncodeFailed(String),
|
|
IoError(String),
|
|
}
|
|
|
|
impl std::fmt::Display for PasteImageError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
PasteImageError::ClipboardUnavailable(msg) => write!(f, "clipboard unavailable: {msg}"),
|
|
PasteImageError::NoImage(msg) => write!(f, "no image on clipboard: {msg}"),
|
|
PasteImageError::EncodeFailed(msg) => write!(f, "could not encode image: {msg}"),
|
|
PasteImageError::IoError(msg) => write!(f, "io error: {msg}"),
|
|
}
|
|
}
|
|
}
|
|
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",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct PastedImageInfo {
|
|
pub width: u32,
|
|
pub height: u32,
|
|
pub encoded_format: EncodedImageFormat, // Always PNG for now.
|
|
}
|
|
|
|
/// Capture image from system clipboard, encode to PNG, and return bytes + info.
|
|
#[cfg(not(target_os = "android"))]
|
|
pub fn paste_image_as_png() -> Result<(Vec<u8>, PastedImageInfo), PasteImageError> {
|
|
let _span = tracing::debug_span!("paste_image_as_png").entered();
|
|
tracing::debug!("attempting clipboard image read");
|
|
let mut cb = arboard::Clipboard::new()
|
|
.map_err(|e| PasteImageError::ClipboardUnavailable(e.to_string()))?;
|
|
// Sometimes images on the clipboard come as files (e.g. when copy/pasting from
|
|
// Finder), sometimes they come as image data (e.g. when pasting from Chrome).
|
|
// Accept both, and prefer files if both are present.
|
|
let files = cb
|
|
.get()
|
|
.file_list()
|
|
.map_err(|e| PasteImageError::ClipboardUnavailable(e.to_string()));
|
|
let dyn_img = if let Some(img) = files
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.find_map(|f| image::open(f).ok())
|
|
{
|
|
tracing::debug!(
|
|
"clipboard image opened from file: {}x{}",
|
|
img.width(),
|
|
img.height()
|
|
);
|
|
img
|
|
} else {
|
|
let _span = tracing::debug_span!("get_image").entered();
|
|
let img = cb
|
|
.get_image()
|
|
.map_err(|e| PasteImageError::NoImage(e.to_string()))?;
|
|
let w = img.width as u32;
|
|
let h = img.height as u32;
|
|
tracing::debug!("clipboard image opened from image: {}x{}", w, h);
|
|
|
|
let Some(rgba_img) = image::RgbaImage::from_raw(w, h, img.bytes.into_owned()) else {
|
|
return Err(PasteImageError::EncodeFailed("invalid RGBA buffer".into()));
|
|
};
|
|
|
|
image::DynamicImage::ImageRgba8(rgba_img)
|
|
};
|
|
|
|
let mut png: Vec<u8> = Vec::new();
|
|
{
|
|
let span =
|
|
tracing::debug_span!("encode_image", byte_length = tracing::field::Empty).entered();
|
|
let mut cursor = std::io::Cursor::new(&mut png);
|
|
dyn_img
|
|
.write_to(&mut cursor, image::ImageFormat::Png)
|
|
.map_err(|e| PasteImageError::EncodeFailed(e.to_string()))?;
|
|
span.record("byte_length", png.len());
|
|
}
|
|
|
|
Ok((
|
|
png,
|
|
PastedImageInfo {
|
|
width: dyn_img.width(),
|
|
height: dyn_img.height(),
|
|
encoded_format: EncodedImageFormat::Png,
|
|
},
|
|
))
|
|
}
|
|
|
|
/// Android/Termux does not support arboard; return a clear error.
|
|
#[cfg(target_os = "android")]
|
|
pub fn paste_image_as_png() -> Result<(Vec<u8>, PastedImageInfo), PasteImageError> {
|
|
Err(PasteImageError::ClipboardUnavailable(
|
|
"clipboard image paste is unsupported on Android".into(),
|
|
))
|
|
}
|
|
|
|
/// Convenience: write to a temp file and return its path + info.
|
|
#[cfg(not(target_os = "android"))]
|
|
pub fn paste_image_to_temp_png() -> Result<(PathBuf, PastedImageInfo), PasteImageError> {
|
|
// First attempt: read image from system clipboard via arboard (native paths or image data).
|
|
match paste_image_as_png() {
|
|
Ok((png, info)) => {
|
|
// Create a unique temporary file with a .png suffix to avoid collisions.
|
|
let tmp = Builder::new()
|
|
.prefix("codex-clipboard-")
|
|
.suffix(".png")
|
|
.tempfile()
|
|
.map_err(|e| PasteImageError::IoError(e.to_string()))?;
|
|
std::fs::write(tmp.path(), &png)
|
|
.map_err(|e| PasteImageError::IoError(e.to_string()))?;
|
|
// Persist the file (so it remains after the handle is dropped) and return its PathBuf.
|
|
let (_file, path) = tmp
|
|
.keep()
|
|
.map_err(|e| PasteImageError::IoError(e.error.to_string()))?;
|
|
Ok((path, info))
|
|
}
|
|
Err(e) => {
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
try_wsl_clipboard_fallback(&e).or(Err(e))
|
|
}
|
|
#[cfg(not(target_os = "linux"))]
|
|
{
|
|
Err(e)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Attempt WSL fallback for clipboard image paste.
|
|
///
|
|
/// If clipboard is unavailable (common under WSL because arboard cannot access
|
|
/// the Windows clipboard), attempt a WSL fallback that calls PowerShell on the
|
|
/// Windows side to write the clipboard image to a temporary file, then return
|
|
/// the corresponding WSL path.
|
|
#[cfg(target_os = "linux")]
|
|
fn try_wsl_clipboard_fallback(
|
|
error: &PasteImageError,
|
|
) -> Result<(PathBuf, PastedImageInfo), PasteImageError> {
|
|
use PasteImageError::ClipboardUnavailable;
|
|
use PasteImageError::NoImage;
|
|
|
|
if !is_probably_wsl() || !matches!(error, ClipboardUnavailable(_) | NoImage(_)) {
|
|
return Err(error.clone());
|
|
}
|
|
|
|
tracing::debug!("attempting Windows PowerShell clipboard fallback");
|
|
let Some(win_path) = try_dump_windows_clipboard_image() else {
|
|
return Err(error.clone());
|
|
};
|
|
|
|
tracing::debug!("powershell produced path: {}", win_path);
|
|
let Some(mapped_path) = convert_windows_path_to_wsl(&win_path) else {
|
|
return Err(error.clone());
|
|
};
|
|
|
|
let Ok((w, h)) = image::image_dimensions(&mapped_path) else {
|
|
return Err(error.clone());
|
|
};
|
|
|
|
// Return the mapped path directly without copying.
|
|
// The file will be read and base64-encoded during serialization.
|
|
Ok((
|
|
mapped_path,
|
|
PastedImageInfo {
|
|
width: w,
|
|
height: h,
|
|
encoded_format: EncodedImageFormat::Png,
|
|
},
|
|
))
|
|
}
|
|
|
|
/// Try to call a Windows PowerShell command (several common names) to save the
|
|
/// clipboard image to a temporary PNG and return the Windows path to that file.
|
|
/// Returns None if no command succeeded or no image was present.
|
|
#[cfg(target_os = "linux")]
|
|
fn try_dump_windows_clipboard_image() -> Option<String> {
|
|
// Powershell script: save image from clipboard to a temp png and print the path.
|
|
// Force UTF-8 output to avoid encoding issues between powershell.exe (UTF-16LE default)
|
|
// and pwsh (UTF-8 default).
|
|
let script = r#"[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; $img = Get-Clipboard -Format Image; if ($img -ne $null) { $p=[System.IO.Path]::GetTempFileName(); $p = [System.IO.Path]::ChangeExtension($p,'png'); $img.Save($p,[System.Drawing.Imaging.ImageFormat]::Png); Write-Output $p } else { exit 1 }"#;
|
|
|
|
for cmd in ["powershell.exe", "pwsh", "powershell"] {
|
|
match std::process::Command::new(cmd)
|
|
.args(["-NoProfile", "-Command", script])
|
|
.output()
|
|
{
|
|
// Executing PowerShell command
|
|
Ok(output) => {
|
|
if output.status.success() {
|
|
// Decode as UTF-8 (forced by the script above).
|
|
let win_path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
if !win_path.is_empty() {
|
|
tracing::debug!("{} saved clipboard image to {}", cmd, win_path);
|
|
return Some(win_path);
|
|
}
|
|
} else {
|
|
tracing::debug!("{} returned non-zero status", cmd);
|
|
}
|
|
}
|
|
Err(err) => {
|
|
tracing::debug!("{} not executable: {}", cmd, err);
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
#[cfg(target_os = "android")]
|
|
pub fn paste_image_to_temp_png() -> Result<(PathBuf, PastedImageInfo), PasteImageError> {
|
|
// Keep error consistent with paste_image_as_png.
|
|
Err(PasteImageError::ClipboardUnavailable(
|
|
"clipboard image paste is unsupported on Android".into(),
|
|
))
|
|
}
|
|
|
|
/// 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 {
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
if is_probably_wsl()
|
|
&& let Some(converted) = convert_windows_path_to_wsl(pasted)
|
|
{
|
|
return Some(converted);
|
|
}
|
|
}
|
|
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
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
pub(crate) fn is_probably_wsl() -> bool {
|
|
// Primary: Check /proc/version for "microsoft" or "WSL" (most reliable for standard WSL).
|
|
if let Ok(version) = std::fs::read_to_string("/proc/version") {
|
|
let version_lower = version.to_lowercase();
|
|
if version_lower.contains("microsoft") || version_lower.contains("wsl") {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Fallback: Check WSL environment variables. This handles edge cases like
|
|
// custom Linux kernels installed in WSL where /proc/version may not contain
|
|
// "microsoft" or "WSL".
|
|
std::env::var_os("WSL_DISTRO_NAME").is_some() || std::env::var_os("WSL_INTEROP").is_some()
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn convert_windows_path_to_wsl(input: &str) -> Option<PathBuf> {
|
|
if input.starts_with("\\\\") {
|
|
return None;
|
|
}
|
|
|
|
let drive_letter = input.chars().next()?.to_ascii_lowercase();
|
|
if !drive_letter.is_ascii_lowercase() {
|
|
return None;
|
|
}
|
|
|
|
if input.get(1..2) != Some(":") {
|
|
return None;
|
|
}
|
|
|
|
let mut result = PathBuf::from(format!("/mnt/{drive_letter}"));
|
|
for component in input
|
|
.get(2..)?
|
|
.trim_start_matches(['\\', '/'])
|
|
.split(['\\', '/'])
|
|
.filter(|component| !component.is_empty())
|
|
{
|
|
result.push(component);
|
|
}
|
|
|
|
Some(result)
|
|
}
|
|
|
|
/// 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(str::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");
|
|
#[cfg(target_os = "linux")]
|
|
let expected = if is_probably_wsl()
|
|
&& let Some(converted) = convert_windows_path_to_wsl(input)
|
|
{
|
|
converted
|
|
} else {
|
|
PathBuf::from(r"C:\Temp\example.png")
|
|
};
|
|
#[cfg(not(target_os = "linux"))]
|
|
let expected = PathBuf::from(r"C:\Temp\example.png");
|
|
assert_eq!(result, expected);
|
|
}
|
|
|
|
#[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");
|
|
#[cfg(target_os = "linux")]
|
|
let expected = if is_probably_wsl()
|
|
&& let Some(converted) = convert_windows_path_to_wsl(input)
|
|
{
|
|
converted
|
|
} else {
|
|
PathBuf::from(r"C:\\Users\\Alice\\My Pictures\\example image.png")
|
|
};
|
|
#[cfg(not(target_os = "linux"))]
|
|
let expected = PathBuf::from(r"C:\\Users\\Alice\\My Pictures\\example image.png");
|
|
assert_eq!(result, expected);
|
|
}
|
|
|
|
#[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
|
|
);
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
#[test]
|
|
fn normalize_windows_path_in_wsl() {
|
|
// This test only runs on actual WSL systems
|
|
if !is_probably_wsl() {
|
|
// Skip test if not on WSL
|
|
return;
|
|
}
|
|
let input = r"C:\\Users\\Alice\\Pictures\\example image.png";
|
|
let result = normalize_pasted_path(input).expect("should convert windows path on wsl");
|
|
assert_eq!(
|
|
result,
|
|
PathBuf::from("/mnt/c/Users/Alice/Pictures/example image.png")
|
|
);
|
|
}
|
|
}
|