返回 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::localization::MessageId;
11 use crate::tui::app::App;
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 let preview = preview_first_line(&entry.text, 80);
65 let ts = if entry.ts.is_empty() {
66 "(no ts)".to_string()
67 } else {
68 entry.ts.clone()
69 };
70 out.push_str(&format!(" {idx}. [{ts}] {preview}\n"));
71 }
72 out.push_str("\nUse `/stash pop` to restore the most recent draft.");
73 CommandResult::message(out)
74 }
75
76 fn clear() -> CommandResult {
77 match composer_stash::clear_stash() {
78 Ok(0) => CommandResult::message("Stash already empty — nothing to clear."),
79 Ok(n) => CommandResult::message(format!("Cleared {n} parked draft(s) from the stash.")),
80 Err(err) => CommandResult::error(format!("Failed to clear stash: {err}")),
81 }
82 }
83
84 fn pop(app: &mut App) -> CommandResult {
85 match composer_stash::pop_stash() {
86 Some(entry) => {
87 // Replace the current composer contents with the popped
88 // draft. We don't merge — replacing is the predictable
89 // behaviour and matches the "restore the parked draft"
90 // mental model. Mirror the queue-edit pattern for the
91 // cursor reset.
92 app.input = entry.text.clone();
93 app.cursor_position = app.input.len();
94 let preview = preview_first_line(&entry.text, 60);
95 // Tell the user how many drafts remain so they can plan
96 // whether to keep popping or move on. Matches the
97 // confirmation pattern used by the queue surface.
98 let remaining = composer_stash::load_stash().len();
99 let suffix = match remaining {
100 0 => " (stash now empty)".to_string(),
101 1 => " (1 more parked)".to_string(),
102 n => format!(" ({n} more parked)"),
103 };
104 CommandResult::message(format!("Restored stashed draft: {preview}{suffix}"))
105 }
106 None => CommandResult::message("Stash empty — nothing to pop."),
107 }
108 }
109
110 /// Take a one-line preview of `text`, capped at `max_chars`.
111 /// Multi-line drafts get a single-line summary so the listing
112 /// stays scannable.
113 fn preview_first_line(text: &str, max_chars: usize) -> String {
114 let head = text.lines().next().unwrap_or("").trim();
115 if head.chars().count() <= max_chars {
116 return head.to_string();
117 }
118 let mut out: String = head.chars().take(max_chars.saturating_sub(1)).collect();
119 out.push('…');
120 out
121 }
122
123 #[cfg(test)]
124 mod tests {
125 use super::*;
126
127 #[test]
128 fn preview_first_line_truncates_to_cap() {
129 let body = "x".repeat(200);
130 let p = preview_first_line(&body, 10);
131 assert_eq!(p.chars().count(), 10);
132 assert!(p.ends_with('…'));
133 }
134
135 #[test]
136 fn preview_first_line_keeps_short_input_intact() {
137 assert_eq!(preview_first_line("short", 50), "short");
138 }
139
140 #[test]
141 fn preview_first_line_only_uses_first_line_of_multiline() {
142 let body = "first line of the draft\nsecond line that's longer\nthird";
143 assert_eq!(preview_first_line(body, 80), "first line of the draft");
144 }
145
146 #[test]
147 fn preview_first_line_handles_empty_input() {
148 assert_eq!(preview_first_line("", 50), "");
149 assert_eq!(preview_first_line(" ", 50), "");
150 }
151 }
152
152 lines RUST