返回 CodeWhale
stash.rs
根目录 / crates / tui / src / commands / groups / core / stash.rs
1 //! `/stash` slash command — list / pop parked composer drafts (#440).
2 //!
3 //! See `crates/tui/src/composer_stash.rs` for the on-disk format
4 //! and persistence rules. The slash command is the user-facing
5 //! surface; Ctrl+G (or Ctrl+S) in the composer is the corresponding push entry
6 //! point.
7
8 use crate::commands::traits::{CommandInfo, RegisterCommand};
9 use crate::composer_stash;
10 use crate::tui::app::App;
11 use codewhale_localization::MessageId;
12
13 use super::CommandResult;
14
15 pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo {
16 name: "stash",
17 aliases: &["park"],
18 usage: "/stash [list|pop|clear]",
19 description_id: MessageId::CmdStashDescription,
20 };
21
22 pub(in crate::commands) struct StashCmd;
23
24 impl RegisterCommand for StashCmd {
25 fn info() -> &'static CommandInfo {
26 &COMMAND_INFO
27 }
28
29 fn execute(app: &mut App, arg: Option<&str>) -> CommandResult {
30 stash(app, arg)
31 }
32 }
33
34 /// Top-level dispatch for `/stash`. Subcommands:
35 ///
36 /// * `/stash` — same as `/stash list`.
37 /// * `/stash list` — show parked drafts, oldest first.
38 /// * `/stash pop` — restore the most recently parked draft into
39 /// the composer; the popped entry is removed from disk.
40 /// * `/stash clear` — wipe the entire stash file. Reports how many
41 /// entries were dropped so the user knows what they deleted.
42 pub fn stash(app: &mut App, arg: Option<&str>) -> CommandResult {
43 let sub = arg.map(str::trim).unwrap_or("list").to_ascii_lowercase();
44 match sub.as_str() {
45 "" | "list" | "ls" | "show" => list(),
46 "pop" | "restore" => pop(app),
47 "clear" | "wipe" | "drop" => clear(),
48 other => CommandResult::error(format!(
49 "unknown subcommand `{other}`. Try `/stash list`, `/stash pop`, or `/stash clear`."
50 )),
51 }
52 }
53
54 fn list() -> CommandResult {
55 let entries = composer_stash::load_stash();
56 if entries.is_empty() {
57 return CommandResult::message(
58 "Stash empty. Press Ctrl+G (or Ctrl+S) in the composer to park the current draft.",
59 );
60 }
61 let mut out = String::new();
62 out.push_str(&format!("{} parked draft(s):\n\n", entries.len()));
63 for (idx, entry) in entries.iter().enumerate() {
64 out.push_str(&format_stash_line(idx, &entry.ts, &entry.text));
65 }
66 out.push_str("\nUse `/stash pop` to restore the most recent draft.");
67 CommandResult::message(out)
68 }
69
70 fn clear() -> CommandResult {
71 match composer_stash::clear_stash() {
72 Ok(0) => CommandResult::message("Stash already empty — nothing to clear."),
73 Ok(n) => CommandResult::message(format!("Cleared {n} parked draft(s) from the stash.")),
74 Err(err) => CommandResult::error(format!("Failed to clear stash: {err}")),
75 }
76 }
77
78 fn pop(app: &mut App) -> CommandResult {
79 match composer_stash::pop_stash() {
80 Some(entry) => {
81 // Replace the current composer contents with the popped
82 // draft. We don't merge — replacing is the predictable
83 // behaviour and matches the "restore the parked draft"
84 // mental model. Mirror the queue-edit pattern for the
85 // cursor reset.
86 app.input = entry.text.clone();
87 app.cursor_position = app.input.len();
88 let preview = preview_first_line(&entry.text, 60);
89 // Tell the user how many drafts remain so they can plan
90 // whether to keep popping or move on. Matches the
91 // confirmation pattern used by the queue surface.
92 let remaining = composer_stash::load_stash().len();
93 let suffix = match remaining {
94 0 => " (stash now empty)".to_string(),
95 1 => " (1 more parked)".to_string(),
96 n => format!(" ({n} more parked)"),
97 };
98 CommandResult::message(format!("Restored stashed draft: {preview}{suffix}"))
99 }
100 None => CommandResult::message("Stash empty — nothing to pop."),
101 }
102 }
103
104 /// One `/stash list` row. `idx` is the 0-based position; users see 1-based.
105 fn format_stash_line(idx: usize, ts: &str, text: &str) -> String {
106 let ts = if ts.is_empty() { "(no ts)" } else { ts };
107 let preview = preview_first_line(text, 80);
108 format!(" {}. [{ts}] {preview}\n", idx + 1)
109 }
110
111 /// Take a one-line preview of `text`, capped at `max_chars`.
112 /// Multi-line drafts get a single-line summary so the listing
113 /// stays scannable.
114 fn preview_first_line(text: &str, max_chars: usize) -> String {
115 let head = text.lines().next().unwrap_or("").trim();
116 if head.chars().count() <= max_chars {
117 return head.to_string();
118 }
119 let mut out: String = head.chars().take(max_chars.saturating_sub(1)).collect();
120 out.push('…');
121 out
122 }
123
124 #[cfg(test)]
125 mod tests {
126 use super::*;
127
128 #[test]
129 fn stash_list_numbers_entries_from_one() {
130 assert_eq!(
131 format_stash_line(0, "2026-09-22T10:00:00Z", "first draft\nmore"),
132 " 1. [2026-09-22T10:00:00Z] first draft\n"
133 );
134 assert_eq!(format_stash_line(2, "", "third"), " 3. [(no ts)] third\n");
135 }
136
137 #[test]
138 fn preview_first_line_truncates_to_cap() {
139 let body = "x".repeat(200);
140 let p = preview_first_line(&body, 10);
141 assert_eq!(p.chars().count(), 10);
142 assert!(p.ends_with('…'));
143 }
144
145 #[test]
146 fn preview_first_line_keeps_short_input_intact() {
147 assert_eq!(preview_first_line("short", 50), "short");
148 }
149
150 #[test]
151 fn preview_first_line_only_uses_first_line_of_multiline() {
152 let body = "first line of the draft\nsecond line that's longer\nthird";
153 assert_eq!(preview_first_line(body, 80), "first line of the draft");
154 }
155
156 #[test]
157 fn preview_first_line_handles_empty_input() {
158 assert_eq!(preview_first_line("", 50), "");
159 assert_eq!(preview_first_line(" ", 50), "");
160 }
161 }
162
162 lines RUST