diff --git a/README.md b/README.md index c6b4918..0e5af46 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,13 @@ copies the feedback to the clipboard as numbered annotations (`# Annotations on `## Annotation 1 (line 12)`, …). Every annotation is saved as JSON the moment you make it; `q` closes. +`I` attaches a local image to the selected note (in the notes, or the selected block's note in +the document): type, paste or drop its path, then `Enter`. Quoted, `~` and `file://` paths +work; a relative path starts at the document's folder. PNG, JPEG, GIF, WebP, BMP and SVG files +are accepted. The feedback lists each one as `Image: /absolute/path.png` under its note, so the +agent opens it from your disk; nothing is copied or uploaded. `D` removes the last image added. +Adding or removing an image makes a sent note pending again. + Copies go to the clipboard as OSC 52, which is the terminal you are looking at, so on Herdr 0.9.0 they reach your own machine even when the app runs on a remote server; Herdr Annotate's global `copy-context` and `copy-archive` actions do not, because they run outside a pane. @@ -90,9 +97,9 @@ dimmed. The keys also work without opening the menu. |---|---| | anywhere | `Tab` cycle tree · document · notes; `E` send; `t` tree; `r` reload; `o` open the file in `$EDITOR`; `q` quit | | reply review | `S` send and close | -| document | `j`/`k` block; `c` comment on the block; `x` clear its annotations; `X` clear every annotation on the document, after a `y`; `v` select with `hjkl` `w` `b` `0` `$`, `Enter` to finish; `i` move the cursor with those keys first, then `v` to select from there | +| document | `j`/`k` block; `c` comment on the block; `x` clear its annotations; `X` clear every annotation on the document, after a `y`; `v` select with `hjkl` `w` `b` `0` `$`, `Enter` to finish; `i` move the cursor with those keys first, then `v` to select from there; `I`/`D` attach or remove an image on the block's note | | toolbar | `a` looks good · `c` comment · `d` delete · `Esc` | -| notes | `j`/`k`; `e` edit; `x` remove; click a bubble | +| notes | `j`/`k`; `e` edit; `x` remove; `I` attach an image, `D` remove the last; click a bubble | | file/folder review | `E` send new · `m` review menu (`R` resend all · `F` finish review · `U` undo · `H` archive) | | folder review | `Ctrl+P` open a file by name, including in collapsed folders: type, `↑`/`↓` or click, `Enter`, `Esc` | | tree | `j`/`k`; `Enter` open; `.` show/hide dot-prefixed entries (`.agents/`, `.github/`); `E` sends new notes across all reviewed files, including collapsed folders | @@ -237,6 +244,7 @@ screen show your keys. `plannotator-tui config` prints every action with its key | `resend-all` `finish-review` `undo-finish` `archive` | `R` `F` `U` `H` | file and folder review actions | | `toggle-hidden` | `.` | show/hide dot-prefixed entries in the tree | | `edit` `remove` | `e` `x`, `Delete` | the selected note | +| `attach-image` `remove-image` | `I` `D` | an image on the selected note, or the selected block's | | `visual` `move-cursor` | `v` `i` | select with the cursor; move it first | | `comment` `clear-block` | `c` `x` | on the block; clear its annotations | | `clear-all` | `X` | every annotation on the document, after `yes` | diff --git a/crates/plannotator-tui-schema/src/annotation.rs b/crates/plannotator-tui-schema/src/annotation.rs index 82c8786..6526f34 100644 --- a/crates/plannotator-tui-schema/src/annotation.rs +++ b/crates/plannotator-tui-schema/src/annotation.rs @@ -50,6 +50,10 @@ pub struct Annotation { pub state: State, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub attachments: Vec, + /// plannotator-tui's own fields. Workspaces stores annotations opaquely beyond its own + /// keys; an empty namespace is omitted, so a note without them keeps the server shape. + #[serde(default, skip_serializing_if = "NoteExtras::is_empty")] + pub plannotator_tui: NoteExtras, pub created_at: String, pub updated_at: String, #[serde(default)] @@ -58,6 +62,24 @@ pub struct Annotation { pub other: BTreeMap, } +/// The `plannotator_tui` namespace on a root annotation. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct NoteExtras { + /// Absolute paths of local image files attached to the note, in the order attached. + /// References only: the files are never copied or uploaded, and Workspaces' own + /// `attachments` (uploaded `https://` URLs) is a different field. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub images: Vec, + #[serde(flatten)] + pub other: BTreeMap, +} + +impl NoteExtras { + pub fn is_empty(&self) -> bool { + self.images.is_empty() && self.other.is_empty() + } +} + /// Body of `POST .../annotations`. The whole request is capped at 32 KiB; `body` at 8 KiB; /// the serialized `anchor` at 16 KiB; `author` at 120 characters. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -103,3 +125,34 @@ impl std::fmt::Display for ApiError { } impl std::error::Error for ApiError {} + +#[cfg(test)] +#[allow(clippy::expect_used, reason = "tests assert by panicking")] +mod tests { + use super::*; + + fn note(extras: &str) -> String { + format!( + r#"{{"id":"a1","document_id":"d1","anchor":{{"originalText":"hello"}},"body":"b","author":null, + "state":"open","created_at":"t","updated_at":"t","replies":[]{extras}}}"# + ) + } + + #[test] + fn a_note_without_images_keeps_the_server_shape() { + let parsed: Annotation = serde_json::from_str(¬e("")).expect("an older record parses"); + assert_eq!(parsed.plannotator_tui, NoteExtras::default()); + let json = serde_json::to_value(&parsed).expect("serializable"); + assert!(json.get("plannotator_tui").is_none(), "{json}"); + } + + #[test] + fn images_round_trip_beside_unknown_namespace_fields() { + let text = note(r#","plannotator_tui":{"images":["/tmp/a.png"],"later":1}"#); + let parsed: Annotation = serde_json::from_str(&text).expect("parses"); + assert_eq!(parsed.plannotator_tui.images, ["/tmp/a.png"]); + assert_eq!(parsed.attachments, Vec::::new(), "local paths never become Workspaces URLs"); + let back = serde_json::to_value(&parsed).expect("serializable"); + assert_eq!(back, serde_json::from_str::(&text).expect("JSON")); + } +} diff --git a/crates/plannotator-tui-schema/src/lib.rs b/crates/plannotator-tui-schema/src/lib.rs index 1e8d51c..9a6159e 100644 --- a/crates/plannotator-tui-schema/src/lib.rs +++ b/crates/plannotator-tui-schema/src/lib.rs @@ -15,7 +15,7 @@ pub mod source; pub mod version; pub use anchor::{Anchor, CONTEXT_CHARS, Extras, Kind, SourceRange}; -pub use annotation::{Annotation, ApiError, CreateAnnotation, PatchAnnotation, Reply, State}; +pub use annotation::{Annotation, ApiError, CreateAnnotation, NoteExtras, PatchAnnotation, Reply, State}; pub use datadir::{annotations_dir, data_dir, history_slug, project_name, sanitize_tag}; pub use resolve::{Resolution, resolve, web_will_match}; pub use source::{DocumentSource, Provenance}; diff --git a/crates/plannotator-tui/README.md b/crates/plannotator-tui/README.md index 577210a..05c1a54 100644 --- a/crates/plannotator-tui/README.md +++ b/crates/plannotator-tui/README.md @@ -15,7 +15,7 @@ cargo build --release | anywhere | `Tab` cycle focus (tree · document · rail) · `E` send feedback (clipboard) · `t` show/hide tree · `r` reload · `q` quit | | document | drag with the mouse, or `v` then `hjkl` / `w` `b` / `0` `$` to select; `Enter` confirms · `i` moves the cursor with those keys first, so `v` can start mid-block · `j`/`k` or click selects a block · `c` comments on the block · `x` clears the block's annotations · `X` clears every annotation on the document after a `y` · wide Markdown tables wrap by cell | | selection toolbar | `a` 👍 looks good · `c` 💬 comment (opens a box at the selection) · `d` ✗ delete · `Esc` clears | -| rail | `j`/`k` move · `e` / `Enter` edit body · `x` remove · click a bubble to focus it | +| rail | `j`/`k` move · `e` / `Enter` edit body · `x` remove · `I` attach a local image by path · `D` remove the last image · click a bubble to focus it | | reply review | `S` send feedback and quit; stays open if the agent is at a dialog | | file/folder review | `E` send new · `m` review menu (`R` resend all · `F` finish review · `U` undo · `H` archive) | | tree | `j`/`k` move · `Enter` open · `.` show/hide hidden (dot-prefixed) entries · `E` send new feedback across files, including collapsed folders · counts show active notes per file | diff --git a/crates/plannotator-tui/src/app/draw.rs b/crates/plannotator-tui/src/app/draw.rs index 4dcac13..7441f4c 100644 --- a/crates/plannotator-tui/src/app/draw.rs +++ b/crates/plannotator-tui/src/app/draw.rs @@ -98,6 +98,9 @@ impl App { match &self.mode { Mode::Compose => self.draw_compose(frame, &self.compose_title("comment")), Mode::Edit(_) => self.draw_compose(frame, &self.compose_title("edit")), + Mode::AttachImage(_) => { + self.draw_compose(frame, " image path \u{b7} enter attaches \u{b7} esc cancels "); + } Mode::Browse if self.pending.is_some() => self.draw_toolbar(frame), Mode::Pick => self.draw_pick(frame), Mode::FileSearch => self.draw_file_search(frame), @@ -318,7 +321,7 @@ impl App { } fn edit_origin(&self, height: u16, width: u16) -> Option { - let Mode::Edit(id) = &self.mode else { return None }; + let (Mode::Edit(id) | Mode::AttachImage(id)) = &self.mode else { return None }; let (rect, _) = self.geometry.bubbles.iter().find(|(_, bubble_id)| bubble_id == id)?; let area = self.geometry.doc.union(*rect); let x = rect.right().saturating_sub(width).max(area.x); @@ -366,8 +369,13 @@ impl App { } else { "" }; + let images = match placed.annotation.plannotator_tui.images.len() { + 0 => String::new(), + 1 => " · 1 image".to_owned(), + n => format!(" · {n} images"), + }; let title = Span::styled( - format!(" {} {}{sent} ", glyph(kind), short_id(&placed.annotation.id)), + format!(" {} {}{images}{sent} ", glyph(kind), short_id(&placed.annotation.id)), Style::new().fg(accent(kind)), ); let bubble = Block::default() @@ -445,6 +453,23 @@ impl App { if self.viewport(frame.area()).width < RAIL_MIN_TOTAL_WIDTH { parts.push("rail hidden: widen to ≥80 cols".into()); } + let help = self.footer_help(); + let help = if help.is_empty() { String::new() } else { format!("{} ", help.join(" · ")) }; + // The status must stay readable at any width, so the key help yields columns to it + // (and is clipped) rather than the other way round. + let status_width = self.status.as_ref().map_or(0, |s| s.width() + 1) as u16; + let help_width = (help.width() as u16).min(area.width.saturating_sub(status_width.max(10))); + let [left_area, right_area] = + Layout::horizontal([Constraint::Min(10), Constraint::Length(help_width)]).areas(area); + frame.render_widget( + Paragraph::new(Line::from(Span::raw(format!(" {}", parts.join(" · "))).dim())), + left_area, + ); + frame.render_widget(Paragraph::new(Line::from(Span::raw(help).dim()).right_aligned()), right_area); + } + + /// The key help for the focused pane, in the user's keys. + fn footer_help(&self) -> Vec { // Each hint names its action's first key; an unbound action drops out of the line. let key = |action, label| self.keys.labelled(action, label); let bare = |hint: String| (!hint.is_empty()).then_some(hint); @@ -462,6 +487,10 @@ impl App { let send_and_close = if self.is_file_review() { None } else { key(Action::SendAndClose, "send+quit") }; let quit = [send_and_close, key(Action::Quit, "quit")]; + // The image keys act on the current note; removing is offered only when it has one. + let note = self.current_note(); + let has_images = note.as_deref().is_some_and(|id| self.open.store.has_images(id)); + let remove_image = has_images.then(|| key(Action::RemoveImage, "remove image")).flatten(); let help: Vec> = match self.focus { _ if self.pending.is_some() => vec![ key(Action::LooksGood, "looks good"), @@ -477,10 +506,17 @@ impl App { key(Action::ToggleTree, "hide"), key(Action::Quit, "quit"), ], - Focus::Rail => [up_down, key(Action::Edit, "edit"), key(Action::Remove, "remove"), tab] - .into_iter() - .chain(quit) - .collect(), + Focus::Rail => [ + up_down, + key(Action::Edit, "edit"), + key(Action::Remove, "remove"), + key(Action::AttachImage, "image"), + remove_image, + tab, + ] + .into_iter() + .chain(quit) + .collect(), Focus::Document if self.roam => [ bare(self.keys.directions_hint()).map(|keys| format!("{keys} move")), key(Action::Visual, "select"), @@ -494,6 +530,8 @@ impl App { key(Action::MoveCursor, "move"), key(Action::Visual, "select"), key(Action::Comment, "comment"), + note.is_some().then(|| key(Action::AttachImage, "image")).flatten(), + remove_image, key(Action::Send, "send"), tab, ] @@ -505,19 +543,7 @@ impl App { // last. let find = if self.tree.is_some() && self.pending.is_none() { key(Action::QuickOpen, "find") } else { None }; - let help: Vec = help.into_iter().chain([find]).flatten().collect(); - let help = if help.is_empty() { String::new() } else { format!("{} ", help.join(" · ")) }; - // The status must stay readable at any width, so the key help yields columns to it - // (and is clipped) rather than the other way round. - let status_width = self.status.as_ref().map_or(0, |s| s.width() + 1) as u16; - let help_width = (help.width() as u16).min(area.width.saturating_sub(status_width.max(10))); - let [left_area, right_area] = - Layout::horizontal([Constraint::Min(10), Constraint::Length(help_width)]).areas(area); - frame.render_widget( - Paragraph::new(Line::from(Span::raw(format!(" {}", parts.join(" · "))).dim())), - left_area, - ); - frame.render_widget(Paragraph::new(Line::from(Span::raw(help).dim()).right_aligned()), right_area); + help.into_iter().chain([find]).flatten().collect() } } diff --git a/crates/plannotator-tui/src/app/feedback.rs b/crates/plannotator-tui/src/app/feedback.rs index 8bb60c2..f1a60fb 100644 --- a/crates/plannotator-tui/src/app/feedback.rs +++ b/crates/plannotator-tui/src/app/feedback.rs @@ -50,6 +50,8 @@ pub(super) struct FeedbackPart { pub(super) struct Feedback { pub(super) text: String, pub(super) count: usize, + /// Image paths across the sent notes, for the archive's counts. + pub(super) images: usize, pub(super) parts: Vec, pub(super) annotations: Vec, pub(super) counts: HashMap, @@ -93,6 +95,7 @@ impl Feedback { self.text.push_str(&export::feedback(name, &entries)); let ids = entries.iter().map(|e| e.annotation.id.clone()).collect(); self.count += entries.len(); + self.images += entries.iter().map(|e| e.annotation.plannotator_tui.images.len()).sum::(); self.annotations.extend(entries.iter().map(|entry| { let a = entry.annotation; AnnotationRecord { diff --git a/crates/plannotator-tui/src/app/images.rs b/crates/plannotator-tui/src/app/images.rs new file mode 100644 index 0000000..b2d3509 --- /dev/null +++ b/crates/plannotator-tui/src/app/images.rs @@ -0,0 +1,201 @@ +//! Local images on notes: `attach-image` asks for a path, `remove-image` drops the last one. +//! +//! An image is a reference to a file on this machine. Nothing is copied or uploaded; the +//! feedback names the absolute path and the agent opens it from there. + +use std::path::{Path, PathBuf}; + +use anyhow::{Result, bail}; +use plannotator_tui_schema::Provenance; +use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use super::compose::{Compose, ComposeAction}; +use super::{App, Focus, Mode}; +use crate::workspace_paths; + +/// Extensions accepted as images, lowercase. +const IMAGE_EXTENSIONS: [&str; 7] = ["png", "jpg", "jpeg", "gif", "webp", "bmp", "svg"]; + +impl App { + /// The note `attach-image` and `remove-image` act on: the rail's selected note, or in + /// the document the selected block's note (the rail's selection when it is in the block). + pub(super) fn current_note(&self) -> Option { + let placed = self.open.store.placed(); + if self.focus == Focus::Rail { + return placed.get(self.rail_cursor).map(|p| p.annotation.id.clone()); + } + let in_block = |index: &usize| { + placed + .get(*index) + .is_some_and(|p| self.open.doc.block_containing(p.range.start) == Some(self.selected)) + }; + let index = Some(self.rail_cursor).filter(in_block).or_else(|| (0..placed.len()).find(in_block))?; + placed.get(index).map(|p| p.annotation.id.clone()) + } + + /// Open the one-line path prompt for the current note. + pub(super) fn begin_attach_image(&mut self) { + let Some(id) = self.current_note() else { + self.status = Some("no note here to attach an image to".into()); + return; + }; + self.compose = Compose::default(); + self.status = None; + self.mode = Mode::AttachImage(id); + } + + pub(super) fn remove_image(&mut self) -> Result<()> { + let Some(id) = self.current_note() else { return Ok(()) }; + match self.open.store.remove_last_image(&id)? { + Some(path) => { + self.mark_unsent(); + self.status = Some(format!("image removed: {}", file_name(&path))); + } + None => self.status = Some("this note has no images".into()), + } + Ok(()) + } + + /// Keys in the path prompt: Enter attaches, Esc cancels, the rest edit the one line. + pub(super) fn image_prompt_key(&mut self, key: KeyEvent) -> Result<()> { + match key.code { + KeyCode::Enter => return self.submit_image_prompt(), + // The comment box's new-line key; a path has one line. + KeyCode::Char('j' | 'J') if key.modifiers.contains(KeyModifiers::CONTROL) => {} + _ => { + if self.compose.handle_key(key) == ComposeAction::Cancel { + self.mode = Mode::Browse; + } + } + } + Ok(()) + } + + /// A paste or a dropped file joins the line; line breaks are dropped. + pub(super) fn image_prompt_paste(&mut self, text: &str) { + self.compose.insert_text(&text.replace(['\r', '\n'], "")); + } + + /// Attach the typed path. A path that is not an image file keeps the prompt open with + /// the reason, so it can be corrected; an empty line closes it. + fn submit_image_prompt(&mut self) -> Result<()> { + let Mode::AttachImage(id) = &self.mode else { return Ok(()) }; + let id = id.clone(); + let input = self.compose.value(); + if input.trim().is_empty() { + self.mode = Mode::Browse; + return Ok(()); + } + let path = match image_path(&input, &self.document_folder()) { + Ok(path) => path, + Err(err) => { + self.status = Some(format!("{err:#}")); + return Ok(()); + } + }; + self.mode = Mode::Browse; + let name = file_name(&path); + if self.open.store.add_image(&id, &path)? { + self.mark_unsent(); + self.status = Some(format!("image attached: {name}")); + } else { + self.status = Some(format!("already attached: {name}")); + } + Ok(()) + } + + /// Where a relative path starts: the open file's folder, else the working directory. + fn document_folder(&self) -> PathBuf { + match &self.open.source.provenance { + Provenance::File { path } => path.parent().map(Path::to_path_buf), + _ => None, + } + .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))) + } +} + +/// The absolute path of the image file `input` names. Accepts what a terminal types, pastes +/// or drops: surrounding quotes, `file://` URLs, `~`, backslash-escaped spaces, and paths +/// relative to `folder`. +fn image_path(input: &str, folder: &Path) -> Result { + let text = unquote(input.trim()); + let text = text.strip_prefix("file://").map_or_else( + || text.to_owned(), + |rest| percent_decode(rest.strip_prefix("localhost").unwrap_or(rest)), + ); + // A dropped file arrives shell-escaped (`Screen\ Shot.png`) in many terminals. The + // literal spelling is tried first, so a Windows path keeps its separators. + let mut path = resolve(&text, folder); + if !path.exists() { + let unescaped = resolve(&unescape(&text), folder); + if unescaped.exists() { + path = unescaped; + } + } + let extension = path.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase); + if !extension.is_some_and(|e| IMAGE_EXTENSIONS.contains(&e.as_str())) { + bail!("not an image ({}): {}", IMAGE_EXTENSIONS.join(", "), path.display()); + } + if !path.is_file() { + bail!("no such file: {}", path.display()); + } + match path.to_str() { + Some(path) => Ok(path.to_owned()), + None => bail!("path is not valid UTF-8: {}", path.display()), + } +} + +fn unquote(text: &str) -> &str { + ['"', '\''].iter().find_map(|&q| text.strip_prefix(q).and_then(|t| t.strip_suffix(q))).unwrap_or(text) +} + +/// `a\ b` → `a b`: drop each backslash and keep the character it escapes. +fn unescape(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut chars = text.chars(); + while let Some(c) = chars.next() { + if c == '\\' { + out.extend(chars.next()); + } else { + out.push(c); + } + } + out +} + +fn percent_decode(text: &str) -> String { + let bytes = text.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while let Some(&byte) = bytes.get(i) { + let hex = (byte == b'%').then(|| text.get(i + 1..i + 3)).flatten(); + if let Some(decoded) = hex.and_then(|h| u8::from_str_radix(h, 16).ok()) { + out.push(decoded); + i += 3; + } else { + out.push(byte); + i += 1; + } + } + String::from_utf8_lossy(&out).into_owned() +} + +/// `~` and `~/…` from the home directory, other relative paths from `folder`. +fn resolve(text: &str, folder: &Path) -> PathBuf { + let home = || std::env::home_dir().unwrap_or_else(|| PathBuf::from("/")); + let path = if text == "~" { + home() + } else if let Some(rest) = text.strip_prefix("~/").or_else(|| text.strip_prefix("~\\")) { + home().join(rest) + } else { + folder.join(text) + }; + workspace_paths::absolute(&path) +} + +fn file_name(path: &str) -> String { + Path::new(path).file_name().map_or_else(|| path.to_owned(), |n| n.to_string_lossy().into_owned()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/plannotator-tui/src/app/images/tests.rs b/crates/plannotator-tui/src/app/images/tests.rs new file mode 100644 index 0000000..89c0dc9 --- /dev/null +++ b/crates/plannotator-tui/src/app/images/tests.rs @@ -0,0 +1,120 @@ +#![allow(clippy::expect_used, clippy::indexing_slicing, reason = "tests assert by panicking")] + +use std::path::{Path, PathBuf}; + +use plannotator_tui_schema::Kind; +use ratatui::crossterm::event::{Event, KeyCode, KeyEvent}; + +use super::{image_path, resolve}; +use crate::app::review_test_support::{draw, file_app, press, reopen}; +use crate::app::{Focus, Mode}; +use crate::workspace_paths; +fn folder(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("plannotator-images-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("shots")).expect("folder"); + std::fs::write(dir.join("shots/screen shot.PNG"), b"png").expect("image"); + std::fs::write(dir.join("notes.txt"), b"text").expect("text"); + dir +} + +#[test] +fn typed_pasted_and_dropped_spellings_name_the_same_absolute_file() { + let dir = folder("spellings"); + let expected = dir.join("shots").join("screen shot.PNG").to_string_lossy().into_owned(); + let absolute = expected.clone(); + let mut inputs = vec![ + "shots/screen shot.PNG".to_owned(), + "./shots/../shots/screen shot.PNG".to_owned(), + format!(" '{absolute}'\t"), + format!("\"{absolute}\""), + ]; + if cfg!(unix) { + inputs.push(format!("file://{}", absolute.replace(' ', "%20"))); + inputs.push(absolute.replace(' ', "\\ ")); + } + for input in inputs { + assert_eq!(image_path(&input, &dir).expect(&input), expected, "{input}"); + } + std::fs::remove_dir_all(dir).expect("cleanup"); +} + +#[test] +fn missing_files_and_other_extensions_are_refused_with_the_path() { + let dir = folder("refused"); + let missing = image_path("shots/gone.png", &dir).expect_err("missing").to_string(); + assert!(missing.starts_with("no such file:") && missing.contains("gone.png"), "{missing}"); + let text = image_path("notes.txt", &dir).expect_err("not an image").to_string(); + assert!(text.starts_with("not an image") && text.contains("notes.txt"), "{text}"); + let folder_named_like_an_image = dir.join("dir.png"); + std::fs::create_dir_all(&folder_named_like_an_image).expect("dir"); + assert!(image_path("dir.png", &dir).is_err(), "a directory is not an image file"); + std::fs::remove_dir_all(dir).expect("cleanup"); +} + +#[test] +fn a_tilde_starts_at_home() { + let home = std::env::home_dir().expect("home"); + assert_eq!(resolve("~/a.png", Path::new("/elsewhere")), workspace_paths::absolute(&home.join("a.png"))); +} + +fn enter(app: &mut crate::app::App) { + app.handle_event(&Event::Key(KeyEvent::from(KeyCode::Enter))).expect("enter"); +} + +#[test] +fn an_image_rides_with_its_note_into_feedback_and_back_out() { + let (root, mut app, delivery) = file_app("attach"); + let image = root.join("docs").join("shots").join("before.png"); + std::fs::create_dir_all(image.parent().expect("parent")).expect("shots"); + std::fs::write(&image, b"png").expect("image"); + let image = image.to_string_lossy().into_owned(); + app.add_quote_annotation("one", Kind::Comment, "match this".into()).expect("note"); + press(&mut app, 'E'); + assert_eq!(app.send_count(), 0); + + // From the document, the key finds the selected block's note. + app.select_block(1); + press(&mut app, 'I'); + let id = app.open.store.placed()[0].annotation.id.clone(); + assert_eq!(app.mode, Mode::AttachImage(id)); + app.handle_event(&Event::Paste("shots/missing.png".into())).expect("paste"); + enter(&mut app); + assert!(matches!(app.mode, Mode::AttachImage(_)), "a bad path keeps the prompt open"); + assert!(app.status.as_deref().is_some_and(|s| s.starts_with("no such file")), "{:?}", app.status); + press(&mut app, 'x'); + app.handle_event(&Event::Key(KeyEvent::from(KeyCode::Esc))).expect("esc"); + assert_eq!(app.mode, Mode::Browse); + + // From the notes rail, a relative path starts at the document's folder. + app.focus = Focus::Rail; + for _ in 0..2 { + press(&mut app, 'I'); + app.handle_event(&Event::Paste("'shots/before.png'\n".into())).expect("paste"); + enter(&mut app); + } + assert_eq!(app.status.as_deref(), Some("already attached: before.png")); + assert_eq!(app.open.store.placed()[0].annotation.plannotator_tui.images, std::slice::from_ref(&image)); + assert_eq!(app.send_count(), 1, "a sent note with a new image is pending again"); + assert!(draw(&mut app, 100, 24).contains("1 image")); + + press(&mut app, 'E'); + let sent = delivery.calls.borrow().last().cloned().expect("sent"); + assert!(sent.contains(&format!("> match this\nImage: {image}\n")), "{sent}"); + let history = + std::fs::read_to_string(app.data_dir.join("feedback").join(&app.project).join("index.jsonl")) + .expect("history"); + let last: serde_json::Value = + serde_json::from_str(history.lines().last().expect("a line")).expect("history JSON"); + assert_eq!(last["counts"]["images"], 1); + + reopen(&mut app); + assert_eq!(app.open.store.placed()[0].annotation.plannotator_tui.images, [image]); + assert_eq!(app.send_count(), 0); + app.focus = Focus::Rail; + press(&mut app, 'D'); + assert_eq!(app.status.as_deref(), Some("image removed: before.png")); + assert_eq!(app.open.store.placed()[0].annotation.plannotator_tui.images, Vec::::new()); + assert_eq!(app.send_count(), 1, "removing an image is a change to send"); + std::fs::remove_dir_all(root).expect("cleanup"); +} diff --git a/crates/plannotator-tui/src/app/input.rs b/crates/plannotator-tui/src/app/input.rs index b925557..287dd12 100644 --- a/crates/plannotator-tui/src/app/input.rs +++ b/crates/plannotator-tui/src/app/input.rs @@ -30,6 +30,7 @@ impl App { } Mode::ReviewMenu => self.menu_key(*key), Mode::Compose | Mode::Edit(_) => self.text_key(*key), + Mode::AttachImage(_) => self.image_prompt_key(*key), }, // A paste lands in the comment box verbatim, newlines included; anywhere else // it is ignored rather than replayed as keystrokes. @@ -37,6 +38,11 @@ impl App { self.compose.insert_text(text); Ok(()) } + // A pasted or dropped path joins the image prompt's one line. + Event::Paste(text) if matches!(self.mode, Mode::AttachImage(_)) => { + self.image_prompt_paste(text); + Ok(()) + } // In Quick Open a paste joins the query, newlines dropped. Event::Paste(text) if self.mode == Mode::FileSearch => { self.file_search_paste(text); @@ -209,6 +215,8 @@ impl App { Some(Action::Up) => self.rail_cursor = self.rail_cursor.saturating_sub(1), Some(Action::Confirm | Action::Edit) => self.edit_selected_annotation(), Some(Action::Remove) => self.remove_selected_annotation()?, + Some(Action::AttachImage) => self.begin_attach_image(), + Some(Action::RemoveImage) => self.remove_image()?, Some(Action::Back) => self.focus = Focus::Document, _ => {} } @@ -297,6 +305,8 @@ impl App { self.status = Some(format!("removed {removed} annotation(s) on block")); } Some(Action::ClearAll) => self.request_clear_all(), + Some(Action::AttachImage) => self.begin_attach_image(), + Some(Action::RemoveImage) => self.remove_image()?, _ => {} } Ok(()) @@ -417,6 +427,7 @@ impl App { } } Mode::Compose + | Mode::AttachImage(_) | Mode::Browse | Mode::ConfirmQuit | Mode::ConfirmClearAll diff --git a/crates/plannotator-tui/src/app/mod.rs b/crates/plannotator-tui/src/app/mod.rs index debd3a7..1a7cc56 100644 --- a/crates/plannotator-tui/src/app/mod.rs +++ b/crates/plannotator-tui/src/app/mod.rs @@ -9,6 +9,7 @@ mod editor; mod feedback; mod file_search; mod header; +mod images; mod input; mod menu; mod pick; @@ -58,6 +59,8 @@ enum Mode { Compose, /// Editing the body of an existing annotation (by id). Edit(String), + /// Typing the path of an image to attach to an annotation (by id). + AttachImage(String), /// Quit was asked for while feedback is unsent; the footer asks first. ConfirmQuit, /// `clear-all` was pressed; the footer asks before every note on the document goes. diff --git a/crates/plannotator-tui/src/app/send.rs b/crates/plannotator-tui/src/app/send.rs index b67e77f..4a8537d 100644 --- a/crates/plannotator-tui/src/app/send.rs +++ b/crates/plannotator-tui/src/app/send.rs @@ -175,6 +175,7 @@ impl App { feedback: &feedback.text, annotations, count: feedback.count, + images: feedback.images, now_ms: None, }); } diff --git a/crates/plannotator-tui/src/archive.rs b/crates/plannotator-tui/src/archive.rs index 2af74dd..94319d9 100644 --- a/crates/plannotator-tui/src/archive.rs +++ b/crates/plannotator-tui/src/archive.rs @@ -157,6 +157,8 @@ pub(crate) struct Submission<'a> { /// Per-annotation records; empty in folder mode, where `count` still carries the total. pub(crate) annotations: Vec, pub(crate) count: usize, + /// Local image paths attached to the sent notes. + pub(crate) images: usize, /// Milliseconds since the epoch; tests pin it, callers pass `None` for now. pub(crate) now_ms: Option, } @@ -188,7 +190,7 @@ fn try_append(submission: &Submission<'_>) -> std::io::Result { target: (!submission.target.is_empty()).then_some(&submission.target), feedback: submission.feedback, annotations: &submission.annotations, - counts: Counts { annotations: submission.count, external: 0, images: 0 }, + counts: Counts { annotations: submission.count, external: 0, images: submission.images }, record_file: None, }; @@ -333,6 +335,7 @@ mod tests { original_text: Some("the selected text".to_owned()), }], count: 1, + images: 0, now_ms: Some(1_788_242_400_123), } } diff --git a/crates/plannotator-tui/src/export.rs b/crates/plannotator-tui/src/export.rs index 5ee6f8d..b954301 100644 --- a/crates/plannotator-tui/src/export.rs +++ b/crates/plannotator-tui/src/export.rs @@ -1,8 +1,9 @@ //! Feedback export: annotations as numbered Markdown a coding agent reads without a schema. //! //! `# Annotations on `, then one `## Annotation N (line X)` per annotation in document -//! order: what kind of note it is, the quoted text, and the body as a blockquote. Deleted -//! text is fenced so quoted markdown cannot escape. +//! order: what kind of note it is, the quoted text, the body as a blockquote, and one +//! `Image: ` line per attached image. Deleted text is fenced so quoted +//! markdown cannot escape. use std::fmt::Write as _; use std::ops::Range; @@ -50,6 +51,10 @@ pub(crate) fn feedback(name: &str, entries: &[Entry<'_>]) -> String { let _ = writeln!(out, "> {}", quote_lines(body)); } } + // A whole line per path, so a path with spaces needs no quoting or escaping. + for image in &entry.annotation.plannotator_tui.images { + let _ = writeln!(out, "Image: {image}"); + } for reply in &entry.annotation.replies { let who = reply.author.as_deref().unwrap_or("reply"); let _ = writeln!(out, "- **Reply ({who}):** {}", reply.body.replace('\n', "\n ")); @@ -109,6 +114,7 @@ mod tests { author_name: None, state: State::Open, attachments: Vec::new(), + plannotator_tui: plannotator_tui_schema::NoteExtras::default(), created_at: String::new(), updated_at: String::new(), replies: Vec::new(), @@ -146,6 +152,19 @@ mod tests { ); } + #[test] + fn each_attached_image_is_one_line_with_its_path() { + let source = "see the header\n"; + let (mut comment, range) = annotation(source, "header", Kind::Comment, "match this"); + comment.plannotator_tui.images = vec!["/shots/before.png".into(), "/shots/after shot.png".into()]; + let entries = [Entry { annotation: &comment, lines: Some((1, 1)), quote: source[range].to_owned() }]; + assert_eq!( + feedback("plan.md", &entries), + "# Annotations on plan.md\n\n## Annotation 1 (line 1)\nComment on: \"header\"\n> match this\n\ + Image: /shots/before.png\nImage: /shots/after shot.png\n\n" + ); + } + #[test] fn fences_grow_past_embedded_backticks() { assert!(fenced("has ``` inside").starts_with("````\n")); diff --git a/crates/plannotator-tui/src/keys.rs b/crates/plannotator-tui/src/keys.rs index 3ed68ec..bb72bbe 100644 --- a/crates/plannotator-tui/src/keys.rs +++ b/crates/plannotator-tui/src/keys.rs @@ -47,6 +47,8 @@ pub(crate) enum Action { ToggleHidden, Edit, Remove, + AttachImage, + RemoveImage, Visual, MoveCursor, Comment, @@ -59,10 +61,11 @@ pub(crate) enum Action { } use Action::{ - Archive, Back, Bottom, ClearAll, ClearBlock, Comment, Confirm, CycleFocus, Delete, Down, Edit, - FinishReview, Interrupt, Left, LineEnd, LineStart, LooksGood, MoveCursor, No, OpenInEditor, PageDown, - PageUp, PickMessage, QuickOpen, Quit, Reload, Remove, ResendAll, ReviewMenu, Right, Send, SendAndClose, - ToggleHidden, ToggleTree, Top, UndoFinish, Up, Visual, WordBack, WordForward, Yes, + Archive, AttachImage, Back, Bottom, ClearAll, ClearBlock, Comment, Confirm, CycleFocus, Delete, Down, + Edit, FinishReview, Interrupt, Left, LineEnd, LineStart, LooksGood, MoveCursor, No, OpenInEditor, + PageDown, PageUp, PickMessage, QuickOpen, Quit, Reload, Remove, RemoveImage, ResendAll, ReviewMenu, + Right, Send, SendAndClose, ToggleHidden, ToggleTree, Top, UndoFinish, Up, Visual, WordBack, WordForward, + Yes, }; /// Browsing, before the focused pane gets the key. @@ -84,10 +87,26 @@ pub(crate) const GLOBAL: &[Action] = &[ PickMessage, ]; pub(crate) const TREE: &[Action] = &[Down, Up, Confirm, Right, ToggleHidden, Back]; -pub(crate) const RAIL: &[Action] = &[Down, Up, Confirm, Edit, Remove, Back]; +pub(crate) const RAIL: &[Action] = &[Down, Up, Confirm, Edit, Remove, AttachImage, RemoveImage, Back]; +/// `attach-image` and `remove-image` act on the selected block's note here. pub(crate) const DOCUMENT: &[Action] = &[ - Back, Visual, MoveCursor, Down, Up, Left, Right, PageDown, PageUp, Top, Bottom, Comment, Confirm, - ClearBlock, ClearAll, + Back, + Visual, + MoveCursor, + Down, + Up, + Left, + Right, + PageDown, + PageUp, + Top, + Bottom, + Comment, + Confirm, + ClearBlock, + ClearAll, + AttachImage, + RemoveImage, ]; /// Cursor motions, in visual mode and while moving the cursor (`move-cursor`). pub(crate) const MOTION: &[Action] = &[Left, Right, Down, Up, WordForward, WordBack, LineStart, LineEnd]; @@ -251,6 +270,8 @@ const DEFAULTS: &[(Action, &[Key])] = &[ (ToggleHidden, &[ch('.')]), (Edit, &[ch('e')]), (Remove, &[ch('x'), named(KeyCode::Delete)]), + (AttachImage, &[ch('I')]), + (RemoveImage, &[ch('D')]), (Visual, &[ch('v')]), (MoveCursor, &[ch('i')]), (Comment, &[ch('c')]), @@ -381,6 +402,8 @@ impl Action { Self::ToggleHidden => "toggle-hidden", Self::Edit => "edit", Self::Remove => "remove", + Self::AttachImage => "attach-image", + Self::RemoveImage => "remove-image", Self::Visual => "visual", Self::MoveCursor => "move-cursor", Self::Comment => "comment", diff --git a/crates/plannotator-tui/src/store.rs b/crates/plannotator-tui/src/store.rs index eede26b..de0e7c9 100644 --- a/crates/plannotator-tui/src/store.rs +++ b/crates/plannotator-tui/src/store.rs @@ -231,6 +231,7 @@ impl Store { author_name: None, state: State::Open, attachments: Vec::new(), + plannotator_tui: plannotator_tui_schema::NoteExtras::default(), created_at: now.clone(), updated_at: now, replies: Vec::new(), @@ -302,6 +303,10 @@ impl Store { out } + pub(crate) fn has_images(&self, id: &str) -> bool { + self.annotations.iter().any(|a| a.id == id && !a.plannotator_tui.images.is_empty()) + } + pub(crate) fn len(&self) -> usize { self.annotations.len() } diff --git a/crates/plannotator-tui/src/store/review.rs b/crates/plannotator-tui/src/store/review.rs index d5aac3d..5e57fbb 100644 --- a/crates/plannotator-tui/src/store/review.rs +++ b/crates/plannotator-tui/src/store/review.rs @@ -76,30 +76,61 @@ impl Store { !self.annotations.is_empty() && self.annotations.iter().all(|a| !self.is_pending(a)) } - /// Replace a body, advancing the existing timestamp beyond its last send even when - /// the clock has not ticked (or has moved backwards) since that send. One millisecond - /// is the smallest step the stored shape can represent. + /// Replace a body. Returns whether it changed. pub(crate) fn edit_body(&mut self, id: &str, body: String) -> Result { + self.change(id, |annotation| { + if annotation.body == body { + return false; + } + annotation.body = body; + true + }) + } + + /// Attach a local image by absolute path. False when the note already has it. + pub(crate) fn add_image(&mut self, id: &str, path: &str) -> Result { + self.change(id, |annotation| { + let images = &mut annotation.plannotator_tui.images; + if images.iter().any(|image| image == path) { + return false; + } + images.push(path.to_owned()); + true + }) + } + + /// Detach the most recently attached image; `None` when the note has none. + pub(crate) fn remove_last_image(&mut self, id: &str) -> Result> { + let mut removed = None; + self.change(id, |annotation| { + removed = annotation.plannotator_tui.images.pop(); + removed.is_some() + })?; + Ok(removed) + } + + /// Apply `edit` to one annotation and save. When it reports a change, the timestamp + /// advances beyond the last send even when the clock has not ticked (or has moved + /// backwards) since that send, so the note is pending again. One millisecond is the + /// smallest step the stored shape can represent. + fn change(&mut self, id: &str, edit: impl FnOnce(&mut Annotation) -> bool) -> Result { let Some(annotation) = self.annotations.iter().find(|a| a.id == id) else { return Ok(false) }; - if annotation.body == body { - return Ok(false); - } let previous = [parse_time(&annotation.updated_at), self.last_delivery(id).and_then(|d| parse_time(&d.at))] .into_iter() .flatten() .max(); + let mut next = self.clone(); + let Some(annotation) = next.annotations.iter_mut().find(|a| a.id == id) else { return Ok(false) }; + if !edit(annotation) { + return Ok(false); + } let mut now = OffsetDateTime::now_utc(); if let Some(previous) = previous { now = now .max(previous.checked_add(Duration::milliseconds(1)).context("advancing annotation time")?); } - let updated_at = format_millis(now)?; - let mut next = self.clone(); - if let Some(annotation) = next.annotations.iter_mut().find(|a| a.id == id) { - annotation.body = body; - annotation.updated_at = updated_at; - } + annotation.updated_at = format_millis(now)?; next.save()?; *self = next; Ok(true) diff --git a/docs/decisions.md b/docs/decisions.md index 51cd131..1d147cf 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -392,3 +392,13 @@ mode still forces the selection to start at the top and be shrunk from below. Sp top-level list into one block per item was built and dropped: it made `j`/`k` + `c` reach a bullet directly, but changed rendering (a block gap between every bullet) and the block model for everyone, when roaming already reaches any line. + +## 16. Images on a note are local paths under `plannotator_tui` (2026-10-27) + +A note can carry images, rebuilt from plannotator-tui#73. Workspaces' `attachments` holds +uploaded `https://` URLs and rejects anything else, so a local file cannot go there. The +paths live in the annotation's own `plannotator_tui` namespace (`{"images": ["/abs/a.png"]}`), +omitted when empty, so a note without images keeps the server shape and older records load +unchanged. Feedback writes one `Image: ` line per image: the whole line is the +path, so spaces need no escaping, and agents open a path more readily than a `file://` URL. +Nothing is copied or uploaded; uploading belongs to a future Workspaces sync.