返回 CodeWhale
queue.rs
根目录 / crates / tui / src / commands / groups / core / queue.rs
1 //! Queue commands: queue list/edit/drop/clear
2
3 use crate::commands::traits::{CommandInfo, RegisterCommand};
4 use crate::localization::{Locale, MessageId, tr};
5 use crate::tui::app::App;
6
7 use super::CommandResult;
8
9 const PREVIEW_LIMIT: usize = 120;
10
11 pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo {
12 name: "queue",
13 aliases: &["queued"],
14 usage: "/queue [list|send <n>|edit <n>|drop <n>|clear]",
15 description_id: MessageId::CmdQueueDescription,
16 };
17
18 pub(in crate::commands) struct QueueCmd;
19
20 impl RegisterCommand for QueueCmd {
21 fn info() -> &'static CommandInfo {
22 &COMMAND_INFO
23 }
24
25 fn execute(app: &mut App, arg: Option<&str>) -> CommandResult {
26 queue(app, arg)
27 }
28 }
29
30 pub fn queue(app: &mut App, args: Option<&str>) -> CommandResult {
31 let locale = app.ui_locale;
32 let arg = args.unwrap_or("").trim();
33 if arg.is_empty() || arg.eq_ignore_ascii_case("list") {
34 return list_queue(app);
35 }
36
37 let mut parts = arg.split_whitespace();
38 let action = parts.next().unwrap_or("").to_lowercase();
39
40 match action.as_str() {
41 "edit" => edit_queue(app, parts.next()),
42 "drop" | "remove" | "rm" => drop_queue(app, parts.next()),
43 "clear" => clear_queue(app),
44 _ => CommandResult::error(tr(locale, MessageId::CmdQueueUsage)),
45 }
46 }
47
48 fn list_queue(app: &mut App) -> CommandResult {
49 let locale = app.ui_locale;
50 let mut lines = Vec::new();
51 let queued = app.queued_message_count();
52
53 if let Some(draft) = app.queued_draft.as_ref() {
54 lines.push("Editing queued message:".to_string());
55 lines.push(format!("- {}", truncate_preview(&draft.display)));
56 }
57
58 if queued == 0 {
59 if lines.is_empty() {
60 return CommandResult::message(tr(locale, MessageId::CmdQueueNoMessages));
61 }
62 return CommandResult::message(lines.join("\n"));
63 }
64
65 lines.push(tr(locale, MessageId::CmdQueueListHeader).replace("{count}", &queued.to_string()));
66 for (idx, message) in app.queued_messages.iter().enumerate() {
67 lines.push(format!(
68 "{}. {}",
69 idx + 1,
70 truncate_preview(&message.display)
71 ));
72 }
73
74 lines.push(tr(locale, MessageId::CmdQueueTip).to_string());
75
76 CommandResult::message(lines.join("\n"))
77 }
78
79 fn edit_queue(app: &mut App, index: Option<&str>) -> CommandResult {
80 let locale = app.ui_locale;
81 if app.queued_draft.is_some() {
82 return CommandResult::error(tr(locale, MessageId::CmdQueueAlreadyEditing));
83 }
84 let index = match parse_index(index, locale) {
85 Ok(index) => index,
86 Err(err) => return CommandResult::error(err),
87 };
88
89 let Some(message) = app.remove_queued_message(index) else {
90 return CommandResult::error(tr(locale, MessageId::CmdQueueNotFound));
91 };
92
93 app.input = message.display.clone();
94 app.cursor_position = app.input.len();
95 app.queued_draft = Some(message);
96 let status =
97 tr(locale, MessageId::CmdQueueEditingStatus).replace("{index}", &(index + 1).to_string());
98 app.status_message = Some(status);
99
100 CommandResult::message(
101 tr(locale, MessageId::CmdQueueEditingMessage).replace("{index}", &(index + 1).to_string()),
102 )
103 }
104
105 fn drop_queue(app: &mut App, index: Option<&str>) -> CommandResult {
106 let locale = app.ui_locale;
107 let index = match parse_index(index, locale) {
108 Ok(index) => index,
109 Err(err) => return CommandResult::error(err),
110 };
111
112 if app.remove_queued_message(index).is_none() {
113 return CommandResult::error(tr(locale, MessageId::CmdQueueNotFound));
114 }
115
116 CommandResult::message(
117 tr(locale, MessageId::CmdQueueDropped).replace("{index}", &(index + 1).to_string()),
118 )
119 }
120
121 fn clear_queue(app: &mut App) -> CommandResult {
122 let locale = app.ui_locale;
123 let queued = app.queued_message_count();
124 let had_draft = app.queued_draft.take().is_some();
125 app.queued_messages.clear();
126 if queued == 0 && !had_draft {
127 return CommandResult::message(tr(locale, MessageId::CmdQueueAlreadyEmpty));
128 }
129
130 CommandResult::message(tr(locale, MessageId::CmdQueueCleared))
131 }
132
133 fn parse_index(input: Option<&str>, locale: Locale) -> Result<usize, String> {
134 let Some(input) = input else {
135 return Err(tr(locale, MessageId::CmdQueueMissingIndex).to_string());
136 };
137 let raw = input
138 .parse::<usize>()
139 .map_err(|_| tr(locale, MessageId::CmdQueueIndexPositive).to_string())?;
140 if raw == 0 {
141 return Err(tr(locale, MessageId::CmdQueueIndexMin).to_string());
142 }
143 Ok(raw - 1)
144 }
145
146 fn truncate_preview(text: &str) -> String {
147 if text.chars().count() <= PREVIEW_LIMIT {
148 return text.to_string();
149 }
150 let mut out = String::new();
151 for ch in text.chars().take(PREVIEW_LIMIT.saturating_sub(3)) {
152 out.push(ch);
153 }
154 out.push_str("...");
155 out
156 }
157
158 #[cfg(test)]
159 mod tests {
160 use super::*;
161 use crate::config::Config;
162 use crate::tui::app::{App, QueuedMessage, TuiOptions};
163 use tempfile::TempDir;
164
165 fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App {
166 let options = TuiOptions {
167 skills_dir: tmpdir.path().join("skills"),
168 memory_path: tmpdir.path().join("memory.md"),
169 notes_path: tmpdir.path().join("notes.txt"),
170 mcp_config_path: tmpdir.path().join("mcp.json"),
171 ..crate::test_support::test_tui_options(tmpdir.path())
172 };
173 App::new(options, &Config::default())
174 }
175
176 #[test]
177 fn test_queue_list_empty() {
178 let tmpdir = TempDir::new().unwrap();
179 let mut app = create_test_app_with_tmpdir(&tmpdir);
180 app.ui_locale = Locale::En;
181 let result = queue(&mut app, None);
182 assert!(result.message.is_some());
183 let msg = result.message.unwrap();
184 assert!(msg.contains(&*tr(app.ui_locale, MessageId::CmdQueueNoMessages)));
185 }
186
187 #[test]
188 fn test_queue_list_with_messages() {
189 let tmpdir = TempDir::new().unwrap();
190 let mut app = create_test_app_with_tmpdir(&tmpdir);
191 app.ui_locale = Locale::En;
192 app.queued_messages
193 .push_back(QueuedMessage::new("First message".to_string(), None));
194 app.queued_messages
195 .push_back(QueuedMessage::new("Second message".to_string(), None));
196 let result = queue(&mut app, Some("list"));
197 assert!(result.message.is_some());
198 let msg = result.message.unwrap();
199 assert!(
200 msg.contains(&tr(app.ui_locale, MessageId::CmdQueueListHeader).replace("{count}", "2"))
201 );
202 assert!(msg.contains("1. First message"));
203 assert!(msg.contains("2. Second message"));
204 }
205
206 #[test]
207 fn test_queue_edit_missing_index() {
208 let tmpdir = TempDir::new().unwrap();
209 let mut app = create_test_app_with_tmpdir(&tmpdir);
210 app.ui_locale = Locale::En;
211 app.queued_messages
212 .push_back(QueuedMessage::new("Test".to_string(), None));
213 let result = queue(&mut app, Some("edit"));
214 assert!(result.message.is_some());
215 let msg = result.message.unwrap();
216 assert!(
217 msg.contains(&*tr(Locale::En, MessageId::CmdQueueMissingIndex)),
218 "msg={msg:?}"
219 );
220 }
221
222 #[test]
223 fn test_queue_edit_invalid_index() {
224 let tmpdir = TempDir::new().unwrap();
225 let mut app = create_test_app_with_tmpdir(&tmpdir);
226 app.ui_locale = Locale::En;
227 let result = queue(&mut app, Some("edit abc"));
228 assert!(result.message.is_some());
229 let msg = result.message.unwrap();
230 assert!(
231 msg.contains(&*tr(Locale::En, MessageId::CmdQueueIndexPositive)),
232 "msg={msg:?}"
233 );
234 }
235
236 #[test]
237 fn test_queue_edit_not_found() {
238 let tmpdir = TempDir::new().unwrap();
239 let mut app = create_test_app_with_tmpdir(&tmpdir);
240 app.ui_locale = Locale::En;
241 let result = queue(&mut app, Some("edit 1"));
242 assert!(result.message.is_some());
243 let msg = result.message.unwrap();
244 assert!(
245 msg.contains(&*tr(Locale::En, MessageId::CmdQueueNotFound)),
246 "msg={msg:?}"
247 );
248 }
249
250 #[test]
251 fn test_queue_edit_already_editing() {
252 let tmpdir = TempDir::new().unwrap();
253 let mut app = create_test_app_with_tmpdir(&tmpdir);
254 app.ui_locale = Locale::En;
255 app.queued_messages
256 .push_back(QueuedMessage::new("First".to_string(), None));
257 app.queued_messages
258 .push_back(QueuedMessage::new("Second".to_string(), None));
259 // Start editing
260 queue(&mut app, Some("edit 1"));
261 // Try to edit another
262 let result = queue(&mut app, Some("edit 2"));
263 assert!(result.message.is_some());
264 let msg = result.message.unwrap();
265 assert!(
266 msg.contains(&*tr(Locale::En, MessageId::CmdQueueAlreadyEditing)),
267 "msg={msg:?}"
268 );
269 }
270
271 #[test]
272 fn test_queue_edit_success() {
273 let tmpdir = TempDir::new().unwrap();
274 let mut app = create_test_app_with_tmpdir(&tmpdir);
275 app.ui_locale = Locale::En;
276 app.queued_messages
277 .push_back(QueuedMessage::new("Original message".to_string(), None));
278 let result = queue(&mut app, Some("edit 1"));
279 assert!(result.message.is_some());
280 assert_eq!(app.input, "Original message");
281 assert_eq!(app.cursor_position, app.input.len());
282 assert!(app.queued_draft.is_some());
283 }
284
285 #[test]
286 fn test_queue_drop_success() {
287 let tmpdir = TempDir::new().unwrap();
288 let mut app = create_test_app_with_tmpdir(&tmpdir);
289 app.ui_locale = Locale::En;
290 app.queued_messages
291 .push_back(QueuedMessage::new("To drop".to_string(), None));
292 let initial_count = app.queued_messages.len();
293 let result = queue(&mut app, Some("drop 1"));
294 assert!(result.message.is_some());
295 let msg = result.message.unwrap();
296 assert!(
297 msg.contains(&tr(Locale::En, MessageId::CmdQueueDropped).replace("{index}", "1")),
298 "msg={msg:?}"
299 );
300 assert_eq!(app.queued_messages.len(), initial_count - 1);
301 }
302
303 #[test]
304 fn test_queue_clear() {
305 let tmpdir = TempDir::new().unwrap();
306 let mut app = create_test_app_with_tmpdir(&tmpdir);
307 app.ui_locale = Locale::En;
308 app.queued_messages
309 .push_back(QueuedMessage::new("Message 1".to_string(), None));
310 app.queued_messages
311 .push_back(QueuedMessage::new("Message 2".to_string(), None));
312 let result = queue(&mut app, Some("clear"));
313 assert!(result.message.is_some());
314 let msg = result.message.unwrap();
315 assert!(
316 msg.contains(&*tr(Locale::En, MessageId::CmdQueueCleared)),
317 "msg={msg:?}"
318 );
319 assert!(app.queued_messages.is_empty());
320 }
321
322 #[test]
323 fn test_queue_clear_already_empty() {
324 let tmpdir = TempDir::new().unwrap();
325 let mut app = create_test_app_with_tmpdir(&tmpdir);
326 app.ui_locale = Locale::En;
327 let result = queue(&mut app, Some("clear"));
328 assert!(result.message.is_some());
329 let msg = result.message.unwrap();
330 assert!(
331 msg.contains(&*tr(Locale::En, MessageId::CmdQueueAlreadyEmpty)),
332 "msg={msg:?}"
333 );
334 }
335
336 #[test]
337 fn queue_messages_are_localized() {
338 let tmpdir = TempDir::new().unwrap();
339 let mut app = create_test_app_with_tmpdir(&tmpdir);
340 app.ui_locale = Locale::ZhHans;
341 app.queued_messages
342 .push_back(QueuedMessage::new("M1".to_string(), None));
343 app.queued_messages
344 .push_back(QueuedMessage::new("M2".to_string(), None));
345 let result = queue(&mut app, Some("list"));
346 let msg = result.message.unwrap();
347 assert!(msg.contains("已排队的消息"), "zh list header: {msg}");
348 assert!(msg.contains("提示"), "zh tip: {msg}");
349 }
350
351 #[test]
352 fn test_truncate_preview_short_text() {
353 let result = truncate_preview("Short text");
354 assert_eq!(result, "Short text");
355 }
356
357 #[test]
358 fn test_truncate_preview_long_text() {
359 let long_text = "x".repeat(200);
360 let result = truncate_preview(&long_text);
361 assert!(result.len() <= PREVIEW_LIMIT + 3);
362 assert!(result.ends_with("..."));
363 }
364
365 #[test]
366 fn test_truncate_preview_unicode() {
367 let text = "Hello 世界 🌍";
368 let result = truncate_preview(text);
369 assert_eq!(result, text);
370 }
371 }
372
372 lines RUST