//! 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, Media, MediaKind, Note, Post, ServerConfig, SyndicationTarget, Update, }; use crate::worker::{PAGE_SIZE, Request, Response}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Focus { Title, Link, Content, Media, Tags, Syndication, } impl Focus { const ORDER: [Focus; 6] = [ Focus::Title, Focus::Link, Focus::Content, Focus::Media, Focus::Tags, Focus::Syndication, ]; } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Screen { Compose, Posts, } /// What to do once the user agrees to throw away unsaved changes. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Discard { /// Start a new, empty note. New, /// Load the post at this index of the list into the editor. Edit(usize), /// Stop editing and go back to the post list. 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, 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, ConfirmPublish, Sending, Published { url: String, draft: bool }, ConfirmQuit, ConfirmDiscard(Discard), ConfirmDelete(String), Prompt(Prompt), } /// Where the syndication target list came from. #[derive(Debug, Clone, PartialEq)] pub enum Targets { Loading, Loaded, /// Couldn't load q=config. Publishing leaves syndication to the server default. Failed(String), } #[derive(Debug, Clone, PartialEq)] pub enum Posts { NotLoaded, Loading, Loaded, Failed(String), } #[derive(Debug, Clone, PartialEq)] pub enum Status { Info(String), Error(String), } pub struct App { pub me: String, pub screen: Screen, pub title: TextArea<'static>, /// In reply to / like of / repost of, depending on `kind`. pub link: TextArea<'static>, pub content: TextArea<'static>, pub tags: TextArea<'static>, pub media: Vec, 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, /// Media kinds the server lists as post types (all, if it lists none). pub media_kinds: Vec, /// 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, pub focus: Focus, pub kind: Kind, /// Kinds the server takes (from q=config). pub kinds: Vec, pub mode: Mode, pub status: Option, pub last_permalink: Option, /// The post being edited (as loaded), or None for a new note. pub editing: Option, pub posts: Vec, pub posts_state: Posts, pub posts_cursor: usize, /// The last page was full, so there may be more posts to load. pub posts_more: bool, pub can_list: bool, /// Permalink of the last deleted post, for undo. pub last_deleted: Option, /// Granted scopes, to explain missing permissions. pub scope: String, pub should_quit: bool, /// Set when a request failed for auth reasons, so main can suggest logging in again. pub needs_login: bool, } fn text_area(placeholder: &str) -> TextArea<'static> { let mut t = TextArea::default(); t.set_placeholder_text(placeholder); t.set_cursor_line_style(Style::default()); t } fn title_area() -> TextArea<'static> { text_area("Optional title") } fn link_area() -> TextArea<'static> { link_area_for(Kind::Note) } fn link_area_for(kind: Kind) -> TextArea<'static> { text_area(match kind { Kind::Note => "Optional: URL of the post you're replying to", Kind::Like => "URL of the post you like", Kind::Repost => "URL of the post you're reposting", }) } /// The note body: soft-wraps at word boundaries (long words such as URLs break mid-word). fn content_area() -> TextArea<'static> { let mut t = text_area("What's on your mind? (Markdown)"); t.set_wrap_mode(WrapMode::WordOrGlyph); t } fn tags_area() -> TextArea<'static> { text_area("Comma-separated, e.g. rust, indieweb") } fn filled(mut area: TextArea<'static>, text: &str) -> TextArea<'static> { area.insert_str(text); area.move_cursor(CursorMove::Top); area.move_cursor(CursorMove::Head); area } 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 { me, screen: Screen::Compose, title: title_area(), 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, focus: Focus::Content, kind: Kind::Note, kinds: Kind::ALL.to_vec(), mode: Mode::Normal, status: None, last_permalink: None, editing: None, posts: Vec::new(), posts_state: Posts::NotLoaded, posts_cursor: 0, posts_more: false, can_list: true, last_deleted: None, scope, should_quit: false, needs_login: false, }; app.update_cursors(); app } fn has_scope(&self, s: &str) -> bool { self.scope.split_whitespace().any(|x| x == s) } /// A hint if the token can't edit or delete (tokens from tuipub 0.1 only had create). pub fn scope_hint(&self) -> Option<&'static str> { (!self.has_scope("update") || !self.has_scope("delete")) .then_some("Run `tuipub login` again to allow editing and deleting.") } fn focus_order(&self) -> Vec { Focus::ORDER .into_iter() // Crosspost targets are chosen once, when posting, and what a like or // repost points at can't be changed either. .filter(|f| match (*f, &self.editing) { (Focus::Syndication, Some(_)) => false, (Focus::Link, Some(p)) => p.kind() == Kind::Note, _ => true, }) .collect() } fn step_focus(&mut self, by: isize) { let order = self.focus_order(); let i = order.iter().position(|f| *f == self.focus).unwrap_or(0) as isize; self.focus = order[(i + by).rem_euclid(order.len() as isize) as usize]; self.update_cursors(); } fn update_cursors(&mut self) { let focus = self.focus; for (field, area) in [ (Focus::Title, &mut self.title), (Focus::Link, &mut self.link), (Focus::Content, &mut self.content), (Focus::Tags, &mut self.tags), ] { let style = if field == focus { Style::default().add_modifier(Modifier::REVERSED) } else { Style::default() }; area.set_cursor_style(style); } } /// Unsaved work: any text in a new note, or any change to the post being edited. pub fn is_dirty(&self) -> bool { 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, 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 { 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 { 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 { 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) { self.title = title_area(); 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; self.update_cursors(); } /// The link field is locked when editing a like or repost. pub fn link_locked(&self) -> bool { self.editing .as_ref() .is_some_and(|p| p.kind() != Kind::Note) } /// Ctrl-T: next post type the server supports. Keeps whatever is typed. fn cycle_kind(&mut self) { if self.editing.is_some() { self.info("An existing post's type can't be changed."); return; } let i = self.kinds.iter().position(|k| *k == self.kind).unwrap_or(0); self.kind = self.kinds[(i + 1) % self.kinds.len()]; let url = single_line(&self.link); self.link = filled(link_area_for(self.kind), &url); self.link.move_cursor(CursorMove::End); if self.kind != Kind::Note { self.focus = Focus::Link; } self.update_cursors(); } fn load_post(&mut self, post: Post) { self.title = filled(title_area(), post.title.as_deref().unwrap_or("")); self.kind = post.kind(); let link = post.target().or(post.in_reply_to.as_deref()).unwrap_or(""); 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(); self.screen = Screen::Compose; self.status = None; } pub fn note(&self, draft: bool) -> Note { let title = single_line(&self.title); let link = single_line(&self.link); let link = (!link.is_empty()).then_some(link); Note { title: (!title.is_empty()).then_some(title), content: self.content.lines().join("\n").trim_end().to_string(), tags: micropub::parse_tags(&self.tags.lines().join(",")), syndicate_to: match self.targets_state { Targets::Loaded => Some( self.targets .iter() .filter(|(_, on)| *on) .map(|(t, _)| t.uid.clone()) .collect(), ), _ => None, }, 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, } } /// The status a save keeps unless the user picks another: drafts stay drafts. pub fn default_draft(&self) -> bool { self.editing.as_ref().is_some_and(|p| p.draft) } /// Human summary of where the post will be crossposted. pub fn syndication_summary(&self) -> String { if let Some(p) = &self.editing { return match p.syndication.len() { 0 => "not crossposted".into(), n => format!( "{n} crossposted cop{} (edits follow)", if n == 1 { "y" } else { "ies" } ), }; } match &self.targets_state { Targets::Loaded => { let names: Vec<_> = self .targets .iter() .filter(|(_, on)| *on) .map(|(t, _)| t.name.as_str()) .collect(); if names.is_empty() { "nowhere".into() } else { names.join(", ") } } _ => "server default".into(), } } pub fn selected_post(&self) -> Option<&Post> { self.posts.get(self.posts_cursor) } fn info(&mut self, s: impl Into) { self.status = Some(Status::Info(s.into())); } fn error(&mut self, s: impl Into) { self.status = Some(Status::Error(s.into())); } fn reload_posts(&mut self) -> Option { if !self.can_list { self.error("This server can't list posts."); return None; } self.posts_state = Posts::Loading; Some(Request::List { offset: 0 }) } /// Handle a reply from the worker. May ask for a follow-up request (e.g. reload the list). pub fn handle_response(&mut self, resp: Response) -> Option { match resp { Response::Config(Ok(cfg)) => self.set_config(cfg), Response::Config(Err(e)) => { self.targets_state = Targets::Failed(e.to_string()); self.report(e); } Response::Published(Ok(url), draft) => { self.clear_form(); self.info(if draft { format!("Saved draft {url}") } else { format!("Published {url}") }); self.last_permalink = Some(url.clone()); self.mode = Mode::Published { url, draft }; // The list is now out of date. if self.posts_state != Posts::NotLoaded { return self.reload_posts(); } } Response::Published(Err(e), _) | Response::Updated { result: Err(e), .. } => { self.mode = Mode::Normal; self.report(e); } Response::Updated { url, result: Ok(()), } => { self.mode = Mode::Normal; if self.editing.as_ref().is_some_and(|p| p.url == url) { self.clear_form(); } self.last_permalink = Some(url); self.screen = Screen::Posts; self.info("Saved."); return self.reload_posts(); } Response::Listed { offset, result: Ok(posts), } => { self.posts_more = posts.len() == PAGE_SIZE; if offset == 0 { self.posts = posts; } else { self.posts.extend(posts); } self.posts_cursor = self.posts_cursor.min(self.posts.len().saturating_sub(1)); self.posts_state = Posts::Loaded; } Response::Listed { result: Err(e), .. } => { self.posts_state = Posts::Failed(e.to_string()); self.report(e); } Response::Deleted { url, result: Ok(()), } => { self.mode = Mode::Normal; self.posts.retain(|p| p.url != url); self.posts_cursor = self.posts_cursor.min(self.posts.len().saturating_sub(1)); self.last_deleted = Some(url); self.info("Deleted. Press u to undo."); } Response::Undeleted { url, result: Ok(()), } => { self.mode = Mode::Normal; self.last_deleted = None; self.info(format!("Restored {url}")); return self.reload_posts(); } Response::Deleted { result: Err(e), .. } | Response::Undeleted { result: Err(e), .. } => { 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 } fn report(&mut self, e: Error) { if matches!(e, Error::Unauthorized(_)) { self.needs_login = true; } self.error(e.to_string()); } 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); self.targets = cfg .syndicate_to .into_iter() .map(|t| { let on = previous .iter() .find(|(p, _)| p.uid == t.uid) .is_none_or(|(_, on)| *on); (t, on) }) .collect(); self.target_cursor = self.target_cursor.min(self.targets.len().saturating_sub(1)); self.targets_state = Targets::Loaded; } /// Handle a key press. Returns a request for the worker, if any. pub fn handle_key(&mut self, key: KeyEvent) -> Option { 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(), KeyCode::Char('p') => false, KeyCode::Char('d') => true, _ => { self.mode = Mode::Normal; return None; } }; self.send(draft) } Mode::ConfirmQuit => { match key.code { KeyCode::Char('y') => self.should_quit = true, _ => self.mode = Mode::Normal, } None } Mode::ConfirmDiscard(action) => { self.mode = Mode::Normal; if key.code == KeyCode::Char('y') { return self.discard(action); } None } Mode::ConfirmDelete(url) => { if key.code == KeyCode::Char('y') { self.mode = Mode::Sending; self.info("Deleting…"); return Some(Request::Delete(url)); } self.mode = Mode::Normal; None } Mode::Published { url, draft } => { self.mode = Mode::Normal; if key.code == KeyCode::Char('o') { self.open_post(&url, draft); } None } Mode::Normal => { if ctrl && matches!(key.code, KeyCode::Char('q' | 'c')) { self.quit(); return None; } match self.screen { Screen::Compose => self.handle_compose_key(key, ctrl), Screen::Posts => self.handle_posts_key(key, ctrl), } } } } fn quit(&mut self) { if self.is_dirty() { self.mode = Mode::ConfirmQuit; } else { self.should_quit = true; } } /// Validate the form, then publish a new note or save the edited post. fn send(&mut self, draft: bool) -> Option { 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() .or(note.like_of.as_ref()) .or(note.repost_of.as_ref()); match (self.kind, link) { (Kind::Like | Kind::Repost, None) if !self.link_locked() => { let what = if self.kind == Kind::Like { "you like" } else { "you're reposting" }; self.error(format!("Add the URL of the post {what}.")); return None; } (_, Some(u)) if !(u.starts_with("http://") || u.starts_with("https://")) => { self.error("The link must be an http(s) URL."); return None; } _ => {} } if let Some(orig) = &self.editing { let update = Update::diff(orig, ¬e); if update.is_empty() { self.info("No changes to save."); return None; } self.mode = Mode::Sending; self.info("Saving…"); return Some(Request::Update(update)); } self.mode = Mode::Sending; self.info(if draft { "Saving draft…" } else { "Publishing…" }); Some(Request::Publish(note)) } fn discard(&mut self, action: Discard) -> Option { match action { Discard::New => { self.clear_form(); self.screen = Screen::Compose; } Discard::BackToPosts => { self.clear_form(); self.screen = Screen::Posts; } Discard::Edit(i) => { if let Some(post) = self.posts.get(i).cloned() { self.load_post(post); } } } None } /// Run `action` now if nothing would be lost, or ask first. fn guard(&mut self, action: Discard) -> Option { if self.is_dirty() { self.mode = Mode::ConfirmDiscard(action); None } else { self.discard(action) } } fn open_post(&mut self, url: &str, draft: bool) { if draft { self.info("Drafts aren't public yet, so there's nothing to open."); return; } if let Err(e) = open::that_detached(url) { self.error(format!("Could not open browser: {e}")); } } fn show_posts(&mut self) -> Option { self.screen = Screen::Posts; if matches!(self.posts_state, Posts::NotLoaded | Posts::Failed(_)) { return self.reload_posts(); } None } fn handle_compose_key(&mut self, key: KeyEvent, ctrl: bool) -> Option { match (ctrl, key.code) { (false, KeyCode::Esc) => { if self.editing.is_some() { return self.guard(Discard::BackToPosts); } self.quit(); return None; } (true, KeyCode::Char('t')) => { self.cycle_kind(); return None; } (true, KeyCode::Char('s')) => { // Likes and reposts don't need a comment. // 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; } return None; } (true, KeyCode::Char('p')) => return self.show_posts(), (true, KeyCode::Char('n')) => { if self.editing.is_some() || self.is_dirty() { return self.guard(Discard::New); } return None; } (true, KeyCode::Char('r')) => { self.targets_state = Targets::Loading; self.info("Refreshing server config…"); return Some(Request::LoadConfig); } (true, KeyCode::Char('o')) => { if let Some(url) = self.last_permalink.clone() { self.open_post(&url, false); } return None; } (_, KeyCode::Tab) => { self.step_focus(1); return None; } (_, KeyCode::BackTab) => { self.step_focus(-1); return None; } _ => {} } match self.focus { Focus::Content => { self.content.input(Input::from(key)); } Focus::Title | Focus::Link | Focus::Tags => { let input = Input::from(key); // Single-line fields: Enter moves on instead of adding a line. if input.key == Key::Enter || (input.ctrl && input.key == Key::Char('m')) { self.step_focus(1); } else { let area = match self.focus { Focus::Title => &mut self.title, Focus::Link => &mut self.link, _ => &mut self.tags, }; area.input(input); } } Focus::Media => return self.handle_media_key(key), Focus::Syndication => self.handle_target_key(key), } None } fn handle_posts_key(&mut self, key: KeyEvent, ctrl: bool) -> Option { let n = self.posts.len(); match (ctrl, key.code) { (false, KeyCode::Esc) | (true, KeyCode::Char('p')) => self.screen = Screen::Compose, (true, KeyCode::Char('n')) | (false, KeyCode::Char('n')) => { return self.guard(Discard::New); } (false, KeyCode::Char('q')) => self.quit(), (false, KeyCode::Up | KeyCode::Char('k')) => { self.posts_cursor = self.posts_cursor.saturating_sub(1) } (false, KeyCode::Down | KeyCode::Char('j')) => { if self.posts_cursor + 1 < n { self.posts_cursor += 1; } else if self.posts_more && self.posts_state == Posts::Loaded { // At the bottom: fetch the next page. self.posts_state = Posts::Loading; return Some(Request::List { offset: n }); } } (false, KeyCode::Home | KeyCode::Char('g')) => self.posts_cursor = 0, (false, KeyCode::Char('r')) => { self.posts_cursor = 0; return self.reload_posts(); } (false, KeyCode::Char('u')) => match self.last_deleted.clone() { Some(url) => { self.mode = Mode::Sending; self.info("Restoring…"); return Some(Request::Undelete(url)); } None => self.info("Nothing to undo."), }, _ => {} } let post = self.selected_post().cloned()?; match (ctrl, key.code) { (false, KeyCode::Enter | KeyCode::Char('e')) => { if self.editing.as_ref().is_some_and(|p| p.url == post.url) { self.screen = Screen::Compose; return None; } return self.guard(Discard::Edit(self.posts_cursor)); } (false, KeyCode::Char('d') | KeyCode::Delete) => { self.mode = Mode::ConfirmDelete(post.url); } (false, KeyCode::Char('o')) => self.open_post(&post.url, post.draft), (false, KeyCode::Char('p')) if post.draft => { let mut update = Update { url: post.url, ..Default::default() }; update .replace .insert("post-status".into(), serde_json::json!(["published"])); self.mode = Mode::Sending; self.info("Publishing draft…"); return Some(Request::Update(update)); } _ => {} } None } fn handle_target_key(&mut self, key: KeyEvent) { let n = self.targets.len(); if n == 0 { return; } match key.code { // Up/Down for the stacked layout, Left/Right for the single row. KeyCode::Up | KeyCode::Left | KeyCode::Char('k' | 'h') => { self.target_cursor = self.target_cursor.saturating_sub(1) } KeyCode::Down | KeyCode::Right | KeyCode::Char('j' | 'l') => { self.target_cursor = (self.target_cursor + 1).min(n - 1) } KeyCode::Char(' ') | KeyCode::Enter => { if let Some((_, on)) = self.targets.get_mut(self.target_cursor) { *on = !*on; } } KeyCode::Char('a') => { let all_on = self.targets.iter().all(|(_, on)| *on); for (_, on) in &mut self.targets { *on = !all_on; } } _ => {} } } /// 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 { 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); } Focus::Title => { self.title.insert_str(text.replace('\n', " ")); } Focus::Link => { self.link.insert_str(text.replace('\n', "")); } Focus::Tags => { self.tags.insert_str(text.replace('\n', ",")); } Focus::Media | Focus::Syndication => {} } Vec::new() } } #[cfg(test)] mod tests { use super::*; fn key(code: KeyCode) -> KeyEvent { KeyEvent::new(code, KeyModifiers::NONE) } fn ctrl(c: char) -> KeyEvent { KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL) } fn typ(app: &mut App, s: &str) { for c in s.chars() { app.handle_key(key(KeyCode::Char(c))); } } fn target(uid: &str) -> SyndicationTarget { SyndicationTarget { uid: uid.into(), name: uid.to_uppercase(), } } fn loaded(targets: &[&str]) -> App { let mut app = App::new( "https://example.com/".into(), "create update delete media".into(), ); app.handle_response(Response::Config(Ok(ServerConfig { syndicate_to: targets.iter().map(|t| target(t)).collect(), ..Default::default() }))); app } fn post(slug: &str, draft: bool) -> Post { Post { url: format!("https://example.com/note/{slug}"), title: None, content: format!("Post {slug}"), tags: vec!["rust".into()], published: "2026-10-31T12:00:00".into(), draft, in_reply_to: None, syndication: vec![], ..Default::default() } } /// App on the post list with these posts loaded. fn with_posts(posts: Vec) -> App { let mut app = loaded(&[]); assert!(matches!( app.handle_key(ctrl('p')), Some(Request::List { offset: 0 }) )); app.handle_response(Response::Listed { offset: 0, result: Ok(posts), }); assert_eq!(app.screen, Screen::Posts); app } #[test] fn compose_and_publish_flow() { let mut app = loaded(&["gts", "bsky"]); 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 app.handle_key(key(KeyCode::Down)); app.handle_key(key(KeyCode::Char(' '))); // uncheck bsky assert!(app.handle_key(ctrl('s')).is_none()); assert_eq!(app.mode, Mode::ConfirmPublish); let Some(Request::Publish(note)) = app.handle_key(key(KeyCode::Char('y'))) else { panic!("expected publish request"); }; assert_eq!(app.mode, Mode::Sending); assert_eq!(note.content, "Hello\nworld"); assert_eq!(note.title, None); assert_eq!(note.tags, vec!["rust", "tui"]); assert_eq!(note.syndicate_to, Some(vec!["gts".to_string()])); assert!(!note.draft); app.handle_response(Response::Published( Ok("https://example.com/note/x".into()), false, )); assert!(matches!(app.mode, Mode::Published { .. })); assert!(!app.is_dirty(), "form clears after publishing"); app.handle_key(key(KeyCode::Esc)); assert_eq!(app.mode, Mode::Normal); } #[test] fn save_new_note_as_draft_with_reply() { let mut app = loaded(&[]); app.handle_key(key(KeyCode::BackTab)); // -> reply assert_eq!(app.focus, Focus::Link); typ(&mut app, "https://other.example/post"); app.handle_key(key(KeyCode::Enter)); // -> content typ(&mut app, "Nice post!"); app.handle_key(ctrl('s')); let Some(Request::Publish(note)) = app.handle_key(key(KeyCode::Char('d'))) else { panic!("expected publish request"); }; assert!(note.draft); assert_eq!( note.in_reply_to.as_deref(), Some("https://other.example/post") ); app.handle_response(Response::Published(Ok("u".into()), true)); assert_eq!( app.mode, Mode::Published { url: "u".into(), draft: true } ); } #[test] fn reply_must_be_a_url() { let mut app = loaded(&[]); app.focus = Focus::Link; typ(&mut app, "not a url"); app.focus = Focus::Content; typ(&mut app, "x"); app.handle_key(ctrl('s')); assert!(app.handle_key(key(KeyCode::Char('y'))).is_none()); assert!(matches!(app.status, Some(Status::Error(_)))); assert_eq!(app.mode, Mode::Normal); } #[test] fn title_enter_moves_on() { let mut app = loaded(&[]); app.focus = Focus::Title; typ(&mut app, "My title"); app.handle_key(key(KeyCode::Enter)); assert_eq!(app.focus, Focus::Link); assert_eq!(app.title.lines(), ["My title"]); } #[test] fn content_wraps_and_keeps_wrapping_after_publish() { let mut app = loaded(&[]); assert_eq!(app.content.wrap_mode(), WrapMode::WordOrGlyph); assert_eq!(app.title.wrap_mode(), WrapMode::None); app.handle_response(Response::Published(Ok("u".into()), false)); assert_eq!(app.content.wrap_mode(), WrapMode::WordOrGlyph); } #[test] fn empty_note_is_not_published() { let mut app = loaded(&[]); app.handle_key(ctrl('s')); assert_eq!(app.mode, Mode::Normal); assert!(matches!(app.status, Some(Status::Error(_)))); } #[test] fn unchecking_all_sends_empty_list_but_unknown_config_sends_none() { let mut app = loaded(&["gts"]); app.focus = Focus::Syndication; app.handle_key(key(KeyCode::Char('a'))); assert_eq!(app.note(false).syndicate_to, Some(vec![])); assert_eq!(app.syndication_summary(), "nowhere"); let mut app = App::new("m".into(), String::new()); app.handle_response(Response::Config(Err(Error::NoLocation( reqwest::StatusCode::OK, )))); assert_eq!(app.note(false).syndicate_to, None); assert_eq!(app.syndication_summary(), "server default"); } #[test] fn refresh_keeps_choices() { let mut app = loaded(&["gts", "bsky"]); app.targets[0].1 = false; app.handle_response(Response::Config(Ok(ServerConfig { syndicate_to: vec![target("gts"), target("bsky"), target("new")], ..Default::default() }))); let on: Vec = app.targets.iter().map(|(_, on)| *on).collect(); assert_eq!(on, [false, true, true]); } #[test] fn quit_asks_when_dirty() { let mut app = loaded(&[]); typ(&mut app, "draft"); app.handle_key(key(KeyCode::Esc)); assert_eq!(app.mode, Mode::ConfirmQuit); app.handle_key(key(KeyCode::Char('n'))); assert!(!app.should_quit); app.handle_key(ctrl('q')); app.handle_key(key(KeyCode::Char('y'))); assert!(app.should_quit); let mut clean = loaded(&[]); clean.handle_key(key(KeyCode::Esc)); assert!(clean.should_quit); } #[test] fn publish_error_returns_to_editing_and_flags_login() { let mut app = loaded(&[]); typ(&mut app, "x"); app.handle_key(ctrl('s')); app.handle_key(key(KeyCode::Char('y'))); app.handle_response(Response::Published( Err(Error::Unauthorized("expired".into())), false, )); assert_eq!(app.mode, Mode::Normal); assert!(app.needs_login); assert!(app.is_dirty(), "draft is kept after a failed publish"); } #[test] fn paste_into_single_line_fields() { let mut app = loaded(&[]); app.focus = Focus::Tags; app.handle_paste("a\nb"); assert_eq!(app.note(false).tags, vec!["a", "b"]); } #[test] fn list_pages_when_scrolling_past_the_end() { let page: Vec = (0..PAGE_SIZE) .map(|i| post(&i.to_string(), false)) .collect(); let mut app = with_posts(page); assert!(app.posts_more); for _ in 0..PAGE_SIZE - 1 { assert!(app.handle_key(key(KeyCode::Down)).is_none()); } let Some(Request::List { offset }) = app.handle_key(key(KeyCode::Down)) else { panic!("expected next page"); }; assert_eq!(offset, PAGE_SIZE); assert!( app.handle_key(key(KeyCode::Down)).is_none(), "one request at a time" ); app.handle_response(Response::Listed { offset, result: Ok(vec![post("last", false)]), }); assert_eq!(app.posts.len(), PAGE_SIZE + 1); assert!(!app.posts_more); app.handle_key(key(KeyCode::Down)); assert_eq!(app.selected_post().unwrap().content, "Post last"); } #[test] fn edit_sends_only_changes_and_returns_to_list() { let mut app = with_posts(vec![post("a", false), post("b", true)]); app.handle_key(key(KeyCode::Down)); app.handle_key(key(KeyCode::Enter)); assert_eq!(app.screen, Screen::Compose); assert_eq!(app.content.lines(), ["Post b"]); assert_eq!(app.tags.lines(), ["rust"]); assert!(!app.is_dirty()); assert!(!app.focus_order().contains(&Focus::Syndication)); // Saving with no changes does nothing. app.handle_key(ctrl('s')); assert!(app.handle_key(key(KeyCode::Enter)).is_none()); app.handle_key(key(KeyCode::End)); typ(&mut app, " edited"); assert!(app.is_dirty()); app.handle_key(ctrl('s')); // Enter keeps the draft a draft, so only content changes. let Some(Request::Update(u)) = app.handle_key(key(KeyCode::Enter)) else { panic!("expected update"); }; assert_eq!(u.url, "https://example.com/note/b"); assert_eq!(u.replace.keys().collect::>(), ["content"]); assert!(u.delete.is_empty()); let next = app.handle_response(Response::Updated { url: u.url, result: Ok(()), }); assert!(matches!(next, Some(Request::List { offset: 0 }))); assert_eq!(app.screen, Screen::Posts); assert!(app.editing.is_none()); } #[test] fn publish_a_draft_from_the_editor_and_the_list() { let mut app = with_posts(vec![post("d", true)]); app.handle_key(key(KeyCode::Enter)); app.handle_key(ctrl('s')); let Some(Request::Update(u)) = app.handle_key(key(KeyCode::Char('p'))) else { panic!("expected update"); }; assert_eq!(u.replace["post-status"], serde_json::json!(["published"])); let mut app = with_posts(vec![post("d", true), post("pub", false)]); let Some(Request::Update(u)) = app.handle_key(key(KeyCode::Char('p'))) else { panic!("expected update"); }; assert_eq!(u.url, "https://example.com/note/d"); app.handle_response(Response::Updated { url: u.url, result: Ok(()), }); app.handle_key(key(KeyCode::Down)); assert!( app.handle_key(key(KeyCode::Char('p'))).is_none(), "already published" ); } #[test] fn delete_then_undo() { let mut app = with_posts(vec![post("a", false), post("b", false)]); assert!(app.handle_key(key(KeyCode::Char('d'))).is_none()); assert!(matches!(app.mode, Mode::ConfirmDelete(_))); let Some(Request::Delete(url)) = app.handle_key(key(KeyCode::Char('y'))) else { panic!("expected delete"); }; app.handle_response(Response::Deleted { url: url.clone(), result: Ok(()), }); assert_eq!(app.posts.len(), 1); assert_eq!(app.last_deleted.as_deref(), Some(url.as_str())); let Some(Request::Undelete(u)) = app.handle_key(key(KeyCode::Char('u'))) else { panic!("expected undelete"); }; assert_eq!(u, url); let next = app.handle_response(Response::Undeleted { url: u, result: Ok(()), }); assert!(matches!(next, Some(Request::List { offset: 0 }))); assert!(app.last_deleted.is_none()); } #[test] fn delete_can_be_cancelled() { let mut app = with_posts(vec![post("a", false)]); app.handle_key(key(KeyCode::Char('d'))); assert!(app.handle_key(key(KeyCode::Char('n'))).is_none()); assert_eq!(app.mode, Mode::Normal); assert_eq!(app.posts.len(), 1); } #[test] fn unsaved_text_is_protected_when_switching_posts() { let mut app = with_posts(vec![post("a", false)]); app.handle_key(key(KeyCode::Esc)); // back to compose typ(&mut app, "half-written note"); app.handle_key(ctrl('p')); app.handle_key(key(KeyCode::Enter)); assert_eq!(app.mode, Mode::ConfirmDiscard(Discard::Edit(0))); app.handle_key(key(KeyCode::Char('n'))); assert_eq!(app.screen, Screen::Posts); assert!(app.editing.is_none()); app.handle_key(key(KeyCode::Enter)); app.handle_key(key(KeyCode::Char('y'))); assert_eq!(app.screen, Screen::Compose); assert_eq!(app.content.lines(), ["Post a"]); // Esc while editing with changes asks before leaving. typ(&mut app, "x"); app.handle_key(key(KeyCode::Esc)); assert_eq!(app.mode, Mode::ConfirmDiscard(Discard::BackToPosts)); } #[test] fn edit_toggles_name_and_reply() { let mut orig = post("a", false); orig.title = Some("Old".into()); orig.in_reply_to = Some("https://r.example/".into()); let mut app = with_posts(vec![orig]); app.handle_key(key(KeyCode::Enter)); assert_eq!(app.title.lines(), ["Old"]); assert_eq!(app.link.lines(), ["https://r.example/"]); app.title = title_area(); app.link = link_area(); app.handle_key(ctrl('s')); let Some(Request::Update(u)) = app.handle_key(key(KeyCode::Char('y'))) else { panic!("expected update"); }; assert_eq!(u.delete, ["name", "in-reply-to"]); } #[test] fn scope_hint_for_old_tokens() { let app = App::new("m".into(), "create media".into()); assert!(app.scope_hint().is_some()); assert!(loaded(&[]).scope_hint().is_none()); } #[test] fn server_without_listing() { let mut app = loaded(&[]); app.handle_response(Response::Config(Ok(ServerConfig { q: vec!["config".into()], ..Default::default() }))); assert!(app.handle_key(ctrl('p')).is_none()); assert!(matches!(app.status, Some(Status::Error(_)))); } #[test] fn drafts_are_not_opened() { let mut app = with_posts(vec![post("d", true)]); app.handle_key(key(KeyCode::Char('o'))); assert!(matches!(app.status, Some(Status::Info(ref s)) if s.contains("Drafts"))); } #[test] fn like_without_comment() { let mut app = loaded(&["gts"]); app.handle_key(ctrl('t')); assert_eq!(app.kind, Kind::Like); assert_eq!(app.focus, Focus::Link); // A like needs its URL. app.handle_key(ctrl('s')); assert_eq!(app.mode, Mode::ConfirmPublish, "no comment needed"); assert!(app.handle_key(key(KeyCode::Enter)).is_none()); assert!(matches!(app.status, Some(Status::Error(ref e)) if e.contains("URL"))); typ(&mut app, "https://social.example/@a/1"); app.handle_key(ctrl('s')); let Some(Request::Publish(note)) = app.handle_key(key(KeyCode::Enter)) else { panic!("expected publish"); }; assert_eq!(note.like_of.as_deref(), Some("https://social.example/@a/1")); assert_eq!(note.in_reply_to, None); assert_eq!(note.content, ""); assert_eq!(note.syndicate_to, Some(vec!["gts".to_string()])); } #[test] fn ctrl_t_cycles_supported_kinds_and_keeps_the_url() { let mut app = loaded(&[]); app.focus = Focus::Link; typ(&mut app, "https://x.example/"); app.handle_key(ctrl('t')); app.handle_key(ctrl('t')); assert_eq!(app.kind, Kind::Repost); assert_eq!( app.note(false).repost_of.as_deref(), Some("https://x.example/") ); app.handle_key(ctrl('t')); assert_eq!(app.kind, Kind::Note); assert_eq!( app.note(false).in_reply_to.as_deref(), Some("https://x.example/") ); // A server without likes or reposts: Ctrl-T stays on Note. app.handle_response(Response::Config(Ok(ServerConfig { post_types: vec![micropub::PostType { kind: "note".into(), }], ..Default::default() }))); app.handle_key(ctrl('t')); assert_eq!(app.kind, Kind::Note); } #[test] fn editing_a_like_locks_its_url_and_type() { let mut like = post("l", false); like.content = String::new(); like.like_of = Some("https://x.example/".into()); let mut app = with_posts(vec![like]); app.handle_key(key(KeyCode::Enter)); assert_eq!(app.kind, Kind::Like); assert!(app.link_locked()); assert!(!app.focus_order().contains(&Focus::Link)); app.handle_key(ctrl('t')); assert_eq!(app.kind, Kind::Like); // Adding a comment is an ordinary content update. typ(&mut app, "So good"); app.handle_key(ctrl('s')); let Some(Request::Update(u)) = app.handle_key(key(KeyCode::Enter)) else { panic!("expected update"); }; assert_eq!(u.replace.keys().collect::>(), ["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 { 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(); } }