diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a6252e7..ab08b14a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -214,6 +214,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `TuiOptions::initial_input` plumb that any future caller can reuse to drop the model into a session with text already typed. +- **`/stash clear` subcommand** (#440 polish) — wipes the + entire stash file and reports how many parked drafts were + dropped. Pairs with `/stash list` and `/stash pop` so the + user can fully manage the stash from inside the TUI without + reaching for `rm`. - **RLM tool family** (#512) — `rlm` tool cards map to `ToolFamily::Rlm` and render `rlm`, not `swarm`. Stale "swarm" wording cleaned out of docs / comments / tests. diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index 8c7dfb6e..27634539 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -175,7 +175,7 @@ pub const COMMANDS: &[CommandInfo] = &[ CommandInfo { name: "stash", aliases: &["park"], - usage: "/stash [list|pop]", + usage: "/stash [list|pop|clear]", description_id: MessageId::CmdStashDescription, }, CommandInfo { diff --git a/crates/tui/src/commands/stash.rs b/crates/tui/src/commands/stash.rs index f0269315..766f877d 100644 --- a/crates/tui/src/commands/stash.rs +++ b/crates/tui/src/commands/stash.rs @@ -16,13 +16,16 @@ use super::CommandResult; /// * `/stash list` — show parked drafts, oldest first. /// * `/stash pop` — restore the most recently parked draft into /// the composer; the popped entry is removed from disk. +/// * `/stash clear` — wipe the entire stash file. Reports how many +/// entries were dropped so the user knows what they deleted. pub fn stash(app: &mut App, arg: Option<&str>) -> CommandResult { let sub = arg.map(str::trim).unwrap_or("list").to_ascii_lowercase(); match sub.as_str() { "" | "list" | "ls" | "show" => list(), "pop" | "restore" => pop(app), + "clear" | "wipe" | "drop" => clear(), other => CommandResult::error(format!( - "unknown subcommand `{other}`. Try `/stash list` or `/stash pop`." + "unknown subcommand `{other}`. Try `/stash list`, `/stash pop`, or `/stash clear`." )), } } @@ -49,6 +52,14 @@ fn list() -> CommandResult { CommandResult::message(out) } +fn clear() -> CommandResult { + match composer_stash::clear_stash() { + Ok(0) => CommandResult::message("Stash already empty — nothing to clear."), + Ok(n) => CommandResult::message(format!("Cleared {n} parked draft(s) from the stash.")), + Err(err) => CommandResult::error(format!("Failed to clear stash: {err}")), + } +} + fn pop(app: &mut App) -> CommandResult { match composer_stash::pop_stash() { Some(entry) => { diff --git a/crates/tui/src/composer_stash.rs b/crates/tui/src/composer_stash.rs index 8e7d37dc..846c73ba 100644 --- a/crates/tui/src/composer_stash.rs +++ b/crates/tui/src/composer_stash.rs @@ -26,6 +26,7 @@ //! ambiguity and the timestamp / future fields land cleanly. use std::fs; +use std::io; use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; @@ -125,6 +126,29 @@ pub fn pop_stash() -> Option { pop_stash_from(&path) } +/// Wipe the stash file entirely. Returns the number of entries +/// that were dropped (so the caller can report it). Returns 0 +/// when the file doesn't exist or had no entries. +pub fn clear_stash() -> io::Result { + let Some(path) = default_stash_path() else { + return Ok(0); + }; + clear_stash_at(&path) +} + +fn clear_stash_at(path: &Path) -> io::Result { + if !path.exists() { + return Ok(0); + } + let entries = load_stash_from(path); + let count = entries.len(); + if count == 0 { + return Ok(0); + } + crate::utils::write_atomic(path, b"")?; + Ok(count) +} + fn pop_stash_from(path: &Path) -> Option { let mut entries = load_stash_from(path); let popped = entries.pop()?; @@ -236,6 +260,32 @@ this is not json assert_eq!(entries[1].text, "good two"); } + #[test] + fn clear_returns_zero_when_file_is_absent() { + let (_tmp, path) = temp_stash_path(); + // Path doesn't exist yet. + assert_eq!(clear_stash_at(&path).unwrap(), 0); + } + + #[test] + fn clear_returns_zero_when_file_is_empty() { + let (_tmp, path) = temp_stash_path(); + std::fs::write(&path, "").unwrap(); + assert_eq!(clear_stash_at(&path).unwrap(), 0); + } + + #[test] + fn clear_drops_entries_and_reports_count() { + let (_tmp, path) = temp_stash_path(); + push_stash_to(&path, "first"); + push_stash_to(&path, "second"); + push_stash_to(&path, "third"); + let dropped = clear_stash_at(&path).expect("clear succeeds"); + assert_eq!(dropped, 3); + // File still exists but is empty so subsequent loads come back clean. + assert!(load_stash_from(&path).is_empty()); + } + #[test] fn cap_prunes_oldest_at_push_time() { let (_tmp, path) = temp_stash_path();