2026-07-16 06:46:02 +01:00
|
|
|
|
//! Shared prompt-side image types and helpers.
|
|
|
|
|
|
//!
|
|
|
|
|
|
//! This is the single source of truth for image data attached to the prompt.
|
|
|
|
|
|
//! Both the view layer ([`crate::views::prompt_widget`]) and the app/dispatch
|
|
|
|
|
|
//! layer ([`crate::app::dispatch`]) consume these types, so they live here
|
|
|
|
|
|
//! rather than inside any single view or app module.
|
|
|
|
|
|
|
|
|
|
|
|
use std::collections::HashSet;
|
|
|
|
|
|
use std::path::PathBuf;
|
|
|
|
|
|
use std::sync::{Arc, OnceLock};
|
|
|
|
|
|
use std::time::Instant;
|
|
|
|
|
|
|
|
|
|
|
|
use xai_ratatui_textarea::ElementId;
|
|
|
|
|
|
|
|
|
|
|
|
/// Tracing target for image-pipeline diagnostics.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Filter with `RUST_LOG=prompt_images=debug` to surface logs from
|
|
|
|
|
|
/// `insert_image`, `sync_images_with_textarea`, and the drop-classifier
|
|
|
|
|
|
/// (`try_read_dropped_paths`) — useful for RCing cross-platform paste
|
|
|
|
|
|
/// regressions from a single user's session capture.
|
|
|
|
|
|
pub const PROMPT_IMAGES_TRACING_TARGET: &str = "prompt_images";
|
|
|
|
|
|
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
// Scrollable image viewer state
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
/// State for a modal image viewer.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Supports deferred loading: [`open_from_path_deferred`] returns instantly
|
|
|
|
|
|
/// with `loading: true`, then [`finish_loading`] performs the heavy I/O on
|
|
|
|
|
|
/// the next tick so the UI can show a spinner while the file is read.
|
|
|
|
|
|
pub struct ImageViewerState {
|
|
|
|
|
|
/// Original encoded image bytes.
|
|
|
|
|
|
pub image_bytes: Vec<u8>,
|
|
|
|
|
|
/// Encoded bytes prepared for terminal display (e.g. JPEG converted to PNG for Kitty).
|
|
|
|
|
|
pub display_bytes: Vec<u8>,
|
|
|
|
|
|
pub mime_type: String,
|
|
|
|
|
|
pub image_width: u32,
|
|
|
|
|
|
pub image_height: u32,
|
|
|
|
|
|
pub display_number: usize,
|
|
|
|
|
|
/// Override title (e.g. filename). When `None`, uses "Image #N".
|
|
|
|
|
|
pub title: Option<String>,
|
|
|
|
|
|
/// Deferred loading in progress; display bytes not yet available.
|
|
|
|
|
|
pub loading: bool,
|
|
|
|
|
|
/// Identity used by the shared terminal overlay upload owner.
|
|
|
|
|
|
pub overlay_owner_id: u64,
|
|
|
|
|
|
/// File path for deferred loading; consumed by [`finish_loading`].
|
|
|
|
|
|
source_path: Option<PathBuf>,
|
|
|
|
|
|
/// Shared modal chrome state (close button hit-test, hover, etc.).
|
|
|
|
|
|
pub modal_state: crate::modal_window_state::ModalWindowState,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn decode_image_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
|
|
|
|
|
|
// Unrestricted: accepts any format `image` recognises so paste previews
|
|
|
|
|
|
// don't fail for non-allow-listed types.
|
|
|
|
|
|
xai_grok_tools::util::image_validate::validate_image_bytes_unrestricted(bytes, false)
|
|
|
|
|
|
.ok()
|
|
|
|
|
|
.map(|(w, h, _)| (w, h))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn is_decodable_image(bytes: &[u8]) -> bool {
|
|
|
|
|
|
decode_image_dimensions(bytes).is_some()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl ImageViewerState {
|
|
|
|
|
|
/// Create a viewer for a prompt-side image. Loads synchronously since
|
|
|
|
|
|
/// prompt images already have bytes in memory.
|
|
|
|
|
|
pub fn open(image: &PastedImage) -> Option<Self> {
|
|
|
|
|
|
let bytes = if let Some(ref b) = image.encoded_bytes {
|
|
|
|
|
|
b.to_vec()
|
|
|
|
|
|
} else if let Some(ref path) = image.session_image_path {
|
|
|
|
|
|
std::fs::read(path).ok()?
|
|
|
|
|
|
} else {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let (w, h) = decode_image_dimensions(&bytes)?;
|
|
|
|
|
|
let display_bytes = crate::terminal::image::prepare_overlay_image_bytes(&bytes)
|
|
|
|
|
|
.unwrap_or_else(|| bytes.clone());
|
|
|
|
|
|
|
|
|
|
|
|
Some(Self {
|
|
|
|
|
|
image_bytes: bytes,
|
|
|
|
|
|
display_bytes,
|
|
|
|
|
|
mime_type: image.mime_type.clone(),
|
|
|
|
|
|
image_width: w,
|
|
|
|
|
|
image_height: h,
|
|
|
|
|
|
display_number: image.display_number,
|
|
|
|
|
|
title: None,
|
|
|
|
|
|
loading: false,
|
|
|
|
|
|
overlay_owner_id: image.preview.identity(),
|
|
|
|
|
|
source_path: None,
|
|
|
|
|
|
modal_state: Default::default(),
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Create a viewer from a file path, loading synchronously. Prefer
|
|
|
|
|
|
/// [`open_from_path_deferred`] from input handlers to avoid blocking.
|
|
|
|
|
|
pub fn open_from_path(path: &std::path::Path) -> Option<Self> {
|
|
|
|
|
|
let bytes = std::fs::read(path).ok()?;
|
|
|
|
|
|
|
|
|
|
|
|
let (w, h) = decode_image_dimensions(&bytes)?;
|
|
|
|
|
|
let display_bytes = crate::terminal::image::prepare_overlay_image_bytes(&bytes)
|
|
|
|
|
|
.unwrap_or_else(|| bytes.clone());
|
|
|
|
|
|
|
|
|
|
|
|
let mime_type = xai_grok_shared::clipboard::mime_from_bytes(&bytes).to_owned();
|
|
|
|
|
|
let file_name = path.file_name().map(|n| n.to_string_lossy().into_owned());
|
|
|
|
|
|
|
|
|
|
|
|
Some(Self {
|
|
|
|
|
|
image_bytes: bytes,
|
|
|
|
|
|
display_bytes,
|
|
|
|
|
|
mime_type,
|
|
|
|
|
|
image_width: w,
|
|
|
|
|
|
image_height: h,
|
|
|
|
|
|
display_number: 1,
|
|
|
|
|
|
title: file_name,
|
|
|
|
|
|
loading: false,
|
|
|
|
|
|
overlay_owner_id: crate::terminal::overlay::next_owner_id(),
|
|
|
|
|
|
source_path: None,
|
|
|
|
|
|
modal_state: Default::default(),
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Create a loading-state viewer for a file path.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Returns immediately with `loading: true`. A background thread
|
|
|
|
|
|
/// runs [`load_image_data`] and the tick handler polls for the result,
|
|
|
|
|
|
/// then calls [`apply_loaded`] to complete the load.
|
|
|
|
|
|
pub fn open_from_path_deferred(path: &std::path::Path) -> Self {
|
|
|
|
|
|
Self {
|
|
|
|
|
|
image_bytes: Vec::new(),
|
|
|
|
|
|
display_bytes: Vec::new(),
|
|
|
|
|
|
mime_type: String::new(),
|
|
|
|
|
|
image_width: 0,
|
|
|
|
|
|
image_height: 0,
|
|
|
|
|
|
display_number: 1,
|
|
|
|
|
|
title: path.file_name().map(|n| n.to_string_lossy().into_owned()),
|
|
|
|
|
|
loading: true,
|
|
|
|
|
|
overlay_owner_id: crate::terminal::overlay::next_owner_id(),
|
|
|
|
|
|
source_path: Some(path.to_path_buf()),
|
|
|
|
|
|
modal_state: Default::default(),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Take the source path for background loading. Returns `None` if
|
|
|
|
|
|
/// already taken or not in loading state.
|
|
|
|
|
|
pub fn take_source_path(&mut self) -> Option<PathBuf> {
|
|
|
|
|
|
if self.loading {
|
|
|
|
|
|
self.source_path.take()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
None
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Apply loaded data from a background thread.
|
|
|
|
|
|
pub fn apply_loaded(&mut self, data: LoadedImageData) {
|
|
|
|
|
|
self.image_bytes = data.image_bytes;
|
|
|
|
|
|
self.display_bytes = data.display_bytes;
|
|
|
|
|
|
self.mime_type = data.mime_type;
|
|
|
|
|
|
self.image_width = data.image_width;
|
|
|
|
|
|
self.image_height = data.image_height;
|
|
|
|
|
|
self.loading = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Complete the deferred load synchronously (convenience for tests).
|
|
|
|
|
|
/// Returns `false` on failure.
|
|
|
|
|
|
pub fn finish_loading(&mut self) -> bool {
|
|
|
|
|
|
if !self.loading {
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
let Some(path) = self.source_path.take() else {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
};
|
|
|
|
|
|
match load_image_data(&path) {
|
|
|
|
|
|
ImageLoadResult::Loaded(data) => {
|
|
|
|
|
|
self.apply_loaded(data);
|
|
|
|
|
|
true
|
|
|
|
|
|
}
|
|
|
|
|
|
ImageLoadResult::Failed => false,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Result of a background image load.
|
|
|
|
|
|
pub enum ImageLoadResult {
|
|
|
|
|
|
Loaded(LoadedImageData),
|
|
|
|
|
|
Failed,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Data loaded from an image file, ready to apply to the viewer.
|
|
|
|
|
|
pub struct LoadedImageData {
|
|
|
|
|
|
pub image_bytes: Vec<u8>,
|
|
|
|
|
|
pub display_bytes: Vec<u8>,
|
|
|
|
|
|
pub mime_type: String,
|
|
|
|
|
|
pub image_width: u32,
|
|
|
|
|
|
pub image_height: u32,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Load image data from a file path. This is the heavy work (file read,
|
|
|
|
|
|
/// decode, format conversion) that runs on a background thread.
|
|
|
|
|
|
pub fn load_image_data(path: &std::path::Path) -> ImageLoadResult {
|
|
|
|
|
|
let bytes = match std::fs::read(path) {
|
|
|
|
|
|
Ok(b) => b,
|
|
|
|
|
|
Err(e) => {
|
|
|
|
|
|
tracing::warn!("image viewer: failed to read {}: {e}", path.display());
|
|
|
|
|
|
return ImageLoadResult::Failed;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
let (w, h) = match decode_image_dimensions(&bytes) {
|
|
|
|
|
|
Some(dims) => dims,
|
|
|
|
|
|
None => {
|
|
|
|
|
|
tracing::warn!("image viewer: failed to decode {}", path.display());
|
|
|
|
|
|
return ImageLoadResult::Failed;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
let display_bytes = crate::terminal::image::prepare_overlay_image_bytes(&bytes)
|
|
|
|
|
|
.unwrap_or_else(|| bytes.clone());
|
|
|
|
|
|
let mime_type = xai_grok_shared::clipboard::mime_from_bytes(&bytes).to_owned();
|
|
|
|
|
|
|
|
|
|
|
|
ImageLoadResult::Loaded(LoadedImageData {
|
|
|
|
|
|
image_bytes: bytes,
|
|
|
|
|
|
display_bytes,
|
|
|
|
|
|
mime_type,
|
|
|
|
|
|
image_width: w,
|
|
|
|
|
|
image_height: h,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
// Video viewer state
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
/// Target frames per second for terminal video playback.
|
|
|
|
|
|
const VIDEO_FPS: f64 = 10.0;
|
|
|
|
|
|
|
|
|
|
|
|
/// Maximum pixel width for extracted frames.
|
|
|
|
|
|
const VIDEO_MAX_WIDTH: u32 = 640;
|
|
|
|
|
|
|
|
|
|
|
|
/// Modal video viewer state.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Holds pre-extracted frames and playback position. The rendering path
|
|
|
|
|
|
/// reuses the same `post_flush_escapes` pipeline as the image viewer.
|
|
|
|
|
|
pub struct VideoViewerState {
|
|
|
|
|
|
/// Pre-extracted frames (PNG for Kitty, JPEG for iTerm2).
|
|
|
|
|
|
pub frames: Vec<Vec<u8>>,
|
|
|
|
|
|
/// Current frame index.
|
|
|
|
|
|
current_frame: usize,
|
|
|
|
|
|
/// Whether playback is active.
|
|
|
|
|
|
pub playing: bool,
|
|
|
|
|
|
/// Playback frame rate.
|
|
|
|
|
|
pub fps: f64,
|
|
|
|
|
|
/// Last frame advance timestamp (for pacing).
|
|
|
|
|
|
last_frame_time: Instant,
|
|
|
|
|
|
/// Original video pixel dimensions.
|
|
|
|
|
|
pub video_width: u32,
|
|
|
|
|
|
pub video_height: u32,
|
|
|
|
|
|
/// Total duration in seconds.
|
|
|
|
|
|
pub duration_secs: f64,
|
|
|
|
|
|
/// Display title (file name).
|
|
|
|
|
|
pub title: Option<String>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl VideoViewerState {
|
|
|
|
|
|
/// Minimal viewer for tests (pager and render unit tests); the real
|
|
|
|
|
|
/// `open_from_path` needs ffmpeg and a graphics-capable terminal, neither
|
|
|
|
|
|
/// available under `cargo test`. Public so dependent crates can construct
|
|
|
|
|
|
/// a viewer without pulling in decode/graphics.
|
|
|
|
|
|
pub fn test_stub() -> Self {
|
|
|
|
|
|
Self {
|
|
|
|
|
|
frames: vec![Vec::new()],
|
|
|
|
|
|
current_frame: 0,
|
|
|
|
|
|
playing: false,
|
|
|
|
|
|
fps: 1.0,
|
|
|
|
|
|
last_frame_time: Instant::now(),
|
|
|
|
|
|
video_width: 1,
|
|
|
|
|
|
video_height: 1,
|
|
|
|
|
|
duration_secs: 0.0,
|
|
|
|
|
|
title: None,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Open a video file for playback. Returns `None` if ffmpeg is
|
|
|
|
|
|
/// unavailable or the video cannot be decoded.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Extracts all frames upfront — for short videos (5–15s at 10fps)
|
|
|
|
|
|
/// this is 50–150 frames and takes ~1–3 seconds.
|
|
|
|
|
|
pub fn open_from_path(path: &std::path::Path) -> Option<Self> {
|
|
|
|
|
|
use crate::terminal::image::{GraphicsProtocol, detect_graphics_protocol};
|
|
|
|
|
|
|
|
|
|
|
|
let protocol = detect_graphics_protocol();
|
|
|
|
|
|
if protocol == GraphicsProtocol::None {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let (width, height, duration, fps) = ffprobe_metadata(path)?;
|
|
|
|
|
|
let target_fps = VIDEO_FPS.min(fps);
|
|
|
|
|
|
|
|
|
|
|
|
// PNG for Kitty (required), JPEG for iTerm2 (smaller).
|
|
|
|
|
|
let ext = match protocol {
|
|
|
|
|
|
GraphicsProtocol::Kitty => "png",
|
|
|
|
|
|
GraphicsProtocol::ITerm2 => "jpg",
|
|
|
|
|
|
GraphicsProtocol::None => return None,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let vf = if width > VIDEO_MAX_WIDTH {
|
|
|
|
|
|
format!("fps={target_fps},scale={}:-2", VIDEO_MAX_WIDTH)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
format!("fps={target_fps}")
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let tmp_dir = make_temp_dir();
|
|
|
|
|
|
std::fs::create_dir_all(&tmp_dir).ok()?;
|
|
|
|
|
|
|
|
|
|
|
|
let mut ffmpeg_cmd = std::process::Command::new("ffmpeg");
|
|
|
|
|
|
ffmpeg_cmd
|
|
|
|
|
|
.args(["-hide_banner", "-loglevel", "error", "-i"])
|
|
|
|
|
|
.arg(path)
|
|
|
|
|
|
.args(["-vf", &vf, "-q:v", "5"])
|
|
|
|
|
|
.arg(tmp_dir.join(format!("%06d.{ext}")))
|
|
|
|
|
|
.stdin(std::process::Stdio::null())
|
|
|
|
|
|
.stdout(std::process::Stdio::null())
|
|
|
|
|
|
.stderr(std::process::Stdio::null());
|
|
|
|
|
|
xai_tty_utils::detach_std_command(&mut ffmpeg_cmd);
|
|
|
|
|
|
let status = ffmpeg_cmd.status();
|
|
|
|
|
|
|
|
|
|
|
|
match &status {
|
|
|
|
|
|
Err(e) => tracing::debug!("ffmpeg not available: {e}"),
|
|
|
|
|
|
Ok(s) if !s.success() => tracing::debug!("ffmpeg exited with {s}"),
|
|
|
|
|
|
_ => {}
|
|
|
|
|
|
}
|
|
|
|
|
|
if !status.as_ref().is_ok_and(|s| s.success()) {
|
|
|
|
|
|
let _ = std::fs::remove_dir_all(&tmp_dir);
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let frames = load_frames(&tmp_dir, ext);
|
|
|
|
|
|
let _ = std::fs::remove_dir_all(&tmp_dir);
|
|
|
|
|
|
|
|
|
|
|
|
if frames.is_empty() {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Some(Self {
|
|
|
|
|
|
current_frame: 0,
|
|
|
|
|
|
playing: true,
|
|
|
|
|
|
fps: target_fps,
|
|
|
|
|
|
last_frame_time: Instant::now(),
|
|
|
|
|
|
video_width: width,
|
|
|
|
|
|
video_height: height,
|
|
|
|
|
|
duration_secs: duration,
|
|
|
|
|
|
title: path.file_name().map(|n| n.to_string_lossy().into_owned()),
|
|
|
|
|
|
frames,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Advance playback based on elapsed time. Returns `true` if the
|
|
|
|
|
|
/// frame changed (caller should redraw).
|
|
|
|
|
|
pub fn tick(&mut self) -> bool {
|
|
|
|
|
|
if !self.playing || self.frames.is_empty() {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let frame_duration = std::time::Duration::from_secs_f64(1.0 / self.fps);
|
|
|
|
|
|
if self.last_frame_time.elapsed() < frame_duration {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
self.current_frame = (self.current_frame + 1) % self.frames.len();
|
|
|
|
|
|
self.last_frame_time = Instant::now();
|
|
|
|
|
|
true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Toggle play/pause.
|
|
|
|
|
|
pub fn toggle_play_pause(&mut self) {
|
|
|
|
|
|
self.playing = !self.playing;
|
|
|
|
|
|
if self.playing {
|
|
|
|
|
|
self.last_frame_time = Instant::now();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Seek forward by ~1 second.
|
|
|
|
|
|
pub fn seek_forward(&mut self) {
|
|
|
|
|
|
let skip = self.fps.round() as usize;
|
|
|
|
|
|
self.current_frame = (self.current_frame + skip).min(self.frames.len().saturating_sub(1));
|
|
|
|
|
|
self.last_frame_time = Instant::now();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Seek backward by ~1 second.
|
|
|
|
|
|
pub fn seek_backward(&mut self) {
|
|
|
|
|
|
let skip = self.fps.round() as usize;
|
|
|
|
|
|
self.current_frame = self.current_frame.saturating_sub(skip);
|
|
|
|
|
|
self.last_frame_time = Instant::now();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Current frame image data.
|
|
|
|
|
|
pub fn current_frame_data(&self) -> &[u8] {
|
|
|
|
|
|
&self.frames[self.current_frame]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Current playback position in seconds.
|
|
|
|
|
|
pub fn position_secs(&self) -> f64 {
|
|
|
|
|
|
if self.fps <= 0.0 {
|
|
|
|
|
|
return 0.0;
|
|
|
|
|
|
}
|
|
|
|
|
|
self.current_frame as f64 / self.fps
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Playback progress fraction (0.0–1.0).
|
|
|
|
|
|
pub fn progress(&self) -> f64 {
|
|
|
|
|
|
if self.frames.len() <= 1 {
|
|
|
|
|
|
return 0.0;
|
|
|
|
|
|
}
|
|
|
|
|
|
self.current_frame as f64 / (self.frames.len() - 1) as f64
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Extract a single poster frame from a video file via ffmpeg.
|
|
|
|
|
|
/// Returns `(image_bytes, width, height)`. The image format depends on
|
|
|
|
|
|
/// the active terminal protocol (PNG for Kitty, JPEG for iTerm2).
|
|
|
|
|
|
pub fn extract_poster_frame(path: &std::path::Path) -> Option<(Vec<u8>, u32, u32)> {
|
|
|
|
|
|
use crate::terminal::image::{GraphicsProtocol, detect_graphics_protocol};
|
|
|
|
|
|
|
|
|
|
|
|
let protocol = detect_graphics_protocol();
|
|
|
|
|
|
let ext = match protocol {
|
|
|
|
|
|
GraphicsProtocol::Kitty => "png",
|
|
|
|
|
|
GraphicsProtocol::ITerm2 => "jpg",
|
|
|
|
|
|
GraphicsProtocol::None => return None,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Try seeking to 1s for a representative frame; fall back to first frame.
|
|
|
|
|
|
let try_extract = |seek: Option<&str>| {
|
|
|
|
|
|
let mut cmd = std::process::Command::new("ffmpeg");
|
|
|
|
|
|
cmd.args(["-hide_banner", "-loglevel", "error"]);
|
|
|
|
|
|
if let Some(ss) = seek {
|
|
|
|
|
|
cmd.args(["-ss", ss]);
|
|
|
|
|
|
}
|
|
|
|
|
|
cmd.args(["-i"])
|
|
|
|
|
|
.arg(path)
|
|
|
|
|
|
.args(["-frames:v", "1", "-f", "image2pipe", "-vcodec", ext])
|
|
|
|
|
|
.arg("-")
|
|
|
|
|
|
.stdin(std::process::Stdio::null())
|
|
|
|
|
|
.stderr(std::process::Stdio::null());
|
|
|
|
|
|
xai_tty_utils::detach_std_command(&mut cmd);
|
|
|
|
|
|
cmd.output()
|
|
|
|
|
|
.ok()
|
|
|
|
|
|
.filter(|o| o.status.success() && !o.stdout.is_empty())
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let output = try_extract(Some("1")).or_else(|| try_extract(None))?;
|
|
|
|
|
|
|
|
|
|
|
|
let (w, h) = decode_image_dimensions(&output.stdout)?;
|
|
|
|
|
|
|
|
|
|
|
|
Some((output.stdout, w, h))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Create a unique temp directory path for frame extraction.
|
|
|
|
|
|
fn make_temp_dir() -> PathBuf {
|
|
|
|
|
|
std::env::temp_dir().join(format!(
|
|
|
|
|
|
"grok-video-{}-{}",
|
|
|
|
|
|
std::process::id(),
|
|
|
|
|
|
std::time::SystemTime::now()
|
|
|
|
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
|
.as_nanos()
|
|
|
|
|
|
))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Read all frame files from `dir` with the given extension, sorted by name.
|
|
|
|
|
|
fn load_frames(dir: &std::path::Path, ext: &str) -> Vec<Vec<u8>> {
|
|
|
|
|
|
let mut paths: Vec<_> = std::fs::read_dir(dir)
|
|
|
|
|
|
.into_iter()
|
|
|
|
|
|
.flatten()
|
|
|
|
|
|
.filter_map(|e| e.ok())
|
|
|
|
|
|
.map(|e| e.path())
|
|
|
|
|
|
.filter(|p| p.extension().is_some_and(|e| e == ext))
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
paths.sort();
|
|
|
|
|
|
paths.iter().filter_map(|p| std::fs::read(p).ok()).collect()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Probe video metadata via ffprobe. Returns `(width, height, duration, fps)`.
|
|
|
|
|
|
fn ffprobe_metadata(path: &std::path::Path) -> Option<(u32, u32, f64, f64)> {
|
|
|
|
|
|
let mut cmd = std::process::Command::new("ffprobe");
|
|
|
|
|
|
cmd.args([
|
|
|
|
|
|
"-v",
|
|
|
|
|
|
"quiet",
|
|
|
|
|
|
"-select_streams",
|
|
|
|
|
|
"v:0",
|
|
|
|
|
|
"-show_entries",
|
|
|
|
|
|
"stream=width,height,r_frame_rate,duration",
|
|
|
|
|
|
"-show_entries",
|
|
|
|
|
|
"format=duration",
|
|
|
|
|
|
"-of",
|
|
|
|
|
|
"csv=p=0:s=,",
|
|
|
|
|
|
])
|
|
|
|
|
|
.arg(path)
|
|
|
|
|
|
.stdin(std::process::Stdio::null())
|
|
|
|
|
|
.stderr(std::process::Stdio::null());
|
|
|
|
|
|
xai_tty_utils::detach_std_command(&mut cmd);
|
|
|
|
|
|
let output = match cmd.output() {
|
|
|
|
|
|
Ok(o) => o,
|
|
|
|
|
|
Err(e) => {
|
|
|
|
|
|
tracing::debug!("ffprobe not available: {e}");
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if !output.status.success() {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let text = String::from_utf8_lossy(&output.stdout);
|
|
|
|
|
|
// Stream line: width,height,r_frame_rate[,duration]
|
|
|
|
|
|
// Format line: duration
|
|
|
|
|
|
let lines: Vec<&str> = text.trim().lines().collect();
|
|
|
|
|
|
let parts: Vec<&str> = lines.first()?.split(',').collect();
|
|
|
|
|
|
if parts.len() < 3 {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let width: u32 = parts[0].trim().parse().ok()?;
|
|
|
|
|
|
let height: u32 = parts[1].trim().parse().ok()?;
|
|
|
|
|
|
let fps = parse_fraction(parts[2].trim()).unwrap_or(30.0);
|
|
|
|
|
|
|
|
|
|
|
|
// Try stream duration, fall back to format duration.
|
|
|
|
|
|
let duration = parts
|
|
|
|
|
|
.get(3)
|
|
|
|
|
|
.and_then(|d| d.trim().parse::<f64>().ok())
|
|
|
|
|
|
.or_else(|| lines.get(1).and_then(|l| l.trim().parse::<f64>().ok()))
|
|
|
|
|
|
.unwrap_or(0.0);
|
|
|
|
|
|
|
|
|
|
|
|
Some((width, height, duration, fps))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Parse a fraction like "30/1" or "30000/1001".
|
|
|
|
|
|
fn parse_fraction(s: &str) -> Option<f64> {
|
|
|
|
|
|
if let Some((num, den)) = s.split_once('/') {
|
|
|
|
|
|
let n: f64 = num.parse().ok()?;
|
|
|
|
|
|
let d: f64 = den.parse().ok()?;
|
|
|
|
|
|
if d == 0.0 {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
Some(n / d)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
s.parse::<f64>().ok()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
// Inline media info (for scrollback inline rendering)
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
/// Metadata for inline media rendering in the scrollback.
|
|
|
|
|
|
/// Returned by blocks that want to display media inline.
|
|
|
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
|
pub struct InlineMediaInfo {
|
|
|
|
|
|
/// Path to the media file.
|
|
|
|
|
|
pub path: PathBuf,
|
|
|
|
|
|
/// Pixel dimensions of the media.
|
|
|
|
|
|
pub width: u32,
|
|
|
|
|
|
pub height: u32,
|
|
|
|
|
|
/// Whether this is a video (poster frame) vs. a static image.
|
|
|
|
|
|
pub is_video: bool,
|
|
|
|
|
|
/// Alt text / description from the markdown `` syntax.
|
|
|
|
|
|
pub alt_text: String,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
use xai_grok_shared::clipboard::mime_to_extension;
|
|
|
|
|
|
|
|
|
|
|
|
/// A single image pasted into the prompt.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Tracks everything needed for display, preview, persistence, and
|
|
|
|
|
|
/// eventual submission as a `ContentBlock::Image`.
|
|
|
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
|
pub struct PastedImage {
|
|
|
|
|
|
/// The [`ElementId`] of the corresponding `KIND_IMAGE` element in the
|
|
|
|
|
|
/// `TextArea` buffer. Used to reconcile live elements against stored images.
|
|
|
|
|
|
pub element_id: ElementId,
|
|
|
|
|
|
|
|
|
|
|
|
/// 1-based display number for the current prompt (e.g. the `1` in
|
|
|
|
|
|
/// `[Image #1]`). Reset when the prompt is cleared.
|
|
|
|
|
|
pub display_number: usize,
|
|
|
|
|
|
|
|
|
|
|
|
/// MIME type of the encoded image (e.g. `"image/png"`).
|
|
|
|
|
|
pub mime_type: String,
|
|
|
|
|
|
|
|
|
|
|
|
/// Header-validated dimensions used for pre-insertion/send policy.
|
|
|
|
|
|
pub dimensions: Option<(u32, u32)>,
|
|
|
|
|
|
|
|
|
|
|
|
/// Size of the encoded image bytes.
|
|
|
|
|
|
pub byte_len: usize,
|
|
|
|
|
|
|
|
|
|
|
|
/// Encoded image bytes kept in memory. Set to `None` once the image
|
|
|
|
|
|
/// has been durably written to [`session_image_path`](Self::session_image_path)
|
|
|
|
|
|
/// to avoid holding large buffers for the lifetime of the prompt.
|
|
|
|
|
|
pub encoded_bytes: Option<Arc<[u8]>>,
|
|
|
|
|
|
|
|
|
|
|
|
/// Original user-visible path for file-path pastes.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// This remains stable after persistence so previews show the path the
|
|
|
|
|
|
/// user pasted. Model/send loading uses `session_image_path` first.
|
|
|
|
|
|
pub source_path: Option<PathBuf>,
|
|
|
|
|
|
|
|
|
|
|
|
/// Temporary staging path before the image is finalized into the session
|
|
|
|
|
|
/// directory. Cleaned up when the chip is removed or on send.
|
|
|
|
|
|
pub staged_temp_path: Option<PathBuf>,
|
|
|
|
|
|
|
|
|
|
|
|
/// Final durable path under `session_dir(info)/images/`. Once set, this
|
|
|
|
|
|
/// is the canonical on-disk location and [`encoded_bytes`](Self::encoded_bytes)
|
|
|
|
|
|
/// may be released.
|
|
|
|
|
|
pub session_image_path: Option<PathBuf>,
|
|
|
|
|
|
|
|
|
|
|
|
/// Shared preview preparation state; draw only reads its resolved result.
|
|
|
|
|
|
pub preview: PromptImagePreview,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl PastedImage {
|
|
|
|
|
|
pub fn preview_preparation(&self) -> Option<PromptImagePreviewPreparation> {
|
|
|
|
|
|
if !self.preview.is_pending() {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
Some(PromptImagePreviewPreparation {
|
|
|
|
|
|
preview: self.preview.clone(),
|
|
|
|
|
|
source: self.encoded_bytes.as_ref()?.clone(),
|
|
|
|
|
|
protocol: crate::terminal::image::detect_graphics_protocol(),
|
|
|
|
|
|
dimensions: self.dimensions,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn prepare_preview_blocking(&self) {
|
|
|
|
|
|
if let Some(preparation) = self.preview_preparation() {
|
|
|
|
|
|
preparation.run();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn preview_dimensions(&self) -> Option<(u32, u32)> {
|
|
|
|
|
|
self.dimensions.or_else(|| self.preview.dimensions())
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Shared readiness of the terminal pixel payload for a pasted image.
|
|
|
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
|
pub struct PromptImagePreview {
|
|
|
|
|
|
identity: u64,
|
|
|
|
|
|
result: Arc<OnceLock<PromptImagePreviewResult>>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug)]
|
|
|
|
|
|
enum PromptImagePreviewResult {
|
|
|
|
|
|
Ready {
|
|
|
|
|
|
bytes: Arc<[u8]>,
|
|
|
|
|
|
dimensions: (u32, u32),
|
|
|
|
|
|
},
|
|
|
|
|
|
Failed,
|
|
|
|
|
|
Unsupported {
|
|
|
|
|
|
dimensions: (u32, u32),
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl Default for PromptImagePreview {
|
|
|
|
|
|
fn default() -> Self {
|
|
|
|
|
|
Self {
|
|
|
|
|
|
identity: crate::terminal::overlay::next_owner_id(),
|
|
|
|
|
|
result: Arc::new(OnceLock::new()),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl PromptImagePreview {
|
|
|
|
|
|
pub fn identity(&self) -> u64 {
|
|
|
|
|
|
self.identity
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn is_pending(&self) -> bool {
|
|
|
|
|
|
self.result.get().is_none()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn is_failed(&self) -> bool {
|
|
|
|
|
|
matches!(self.result.get(), Some(PromptImagePreviewResult::Failed))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn prepared(&self) -> Option<(&[u8], (u32, u32))> {
|
|
|
|
|
|
match self.result.get()? {
|
|
|
|
|
|
PromptImagePreviewResult::Ready { bytes, dimensions } => {
|
|
|
|
|
|
Some((bytes.as_ref(), *dimensions))
|
|
|
|
|
|
}
|
|
|
|
|
|
PromptImagePreviewResult::Failed | PromptImagePreviewResult::Unsupported { .. } => None,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn dimensions(&self) -> Option<(u32, u32)> {
|
|
|
|
|
|
match self.result.get()? {
|
|
|
|
|
|
PromptImagePreviewResult::Ready { dimensions, .. }
|
|
|
|
|
|
| PromptImagePreviewResult::Unsupported { dimensions } => Some(*dimensions),
|
|
|
|
|
|
PromptImagePreviewResult::Failed => None,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn finish(&self, result: PromptImagePreviewResult) {
|
|
|
|
|
|
let _ = self.result.set(result);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn mark_failed(&self) {
|
|
|
|
|
|
self.finish(PromptImagePreviewResult::Failed);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-16 20:27:30 +01:00
|
|
|
|
#[cfg(any(test, feature = "test-support"))]
|
2026-07-16 06:46:02 +01:00
|
|
|
|
pub fn ready_for_test(bytes: Vec<u8>, dimensions: (u32, u32)) -> Self {
|
|
|
|
|
|
let preview = Self::default();
|
|
|
|
|
|
preview.finish(PromptImagePreviewResult::Ready {
|
|
|
|
|
|
bytes: Arc::from(bytes),
|
|
|
|
|
|
dimensions,
|
|
|
|
|
|
});
|
|
|
|
|
|
preview
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Off-thread work required to prepare one prompt image preview.
|
|
|
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
|
pub struct PromptImagePreviewPreparation {
|
|
|
|
|
|
preview: PromptImagePreview,
|
|
|
|
|
|
source: Arc<[u8]>,
|
|
|
|
|
|
protocol: crate::terminal::image::GraphicsProtocol,
|
|
|
|
|
|
dimensions: Option<(u32, u32)>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl PromptImagePreviewPreparation {
|
|
|
|
|
|
pub fn preview(&self) -> PromptImagePreview {
|
|
|
|
|
|
self.preview.clone()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn run(self) {
|
|
|
|
|
|
let Some(dimensions) = self
|
|
|
|
|
|
.dimensions
|
|
|
|
|
|
.or_else(|| decode_image_dimensions(&self.source))
|
|
|
|
|
|
else {
|
|
|
|
|
|
self.preview.finish(PromptImagePreviewResult::Failed);
|
|
|
|
|
|
return;
|
|
|
|
|
|
};
|
|
|
|
|
|
let result = match self.protocol {
|
|
|
|
|
|
crate::terminal::image::GraphicsProtocol::Kitty => {
|
|
|
|
|
|
let bytes =
|
|
|
|
|
|
if crate::terminal::image::kitty_format_from_bytes(&self.source).is_some() {
|
|
|
|
|
|
self.source
|
|
|
|
|
|
} else {
|
|
|
|
|
|
let Some(converted) =
|
|
|
|
|
|
crate::terminal::image::prepare_kitty_overlay_image_bytes(&self.source)
|
|
|
|
|
|
else {
|
|
|
|
|
|
self.preview.finish(PromptImagePreviewResult::Failed);
|
|
|
|
|
|
return;
|
|
|
|
|
|
};
|
|
|
|
|
|
Arc::from(converted)
|
|
|
|
|
|
};
|
|
|
|
|
|
PromptImagePreviewResult::Ready { bytes, dimensions }
|
|
|
|
|
|
}
|
|
|
|
|
|
crate::terminal::image::GraphicsProtocol::ITerm2 => PromptImagePreviewResult::Ready {
|
|
|
|
|
|
bytes: self.source,
|
|
|
|
|
|
dimensions,
|
|
|
|
|
|
},
|
|
|
|
|
|
crate::terminal::image::GraphicsProtocol::None => {
|
|
|
|
|
|
PromptImagePreviewResult::Unsupported { dimensions }
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
self.preview.finish(result);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
// Display helpers
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
/// Build the buffer text for an image chip.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Always path-free: `[Image #1]`. Filepaths live on the [`PastedImage`]
|
|
|
|
|
|
/// record and surface only in the hover/cursor preview overlay — never in
|
|
|
|
|
|
/// the prompt-bar chip.
|
|
|
|
|
|
pub fn display_text(display_number: usize) -> String {
|
|
|
|
|
|
format!("[Image #{display_number}]")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Derive a file extension from a MIME type.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Delegates to [`xai_grok_shared::clipboard::mime_to_extension`].
|
|
|
|
|
|
pub fn extension_for_mime(mime: &str) -> &'static str {
|
|
|
|
|
|
mime_to_extension(mime)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
// Reconciliation
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
/// Remove entries from `images` whose `element_id` is not present in
|
|
|
|
|
|
/// `live_ids`.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// This is the primary mechanism that prevents deleted image chips from
|
|
|
|
|
|
/// being submitted. Call at cleanup boundaries (prompt clear, drain-for-send,
|
|
|
|
|
|
/// explicit chip deletion) rather than on every keystroke.
|
|
|
|
|
|
pub fn reconcile(images: &mut Vec<PastedImage>, live_ids: &HashSet<ElementId>) {
|
|
|
|
|
|
images.retain(|img| {
|
|
|
|
|
|
if live_ids.contains(&img.element_id) {
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
// Clean up temp-file-only staged images for removed chips.
|
|
|
|
|
|
// Session-persisted files are intentionally left as orphans in v1.
|
|
|
|
|
|
cleanup_temp_file(img);
|
|
|
|
|
|
false
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Drain `images`, cleaning up each entry's temp file. The caller
|
|
|
|
|
|
/// is responsible for resetting `image_counter` if appropriate —
|
|
|
|
|
|
/// resetting the counter is a separate semantic concern from clearing
|
|
|
|
|
|
/// a Vec's contents. Use [`reset_counter`] when the counter should
|
|
|
|
|
|
/// also be zeroed.
|
|
|
|
|
|
pub fn drain_and_cleanup(images: &mut Vec<PastedImage>) {
|
|
|
|
|
|
for img in images.drain(..) {
|
|
|
|
|
|
cleanup_temp_file(&img);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Reset the monotonic image counter to 0. Pair with
|
|
|
|
|
|
/// [`drain_and_cleanup`] when a prompt is fully reset (Ctrl+C,
|
|
|
|
|
|
/// `set_text("")`, successful send).
|
|
|
|
|
|
pub fn reset_counter(image_counter: &mut usize) {
|
|
|
|
|
|
*image_counter = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Drain images and reset the counter in one call. Prefer
|
|
|
|
|
|
/// [`drain_and_cleanup`] and [`reset_counter`] when only one side
|
|
|
|
|
|
/// is needed; this shim exists so "wipe everything" call sites
|
|
|
|
|
|
/// don't have to repeat the pair.
|
|
|
|
|
|
pub fn clear(images: &mut Vec<PastedImage>, image_counter: &mut usize) {
|
|
|
|
|
|
drain_and_cleanup(images);
|
|
|
|
|
|
reset_counter(image_counter);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Delete a staged temp file if it exists and no session-persisted copy
|
|
|
|
|
|
/// has been made. Session-persisted files are left intact (orphan cleanup
|
|
|
|
|
|
/// is acceptable in v1).
|
|
|
|
|
|
pub fn cleanup_temp_file(img: &PastedImage) {
|
|
|
|
|
|
if img.session_image_path.is_some() {
|
|
|
|
|
|
return; // already persisted to session dir, leave it
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(ref path) = img.staged_temp_path {
|
|
|
|
|
|
let _ = std::fs::remove_file(path);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
// Construction from file path
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
/// Image file extensions recognized when a pasted path is checked.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Formats omitted on purpose: HEIC/HEIF/AVIF/ICO/SVG. The inline
|
|
|
|
|
|
/// image overlay doesn't decode or render them today, so promoting
|
|
|
|
|
|
/// them to chips would falsely promise rendering. Drops of these
|
|
|
|
|
|
/// extensions fall through to NonImage path text instead.
|
|
|
|
|
|
const IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "tiff", "tif"];
|
|
|
|
|
|
|
|
|
|
|
|
/// Normalize a media path by dropping the Windows `\\?\` verbatim prefix
|
|
|
|
|
|
/// (`\\?\C:\x` → `C:\x`, `\\?\UNC\srv\s` → `\\srv\s`). Applied once when a
|
|
|
|
|
|
/// scrollback media ref is built so every consumer (display, open, copy) gets
|
|
|
|
|
|
/// a path that GUI openers and the clipboard can resolve. No-op off Windows.
|
|
|
|
|
|
fn strip_verbatim_prefix(path: &std::path::Path) -> PathBuf {
|
|
|
|
|
|
let s = path.to_string_lossy();
|
|
|
|
|
|
if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
|
|
|
|
|
|
PathBuf::from(format!(r"\\{rest}"))
|
|
|
|
|
|
} else if let Some(rest) = s.strip_prefix(r"\\?\") {
|
|
|
|
|
|
PathBuf::from(rest)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
path.to_path_buf()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Drive-letter (`X:\` / `X:/`) or UNC (`\\…`) Windows path. Used to
|
|
|
|
|
|
/// short-circuit [`shell_unescape`], which would otherwise treat the
|
|
|
|
|
|
/// separator backslashes as escape characters and collapse the path.
|
|
|
|
|
|
fn looks_like_windows_path(s: &str) -> bool {
|
|
|
|
|
|
let b = s.as_bytes();
|
|
|
|
|
|
let drive = b.len() >= 3
|
|
|
|
|
|
&& b[0].is_ascii_alphabetic()
|
|
|
|
|
|
&& b[1] == b':'
|
|
|
|
|
|
&& (b[2] == b'\\' || b[2] == b'/');
|
|
|
|
|
|
drive || b.starts_with(b"\\\\")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Strip shell backslash escapes (`\X` → `X`) so terminal-pasted file
|
|
|
|
|
|
/// paths with escaped spaces / parens resolve on disk. Windows-style
|
|
|
|
|
|
/// paths pass through unchanged — see [`looks_like_windows_path`].
|
|
|
|
|
|
fn shell_unescape(s: &str) -> std::borrow::Cow<'_, str> {
|
|
|
|
|
|
if !s.contains('\\') || looks_like_windows_path(s) {
|
|
|
|
|
|
return std::borrow::Cow::Borrowed(s);
|
|
|
|
|
|
}
|
|
|
|
|
|
let mut result = String::with_capacity(s.len());
|
|
|
|
|
|
let mut chars = s.chars();
|
|
|
|
|
|
while let Some(c) = chars.next() {
|
|
|
|
|
|
if c == '\\' {
|
|
|
|
|
|
match chars.next() {
|
|
|
|
|
|
Some(next) => result.push(next),
|
|
|
|
|
|
None => result.push(c),
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
result.push(c);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
std::borrow::Cow::Owned(result)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Strip a single pair of matching ASCII single or double quotes that
|
|
|
|
|
|
/// wrap `s`. Otherwise return `s` unchanged.
|
|
|
|
|
|
fn strip_matching_quotes(s: &str) -> &str {
|
|
|
|
|
|
let bytes = s.as_bytes();
|
|
|
|
|
|
if bytes.len() >= 2 {
|
|
|
|
|
|
let first = bytes[0];
|
|
|
|
|
|
let last = bytes[bytes.len() - 1];
|
|
|
|
|
|
if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
|
|
|
|
|
|
return &s[1..s.len() - 1];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
s
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Resolve one paste token to a filesystem path.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Accepts bare paths (with optional shell backslash escapes), `file://`
|
|
|
|
|
|
/// URLs (percent-decoded by the `url` crate), and paths wrapped in a
|
|
|
|
|
|
/// single pair of `"…"` or `'…'` quotes. Returns `None` if a `file://`
|
|
|
|
|
|
/// prefix is present but the URL is not parseable as a local path.
|
|
|
|
|
|
fn token_to_path(token: &str) -> Option<PathBuf> {
|
|
|
|
|
|
let token = token.trim();
|
|
|
|
|
|
if token.is_empty() {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
let unquoted = strip_matching_quotes(token);
|
|
|
|
|
|
|
|
|
|
|
|
if unquoted.starts_with("file://") {
|
|
|
|
|
|
let url = url::Url::parse(unquoted).ok()?;
|
|
|
|
|
|
if url.scheme() != "file" {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
return url.to_file_path().ok();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let unescaped = shell_unescape(unquoted);
|
|
|
|
|
|
Some(PathBuf::from(unescaped.into_owned()))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Validate that `path` points to a readable image file and load it as
|
|
|
|
|
|
/// a [`PastedImage`]. Returns `None` if the extension isn't recognized,
|
|
|
|
|
|
/// the file is missing, empty, or whose bytes don't sniff as an image.
|
|
|
|
|
|
fn read_image_at_path(path: &std::path::Path) -> Option<PastedImage> {
|
|
|
|
|
|
let ext = path.extension()?.to_str()?.to_ascii_lowercase();
|
|
|
|
|
|
if !IMAGE_EXTENSIONS.contains(&ext.as_str()) {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
if !path.is_file() {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let data = std::fs::read(path).ok()?;
|
|
|
|
|
|
if data.is_empty() {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let mime_type = xai_grok_shared::clipboard::mime_from_bytes(&data);
|
|
|
|
|
|
if mime_type == "application/octet-stream" {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
let dimensions = decode_image_dimensions(&data)?;
|
|
|
|
|
|
|
|
|
|
|
|
Some(PastedImage {
|
|
|
|
|
|
element_id: ElementId::from_raw(0),
|
|
|
|
|
|
display_number: 0,
|
|
|
|
|
|
mime_type: mime_type.to_owned(),
|
|
|
|
|
|
dimensions: Some(dimensions),
|
|
|
|
|
|
byte_len: data.len(),
|
|
|
|
|
|
encoded_bytes: Some(Arc::from(data)),
|
|
|
|
|
|
source_path: Some(path.to_path_buf()),
|
|
|
|
|
|
staged_temp_path: None,
|
|
|
|
|
|
session_image_path: None,
|
|
|
|
|
|
preview: PromptImagePreview::default(),
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Whether `s` begins with a drop-style path anchor: `/`, `~/`, a
|
|
|
|
|
|
/// Windows drive (`X:\` or `X:/`), or a Windows UNC (`\\`). ASCII-only
|
|
|
|
|
|
/// so it never inspects a partial UTF-8 codepoint.
|
|
|
|
|
|
fn starts_with_path_anchor(s: &str) -> bool {
|
|
|
|
|
|
let b = s.as_bytes();
|
|
|
|
|
|
matches!(b.first(), Some(b'/'))
|
|
|
|
|
|
|| b.starts_with(b"~/")
|
|
|
|
|
|
|| (b.len() >= 3
|
|
|
|
|
|
&& b[0].is_ascii_alphabetic()
|
|
|
|
|
|
&& b[1] == b':'
|
|
|
|
|
|
&& (b[2] == b'\\' || b[2] == b'/'))
|
|
|
|
|
|
|| b.starts_with(b"\\\\")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Whether `s` begins with something the space-splitter should treat as
|
|
|
|
|
|
/// a path token boundary: a bare path anchor, a `file://` URL, or a
|
|
|
|
|
|
/// quoted form of either (quotes are stripped before re-checking).
|
|
|
|
|
|
pub fn starts_with_drop_anchor(s: &str) -> bool {
|
|
|
|
|
|
if starts_with_path_anchor(s) || s.starts_with("file://") {
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
let unq = strip_matching_quotes(s);
|
|
|
|
|
|
!std::ptr::eq(unq, s) && (starts_with_path_anchor(unq) || unq.starts_with("file://"))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Split `s` on each space that is immediately followed by a drop-style
|
|
|
|
|
|
/// anchor: a bare path start (`/`, `~/`, `X:\`) or a `file://` URL.
|
|
|
|
|
|
/// Operates on ASCII bytes (the only characters we need to match) and
|
|
|
|
|
|
/// never splits inside a multi-byte UTF-8 sequence because all match
|
|
|
|
|
|
/// bytes are ASCII.
|
|
|
|
|
|
fn split_space_before_path(s: &str) -> Vec<&str> {
|
|
|
|
|
|
let bytes = s.as_bytes();
|
|
|
|
|
|
let mut parts = Vec::new();
|
|
|
|
|
|
let mut start = 0;
|
|
|
|
|
|
let mut i = 0;
|
|
|
|
|
|
while i < bytes.len() {
|
|
|
|
|
|
if bytes[i] == b' ' && starts_with_drop_anchor(&s[i + 1..]) {
|
|
|
|
|
|
parts.push(&s[start..i]);
|
|
|
|
|
|
start = i + 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
i += 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
parts.push(&s[start..]);
|
|
|
|
|
|
parts
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Tokenize one trimmed line into one or more path candidates.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Returns the whole line as a single token unless every space-separated
|
|
|
|
|
|
/// part itself starts with a drop-style anchor — the all-parts gate
|
|
|
|
|
|
/// keeps prose like `"check /tmp/foo.png please"` or bash pastes like
|
|
|
|
|
|
/// `"! /tmp/foo.png"` from being mis-split. Empty input yields an empty
|
|
|
|
|
|
/// `Vec` so the caller's `flat_map` skips blank lines cleanly.
|
|
|
|
|
|
fn space_split_line(line: &str) -> Vec<&str> {
|
|
|
|
|
|
let line = line.trim();
|
|
|
|
|
|
if line.is_empty() {
|
|
|
|
|
|
return Vec::new();
|
|
|
|
|
|
}
|
|
|
|
|
|
let parts: Vec<&str> = split_space_before_path(line)
|
|
|
|
|
|
.into_iter()
|
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
|
.filter(|s| !s.is_empty())
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
if parts.len() > 1 && parts.iter().all(|p| starts_with_drop_anchor(p)) {
|
|
|
|
|
|
parts
|
|
|
|
|
|
} else {
|
|
|
|
|
|
vec![line]
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Normalize line endings (`\r\n`/`\r` → `\n`) without allocating when
|
|
|
|
|
|
/// the input has no carriage returns (the common case from macOS).
|
|
|
|
|
|
fn normalize_line_endings(text: &str) -> std::borrow::Cow<'_, str> {
|
|
|
|
|
|
if text.contains('\r') {
|
|
|
|
|
|
std::borrow::Cow::Owned(text.replace("\r\n", "\n").replace('\r', "\n"))
|
|
|
|
|
|
} else {
|
|
|
|
|
|
std::borrow::Cow::Borrowed(text)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Filter the output of [`try_read_dropped_paths`] down to image
|
|
|
|
|
|
/// entries only — kept as a stable API for prior image-only callers.
|
|
|
|
|
|
/// See [`try_read_dropped_paths`] for the full tokenisation and
|
|
|
|
|
|
/// decoding behaviour spec.
|
|
|
|
|
|
pub fn try_read_images_from_paste(text: &str) -> Vec<PastedImage> {
|
|
|
|
|
|
try_read_dropped_paths(text)
|
|
|
|
|
|
.into_iter()
|
|
|
|
|
|
.filter_map(|d| match d {
|
|
|
|
|
|
DroppedPath::Image(img) => Some(img),
|
|
|
|
|
|
DroppedPath::NonImage(_) => None,
|
|
|
|
|
|
})
|
|
|
|
|
|
.collect()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Classification of a single drop-style paste token.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Returned by [`try_read_dropped_paths`]. Images are routed to
|
|
|
|
|
|
/// `[Image #N]` chip insertion; non-images are inserted as decoded
|
|
|
|
|
|
/// absolute path text so the user can reference them by path / let the
|
|
|
|
|
|
/// agent read them.
|
|
|
|
|
|
#[derive(Debug)]
|
|
|
|
|
|
pub enum DroppedPath {
|
|
|
|
|
|
/// Token resolved to a readable image file (extension in
|
|
|
|
|
|
/// [`IMAGE_EXTENSIONS`] and bytes sniff as a known image format).
|
|
|
|
|
|
Image(PastedImage),
|
|
|
|
|
|
/// Token resolved to a `file://` URL or an existing on-disk path
|
|
|
|
|
|
/// (file *or* directory) that is not a recognised image — the
|
|
|
|
|
|
/// caller should insert this decoded path as plain text in the
|
|
|
|
|
|
/// prompt.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// The stored `PathBuf` is canonicalised when possible
|
|
|
|
|
|
/// (`canonicalize()` succeeds) so symlinks resolve to their target,
|
|
|
|
|
|
/// matching the image branch's `read_image_at_path` behaviour. The
|
|
|
|
|
|
/// raw decoded path is used as a fallback when canonicalisation
|
|
|
|
|
|
/// fails (broken symlinks, permission errors, network mounts that
|
|
|
|
|
|
/// are unreachable, or `file://` URLs to non-existent paths).
|
|
|
|
|
|
#[allow(dead_code)]
|
|
|
|
|
|
NonImage(PathBuf),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Resolve one paste token to either an image chip, a non-image path
|
|
|
|
|
|
/// for text insertion, or `None` when the token does not look like a
|
|
|
|
|
|
/// drop-event path at all.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Non-image bare paths must satisfy *three* conditions to be
|
|
|
|
|
|
/// intercepted: the token must start with a drop anchor (`/`, `~/`,
|
|
|
|
|
|
/// `X:\`), the path must exist on disk (file OR directory), and the
|
|
|
|
|
|
/// raw bytes must not decode as an image (else the image branch wins).
|
|
|
|
|
|
/// The drop-anchor gate prevents arbitrary prose strings that happen
|
|
|
|
|
|
/// to coincide with a filesystem path from being intercepted from
|
|
|
|
|
|
/// inside a sentence. `file://` URLs bypass both the anchor gate and
|
|
|
|
|
|
/// the existence gate (an explicit URI is unambiguous drop intent).
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Predicate order: cheap anchor/`file://` checks run first; the
|
|
|
|
|
|
/// (relatively) more expensive [`read_image_at_path`] file-read +
|
|
|
|
|
|
/// magic-byte sniff runs only for tokens that pass the gate. Bare
|
|
|
|
|
|
/// cwd-relative image filenames are intentionally NOT intercepted —
|
|
|
|
|
|
/// drag-and-drop / Finder-paste always emit absolute paths or
|
|
|
|
|
|
/// `file://` URLs, never `foo.png`-style relative refs.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// **Silent fallthroughs**: a typo'd `file://` URL (missing path)
|
|
|
|
|
|
/// and an image-extension-but-garbage-bytes drop both land as text
|
|
|
|
|
|
/// with no toast. Drops can be partial (network mounts, broken
|
|
|
|
|
|
/// symlinks, corrupted exports) and a toast for every such case
|
|
|
|
|
|
/// would be noisier than the silent path-as-text behaviour the
|
|
|
|
|
|
/// user already gets.
|
|
|
|
|
|
fn try_read_dropped_path(token: &str) -> Option<DroppedPath> {
|
|
|
|
|
|
let trimmed_unq = strip_matching_quotes(token.trim());
|
|
|
|
|
|
let is_file_url = trimmed_unq.starts_with("file://");
|
|
|
|
|
|
let is_bare_anchored = starts_with_path_anchor(trimmed_unq);
|
|
|
|
|
|
if !is_file_url && !is_bare_anchored {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
let path = token_to_path(token)?;
|
|
|
|
|
|
// Reject empty or root paths. `file:///` decodes to an empty
|
|
|
|
|
|
// path on some platforms or to `/` on others; either way it is
|
|
|
|
|
|
// never a legitimate drop target (root would force the user to
|
|
|
|
|
|
// "attach the entire filesystem").
|
|
|
|
|
|
if path.as_os_str().is_empty() || path == std::path::Path::new("/") {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
// Reject the bytes that actually corrupt the terminal/text-paste
|
|
|
|
|
|
// pipeline — NUL, CR, LF — produced by pathological encodings
|
|
|
|
|
|
// like `file:///path%00.png`. TAB and other low-control bytes
|
|
|
|
|
|
// are legal in Unix filenames and tolerated by the TUI text
|
|
|
|
|
|
// rendering, so they pass through.
|
|
|
|
|
|
if path
|
|
|
|
|
|
.as_os_str()
|
|
|
|
|
|
.as_encoded_bytes()
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.any(|&b| b == 0 || b == b'\r' || b == b'\n')
|
|
|
|
|
|
{
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
// Image branch wins when extension + magic bytes both match —
|
|
|
|
|
|
// keeps the established "`[Image #N]` for image drops" behaviour
|
|
|
|
|
|
// for tokens that already passed the anchor / `file://` gate.
|
|
|
|
|
|
if let Some(img) = read_image_at_path(&path) {
|
|
|
|
|
|
return Some(DroppedPath::Image(img));
|
|
|
|
|
|
}
|
|
|
|
|
|
// `file://` is an unambiguous drop URI even when the file is
|
|
|
|
|
|
// missing (stale links, network mounts). Bare anchored paths
|
|
|
|
|
|
// require the target to *exist* (file or directory) so that prose
|
|
|
|
|
|
// typed at the path-anchor start, e.g. `/tmp is a dir on Unix`,
|
|
|
|
|
|
// falls through to plain text paste.
|
|
|
|
|
|
if !is_file_url && !path.exists() {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
// Canonicalise when possible so the inserted text matches what
|
|
|
|
|
|
// other code paths see (e.g. cwd-relative comparisons). Fall back
|
|
|
|
|
|
// to the raw decoded path when canonicalisation fails (broken
|
|
|
|
|
|
// symlinks, permission issues, network mounts, `file://` URLs to
|
|
|
|
|
|
// missing files).
|
|
|
|
|
|
let resolved = dunce::canonicalize(&path).unwrap_or(path);
|
|
|
|
|
|
Some(DroppedPath::NonImage(resolved))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Parse `text` from a terminal paste into a list of [`DroppedPath`]
|
|
|
|
|
|
/// entries — images and non-image file paths interleaved in the order
|
|
|
|
|
|
/// they appeared.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// This is the superset routine used by the drag-and-drop / Finder-paste
|
|
|
|
|
|
/// pipeline. It handles `file://` URLs (percent-decoded, including
|
|
|
|
|
|
/// `%20`/`%23`/`%3F` etc.), bare absolute paths, shell-escaped tokens,
|
|
|
|
|
|
/// quoted tokens, and multi-file payloads (newline- or space-separated).
|
|
|
|
|
|
/// Trailing whitespace and CRLF/CR line endings are tolerated.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Non-image bare paths are only intercepted when the token itself
|
|
|
|
|
|
/// begins with a drop anchor (`/`, `~/`, `X:\`) **and** the path
|
|
|
|
|
|
/// exists on disk. This guards against prose that happens to coincide
|
|
|
|
|
|
/// with a filesystem path being eaten from inside a sentence. See
|
|
|
|
|
|
/// [`try_read_dropped_path`] for the full predicate.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// **Whole-paste-or-nothing.** A paste of
|
|
|
|
|
|
/// `"file:///foo.png\nplease look at this"` must not emit just the
|
|
|
|
|
|
/// image and silently lose the comment — "screenshot URL + caption"
|
|
|
|
|
|
/// pastes are common in practice (browser/Slack right-click
|
|
|
|
|
|
/// "Copy image address" plus hand-typed prose). The function
|
|
|
|
|
|
/// returns an empty `Vec` if *any* non-whitespace line fails to
|
|
|
|
|
|
/// resolve to a drop, so the caller falls through to plain-text
|
|
|
|
|
|
/// paste of the whole payload.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Concretely:
|
|
|
|
|
|
/// - Empty/whitespace lines are separators (skipped).
|
|
|
|
|
|
/// - For each non-empty line, all tokens emitted by
|
|
|
|
|
|
/// [`space_split_line`] must resolve. If any fail, the whole paste
|
|
|
|
|
|
/// falls through to prose.
|
|
|
|
|
|
/// - If every non-empty line fully resolves, entries are emitted in
|
|
|
|
|
|
/// source order.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Returns an empty `Vec` when no token resolves to either an image
|
|
|
|
|
|
/// or a recognised file path — callers should then treat the payload
|
|
|
|
|
|
/// as a plain text paste.
|
|
|
|
|
|
pub fn try_read_dropped_paths(text: &str) -> Vec<DroppedPath> {
|
|
|
|
|
|
let trimmed = text.trim();
|
|
|
|
|
|
if trimmed.is_empty() {
|
|
|
|
|
|
return Vec::new();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let normalized = normalize_line_endings(trimmed);
|
|
|
|
|
|
let mut result = Vec::new();
|
|
|
|
|
|
for line in normalized.split('\n') {
|
|
|
|
|
|
let tokens = space_split_line(line);
|
|
|
|
|
|
if tokens.is_empty() {
|
|
|
|
|
|
// Blank line — treat as separator.
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
let resolved: Vec<DroppedPath> = tokens
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.filter_map(|t| try_read_dropped_path(t))
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
// Any-line-fails-falls-through: even a single-token line that
|
|
|
|
|
|
// doesn't resolve poisons the whole paste so the user's prose
|
|
|
|
|
|
// is preserved via the caller's plain-text-paste fallback.
|
|
|
|
|
|
if resolved.len() < tokens.len() {
|
|
|
|
|
|
return Vec::new();
|
|
|
|
|
|
}
|
|
|
|
|
|
result.extend(resolved);
|
|
|
|
|
|
}
|
|
|
|
|
|
let (images, non_images) = result.iter().fold((0usize, 0usize), |(i, n), e| match e {
|
|
|
|
|
|
DroppedPath::Image(_) => (i + 1, n),
|
|
|
|
|
|
DroppedPath::NonImage(_) => (i, n + 1),
|
|
|
|
|
|
});
|
|
|
|
|
|
tracing::debug!(
|
|
|
|
|
|
target: PROMPT_IMAGES_TRACING_TARGET,
|
|
|
|
|
|
input_len = text.len(),
|
|
|
|
|
|
anchor = paste_anchor_kind(trimmed),
|
|
|
|
|
|
images,
|
|
|
|
|
|
non_images,
|
|
|
|
|
|
"try_read_dropped_paths verdict",
|
|
|
|
|
|
);
|
|
|
|
|
|
result
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Categorical label for the leading bytes of a paste, used in
|
|
|
|
|
|
/// diagnostic logging only. Lossy by design — the classifier itself
|
|
|
|
|
|
/// re-derives the real predicate.
|
|
|
|
|
|
fn paste_anchor_kind(trimmed: &str) -> &'static str {
|
|
|
|
|
|
if trimmed.starts_with("file://") {
|
|
|
|
|
|
"file_url"
|
|
|
|
|
|
} else if trimmed.starts_with('/') {
|
|
|
|
|
|
"absolute"
|
|
|
|
|
|
} else if trimmed.starts_with("~/") {
|
|
|
|
|
|
"tilde"
|
|
|
|
|
|
} else if trimmed.len() >= 3
|
|
|
|
|
|
&& trimmed.as_bytes()[0].is_ascii_alphabetic()
|
|
|
|
|
|
&& &trimmed.as_bytes()[1..3] == b":\\"
|
|
|
|
|
|
{
|
|
|
|
|
|
"windows_drive"
|
|
|
|
|
|
} else if trimmed.starts_with("\\\\") {
|
|
|
|
|
|
"windows_unc"
|
|
|
|
|
|
} else {
|
|
|
|
|
|
"none"
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Check whether `text` looks like a single file path to an image that
|
|
|
|
|
|
/// exists on disk. Returns the loaded image if so.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Thin wrapper over [`try_read_images_from_paste`] that returns `Some`
|
|
|
|
|
|
/// only when the paste resolves to exactly one image; returns `None`
|
|
|
|
|
|
/// for zero or multiple images. Trailing whitespace (including a single
|
|
|
|
|
|
/// trailing `\n` or `\r\n`) is tolerated; multi-image payloads return
|
|
|
|
|
|
/// `None` so the caller can route them through the multi-image helper
|
|
|
|
|
|
/// instead.
|
|
|
|
|
|
pub fn try_read_image_from_path(text: &str) -> Option<PastedImage> {
|
|
|
|
|
|
let mut images = try_read_images_from_paste(text);
|
|
|
|
|
|
if images.len() == 1 {
|
|
|
|
|
|
Some(images.remove(0))
|
|
|
|
|
|
} else {
|
|
|
|
|
|
None
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
// Construction from clipboard data
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
/// Build a `PastedImage` from raw clipboard [`ImageData`].
|
|
|
|
|
|
///
|
|
|
|
|
|
/// `element_id` and `display_number` are set to placeholder values and
|
|
|
|
|
|
/// will be overwritten by [`crate::views::prompt_widget::PromptWidget::insert_image`].
|
|
|
|
|
|
pub fn from_clipboard_data(data: &crate::clipboard::ImageData) -> PastedImage {
|
|
|
|
|
|
PastedImage {
|
|
|
|
|
|
element_id: ElementId::from_raw(0),
|
|
|
|
|
|
display_number: 0,
|
|
|
|
|
|
mime_type: data.mime_type.clone(),
|
|
|
|
|
|
dimensions: decode_image_dimensions(&data.data),
|
|
|
|
|
|
byte_len: data.data.len(),
|
|
|
|
|
|
encoded_bytes: Some(Arc::from(data.data.clone())),
|
|
|
|
|
|
source_path: None,
|
|
|
|
|
|
staged_temp_path: None,
|
|
|
|
|
|
session_image_path: None,
|
|
|
|
|
|
preview: PromptImagePreview::default(),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
// Session image persistence
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
/// Persist image bytes into the session `images/` directory.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Creates the directory if it does not exist. Uses a UUID-v4 filename
|
|
|
|
|
|
/// to avoid collisions (two pastes of the same image are independent).
|
|
|
|
|
|
///
|
|
|
|
|
|
/// On success:
|
|
|
|
|
|
/// - Sets `img.session_image_path` to the written file.
|
|
|
|
|
|
/// - Leaves `img.source_path` unchanged as the original display path.
|
|
|
|
|
|
/// - Drops `encoded_bytes` from memory.
|
|
|
|
|
|
pub fn persist_to_session(
|
|
|
|
|
|
img: &mut PastedImage,
|
|
|
|
|
|
session_images_dir: &std::path::Path,
|
|
|
|
|
|
) -> anyhow::Result<()> {
|
|
|
|
|
|
let bytes = img
|
|
|
|
|
|
.encoded_bytes
|
|
|
|
|
|
.as_ref()
|
|
|
|
|
|
.ok_or_else(|| anyhow::anyhow!("no encoded bytes to persist"))?;
|
|
|
|
|
|
|
|
|
|
|
|
std::fs::create_dir_all(session_images_dir)?;
|
|
|
|
|
|
|
|
|
|
|
|
let ext = extension_for_mime(&img.mime_type);
|
|
|
|
|
|
let filename = format!("image-{}.{}", uuid::Uuid::new_v4(), ext);
|
|
|
|
|
|
let path = session_images_dir.join(&filename);
|
|
|
|
|
|
// Atomic write: a crash mid-write leaves no partially-written final file.
|
|
|
|
|
|
let tmp_path = path.with_extension(format!("{ext}.tmp"));
|
|
|
|
|
|
let write_result: anyhow::Result<()> = (|| {
|
|
|
|
|
|
use std::io::Write as _;
|
|
|
|
|
|
let mut f = std::fs::File::create(&tmp_path)?;
|
|
|
|
|
|
f.write_all(bytes)?;
|
|
|
|
|
|
f.sync_all()?;
|
|
|
|
|
|
drop(f);
|
|
|
|
|
|
std::fs::rename(&tmp_path, &path)?;
|
|
|
|
|
|
Ok(())
|
|
|
|
|
|
})();
|
|
|
|
|
|
if write_result.is_err() {
|
|
|
|
|
|
let _ = std::fs::remove_file(&tmp_path);
|
|
|
|
|
|
}
|
|
|
|
|
|
write_result?;
|
|
|
|
|
|
|
|
|
|
|
|
img.session_image_path = Some(path);
|
|
|
|
|
|
img.encoded_bytes = None;
|
|
|
|
|
|
Ok(())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Derive the `images/` directory for a session.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Returns `None` if session identity is not yet known.
|
|
|
|
|
|
pub fn session_images_dir(
|
|
|
|
|
|
session_id: Option<&agent_client_protocol::SessionId>,
|
|
|
|
|
|
cwd: &std::path::Path,
|
|
|
|
|
|
) -> Option<PathBuf> {
|
|
|
|
|
|
let sid = session_id?;
|
|
|
|
|
|
let info = xai_grok_shared::session::info::Info {
|
|
|
|
|
|
id: sid.clone(),
|
|
|
|
|
|
cwd: cwd.to_string_lossy().into_owned(),
|
|
|
|
|
|
};
|
|
|
|
|
|
Some(xai_grok_shared::session::session_dir(&info).join("images"))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Derive the `mermaid/` cache directory for a session.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Mirrors [`session_images_dir`]: rendered diagram PNGs live alongside the
|
|
|
|
|
|
/// session's other artifacts (`events.jsonl`, `images/`) so they are owned by
|
|
|
|
|
|
/// the session and torn down with it. Returns `None` until session identity is
|
|
|
|
|
|
/// known (no diagrams are cached on disk before then).
|
|
|
|
|
|
pub fn session_mermaid_dir(
|
|
|
|
|
|
session_id: Option<&agent_client_protocol::SessionId>,
|
|
|
|
|
|
cwd: &std::path::Path,
|
|
|
|
|
|
) -> Option<PathBuf> {
|
|
|
|
|
|
let sid = session_id?;
|
|
|
|
|
|
let info = xai_grok_shared::session::info::Info {
|
|
|
|
|
|
id: sid.clone(),
|
|
|
|
|
|
cwd: cwd.to_string_lossy().into_owned(),
|
|
|
|
|
|
};
|
|
|
|
|
|
Some(xai_grok_shared::session::session_dir(&info).join("mermaid"))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
// Image loading for send
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
const MAX_SEND_BYTES: usize = 50_000_000; // 50 MB
|
|
|
|
|
|
|
|
|
|
|
|
/// Load image bytes from a `PastedImage` (in-memory or from disk).
|
|
|
|
|
|
/// Returns `None` if the image cannot be loaded or exceeds [`MAX_SEND_BYTES`].
|
|
|
|
|
|
pub fn load_for_send(img: &PastedImage) -> Option<(Vec<u8>, String)> {
|
|
|
|
|
|
let raw_bytes = if let Some(ref b) = img.encoded_bytes {
|
|
|
|
|
|
b.to_vec()
|
|
|
|
|
|
} else if let Some(ref path) = img.session_image_path {
|
|
|
|
|
|
match std::fs::read(path) {
|
|
|
|
|
|
Ok(b) => b,
|
|
|
|
|
|
Err(e) => {
|
|
|
|
|
|
tracing::warn!(
|
|
|
|
|
|
"image {}: failed to read {}: {e}",
|
|
|
|
|
|
img.display_number,
|
|
|
|
|
|
path.display()
|
|
|
|
|
|
);
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
tracing::warn!("image {}: no bytes or file path", img.display_number);
|
|
|
|
|
|
return None;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if raw_bytes.len() > MAX_SEND_BYTES {
|
|
|
|
|
|
tracing::warn!(
|
|
|
|
|
|
"image {}: {} bytes exceeds send limit, skipping",
|
|
|
|
|
|
img.display_number,
|
|
|
|
|
|
raw_bytes.len()
|
|
|
|
|
|
);
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some((width, height)) = img.dimensions
|
|
|
|
|
|
&& (width < 8 || height < 8)
|
|
|
|
|
|
{
|
|
|
|
|
|
tracing::warn!(
|
|
|
|
|
|
"image {}: dimensions {}x{} are below the 8x8 minimum",
|
|
|
|
|
|
img.display_number,
|
|
|
|
|
|
width,
|
|
|
|
|
|
height
|
|
|
|
|
|
);
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Some((raw_bytes, img.mime_type.clone()))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
// ACP content block construction
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
/// Build ACP `ContentBlock` values from prompt text and attached images,
|
|
|
|
|
|
/// with an optional fallback that re-loads orphan
|
|
|
|
|
|
/// `[Image #N: <path>]` placeholders from disk.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Behaviour for each placeholder in `text`:
|
|
|
|
|
|
/// - If a [`PastedImage`] with the matching `display_number` is present
|
|
|
|
|
|
/// in `images`, the placeholder is left untouched and the
|
|
|
|
|
|
/// `PastedImage` provides the bytes.
|
|
|
|
|
|
/// - Otherwise, if `workspace_cwd` is `Some`, attempt to load the
|
|
|
|
|
|
/// placeholder's path via the shared
|
|
|
|
|
|
/// [`xai_grok_shell::session::placeholder_images::load_placeholder_image`]
|
|
|
|
|
|
/// helper. On success: attach a `ContentBlock::Image` and leave the
|
|
|
|
|
|
/// placeholder text in place. On failure: strip the placeholder from
|
|
|
|
|
|
/// the forwarded text and emit a `tracing::warn!` (no UI alert
|
|
|
|
|
|
/// surface exists today — the warn log is the established pattern,
|
|
|
|
|
|
/// see `load_for_send` for prior art).
|
|
|
|
|
|
/// - When `workspace_cwd` is `None`, the orphan placeholder is left in
|
|
|
|
|
|
/// the text unchanged (legacy behaviour, used by unit tests).
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Path validation, extension allowlist, and the 50-MB size cap come
|
|
|
|
|
|
/// from the shared helper so the TUI and the server use the same rules.
|
|
|
|
|
|
pub fn build_content_blocks_with_workspace(
|
|
|
|
|
|
text: String,
|
|
|
|
|
|
images: Vec<PastedImage>,
|
|
|
|
|
|
workspace_cwd: Option<&std::path::Path>,
|
|
|
|
|
|
) -> Vec<agent_client_protocol::ContentBlock> {
|
|
|
|
|
|
let allowed: Option<Vec<std::path::PathBuf>> =
|
|
|
|
|
|
workspace_cwd.map(xai_grok_shared::placeholder_images::default_allowed_prefixes);
|
|
|
|
|
|
build_content_blocks_with_prefixes(text, images, allowed.as_deref())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Test-injectable variant of [`build_content_blocks_with_workspace`].
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Accepts an explicit `allowed_prefixes` slice so unit tests can
|
|
|
|
|
|
/// pass a hermetic prefix list and avoid reading the ambient process
|
|
|
|
|
|
/// `$HOME`. Production calls go through
|
|
|
|
|
|
/// [`build_content_blocks_with_workspace`].
|
|
|
|
|
|
pub fn build_content_blocks_with_prefixes(
|
|
|
|
|
|
text: String,
|
|
|
|
|
|
images: Vec<PastedImage>,
|
|
|
|
|
|
allowed_prefixes: Option<&[std::path::PathBuf]>,
|
|
|
|
|
|
) -> Vec<agent_client_protocol::ContentBlock> {
|
|
|
|
|
|
build_content_blocks_with_prefixes_and_caps(
|
|
|
|
|
|
text,
|
|
|
|
|
|
images,
|
|
|
|
|
|
allowed_prefixes,
|
|
|
|
|
|
xai_grok_shared::placeholder_images::MAX_PLACEHOLDER_AGGREGATE_BYTES,
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Test-injectable variant of [`build_content_blocks_with_prefixes`]
|
|
|
|
|
|
/// that takes an explicit aggregate-bytes cap.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Mirrors the server-side
|
|
|
|
|
|
/// [`xai_grok_shell::session::placeholder_images::recover_orphan_placeholders_with_prefixes_and_caps`].
|
|
|
|
|
|
/// Aggregate-cap semantics match: `aggregate + image.len() > cap`
|
|
|
|
|
|
/// triggers the loop break (inclusive boundary — a running total
|
|
|
|
|
|
/// exactly equal to the cap is admitted).
|
|
|
|
|
|
pub fn build_content_blocks_with_prefixes_and_caps(
|
|
|
|
|
|
text: String,
|
|
|
|
|
|
images: Vec<PastedImage>,
|
|
|
|
|
|
allowed_prefixes: Option<&[std::path::PathBuf]>,
|
|
|
|
|
|
aggregate_max: usize,
|
|
|
|
|
|
) -> Vec<agent_client_protocol::ContentBlock> {
|
|
|
|
|
|
use agent_client_protocol::{ContentBlock, ImageContent, TextContent};
|
|
|
|
|
|
use base64::Engine as _;
|
|
|
|
|
|
|
|
|
|
|
|
// Phase 1: rewrite the text to strip failed-load placeholders, and
|
|
|
|
|
|
// collect successfully-loaded orphan images. PastedImage-backed
|
|
|
|
|
|
// placeholders (display_number present in `images`) are left alone.
|
|
|
|
|
|
let (rewritten_text, orphan_images) =
|
|
|
|
|
|
resolve_orphan_placeholders(text, &images, allowed_prefixes, aggregate_max);
|
|
|
|
|
|
|
|
|
|
|
|
// Phase 2: drop `[Image #N: <path>]` → `[Image #N]`. The path
|
|
|
|
|
|
// tempts the model into a redundant `Read` on its own
|
|
|
|
|
|
// attachment.
|
|
|
|
|
|
let rewritten_text =
|
|
|
|
|
|
xai_grok_shared::placeholder_images::strip_paths_from_image_placeholders(rewritten_text);
|
|
|
|
|
|
|
|
|
|
|
|
let mut blocks = Vec::with_capacity(1 + images.len() + orphan_images.len());
|
|
|
|
|
|
blocks.push(ContentBlock::Text(TextContent::new(rewritten_text)));
|
|
|
|
|
|
|
|
|
|
|
|
for img in &images {
|
|
|
|
|
|
let (bytes, mime_type) = match load_for_send(img) {
|
|
|
|
|
|
Some(loaded) => loaded,
|
|
|
|
|
|
None => continue,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let data = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
|
|
|
|
|
|
|
|
|
|
|
// Canonicalize the display path only when no durable wire path exists.
|
|
|
|
|
|
let uri = if let Some(path) = img.session_image_path.as_ref() {
|
|
|
|
|
|
Some(format!("file://{}", path.display()))
|
|
|
|
|
|
} else {
|
|
|
|
|
|
img.source_path
|
|
|
|
|
|
.as_ref()
|
|
|
|
|
|
.and_then(|path| dunce::canonicalize(path).ok())
|
|
|
|
|
|
.map(|canonical| format!("file://{}", canonical.display()))
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
blocks.push(ContentBlock::Image(
|
|
|
|
|
|
ImageContent::new(data, mime_type)
|
|
|
|
|
|
.uri(uri)
|
|
|
|
|
|
// Record the `[Image #N]` display number so the server resolves
|
|
|
|
|
|
// the token by number, not list position. See `AttachedImages`.
|
|
|
|
|
|
.meta(Some(
|
|
|
|
|
|
xai_grok_shared::placeholder_images::display_number_meta(img.display_number),
|
|
|
|
|
|
)),
|
|
|
|
|
|
));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for orphan in orphan_images {
|
|
|
|
|
|
blocks.push(ContentBlock::Image(orphan));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
blocks
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Scan `text` for `[Image #N: <path>]` placeholders that lack a
|
|
|
|
|
|
/// matching [`PastedImage`] and attempt to recover them from disk.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Returns `(rewritten_text, recovered_images)`:
|
|
|
|
|
|
/// - Placeholders with a matching `PastedImage` (by `display_number`)
|
|
|
|
|
|
/// are left untouched.
|
|
|
|
|
|
/// - Orphan placeholders whose path loads successfully via the shared
|
|
|
|
|
|
/// helper are kept in the text and produce a recovered
|
|
|
|
|
|
/// `ImageContent`.
|
|
|
|
|
|
/// - Orphan placeholders whose path fails to load are stripped from
|
|
|
|
|
|
/// the text and a `tracing::warn!` is emitted.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// `allowed_prefixes == None` short-circuits to the legacy behaviour:
|
|
|
|
|
|
/// the text is returned unchanged and no recovery is attempted.
|
|
|
|
|
|
fn resolve_orphan_placeholders(
|
|
|
|
|
|
text: String,
|
|
|
|
|
|
images: &[PastedImage],
|
|
|
|
|
|
allowed_prefixes: Option<&[std::path::PathBuf]>,
|
|
|
|
|
|
aggregate_max: usize,
|
|
|
|
|
|
) -> (String, Vec<agent_client_protocol::ImageContent>) {
|
|
|
|
|
|
use agent_client_protocol::ImageContent;
|
|
|
|
|
|
use base64::Engine as _;
|
|
|
|
|
|
|
|
|
|
|
|
let Some(allowed) = allowed_prefixes else {
|
|
|
|
|
|
return (text, Vec::new());
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let attached_numbers: std::collections::HashSet<usize> =
|
|
|
|
|
|
images.iter().map(|i| i.display_number).collect();
|
|
|
|
|
|
|
|
|
|
|
|
let placeholders = xai_grok_shared::placeholder_images::extract_placeholders(&text);
|
|
|
|
|
|
if placeholders.is_empty() {
|
|
|
|
|
|
return (text, Vec::new());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let mut recovered: Vec<ImageContent> = Vec::new();
|
|
|
|
|
|
// Spans to delete from the text (failed loads). Recorded as
|
|
|
|
|
|
// half-open byte ranges so we can splice them out in one pass.
|
|
|
|
|
|
let mut strip_spans: Vec<(usize, usize)> = Vec::new();
|
|
|
|
|
|
let mut aggregate_bytes: usize = 0;
|
|
|
|
|
|
|
|
|
|
|
|
for ph in &placeholders {
|
|
|
|
|
|
if attached_numbers.contains(&ph.display_number) {
|
|
|
|
|
|
continue; // PastedImage already supplies these bytes.
|
|
|
|
|
|
}
|
|
|
|
|
|
match xai_grok_shared::placeholder_images::load_placeholder_image(&ph.path, allowed) {
|
|
|
|
|
|
Ok(loaded) => {
|
|
|
|
|
|
let next_total = aggregate_bytes.saturating_add(loaded.data.len());
|
|
|
|
|
|
if next_total > aggregate_max {
|
|
|
|
|
|
tracing::warn!(
|
|
|
|
|
|
path = ?ph.path,
|
|
|
|
|
|
aggregate_bytes,
|
|
|
|
|
|
per_image_bytes = loaded.data.len(),
|
|
|
|
|
|
cap = aggregate_max,
|
|
|
|
|
|
"TUI placeholder fallback: aggregate-bytes cap reached; skipping remaining orphan placeholders",
|
|
|
|
|
|
);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
aggregate_bytes = next_total;
|
|
|
|
|
|
let data = base64::engine::general_purpose::STANDARD.encode(&loaded.data);
|
|
|
|
|
|
let uri = dunce::canonicalize(std::path::Path::new(&ph.path))
|
|
|
|
|
|
.ok()
|
|
|
|
|
|
.map(|p| format!("file://{}", p.display()));
|
|
|
|
|
|
recovered.push(
|
|
|
|
|
|
ImageContent::new(data, loaded.mime_type)
|
|
|
|
|
|
.uri(uri)
|
|
|
|
|
|
// Same `[Image #N]` → number mapping as inline images.
|
|
|
|
|
|
.meta(Some(
|
|
|
|
|
|
xai_grok_shared::placeholder_images::display_number_meta(
|
|
|
|
|
|
ph.display_number,
|
|
|
|
|
|
),
|
|
|
|
|
|
)),
|
|
|
|
|
|
);
|
|
|
|
|
|
tracing::info!(
|
|
|
|
|
|
path = ?ph.path,
|
|
|
|
|
|
"TUI placeholder fallback: loaded orphan image from disk",
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
Err(e) => {
|
|
|
|
|
|
tracing::warn!(
|
|
|
|
|
|
path = ?ph.path,
|
|
|
|
|
|
error = %e,
|
|
|
|
|
|
"TUI placeholder fallback: orphan image failed to load; stripping placeholder text",
|
|
|
|
|
|
);
|
|
|
|
|
|
strip_spans.push(ph.span);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if strip_spans.is_empty() {
|
|
|
|
|
|
return (text, recovered);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Splice out the failed-load spans in reverse order so earlier
|
|
|
|
|
|
// indices stay valid. Only collapse the single whitespace seam
|
|
|
|
|
|
// created by the strip itself — global collapsing of all
|
|
|
|
|
|
// 2+-space runs would mangle code blocks, indentation-sensitive
|
|
|
|
|
|
// markdown, and double-space punctuation elsewhere in the text.
|
|
|
|
|
|
let mut rewritten = text;
|
|
|
|
|
|
strip_spans.sort_by_key(|(s, _)| *s);
|
|
|
|
|
|
for (start, end) in strip_spans.into_iter().rev() {
|
|
|
|
|
|
collapse_strip_seam(&mut rewritten, start, end);
|
|
|
|
|
|
}
|
|
|
|
|
|
(rewritten, recovered)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Splice out `[start, end)` from `text` and, only if both sides of
|
|
|
|
|
|
/// the seam are ASCII whitespace, collapse the run to a single space.
|
|
|
|
|
|
/// Newlines are preserved (treated as non-collapsible boundaries).
|
|
|
|
|
|
fn collapse_strip_seam(text: &mut String, start: usize, end: usize) {
|
|
|
|
|
|
text.replace_range(start..end, "");
|
|
|
|
|
|
// After removal `start` is the seam position. Walk left/right
|
|
|
|
|
|
// over the immediate space chars only; do not cross newlines or
|
|
|
|
|
|
// non-space whitespace (tab/CR).
|
|
|
|
|
|
let bytes = text.as_bytes();
|
|
|
|
|
|
let mut left = start;
|
|
|
|
|
|
while left > 0 && bytes.get(left - 1) == Some(&b' ') {
|
|
|
|
|
|
left -= 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
let mut right = start;
|
|
|
|
|
|
while right < bytes.len() && bytes.get(right) == Some(&b' ') {
|
|
|
|
|
|
right += 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
if right - left > 1 {
|
|
|
|
|
|
// Replace the run with a single space if there is content on
|
|
|
|
|
|
// both sides; otherwise (seam at start/end of text) trim
|
|
|
|
|
|
// entirely.
|
|
|
|
|
|
let has_left_content = left > 0;
|
|
|
|
|
|
let has_right_content = right < bytes.len();
|
|
|
|
|
|
let replacement = if has_left_content && has_right_content {
|
|
|
|
|
|
" "
|
|
|
|
|
|
} else {
|
|
|
|
|
|
""
|
|
|
|
|
|
};
|
|
|
|
|
|
text.replace_range(left..right, replacement);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
// Scrollback image references
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
/// An image file referenced in scrollback content via `` markdown
|
|
|
|
|
|
/// or a bare absolute path. Validated on construction: path must exist, have a
|
|
|
|
|
|
/// recognized image extension, and decode successfully.
|
|
|
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
|
pub struct ScrollbackImageRef {
|
|
|
|
|
|
/// Absolute path to the image file on disk.
|
|
|
|
|
|
pub path: PathBuf,
|
|
|
|
|
|
/// Pixel dimensions `(width, height)`, decoded on construction.
|
|
|
|
|
|
pub dimensions: Option<(u32, u32)>,
|
|
|
|
|
|
/// Alt text from the `` markdown syntax (empty for bare paths).
|
|
|
|
|
|
pub alt_text: String,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl ScrollbackImageRef {
|
|
|
|
|
|
/// Construct from a file path, returning `None` if the path doesn't
|
|
|
|
|
|
/// exist, isn't a file, lacks a recognized image extension, or can't
|
|
|
|
|
|
/// be decoded as an image.
|
|
|
|
|
|
pub fn from_path(path: impl Into<PathBuf>) -> Option<Self> {
|
|
|
|
|
|
Self::from_path_with_alt(path, String::new())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Construct with alt text from the markdown `` syntax.
|
|
|
|
|
|
pub fn from_path_with_alt(path: impl Into<PathBuf>, alt_text: String) -> Option<Self> {
|
|
|
|
|
|
let path = strip_verbatim_prefix(&path.into());
|
|
|
|
|
|
let ext = path.extension()?.to_str()?.to_ascii_lowercase();
|
|
|
|
|
|
if !IMAGE_EXTENSIONS.contains(&ext.as_str()) {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
if !path.is_file() {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
let bytes = std::fs::read(&path).ok()?;
|
|
|
|
|
|
if !is_decodable_image(&bytes) {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
let dimensions = decode_image_dimensions(&bytes);
|
|
|
|
|
|
Some(Self {
|
|
|
|
|
|
path,
|
|
|
|
|
|
dimensions,
|
|
|
|
|
|
alt_text,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Regex pattern for `` — captures alt text (group 1) and path (group 2).
|
|
|
|
|
|
const MARKDOWN_IMAGE_REF_PATTERN: &str = r"!\[([^\]]*)\]\(([^)\s]+)\)";
|
|
|
|
|
|
|
|
|
|
|
|
/// Whether text consists only of markdown media references (``).
|
|
|
|
|
|
///
|
|
|
|
|
|
/// `resolved_ref_count` is the total number of resolved media refs
|
|
|
|
|
|
/// (images + videos) extracted from the same text. Unresolved or
|
|
|
|
|
|
/// undecodable paths are not counted, preventing false positives.
|
|
|
|
|
|
pub fn is_media_only_markdown(text: &str, resolved_ref_count: usize) -> bool {
|
|
|
|
|
|
use std::sync::LazyLock;
|
|
|
|
|
|
|
|
|
|
|
|
if resolved_ref_count == 0 {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
static ONLY_MD_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
|
|
|
|
|
|
regex::Regex::new(&format!(r"^\s*(?:{}\s*)+$", MARKDOWN_IMAGE_REF_PATTERN)).unwrap()
|
|
|
|
|
|
});
|
|
|
|
|
|
static MD_RE: LazyLock<regex::Regex> =
|
|
|
|
|
|
LazyLock::new(|| regex::Regex::new(MARKDOWN_IMAGE_REF_PATTERN).unwrap());
|
|
|
|
|
|
|
|
|
|
|
|
if !ONLY_MD_RE.is_match(text) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let unique_ref_count = MD_RE
|
|
|
|
|
|
.captures_iter(text)
|
|
|
|
|
|
.filter_map(|cap| cap.get(2).map(|m| m.as_str()))
|
|
|
|
|
|
.collect::<std::collections::HashSet<_>>()
|
|
|
|
|
|
.len();
|
|
|
|
|
|
unique_ref_count == resolved_ref_count
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Extract image references from text (markdown or tool output).
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Scans for `` patterns and bare absolute image paths.
|
|
|
|
|
|
/// Only returns references where the file exists on disk and decodes as an
|
|
|
|
|
|
/// image.
|
|
|
|
|
|
pub fn extract_image_refs(text: &str) -> Vec<ScrollbackImageRef> {
|
|
|
|
|
|
use std::sync::LazyLock;
|
|
|
|
|
|
|
|
|
|
|
|
static MD_RE: LazyLock<regex::Regex> =
|
|
|
|
|
|
LazyLock::new(|| regex::Regex::new(MARKDOWN_IMAGE_REF_PATTERN).unwrap());
|
|
|
|
|
|
|
|
|
|
|
|
static PATH_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
|
|
|
|
|
|
let exts = IMAGE_EXTENSIONS.join("|");
|
|
|
|
|
|
// Unix absolute paths (/...) and Windows absolute paths (C:\..., C:/...,
|
|
|
|
|
|
// or \\... UNC). Drive letters accept either separator.
|
|
|
|
|
|
regex::Regex::new(&format!(
|
|
|
|
|
|
r"(?:^|[\s,])((?:/|[A-Za-z]:[\\/]|\\\\)[^\s,]+\.(?:{exts}))(?:[\s,.(]|$)"
|
|
|
|
|
|
))
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
let mut refs = Vec::new();
|
|
|
|
|
|
let mut seen = std::collections::HashSet::new();
|
|
|
|
|
|
|
|
|
|
|
|
for cap in MD_RE.captures_iter(text) {
|
|
|
|
|
|
if let Some(m) = cap.get(2) {
|
|
|
|
|
|
let path_str = m.as_str();
|
|
|
|
|
|
let alt_text = cap
|
|
|
|
|
|
.get(1)
|
|
|
|
|
|
.map(|a| a.as_str().to_owned())
|
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
|
if seen.insert(path_str.to_owned())
|
|
|
|
|
|
&& let Some(r) = ScrollbackImageRef::from_path_with_alt(path_str, alt_text)
|
|
|
|
|
|
{
|
|
|
|
|
|
refs.push(r);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for cap in PATH_RE.captures_iter(text) {
|
|
|
|
|
|
if let Some(m) = cap.get(1) {
|
|
|
|
|
|
let path_str = m.as_str();
|
|
|
|
|
|
if seen.insert(path_str.to_owned())
|
|
|
|
|
|
&& let Some(r) = ScrollbackImageRef::from_path(path_str)
|
|
|
|
|
|
{
|
|
|
|
|
|
refs.push(r);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
refs
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
// Scrollback video references
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
const VIDEO_EXTENSIONS: &[&str] = &["mp4", "webm", "mov", "avi", "mkv"];
|
|
|
|
|
|
|
|
|
|
|
|
/// A video file referenced in scrollback content via `` markdown
|
|
|
|
|
|
/// or a bare absolute path. Validated on construction: path must exist and have a
|
|
|
|
|
|
/// recognized video extension.
|
|
|
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
|
pub struct ScrollbackVideoRef {
|
|
|
|
|
|
/// Absolute path to the video file on disk.
|
|
|
|
|
|
pub path: PathBuf,
|
|
|
|
|
|
/// Alt text from the `` markdown syntax (empty for bare paths).
|
|
|
|
|
|
pub alt_text: String,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl ScrollbackVideoRef {
|
|
|
|
|
|
/// Validate that `path` exists and has a recognized video extension.
|
|
|
|
|
|
pub fn from_path(path: impl Into<PathBuf>) -> Option<Self> {
|
|
|
|
|
|
Self::from_path_with_alt(path, String::new())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Construct with alt text from the markdown `` syntax.
|
|
|
|
|
|
pub fn from_path_with_alt(path: impl Into<PathBuf>, alt_text: String) -> Option<Self> {
|
|
|
|
|
|
let path = strip_verbatim_prefix(&path.into());
|
|
|
|
|
|
let ext = path.extension()?.to_str()?.to_ascii_lowercase();
|
|
|
|
|
|
if !VIDEO_EXTENSIONS.contains(&ext.as_str()) || !path.is_file() {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
Some(Self { path, alt_text })
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Extract video references from text (markdown `` and bare paths).
|
|
|
|
|
|
pub fn extract_video_refs(text: &str) -> Vec<ScrollbackVideoRef> {
|
|
|
|
|
|
use std::sync::LazyLock;
|
|
|
|
|
|
|
|
|
|
|
|
// Reuse the markdown image ref pattern — video_gen uses .
|
|
|
|
|
|
static MD_RE: LazyLock<regex::Regex> =
|
|
|
|
|
|
LazyLock::new(|| regex::Regex::new(MARKDOWN_IMAGE_REF_PATTERN).unwrap());
|
|
|
|
|
|
|
|
|
|
|
|
static VIDEO_PATH_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
|
|
|
|
|
|
let exts = VIDEO_EXTENSIONS.join("|");
|
|
|
|
|
|
// Unix absolute paths (/...) and Windows absolute paths (C:\..., C:/...,
|
|
|
|
|
|
// or \\... UNC). Drive letters accept either separator.
|
|
|
|
|
|
regex::Regex::new(&format!(
|
|
|
|
|
|
r"(?:^|[\s,])((?:/|[A-Za-z]:[\\/]|\\\\)[^\s,]+\.(?:{exts}))(?:[\s,.(]|$)"
|
|
|
|
|
|
))
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
let mut refs = Vec::new();
|
|
|
|
|
|
let mut seen = std::collections::HashSet::new();
|
|
|
|
|
|
|
|
|
|
|
|
// MD_RE: group 1 = alt text, group 2 = path
|
|
|
|
|
|
for cap in MD_RE.captures_iter(text) {
|
|
|
|
|
|
if let Some(m) = cap.get(2) {
|
|
|
|
|
|
let path_str = m.as_str();
|
|
|
|
|
|
let alt_text = cap
|
|
|
|
|
|
.get(1)
|
|
|
|
|
|
.map(|a| a.as_str().to_owned())
|
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
|
if seen.insert(path_str.to_owned())
|
|
|
|
|
|
&& let Some(r) = ScrollbackVideoRef::from_path_with_alt(path_str, alt_text)
|
|
|
|
|
|
{
|
|
|
|
|
|
refs.push(r);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// VIDEO_PATH_RE: group 1 = path (no alt text)
|
|
|
|
|
|
for cap in VIDEO_PATH_RE.captures_iter(text) {
|
|
|
|
|
|
if let Some(m) = cap.get(1) {
|
|
|
|
|
|
let path_str = m.as_str();
|
|
|
|
|
|
if seen.insert(path_str.to_owned())
|
|
|
|
|
|
&& let Some(r) = ScrollbackVideoRef::from_path(path_str)
|
|
|
|
|
|
{
|
|
|
|
|
|
refs.push(r);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
refs
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// =========================================================================
|
|
|
|
|
|
// Tests
|
|
|
|
|
|
// =========================================================================
|
|
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
mod tests {
|
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
|
|
/// The image dir is keyed off the session's cwd — the glue the cross-cwd
|
|
|
|
|
|
/// resume fix relies on: with `AgentSession.cwd` anchored to the origin cwd,
|
|
|
|
|
|
/// pasted images land under that origin, not the process cwd.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn session_images_dir_keys_off_the_passed_cwd() {
|
|
|
|
|
|
let id = agent_client_protocol::SessionId::new("sid-xyz");
|
|
|
|
|
|
let a = session_images_dir(Some(&id), std::path::Path::new("/origin/a")).unwrap();
|
|
|
|
|
|
let b = session_images_dir(Some(&id), std::path::Path::new("/origin/b")).unwrap();
|
|
|
|
|
|
assert_ne!(
|
|
|
|
|
|
a, b,
|
|
|
|
|
|
"same id under different cwd must map to different image dirs"
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(a.ends_with(std::path::Path::new("sid-xyz").join("images")));
|
|
|
|
|
|
assert!(session_images_dir(None, std::path::Path::new("/x")).is_none());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn strip_verbatim_prefix_normalizes_paths() {
|
|
|
|
|
|
let strip = |s| strip_verbatim_prefix(std::path::Path::new(s));
|
|
|
|
|
|
assert_eq!(strip(r"\\?\C:\x\1.jpg"), PathBuf::from(r"C:\x\1.jpg"));
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
strip(r"\\?\UNC\srv\s\1.jpg"),
|
|
|
|
|
|
PathBuf::from(r"\\srv\s\1.jpg")
|
|
|
|
|
|
);
|
|
|
|
|
|
// Plain paths (the only form off Windows) pass through untouched.
|
|
|
|
|
|
assert_eq!(strip("/Users/k/1.jpg"), PathBuf::from("/Users/k/1.jpg"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Helper to create a `PastedImage` with minimal required fields.
|
|
|
|
|
|
fn make_image(element_id: u64, display_number: usize) -> PastedImage {
|
|
|
|
|
|
PastedImage {
|
|
|
|
|
|
element_id: ElementId::from_raw(element_id),
|
|
|
|
|
|
display_number,
|
|
|
|
|
|
mime_type: "image/png".into(),
|
|
|
|
|
|
dimensions: None,
|
|
|
|
|
|
byte_len: 1024,
|
|
|
|
|
|
encoded_bytes: None,
|
|
|
|
|
|
source_path: None,
|
|
|
|
|
|
staged_temp_path: None,
|
|
|
|
|
|
session_image_path: None,
|
|
|
|
|
|
preview: PromptImagePreview::default(),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ----- display_text ---------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn display_text_format() {
|
|
|
|
|
|
assert_eq!(display_text(1), "[Image #1]");
|
|
|
|
|
|
assert_eq!(display_text(5), "[Image #5]");
|
|
|
|
|
|
assert_eq!(display_text(10), "[Image #10]");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ----- extension_for_mime ---------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn extension_for_known_mimes() {
|
|
|
|
|
|
assert_eq!(extension_for_mime("image/png"), "png");
|
|
|
|
|
|
assert_eq!(extension_for_mime("image/jpeg"), "jpg");
|
|
|
|
|
|
assert_eq!(extension_for_mime("image/tiff"), "tiff");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn extension_for_unknown_mime() {
|
|
|
|
|
|
assert_eq!(extension_for_mime("application/octet-stream"), "bin");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn make_persistable_image(bytes: Vec<u8>) -> PastedImage {
|
|
|
|
|
|
PastedImage {
|
|
|
|
|
|
element_id: ElementId::from_raw(1),
|
|
|
|
|
|
display_number: 1,
|
|
|
|
|
|
mime_type: "image/png".into(),
|
|
|
|
|
|
dimensions: None,
|
|
|
|
|
|
byte_len: bytes.len(),
|
|
|
|
|
|
encoded_bytes: Some(Arc::from(bytes)),
|
|
|
|
|
|
source_path: None,
|
|
|
|
|
|
staged_temp_path: None,
|
|
|
|
|
|
session_image_path: None,
|
|
|
|
|
|
preview: PromptImagePreview::default(),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// `persist_to_session` is atomic (write tmp + rename) and clears
|
|
|
|
|
|
/// `encoded_bytes` after success.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn persist_to_session_writes_full_bytes() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let payload = b"\x89PNG\r\n\x1a\nfake-but-recognizable-payload".to_vec();
|
|
|
|
|
|
let mut img = make_persistable_image(payload.clone());
|
|
|
|
|
|
persist_to_session(&mut img, dir.path()).expect("persist succeeded");
|
|
|
|
|
|
let path = img.session_image_path.as_ref().expect("path set");
|
|
|
|
|
|
let on_disk = std::fs::read(path).expect("readable");
|
|
|
|
|
|
assert_eq!(on_disk, payload);
|
|
|
|
|
|
assert!(img.encoded_bytes.is_none(), "in-memory bytes released");
|
|
|
|
|
|
// No .tmp left behind after success. `Path::extension()` returns
|
|
|
|
|
|
// `OsStr("tmp")` without the dot, so check filenames directly.
|
|
|
|
|
|
let leftovers: Vec<_> = std::fs::read_dir(dir.path())
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.filter_map(|e| e.ok())
|
|
|
|
|
|
.filter(|e| e.file_name().to_string_lossy().ends_with(".tmp"))
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
assert!(leftovers.is_empty(), "leaked tmp files: {leftovers:?}");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// On `File::create` failure (read-only dir) `persist_to_session`
|
|
|
|
|
|
/// returns `Err` and leaves no `.tmp` behind.
|
|
|
|
|
|
#[cfg(unix)]
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn persist_cleans_up_tmp_on_create_failure() {
|
|
|
|
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
|
|
let euid = unsafe { libc::geteuid() };
|
|
|
|
|
|
if euid == 0 {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let mut img = make_persistable_image(b"abc".to_vec());
|
|
|
|
|
|
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o500)).unwrap();
|
|
|
|
|
|
let result = persist_to_session(&mut img, dir.path());
|
|
|
|
|
|
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
|
|
|
|
|
|
assert!(result.is_err());
|
|
|
|
|
|
let leftovers: Vec<_> = std::fs::read_dir(dir.path())
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.filter_map(|e| e.ok())
|
|
|
|
|
|
.filter(|e| e.file_name().to_string_lossy().ends_with(".tmp"))
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
assert!(leftovers.is_empty(), "tmp leaked: {leftovers:?}");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Integration: persist → load_for_send round-trips the bytes.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn persist_then_load_for_send_round_trips() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let payload = b"\x89PNG\r\n\x1a\nround-trip-bytes-12345".to_vec();
|
|
|
|
|
|
let mut img = make_persistable_image(payload.clone());
|
|
|
|
|
|
persist_to_session(&mut img, dir.path()).expect("persist");
|
|
|
|
|
|
let (bytes, mime) = load_for_send(&img).expect("load");
|
|
|
|
|
|
assert_eq!(bytes, payload);
|
|
|
|
|
|
assert_eq!(mime, "image/png");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// `encoded_bytes.is_none()` is an error.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn persist_no_bytes_returns_err() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let mut img = make_persistable_image(b"x".to_vec());
|
|
|
|
|
|
img.encoded_bytes = None;
|
|
|
|
|
|
let err = persist_to_session(&mut img, dir.path()).unwrap_err();
|
|
|
|
|
|
assert!(err.to_string().contains("no encoded bytes"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn persist_preserves_original_source_path_for_file_paste() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let mut img = make_persistable_image(b"abc".to_vec());
|
|
|
|
|
|
let original_source = std::path::PathBuf::from("/tmp/ephemeral/original.png");
|
|
|
|
|
|
img.source_path = Some(original_source.clone());
|
|
|
|
|
|
persist_to_session(&mut img, dir.path()).expect("persist");
|
|
|
|
|
|
assert_eq!(img.source_path.as_ref(), Some(&original_source));
|
|
|
|
|
|
assert_ne!(
|
|
|
|
|
|
img.source_path.as_ref(),
|
|
|
|
|
|
img.session_image_path.as_ref(),
|
|
|
|
|
|
"display and durable paths have distinct ownership"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Write into a path that cannot be created returns `Err`.
|
|
|
|
|
|
#[cfg(unix)]
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn persist_readonly_dir_returns_err() {
|
|
|
|
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
|
|
// Root bypasses read-only mode; skip.
|
|
|
|
|
|
let euid = unsafe { libc::geteuid() };
|
|
|
|
|
|
if euid == 0 {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let readonly = dir.path().join("ro");
|
|
|
|
|
|
std::fs::create_dir(&readonly).unwrap();
|
|
|
|
|
|
std::fs::set_permissions(&readonly, std::fs::Permissions::from_mode(0o500)).unwrap();
|
|
|
|
|
|
let mut img = make_persistable_image(b"abc".to_vec());
|
|
|
|
|
|
let res = persist_to_session(&mut img, &readonly);
|
|
|
|
|
|
let _ = std::fs::set_permissions(&readonly, std::fs::Permissions::from_mode(0o700));
|
|
|
|
|
|
assert!(res.is_err(), "expected write to read-only dir to fail");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ----- shell_unescape -------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn shell_unescape_spaces() {
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
shell_unescape(r"/path/to/my\ file.png"),
|
|
|
|
|
|
"/path/to/my file.png"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn shell_unescape_parens() {
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
shell_unescape(r"/Downloads/screenshot\ \(2\).png"),
|
|
|
|
|
|
"/Downloads/screenshot (2).png"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn shell_unescape_no_escapes() {
|
|
|
|
|
|
assert_eq!(shell_unescape("/simple/path.png"), "/simple/path.png");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn shell_unescape_trailing_backslash() {
|
|
|
|
|
|
assert_eq!(shell_unescape("trailing\\"), "trailing\\");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn shell_unescape_literal_backslash() {
|
|
|
|
|
|
assert_eq!(shell_unescape(r"path\\name"), r"path\name");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ----- shell_unescape / Windows-path round-trip ----------------------
|
|
|
|
|
|
//
|
|
|
|
|
|
// `\` is a path separator on Windows, not a shell escape. The
|
|
|
|
|
|
// unescape must skip Windows-looking inputs or it would collapse
|
|
|
|
|
|
// `C:\Users\Alice\image.png` to `C:UsersAliceimage.png`.
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn shell_unescape_preserves_windows_drive_letter() {
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
shell_unescape(r"C:\Users\Alice\image.png"),
|
|
|
|
|
|
r"C:\Users\Alice\image.png"
|
|
|
|
|
|
);
|
|
|
|
|
|
// Forward-slash drive paths (MSYS2 / MinGW) also pass through.
|
|
|
|
|
|
assert_eq!(shell_unescape("C:/x/y.png"), "C:/x/y.png");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn shell_unescape_preserves_windows_unc() {
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
shell_unescape(r"\\server\share\image.png"),
|
|
|
|
|
|
r"\\server\share\image.png"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn looks_like_windows_path_positive_cases() {
|
|
|
|
|
|
assert!(looks_like_windows_path(r"C:\Users\Alice"));
|
|
|
|
|
|
assert!(looks_like_windows_path(r"d:\Downloads"));
|
|
|
|
|
|
assert!(looks_like_windows_path("C:/Users/Alice"));
|
|
|
|
|
|
assert!(looks_like_windows_path(r"\\server\share\file.png"));
|
|
|
|
|
|
assert!(looks_like_windows_path(r"\\?\C:\long\path"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn looks_like_windows_path_negative_cases() {
|
|
|
|
|
|
assert!(!looks_like_windows_path("/Users/Alice/file.png"));
|
|
|
|
|
|
assert!(!looks_like_windows_path("~/Downloads/file.png"));
|
|
|
|
|
|
assert!(!looks_like_windows_path("file://path"));
|
|
|
|
|
|
assert!(!looks_like_windows_path("C:"));
|
|
|
|
|
|
assert!(!looks_like_windows_path("C:foo"));
|
|
|
|
|
|
assert!(!looks_like_windows_path(r"\foo"));
|
|
|
|
|
|
assert!(!looks_like_windows_path(""));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn token_to_path_round_trips_quoted_windows_path() {
|
|
|
|
|
|
// Windows Terminal wraps paths-with-spaces in double quotes;
|
|
|
|
|
|
// quote stripping then shell_unescape-skip must leave the
|
|
|
|
|
|
// path intact for downstream `is_file()`.
|
|
|
|
|
|
let path = token_to_path("\"C:\\Users\\Alice\\My Folder\\image.png\"").unwrap();
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
path,
|
|
|
|
|
|
std::path::PathBuf::from(r"C:\Users\Alice\My Folder\image.png")
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ----- try_read_image_from_path ----------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn try_read_image_with_escaped_parens() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let real_path = dir.path().join("screenshot (2).png");
|
|
|
|
|
|
let png = make_test_png(10, 10);
|
|
|
|
|
|
std::fs::write(&real_path, &png).unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
// Simulate what the terminal pastes: escaped spaces and parens
|
|
|
|
|
|
let escaped = format!("{}/screenshot\\ \\(2\\).png", dir.path().display());
|
|
|
|
|
|
let result = try_read_image_from_path(&escaped);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
result.is_some(),
|
|
|
|
|
|
"should recognize path with escaped parens"
|
|
|
|
|
|
);
|
|
|
|
|
|
let img = result.unwrap();
|
|
|
|
|
|
assert_eq!(img.mime_type, "image/png");
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
img.source_path.is_some(),
|
|
|
|
|
|
"source_path should be set for file-path images"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn try_read_image_with_escaped_spaces() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let real_path = dir.path().join("my file.png");
|
|
|
|
|
|
let png = make_test_png(10, 10);
|
|
|
|
|
|
std::fs::write(&real_path, &png).unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let escaped = format!("{}/my\\ file.png", dir.path().display());
|
|
|
|
|
|
let result = try_read_image_from_path(&escaped);
|
|
|
|
|
|
assert!(result.is_some(), "should recognize path with escaped space");
|
|
|
|
|
|
assert!(result.unwrap().source_path.is_some());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ----- single-file resilience (drop with trailing whitespace / quotes /
|
|
|
|
|
|
// file:// URLs) ---------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
/// Writes a real PNG at `path`. Helper to keep the multi-file tests tidy.
|
|
|
|
|
|
fn write_png(path: &std::path::Path, w: u32, h: u32) {
|
|
|
|
|
|
std::fs::write(path, make_test_png(w, h)).unwrap();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn try_read_image_bare_absolute_path() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let p = dir.path().join("foo.png");
|
|
|
|
|
|
write_png(&p, 2, 2);
|
|
|
|
|
|
|
|
|
|
|
|
let result = try_read_image_from_path(&p.display().to_string()).unwrap();
|
|
|
|
|
|
assert!(result.source_path.is_some());
|
|
|
|
|
|
assert_eq!(result.mime_type, "image/png");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn try_read_image_trailing_newline() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let p = dir.path().join("foo.png");
|
|
|
|
|
|
write_png(&p, 2, 2);
|
|
|
|
|
|
|
|
|
|
|
|
let pasted = format!("{}\n", p.display());
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
try_read_image_from_path(&pasted).is_some(),
|
|
|
|
|
|
"single trailing newline must not break drop"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn try_read_image_trailing_crlf() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let p = dir.path().join("foo.png");
|
|
|
|
|
|
write_png(&p, 2, 2);
|
|
|
|
|
|
|
|
|
|
|
|
let pasted = format!("{}\r\n", p.display());
|
|
|
|
|
|
assert!(try_read_image_from_path(&pasted).is_some());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn try_read_image_double_quoted_path() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let p = dir.path().join("foo.png");
|
|
|
|
|
|
write_png(&p, 2, 2);
|
|
|
|
|
|
|
|
|
|
|
|
let pasted = format!("\"{}\"", p.display());
|
|
|
|
|
|
assert!(try_read_image_from_path(&pasted).is_some());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn try_read_image_single_quoted_path() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let p = dir.path().join("foo.png");
|
|
|
|
|
|
write_png(&p, 2, 2);
|
|
|
|
|
|
|
|
|
|
|
|
let pasted = format!("'{}'", p.display());
|
|
|
|
|
|
assert!(try_read_image_from_path(&pasted).is_some());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ----- file:// URL parsing -------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn try_read_image_file_url() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let p = dir.path().join("foo.png");
|
|
|
|
|
|
write_png(&p, 2, 2);
|
|
|
|
|
|
|
|
|
|
|
|
let url = format!("file://{}", p.display());
|
|
|
|
|
|
assert!(try_read_image_from_path(&url).is_some());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn try_read_image_file_url_percent_encoded() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let p = dir.path().join("has space.png");
|
|
|
|
|
|
write_png(&p, 2, 2);
|
|
|
|
|
|
|
|
|
|
|
|
// Build a file:// URL with %20 in place of the literal space.
|
|
|
|
|
|
let url = format!(
|
|
|
|
|
|
"file://{}/has%20space.png",
|
|
|
|
|
|
dir.path().display().to_string().replace(' ', "%20")
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
try_read_image_from_path(&url).is_some(),
|
|
|
|
|
|
"percent-encoded file:// URL must decode to a real path"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn try_read_image_file_url_trailing_newline() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let p = dir.path().join("foo.png");
|
|
|
|
|
|
write_png(&p, 2, 2);
|
|
|
|
|
|
|
|
|
|
|
|
let pasted = format!("file://{}\n", p.display());
|
|
|
|
|
|
assert!(try_read_image_from_path(&pasted).is_some());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn try_read_image_file_url_quoted() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let p = dir.path().join("foo.png");
|
|
|
|
|
|
write_png(&p, 2, 2);
|
|
|
|
|
|
|
|
|
|
|
|
let pasted = format!("\"file://{}\"", p.display());
|
|
|
|
|
|
assert!(try_read_image_from_path(&pasted).is_some());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ----- multi-file drop -----------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
/// Non-image paths are canonicalized before insertion.
|
|
|
|
|
|
fn canon(p: &std::path::Path) -> PathBuf {
|
|
|
|
|
|
dunce::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn multi_file_newline_separated() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let a = dir.path().join("a.png");
|
|
|
|
|
|
let b = dir.path().join("b.png");
|
|
|
|
|
|
write_png(&a, 2, 2);
|
|
|
|
|
|
write_png(&b, 2, 2);
|
|
|
|
|
|
|
|
|
|
|
|
let pasted = format!("{}\n{}", a.display(), b.display());
|
|
|
|
|
|
let images = try_read_images_from_paste(&pasted);
|
|
|
|
|
|
assert_eq!(images.len(), 2);
|
|
|
|
|
|
assert_eq!(images[0].source_path.as_ref().unwrap(), &a);
|
|
|
|
|
|
assert_eq!(images[1].source_path.as_ref().unwrap(), &b);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn multi_file_space_separated_absolute_paths() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let a = dir.path().join("a.png");
|
|
|
|
|
|
let b = dir.path().join("b.png");
|
|
|
|
|
|
write_png(&a, 2, 2);
|
|
|
|
|
|
write_png(&b, 2, 2);
|
|
|
|
|
|
|
|
|
|
|
|
let pasted = format!("{} {}", a.display(), b.display());
|
|
|
|
|
|
let images = try_read_images_from_paste(&pasted);
|
|
|
|
|
|
assert_eq!(images.len(), 2);
|
|
|
|
|
|
assert_eq!(images[0].source_path.as_ref().unwrap(), &a);
|
|
|
|
|
|
assert_eq!(images[1].source_path.as_ref().unwrap(), &b);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn multi_file_mixed_line_endings() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let a = dir.path().join("a.png");
|
|
|
|
|
|
let b = dir.path().join("b.png");
|
|
|
|
|
|
let c = dir.path().join("c.png");
|
|
|
|
|
|
write_png(&a, 2, 2);
|
|
|
|
|
|
write_png(&b, 2, 2);
|
|
|
|
|
|
write_png(&c, 2, 2);
|
|
|
|
|
|
|
|
|
|
|
|
let pasted = format!("{}\r\n{}\n{}", a.display(), b.display(), c.display());
|
|
|
|
|
|
let images = try_read_images_from_paste(&pasted);
|
|
|
|
|
|
assert_eq!(images.len(), 3);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn multi_file_file_urls_and_bare_paths_interleaved() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let a = dir.path().join("a.png");
|
|
|
|
|
|
let b = dir.path().join("b.png");
|
|
|
|
|
|
let c = dir.path().join("c.png");
|
|
|
|
|
|
write_png(&a, 2, 2);
|
|
|
|
|
|
write_png(&b, 2, 2);
|
|
|
|
|
|
write_png(&c, 2, 2);
|
|
|
|
|
|
|
|
|
|
|
|
let pasted = format!(
|
|
|
|
|
|
"file://{}\n{}\nfile://{}",
|
|
|
|
|
|
a.display(),
|
|
|
|
|
|
b.display(),
|
|
|
|
|
|
c.display()
|
|
|
|
|
|
);
|
|
|
|
|
|
let images = try_read_images_from_paste(&pasted);
|
|
|
|
|
|
assert_eq!(images.len(), 3);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// The whole-paste-or-nothing rule (preserves prose) causes the
|
|
|
|
|
|
/// *entire* paste to fall through to plain text when any line
|
|
|
|
|
|
/// fails to resolve. A non-image text file IS still a valid
|
|
|
|
|
|
/// drop (it becomes a NonImage entry) — but a truly missing
|
|
|
|
|
|
/// path breaks the batch.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn multi_file_missing_middle_path_falls_through() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let a = dir.path().join("a.png");
|
|
|
|
|
|
let c = dir.path().join("c.png");
|
|
|
|
|
|
write_png(&a, 2, 2);
|
|
|
|
|
|
write_png(&c, 2, 2);
|
|
|
|
|
|
let missing = dir.path().join("missing_zzz.png");
|
|
|
|
|
|
|
|
|
|
|
|
let pasted = format!("{}\n{}\n{}", a.display(), missing.display(), c.display());
|
|
|
|
|
|
let images = try_read_images_from_paste(&pasted);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
images.is_empty(),
|
|
|
|
|
|
"missing middle path must cause whole-paste fall-through; \
|
|
|
|
|
|
got {images:?}",
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// A non-image file in the middle IS a valid drop (becomes
|
|
|
|
|
|
/// `NonImage`), so the batch still produces 2 images plus 1
|
|
|
|
|
|
/// NonImage path — nothing is silently lost.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn multi_file_non_image_middle_still_emits_all_entries() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let a = dir.path().join("a.png");
|
|
|
|
|
|
let txt = dir.path().join("notes.txt");
|
|
|
|
|
|
let c = dir.path().join("c.png");
|
|
|
|
|
|
write_png(&a, 2, 2);
|
|
|
|
|
|
std::fs::write(&txt, b"not an image").unwrap();
|
|
|
|
|
|
write_png(&c, 2, 2);
|
|
|
|
|
|
|
|
|
|
|
|
let pasted = format!("{}\n{}\n{}", a.display(), txt.display(), c.display());
|
|
|
|
|
|
let entries = dropped_paths(&pasted);
|
|
|
|
|
|
assert_eq!(entries.len(), 3);
|
|
|
|
|
|
// Pin the source-order of the variants, not just the
|
|
|
|
|
|
// counts. The drop classifier inserts in source order and
|
|
|
|
|
|
// order determines the final prompt layout — a regression
|
|
|
|
|
|
// that scrambled the order would still pass a count-only
|
|
|
|
|
|
// assertion.
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
matches!(entries[0], DroppedPath::Image(_)),
|
|
|
|
|
|
"entries[0] must be Image; got {:?}",
|
|
|
|
|
|
entries[0],
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
matches!(entries[1], DroppedPath::NonImage(_)),
|
|
|
|
|
|
"entries[1] must be NonImage; got {:?}",
|
|
|
|
|
|
entries[1],
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
matches!(entries[2], DroppedPath::Image(_)),
|
|
|
|
|
|
"entries[2] must be Image; got {:?}",
|
|
|
|
|
|
entries[2],
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ----- negatives — must not auto-attach ------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn free_prose_containing_slash_returns_empty() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let p = dir.path().join("foo.png");
|
|
|
|
|
|
write_png(&p, 2, 2);
|
|
|
|
|
|
|
|
|
|
|
|
// The path exists, but the surrounding prose attaches extra
|
|
|
|
|
|
// tokens that prevent the path from validating.
|
|
|
|
|
|
let pasted = format!("check out {} for the bug", p.display());
|
|
|
|
|
|
let images = try_read_images_from_paste(&pasted);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
images.is_empty(),
|
|
|
|
|
|
"free prose containing an image path must not auto-attach"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn missing_single_path_returns_empty() {
|
|
|
|
|
|
let images = try_read_images_from_paste("/tmp/does_not_exist_zzz.png");
|
|
|
|
|
|
assert!(images.is_empty());
|
|
|
|
|
|
assert!(try_read_image_from_path("/tmp/does_not_exist_zzz.png").is_none());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn relative_path_returns_empty() {
|
|
|
|
|
|
// Relative path "foo.png" is not a real file at the test cwd.
|
|
|
|
|
|
assert!(try_read_images_from_paste("foo.png").is_empty());
|
|
|
|
|
|
assert!(try_read_image_from_path("foo.png").is_none());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn random_multi_line_text_returns_empty() {
|
|
|
|
|
|
assert!(try_read_images_from_paste("line one\nline two").is_empty());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ----- additional edge cases ----
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn bash_mode_prefix_not_treated_as_image() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let p = dir.path().join("foo.png");
|
|
|
|
|
|
write_png(&p, 2, 2);
|
|
|
|
|
|
|
|
|
|
|
|
// "! /tmp/foo.png" is a bash-mode prefix paste, not a 2-token
|
|
|
|
|
|
// file drop. The all-parts-anchored gate must reject the split
|
|
|
|
|
|
// and leave the whole payload as one (invalid) token so the
|
|
|
|
|
|
// caller can detect the `! ` prefix.
|
|
|
|
|
|
let pasted = format!("! {}", p.display());
|
|
|
|
|
|
assert!(try_read_images_from_paste(&pasted).is_empty());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn prose_ending_in_png_is_not_attached() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let foo = dir.path().join("foo.png");
|
|
|
|
|
|
let bar = dir.path().join("bar.png");
|
|
|
|
|
|
write_png(&foo, 2, 2);
|
|
|
|
|
|
write_png(&bar, 2, 2);
|
|
|
|
|
|
|
|
|
|
|
|
// Prose that *ends* with an image extension is a footgun.
|
|
|
|
|
|
// With the all-parts-anchored gate, the first part
|
|
|
|
|
|
// "see" doesn't anchor and we fall back to a single token that
|
|
|
|
|
|
// fails validation as a path.
|
|
|
|
|
|
let pasted = format!("see {} referenced in {}", foo.display(), bar.display());
|
|
|
|
|
|
assert!(try_read_images_from_paste(&pasted).is_empty());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn leading_space_before_single_path_attaches() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let p = dir.path().join("foo.png");
|
|
|
|
|
|
write_png(&p, 2, 2);
|
|
|
|
|
|
|
|
|
|
|
|
// Leading space followed by a path produces a split-empty + path
|
|
|
|
|
|
// pair; after trim+filter the parts collapse to a single token
|
|
|
|
|
|
// and the path still attaches. Pins down this behavior.
|
|
|
|
|
|
let pasted = format!(" {}", p.display());
|
|
|
|
|
|
assert!(try_read_image_from_path(&pasted).is_some());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn newline_wins_space_inside_line_not_split() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let a = dir.path().join("a.png");
|
|
|
|
|
|
let bc = dir.path().join("b.png c.png"); // a single file with a space in its name
|
|
|
|
|
|
let other = dir.path().join("d.png");
|
|
|
|
|
|
write_png(&a, 2, 2);
|
|
|
|
|
|
write_png(&bc, 2, 2);
|
|
|
|
|
|
write_png(&other, 2, 2);
|
|
|
|
|
|
|
|
|
|
|
|
// Mixed payload: newline-split wins; the second line is NOT
|
|
|
|
|
|
// further space-split, so "b.png c.png" is one filename. Pins
|
|
|
|
|
|
// down the "newline wins, space is only a single-line fallback"
|
|
|
|
|
|
// rule.
|
|
|
|
|
|
let pasted = format!("{}\n{}\n{}", a.display(), bc.display(), other.display());
|
|
|
|
|
|
let images = try_read_images_from_paste(&pasted);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
images.len(),
|
|
|
|
|
|
3,
|
|
|
|
|
|
"second line must be treated as a single filename with a space"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn quoted_path_with_internal_backslash_escape() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
// Inside the quotes the user supplied a backslash escape. Our
|
|
|
|
|
|
// code still runs `shell_unescape` after stripping quotes — this
|
|
|
|
|
|
// diverges from POSIX shell semantics (where backslash inside
|
|
|
|
|
|
// double quotes is mostly literal) but is consistent with the
|
|
|
|
|
|
// pre-existing single-image wrapper and the common drag-and-drop
|
|
|
|
|
|
// flow. The test pins down the actual behavior.
|
|
|
|
|
|
let p = dir.path().join("my file.png");
|
|
|
|
|
|
write_png(&p, 2, 2);
|
|
|
|
|
|
let pasted = format!("\"{}/my\\ file.png\"", dir.path().display());
|
|
|
|
|
|
assert!(try_read_image_from_path(&pasted).is_some());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ----- file:// URL edge cases ----------------------------
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn file_url_with_localhost_host() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let p = dir.path().join("foo.png");
|
|
|
|
|
|
write_png(&p, 2, 2);
|
|
|
|
|
|
|
|
|
|
|
|
// `file://localhost/...` is accepted by the `url` crate and
|
|
|
|
|
|
// yields the same local path as `file:///...`. Pins behavior.
|
|
|
|
|
|
let pasted = format!("file://localhost{}", p.display());
|
|
|
|
|
|
assert!(try_read_image_from_path(&pasted).is_some());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn file_url_with_query_string_accepted() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let p = dir.path().join("foo.png");
|
|
|
|
|
|
write_png(&p, 2, 2);
|
|
|
|
|
|
|
|
|
|
|
|
// `url::Url::to_file_path()` on `file:///…/foo.png?q=1` strips
|
|
|
|
|
|
// the query, so the file is found and the image attaches. Pins
|
|
|
|
|
|
// the current `url` crate contract; a future change that, say,
|
|
|
|
|
|
// started including the query in the path component would trip
|
|
|
|
|
|
// this assertion.
|
|
|
|
|
|
let pasted = format!("file://{}?q=1", p.display());
|
|
|
|
|
|
assert!(try_read_image_from_path(&pasted).is_some());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn file_url_with_fragment_accepted() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let p = dir.path().join("foo.png");
|
|
|
|
|
|
write_png(&p, 2, 2);
|
|
|
|
|
|
|
|
|
|
|
|
// Fragments are stripped by `url::Url::to_file_path()`, so this
|
|
|
|
|
|
// resolves to the same local path. Pins behavior.
|
|
|
|
|
|
let pasted = format!("file://{}#frag", p.display());
|
|
|
|
|
|
assert!(try_read_image_from_path(&pasted).is_some());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ----- multi-file space-separated mixed file:// + bare ---
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn multi_file_space_separated_file_url_then_bare() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let a = dir.path().join("a.png");
|
|
|
|
|
|
let b = dir.path().join("b.png");
|
|
|
|
|
|
write_png(&a, 2, 2);
|
|
|
|
|
|
write_png(&b, 2, 2);
|
|
|
|
|
|
|
|
|
|
|
|
let pasted = format!("file://{} {}", a.display(), b.display());
|
|
|
|
|
|
let images = try_read_images_from_paste(&pasted);
|
|
|
|
|
|
assert_eq!(images.len(), 2, "file:// + bare path split must work");
|
|
|
|
|
|
assert_eq!(images[0].source_path.as_ref().unwrap(), &a);
|
|
|
|
|
|
assert_eq!(images[1].source_path.as_ref().unwrap(), &b);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn multi_file_space_separated_bare_then_file_url() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let a = dir.path().join("a.png");
|
|
|
|
|
|
let b = dir.path().join("b.png");
|
|
|
|
|
|
write_png(&a, 2, 2);
|
|
|
|
|
|
write_png(&b, 2, 2);
|
|
|
|
|
|
|
|
|
|
|
|
let pasted = format!("{} file://{}", a.display(), b.display());
|
|
|
|
|
|
let images = try_read_images_from_paste(&pasted);
|
|
|
|
|
|
assert_eq!(images.len(), 2, "bare + file:// path split must work");
|
|
|
|
|
|
assert_eq!(images[0].source_path.as_ref().unwrap(), &a);
|
|
|
|
|
|
assert_eq!(images[1].source_path.as_ref().unwrap(), &b);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn multi_file_mixed_newline_and_space_separated() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let a = dir.path().join("a.png");
|
|
|
|
|
|
let b = dir.path().join("b.png");
|
|
|
|
|
|
let c = dir.path().join("c.png");
|
|
|
|
|
|
let d = dir.path().join("d.png");
|
|
|
|
|
|
write_png(&a, 2, 2);
|
|
|
|
|
|
write_png(&b, 2, 2);
|
|
|
|
|
|
write_png(&c, 2, 2);
|
|
|
|
|
|
write_png(&d, 2, 2);
|
|
|
|
|
|
|
|
|
|
|
|
let pasted = format!(
|
|
|
|
|
|
"{} {}\n{} {}",
|
|
|
|
|
|
a.display(),
|
|
|
|
|
|
b.display(),
|
|
|
|
|
|
c.display(),
|
|
|
|
|
|
d.display()
|
|
|
|
|
|
);
|
|
|
|
|
|
let images = try_read_images_from_paste(&pasted);
|
|
|
|
|
|
assert_eq!(images.len(), 4, "mixed newline+space must flatten to 4");
|
|
|
|
|
|
assert_eq!(images[0].source_path.as_ref().unwrap(), &a);
|
|
|
|
|
|
assert_eq!(images[1].source_path.as_ref().unwrap(), &b);
|
|
|
|
|
|
assert_eq!(images[2].source_path.as_ref().unwrap(), &c);
|
|
|
|
|
|
assert_eq!(images[3].source_path.as_ref().unwrap(), &d);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// A valid drop line followed by a prose comment line causes
|
|
|
|
|
|
/// the whole paste to fall through to plain text — the dominant
|
|
|
|
|
|
/// real-world use case is "screenshot URL + caption" and the
|
|
|
|
|
|
/// caption must survive verbatim instead of being silently
|
|
|
|
|
|
/// dropped on the floor.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn multi_file_valid_line_plus_prose_line_falls_through() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let a = dir.path().join("a.png");
|
|
|
|
|
|
let b = dir.path().join("b.png");
|
|
|
|
|
|
write_png(&a, 2, 2);
|
|
|
|
|
|
write_png(&b, 2, 2);
|
|
|
|
|
|
|
|
|
|
|
|
let pasted = format!(
|
|
|
|
|
|
"{} {}\nbut please ignore the second screenshot",
|
|
|
|
|
|
a.display(),
|
|
|
|
|
|
b.display()
|
|
|
|
|
|
);
|
|
|
|
|
|
let images = try_read_images_from_paste(&pasted);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
images.is_empty(),
|
|
|
|
|
|
"valid-drop-plus-prose paste must fall through so the prose \
|
|
|
|
|
|
survives via plain-text-paste; got {images:?}",
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn multi_file_space_separated_both_file_urls() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let a = dir.path().join("a.png");
|
|
|
|
|
|
let b = dir.path().join("b.png");
|
|
|
|
|
|
write_png(&a, 2, 2);
|
|
|
|
|
|
write_png(&b, 2, 2);
|
|
|
|
|
|
|
|
|
|
|
|
let pasted = format!("file://{} file://{}", a.display(), b.display());
|
|
|
|
|
|
let images = try_read_images_from_paste(&pasted);
|
|
|
|
|
|
assert_eq!(images.len(), 2, "two file:// URLs must split on space");
|
|
|
|
|
|
assert_eq!(images[0].source_path.as_ref().unwrap(), &a);
|
|
|
|
|
|
assert_eq!(images[1].source_path.as_ref().unwrap(), &b);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn file_url_single_slash_rejected_at_anchor_gate() {
|
|
|
|
|
|
// Single-slash `file:` URLs lack the `file://` prefix the
|
|
|
|
|
|
// anchor gate requires, and don't start with a path
|
|
|
|
|
|
// anchor (`/`, `~/`, `X:\`) either — `file:` starts with the
|
|
|
|
|
|
// ASCII letter `f`. So they fall through to plain text paste
|
|
|
|
|
|
// regardless of filesystem state. Pins observable wrapper
|
|
|
|
|
|
// behaviour against accidentally relaxing the anchor gate to
|
|
|
|
|
|
// also accept `file:` (single-slash). A `url`-crate upgrade
|
|
|
|
|
|
// that started or stopped accepting `file:/...` at parse
|
|
|
|
|
|
// time would still satisfy this assertion because the
|
|
|
|
|
|
// anchor gate now rejects single-slash strings *before* any
|
|
|
|
|
|
// URL parsing runs.
|
|
|
|
|
|
let pasted = "file:/tmp/should_not_exist_abc_grok_pager.png";
|
|
|
|
|
|
assert!(try_read_image_from_path(pasted).is_none());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn file_url_lookalike_scheme_rejected() {
|
|
|
|
|
|
// `file_url://` does NOT start with the literal `file://`, so
|
|
|
|
|
|
// the URL branch is skipped and the bare-path branch runs;
|
|
|
|
|
|
// the resulting "path" can't be on disk → None.
|
|
|
|
|
|
let pasted = "file_url:///tmp/foo.png";
|
|
|
|
|
|
assert!(try_read_image_from_path(pasted).is_none());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ----- try_read_dropped_paths -----------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
fn dropped_paths(text: &str) -> Vec<DroppedPath> {
|
|
|
|
|
|
try_read_dropped_paths(text)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn dropped_image_paths(text: &str) -> Vec<PathBuf> {
|
|
|
|
|
|
dropped_paths(text)
|
|
|
|
|
|
.into_iter()
|
|
|
|
|
|
.filter_map(|d| match d {
|
|
|
|
|
|
DroppedPath::Image(img) => img.source_path,
|
|
|
|
|
|
_ => None,
|
|
|
|
|
|
})
|
|
|
|
|
|
.collect()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[cfg(unix)]
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn image_source_path_preserves_user_visible_symlink() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let target = dir.path().join("target.png");
|
|
|
|
|
|
let visible = dir.path().join("visible.png");
|
|
|
|
|
|
write_png(&target, 8, 8);
|
|
|
|
|
|
std::os::unix::fs::symlink(&target, &visible).unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let images = try_read_images_from_paste(&visible.display().to_string());
|
|
|
|
|
|
assert_eq!(images.len(), 1);
|
|
|
|
|
|
assert_eq!(images[0].source_path.as_deref(), Some(visible.as_path()));
|
|
|
|
|
|
assert_ne!(visible, dunce::canonicalize(&visible).unwrap());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn dropped_non_image_paths(text: &str) -> Vec<PathBuf> {
|
|
|
|
|
|
dropped_paths(text)
|
|
|
|
|
|
.into_iter()
|
|
|
|
|
|
.filter_map(|d| match d {
|
|
|
|
|
|
DroppedPath::NonImage(p) => Some(p),
|
|
|
|
|
|
_ => None,
|
|
|
|
|
|
})
|
|
|
|
|
|
.collect()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn dropped_path_non_image_file_url_returns_decoded_path() {
|
|
|
|
|
|
// A `file://` URL pointing at a non-image file should produce a
|
|
|
|
|
|
// NonImage entry with the decoded absolute path — not be silently
|
|
|
|
|
|
// ignored or routed to an image chip.
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let txt = dir.path().join("notes.txt");
|
|
|
|
|
|
std::fs::write(&txt, b"hello").unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let url = format!("file://{}", txt.display());
|
|
|
|
|
|
let non_images = dropped_non_image_paths(&url);
|
|
|
|
|
|
assert_eq!(non_images.len(), 1);
|
|
|
|
|
|
assert_eq!(non_images[0], canon(&txt));
|
|
|
|
|
|
// No image chip should be created for a .txt file.
|
|
|
|
|
|
assert!(dropped_image_paths(&url).is_empty());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn dropped_path_non_image_bare_path_returns_decoded_path() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let txt = dir.path().join("data.json");
|
|
|
|
|
|
std::fs::write(&txt, b"{}").unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let non_images = dropped_non_image_paths(&txt.display().to_string());
|
|
|
|
|
|
assert_eq!(non_images.len(), 1);
|
|
|
|
|
|
assert_eq!(non_images[0], canon(&txt));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn paste_anchor_kind_classifies_each_form() {
|
|
|
|
|
|
assert_eq!(paste_anchor_kind("file:///tmp/x.png"), "file_url");
|
|
|
|
|
|
assert_eq!(paste_anchor_kind("/tmp/x.png"), "absolute");
|
|
|
|
|
|
assert_eq!(paste_anchor_kind("~/Downloads/x.png"), "tilde");
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
paste_anchor_kind("C:\\Users\\Alice\\x.png"),
|
|
|
|
|
|
"windows_drive"
|
|
|
|
|
|
);
|
|
|
|
|
|
assert_eq!(paste_anchor_kind("hello world"), "none");
|
|
|
|
|
|
assert_eq!(paste_anchor_kind(""), "none");
|
|
|
|
|
|
// ~ without slash is just prose, not a tilde anchor.
|
|
|
|
|
|
assert_eq!(paste_anchor_kind("~lonely"), "none");
|
|
|
|
|
|
// Lowercase Windows drive letters also accepted.
|
|
|
|
|
|
assert_eq!(paste_anchor_kind("d:\\path"), "windows_drive");
|
|
|
|
|
|
// Windows UNC paths (`\\server\share\...`) get their own bucket.
|
|
|
|
|
|
assert_eq!(paste_anchor_kind(r"\\server\share\x.png"), "windows_unc");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn starts_with_path_anchor_accepts_unc() {
|
|
|
|
|
|
assert!(starts_with_path_anchor(r"\\server\share\file.png"));
|
|
|
|
|
|
assert!(starts_with_path_anchor(r"\\?\C:\Users\Alice"));
|
|
|
|
|
|
// Single backslash is not a UNC prefix and not a path anchor.
|
|
|
|
|
|
assert!(!starts_with_path_anchor(r"\foo"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn dropped_path_image_file_url_returns_image() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let img = dir.path().join("pic.png");
|
|
|
|
|
|
write_png(&img, 2, 2);
|
|
|
|
|
|
|
|
|
|
|
|
let url = format!("file://{}", img.display());
|
|
|
|
|
|
let images = dropped_image_paths(&url);
|
|
|
|
|
|
assert_eq!(images.len(), 1);
|
|
|
|
|
|
assert_eq!(images[0], img);
|
|
|
|
|
|
assert!(dropped_non_image_paths(&url).is_empty());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn dropped_path_percent_encoded_space_round_trips() {
|
|
|
|
|
|
// `%20` must decode to a real space; the path with spaces must
|
|
|
|
|
|
// survive intact (no truncation at the space).
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let sub = dir.path().join("My Documents");
|
|
|
|
|
|
std::fs::create_dir_all(&sub).unwrap();
|
|
|
|
|
|
let txt = sub.join("notes report.txt");
|
|
|
|
|
|
std::fs::write(&txt, b"x").unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
// Construct the `file://` URL with `%20` for every space.
|
|
|
|
|
|
let encoded = txt.display().to_string().replace(' ', "%20");
|
|
|
|
|
|
let url = format!("file://{}", encoded);
|
|
|
|
|
|
|
|
|
|
|
|
let non_images = dropped_non_image_paths(&url);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
non_images.len(),
|
|
|
|
|
|
1,
|
|
|
|
|
|
"percent-encoded space must round-trip; got {non_images:?}"
|
|
|
|
|
|
);
|
|
|
|
|
|
assert_eq!(non_images[0], canon(&txt));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn dropped_path_percent_encoded_hash_round_trips() {
|
|
|
|
|
|
// `%23` decodes to `#`. The URL parser must not treat the
|
|
|
|
|
|
// suffix as a fragment.
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let txt = dir.path().join("notes#draft.txt");
|
|
|
|
|
|
std::fs::write(&txt, b"x").unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let encoded = txt.display().to_string().replace('#', "%23");
|
|
|
|
|
|
let url = format!("file://{}", encoded);
|
|
|
|
|
|
|
|
|
|
|
|
let non_images = dropped_non_image_paths(&url);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
non_images.len(),
|
|
|
|
|
|
1,
|
|
|
|
|
|
"percent-encoded `#` must round-trip; got {non_images:?}"
|
|
|
|
|
|
);
|
|
|
|
|
|
assert_eq!(non_images[0], canon(&txt));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn dropped_path_percent_encoded_question_round_trips() {
|
|
|
|
|
|
// `%3F` decodes to `?`. The URL parser must not treat the
|
|
|
|
|
|
// suffix as a query string.
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let txt = dir.path().join("query?file.txt");
|
|
|
|
|
|
std::fs::write(&txt, b"x").unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let encoded = txt.display().to_string().replace('?', "%3F");
|
|
|
|
|
|
let url = format!("file://{}", encoded);
|
|
|
|
|
|
|
|
|
|
|
|
let non_images = dropped_non_image_paths(&url);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
non_images.len(),
|
|
|
|
|
|
1,
|
|
|
|
|
|
"percent-encoded `?` must round-trip; got {non_images:?}"
|
|
|
|
|
|
);
|
|
|
|
|
|
assert_eq!(non_images[0], canon(&txt));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn dropped_path_multi_file_mixed_image_and_non_image() {
|
|
|
|
|
|
// Drop one image + one text file. Image should become an
|
|
|
|
|
|
// image chip, non-image should become a decoded path —
|
|
|
|
|
|
// *neither* should be silently dropped on the floor.
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let png = dir.path().join("a.png");
|
|
|
|
|
|
let txt = dir.path().join("b.txt");
|
|
|
|
|
|
write_png(&png, 2, 2);
|
|
|
|
|
|
std::fs::write(&txt, b"hi").unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let pasted = format!("{}\n{}", png.display(), txt.display());
|
|
|
|
|
|
let entries = dropped_paths(&pasted);
|
|
|
|
|
|
assert_eq!(entries.len(), 2, "both entries must be reported");
|
|
|
|
|
|
|
|
|
|
|
|
let mut saw_image = false;
|
|
|
|
|
|
let mut saw_non_image = false;
|
|
|
|
|
|
for entry in entries {
|
|
|
|
|
|
match entry {
|
|
|
|
|
|
DroppedPath::Image(img) => {
|
|
|
|
|
|
assert_eq!(img.source_path.as_ref().unwrap(), &png);
|
|
|
|
|
|
saw_image = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
DroppedPath::NonImage(p) => {
|
|
|
|
|
|
assert_eq!(p, canon(&txt));
|
|
|
|
|
|
saw_non_image = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
assert!(saw_image && saw_non_image);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn dropped_path_multi_file_url_mixed() {
|
|
|
|
|
|
// Same as above but both tokens are `file://` URLs.
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let png = dir.path().join("img.png");
|
|
|
|
|
|
let txt = dir.path().join("doc.md");
|
|
|
|
|
|
write_png(&png, 2, 2);
|
|
|
|
|
|
std::fs::write(&txt, b"# hi").unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let pasted = format!("file://{} file://{}", png.display(), txt.display());
|
|
|
|
|
|
let entries = dropped_paths(&pasted);
|
|
|
|
|
|
assert_eq!(entries.len(), 2);
|
|
|
|
|
|
// Pin the variant of each token — a regression that collapses
|
|
|
|
|
|
// both into Image (or both into NonImage) would otherwise be
|
|
|
|
|
|
// missed by a bare `len() == 2` assertion.
|
|
|
|
|
|
let mut saw_image = false;
|
|
|
|
|
|
let mut saw_non_image = false;
|
|
|
|
|
|
for entry in entries {
|
|
|
|
|
|
match entry {
|
|
|
|
|
|
DroppedPath::Image(img) => {
|
|
|
|
|
|
assert_eq!(img.source_path.as_ref().unwrap(), &png);
|
|
|
|
|
|
saw_image = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
DroppedPath::NonImage(p) => {
|
|
|
|
|
|
assert_eq!(p, canon(&txt));
|
|
|
|
|
|
saw_non_image = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
assert!(saw_image && saw_non_image);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn dropped_path_two_non_image_file_urls_both_intercepted() {
|
|
|
|
|
|
// The most likely real-world Finder multi-select drop pattern
|
|
|
|
|
|
// for source code review: drag two text files into the TUI.
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let a = dir.path().join("a.md");
|
|
|
|
|
|
let b = dir.path().join("b.md");
|
|
|
|
|
|
std::fs::write(&a, b"# a").unwrap();
|
|
|
|
|
|
std::fs::write(&b, b"# b").unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let pasted = format!("file://{}\nfile://{}", a.display(), b.display());
|
|
|
|
|
|
let entries = dropped_paths(&pasted);
|
|
|
|
|
|
assert_eq!(entries.len(), 2);
|
|
|
|
|
|
let paths: Vec<_> = entries
|
|
|
|
|
|
.into_iter()
|
|
|
|
|
|
.map(|d| match d {
|
|
|
|
|
|
DroppedPath::NonImage(p) => p,
|
|
|
|
|
|
_ => panic!("expected both as NonImage"),
|
|
|
|
|
|
})
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
assert_eq!(paths[0], canon(&a));
|
|
|
|
|
|
assert_eq!(paths[1], canon(&b));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn dropped_path_plus_sign_not_decoded_as_space() {
|
|
|
|
|
|
// RFC 3986 path-style decoding preserves `+`; only
|
|
|
|
|
|
// application/x-www-form-urlencoded decoding maps `+` → ` `.
|
|
|
|
|
|
// Filenames with `+` are common (`C++ Source.cpp`, `5+5.txt`).
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let txt = dir.path().join("c++ source.cpp");
|
|
|
|
|
|
std::fs::write(&txt, b"int main() {}").unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
// No percent-encoding: literal `+` in the URL.
|
|
|
|
|
|
// (Spaces still need to be encoded.)
|
|
|
|
|
|
let encoded = txt.display().to_string().replace(' ', "%20");
|
|
|
|
|
|
let url = format!("file://{}", encoded);
|
|
|
|
|
|
|
|
|
|
|
|
let non_images = dropped_non_image_paths(&url);
|
|
|
|
|
|
assert_eq!(non_images.len(), 1, "got {non_images:?}");
|
|
|
|
|
|
let got = &non_images[0];
|
|
|
|
|
|
assert_eq!(got, &canon(&txt));
|
|
|
|
|
|
// Belt-and-braces: the decoded path must still contain a `+`
|
|
|
|
|
|
// character, not a stray space.
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
got.to_string_lossy().contains('+'),
|
|
|
|
|
|
"`+` must survive decoding intact; got {got:?}"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Build a `file://` URL by URL-encoding `dir.path()` as a path
|
|
|
|
|
|
/// segment first, then appending the (already-encoded) leaf. Used
|
|
|
|
|
|
/// by tests so a `TMPDIR` resolving under a path with characters
|
|
|
|
|
|
/// that need percent-encoding (e.g. `+` or space) doesn't yield a
|
|
|
|
|
|
/// silently-unparseable URL.
|
|
|
|
|
|
fn build_file_url(dir: &std::path::Path, encoded_leaf: &str) -> String {
|
|
|
|
|
|
let base = url::Url::from_file_path(dir)
|
|
|
|
|
|
.expect("tempdir path must round-trip through url::Url::from_file_path");
|
|
|
|
|
|
let url = format!("{}/{}", base.as_str().trim_end_matches('/'), encoded_leaf);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
url::Url::parse(&url).is_ok(),
|
|
|
|
|
|
"constructed URL must parse: {url}"
|
|
|
|
|
|
);
|
|
|
|
|
|
url
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn dropped_path_multibyte_utf8_percent_encoded_round_trips() {
|
|
|
|
|
|
// macOS Finder emits `%XX` triplets for each UTF-8 byte of
|
|
|
|
|
|
// non-ASCII filename characters. `…` (U+2026) encodes as
|
|
|
|
|
|
// `%E2%80%A6`. The full triplet sequence must decode to the
|
|
|
|
|
|
// original codepoint, not be partially decoded or dropped.
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let txt = dir.path().join("ellipsis…file.md");
|
|
|
|
|
|
std::fs::write(&txt, b"# hi").unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let url = build_file_url(dir.path(), "ellipsis%E2%80%A6file.md");
|
|
|
|
|
|
|
|
|
|
|
|
let non_images = dropped_non_image_paths(&url);
|
|
|
|
|
|
assert_eq!(non_images.len(), 1, "got {non_images:?}");
|
|
|
|
|
|
assert_eq!(non_images[0], canon(&txt));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn dropped_path_mixed_case_percent_hex_equivalent() {
|
|
|
|
|
|
// RFC 3986 §2.1: `%2F` and `%2f` are equivalent. Some
|
|
|
|
|
|
// producers emit lowercase, some uppercase — both must
|
|
|
|
|
|
// round-trip to the same decoded path. Uses the ellipsis
|
|
|
|
|
|
// codepoint `…` (UTF-8 bytes `E2 80 A6`) so the percent
|
|
|
|
|
|
// triplets contain letters and case actually matters.
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let ellipsis_file = dir.path().join("e…e.txt");
|
|
|
|
|
|
std::fs::write(&ellipsis_file, b"x").unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let upper = build_file_url(dir.path(), "e%E2%80%A6e.txt");
|
|
|
|
|
|
let lower = build_file_url(dir.path(), "e%e2%80%a6e.txt");
|
|
|
|
|
|
|
|
|
|
|
|
let up = dropped_non_image_paths(&upper);
|
|
|
|
|
|
let lo = dropped_non_image_paths(&lower);
|
|
|
|
|
|
assert_eq!(up, lo, "mixed-case %XX must decode identically");
|
|
|
|
|
|
assert_eq!(up.len(), 1);
|
|
|
|
|
|
assert_eq!(up[0], canon(&ellipsis_file));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn dropped_path_invalid_percent_sequence_tolerated_outcome() {
|
|
|
|
|
|
// `%ZZ` is not a valid percent escape. The workspace-pinned
|
|
|
|
|
|
// `url` crate is lenient: it accepts the URL and `to_file_path`
|
|
|
|
|
|
// returns the path with the literal `%ZZ` triplet preserved.
|
|
|
|
|
|
// Two outcomes are acceptable: (a) the parser preserves the
|
|
|
|
|
|
// literal `%ZZ` and we emit a single `NonImage` with `%ZZ` in
|
|
|
|
|
|
// the path, or (b) the parser rejects the URL and we emit
|
|
|
|
|
|
// empty Vec → caller falls through to plain text paste. Any
|
|
|
|
|
|
// *third* outcome — empty Vec without falling through, or
|
|
|
|
|
|
// partial decoding of the suffix — must fail the test.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Hermeticity: build the URL under a tempfile so a hostile
|
|
|
|
|
|
// `/tmp/bad%ZZname.txt` left around by a previous test can't
|
|
|
|
|
|
// change the variant emitted.
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let base = url::Url::from_file_path(dir.path()).unwrap();
|
|
|
|
|
|
let url = format!("{}/bad%ZZname.txt", base.as_str().trim_end_matches('/'));
|
|
|
|
|
|
let entries = dropped_paths(&url);
|
|
|
|
|
|
let ok = entries.is_empty()
|
|
|
|
|
|
|| (entries.len() == 1
|
|
|
|
|
|
&& matches!(&entries[0], DroppedPath::NonImage(p) if p.to_string_lossy().contains("%ZZ")));
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
ok,
|
|
|
|
|
|
"%ZZ outcome must be either empty-Vec or single NonImage with literal `%ZZ`; got {entries:?}"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn dropped_path_extension_says_image_bytes_say_no_falls_to_non_image() {
|
|
|
|
|
|
// `.png` extension but bytes are garbage — `read_image_at_path`
|
|
|
|
|
|
// rejects via `mime_from_bytes` returning octet-stream, so the
|
|
|
|
|
|
// NonImage gate fires and the user gets the path text instead
|
|
|
|
|
|
// of a chip.
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let fake = dir.path().join("corrupt.png");
|
|
|
|
|
|
std::fs::write(&fake, b"this is not a PNG").unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let url = format!("file://{}", fake.display());
|
|
|
|
|
|
let entries = dropped_paths(&url);
|
|
|
|
|
|
assert_eq!(entries.len(), 1);
|
|
|
|
|
|
match &entries[0] {
|
|
|
|
|
|
DroppedPath::NonImage(p) => assert_eq!(p, &canon(&fake)),
|
|
|
|
|
|
other => panic!("expected NonImage fallthrough, got {other:?}"),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// A bare cwd-relative image filename like `foo.png` (no `/`,
|
|
|
|
|
|
/// no `~/`, no `X:\`, no `file://`) is rejected by the anchor
|
|
|
|
|
|
/// gate BEFORE `read_image_at_path` runs — so even if `foo.png`
|
|
|
|
|
|
/// happened to exist in the test process's cwd, it would not be
|
|
|
|
|
|
/// intercepted as an Image. The contrast case (an absolute-path
|
|
|
|
|
|
/// `file://` URL to the same content) is intercepted,
|
|
|
|
|
|
/// documenting the asymmetry next to executable code.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn dropped_path_bare_cwd_relative_image_name_not_intercepted() {
|
|
|
|
|
|
// Create a real PNG at <tempdir>/foo.png. The bare name
|
|
|
|
|
|
// `foo.png` must NOT be intercepted: the anchor gate
|
|
|
|
|
|
// short-circuits before `read_image_at_path` runs.
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let abs = dir.path().join("foo.png");
|
|
|
|
|
|
write_png(&abs, 2, 2);
|
|
|
|
|
|
|
|
|
|
|
|
// Bare relative name → rejected at the anchor gate.
|
|
|
|
|
|
let bare = dropped_paths("foo.png");
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
bare.is_empty(),
|
|
|
|
|
|
"bare cwd-relative image name must NOT be intercepted; got {bare:?}"
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// Same content via an absolute `file://` URL → accepted as Image.
|
|
|
|
|
|
let url = format!("file://{}", abs.display());
|
|
|
|
|
|
let via_url = dropped_paths(&url);
|
|
|
|
|
|
assert_eq!(via_url.len(), 1);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
matches!(via_url[0], DroppedPath::Image(_)),
|
|
|
|
|
|
"absolute file:// to the same PNG must be intercepted as Image; got {via_url:?}"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// `file:///` (empty path) and `file://` (root path) are
|
|
|
|
|
|
/// pathological — never legitimate drop URIs. They must be
|
|
|
|
|
|
/// rejected upstream of the existence check so the fallback
|
|
|
|
|
|
/// branch can't emit a bogus `NonImage("/")` entry.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn file_url_with_empty_or_root_path_is_rejected() {
|
|
|
|
|
|
// `file:///` parses to path `/` on Unix. Rejected by the
|
|
|
|
|
|
// root-path gate.
|
|
|
|
|
|
let entries = dropped_paths("file:///");
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
entries.is_empty(),
|
|
|
|
|
|
"file:/// must be rejected; got {entries:?}",
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// `file://` (no path component) is also rejected — either
|
|
|
|
|
|
// url-parse returns None for the empty path, or the empty-
|
|
|
|
|
|
// OsStr gate catches it.
|
|
|
|
|
|
let entries = dropped_paths("file://");
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
entries.is_empty(),
|
|
|
|
|
|
"file:// must be rejected; got {entries:?}",
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// A `file://` URL with a percent-encoded NUL or embedded
|
|
|
|
|
|
/// CR/LF byte decodes to a path that corrupts the terminal/text
|
|
|
|
|
|
/// pipeline when inserted as text. Reject these at parse time
|
|
|
|
|
|
/// so the prompt never sees them.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// The gate is intentionally narrow (NUL, CR, LF) — TAB and
|
|
|
|
|
|
/// other low-control bytes are legal in Unix filenames and the
|
|
|
|
|
|
/// TUI's text path renders them fine.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn file_url_with_nul_byte_path_is_rejected() {
|
|
|
|
|
|
let entries = dropped_paths("file:///tmp/path%00.png");
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
entries.is_empty(),
|
|
|
|
|
|
"NUL-byte path must be rejected; got {entries:?}",
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// CR and LF likewise corrupt the paste pipeline; reject them.
|
|
|
|
|
|
let entries = dropped_paths("file:///tmp/cr%0Dpath.txt");
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
entries.is_empty(),
|
|
|
|
|
|
"CR-byte path must be rejected; got {entries:?}",
|
|
|
|
|
|
);
|
|
|
|
|
|
let entries = dropped_paths("file:///tmp/lf%0Apath.txt");
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
entries.is_empty(),
|
|
|
|
|
|
"LF-byte path must be rejected; got {entries:?}",
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// HEIC/HEIF/AVIF/ICO are intentionally NOT in `IMAGE_EXTENSIONS`
|
|
|
|
|
|
/// — the inline overlay doesn't render them, so we fall through
|
|
|
|
|
|
/// to NonImage path text instead of falsely promoting a chip.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn unsupported_image_extensions_fall_to_non_image() {
|
|
|
|
|
|
for ext in ["heic", "heif", "avif", "ico"] {
|
|
|
|
|
|
assert!(!IMAGE_EXTENSIONS.contains(&ext), "{ext} must be omitted");
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let path = dir.path().join(format!("img.{ext}"));
|
|
|
|
|
|
std::fs::write(&path, b"any bytes").unwrap();
|
|
|
|
|
|
let url = format!("file://{}", path.display());
|
|
|
|
|
|
let entries = dropped_paths(&url);
|
|
|
|
|
|
assert_eq!(entries.len(), 1, "{ext}: {entries:?}");
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
matches!(entries[0], DroppedPath::NonImage(_)),
|
|
|
|
|
|
"{ext} must fall through to NonImage, got {:?}",
|
|
|
|
|
|
entries[0],
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// SVG is intentionally NOT in `IMAGE_EXTENSIONS` (XML/text
|
|
|
|
|
|
/// formats aren't sniffed as images and the inline overlay does
|
|
|
|
|
|
/// not render SVG). An `.svg` drop must fall through to NonImage
|
|
|
|
|
|
/// so the user gets a path string they can pass to the agent.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn svg_extension_not_intercepted_as_image() {
|
|
|
|
|
|
assert!(!IMAGE_EXTENSIONS.contains(&"svg"));
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let svg = dir.path().join("logo.svg");
|
|
|
|
|
|
std::fs::write(
|
|
|
|
|
|
&svg,
|
|
|
|
|
|
br#"<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg"/>"#,
|
|
|
|
|
|
)
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let url = format!("file://{}", svg.display());
|
|
|
|
|
|
let entries = dropped_paths(&url);
|
|
|
|
|
|
assert_eq!(entries.len(), 1);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
matches!(entries[0], DroppedPath::NonImage(_)),
|
|
|
|
|
|
"SVG must fall through to NonImage; got {:?}",
|
|
|
|
|
|
entries[0],
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn dropped_path_trailing_spaces_tolerated() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let txt = dir.path().join("trail3.txt");
|
|
|
|
|
|
std::fs::write(&txt, b"x").unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let url = format!("file://{} ", txt.display());
|
|
|
|
|
|
let non_images = dropped_non_image_paths(&url);
|
|
|
|
|
|
assert_eq!(non_images.len(), 1, "got {non_images:?}");
|
|
|
|
|
|
assert_eq!(non_images[0], canon(&txt));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn dropped_path_trailing_tab_and_newline_tolerated() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let txt = dir.path().join("trail4.txt");
|
|
|
|
|
|
std::fs::write(&txt, b"x").unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let url = format!("file://{}\t\n", txt.display());
|
|
|
|
|
|
let non_images = dropped_non_image_paths(&url);
|
|
|
|
|
|
assert_eq!(non_images.len(), 1, "got {non_images:?}");
|
|
|
|
|
|
assert_eq!(non_images[0], canon(&txt));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn dropped_path_empty_line_between_file_urls_tolerated() {
|
|
|
|
|
|
// Double newline between two `file://` URLs. `tokenize_paste`
|
|
|
|
|
|
// is supposed to filter the empty intermediate token; pin
|
|
|
|
|
|
// that for the `DroppedPath` flow.
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let a = dir.path().join("blank1.txt");
|
|
|
|
|
|
let b = dir.path().join("blank2.txt");
|
|
|
|
|
|
std::fs::write(&a, b"x").unwrap();
|
|
|
|
|
|
std::fs::write(&b, b"x").unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let pasted = format!("file://{}\n\nfile://{}", a.display(), b.display());
|
|
|
|
|
|
let non_images = dropped_non_image_paths(&pasted);
|
|
|
|
|
|
assert_eq!(non_images.len(), 2, "got {non_images:?}");
|
|
|
|
|
|
assert_eq!(non_images[0], canon(&a));
|
|
|
|
|
|
assert_eq!(non_images[1], canon(&b));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn dropped_path_double_space_between_file_urls_tolerated() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let a = dir.path().join("dbl1.txt");
|
|
|
|
|
|
let b = dir.path().join("dbl2.txt");
|
|
|
|
|
|
std::fs::write(&a, b"x").unwrap();
|
|
|
|
|
|
std::fs::write(&b, b"x").unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let pasted = format!("file://{} file://{}", a.display(), b.display());
|
|
|
|
|
|
let non_images = dropped_non_image_paths(&pasted);
|
|
|
|
|
|
assert_eq!(non_images.len(), 2, "got {non_images:?}");
|
|
|
|
|
|
assert_eq!(non_images[0], canon(&a));
|
|
|
|
|
|
assert_eq!(non_images[1], canon(&b));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn dropped_path_directory_via_file_url_intercepted_as_non_image() {
|
|
|
|
|
|
// A directory dropped as a `file://` URL is intercepted as
|
|
|
|
|
|
// NonImage (the NonImage branch uses `path.exists()`, not
|
|
|
|
|
|
// `is_file()`). Useful for "drag a folder into the TUI" UX.
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let sub = dir.path().join("a_folder");
|
|
|
|
|
|
std::fs::create_dir_all(&sub).unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let url = format!("file://{}", sub.display());
|
|
|
|
|
|
let entries = dropped_paths(&url);
|
|
|
|
|
|
assert_eq!(entries.len(), 1);
|
|
|
|
|
|
assert!(matches!(entries[0], DroppedPath::NonImage(_)));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn dropped_path_directory_via_bare_path_intercepted_as_non_image() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let sub = dir.path().join("another_folder");
|
|
|
|
|
|
std::fs::create_dir_all(&sub).unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let entries = dropped_paths(&sub.display().to_string());
|
|
|
|
|
|
assert_eq!(entries.len(), 1);
|
|
|
|
|
|
assert!(matches!(entries[0], DroppedPath::NonImage(_)));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn dropped_path_prose_with_embedded_existing_path_not_truncated() {
|
|
|
|
|
|
// /etc/hosts exists on every Unix-y machine. A sentence that
|
|
|
|
|
|
// mentions it must NOT be truncated to just the path token.
|
|
|
|
|
|
// (Regression sentinel for "prose-with-real-paths" — the
|
|
|
|
|
|
// full sentence isn't a valid file, so the all-anchored
|
|
|
|
|
|
// tokenizer gate falls back to a single-token line which
|
|
|
|
|
|
// doesn't exist on disk and hence is rejected.)
|
|
|
|
|
|
let entries = dropped_paths("I read /etc/passwd and got confused");
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
entries.is_empty(),
|
|
|
|
|
|
"prose with embedded existing path must fall through to plain text; got {entries:?}"
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
let entries = dropped_paths("Look at /etc/hosts to debug DNS");
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
entries.is_empty(),
|
|
|
|
|
|
"prose with embedded existing path must fall through to plain text; got {entries:?}"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn try_read_images_from_paste_equals_image_filtered_dropped_paths() {
|
|
|
|
|
|
// `try_read_images_from_paste` is now a thin filter over
|
|
|
|
|
|
// `try_read_dropped_paths`. Lock in the delegation invariant
|
|
|
|
|
|
// for several input shapes so a regression that diverges
|
|
|
|
|
|
// only in one shape (e.g. the empty-paste case) would still
|
|
|
|
|
|
// break the public-API contract visibly.
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let png1 = dir.path().join("img1.png");
|
|
|
|
|
|
let png2 = dir.path().join("img2.png");
|
|
|
|
|
|
let txt1 = dir.path().join("doc1.md");
|
|
|
|
|
|
let txt2 = dir.path().join("doc2.md");
|
|
|
|
|
|
write_png(&png1, 2, 2);
|
|
|
|
|
|
write_png(&png2, 2, 2);
|
|
|
|
|
|
std::fs::write(&txt1, b"# x").unwrap();
|
|
|
|
|
|
std::fs::write(&txt2, b"# y").unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
// Cover: empty, prose, image-only, non-image-only,
|
|
|
|
|
|
// mixed-image-and-non-image, multi-image, multi-non-image.
|
|
|
|
|
|
let inputs: Vec<String> = vec![
|
|
|
|
|
|
String::new(),
|
|
|
|
|
|
"hello world this is just prose".to_string(),
|
|
|
|
|
|
format!("file://{}", png1.display()),
|
|
|
|
|
|
format!("file://{}", txt1.display()),
|
|
|
|
|
|
format!("file://{} file://{}", png1.display(), txt1.display()),
|
|
|
|
|
|
format!("file://{}\nfile://{}", png1.display(), png2.display()),
|
|
|
|
|
|
format!("file://{}\nfile://{}", txt1.display(), txt2.display()),
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
for input in &inputs {
|
|
|
|
|
|
let images = try_read_images_from_paste(input);
|
|
|
|
|
|
let dropped = try_read_dropped_paths(input);
|
|
|
|
|
|
let image_paths_via_dropped: Vec<PathBuf> = dropped
|
|
|
|
|
|
.into_iter()
|
|
|
|
|
|
.filter_map(|d| match d {
|
|
|
|
|
|
DroppedPath::Image(img) => img.source_path,
|
|
|
|
|
|
DroppedPath::NonImage(_) => None,
|
|
|
|
|
|
})
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
let image_paths_via_filter: Vec<PathBuf> = images
|
|
|
|
|
|
.into_iter()
|
|
|
|
|
|
.filter_map(|img| img.source_path)
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
image_paths_via_filter, image_paths_via_dropped,
|
|
|
|
|
|
"delegation invariant must hold for input {input:?}"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// A single line that space-splits into multiple anchored
|
|
|
|
|
|
/// tokens where some don't resolve must NOT silently emit only
|
|
|
|
|
|
/// the resolving ones. The entire line falls through so the
|
|
|
|
|
|
/// unresolved substring isn't dropped into the void.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn dropped_path_per_line_partial_resolution_falls_through() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
// Hermeticity: `space_split_line` splits on each space that
|
|
|
|
|
|
// precedes a drop anchor. A `$TMPDIR` containing a space
|
|
|
|
|
|
// would inject extra split points into our `bogus` token and
|
|
|
|
|
|
// break the intended two-token tokenisation. macOS' default
|
|
|
|
|
|
// `/var/folders/...` and Linux' default `/tmp/...` are safe;
|
|
|
|
|
|
// a CI sandbox with `TMPDIR=/some path/foo` is not. Fail
|
|
|
|
|
|
// loudly rather than silently producing the wrong test
|
|
|
|
|
|
// shape.
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
!dir.path().to_string_lossy().contains(' '),
|
|
|
|
|
|
"this test assumes no-space TMPDIR; got {:?}",
|
|
|
|
|
|
dir.path()
|
|
|
|
|
|
);
|
|
|
|
|
|
let real = dir.path().join("real_dir");
|
|
|
|
|
|
std::fs::create_dir_all(&real).unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
// Build a line that space-splits to [bogus_anchored, real_dir].
|
|
|
|
|
|
// `bogus_anchored` is `<tmpdir>/nope nope nope` — anchored
|
|
|
|
|
|
// (starts with `/`) so the all-anchored gate keeps it, but
|
|
|
|
|
|
// doesn't exist on disk.
|
|
|
|
|
|
let bogus = dir.path().join("nope nope nope");
|
|
|
|
|
|
let pasted = format!("{} {}", bogus.display(), real.display());
|
|
|
|
|
|
|
|
|
|
|
|
// bogus.exists() is false; real.exists() is true. After the
|
|
|
|
|
|
// per-line all-or-nothing gate the line should emit *nothing*
|
|
|
|
|
|
// so the caller falls through to plain text paste — the user
|
|
|
|
|
|
// sees the verbatim string in the prompt rather than only
|
|
|
|
|
|
// `real_dir` appearing.
|
|
|
|
|
|
let entries = dropped_paths(&pasted);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
entries.is_empty(),
|
|
|
|
|
|
"partial-resolution within a single line must fall through; got {entries:?}"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// A drop-line + prose-line paste falls through to plain text
|
|
|
|
|
|
/// so the prose isn't silently lost — the screenshot-URL-
|
|
|
|
|
|
/// plus-caption pattern is the dominant use case and naive
|
|
|
|
|
|
/// per-line independence would eat the caption.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn dropped_path_url_plus_prose_falls_through() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let real = dir.path().join("real.txt");
|
|
|
|
|
|
std::fs::write(&real, b"x").unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let pasted = format!(
|
|
|
|
|
|
"file://{}\nbut please ignore the second comment line",
|
|
|
|
|
|
real.display()
|
|
|
|
|
|
);
|
|
|
|
|
|
let entries = dropped_paths(&pasted);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
entries.is_empty(),
|
|
|
|
|
|
"URL-plus-prose paste must fall through; got {entries:?}",
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// The canonical case: a screenshot URL followed by a
|
|
|
|
|
|
/// hand-typed caption must NOT lose the caption — the whole
|
|
|
|
|
|
/// paste falls through to plain text.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn dropped_path_url_plus_caption_falls_through_for_plain_paste() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let png = dir.path().join("screenshot.png");
|
|
|
|
|
|
write_png(&png, 2, 2);
|
|
|
|
|
|
|
|
|
|
|
|
let pasted = format!("file://{}\nplease look at this", png.display());
|
|
|
|
|
|
let entries = dropped_paths(&pasted);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
entries.is_empty(),
|
|
|
|
|
|
"screenshot URL + caption must fall through to plain text \
|
|
|
|
|
|
paste; got {entries:?}",
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn dropped_path_single_token_existing_bare_path_intercepted() {
|
|
|
|
|
|
// The flip side of the prose test: a single bare anchored
|
|
|
|
|
|
// path that exists IS a drop. Use a hermetic tempfile-backed
|
|
|
|
|
|
// directory so the test doesn't depend on `/tmp` existing or
|
|
|
|
|
|
// having any particular contents in a CI sandbox.
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let entries = dropped_paths(&dir.path().display().to_string());
|
|
|
|
|
|
// The directory exists; the NonImage gate uses `path.exists()`
|
|
|
|
|
|
// (not `is_file()`), so directories qualify.
|
|
|
|
|
|
assert_eq!(entries.len(), 1);
|
|
|
|
|
|
assert!(matches!(entries[0], DroppedPath::NonImage(_)));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn dropped_path_trailing_newline_tolerated_non_image() {
|
|
|
|
|
|
// Trailing-newline tolerance must extend to non-image paths.
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let txt = dir.path().join("trail.txt");
|
|
|
|
|
|
std::fs::write(&txt, b"x").unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let url = format!("file://{}\n", txt.display());
|
|
|
|
|
|
let non_images = dropped_non_image_paths(&url);
|
|
|
|
|
|
assert_eq!(non_images.len(), 1);
|
|
|
|
|
|
assert_eq!(non_images[0], canon(&txt));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn dropped_path_trailing_crlf_tolerated_non_image() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let txt = dir.path().join("trail2.txt");
|
|
|
|
|
|
std::fs::write(&txt, b"x").unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let url = format!("file://{}\r\n", txt.display());
|
|
|
|
|
|
let non_images = dropped_non_image_paths(&url);
|
|
|
|
|
|
assert_eq!(non_images.len(), 1);
|
|
|
|
|
|
assert_eq!(non_images[0], canon(&txt));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn dropped_path_prose_not_intercepted() {
|
|
|
|
|
|
// Free-form text must NOT be intercepted as a drop path.
|
|
|
|
|
|
let entries = dropped_paths("hello world this is just text");
|
|
|
|
|
|
assert!(entries.is_empty());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn dropped_path_nonexistent_bare_path_not_intercepted() {
|
|
|
|
|
|
// A bare path to a non-existent file is NOT intercepted: the
|
|
|
|
|
|
// user might just have typed `/etc/passwd` as part of prose.
|
|
|
|
|
|
// (The image branch already filters on file existence; this
|
|
|
|
|
|
// mirrors that for the non-image branch.)
|
|
|
|
|
|
let entries = dropped_paths("/tmp/definitely_does_not_exist_xyz_grok_pager.txt");
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
entries.is_empty(),
|
|
|
|
|
|
"bare nonexistent path must fall through to prose; got {entries:?}"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Regression sentinel for the `canonicalize().unwrap_or(path)`
|
|
|
|
|
|
/// fallback inside `try_read_dropped_path`. A `file://` URL is an
|
|
|
|
|
|
/// unambiguous drop URI even when the target is missing (stale
|
|
|
|
|
|
/// path, network mount). `canonicalize()` returns `Err` for
|
|
|
|
|
|
/// missing targets — the fallback branch must emit the raw
|
|
|
|
|
|
/// decoded path as `NonImage` so the user still gets a usable
|
|
|
|
|
|
/// path string.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn dropped_path_nonexistent_file_url_still_intercepted() {
|
|
|
|
|
|
// Use a tempdir-rooted nonexistent path so a developer's or
|
|
|
|
|
|
// CI sandbox's filesystem can't accidentally make the path
|
|
|
|
|
|
// resolve (`/tmp/definitely_does_not_exist...` could be
|
|
|
|
|
|
// present on a noisy machine).
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let nonexistent = dir.path().join("does/not/exist/at/all.txt");
|
|
|
|
|
|
let url = format!("file://{}", nonexistent.display());
|
|
|
|
|
|
let entries = dropped_paths(&url);
|
|
|
|
|
|
assert_eq!(entries.len(), 1);
|
|
|
|
|
|
// Explicit variant check first so a regression that
|
|
|
|
|
|
// collapsed `NonImage` into `Image` (or returned no entries)
|
|
|
|
|
|
// fails with a clean error rather than a destructuring panic.
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
matches!(entries[0], DroppedPath::NonImage(_)),
|
|
|
|
|
|
"expected NonImage variant; got {:?}",
|
|
|
|
|
|
entries[0]
|
|
|
|
|
|
);
|
|
|
|
|
|
match &entries[0] {
|
|
|
|
|
|
DroppedPath::NonImage(p) => {
|
|
|
|
|
|
assert_eq!(p, &nonexistent);
|
|
|
|
|
|
}
|
|
|
|
|
|
_ => unreachable!("matches! above asserted the variant"),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ----- reconcile ------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn reconcile_keeps_live_images() {
|
|
|
|
|
|
let mut images = vec![make_image(1, 1), make_image(2, 2), make_image(3, 3)];
|
|
|
|
|
|
let live: HashSet<ElementId> = [ElementId::from_raw(1), ElementId::from_raw(3)].into();
|
|
|
|
|
|
|
|
|
|
|
|
reconcile(&mut images, &live);
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(images.len(), 2);
|
|
|
|
|
|
assert_eq!(images[0].display_number, 1);
|
|
|
|
|
|
assert_eq!(images[1].display_number, 3);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn reconcile_removes_all_when_empty_live_set() {
|
|
|
|
|
|
let mut images = vec![make_image(1, 1), make_image(2, 2)];
|
|
|
|
|
|
let live: HashSet<ElementId> = HashSet::new();
|
|
|
|
|
|
|
|
|
|
|
|
reconcile(&mut images, &live);
|
|
|
|
|
|
|
|
|
|
|
|
assert!(images.is_empty());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn reconcile_noop_when_all_live() {
|
|
|
|
|
|
let mut images = vec![make_image(1, 1), make_image(2, 2)];
|
|
|
|
|
|
let live: HashSet<ElementId> = [ElementId::from_raw(1), ElementId::from_raw(2)].into();
|
|
|
|
|
|
|
|
|
|
|
|
reconcile(&mut images, &live);
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(images.len(), 2);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn reconcile_noop_on_empty_images() {
|
|
|
|
|
|
let mut images: Vec<PastedImage> = Vec::new();
|
|
|
|
|
|
let live: HashSet<ElementId> = [ElementId::from_raw(1)].into();
|
|
|
|
|
|
|
|
|
|
|
|
reconcile(&mut images, &live);
|
|
|
|
|
|
|
|
|
|
|
|
assert!(images.is_empty());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ----- clear ----------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn clear_resets_images_and_counter() {
|
|
|
|
|
|
let mut images = vec![make_image(1, 1), make_image(2, 2)];
|
|
|
|
|
|
let mut counter = 5usize;
|
|
|
|
|
|
|
|
|
|
|
|
clear(&mut images, &mut counter);
|
|
|
|
|
|
|
|
|
|
|
|
assert!(images.is_empty());
|
|
|
|
|
|
assert_eq!(counter, 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ----- persist_to_session ------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn persist_writes_file_and_clears_bytes() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let images_dir = dir.path().join("images");
|
|
|
|
|
|
|
|
|
|
|
|
let png_bytes = vec![0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, 0, 0];
|
|
|
|
|
|
let mut img = PastedImage {
|
|
|
|
|
|
element_id: ElementId::from_raw(1),
|
|
|
|
|
|
display_number: 1,
|
|
|
|
|
|
mime_type: "image/png".into(),
|
|
|
|
|
|
dimensions: None,
|
|
|
|
|
|
byte_len: png_bytes.len(),
|
|
|
|
|
|
encoded_bytes: Some(Arc::from(png_bytes.clone())),
|
|
|
|
|
|
source_path: None,
|
|
|
|
|
|
staged_temp_path: None,
|
|
|
|
|
|
session_image_path: None,
|
|
|
|
|
|
preview: PromptImagePreview::default(),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
persist_to_session(&mut img, &images_dir).unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
// File was written
|
|
|
|
|
|
let path = img.session_image_path.as_ref().unwrap();
|
|
|
|
|
|
assert!(path.exists());
|
|
|
|
|
|
assert!(path.to_string_lossy().ends_with(".png"));
|
|
|
|
|
|
assert_eq!(std::fs::read(path).unwrap(), png_bytes);
|
|
|
|
|
|
|
|
|
|
|
|
// In-memory bytes released
|
|
|
|
|
|
assert!(img.encoded_bytes.is_none());
|
|
|
|
|
|
|
|
|
|
|
|
// source_path stays None for clipboard pastes — the chip
|
|
|
|
|
|
// should show `[Image #1]` without an internal path.
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
img.source_path.is_none(),
|
|
|
|
|
|
"clipboard paste source_path should remain None"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn persist_creates_directory() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let images_dir = dir.path().join("deep").join("nested").join("images");
|
|
|
|
|
|
assert!(!images_dir.exists());
|
|
|
|
|
|
|
|
|
|
|
|
let mut img = PastedImage {
|
|
|
|
|
|
element_id: ElementId::from_raw(1),
|
|
|
|
|
|
display_number: 1,
|
|
|
|
|
|
mime_type: "image/jpeg".into(),
|
|
|
|
|
|
dimensions: None,
|
|
|
|
|
|
byte_len: 4,
|
|
|
|
|
|
encoded_bytes: Some(Arc::from(vec![0xff, 0xd8, 0xff, 0xe0])),
|
|
|
|
|
|
source_path: None,
|
|
|
|
|
|
staged_temp_path: None,
|
|
|
|
|
|
session_image_path: None,
|
|
|
|
|
|
preview: PromptImagePreview::default(),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
persist_to_session(&mut img, &images_dir).unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
assert!(images_dir.exists());
|
|
|
|
|
|
let path = img.session_image_path.as_ref().unwrap();
|
|
|
|
|
|
assert!(path.to_string_lossy().ends_with(".jpg"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn persist_fails_without_bytes() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let mut img = make_image(1, 1); // encoded_bytes is None
|
|
|
|
|
|
let result = persist_to_session(&mut img, dir.path());
|
|
|
|
|
|
assert!(result.is_err());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ----- persist_to_session path ownership --------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn persist_clipboard_image_keeps_source_path_none() {
|
|
|
|
|
|
// Clipboard paste (Copy Image in browser/Slack): source_path
|
|
|
|
|
|
// starts as None and should stay None after persistence so the
|
|
|
|
|
|
// chip shows `[Image #1]` without an internal session path.
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let images_dir = dir.path().join("images");
|
|
|
|
|
|
|
|
|
|
|
|
let png = make_test_png(100, 80);
|
|
|
|
|
|
let mut img = PastedImage {
|
|
|
|
|
|
element_id: ElementId::from_raw(1),
|
|
|
|
|
|
display_number: 1,
|
|
|
|
|
|
mime_type: "image/png".into(),
|
|
|
|
|
|
dimensions: Some((100, 80)),
|
|
|
|
|
|
byte_len: png.len(),
|
|
|
|
|
|
encoded_bytes: Some(Arc::from(png)),
|
|
|
|
|
|
source_path: None, // clipboard paste — no original path
|
|
|
|
|
|
staged_temp_path: None,
|
|
|
|
|
|
session_image_path: None,
|
|
|
|
|
|
preview: PromptImagePreview::default(),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
persist_to_session(&mut img, &images_dir).unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
// source_path stays None for clipboard pastes.
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
img.source_path.is_none(),
|
|
|
|
|
|
"clipboard paste should not gain a source_path"
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// session_image_path is set (bytes are persisted for reload).
|
|
|
|
|
|
assert!(img.session_image_path.is_some());
|
|
|
|
|
|
|
|
|
|
|
|
// Display text shows no path.
|
|
|
|
|
|
let text = display_text(1);
|
|
|
|
|
|
assert_eq!(text, "[Image #1]");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn persist_file_image_keeps_original_and_durable_paths_distinct() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let images_dir = dir.path().join("images");
|
|
|
|
|
|
|
|
|
|
|
|
let png = make_test_png(50, 50);
|
|
|
|
|
|
let original_path = PathBuf::from("/tmp/ephemeral-screenshot.png");
|
|
|
|
|
|
let mut img = PastedImage {
|
|
|
|
|
|
element_id: ElementId::from_raw(1),
|
|
|
|
|
|
display_number: 1,
|
|
|
|
|
|
mime_type: "image/png".into(),
|
|
|
|
|
|
dimensions: Some((50, 50)),
|
|
|
|
|
|
byte_len: png.len(),
|
|
|
|
|
|
encoded_bytes: Some(Arc::from(png)),
|
|
|
|
|
|
source_path: Some(original_path.clone()),
|
|
|
|
|
|
staged_temp_path: None,
|
|
|
|
|
|
session_image_path: None,
|
|
|
|
|
|
preview: PromptImagePreview::default(),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
persist_to_session(&mut img, &images_dir).unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let session_path = img.session_image_path.as_ref().unwrap();
|
|
|
|
|
|
assert_eq!(img.source_path.as_ref(), Some(&original_path));
|
|
|
|
|
|
assert_ne!(
|
|
|
|
|
|
img.source_path.as_ref(),
|
|
|
|
|
|
Some(session_path),
|
|
|
|
|
|
"source_path is the original display path, not the durable copy"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ----- from_clipboard_data -----------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn from_clipboard_data_populates_fields() {
|
|
|
|
|
|
let data = crate::clipboard::ImageData {
|
|
|
|
|
|
data: vec![1, 2, 3, 4],
|
|
|
|
|
|
mime_type: "image/png".into(),
|
|
|
|
|
|
};
|
|
|
|
|
|
let img = from_clipboard_data(&data);
|
|
|
|
|
|
assert_eq!(img.mime_type, "image/png");
|
|
|
|
|
|
assert_eq!(img.byte_len, 4);
|
|
|
|
|
|
assert!(img.encoded_bytes.is_some());
|
|
|
|
|
|
assert_eq!(img.encoded_bytes.as_ref().unwrap().len(), 4);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn from_clipboard_prepares_preview_before_render() {
|
|
|
|
|
|
let _guard = crate::terminal::image::set_protocol_for_test(
|
|
|
|
|
|
crate::terminal::image::GraphicsProtocol::Kitty,
|
|
|
|
|
|
);
|
|
|
|
|
|
let data = crate::clipboard::ImageData {
|
|
|
|
|
|
data: make_test_png(16, 12),
|
|
|
|
|
|
mime_type: "image/png".into(),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let img = from_clipboard_data(&data);
|
|
|
|
|
|
assert!(img.preview.is_pending());
|
|
|
|
|
|
img.preview_preparation().unwrap().run();
|
|
|
|
|
|
let (bytes, dimensions) = img.preview.prepared().expect("valid PNG becomes ready");
|
|
|
|
|
|
assert_eq!(dimensions, (16, 12));
|
|
|
|
|
|
assert!(crate::terminal::image::kitty_format_from_bytes(bytes).is_some());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn from_clipboard_marks_corrupt_preview_failed_without_losing_send_bytes() {
|
|
|
|
|
|
let _guard = crate::terminal::image::set_protocol_for_test(
|
|
|
|
|
|
crate::terminal::image::GraphicsProtocol::Kitty,
|
|
|
|
|
|
);
|
|
|
|
|
|
let data = crate::clipboard::ImageData {
|
|
|
|
|
|
data: b"\x89PNG\r\n\x1a\ncorrupt".to_vec(),
|
|
|
|
|
|
mime_type: "image/png".into(),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let img = from_clipboard_data(&data);
|
|
|
|
|
|
img.preview_preparation().unwrap().run();
|
|
|
|
|
|
assert!(img.preview.is_failed());
|
|
|
|
|
|
assert_eq!(img.encoded_bytes.as_deref(), Some(data.data.as_slice()));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ----- test PNG helper --------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
/// Generate a valid minimal PNG of the given dimensions.
|
|
|
|
|
|
fn make_test_png(width: u32, height: u32) -> Vec<u8> {
|
|
|
|
|
|
use image::{ImageBuffer, Rgba};
|
|
|
|
|
|
let img: ImageBuffer<Rgba<u8>, Vec<u8>> =
|
|
|
|
|
|
ImageBuffer::from_pixel(width, height, Rgba([128, 64, 32, 255]));
|
|
|
|
|
|
let mut buf = Vec::new();
|
|
|
|
|
|
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
buf
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn make_test_jpeg(width: u32, height: u32) -> Vec<u8> {
|
|
|
|
|
|
use image::{ImageBuffer, Rgb};
|
|
|
|
|
|
let img: ImageBuffer<Rgb<u8>, Vec<u8>> =
|
|
|
|
|
|
ImageBuffer::from_pixel(width, height, Rgb([128, 64, 32]));
|
|
|
|
|
|
let mut buf = Vec::new();
|
|
|
|
|
|
img.write_to(
|
|
|
|
|
|
&mut std::io::Cursor::new(&mut buf),
|
|
|
|
|
|
image::ImageFormat::Jpeg,
|
|
|
|
|
|
)
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
buf
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Create a `PastedImage` with real PNG bytes.
|
|
|
|
|
|
fn make_real_image(width: u32, height: u32) -> PastedImage {
|
|
|
|
|
|
let png = make_test_png(width, height);
|
|
|
|
|
|
PastedImage {
|
|
|
|
|
|
element_id: ElementId::from_raw(1),
|
|
|
|
|
|
display_number: 1,
|
|
|
|
|
|
mime_type: "image/png".into(),
|
|
|
|
|
|
dimensions: Some((width, height)),
|
|
|
|
|
|
byte_len: png.len(),
|
|
|
|
|
|
encoded_bytes: Some(Arc::from(png)),
|
|
|
|
|
|
source_path: None,
|
|
|
|
|
|
staged_temp_path: None,
|
|
|
|
|
|
session_image_path: None,
|
|
|
|
|
|
preview: PromptImagePreview::default(),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ----- load_for_send ------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn load_small_image_passes_through() {
|
|
|
|
|
|
let img = make_real_image(100, 80);
|
|
|
|
|
|
let original_len = img.encoded_bytes.as_ref().unwrap().len();
|
|
|
|
|
|
let (bytes, mime) = load_for_send(&img).unwrap();
|
|
|
|
|
|
assert_eq!(mime, "image/png");
|
|
|
|
|
|
assert_eq!(bytes.len(), original_len);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn load_rejects_image_below_eight_pixel_minimum() {
|
|
|
|
|
|
let img = make_real_image(1, 1);
|
|
|
|
|
|
assert!(load_for_send(&img).is_none());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn load_from_file() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let path = dir.path().join("test.png");
|
|
|
|
|
|
let png = make_test_png(50, 50);
|
|
|
|
|
|
std::fs::write(&path, &png).unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let img = PastedImage {
|
|
|
|
|
|
element_id: ElementId::from_raw(1),
|
|
|
|
|
|
display_number: 1,
|
|
|
|
|
|
mime_type: "image/png".into(),
|
|
|
|
|
|
dimensions: Some((50, 50)),
|
|
|
|
|
|
byte_len: png.len(),
|
|
|
|
|
|
encoded_bytes: None, // bytes released
|
|
|
|
|
|
source_path: None,
|
|
|
|
|
|
staged_temp_path: None,
|
|
|
|
|
|
session_image_path: Some(path),
|
|
|
|
|
|
preview: PromptImagePreview::default(),
|
|
|
|
|
|
};
|
|
|
|
|
|
let (bytes, _) = load_for_send(&img).unwrap();
|
|
|
|
|
|
assert_eq!(bytes.len(), png.len());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn load_returns_none_for_missing_data() {
|
|
|
|
|
|
let img = make_image(1, 1); // no bytes, no file
|
|
|
|
|
|
assert!(load_for_send(&img).is_none());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ----- build_content_blocks_with_workspace --------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
fn build_blocks_no_workspace(
|
|
|
|
|
|
text: String,
|
|
|
|
|
|
images: Vec<PastedImage>,
|
|
|
|
|
|
) -> Vec<agent_client_protocol::ContentBlock> {
|
|
|
|
|
|
build_content_blocks_with_workspace(text, images, None)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn build_blocks_text_only() {
|
|
|
|
|
|
let blocks = build_blocks_no_workspace("hello".into(), vec![]);
|
|
|
|
|
|
assert_eq!(blocks.len(), 1);
|
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
|
&blocks[0],
|
|
|
|
|
|
agent_client_protocol::ContentBlock::Text(_)
|
|
|
|
|
|
));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn build_blocks_with_in_memory_image() {
|
|
|
|
|
|
let img = make_real_image(100, 80);
|
|
|
|
|
|
let blocks = build_blocks_no_workspace("look at this [Image #1]".into(), vec![img]);
|
|
|
|
|
|
assert_eq!(blocks.len(), 2);
|
|
|
|
|
|
if let agent_client_protocol::ContentBlock::Image(ic) = &blocks[1] {
|
|
|
|
|
|
assert_eq!(ic.mime_type, "image/png");
|
|
|
|
|
|
assert!(!ic.data.is_empty());
|
|
|
|
|
|
assert!(ic.uri.is_none());
|
|
|
|
|
|
} else {
|
|
|
|
|
|
panic!("expected Image block");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn build_blocks_with_file_image() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let path = dir.path().join("test.png");
|
|
|
|
|
|
let png = make_test_png(60, 40);
|
|
|
|
|
|
std::fs::write(&path, &png).unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let img = PastedImage {
|
|
|
|
|
|
element_id: ElementId::from_raw(1),
|
|
|
|
|
|
display_number: 1,
|
|
|
|
|
|
mime_type: "image/png".into(),
|
|
|
|
|
|
dimensions: Some((60, 40)),
|
|
|
|
|
|
byte_len: png.len(),
|
|
|
|
|
|
encoded_bytes: None,
|
|
|
|
|
|
source_path: None,
|
|
|
|
|
|
staged_temp_path: None,
|
|
|
|
|
|
session_image_path: Some(path.clone()),
|
|
|
|
|
|
preview: PromptImagePreview::default(),
|
|
|
|
|
|
};
|
|
|
|
|
|
let blocks = build_blocks_no_workspace("text".into(), vec![img]);
|
|
|
|
|
|
assert_eq!(blocks.len(), 2);
|
|
|
|
|
|
if let agent_client_protocol::ContentBlock::Image(ic) = &blocks[1] {
|
|
|
|
|
|
assert!(!ic.data.is_empty());
|
|
|
|
|
|
// The durable session copy is surfaced through `uri` even for
|
|
|
|
|
|
// clipboard pastes (no `source_path`). This is the reference
|
|
|
|
|
|
// `image_edit` resolves `[Image #N]` against; vision is
|
|
|
|
|
|
// unaffected because `pick_user_image_url` never forwards a
|
|
|
|
|
|
// `file://` URI to the model.
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
ic.uri.as_deref(),
|
|
|
|
|
|
Some(format!("file://{}", path.display()).as_str()),
|
|
|
|
|
|
"clipboard images must carry the durable session path as a file:// URI"
|
|
|
|
|
|
);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
panic!("expected Image block");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn build_blocks_omits_uri_for_stale_source_path() {
|
|
|
|
|
|
let mut img = make_real_image(100, 80);
|
|
|
|
|
|
img.source_path = Some(PathBuf::from("/Users/test/logo.png"));
|
|
|
|
|
|
let blocks = build_blocks_no_workspace("text".into(), vec![img]);
|
|
|
|
|
|
assert_eq!(blocks.len(), 2);
|
|
|
|
|
|
if let agent_client_protocol::ContentBlock::Image(ic) = &blocks[1] {
|
|
|
|
|
|
assert!(ic.uri.is_none());
|
|
|
|
|
|
assert!(!ic.data.is_empty());
|
|
|
|
|
|
} else {
|
|
|
|
|
|
panic!("expected Image block");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[cfg(unix)]
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn build_blocks_canonicalizes_source_only_for_wire_uri() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let target = dir.path().join("target.png");
|
|
|
|
|
|
let visible = dir.path().join("visible.png");
|
|
|
|
|
|
std::fs::write(&target, make_test_png(8, 8)).unwrap();
|
|
|
|
|
|
std::os::unix::fs::symlink(&target, &visible).unwrap();
|
|
|
|
|
|
let mut img = make_real_image(8, 8);
|
|
|
|
|
|
img.source_path = Some(visible.clone());
|
|
|
|
|
|
|
|
|
|
|
|
let blocks = build_blocks_no_workspace("text".into(), vec![img]);
|
|
|
|
|
|
let agent_client_protocol::ContentBlock::Image(image) = &blocks[1] else {
|
|
|
|
|
|
panic!("expected image");
|
|
|
|
|
|
};
|
|
|
|
|
|
let canonical_target = dunce::canonicalize(&target).unwrap();
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
image.uri.as_deref(),
|
|
|
|
|
|
Some(format!("file://{}", canonical_target.display()).as_str())
|
|
|
|
|
|
);
|
|
|
|
|
|
assert_ne!(visible, target);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn build_blocks_sets_display_number_meta() {
|
|
|
|
|
|
let mut img = make_real_image(40, 30);
|
|
|
|
|
|
img.display_number = 3;
|
|
|
|
|
|
let blocks = build_blocks_no_workspace("text [Image #3]".into(), vec![img]);
|
|
|
|
|
|
assert_eq!(blocks.len(), 2);
|
|
|
|
|
|
let agent_client_protocol::ContentBlock::Image(ic) = &blocks[1] else {
|
|
|
|
|
|
panic!("expected Image block");
|
|
|
|
|
|
};
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
xai_grok_shared::placeholder_images::display_number_from_meta(ic.meta.as_ref()),
|
|
|
|
|
|
Some(3),
|
|
|
|
|
|
"image block _meta must carry the real display number for token resolution"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn build_blocks_uri_prefers_durable_session_path() {
|
|
|
|
|
|
let mut img = make_real_image(100, 80);
|
|
|
|
|
|
img.source_path = Some(PathBuf::from("/Users/test/original.png"));
|
|
|
|
|
|
img.session_image_path = Some(PathBuf::from("/Users/test/.grok/session/image.png"));
|
|
|
|
|
|
let blocks = build_blocks_no_workspace("text".into(), vec![img]);
|
|
|
|
|
|
assert_eq!(blocks.len(), 2);
|
|
|
|
|
|
if let agent_client_protocol::ContentBlock::Image(ic) = &blocks[1] {
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
ic.uri.as_deref(),
|
|
|
|
|
|
Some("file:///Users/test/.grok/session/image.png"),
|
|
|
|
|
|
"model URI must prefer the durable session path"
|
|
|
|
|
|
);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
panic!("expected Image block");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn build_blocks_skips_missing_image() {
|
|
|
|
|
|
let img = make_image(1, 1); // no bytes, no file path
|
|
|
|
|
|
let blocks = build_blocks_no_workspace("text".into(), vec![img]);
|
|
|
|
|
|
// Only the text block; image was skipped.
|
|
|
|
|
|
assert_eq!(blocks.len(), 1);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn build_blocks_one_bad_one_good() {
|
|
|
|
|
|
let bad = make_image(1, 1); // no bytes
|
|
|
|
|
|
let good = make_real_image(50, 50);
|
|
|
|
|
|
let blocks = build_blocks_no_workspace("text".into(), vec![bad, good]);
|
|
|
|
|
|
// Text + 1 good image; bad image skipped.
|
|
|
|
|
|
assert_eq!(blocks.len(), 2);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ----- Orphan placeholder fallback ----------------------------------
|
|
|
|
|
|
//
|
|
|
|
|
|
// These tests go through `build_content_blocks_with_prefixes` with
|
|
|
|
|
|
// an explicit hermetic prefix list, so they do NOT read the
|
|
|
|
|
|
// ambient process `$HOME`. CI runners with unusual `HOME`
|
|
|
|
|
|
// settings cannot flip the outcomes.
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn build_blocks_orphan_placeholder_loaded_from_disk() {
|
|
|
|
|
|
use base64::Engine as _;
|
|
|
|
|
|
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let path = dir.path().join("orphan.png");
|
|
|
|
|
|
let on_disk = make_test_png(20, 20);
|
|
|
|
|
|
std::fs::write(&path, &on_disk).unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let text = format!(
|
|
|
|
|
|
"look at [Image #1: {}] please",
|
|
|
|
|
|
dunce::canonicalize(&path).unwrap().display(),
|
|
|
|
|
|
);
|
|
|
|
|
|
let allowed = [dunce::canonicalize(dir.path()).unwrap()];
|
|
|
|
|
|
let blocks = build_content_blocks_with_prefixes(text, vec![], Some(&allowed));
|
|
|
|
|
|
|
|
|
|
|
|
// Text block + 1 recovered image.
|
|
|
|
|
|
assert_eq!(blocks.len(), 2);
|
|
|
|
|
|
let agent_client_protocol::ContentBlock::Image(ic) = &blocks[1] else {
|
|
|
|
|
|
panic!("expected recovered Image block");
|
|
|
|
|
|
};
|
|
|
|
|
|
assert_eq!(ic.mime_type, "image/png");
|
|
|
|
|
|
assert!(!ic.data.is_empty());
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
ic.uri.as_deref().unwrap().starts_with("file://"),
|
|
|
|
|
|
"uri should be file:// URI form, got {:?}",
|
|
|
|
|
|
ic.uri
|
|
|
|
|
|
);
|
|
|
|
|
|
// Base64-encoded `data` must round-trip back to the on-disk
|
|
|
|
|
|
// PNG bytes. A regression emitting raw bytes or
|
|
|
|
|
|
// double-encoding would fail this assertion.
|
|
|
|
|
|
let decoded = base64::engine::general_purpose::STANDARD
|
|
|
|
|
|
.decode(&ic.data)
|
|
|
|
|
|
.expect("data must be valid base64");
|
|
|
|
|
|
assert_eq!(decoded, on_disk);
|
|
|
|
|
|
// Placeholder anchor stays but the path is now stripped — the
|
|
|
|
|
|
// image is already attached inline, so the model has no reason
|
|
|
|
|
|
// to call `Read` on the path (and the path component would
|
|
|
|
|
|
// tempt it to). The bracketed `[Image #N]` form preserves the
|
|
|
|
|
|
// positional anchor inside the prose.
|
|
|
|
|
|
let agent_client_protocol::ContentBlock::Text(t) = &blocks[0] else {
|
|
|
|
|
|
panic!("first block must be text");
|
|
|
|
|
|
};
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
t.text.contains("[Image #1]"),
|
|
|
|
|
|
"anchor should be preserved on successful recovery, got: {}",
|
|
|
|
|
|
t.text
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
!t.text.contains("[Image #1:"),
|
|
|
|
|
|
"the path-bearing form must be stripped once the image is attached, got: {}",
|
|
|
|
|
|
t.text
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
!t.text.contains("orphan.png"),
|
|
|
|
|
|
"file name leaked through after path strip, got: {}",
|
|
|
|
|
|
t.text
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Regression: when the user types `[Image #N: <path>]` and the file
|
|
|
|
|
|
// exists at paste time, the prompt-widget creates a PastedImage and
|
|
|
|
|
|
// the image is attached inline. The path in the prompt text must
|
|
|
|
|
|
// then be stripped to `[Image #N]` so the model doesn't follow up
|
|
|
|
|
|
// with a redundant `Read` tool call on the same file.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn build_blocks_strips_path_when_pasted_image_attached() {
|
|
|
|
|
|
let mut img = make_real_image(40, 30);
|
|
|
|
|
|
img.display_number = 1;
|
|
|
|
|
|
img.source_path = Some(std::path::PathBuf::from(
|
|
|
|
|
|
"/Users/me/Desktop/Screenshot 2026-05-22 at 16.01.21.png",
|
|
|
|
|
|
));
|
|
|
|
|
|
let text = "what is that?[Image #1: /Users/me/Desktop/Screenshot 2026-05-22 at 16.01.21.png] thanks".to_string();
|
|
|
|
|
|
|
|
|
|
|
|
// Hermetic: no orphan-recovery path needed (PastedImage matches).
|
|
|
|
|
|
let blocks = build_content_blocks_with_prefixes(text, vec![img], Some(&[]));
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(blocks.len(), 2, "expected text + 1 inline image");
|
|
|
|
|
|
let agent_client_protocol::ContentBlock::Text(t) = &blocks[0] else {
|
|
|
|
|
|
panic!("first block must be text");
|
|
|
|
|
|
};
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
t.text, "what is that?[Image #1] thanks",
|
|
|
|
|
|
"placeholder path must be stripped while the anchor survives"
|
|
|
|
|
|
);
|
|
|
|
|
|
let agent_client_protocol::ContentBlock::Image(ic) = &blocks[1] else {
|
|
|
|
|
|
panic!("second block must be the inline image");
|
|
|
|
|
|
};
|
|
|
|
|
|
assert_eq!(ic.mime_type, "image/png");
|
|
|
|
|
|
assert!(!ic.data.is_empty(), "inline image must carry base64 bytes");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn build_blocks_orphan_placeholder_missing_file_is_stripped_with_warn() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let bogus = dir.path().join("nope.png");
|
|
|
|
|
|
let text = format!("before [Image #4: {}] after", bogus.display());
|
|
|
|
|
|
|
|
|
|
|
|
let allowed = [dunce::canonicalize(dir.path()).unwrap()];
|
|
|
|
|
|
let blocks = build_content_blocks_with_prefixes(text, vec![], Some(&allowed));
|
|
|
|
|
|
// No image attached, only text block.
|
|
|
|
|
|
assert_eq!(blocks.len(), 1);
|
|
|
|
|
|
let agent_client_protocol::ContentBlock::Text(t) = &blocks[0] else {
|
|
|
|
|
|
panic!("expected text block");
|
|
|
|
|
|
};
|
|
|
|
|
|
// Pin the exact post-strip text — the strip seam (space
|
|
|
|
|
|
// before + space after the placeholder) collapses to a
|
|
|
|
|
|
// single space.
|
|
|
|
|
|
assert_eq!(t.text, "before after");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn collapse_strip_seam_preserves_code_block_indentation() {
|
|
|
|
|
|
// A naive implementation could collapse ALL 2+-space runs
|
|
|
|
|
|
// in the text after a single strip; indented code further
|
|
|
|
|
|
// down the text must survive intact.
|
|
|
|
|
|
let mut text = String::from("hello [Image #1: /tmp/x.png] world\n fn foo() {}\n");
|
|
|
|
|
|
let span = (text.find("[Image").unwrap(), text.find("]").unwrap() + 1);
|
|
|
|
|
|
collapse_strip_seam(&mut text, span.0, span.1);
|
|
|
|
|
|
// The strip seam (space–space) collapses to one space, while
|
|
|
|
|
|
// the 4-space code indentation further down is untouched.
|
|
|
|
|
|
assert_eq!(text, "hello world\n fn foo() {}\n");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn collapse_strip_seam_preserves_double_space_elsewhere() {
|
|
|
|
|
|
// Punctuation double-space far from the strip seam stays.
|
|
|
|
|
|
let mut text = String::from("a. b [Image #1: /x.png] c. d");
|
|
|
|
|
|
let span = (text.find("[Image").unwrap(), text.find("]").unwrap() + 1);
|
|
|
|
|
|
collapse_strip_seam(&mut text, span.0, span.1);
|
|
|
|
|
|
assert_eq!(text, "a. b c. d");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn build_blocks_orphan_skipped_when_pasted_image_present() {
|
|
|
|
|
|
// PastedImage with display_number 1 is attached; the matching
|
|
|
|
|
|
// placeholder must NOT trigger an on-disk load even when the
|
|
|
|
|
|
// placeholder's path doesn't exist.
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let missing = dir.path().join("does-not-exist.png");
|
|
|
|
|
|
let img = make_real_image(40, 40);
|
|
|
|
|
|
let text = format!("see [Image #1: {}]", missing.display());
|
|
|
|
|
|
|
|
|
|
|
|
let allowed = [dunce::canonicalize(dir.path()).unwrap()];
|
|
|
|
|
|
let blocks = build_content_blocks_with_prefixes(text, vec![img], Some(&allowed));
|
|
|
|
|
|
// Text + the PastedImage's own block; no orphan recovery
|
|
|
|
|
|
// (skipped because `display_number` matches).
|
|
|
|
|
|
assert_eq!(blocks.len(), 2);
|
|
|
|
|
|
let agent_client_protocol::ContentBlock::Text(t) = &blocks[0] else {
|
|
|
|
|
|
panic!("first block must be text");
|
|
|
|
|
|
};
|
|
|
|
|
|
// Phase 2 universal strip: the anchor `[Image #1]` survives so
|
|
|
|
|
|
// the model can place the inline image, but the path is gone
|
|
|
|
|
|
// even though no orphan-recovery loaded it (a PastedImage
|
|
|
|
|
|
// already provided the bytes). This avoids the "model calls
|
|
|
|
|
|
// Read on the path even though the image is attached" pattern.
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
t.text.contains("[Image #1]"),
|
|
|
|
|
|
"anchor must survive when a PastedImage backs the placeholder, got: {}",
|
|
|
|
|
|
t.text
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
!t.text.contains("[Image #1:"),
|
|
|
|
|
|
"path must be stripped even when the load was skipped, got: {}",
|
|
|
|
|
|
t.text
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn build_blocks_no_workspace_falls_back_to_legacy_behavior() {
|
|
|
|
|
|
// Without a workspace cwd, orphan placeholders are not loaded
|
|
|
|
|
|
// from disk (legacy behaviour preserved). The Phase 2 path
|
|
|
|
|
|
// strip still runs — it is independent of the allowlist —
|
|
|
|
|
|
// because the model-facing prompt should never contain the
|
|
|
|
|
|
// path-bearing form regardless of whether the load happened.
|
|
|
|
|
|
let text = "look at [Image #2: /nowhere/missing.png]";
|
|
|
|
|
|
let blocks = build_content_blocks_with_workspace(text.into(), vec![], None);
|
|
|
|
|
|
// Text block only — no recovery without a workspace.
|
|
|
|
|
|
assert_eq!(blocks.len(), 1);
|
|
|
|
|
|
let agent_client_protocol::ContentBlock::Text(t) = &blocks[0] else {
|
|
|
|
|
|
panic!("expected text block");
|
|
|
|
|
|
};
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
t.text.contains("[Image #2]"),
|
|
|
|
|
|
"anchor must survive the no-workspace path, got: {}",
|
|
|
|
|
|
t.text
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
!t.text.contains("[Image #2:"),
|
|
|
|
|
|
"path strip is unconditional once the regex matches, got: {}",
|
|
|
|
|
|
t.text
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ----- TUI aggregate-cap injectable variant + tests -----------------
|
|
|
|
|
|
//
|
|
|
|
|
|
// Mirrors the server-side
|
|
|
|
|
|
// `recover_orphan_placeholders_with_prefixes_and_caps` tests so a
|
|
|
|
|
|
// refactor of the TUI loop (e.g. moving `aggregate_bytes += ...`
|
|
|
|
|
|
// before the cap check, or swapping `break` for `continue`) is
|
|
|
|
|
|
// caught here even though the cap constant is shared.
|
|
|
|
|
|
|
|
|
|
|
|
/// Two orphan placeholders, aggregate cap admits exactly one.
|
|
|
|
|
|
/// Asserts the second placeholder did NOT load (only one image
|
|
|
|
|
|
/// block in the output) and the first one did.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn build_blocks_orphan_aggregate_cap_breaks_loop() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let p1 = dir.path().join("a.png");
|
|
|
|
|
|
let p2 = dir.path().join("b.png");
|
|
|
|
|
|
let png = make_test_png(20, 20);
|
|
|
|
|
|
std::fs::write(&p1, &png).unwrap();
|
|
|
|
|
|
std::fs::write(&p2, &png).unwrap();
|
|
|
|
|
|
let c1 = dunce::canonicalize(&p1).unwrap();
|
|
|
|
|
|
let c2 = dunce::canonicalize(&p2).unwrap();
|
|
|
|
|
|
let text = format!("[Image #1: {}] [Image #2: {}]", c1.display(), c2.display());
|
|
|
|
|
|
let allowed = [dunce::canonicalize(dir.path()).unwrap()];
|
|
|
|
|
|
// Cap admits the first image but not the cumulative second.
|
|
|
|
|
|
let blocks =
|
|
|
|
|
|
build_content_blocks_with_prefixes_and_caps(text, vec![], Some(&allowed), png.len());
|
|
|
|
|
|
// Text + 1 recovered image (not 2).
|
|
|
|
|
|
assert_eq!(blocks.len(), 2);
|
|
|
|
|
|
let agent_client_protocol::ContentBlock::Image(ic) = &blocks[1] else {
|
|
|
|
|
|
panic!("expected recovered Image block");
|
|
|
|
|
|
};
|
|
|
|
|
|
let attached_uri = ic.uri.as_deref().unwrap();
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
attached_uri.contains("a.png"),
|
|
|
|
|
|
"first placeholder must be the one kept, got: {attached_uri}"
|
|
|
|
|
|
);
|
|
|
|
|
|
// Cap-breach is a `break` path, not an `Err`-path strip —
|
|
|
|
|
|
// the rejected placeholder's anchor must survive in the
|
|
|
|
|
|
// prompt. Symmetric to the single-image inclusive-boundary
|
|
|
|
|
|
// pin in
|
|
|
|
|
|
// `build_blocks_orphan_aggregate_cap_inclusive_boundary_rejects_at_one_below`.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Phase 2 universal path-strip: the `: <path>` component is
|
|
|
|
|
|
// stripped uniformly across every surviving placeholder, so
|
|
|
|
|
|
// the anchor `[Image #2]` is what survives. Intent: anchor
|
|
|
|
|
|
// preserved so the model still sees the in-prose position;
|
|
|
|
|
|
// path is gone because the image isn't attached and a bare
|
|
|
|
|
|
// path would tempt a `Read`.
|
|
|
|
|
|
let agent_client_protocol::ContentBlock::Text(t) = &blocks[0] else {
|
|
|
|
|
|
panic!("expected text block");
|
|
|
|
|
|
};
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
t.text.contains("[Image #2]"),
|
|
|
|
|
|
"anchor of rejected placeholder must survive cap breach, got: {}",
|
|
|
|
|
|
t.text
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
!t.text.contains("[Image #2:"),
|
|
|
|
|
|
"path of rejected placeholder must be stripped, got: {}",
|
|
|
|
|
|
t.text
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Inclusive boundary: cap == single image size admits the image.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn build_blocks_orphan_aggregate_cap_inclusive_boundary() {
|
|
|
|
|
|
use base64::Engine as _;
|
|
|
|
|
|
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let path = dir.path().join("one.png");
|
|
|
|
|
|
let png = make_test_png(20, 20);
|
|
|
|
|
|
std::fs::write(&path, &png).unwrap();
|
|
|
|
|
|
let canon = dunce::canonicalize(&path).unwrap();
|
|
|
|
|
|
let text = format!("[Image #1: {}]", canon.display());
|
|
|
|
|
|
let allowed = [dunce::canonicalize(dir.path()).unwrap()];
|
|
|
|
|
|
let blocks =
|
|
|
|
|
|
build_content_blocks_with_prefixes_and_caps(text, vec![], Some(&allowed), png.len());
|
|
|
|
|
|
assert_eq!(blocks.len(), 2);
|
|
|
|
|
|
// Symmetric to
|
|
|
|
|
|
// `build_blocks_orphan_placeholder_loaded_from_disk` —
|
|
|
|
|
|
// decode the base64 data and assert byte-for-byte equality
|
|
|
|
|
|
// with the on-disk PNG so a regression emitting wrong bytes
|
|
|
|
|
|
// at the inclusive boundary is caught.
|
|
|
|
|
|
let agent_client_protocol::ContentBlock::Image(ic) = &blocks[1] else {
|
|
|
|
|
|
panic!("expected recovered Image block");
|
|
|
|
|
|
};
|
|
|
|
|
|
assert_eq!(ic.mime_type, "image/png");
|
|
|
|
|
|
let decoded = base64::engine::general_purpose::STANDARD
|
|
|
|
|
|
.decode(&ic.data)
|
|
|
|
|
|
.expect("data must be valid base64");
|
|
|
|
|
|
assert_eq!(decoded, png);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Reject side: cap == image size - 1 rejects the image.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// **Text-side contract.** Aggregate-cap breach is a `break`
|
|
|
|
|
|
/// path in `resolve_orphan_placeholders`, not a per-image
|
|
|
|
|
|
/// `Err` path. Only `Err`-path failures strip the placeholder
|
|
|
|
|
|
/// text; cap-breach intentionally **leaves the placeholder
|
|
|
|
|
|
/// text intact** because the load itself succeeded (the file
|
|
|
|
|
|
/// is valid, just doesn't fit in the budget). The test pins
|
|
|
|
|
|
/// both halves of this contract: no image block AND
|
|
|
|
|
|
/// placeholder text preserved.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn build_blocks_orphan_aggregate_cap_inclusive_boundary_rejects_at_one_below() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let path = dir.path().join("one.png");
|
|
|
|
|
|
let png = make_test_png(20, 20);
|
|
|
|
|
|
std::fs::write(&path, &png).unwrap();
|
|
|
|
|
|
let canon = dunce::canonicalize(&path).unwrap();
|
|
|
|
|
|
let text = format!("[Image #1: {}]", canon.display());
|
|
|
|
|
|
let allowed = [dunce::canonicalize(dir.path()).unwrap()];
|
|
|
|
|
|
let blocks = build_content_blocks_with_prefixes_and_caps(
|
|
|
|
|
|
text,
|
|
|
|
|
|
vec![],
|
|
|
|
|
|
Some(&allowed),
|
|
|
|
|
|
png.len() - 1,
|
|
|
|
|
|
);
|
|
|
|
|
|
// Cap below image size → no recovered image; only the text
|
|
|
|
|
|
// block remains.
|
|
|
|
|
|
assert_eq!(blocks.len(), 1);
|
|
|
|
|
|
let agent_client_protocol::ContentBlock::Text(t) = &blocks[0] else {
|
|
|
|
|
|
panic!("expected text block");
|
|
|
|
|
|
};
|
|
|
|
|
|
// Placeholder anchor is NOT stripped on aggregate-cap
|
|
|
|
|
|
// breach (cap-breach is a `break` path, not a load `Err`).
|
|
|
|
|
|
// Pinning the preservation half of the contract.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Phase 2 path-strip update: the bracketed anchor
|
|
|
|
|
|
// `[Image #N]` survives, but the `: <path>` component is
|
|
|
|
|
|
// stripped uniformly across every surviving placeholder. The
|
|
|
|
|
|
// model can still see *where* in the prose the image was
|
|
|
|
|
|
// referenced via the anchor; the path metadata is no longer
|
|
|
|
|
|
// leaked because no image is actually attached.
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
t.text.contains("[Image #1]"),
|
|
|
|
|
|
"anchor must survive aggregate-cap breach, got: {}",
|
|
|
|
|
|
t.text
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
!t.text.contains("[Image #1:"),
|
|
|
|
|
|
"path-bearing form must be stripped, got: {}",
|
|
|
|
|
|
t.text
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ----- T8: cleanup and lifecycle edge cases ------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn clear_deletes_staged_temp_file() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let tmp_path = dir.path().join("staged.png");
|
|
|
|
|
|
std::fs::write(&tmp_path, b"fake").unwrap();
|
|
|
|
|
|
assert!(tmp_path.exists());
|
|
|
|
|
|
|
|
|
|
|
|
let mut images = vec![PastedImage {
|
|
|
|
|
|
element_id: ElementId::from_raw(1),
|
|
|
|
|
|
display_number: 1,
|
|
|
|
|
|
mime_type: "image/png".into(),
|
|
|
|
|
|
dimensions: None,
|
|
|
|
|
|
byte_len: 4,
|
|
|
|
|
|
encoded_bytes: None,
|
|
|
|
|
|
source_path: None,
|
|
|
|
|
|
staged_temp_path: Some(tmp_path.clone()),
|
|
|
|
|
|
session_image_path: None, // not yet persisted to session
|
|
|
|
|
|
preview: PromptImagePreview::default(),
|
|
|
|
|
|
}];
|
|
|
|
|
|
let mut counter = 1;
|
|
|
|
|
|
clear(&mut images, &mut counter);
|
|
|
|
|
|
|
|
|
|
|
|
assert!(images.is_empty());
|
|
|
|
|
|
assert!(!tmp_path.exists(), "staged temp file should be deleted");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn clear_preserves_session_persisted_file() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let session_path = dir.path().join("image-abc.png");
|
|
|
|
|
|
std::fs::write(&session_path, b"real").unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let mut images = vec![PastedImage {
|
|
|
|
|
|
element_id: ElementId::from_raw(1),
|
|
|
|
|
|
display_number: 1,
|
|
|
|
|
|
mime_type: "image/png".into(),
|
|
|
|
|
|
dimensions: None,
|
|
|
|
|
|
byte_len: 4,
|
|
|
|
|
|
encoded_bytes: None,
|
|
|
|
|
|
source_path: None,
|
|
|
|
|
|
staged_temp_path: None,
|
|
|
|
|
|
session_image_path: Some(session_path.clone()),
|
|
|
|
|
|
preview: PromptImagePreview::default(),
|
|
|
|
|
|
}];
|
|
|
|
|
|
let mut counter = 1;
|
|
|
|
|
|
clear(&mut images, &mut counter);
|
|
|
|
|
|
|
|
|
|
|
|
assert!(session_path.exists(), "session file should NOT be deleted");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn reconcile_deletes_staged_temp_for_removed_chip() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let tmp_path = dir.path().join("orphan.png");
|
|
|
|
|
|
std::fs::write(&tmp_path, b"orphan").unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let mut images = vec![PastedImage {
|
|
|
|
|
|
element_id: ElementId::from_raw(42),
|
|
|
|
|
|
display_number: 1,
|
|
|
|
|
|
mime_type: "image/png".into(),
|
|
|
|
|
|
dimensions: None,
|
|
|
|
|
|
byte_len: 6,
|
|
|
|
|
|
encoded_bytes: None,
|
|
|
|
|
|
source_path: None,
|
|
|
|
|
|
staged_temp_path: Some(tmp_path.clone()),
|
|
|
|
|
|
session_image_path: None,
|
|
|
|
|
|
preview: PromptImagePreview::default(),
|
|
|
|
|
|
}];
|
|
|
|
|
|
|
|
|
|
|
|
// Element 42 is no longer live.
|
|
|
|
|
|
let live: HashSet<ElementId> = HashSet::new();
|
|
|
|
|
|
reconcile(&mut images, &live);
|
|
|
|
|
|
|
|
|
|
|
|
assert!(images.is_empty());
|
|
|
|
|
|
assert!(!tmp_path.exists(), "staged temp file should be cleaned up");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn reconcile_preserves_session_file_for_removed_chip() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let session_path = dir.path().join("persisted.png");
|
|
|
|
|
|
std::fs::write(&session_path, b"keep").unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let mut images = vec![PastedImage {
|
|
|
|
|
|
element_id: ElementId::from_raw(42),
|
|
|
|
|
|
display_number: 1,
|
|
|
|
|
|
mime_type: "image/png".into(),
|
|
|
|
|
|
dimensions: None,
|
|
|
|
|
|
byte_len: 4,
|
|
|
|
|
|
encoded_bytes: None,
|
|
|
|
|
|
source_path: None,
|
|
|
|
|
|
staged_temp_path: None,
|
|
|
|
|
|
session_image_path: Some(session_path.clone()),
|
|
|
|
|
|
preview: PromptImagePreview::default(),
|
|
|
|
|
|
}];
|
|
|
|
|
|
|
|
|
|
|
|
let live: HashSet<ElementId> = HashSet::new();
|
|
|
|
|
|
reconcile(&mut images, &live);
|
|
|
|
|
|
|
|
|
|
|
|
assert!(images.is_empty());
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
session_path.exists(),
|
|
|
|
|
|
"session-persisted file should remain"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ----- ScrollbackImageRef ------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn scrollback_ref_from_valid_image_path() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let path = dir.path().join("test.png");
|
|
|
|
|
|
std::fs::write(&path, make_test_png(100, 80)).unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let r = ScrollbackImageRef::from_path(&path).unwrap();
|
|
|
|
|
|
assert_eq!(r.path, path);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn scrollback_ref_rejects_non_image_extension() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let path = dir.path().join("data.txt");
|
|
|
|
|
|
std::fs::write(&path, b"hello").unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
assert!(ScrollbackImageRef::from_path(&path).is_none());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn scrollback_ref_rejects_undecodable_image_extension() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let path = dir.path().join("not-an-image.png");
|
|
|
|
|
|
std::fs::write(&path, b"hello").unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
assert!(ScrollbackImageRef::from_path(&path).is_none());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn scrollback_ref_accepts_valid_jpeg_path() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let path = dir.path().join("photo.jpg");
|
|
|
|
|
|
std::fs::write(&path, make_test_jpeg(25, 20)).unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let r = ScrollbackImageRef::from_path(&path).unwrap();
|
|
|
|
|
|
assert_eq!(r.path, path);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn extract_skips_undecodable_image_paths() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let path = dir.path().join("not-an-image.png");
|
|
|
|
|
|
std::fs::write(&path, b"hello").unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let text = format!("", path.display());
|
|
|
|
|
|
let refs = extract_image_refs(&text);
|
|
|
|
|
|
assert!(refs.is_empty());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn scrollback_ref_rejects_missing_file() {
|
|
|
|
|
|
assert!(ScrollbackImageRef::from_path("/nonexistent/image.png").is_none());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ----- extract_image_refs ------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn extract_markdown_image_ref() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let path = dir.path().join("hero.png");
|
|
|
|
|
|
std::fs::write(&path, make_test_png(50, 50)).unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let text = format!("Here is the image: ", path.display());
|
|
|
|
|
|
let refs = extract_image_refs(&text);
|
|
|
|
|
|
assert_eq!(refs.len(), 1);
|
|
|
|
|
|
assert_eq!(refs[0].path, path);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn extract_bare_absolute_path() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let path = dir.path().join("photo.png");
|
|
|
|
|
|
std::fs::write(&path, make_test_png(25, 20)).unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let text = format!("saved to {} (1234 bytes)", path.display());
|
|
|
|
|
|
let refs = extract_image_refs(&text);
|
|
|
|
|
|
assert_eq!(refs.len(), 1);
|
|
|
|
|
|
assert_eq!(refs[0].path, path);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn extract_bare_absolute_jpeg_path() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let path = dir.path().join("photo.jpg");
|
|
|
|
|
|
std::fs::write(&path, make_test_jpeg(25, 20)).unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let text = format!("saved to {} (1234 bytes)", path.display());
|
|
|
|
|
|
let refs = extract_image_refs(&text);
|
|
|
|
|
|
assert_eq!(refs.len(), 1);
|
|
|
|
|
|
assert_eq!(refs[0].path, path);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn media_only_markdown_accepts_image_ref_only() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let path = dir.path().join("generated.jpg");
|
|
|
|
|
|
std::fs::write(&path, make_test_jpeg(25, 20)).unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let text = format!(" \n\n", path.display());
|
|
|
|
|
|
let refs = extract_image_refs(&text);
|
|
|
|
|
|
assert_eq!(refs.len(), 1);
|
|
|
|
|
|
assert!(is_media_only_markdown(&text, refs.len()));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn media_only_markdown_rejects_mixed_text() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let path = dir.path().join("generated.jpg");
|
|
|
|
|
|
std::fs::write(&path, make_test_jpeg(25, 20)).unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let text = format!("Here is an image: ", path.display());
|
|
|
|
|
|
let refs = extract_image_refs(&text);
|
|
|
|
|
|
assert_eq!(refs.len(), 1);
|
|
|
|
|
|
assert!(!is_media_only_markdown(&text, refs.len()));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn extract_deduplicates_same_path() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let path = dir.path().join("dup.png");
|
|
|
|
|
|
std::fs::write(&path, make_test_png(10, 10)).unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let text = format!(" and ", p = path.display());
|
|
|
|
|
|
let refs = extract_image_refs(&text);
|
|
|
|
|
|
assert_eq!(refs.len(), 1);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn extract_skips_nonexistent_paths() {
|
|
|
|
|
|
let text = "";
|
|
|
|
|
|
let refs = extract_image_refs(text);
|
|
|
|
|
|
assert!(refs.is_empty());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ----- open_from_path ----------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn open_from_path_valid_image() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let path = dir.path().join("viewer.png");
|
|
|
|
|
|
let png = make_test_png(200, 150);
|
|
|
|
|
|
std::fs::write(&path, &png).unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let viewer = ImageViewerState::open_from_path(&path).unwrap();
|
|
|
|
|
|
assert_eq!(viewer.image_width, 200);
|
|
|
|
|
|
assert_eq!(viewer.image_height, 150);
|
|
|
|
|
|
assert_eq!(viewer.display_number, 1);
|
|
|
|
|
|
assert_eq!(viewer.image_bytes, png);
|
|
|
|
|
|
assert_eq!(viewer.display_bytes, viewer.image_bytes);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn open_from_path_valid_jpeg_image() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let path = dir.path().join("viewer.jpg");
|
|
|
|
|
|
let jpeg = make_test_jpeg(200, 150);
|
|
|
|
|
|
std::fs::write(&path, &jpeg).unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let viewer = ImageViewerState::open_from_path(&path).unwrap();
|
|
|
|
|
|
assert_eq!(viewer.image_width, 200);
|
|
|
|
|
|
assert_eq!(viewer.image_height, 150);
|
|
|
|
|
|
assert_eq!(viewer.image_bytes, jpeg);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn open_from_path_missing_file() {
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
ImageViewerState::open_from_path(std::path::Path::new("/no/such/file.png")).is_none()
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ----- open_from_path_deferred -------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn deferred_open_starts_in_loading_state() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let path = dir.path().join("deferred.png");
|
|
|
|
|
|
let png = make_test_png(80, 60);
|
|
|
|
|
|
std::fs::write(&path, &png).unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let viewer = ImageViewerState::open_from_path_deferred(&path);
|
|
|
|
|
|
assert!(viewer.loading);
|
|
|
|
|
|
assert!(viewer.image_bytes.is_empty());
|
|
|
|
|
|
assert_eq!(viewer.image_width, 0);
|
|
|
|
|
|
assert_eq!(viewer.title.as_deref(), Some("deferred.png"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn deferred_finish_loading_succeeds() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let path = dir.path().join("deferred.png");
|
|
|
|
|
|
let png = make_test_png(80, 60);
|
|
|
|
|
|
std::fs::write(&path, &png).unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let mut viewer = ImageViewerState::open_from_path_deferred(&path);
|
|
|
|
|
|
assert!(viewer.finish_loading());
|
|
|
|
|
|
assert!(!viewer.loading);
|
|
|
|
|
|
assert_eq!(viewer.image_width, 80);
|
|
|
|
|
|
assert_eq!(viewer.image_height, 60);
|
|
|
|
|
|
assert_eq!(viewer.image_bytes, png);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn deferred_finish_loading_missing_file_fails() {
|
|
|
|
|
|
let mut viewer =
|
|
|
|
|
|
ImageViewerState::open_from_path_deferred(std::path::Path::new("/no/such/file.png"));
|
|
|
|
|
|
assert!(!viewer.finish_loading());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn deferred_finish_loading_idempotent() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let path = dir.path().join("idem.png");
|
|
|
|
|
|
std::fs::write(&path, make_test_png(10, 10)).unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let mut viewer = ImageViewerState::open_from_path_deferred(&path);
|
|
|
|
|
|
assert!(viewer.finish_loading());
|
|
|
|
|
|
// Second call is a no-op, returns true.
|
|
|
|
|
|
assert!(viewer.finish_loading());
|
|
|
|
|
|
assert!(!viewer.loading);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|