clean ups

This commit is contained in:
Daniel Edrisian
2025-08-19 21:44:56 -07:00
parent 3f539c4d07
commit ee67dcaa99
5 changed files with 163 additions and 182 deletions

View File

@@ -36,67 +36,6 @@ use std::time::Instant;
/// Time window for debouncing redraw requests.
const REDRAW_DEBOUNCE: Duration = Duration::from_millis(1);
/// Naive percent-decoding for file:// URL paths; returns None on invalid UTF-8.
fn percent_decode_to_string(input: &str) -> Option<String> {
let bytes = input.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
let h1 = bytes[i + 1];
let h2 = bytes[i + 2];
let hex = |c: u8| -> Option<u8> {
match c {
b'0'..=b'9' => Some(c - b'0'),
b'a'..=b'f' => Some(c - b'a' + 10),
b'A'..=b'F' => Some(c - b'A' + 10),
_ => None,
}
};
if let (Some(x), Some(y)) = (hex(h1), hex(h2)) {
out.push(x * 16 + y);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8(out).ok()
}
/// Convert a file:// URL into a local path (macOS/Unix only, UTF-8).
fn file_url_to_path(s: &str) -> Option<PathBuf> {
if let Some(rest) = s.strip_prefix("file://") {
// Strip optional host like file://localhost/...
let rest = rest.strip_prefix("localhost").unwrap_or(rest);
// Ensure leading slash remains for absolute paths
let decoded = percent_decode_to_string(rest)?;
let p = PathBuf::from(decoded);
return Some(p);
}
None
}
/// Unescape simple bash-style backslash escapes (e.g., spaces, parens).
fn unescape_backslashes(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\\' {
if let Some(n) = chars.next() {
out.push(n);
} else {
// Trailing backslash; keep it.
out.push('\\');
}
} else {
out.push(c);
}
}
out
}
// Testable helper: generic over paste function so we can inject stubs in unit tests.
fn try_handle_ctrl_v_with<F>(
app_event_tx: &AppEventSender,
@@ -452,125 +391,8 @@ impl App<'_> {
};
}
AppEvent::Paste(text) => {
// Prefer attaching a pasted image file path, if the text looks
// like an existing image file. This avoids grabbing the Finder
// icon bitmap from the clipboard when a user copied a file.
let mut handled = false;
let mut s = text.trim().to_string();
if !s.is_empty() {
// Strip surrounding quotes (common for paths with spaces)
if (s.starts_with('"') && s.ends_with('"'))
|| (s.starts_with('\'') && s.ends_with('\''))
{
s = s[1..s.len() - 1].to_string();
}
// Expand leading ~/ to HOME
if let Some(rest) = s.strip_prefix("~/") {
if let Ok(home) = std::env::var("HOME") {
let mut p = std::path::PathBuf::from(home);
p.push(rest);
s = p.to_string_lossy().into_owned();
}
}
let path = std::path::PathBuf::from(&s);
if path.is_file() {
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
let ext_l = ext.to_ascii_lowercase();
if matches!(ext_l.as_str(), "png" | "jpg" | "jpeg") {
let (mut w, mut h) = (0u32, 0u32);
if let Ok((dw, dh)) = image::image_dimensions(&path) {
w = dw;
h = dh;
}
let fmt = if ext_l == "png" { "PNG" } else { "JPEG" };
if let AppState::Chat { widget } = &mut self.app_state {
widget.attach_image(path, w, h, fmt);
}
handled = true;
}
}
}
}
if !handled {
// Try to parse shell-escaped or URL-style file paths from the paste.
let candidates: Vec<String> = if let Some(tokens) = shlex::split(&text) {
tokens
} else {
vec![text.clone()]
};
'outer: for raw in candidates {
let mut s = raw.trim().to_string();
// Strip surrounding quotes if present (redundant with shlex, but safe)
if (s.starts_with('"') && s.ends_with('"'))
|| (s.starts_with('\'') && s.ends_with('\''))
{
s = s[1..s.len() - 1].to_string();
}
// Expand leading ~/ to HOME
if let Some(rest) = s.strip_prefix("~/") {
if let Ok(home) = std::env::var("HOME") {
let mut p = std::path::PathBuf::from(home);
p.push(rest);
s = p.to_string_lossy().into_owned();
}
}
let mut try_paths: Vec<PathBuf> = Vec::new();
if let Some(p) = file_url_to_path(&s) {
try_paths.push(p);
}
// As-is path
try_paths.push(PathBuf::from(&s));
// Unescaped variant (e.g., My\ Photo.png)
let unescaped = unescape_backslashes(&s);
if unescaped != s {
try_paths.push(PathBuf::from(unescaped));
}
for path in try_paths {
if path.is_file() {
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
let ext_l = ext.to_ascii_lowercase();
if matches!(ext_l.as_str(), "png" | "jpg" | "jpeg") {
let (mut w, mut h) = (0u32, 0u32);
if let Ok((dw, dh)) = image::image_dimensions(&path) {
w = dw;
h = dh;
}
let fmt = if ext_l == "png" { "PNG" } else { "JPEG" };
if let AppState::Chat { widget } = &mut self.app_state {
widget.attach_image(path, w, h, fmt);
}
handled = true;
break 'outer;
}
}
}
}
}
if !handled {
// If no usable path was pasted, try to read an image bitmap
// from the clipboard; otherwise, fall back to text paste.
match crate::clipboard_paste::paste_image_to_temp_png() {
Ok((path, info)) => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.attach_image(
path,
info.width,
info.height,
info.encoded_format_label,
);
}
}
Err(_) => {
self.dispatch_paste_event(text);
}
}
}
}
// Route paste handling to the active widget.
self.dispatch_paste_event(text);
}
AppEvent::CodexEvent(event) => {
self.dispatch_codex_event(event);

View File

@@ -215,7 +215,6 @@ impl ChatComposer {
pub fn take_recent_submission_images_with_placeholders(
&mut self,
) -> Vec<(String, std::path::PathBuf)> {
std::mem::take(&mut self.attached_images)
}

View File

@@ -97,6 +97,8 @@ struct UserMessage {
}
use crate::streaming::StreamKind;
use crate::string_utils::file_url_to_path;
use crate::string_utils::unescape_backslashes;
impl From<String> for UserMessage {
fn from(text: String) -> Self {
@@ -578,7 +580,103 @@ impl ChatWidget<'_> {
}
pub(crate) fn handle_paste(&mut self, text: String) {
self.bottom_pane.handle_paste(text);
// First, attempt to interpret the pasted text as a file path to an image
// and attach it. This mirrors the logic previously handled at the app level.
let mut handled = false;
// Helper to attach an image if the path looks valid, returning true if handled.
fn try_attach_image(widget: &mut ChatWidget<'_>, path: std::path::PathBuf) -> bool {
if path.is_file() {
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
let ext_l = ext.to_ascii_lowercase();
if matches!(ext_l.as_str(), "png" | "jpg" | "jpeg") {
let (mut w, mut h) = (0u32, 0u32);
if let Ok((dw, dh)) = image::image_dimensions(&path) {
w = dw;
h = dh;
}
let fmt = if ext_l == "png" { "PNG" } else { "JPEG" };
widget.attach_image(path, w, h, fmt);
return true;
}
}
}
false
}
// Trim and strip quotes for the most direct case.
let mut s = text.trim().to_string();
if !s.is_empty() {
if (s.starts_with('"') && s.ends_with('"'))
|| (s.starts_with('\'') && s.ends_with('\''))
{
s = s[1..s.len() - 1].to_string();
}
if let Some(rest) = s.strip_prefix("~/") {
if let Ok(home) = std::env::var("HOME") {
let mut p = std::path::PathBuf::from(home);
p.push(rest);
s = p.to_string_lossy().into_owned();
}
}
handled = try_attach_image(self, std::path::PathBuf::from(&s));
}
// If not handled yet, try multiple candidate interpretations: shlex tokens,
// URL-style paths, and unescaped variants.
if !handled {
let candidates: Vec<String> = if let Some(tokens) = shlex::split(&text) {
tokens
} else {
vec![text.clone()]
};
'outer: for raw in candidates {
let mut s = raw.trim().to_string();
if (s.starts_with('"') && s.ends_with('"'))
|| (s.starts_with('\'') && s.ends_with('\''))
{
s = s[1..s.len() - 1].to_string();
}
if let Some(rest) = s.strip_prefix("~/") {
if let Ok(home) = std::env::var("HOME") {
let mut p = std::path::PathBuf::from(home);
p.push(rest);
s = p.to_string_lossy().into_owned();
}
}
let mut try_paths: Vec<std::path::PathBuf> = Vec::new();
if let Some(p) = file_url_to_path(&s) {
try_paths.push(p);
}
try_paths.push(std::path::PathBuf::from(&s));
let unescaped = unescape_backslashes(&s);
if unescaped != s {
try_paths.push(std::path::PathBuf::from(unescaped));
}
for path in try_paths {
if try_attach_image(self, path) {
handled = true;
break 'outer;
}
}
}
}
// If still not handled, try to read an image bitmap from the clipboard.
if !handled {
match crate::clipboard_paste::paste_image_to_temp_png() {
Ok((path, info)) => {
self.attach_image(path, info.width, info.height, info.encoded_format_label);
}
Err(_) => {
// Fall back to textual paste into the composer.
self.bottom_pane.handle_paste(text);
}
}
}
}
fn flush_active_exec_cell(&mut self) {

View File

@@ -49,6 +49,7 @@ mod shimmer;
mod slash_command;
mod status_indicator_widget;
mod streaming;
mod string_utils;
mod text_formatting;
mod tui;
mod user_approval_widget;

View File

@@ -0,0 +1,61 @@
// String and path parsing helpers used across the TUI.
// Naive percent-decoding for file:// URL paths; returns None on invalid UTF-8.
pub(crate) fn percent_decode_to_string(input: &str) -> Option<String> {
let bytes = input.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
let h1 = bytes[i + 1];
let h2 = bytes[i + 2];
let hex = |c: u8| -> Option<u8> {
match c {
b'0'..=b'9' => Some(c - b'0'),
b'a'..=b'f' => Some(c - b'a' + 10),
b'A'..=b'F' => Some(c - b'A' + 10),
_ => None,
}
};
if let (Some(x), Some(y)) = (hex(h1), hex(h2)) {
out.push(x * 16 + y);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8(out).ok()
}
// Convert a file:// URL into a local path (macOS/Unix only, UTF-8).
pub(crate) fn file_url_to_path(s: &str) -> Option<std::path::PathBuf> {
if let Some(rest) = s.strip_prefix("file://") {
// Strip optional host like file://localhost/...
let rest = rest.strip_prefix("localhost").unwrap_or(rest);
let decoded = percent_decode_to_string(rest)?;
let p = std::path::PathBuf::from(decoded);
return Some(p);
}
None
}
// Unescape simple bash-style backslash escapes (e.g., spaces, parens).
pub(crate) fn unescape_backslashes(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\\' {
if let Some(n) = chars.next() {
out.push(n);
} else {
// Trailing backslash; keep it.
out.push('\\');
}
} else {
out.push(c);
}
}
out
}