Media uploads: photos, video and audio

Attach files from the new Media field (path prompt with Tab completion,
or paste/drop paths) or by URL. Files go to the server's media endpoint
(q=config media-endpoint, multipart 'file', 201 Location) in the
background; publishing waits for them. The extension picks the photo,
video or audio property; photos can carry alt text ({value, alt}).
Edits replace or delete a media property only when it changed. Notes
with media need no text. Warns when q=config lists post types without
video or audio.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Phil
2026-11-01 09:31:03 -07:00
co-authored by Claude Opus 5.5
parent c78ade3900
commit c3ad8d05e0
8 changed files with 1125 additions and 36 deletions
+625 -14
View File
@@ -1,10 +1,14 @@
//! App state and key handling. Pure logic: no terminal or network here.
use std::path::PathBuf;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::style::{Modifier, Style};
use ratatui_textarea::{CursorMove, Input, Key, TextArea, WrapMode};
use crate::micropub::{self, Error, Kind, Note, Post, ServerConfig, SyndicationTarget, Update};
use crate::micropub::{
self, Error, Kind, Media, MediaKind, Note, Post, ServerConfig, SyndicationTarget, Update,
};
use crate::worker::{PAGE_SIZE, Request, Response};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -12,15 +16,17 @@ pub enum Focus {
Title,
Link,
Content,
Media,
Tags,
Syndication,
}
impl Focus {
const ORDER: [Focus; 5] = [
const ORDER: [Focus; 6] = [
Focus::Title,
Focus::Link,
Focus::Content,
Focus::Media,
Focus::Tags,
Focus::Syndication,
];
@@ -43,6 +49,27 @@ pub enum Discard {
BackToPosts,
}
/// A photo, video or audio file in the form. `url` is None while it uploads.
#[derive(Debug, Clone, PartialEq)]
pub struct Attachment {
/// Matches an upload's reply to its attachment.
pub id: u64,
pub kind: MediaKind,
/// File name, for display.
pub name: String,
pub url: Option<String>,
pub alt: String,
}
/// A one-line question shown in a popup.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Prompt {
/// A file path or URL to attach.
AddMedia,
/// Alt text for the attachment with this id.
Alt(u64),
}
#[derive(Debug, Clone, PartialEq)]
pub enum Mode {
Normal,
@@ -52,6 +79,7 @@ pub enum Mode {
ConfirmQuit,
ConfirmDiscard(Discard),
ConfirmDelete(String),
Prompt(Prompt),
}
/// Where the syndication target list came from.
@@ -85,6 +113,17 @@ pub struct App {
pub link: TextArea<'static>,
pub content: TextArea<'static>,
pub tags: TextArea<'static>,
pub media: Vec<Attachment>,
pub media_cursor: usize,
next_media_id: u64,
/// From q=config. None if the server has none (or the config hasn't loaded).
pub media_endpoint: Option<String>,
/// Media kinds the server lists as post types (all, if it lists none).
pub media_kinds: Vec<MediaKind>,
/// Folder of the last file attached, to start the next path from.
last_dir: String,
/// The text box of the open prompt.
pub prompt: TextArea<'static>,
pub targets: Vec<(SyndicationTarget, bool)>,
pub targets_state: Targets,
pub target_cursor: usize,
@@ -157,6 +196,99 @@ fn single_line(area: &TextArea) -> String {
area.lines().join(" ").trim().to_string()
}
const ADD_MEDIA_HINT: &str = "Path to a photo, video or audio file, or its URL";
fn is_url(s: &str) -> bool {
s.starts_with("http://") || s.starts_with("https://")
}
/// The last part of a URL's path, to name an attachment.
fn name_from_url(url: &str) -> String {
let path = url.split(['?', '#']).next().unwrap_or(url);
path.trim_end_matches('/')
.rsplit('/')
.next()
.filter(|n| !n.is_empty() && !n.contains(':'))
.unwrap_or(url)
.to_string()
}
fn expand_tilde(s: &str) -> PathBuf {
let home = std::env::var_os("HOME").map(PathBuf::from);
match (s.strip_prefix('~'), home) {
(Some(""), Some(h)) => h,
(Some(rest), Some(h)) if rest.starts_with('/') => h.join(&rest[1..]),
_ => PathBuf::from(s),
}
}
/// A path as typed, pasted or dropped onto the terminal: quoted, `file://` URIs,
/// backslash-escaped spaces and `~` all work.
pub fn clean_path(input: &str) -> PathBuf {
let s = input.trim();
for q in ['\'', '"'] {
if s.len() >= 2 && s.starts_with(q) && s.ends_with(q) {
return expand_tilde(&s[1..s.len() - 1]);
}
}
if s.starts_with("file://")
&& let Some(path) = url::Url::parse(s).ok().and_then(|u| u.to_file_path().ok())
{
return path;
}
let mut out = String::new();
let mut chars = s.chars();
while let Some(c) = chars.next() {
match c {
'\\' => out.extend(chars.next()),
c => out.push(c),
}
}
expand_tilde(&out)
}
/// Tab completion for a path: completes as far as it's unambiguous.
/// Returns the new text and how many entries matched.
pub fn complete_path(input: &str) -> (String, usize) {
let (dir_part, prefix) = match input.rfind('/') {
Some(i) => input.split_at(i + 1),
None => ("", input),
};
let dir = if dir_part.is_empty() {
PathBuf::from(".")
} else {
clean_path(dir_part)
};
let Ok(entries) = std::fs::read_dir(&dir) else {
return (input.to_string(), 0);
};
let matches: Vec<(String, bool)> = entries
.flatten()
.filter_map(|e| {
let name = e.file_name().into_string().ok()?;
let hidden = name.starts_with('.') && !prefix.starts_with('.');
(name.starts_with(prefix) && !hidden).then(|| (name, e.path().is_dir()))
})
.collect();
let done = match matches.as_slice() {
[] => return (input.to_string(), 0),
[(name, true)] => format!("{name}/"),
[(name, false)] => name.clone(),
[(first, _), rest @ ..] => {
let mut common = first.as_str();
for (name, _) in rest {
while !name.starts_with(common) {
let mut c = common.chars();
c.next_back();
common = c.as_str();
}
}
common.to_string()
}
};
(format!("{dir_part}{done}"), matches.len())
}
impl App {
pub fn new(me: String, scope: String) -> Self {
let mut app = App {
@@ -166,6 +298,13 @@ impl App {
link: link_area(),
content: content_area(),
tags: tags_area(),
media: Vec::new(),
media_cursor: 0,
next_media_id: 0,
media_endpoint: None,
media_kinds: MediaKind::ALL.to_vec(),
last_dir: String::new(),
prompt: TextArea::default(),
targets: Vec::new(),
targets_state: Targets::Loading,
target_cursor: 0,
@@ -239,12 +378,183 @@ impl App {
/// Unsaved work: any text in a new note, or any change to the post being edited.
pub fn is_dirty(&self) -> bool {
match &self.editing {
Some(orig) => !Update::diff(orig, &self.note(orig.draft)).is_empty(),
None => [&self.title, &self.link, &self.content, &self.tags]
.iter()
.any(|t| t.lines().iter().any(|l| !l.trim().is_empty())),
self.uploading()
|| match &self.editing {
Some(orig) => !Update::diff(orig, &self.note(orig.draft)).is_empty(),
None => {
!self.media.is_empty()
|| [&self.title, &self.link, &self.content, &self.tags]
.iter()
.any(|t| t.lines().iter().any(|l| !l.trim().is_empty()))
}
}
}
/// Whether any attachment is still uploading.
pub fn uploading(&self) -> bool {
self.media.iter().any(|p| p.url.is_none())
}
fn push_media(
&mut self,
kind: MediaKind,
name: String,
url: Option<String>,
alt: String,
) -> u64 {
if !self.media_kinds.contains(&kind) {
self.error(format!(
"This server doesn't list {} posts, so it may drop {name}.",
kind.property()
));
}
let id = self.next_media_id;
self.next_media_id += 1;
self.media.push(Attachment {
id,
kind,
name,
url,
alt,
});
self.media_cursor = self.media.len() - 1;
id
}
/// Attach a file by URL, or start uploading one. The extension decides
/// whether it's a photo, video or audio.
fn add_media(&mut self, input: &str) -> Option<Request> {
let input = input.trim();
if input.is_empty() {
return None;
}
if is_url(input) {
let kind = MediaKind::guess(input);
self.push_media(
kind,
name_from_url(input),
Some(input.to_string()),
String::new(),
);
return None;
}
let Some(endpoint) = self.media_endpoint.clone() else {
self.error(match self.targets_state {
Targets::Loading => "Still loading the server config; try again in a moment.",
Targets::Failed(_) => "Couldn't load the server config (Ctrl-R to retry).",
Targets::Loaded => {
"This server has no media endpoint. You can still attach files by URL."
}
});
return None;
};
let path = clean_path(input);
if !path.is_file() {
self.error(format!("No such file: {}", path.display()));
return None;
}
if let Some(dir) = input.rfind('/').map(|i| &input[..=i])
&& !dir.starts_with(['\'', '"'])
{
self.last_dir = dir.to_string();
}
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| input.to_string());
self.info(format!("Uploading {name}…"));
let id = self.push_media(MediaKind::guess(&name), name, None, String::new());
Some(Request::Upload { id, endpoint, path })
}
fn open_prompt(&mut self, prompt: Prompt) {
let (placeholder, text) = match prompt {
Prompt::AddMedia => (ADD_MEDIA_HINT, self.last_dir.clone()),
Prompt::Alt(id) => (
"Describe it for people who can't see it",
self.media
.iter()
.find(|p| p.id == id)
.map(|p| p.alt.clone())
.unwrap_or_default(),
),
};
let mut area = filled(text_area(placeholder), &text);
area.move_cursor(CursorMove::End);
area.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
self.prompt = area;
self.mode = Mode::Prompt(prompt);
}
fn handle_prompt_key(&mut self, prompt: Prompt, key: KeyEvent) -> Option<Request> {
match key.code {
KeyCode::Esc => self.mode = Mode::Normal,
KeyCode::Enter => {
self.mode = Mode::Normal;
let text = single_line(&self.prompt);
match prompt {
Prompt::AddMedia => return self.add_media(&text),
Prompt::Alt(id) => {
if let Some(p) = self.media.iter_mut().find(|p| p.id == id) {
p.alt = text;
}
}
}
}
KeyCode::Tab if prompt == Prompt::AddMedia => {
let (text, n) = complete_path(&single_line(&self.prompt));
if n == 0 {
self.error("No matching files.");
} else {
self.status = None;
}
let mut area = filled(text_area(ADD_MEDIA_HINT), &text);
area.move_cursor(CursorMove::End);
area.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
self.prompt = area;
}
_ => {
let input = Input::from(key);
if !(input.ctrl && input.key == Key::Char('m')) {
self.prompt.input(input);
}
}
}
None
}
fn handle_media_key(&mut self, key: KeyEvent) -> Option<Request> {
let n = self.media.len();
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
self.media_cursor = self.media_cursor.saturating_sub(1)
}
KeyCode::Down | KeyCode::Char('j') => {
self.media_cursor = (self.media_cursor + 1).min(n.saturating_sub(1))
}
KeyCode::Char('a') => self.open_prompt(Prompt::AddMedia),
KeyCode::Enter if n == 0 => self.open_prompt(Prompt::AddMedia),
KeyCode::Enter | KeyCode::Char('e') => {
if let Some(p) = self.media.get(self.media_cursor) {
self.open_prompt(Prompt::Alt(p.id));
}
}
KeyCode::Char('d') | KeyCode::Delete | KeyCode::Backspace if n > 0 => {
self.media.remove(self.media_cursor);
self.media_cursor = self.media_cursor.min(self.media.len().saturating_sub(1));
}
// Move the selected attachment up or down (the first one leads).
KeyCode::Char('K') if self.media_cursor > 0 => {
self.media.swap(self.media_cursor, self.media_cursor - 1);
self.media_cursor -= 1;
}
KeyCode::Char('J') if self.media_cursor + 1 < n => {
self.media.swap(self.media_cursor, self.media_cursor + 1);
self.media_cursor += 1;
}
_ => {}
}
None
}
fn clear_form(&mut self) {
@@ -252,6 +562,8 @@ impl App {
self.link = link_area();
self.content = content_area();
self.tags = tags_area();
self.media.clear();
self.media_cursor = 0;
self.editing = None;
self.kind = Kind::Note;
self.focus = Focus::Content;
@@ -289,6 +601,16 @@ impl App {
self.link = filled(link_area_for(self.kind), link);
self.content = filled(content_area(), &post.content);
self.tags = filled(tags_area(), &post.tags.join(", "));
self.media.clear();
for p in &post.media {
self.push_media(
p.kind,
name_from_url(&p.url),
Some(p.url.clone()),
p.alt.clone(),
);
}
self.media_cursor = 0;
self.editing = Some(post);
self.focus = Focus::Content;
self.update_cursors();
@@ -317,6 +639,17 @@ impl App {
in_reply_to: link.clone().filter(|_| self.kind == Kind::Note),
like_of: link.clone().filter(|_| self.kind == Kind::Like),
repost_of: link.filter(|_| self.kind == Kind::Repost),
media: self
.media
.iter()
.filter_map(|a| {
a.url.clone().map(|url| Media {
kind: a.kind,
url,
alt: a.alt.clone(),
})
})
.collect(),
draft,
}
}
@@ -456,6 +789,28 @@ impl App {
self.mode = Mode::Normal;
self.report(e);
}
// Replies for attachments that were removed (or a form that was cleared) are dropped.
Response::Uploaded { id, result } => {
let i = self.media.iter().position(|p| p.id == id)?;
match result {
Ok(url) => {
self.media[i].url = Some(url);
if !self.uploading() {
let name = self.media[i].name.clone();
self.info(format!("Uploaded {name}."));
}
}
Err(e) => {
let gone = self.media.remove(i);
self.media_cursor =
self.media_cursor.min(self.media.len().saturating_sub(1));
if matches!(e, Error::Unauthorized(_)) {
self.needs_login = true;
}
self.error(format!("Couldn't upload {}: {e}", gone.name));
}
}
}
}
None
}
@@ -469,6 +824,11 @@ impl App {
fn set_config(&mut self, cfg: ServerConfig) {
self.can_list = cfg.can_list();
self.media_endpoint = cfg.media_endpoint.clone();
self.media_kinds = MediaKind::ALL
.into_iter()
.filter(|k| cfg.supports_media(*k))
.collect();
self.kinds = Kind::ALL.into_iter().filter(|k| cfg.supports(*k)).collect();
// Keep earlier choices on refresh; new targets start checked (the server's default).
let previous = std::mem::take(&mut self.targets);
@@ -492,6 +852,7 @@ impl App {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match self.mode.clone() {
Mode::Sending => None,
Mode::Prompt(p) => self.handle_prompt_key(p, key),
Mode::ConfirmPublish => {
let draft = match key.code {
KeyCode::Char('y') | KeyCode::Enter => self.default_draft(),
@@ -559,6 +920,10 @@ impl App {
fn send(&mut self, draft: bool) -> Option<Request> {
let note = self.note(draft);
self.mode = Mode::Normal;
if self.uploading() {
self.error("Wait for the uploads to finish.");
return None;
}
let link = note
.in_reply_to
.as_ref()
@@ -661,8 +1026,14 @@ impl App {
}
(true, KeyCode::Char('s')) => {
// Likes and reposts don't need a comment.
if self.kind == Kind::Note && self.note(false).content.trim().is_empty() {
// Micropub doesn't require text, so a note with media may have none.
if self.kind == Kind::Note
&& self.media.is_empty()
&& self.note(false).content.trim().is_empty()
{
self.error("Write something first.");
} else if self.uploading() {
self.error("Wait for the uploads to finish.");
} else {
self.status = None;
self.mode = Mode::ConfirmPublish;
@@ -716,6 +1087,7 @@ impl App {
area.input(input);
}
}
Focus::Media => return self.handle_media_key(key),
Focus::Syndication => self.handle_target_key(key),
}
None
@@ -814,12 +1186,20 @@ impl App {
}
}
/// Bracketed paste: insert text into the focused field.
pub fn handle_paste(&mut self, text: &str) {
if self.mode != Mode::Normal || self.screen != Screen::Compose {
return;
}
/// Bracketed paste: insert text into the focused field. Pasting (or dropping)
/// files onto the Media field attaches them, one per line.
pub fn handle_paste(&mut self, text: &str) -> Vec<Request> {
let text = text.replace("\r\n", "\n").replace('\r', "\n");
if matches!(self.mode, Mode::Prompt(_)) {
self.prompt.insert_str(text.trim().replace('\n', " "));
return Vec::new();
}
if self.mode != Mode::Normal || self.screen != Screen::Compose {
return Vec::new();
}
if self.focus == Focus::Media {
return text.lines().filter_map(|l| self.add_media(l)).collect();
}
match self.focus {
Focus::Content => {
self.content.insert_str(text);
@@ -833,8 +1213,9 @@ impl App {
Focus::Tags => {
self.tags.insert_str(text.replace('\n', ","));
}
Focus::Syndication => {}
Focus::Media | Focus::Syndication => {}
}
Vec::new()
}
}
@@ -904,6 +1285,7 @@ mod tests {
typ(&mut app, "Hello");
app.handle_key(key(KeyCode::Enter));
typ(&mut app, "world");
app.handle_key(key(KeyCode::Tab)); // -> media
app.handle_key(key(KeyCode::Tab)); // -> tags
typ(&mut app, "rust, #tui");
app.handle_key(key(KeyCode::Tab)); // -> syndication
@@ -1333,4 +1715,233 @@ mod tests {
};
assert_eq!(u.replace.keys().collect::<Vec<_>>(), ["content"]);
}
/// Loaded with a media endpoint, focused on the Media field.
fn media_app() -> App {
let mut app = App::new("https://example.com/".into(), "create media".into());
app.handle_response(Response::Config(Ok(ServerConfig {
media_endpoint: Some("https://example.com/micropub/media".into()),
..Default::default()
})));
app.focus = Focus::Media;
app
}
fn scratch_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("tuipub-test-{name}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn attach(app: &mut App, path: &str) -> Option<Request> {
app.handle_key(key(KeyCode::Char('a')));
assert_eq!(app.mode, Mode::Prompt(Prompt::AddMedia));
app.handle_paste(path);
app.handle_key(key(KeyCode::Enter))
}
#[test]
fn attach_upload_and_publish() {
let dir = scratch_dir("attach");
let file = dir.join("cat pic.jpg");
std::fs::write(&file, b"jpeg").unwrap();
let mut app = media_app();
let Some(Request::Upload { id, endpoint, path }) =
attach(&mut app, &file.display().to_string())
else {
panic!("expected an upload");
};
assert_eq!(endpoint, "https://example.com/micropub/media");
assert_eq!(path, file);
assert!(app.uploading() && app.is_dirty());
// Can't publish until the upload is done.
app.focus = Focus::Content;
typ(&mut app, "Look");
app.handle_key(ctrl('s'));
assert_eq!(app.mode, Mode::Normal);
assert!(matches!(app.status, Some(Status::Error(_))));
app.handle_response(Response::Uploaded {
id,
result: Ok("https://example.com/media/a.jpg".into()),
});
// Alt text.
app.focus = Focus::Media;
app.handle_key(key(KeyCode::Enter));
assert_eq!(app.mode, Mode::Prompt(Prompt::Alt(id)));
typ(&mut app, "A cat");
app.handle_key(key(KeyCode::Enter));
app.handle_key(ctrl('s'));
let Some(Request::Publish(note)) = app.handle_key(key(KeyCode::Enter)) else {
panic!("expected publish");
};
assert_eq!(
note.media,
vec![Media {
kind: MediaKind::Photo,
url: "https://example.com/media/a.jpg".into(),
alt: "A cat".into()
}]
);
assert_eq!(app.last_dir, format!("{}/", dir.display()));
std::fs::remove_dir_all(dir).ok();
}
#[test]
fn failed_upload_is_removed_and_late_replies_ignored() {
let dir = scratch_dir("fail");
let file = dir.join("x.png");
std::fs::write(&file, b"png").unwrap();
let mut app = media_app();
let Some(Request::Upload { id, .. }) = attach(&mut app, &file.display().to_string()) else {
panic!("expected an upload");
};
app.handle_response(Response::Uploaded {
id,
result: Err(Error::Unauthorized("nope".into())),
});
assert!(app.media.is_empty() && app.needs_login);
assert!(matches!(&app.status, Some(Status::Error(e)) if e.contains("x.png")));
// A reply for something no longer in the form does nothing.
app.handle_response(Response::Uploaded {
id: 99,
result: Ok("u".into()),
});
assert!(app.media.is_empty());
std::fs::remove_dir_all(dir).ok();
}
#[test]
fn urls_attach_without_upload_and_kinds_follow_extension() {
let mut app = loaded(&[]); // no media endpoint
app.focus = Focus::Media;
assert!(attach(&mut app, "https://cdn.example/clip.mp4").is_none());
assert!(attach(&mut app, "/nonexistent/file.jpg").is_none());
assert!(matches!(&app.status, Some(Status::Error(e)) if e.contains("no media endpoint")));
assert_eq!(app.media.len(), 1);
assert_eq!(app.media[0].kind, MediaKind::Video);
assert_eq!(app.media[0].name, "clip.mp4");
// Servers that list post types without video get a warning (but keep the file).
let mut app = App::new("m".into(), "media".into());
app.handle_response(Response::Config(Ok(ServerConfig {
post_types: vec![micropub::PostType {
kind: "photo".into(),
}],
..Default::default()
})));
app.focus = Focus::Media;
attach(&mut app, "https://cdn.example/clip.mp4");
assert!(matches!(&app.status, Some(Status::Error(e)) if e.contains("may drop")));
assert_eq!(app.media.len(), 1);
attach(&mut app, "https://cdn.example/a.jpg");
assert_eq!(app.media.len(), 2);
// Media alone is enough for a note: Micropub doesn't require text.
app.handle_key(ctrl('s'));
assert_eq!(app.mode, Mode::ConfirmPublish);
}
#[test]
fn missing_file_is_reported() {
let mut app = media_app();
assert!(attach(&mut app, "/nonexistent/file.jpg").is_none());
assert!(matches!(&app.status, Some(Status::Error(e)) if e.contains("No such file")));
assert!(app.media.is_empty());
}
#[test]
fn pasting_paths_onto_media_field_uploads_each() {
let dir = scratch_dir("paste");
let (a, b) = (dir.join("a.jpg"), dir.join("b c.ogg"));
std::fs::write(&a, b"").unwrap();
std::fs::write(&b, b"").unwrap();
let mut app = media_app();
let reqs = app.handle_paste(&format!("{}\n'{}'\n", a.display(), b.display()));
assert_eq!(reqs.len(), 2);
assert_eq!(app.media[1].kind, MediaKind::Audio);
// Elsewhere, a path is just text.
app.focus = Focus::Content;
assert!(app.handle_paste(&a.display().to_string()).is_empty());
std::fs::remove_dir_all(dir).ok();
}
#[test]
fn remove_and_reorder_media_on_an_edited_post() {
let photo = |u: &str| Media {
kind: MediaKind::Photo,
url: u.into(),
alt: String::new(),
};
let mut p = post("a", false);
p.media = vec![photo("https://e/1.jpg"), photo("https://e/2.jpg")];
let mut app = with_posts(vec![p]);
app.handle_key(key(KeyCode::Enter));
assert_eq!(app.media.len(), 2);
assert!(!app.is_dirty());
app.focus = Focus::Media;
app.handle_key(key(KeyCode::Char('J')));
assert_eq!(app.media[1].name, "1.jpg");
assert_eq!(app.media_cursor, 1);
assert!(app.is_dirty(), "order counts as a change");
app.handle_key(key(KeyCode::Char('d')));
app.handle_key(key(KeyCode::Char('d')));
assert!(app.media.is_empty());
app.handle_key(ctrl('s'));
let Some(Request::Update(u)) = app.handle_key(key(KeyCode::Enter)) else {
panic!("expected update");
};
assert_eq!(u.delete, vec!["photo"]);
}
#[test]
fn paths_are_cleaned() {
let home = std::env::var("HOME").unwrap();
assert_eq!(
clean_path(" ~/a b.jpg "),
PathBuf::from(format!("{home}/a b.jpg"))
);
assert_eq!(clean_path("/x/a\\ b.jpg"), PathBuf::from("/x/a b.jpg"));
assert_eq!(clean_path("'/x/it\\s.jpg'"), PathBuf::from("/x/it\\s.jpg"));
assert_eq!(clean_path("\"/x/a b.jpg\""), PathBuf::from("/x/a b.jpg"));
assert_eq!(
clean_path("file:///x/a%20b.jpg"),
PathBuf::from("/x/a b.jpg")
);
assert_eq!(name_from_url("https://e/media/2026/x.jpg?s=1"), "x.jpg");
assert_eq!(name_from_url("https://e/"), "e");
}
#[test]
fn tab_completes_paths() {
let dir = scratch_dir("complete");
std::fs::write(dir.join("photo-1.jpg"), b"").unwrap();
std::fs::write(dir.join("photo-2.jpg"), b"").unwrap();
std::fs::create_dir_all(dir.join("videos")).unwrap();
std::fs::write(dir.join(".hidden"), b"").unwrap();
let d = format!("{}/", dir.display());
assert_eq!(complete_path(&format!("{d}ph")), (format!("{d}photo-"), 2));
assert_eq!(complete_path(&format!("{d}v")), (format!("{d}videos/"), 1));
assert_eq!(
complete_path(&format!("{d}photo-2")),
(format!("{d}photo-2.jpg"), 1)
);
assert_eq!(complete_path(&format!("{d}zz")).1, 0);
assert_eq!(complete_path(&d).1, 3, "hidden files only when asked for");
let mut app = media_app();
app.handle_key(key(KeyCode::Char('a')));
app.handle_paste(&format!("{d}v"));
app.handle_key(key(KeyCode::Tab));
assert_eq!(single_line(&app.prompt), format!("{d}videos/"));
app.handle_key(key(KeyCode::Esc));
assert_eq!(app.mode, Mode::Normal);
assert!(app.media.is_empty());
std::fs::remove_dir_all(dir).ok();
}
}