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>
1948 lines
67 KiB
Rust
1948 lines
67 KiB
Rust
//! 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<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,
|
|
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<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,
|
|
pub focus: Focus,
|
|
pub kind: Kind,
|
|
/// Kinds the server takes (from q=config).
|
|
pub kinds: Vec<Kind>,
|
|
pub mode: Mode,
|
|
pub status: Option<Status>,
|
|
pub last_permalink: Option<String>,
|
|
/// The post being edited (as loaded), or None for a new note.
|
|
pub editing: Option<Post>,
|
|
pub posts: Vec<Post>,
|
|
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<String>,
|
|
/// 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> {
|
|
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<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) {
|
|
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<String>) {
|
|
self.status = Some(Status::Info(s.into()));
|
|
}
|
|
|
|
fn error(&mut self, s: impl Into<String>) {
|
|
self.status = Some(Status::Error(s.into()));
|
|
}
|
|
|
|
fn reload_posts(&mut self) -> Option<Request> {
|
|
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<Request> {
|
|
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<Request> {
|
|
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<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()
|
|
.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<Request> {
|
|
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<Request> {
|
|
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<Request> {
|
|
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<Request> {
|
|
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<Request> {
|
|
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<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);
|
|
}
|
|
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<Post>) -> 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<bool> = 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<Post> = (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::<Vec<_>>(), ["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::<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();
|
|
}
|
|
}
|