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