1pub mod action;
2pub mod app;
3pub mod client;
4pub mod desktop_manifest;
5pub mod input;
6pub mod keybindings;
7pub mod theme;
8pub mod ui;
9
10use app::{App, AttachmentOperation, ComposeAction, PendingSend};
11use client::Client;
12use crossterm::event::{Event, EventStream};
13use futures::StreamExt;
14use mxr_config::{load_config, socket_path as config_socket_path};
15use mxr_core::MxrError;
16use mxr_protocol::{DaemonEvent, Request, Response, ResponseData};
17use std::path::Path;
18use std::process::Stdio;
19use tokio::sync::{mpsc, oneshot};
20use tokio::time::{sleep, Duration, Instant};
21
22struct IpcRequest {
24 request: Request,
25 reply: oneshot::Sender<Result<Response, MxrError>>,
26}
27
28fn spawn_ipc_worker(
32 socket_path: std::path::PathBuf,
33 result_tx: mpsc::UnboundedSender<AsyncResult>,
34) -> mpsc::UnboundedSender<IpcRequest> {
35 let (tx, mut rx) = mpsc::unbounded_channel::<IpcRequest>();
36 tokio::spawn(async move {
37 let (event_tx, mut event_rx) = mpsc::unbounded_channel::<DaemonEvent>();
39 let mut client = match connect_ipc_client(&socket_path, event_tx.clone()).await {
40 Ok(client) => client,
41 Err(_) => return,
42 };
43
44 loop {
45 tokio::select! {
46 req = rx.recv() => {
47 match req {
48 Some(req) => {
49 let mut result = client.raw_request(req.request.clone()).await;
50 if should_reconnect_ipc(&result)
51 && request_supports_retry(&req.request)
52 {
53 match connect_ipc_client(&socket_path, event_tx.clone()).await {
54 Ok(mut reconnected) => {
55 let retry = reconnected.raw_request(req.request.clone()).await;
56 if retry.is_ok() {
57 client = reconnected;
58 }
59 result = retry;
60 }
61 Err(error) => {
62 result = Err(error);
63 }
64 }
65 }
66 let _ = req.reply.send(result);
67 }
68 None => break,
69 }
70 }
71 event = event_rx.recv() => {
72 if let Some(event) = event {
73 let _ = result_tx.send(AsyncResult::DaemonEvent(event));
74 }
75 }
76 }
77 }
78 });
79 tx
80}
81
82async fn connect_ipc_client(
83 socket_path: &std::path::Path,
84 event_tx: mpsc::UnboundedSender<DaemonEvent>,
85) -> Result<Client, MxrError> {
86 match Client::connect(socket_path).await {
87 Ok(client) => Ok(client.with_event_channel(event_tx)),
88 Err(error) if should_autostart_daemon(&error) => {
89 start_daemon_process(socket_path).await?;
90 wait_for_daemon_client(socket_path, START_DAEMON_TIMEOUT)
91 .await
92 .map(|client| client.with_event_channel(event_tx))
93 }
94 Err(error) => Err(MxrError::Ipc(error.to_string())),
95 }
96}
97
98fn should_reconnect_ipc(result: &Result<Response, MxrError>) -> bool {
99 match result {
100 Err(MxrError::Ipc(message)) => {
101 let lower = message.to_lowercase();
102 lower.contains("broken pipe")
103 || lower.contains("connection closed")
104 || lower.contains("connection refused")
105 || lower.contains("connection reset")
106 }
107 _ => false,
108 }
109}
110
111const START_DAEMON_TIMEOUT: Duration = Duration::from_secs(5);
112const START_DAEMON_POLL_INTERVAL: Duration = Duration::from_millis(100);
113
114fn should_autostart_daemon(error: &std::io::Error) -> bool {
115 matches!(
116 error.kind(),
117 std::io::ErrorKind::ConnectionRefused | std::io::ErrorKind::NotFound
118 )
119}
120
121async fn start_daemon_process(socket_path: &Path) -> Result<(), MxrError> {
122 let exe = std::env::current_exe()
123 .map_err(|error| MxrError::Ipc(format!("failed to locate mxr binary: {error}")))?;
124 std::process::Command::new(exe)
125 .arg("daemon")
126 .stdin(Stdio::null())
127 .stdout(Stdio::null())
128 .stderr(Stdio::null())
129 .spawn()
130 .map_err(|error| {
131 MxrError::Ipc(format!(
132 "failed to start daemon for {}: {error}",
133 socket_path.display()
134 ))
135 })?;
136 Ok(())
137}
138
139async fn wait_for_daemon_client(socket_path: &Path, timeout: Duration) -> Result<Client, MxrError> {
140 let deadline = Instant::now() + timeout;
141 let mut last_error: Option<MxrError> = None;
142
143 loop {
144 if Instant::now() >= deadline {
145 let detail =
146 last_error.unwrap_or_else(|| MxrError::Ipc("daemon did not become ready".into()));
147 return Err(MxrError::Ipc(format!(
148 "daemon restart did not become ready for {}: {}",
149 socket_path.display(),
150 detail
151 )));
152 }
153
154 match Client::connect(socket_path).await {
155 Ok(mut client) => match client.raw_request(Request::GetStatus).await {
156 Ok(_) => return Ok(client),
157 Err(error) => last_error = Some(error),
158 },
159 Err(error) => last_error = Some(MxrError::Ipc(error.to_string())),
160 }
161
162 sleep(START_DAEMON_POLL_INTERVAL).await;
163 }
164}
165
166fn request_supports_retry(request: &Request) -> bool {
167 matches!(
168 request,
169 Request::ListEnvelopes { .. }
170 | Request::ListEnvelopesByIds { .. }
171 | Request::GetEnvelope { .. }
172 | Request::GetBody { .. }
173 | Request::ListBodies { .. }
174 | Request::GetThread { .. }
175 | Request::ListLabels { .. }
176 | Request::ListRules
177 | Request::ListAccounts
178 | Request::ListAccountsConfig
179 | Request::GetRule { .. }
180 | Request::GetRuleForm { .. }
181 | Request::DryRunRules { .. }
182 | Request::ListEvents { .. }
183 | Request::GetLogs { .. }
184 | Request::GetDoctorReport
185 | Request::GenerateBugReport { .. }
186 | Request::ListRuleHistory { .. }
187 | Request::Search { .. }
188 | Request::GetSyncStatus { .. }
189 | Request::Count { .. }
190 | Request::GetHeaders { .. }
191 | Request::ListSavedSearches
192 | Request::ListSubscriptions { .. }
193 | Request::RunSavedSearch { .. }
194 | Request::ListSnoozed
195 | Request::PrepareReply { .. }
196 | Request::PrepareForward { .. }
197 | Request::ListDrafts
198 | Request::GetStatus
199 | Request::Ping
200 )
201}
202
203async fn ipc_call(
205 tx: &mpsc::UnboundedSender<IpcRequest>,
206 request: Request,
207) -> Result<Response, MxrError> {
208 let (reply_tx, reply_rx) = oneshot::channel();
209 tx.send(IpcRequest {
210 request,
211 reply: reply_tx,
212 })
213 .map_err(|_| MxrError::Ipc("IPC worker closed".into()))?;
214 reply_rx
215 .await
216 .map_err(|_| MxrError::Ipc("IPC worker dropped".into()))?
217}
218
219fn edit_tui_config(app: &mut App) -> Result<String, MxrError> {
220 let config_path = mxr_config::config_file_path();
221 let current_config = load_config().map_err(|error| MxrError::Ipc(error.to_string()))?;
222
223 if !config_path.exists() {
224 mxr_config::save_config(¤t_config)
225 .map_err(|error| MxrError::Ipc(error.to_string()))?;
226 }
227
228 let editor = mxr_compose::editor::resolve_editor(current_config.general.editor.as_deref());
229 let status = std::process::Command::new(&editor)
230 .arg(&config_path)
231 .status()
232 .map_err(|error| MxrError::Ipc(format!("failed to launch editor: {error}")))?;
233
234 if !status.success() {
235 return Ok("Config edit cancelled".into());
236 }
237
238 let reloaded = load_config().map_err(|error| MxrError::Ipc(error.to_string()))?;
239 app.apply_runtime_config(&reloaded);
240 app.accounts_page.refresh_pending = true;
241 app.pending_status_refresh = true;
242
243 Ok("Config reloaded. Restart daemon for account/provider changes.".into())
244}
245
246fn open_tui_log_file() -> Result<String, MxrError> {
247 let log_path = mxr_config::data_dir().join("logs").join("mxr.log");
248 if !log_path.exists() {
249 return Err(MxrError::Ipc(format!(
250 "log file not found at {}",
251 log_path.display()
252 )));
253 }
254
255 let editor = load_config()
256 .ok()
257 .and_then(|config| config.general.editor)
258 .map(|editor| mxr_compose::editor::resolve_editor(Some(editor.as_str())))
259 .unwrap_or_else(|| mxr_compose::editor::resolve_editor(None));
260 let status = std::process::Command::new(&editor)
261 .arg(&log_path)
262 .status()
263 .map_err(|error| MxrError::Ipc(format!("failed to launch editor: {error}")))?;
264
265 if !status.success() {
266 return Ok("Log open cancelled".into());
267 }
268
269 Ok(format!("Opened logs at {}", log_path.display()))
270}
271
272fn open_temp_text_buffer(name: &str, content: &str) -> Result<String, MxrError> {
273 let path = std::env::temp_dir().join(format!(
274 "mxr-{}-{}.txt",
275 name,
276 chrono::Utc::now().format("%Y%m%d-%H%M%S")
277 ));
278 std::fs::write(&path, content)
279 .map_err(|error| MxrError::Ipc(format!("failed to write temp file: {error}")))?;
280
281 let editor = load_config()
282 .ok()
283 .and_then(|config| config.general.editor)
284 .map(|editor| mxr_compose::editor::resolve_editor(Some(editor.as_str())))
285 .unwrap_or_else(|| mxr_compose::editor::resolve_editor(None));
286 let status = std::process::Command::new(&editor)
287 .arg(&path)
288 .status()
289 .map_err(|error| MxrError::Ipc(format!("failed to launch editor: {error}")))?;
290
291 if !status.success() {
292 return Ok(format!(
293 "Diagnostics detail open cancelled ({})",
294 path.display()
295 ));
296 }
297
298 Ok(format!("Opened diagnostics details at {}", path.display()))
299}
300
301fn open_diagnostics_pane_details(
302 state: &app::DiagnosticsPageState,
303 pane: app::DiagnosticsPaneKind,
304) -> Result<String, MxrError> {
305 if pane == app::DiagnosticsPaneKind::Logs {
306 return open_tui_log_file();
307 }
308
309 let name = match pane {
310 app::DiagnosticsPaneKind::Status => "doctor",
311 app::DiagnosticsPaneKind::Data => "storage",
312 app::DiagnosticsPaneKind::Sync => "sync-health",
313 app::DiagnosticsPaneKind::Events => "events",
314 app::DiagnosticsPaneKind::Logs => "logs",
315 };
316 let content = crate::ui::diagnostics_page::pane_details_text(state, pane);
317 open_temp_text_buffer(name, &content)
318}
319
320pub async fn run() -> anyhow::Result<()> {
321 let socket_path = daemon_socket_path();
322 let mut client = Client::connect(&socket_path).await?;
323 let config = load_config()?;
324
325 let mut app = App::from_config(&config);
326 if config.accounts.is_empty() {
327 app.accounts_page.refresh_pending = true;
328 } else {
329 app.load(&mut client).await?;
330 }
331
332 let mut terminal = ratatui::init();
333 let mut events = EventStream::new();
334
335 let (result_tx, mut result_rx) = mpsc::unbounded_channel::<AsyncResult>();
337
338 let bg = spawn_ipc_worker(socket_path, result_tx.clone());
340
341 loop {
342 if app.pending_config_edit {
343 app.pending_config_edit = false;
344 ratatui::restore();
345 let result = edit_tui_config(&mut app);
346 terminal = ratatui::init();
347 match result {
348 Ok(message) => {
349 app.status_message = Some(message);
350 }
351 Err(error) => {
352 app.error_modal = Some(app::ErrorModalState {
353 title: "Config Reload Failed".into(),
354 detail: format!(
355 "Config could not be reloaded after editing.\n\n{error}\n\nFix the file and run Edit Config again."
356 ),
357 });
358 app.status_message = Some(format!("Config reload failed: {error}"));
359 }
360 }
361 }
362 if app.pending_log_open {
363 app.pending_log_open = false;
364 ratatui::restore();
365 let result = open_tui_log_file();
366 terminal = ratatui::init();
367 match result {
368 Ok(message) => {
369 app.status_message = Some(message);
370 }
371 Err(error) => {
372 app.error_modal = Some(app::ErrorModalState {
373 title: "Open Logs Failed".into(),
374 detail: format!(
375 "The log file could not be opened.\n\n{error}\n\nCheck that the daemon has created the log file and try again."
376 ),
377 });
378 app.status_message = Some(format!("Open logs failed: {error}"));
379 }
380 }
381 }
382 if let Some(pane) = app.pending_diagnostics_details.take() {
383 ratatui::restore();
384 let result = open_diagnostics_pane_details(&app.diagnostics_page, pane);
385 terminal = ratatui::init();
386 match result {
387 Ok(message) => {
388 app.status_message = Some(message);
389 }
390 Err(error) => {
391 app.error_modal = Some(app::ErrorModalState {
392 title: "Diagnostics Open Failed".into(),
393 detail: format!(
394 "The diagnostics source could not be opened.\n\n{error}\n\nTry refresh first, then open details again."
395 ),
396 });
397 app.status_message = Some(format!("Open diagnostics failed: {error}"));
398 }
399 }
400 }
401
402 if !app.queued_body_fetches.is_empty() {
405 let ids = std::mem::take(&mut app.queued_body_fetches);
406 let bg = bg.clone();
407 let tx = result_tx.clone();
408 tokio::spawn(async move {
409 let requested = ids;
410 let resp = ipc_call(
411 &bg,
412 Request::ListBodies {
413 message_ids: requested.clone(),
414 },
415 )
416 .await;
417 let result = match resp {
418 Ok(Response::Ok {
419 data: ResponseData::Bodies { bodies },
420 }) => Ok(bodies),
421 Ok(Response::Error { message }) => Err(MxrError::Ipc(message)),
422 Err(e) => Err(e),
423 _ => Err(MxrError::Ipc("unexpected response".into())),
424 };
425 let _ = tx.send(AsyncResult::Bodies { requested, result });
426 });
427 }
428
429 if let Some(thread_id) = app.pending_thread_fetch.take() {
430 app.in_flight_thread_fetch = Some(thread_id.clone());
431 let bg = bg.clone();
432 let tx = result_tx.clone();
433 tokio::spawn(async move {
434 let resp = ipc_call(
435 &bg,
436 Request::GetThread {
437 thread_id: thread_id.clone(),
438 },
439 )
440 .await;
441 let result = match resp {
442 Ok(Response::Ok {
443 data: ResponseData::Thread { thread, messages },
444 }) => Ok((thread, messages)),
445 Ok(Response::Error { message }) => Err(MxrError::Ipc(message)),
446 Err(e) => Err(e),
447 _ => Err(MxrError::Ipc("unexpected response".into())),
448 };
449 let _ = tx.send(AsyncResult::Thread { thread_id, result });
450 });
451 }
452
453 terminal.draw(|frame| app.draw(frame))?;
454
455 let timeout = if app.input_pending() {
456 std::time::Duration::from_millis(500)
457 } else {
458 std::time::Duration::from_secs(60)
459 };
460 let timeout = app.next_background_timeout(timeout);
461
462 if let Some(pending) = app.pending_search.take() {
464 let bg = bg.clone();
465 let tx = result_tx.clone();
466 tokio::spawn(async move {
467 let query = pending.query.clone();
468 let target = pending.target;
469 let append = pending.append;
470 let session_id = pending.session_id;
471 let results = match ipc_call(
472 &bg,
473 Request::Search {
474 query,
475 limit: pending.limit,
476 offset: pending.offset,
477 mode: Some(pending.mode),
478 sort: Some(pending.sort),
479 explain: false,
480 },
481 )
482 .await
483 {
484 Ok(Response::Ok {
485 data:
486 ResponseData::SearchResults {
487 results, has_more, ..
488 },
489 }) => {
490 let mut scores = std::collections::HashMap::new();
491 let message_ids = results
492 .into_iter()
493 .map(|result| {
494 scores.insert(result.message_id.clone(), result.score);
495 result.message_id
496 })
497 .collect::<Vec<_>>();
498 if message_ids.is_empty() {
499 Ok(SearchResultData {
500 envelopes: Vec::new(),
501 scores,
502 has_more,
503 })
504 } else {
505 match ipc_call(&bg, Request::ListEnvelopesByIds { message_ids }).await {
506 Ok(Response::Ok {
507 data: ResponseData::Envelopes { envelopes },
508 }) => Ok(SearchResultData {
509 envelopes,
510 scores,
511 has_more,
512 }),
513 Ok(Response::Error { message }) => Err(MxrError::Ipc(message)),
514 Err(e) => Err(e),
515 _ => Err(MxrError::Ipc("unexpected response".into())),
516 }
517 }
518 }
519 Ok(Response::Error { message }) => Err(MxrError::Ipc(message)),
520 Err(e) => Err(e),
521 _ => Err(MxrError::Ipc("unexpected response".into())),
522 };
523 let _ = tx.send(AsyncResult::Search {
524 target,
525 append,
526 session_id,
527 result: results,
528 });
529 });
530 }
531
532 if let Some(pending) = app.pending_unsubscribe_action.take() {
533 let bg = bg.clone();
534 let tx = result_tx.clone();
535 tokio::spawn(async move {
536 let unsubscribe_resp = ipc_call(
537 &bg,
538 Request::Unsubscribe {
539 message_id: pending.message_id.clone(),
540 },
541 )
542 .await;
543 let unsubscribe_result = match unsubscribe_resp {
544 Ok(Response::Ok {
545 data: ResponseData::Ack,
546 }) => Ok(()),
547 Ok(Response::Error { message }) => Err(MxrError::Ipc(message)),
548 Err(error) => Err(error),
549 _ => Err(MxrError::Ipc("unexpected response".into())),
550 };
551
552 let result = match unsubscribe_result {
553 Ok(()) if pending.archive_message_ids.is_empty() => Ok(UnsubscribeResultData {
554 archived_ids: Vec::new(),
555 message: format!("Unsubscribed from {}", pending.sender_email),
556 }),
557 Ok(()) => {
558 let archived_count = pending.archive_message_ids.len();
559 let archive_resp = ipc_call(
560 &bg,
561 Request::Mutation(mxr_protocol::MutationCommand::Archive {
562 message_ids: pending.archive_message_ids.clone(),
563 }),
564 )
565 .await;
566 match archive_resp {
567 Ok(Response::Ok {
568 data: ResponseData::Ack,
569 }) => Ok(UnsubscribeResultData {
570 archived_ids: pending.archive_message_ids,
571 message: format!(
572 "Unsubscribed and archived {} messages from {}",
573 archived_count, pending.sender_email
574 ),
575 }),
576 Ok(Response::Error { message }) => Err(MxrError::Ipc(message)),
577 Err(error) => Err(error),
578 _ => Err(MxrError::Ipc("unexpected response".into())),
579 }
580 }
581 Err(error) => Err(error),
582 };
583 let _ = tx.send(AsyncResult::Unsubscribe(result));
584 });
585 }
586
587 if app.rules_page.refresh_pending {
588 app.rules_page.refresh_pending = false;
589 let bg = bg.clone();
590 let tx = result_tx.clone();
591 tokio::spawn(async move {
592 let resp = ipc_call(&bg, Request::ListRules).await;
593 let result = match resp {
594 Ok(Response::Ok {
595 data: ResponseData::Rules { rules },
596 }) => Ok(rules),
597 Ok(Response::Error { message }) => Err(MxrError::Ipc(message)),
598 Err(e) => Err(e),
599 _ => Err(MxrError::Ipc("unexpected response".into())),
600 };
601 let _ = tx.send(AsyncResult::Rules(result));
602 });
603 }
604
605 if let Some(rule) = app.pending_rule_detail.take() {
606 let bg = bg.clone();
607 let tx = result_tx.clone();
608 tokio::spawn(async move {
609 let resp = ipc_call(&bg, Request::GetRule { rule }).await;
610 let result = match resp {
611 Ok(Response::Ok {
612 data: ResponseData::RuleData { rule },
613 }) => Ok(rule),
614 Ok(Response::Error { message }) => Err(MxrError::Ipc(message)),
615 Err(e) => Err(e),
616 _ => Err(MxrError::Ipc("unexpected response".into())),
617 };
618 let _ = tx.send(AsyncResult::RuleDetail(result));
619 });
620 }
621
622 if let Some(rule) = app.pending_rule_history.take() {
623 let bg = bg.clone();
624 let tx = result_tx.clone();
625 tokio::spawn(async move {
626 let resp = ipc_call(
627 &bg,
628 Request::ListRuleHistory {
629 rule: Some(rule),
630 limit: 20,
631 },
632 )
633 .await;
634 let result = match resp {
635 Ok(Response::Ok {
636 data: ResponseData::RuleHistory { entries },
637 }) => Ok(entries),
638 Ok(Response::Error { message }) => Err(MxrError::Ipc(message)),
639 Err(e) => Err(e),
640 _ => Err(MxrError::Ipc("unexpected response".into())),
641 };
642 let _ = tx.send(AsyncResult::RuleHistory(result));
643 });
644 }
645
646 if let Some(rule) = app.pending_rule_dry_run.take() {
647 let bg = bg.clone();
648 let tx = result_tx.clone();
649 tokio::spawn(async move {
650 let resp = ipc_call(
651 &bg,
652 Request::DryRunRules {
653 rule: Some(rule),
654 all: false,
655 after: None,
656 },
657 )
658 .await;
659 let result = match resp {
660 Ok(Response::Ok {
661 data: ResponseData::RuleDryRun { results },
662 }) => Ok(results),
663 Ok(Response::Error { message }) => Err(MxrError::Ipc(message)),
664 Err(e) => Err(e),
665 _ => Err(MxrError::Ipc("unexpected response".into())),
666 };
667 let _ = tx.send(AsyncResult::RuleDryRun(result));
668 });
669 }
670
671 if let Some(rule) = app.pending_rule_form_load.take() {
672 let bg = bg.clone();
673 let tx = result_tx.clone();
674 tokio::spawn(async move {
675 let resp = ipc_call(&bg, Request::GetRuleForm { rule }).await;
676 let result = match resp {
677 Ok(Response::Ok {
678 data: ResponseData::RuleFormData { form },
679 }) => Ok(form),
680 Ok(Response::Error { message }) => Err(MxrError::Ipc(message)),
681 Err(e) => Err(e),
682 _ => Err(MxrError::Ipc("unexpected response".into())),
683 };
684 let _ = tx.send(AsyncResult::RuleForm(result));
685 });
686 }
687
688 if let Some(rule) = app.pending_rule_delete.take() {
689 let bg = bg.clone();
690 let tx = result_tx.clone();
691 tokio::spawn(async move {
692 let resp = ipc_call(&bg, Request::DeleteRule { rule }).await;
693 let result = match resp {
694 Ok(Response::Ok { .. }) => Ok(()),
695 Ok(Response::Error { message }) => Err(MxrError::Ipc(message)),
696 Err(e) => Err(e),
697 };
698 let _ = tx.send(AsyncResult::RuleDeleted(result));
699 });
700 }
701
702 if let Some(rule) = app.pending_rule_upsert.take() {
703 let bg = bg.clone();
704 let tx = result_tx.clone();
705 tokio::spawn(async move {
706 let resp = ipc_call(&bg, Request::UpsertRule { rule }).await;
707 let result = match resp {
708 Ok(Response::Ok {
709 data: ResponseData::RuleData { rule },
710 }) => Ok(rule),
711 Ok(Response::Error { message }) => Err(MxrError::Ipc(message)),
712 Err(e) => Err(e),
713 _ => Err(MxrError::Ipc("unexpected response".into())),
714 };
715 let _ = tx.send(AsyncResult::RuleUpsert(result));
716 });
717 }
718
719 if app.pending_rule_form_save {
720 app.pending_rule_form_save = false;
721 let bg = bg.clone();
722 let tx = result_tx.clone();
723 let existing_rule = app.rules_page.form.existing_rule.clone();
724 let name = app.rules_page.form.name.clone();
725 let condition = app.rules_page.form.condition.clone();
726 let action = app.rules_page.form.action.clone();
727 let priority = app.rules_page.form.priority.parse::<i32>().unwrap_or(100);
728 let enabled = app.rules_page.form.enabled;
729 tokio::spawn(async move {
730 let resp = ipc_call(
731 &bg,
732 Request::UpsertRuleForm {
733 existing_rule,
734 name,
735 condition,
736 action,
737 priority,
738 enabled,
739 },
740 )
741 .await;
742 let result = match resp {
743 Ok(Response::Ok {
744 data: ResponseData::RuleData { rule },
745 }) => Ok(rule),
746 Ok(Response::Error { message }) => Err(MxrError::Ipc(message)),
747 Err(e) => Err(e),
748 _ => Err(MxrError::Ipc("unexpected response".into())),
749 };
750 let _ = tx.send(AsyncResult::RuleUpsert(result));
751 });
752 }
753
754 if app.diagnostics_page.refresh_pending {
755 app.diagnostics_page.refresh_pending = false;
756 app.pending_status_refresh = false;
757 app.diagnostics_page.pending_requests = 4;
758 for request in [
759 Request::GetStatus,
760 Request::GetDoctorReport,
761 Request::ListEvents {
762 limit: 20,
763 level: None,
764 category: None,
765 },
766 Request::GetLogs {
767 limit: 50,
768 level: None,
769 },
770 ] {
771 let bg = bg.clone();
772 let tx = result_tx.clone();
773 tokio::spawn(async move {
774 let resp = ipc_call(&bg, request).await;
775 let _ = tx.send(AsyncResult::Diagnostics(Box::new(resp)));
776 });
777 }
778 }
779
780 if app.pending_status_refresh {
781 app.pending_status_refresh = false;
782 let bg = bg.clone();
783 let tx = result_tx.clone();
784 tokio::spawn(async move {
785 let resp = ipc_call(&bg, Request::GetStatus).await;
786 let result = match resp {
787 Ok(Response::Ok {
788 data:
789 ResponseData::Status {
790 uptime_secs,
791 daemon_pid,
792 accounts,
793 total_messages,
794 sync_statuses,
795 ..
796 },
797 }) => Ok(StatusSnapshot {
798 uptime_secs,
799 daemon_pid,
800 accounts,
801 total_messages,
802 sync_statuses,
803 }),
804 Ok(Response::Error { message }) => Err(MxrError::Ipc(message)),
805 Err(e) => Err(e),
806 _ => Err(MxrError::Ipc("unexpected response".into())),
807 };
808 let _ = tx.send(AsyncResult::Status(result));
809 });
810 }
811
812 if app.accounts_page.refresh_pending {
813 app.accounts_page.refresh_pending = false;
814 let bg = bg.clone();
815 let tx = result_tx.clone();
816 tokio::spawn(async move {
817 let result = load_accounts_page_accounts(&bg).await;
818 let _ = tx.send(AsyncResult::Accounts(result));
819 });
820 }
821
822 if app.pending_labels_refresh {
823 app.pending_labels_refresh = false;
824 let bg = bg.clone();
825 let tx = result_tx.clone();
826 tokio::spawn(async move {
827 let resp = ipc_call(&bg, Request::ListLabels { account_id: None }).await;
828 let result = match resp {
829 Ok(Response::Ok {
830 data: ResponseData::Labels { labels },
831 }) => Ok(labels),
832 Ok(Response::Error { message }) => Err(MxrError::Ipc(message)),
833 Err(e) => Err(e),
834 _ => Err(MxrError::Ipc("unexpected response".into())),
835 };
836 let _ = tx.send(AsyncResult::Labels(result));
837 });
838 }
839
840 if app.pending_all_envelopes_refresh {
841 app.pending_all_envelopes_refresh = false;
842 let bg = bg.clone();
843 let tx = result_tx.clone();
844 tokio::spawn(async move {
845 let resp = ipc_call(
846 &bg,
847 Request::ListEnvelopes {
848 label_id: None,
849 account_id: None,
850 limit: 5000,
851 offset: 0,
852 },
853 )
854 .await;
855 let result = match resp {
856 Ok(Response::Ok {
857 data: ResponseData::Envelopes { envelopes },
858 }) => Ok(envelopes),
859 Ok(Response::Error { message }) => Err(MxrError::Ipc(message)),
860 Err(e) => Err(e),
861 _ => Err(MxrError::Ipc("unexpected response".into())),
862 };
863 let _ = tx.send(AsyncResult::AllEnvelopes(result));
864 });
865 }
866
867 if app.pending_subscriptions_refresh {
868 app.pending_subscriptions_refresh = false;
869 let bg = bg.clone();
870 let tx = result_tx.clone();
871 tokio::spawn(async move {
872 let resp = ipc_call(&bg, Request::ListSubscriptions { limit: 500 }).await;
873 let result = match resp {
874 Ok(Response::Ok {
875 data: ResponseData::Subscriptions { subscriptions },
876 }) => Ok(subscriptions),
877 Ok(Response::Error { message }) => Err(MxrError::Ipc(message)),
878 Err(e) => Err(e),
879 _ => Err(MxrError::Ipc("unexpected response".into())),
880 };
881 let _ = tx.send(AsyncResult::Subscriptions(result));
882 });
883 }
884
885 if let Some(account) = app.pending_account_save.take() {
886 let bg = bg.clone();
887 let tx = result_tx.clone();
888 tokio::spawn(async move {
889 let result = run_account_save_workflow(&bg, account).await;
890 let _ = tx.send(AsyncResult::AccountOperation(result));
891 });
892 }
893
894 if let Some(account) = app.pending_account_test.take() {
895 let bg = bg.clone();
896 let tx = result_tx.clone();
897 tokio::spawn(async move {
898 let result =
899 request_account_operation(&bg, Request::TestAccountConfig { account }).await;
900 let _ = tx.send(AsyncResult::AccountOperation(result));
901 });
902 }
903
904 if let Some((account, reauthorize)) = app.pending_account_authorize.take() {
905 let bg = bg.clone();
906 let tx = result_tx.clone();
907 tokio::spawn(async move {
908 let result = request_account_operation(
909 &bg,
910 Request::AuthorizeAccountConfig {
911 account,
912 reauthorize,
913 },
914 )
915 .await;
916 let _ = tx.send(AsyncResult::AccountOperation(result));
917 });
918 }
919
920 if let Some(key) = app.pending_account_set_default.take() {
921 let bg = bg.clone();
922 let tx = result_tx.clone();
923 tokio::spawn(async move {
924 let result =
925 request_account_operation(&bg, Request::SetDefaultAccount { key }).await;
926 let _ = tx.send(AsyncResult::AccountOperation(result));
927 });
928 }
929
930 if app.pending_bug_report {
931 app.pending_bug_report = false;
932 let bg = bg.clone();
933 let tx = result_tx.clone();
934 tokio::spawn(async move {
935 let resp = ipc_call(
936 &bg,
937 Request::GenerateBugReport {
938 verbose: false,
939 full_logs: false,
940 since: None,
941 },
942 )
943 .await;
944 let result = match resp {
945 Ok(Response::Ok {
946 data: ResponseData::BugReport { content },
947 }) => Ok(content),
948 Ok(Response::Error { message }) => Err(MxrError::Ipc(message)),
949 Err(e) => Err(e),
950 _ => Err(MxrError::Ipc("unexpected response".into())),
951 };
952 let _ = tx.send(AsyncResult::BugReport(result));
953 });
954 }
955
956 if let Some(pending) = app.pending_attachment_action.take() {
957 let bg = bg.clone();
958 let tx = result_tx.clone();
959 tokio::spawn(async move {
960 let request = match pending.operation {
961 AttachmentOperation::Open => Request::OpenAttachment {
962 message_id: pending.message_id,
963 attachment_id: pending.attachment_id,
964 },
965 AttachmentOperation::Download => Request::DownloadAttachment {
966 message_id: pending.message_id,
967 attachment_id: pending.attachment_id,
968 },
969 };
970 let resp = ipc_call(&bg, request).await;
971 let result = match resp {
972 Ok(Response::Ok {
973 data: ResponseData::AttachmentFile { file },
974 }) => Ok(file),
975 Ok(Response::Error { message }) => Err(MxrError::Ipc(message)),
976 Err(e) => Err(e),
977 _ => Err(MxrError::Ipc("unexpected response".into())),
978 };
979 let _ = tx.send(AsyncResult::AttachmentFile {
980 operation: pending.operation,
981 result,
982 });
983 });
984 }
985
986 if let Some(label_id) = app.pending_label_fetch.take() {
988 let bg = bg.clone();
989 let tx = result_tx.clone();
990 tokio::spawn(async move {
991 let resp = ipc_call(
992 &bg,
993 Request::ListEnvelopes {
994 label_id: Some(label_id),
995 account_id: None,
996 limit: 5000,
997 offset: 0,
998 },
999 )
1000 .await;
1001 let envelopes = match resp {
1002 Ok(Response::Ok {
1003 data: ResponseData::Envelopes { envelopes },
1004 }) => Ok(envelopes),
1005 Ok(Response::Error { message }) => Err(MxrError::Ipc(message)),
1006 Err(e) => Err(e),
1007 _ => Err(MxrError::Ipc("unexpected response".into())),
1008 };
1009 let _ = tx.send(AsyncResult::LabelEnvelopes(envelopes));
1010 });
1011 }
1012
1013 for (req, effect) in app.pending_mutation_queue.drain(..) {
1015 let bg = bg.clone();
1016 let tx = result_tx.clone();
1017 tokio::spawn(async move {
1018 let resp = ipc_call(&bg, req).await;
1019 let result = match resp {
1020 Ok(Response::Ok {
1021 data: ResponseData::Ack,
1022 }) => Ok(effect),
1023 Ok(Response::Error { message }) => Err(MxrError::Ipc(message)),
1024 Err(e) => Err(e),
1025 _ => Err(MxrError::Ipc("unexpected response".into())),
1026 };
1027 let _ = tx.send(AsyncResult::MutationResult(result));
1028 });
1029 }
1030
1031 if let Some(thread_id) = app.pending_export_thread.take() {
1033 let bg = bg.clone();
1034 let tx = result_tx.clone();
1035 tokio::spawn(async move {
1036 let resp = ipc_call(
1037 &bg,
1038 Request::ExportThread {
1039 thread_id,
1040 format: mxr_core::types::ExportFormat::Markdown,
1041 },
1042 )
1043 .await;
1044 let result = match resp {
1045 Ok(Response::Ok {
1046 data: ResponseData::ExportResult { content },
1047 }) => {
1048 let filename = format!(
1050 "mxr-export-{}.md",
1051 chrono::Utc::now().format("%Y%m%d-%H%M%S")
1052 );
1053 let path = std::env::temp_dir().join(&filename);
1054 match std::fs::write(&path, &content) {
1055 Ok(()) => Ok(format!("Exported to {}", path.display())),
1056 Err(e) => Err(MxrError::Ipc(format!("Write failed: {e}"))),
1057 }
1058 }
1059 Ok(Response::Error { message }) => Err(MxrError::Ipc(message)),
1060 Err(e) => Err(e),
1061 _ => Err(MxrError::Ipc("unexpected response".into())),
1062 };
1063 let _ = tx.send(AsyncResult::ExportResult(result));
1064 });
1065 }
1066
1067 if let Some(compose_action) = app.pending_compose.take() {
1069 let bg = bg.clone();
1070 let tx = result_tx.clone();
1071 tokio::spawn(async move {
1072 let result = handle_compose_action(&bg, compose_action).await;
1073 let _ = tx.send(AsyncResult::ComposeReady(result));
1074 });
1075 }
1076
1077 tokio::select! {
1078 event = events.next() => {
1079 if let Some(Ok(Event::Key(key))) = event {
1080 if let Some(action) = app.handle_key(key) {
1081 app.apply(action);
1082 }
1083 }
1084 }
1085 result = result_rx.recv() => {
1086 if let Some(msg) = result {
1087 match msg {
1088 AsyncResult::Search {
1089 target,
1090 append,
1091 session_id,
1092 result: Ok(results),
1093 } => match target {
1094 app::SearchTarget::SearchPage => {
1095 if session_id != app.search_page.session_id {
1096 continue;
1097 }
1098
1099 if append {
1100 app.search_page.results.extend(results.envelopes);
1101 app.search_page.scores.extend(results.scores);
1102 } else {
1103 app.search_page.results = results.envelopes;
1104 app.search_page.scores = results.scores;
1105 app.search_page.selected_index = 0;
1106 app.search_page.scroll_offset = 0;
1107 }
1108
1109 app.search_page.has_more = results.has_more;
1110 app.search_page.loading_more = false;
1111 app.search_page.session_active =
1112 !app.search_page.query.is_empty()
1113 || !app.search_page.results.is_empty();
1114
1115 if app.search_page.load_to_end {
1116 if app.search_page.has_more {
1117 app.load_more_search_results();
1118 } else {
1119 app.search_page.load_to_end = false;
1120 if app.search_row_count() > 0 {
1121 app.search_page.selected_index =
1122 app.search_row_count() - 1;
1123 app.ensure_search_visible();
1124 app.auto_preview_search();
1125 }
1126 }
1127 } else {
1128 app.search_page.selected_index = app
1129 .search_page
1130 .selected_index
1131 .min(app.search_row_count().saturating_sub(1));
1132 if app.screen == app::Screen::Search {
1133 app.ensure_search_visible();
1134 app.auto_preview_search();
1135 }
1136 }
1137 }
1138 app::SearchTarget::Mailbox => {
1139 if session_id != app.mailbox_search_session_id {
1140 continue;
1141 }
1142 app.envelopes = results.envelopes;
1143 app.selected_index = 0;
1144 app.scroll_offset = 0;
1145 }
1146 },
1147 AsyncResult::Search {
1148 target,
1149 append: _,
1150 session_id,
1151 result: Err(error),
1152 } => {
1153 match target {
1154 app::SearchTarget::SearchPage => {
1155 if session_id != app.search_page.session_id {
1156 continue;
1157 }
1158 app.search_page.loading_more = false;
1159 app.search_page.load_to_end = false;
1160 }
1161 app::SearchTarget::Mailbox => {
1162 if session_id != app.mailbox_search_session_id {
1163 continue;
1164 }
1165 app.envelopes = app.all_envelopes.clone();
1166 }
1167 }
1168 app.status_message = Some(format!("Search failed: {error}"));
1169 }
1170 AsyncResult::Rules(Ok(rules)) => {
1171 app.rules_page.rules = rules;
1172 app.rules_page.selected_index = app
1173 .rules_page
1174 .selected_index
1175 .min(app.rules_page.rules.len().saturating_sub(1));
1176 if let Some(rule_id) = app
1177 .selected_rule()
1178 .and_then(|rule| rule["id"].as_str())
1179 .map(ToString::to_string)
1180 {
1181 app.pending_rule_detail = Some(rule_id);
1182 }
1183 }
1184 AsyncResult::Rules(Err(e)) => {
1185 app.rules_page.status = Some(format!("Rules error: {e}"));
1186 }
1187 AsyncResult::RuleDetail(Ok(rule)) => {
1188 app.rules_page.detail = Some(rule);
1189 app.rules_page.panel = app::RulesPanel::Details;
1190 }
1191 AsyncResult::RuleDetail(Err(e)) => {
1192 app.rules_page.status = Some(format!("Rule error: {e}"));
1193 }
1194 AsyncResult::RuleHistory(Ok(entries)) => {
1195 app.rules_page.history = entries;
1196 }
1197 AsyncResult::RuleHistory(Err(e)) => {
1198 app.rules_page.status = Some(format!("History error: {e}"));
1199 }
1200 AsyncResult::RuleDryRun(Ok(results)) => {
1201 app.rules_page.dry_run = results;
1202 }
1203 AsyncResult::RuleDryRun(Err(e)) => {
1204 app.rules_page.status = Some(format!("Dry-run error: {e}"));
1205 }
1206 AsyncResult::RuleForm(Ok(form)) => {
1207 app.rules_page.form.visible = true;
1208 app.rules_page.form.existing_rule = form.id;
1209 app.rules_page.form.name = form.name;
1210 app.rules_page.form.condition = form.condition;
1211 app.rules_page.form.action = form.action;
1212 app.rules_page.form.priority = form.priority.to_string();
1213 app.rules_page.form.enabled = form.enabled;
1214 app.rules_page.form.active_field = 0;
1215 app.rules_page.panel = app::RulesPanel::Form;
1216 }
1217 AsyncResult::RuleForm(Err(e)) => {
1218 app.rules_page.status = Some(format!("Form error: {e}"));
1219 }
1220 AsyncResult::RuleDeleted(Ok(())) => {
1221 app.rules_page.status = Some("Rule deleted".into());
1222 app.rules_page.refresh_pending = true;
1223 }
1224 AsyncResult::RuleDeleted(Err(e)) => {
1225 app.rules_page.status = Some(format!("Delete error: {e}"));
1226 }
1227 AsyncResult::RuleUpsert(Ok(rule)) => {
1228 app.rules_page.detail = Some(rule.clone());
1229 app.rules_page.form.visible = false;
1230 app.rules_page.panel = app::RulesPanel::Details;
1231 app.rules_page.status = Some("Rule saved".into());
1232 app.rules_page.refresh_pending = true;
1233 }
1234 AsyncResult::RuleUpsert(Err(e)) => {
1235 app.rules_page.status = Some(format!("Save error: {e}"));
1236 }
1237 AsyncResult::Diagnostics(result) => {
1238 app.diagnostics_page.pending_requests =
1239 app.diagnostics_page.pending_requests.saturating_sub(1);
1240 match *result {
1241 Ok(response) => match response {
1242 Response::Ok {
1243 data:
1244 ResponseData::Status {
1245 uptime_secs,
1246 daemon_pid,
1247 accounts,
1248 total_messages,
1249 sync_statuses,
1250 ..
1251 },
1252 } => {
1253 app.apply_status_snapshot(
1254 uptime_secs,
1255 daemon_pid,
1256 accounts,
1257 total_messages,
1258 sync_statuses,
1259 );
1260 }
1261 Response::Ok {
1262 data: ResponseData::DoctorReport { report },
1263 } => {
1264 app.diagnostics_page.doctor = Some(report);
1265 }
1266 Response::Ok {
1267 data: ResponseData::EventLogEntries { entries },
1268 } => {
1269 app.diagnostics_page.events = entries;
1270 }
1271 Response::Ok {
1272 data: ResponseData::LogLines { lines },
1273 } => {
1274 app.diagnostics_page.logs = lines;
1275 }
1276 Response::Error { message } => {
1277 app.diagnostics_page.status = Some(message);
1278 }
1279 _ => {}
1280 },
1281 Err(e) => {
1282 app.diagnostics_page.status =
1283 Some(format!("Diagnostics error: {e}"));
1284 }
1285 }
1286 }
1287 AsyncResult::Status(Ok(snapshot)) => {
1288 app.apply_status_snapshot(
1289 snapshot.uptime_secs,
1290 snapshot.daemon_pid,
1291 snapshot.accounts,
1292 snapshot.total_messages,
1293 snapshot.sync_statuses,
1294 );
1295 }
1296 AsyncResult::Status(Err(e)) => {
1297 app.status_message = Some(format!("Status refresh failed: {e}"));
1298 }
1299 AsyncResult::Accounts(Ok(accounts)) => {
1300 app.accounts_page.accounts = accounts;
1301 app.accounts_page.selected_index = app
1302 .accounts_page
1303 .selected_index
1304 .min(app.accounts_page.accounts.len().saturating_sub(1));
1305 if app.accounts_page.accounts.is_empty() {
1306 app.accounts_page.onboarding_required = true;
1307 } else {
1308 app.accounts_page.onboarding_required = false;
1309 app.accounts_page.onboarding_modal_open = false;
1310 }
1311 }
1312 AsyncResult::Accounts(Err(e)) => {
1313 app.accounts_page.status = Some(format!("Accounts error: {e}"));
1314 }
1315 AsyncResult::Labels(Ok(labels)) => {
1316 app.labels = labels;
1317 app.resolve_desired_system_mailbox();
1318 }
1319 AsyncResult::Labels(Err(e)) => {
1320 app.status_message = Some(format!("Label refresh failed: {e}"));
1321 }
1322 AsyncResult::AllEnvelopes(Ok(envelopes)) => {
1323 apply_all_envelopes_refresh(&mut app, envelopes);
1324 }
1325 AsyncResult::AllEnvelopes(Err(e)) => {
1326 app.status_message =
1327 Some(format!("Mailbox refresh failed: {e}"));
1328 }
1329 AsyncResult::AccountOperation(Ok(result)) => {
1330 app.accounts_page.status = Some(result.summary.clone());
1331 app.accounts_page.last_result = Some(result.clone());
1332 app.accounts_page.form.last_result = Some(result.clone());
1333 app.accounts_page.form.gmail_authorized = result
1334 .auth
1335 .as_ref()
1336 .map(|step| step.ok)
1337 .unwrap_or(app.accounts_page.form.gmail_authorized);
1338 if result.save.as_ref().is_some_and(|step| step.ok) {
1339 app.accounts_page.form.visible = false;
1340 }
1341 app.accounts_page.refresh_pending = true;
1342 }
1343 AsyncResult::AccountOperation(Err(e)) => {
1344 app.accounts_page.status = Some(format!("Account error: {e}"));
1345 }
1346 AsyncResult::BugReport(Ok(content)) => {
1347 let filename = format!(
1348 "mxr-bug-report-{}.md",
1349 chrono::Utc::now().format("%Y%m%d-%H%M%S")
1350 );
1351 let path = std::env::temp_dir().join(filename);
1352 match std::fs::write(&path, &content) {
1353 Ok(()) => {
1354 app.diagnostics_page.status =
1355 Some(format!("Bug report saved to {}", path.display()));
1356 }
1357 Err(e) => {
1358 app.diagnostics_page.status =
1359 Some(format!("Bug report write failed: {e}"));
1360 }
1361 }
1362 }
1363 AsyncResult::BugReport(Err(e)) => {
1364 app.diagnostics_page.status = Some(format!("Bug report error: {e}"));
1365 }
1366 AsyncResult::AttachmentFile {
1367 operation,
1368 result: Ok(file),
1369 } => {
1370 app.resolve_attachment_file(&file);
1371 let action = match operation {
1372 AttachmentOperation::Open => "Opened",
1373 AttachmentOperation::Download => "Downloaded",
1374 };
1375 let message = format!("{action} {} -> {}", file.filename, file.path);
1376 app.attachment_panel.status = Some(message.clone());
1377 app.status_message = Some(message);
1378 }
1379 AsyncResult::AttachmentFile {
1380 result: Err(e), ..
1381 } => {
1382 let message = format!("Attachment error: {e}");
1383 app.attachment_panel.status = Some(message.clone());
1384 app.status_message = Some(message);
1385 }
1386 AsyncResult::LabelEnvelopes(Ok(envelopes)) => {
1387 let selected_id =
1388 app.selected_mail_row().map(|row| row.representative.id);
1389 app.envelopes = envelopes;
1390 app.active_label = app.pending_active_label.take();
1391 restore_mail_list_selection(&mut app, selected_id);
1392 app.queue_body_window();
1393 }
1394 AsyncResult::LabelEnvelopes(Err(e)) => {
1395 app.pending_active_label = None;
1396 app.status_message = Some(format!("Label filter failed: {e}"));
1397 }
1398 AsyncResult::Bodies { requested, result: Ok(bodies) } => {
1399 let mut returned = std::collections::HashSet::new();
1400 for body in bodies {
1401 returned.insert(body.message_id.clone());
1402 app.resolve_body_success(body);
1403 }
1404 for message_id in requested {
1405 if !returned.contains(&message_id) {
1406 app.resolve_body_fetch_error(
1407 &message_id,
1408 "body not available".into(),
1409 );
1410 }
1411 }
1412 }
1413 AsyncResult::Bodies { requested, result: Err(e) } => {
1414 let message = e.to_string();
1415 for message_id in requested {
1416 app.resolve_body_fetch_error(&message_id, message.clone());
1417 }
1418 }
1419 AsyncResult::Thread {
1420 thread_id,
1421 result: Ok((thread, messages)),
1422 } => {
1423 app.resolve_thread_success(thread, messages);
1424 let _ = thread_id;
1425 }
1426 AsyncResult::Thread {
1427 thread_id,
1428 result: Err(_),
1429 } => {
1430 app.resolve_thread_fetch_error(&thread_id);
1431 }
1432 AsyncResult::MutationResult(Ok(effect)) => {
1433 app.finish_pending_mutation();
1434 let show_completion_status = app.pending_mutation_count == 0;
1435 match effect {
1436 app::MutationEffect::RemoveFromList(id) => {
1437 app.apply_removed_message_ids(std::slice::from_ref(&id));
1438 if show_completion_status {
1439 app.status_message = Some("Done".into());
1440 }
1441 app.pending_subscriptions_refresh = true;
1442 }
1443 app::MutationEffect::RemoveFromListMany(ids) => {
1444 app.apply_removed_message_ids(&ids);
1445 if show_completion_status {
1446 app.status_message = Some("Done".into());
1447 }
1448 app.pending_subscriptions_refresh = true;
1449 }
1450 app::MutationEffect::UpdateFlags { message_id, flags } => {
1451 app.apply_local_flags(&message_id, flags);
1452 if show_completion_status {
1453 app.status_message = Some("Done".into());
1454 }
1455 }
1456 app::MutationEffect::UpdateFlagsMany { updates } => {
1457 app.apply_local_flags_many(&updates);
1458 if show_completion_status {
1459 app.status_message = Some("Done".into());
1460 }
1461 }
1462 app::MutationEffect::RefreshList => {
1463 if let Some(label_id) = app.active_label.clone() {
1464 app.pending_label_fetch = Some(label_id);
1465 }
1466 app.pending_subscriptions_refresh = true;
1467 if show_completion_status {
1468 app.status_message = Some("Synced".into());
1469 }
1470 }
1471 app::MutationEffect::ModifyLabels {
1472 message_ids,
1473 add,
1474 remove,
1475 status,
1476 } => {
1477 app.apply_local_label_refs(&message_ids, &add, &remove);
1478 if show_completion_status {
1479 app.status_message = Some(status);
1480 }
1481 }
1482 app::MutationEffect::StatusOnly(msg) => {
1483 if show_completion_status {
1484 app.status_message = Some(msg);
1485 }
1486 }
1487 }
1488 }
1489 AsyncResult::MutationResult(Err(e)) => {
1490 app.finish_pending_mutation();
1491 app.refresh_mailbox_after_mutation_failure();
1492 app.show_mutation_failure(&e);
1493 }
1494 AsyncResult::ComposeReady(Ok(data)) => {
1495 ratatui::restore();
1497 let editor = mxr_compose::editor::resolve_editor(None);
1498 let status = std::process::Command::new(&editor)
1499 .arg(format!("+{}", data.cursor_line))
1500 .arg(&data.draft_path)
1501 .status();
1502 terminal = ratatui::init();
1503 match status {
1504 Ok(s) if s.success() => {
1505 match pending_send_from_edited_draft(&data) {
1506 Ok(Some(pending)) => {
1507 app.pending_send_confirm = Some(pending);
1508 }
1509 Ok(None) => {}
1510 Err(message) => {
1511 app.status_message = Some(message);
1512 }
1513 }
1514 }
1515 Ok(_) => {
1516 app.status_message = Some("Draft discarded".into());
1518 let _ = std::fs::remove_file(&data.draft_path);
1519 }
1520 Err(e) => {
1521 app.status_message =
1522 Some(format!("Failed to launch editor: {e}"));
1523 }
1524 }
1525 }
1526 AsyncResult::ComposeReady(Err(e)) => {
1527 app.status_message = Some(format!("Compose error: {e}"));
1528 }
1529 AsyncResult::ExportResult(Ok(msg)) => {
1530 app.status_message = Some(msg);
1531 }
1532 AsyncResult::ExportResult(Err(e)) => {
1533 app.status_message = Some(format!("Export failed: {e}"));
1534 }
1535 AsyncResult::Unsubscribe(Ok(result)) => {
1536 if !result.archived_ids.is_empty() {
1537 app.apply_removed_message_ids(&result.archived_ids);
1538 }
1539 app.status_message = Some(result.message);
1540 app.pending_subscriptions_refresh = true;
1541 }
1542 AsyncResult::Unsubscribe(Err(e)) => {
1543 app.status_message = Some(format!("Unsubscribe failed: {e}"));
1544 }
1545 AsyncResult::Subscriptions(Ok(subscriptions)) => {
1546 app.set_subscriptions(subscriptions);
1547 }
1548 AsyncResult::Subscriptions(Err(e)) => {
1549 app.status_message = Some(format!("Subscriptions error: {e}"));
1550 }
1551 AsyncResult::DaemonEvent(event) => handle_daemon_event(&mut app, event),
1552 }
1553 }
1554 }
1555 _ = tokio::time::sleep(timeout) => {
1556 app.tick();
1557 }
1558 }
1559
1560 if app.should_quit {
1561 break;
1562 }
1563 }
1564
1565 ratatui::restore();
1566 Ok(())
1567}
1568
1569enum AsyncResult {
1570 Search {
1571 target: app::SearchTarget,
1572 append: bool,
1573 session_id: u64,
1574 result: Result<SearchResultData, MxrError>,
1575 },
1576 Rules(Result<Vec<serde_json::Value>, MxrError>),
1577 RuleDetail(Result<serde_json::Value, MxrError>),
1578 RuleHistory(Result<Vec<serde_json::Value>, MxrError>),
1579 RuleDryRun(Result<Vec<serde_json::Value>, MxrError>),
1580 RuleForm(Result<mxr_protocol::RuleFormData, MxrError>),
1581 RuleDeleted(Result<(), MxrError>),
1582 RuleUpsert(Result<serde_json::Value, MxrError>),
1583 Diagnostics(Box<Result<Response, MxrError>>),
1584 Status(Result<StatusSnapshot, MxrError>),
1585 Accounts(Result<Vec<mxr_protocol::AccountSummaryData>, MxrError>),
1586 Labels(Result<Vec<mxr_core::Label>, MxrError>),
1587 AllEnvelopes(Result<Vec<mxr_core::Envelope>, MxrError>),
1588 Subscriptions(Result<Vec<mxr_core::types::SubscriptionSummary>, MxrError>),
1589 AccountOperation(Result<mxr_protocol::AccountOperationResult, MxrError>),
1590 BugReport(Result<String, MxrError>),
1591 AttachmentFile {
1592 operation: AttachmentOperation,
1593 result: Result<mxr_protocol::AttachmentFile, MxrError>,
1594 },
1595 LabelEnvelopes(Result<Vec<mxr_core::Envelope>, MxrError>),
1596 Bodies {
1597 requested: Vec<mxr_core::MessageId>,
1598 result: Result<Vec<mxr_core::MessageBody>, MxrError>,
1599 },
1600 Thread {
1601 thread_id: mxr_core::ThreadId,
1602 result: Result<(mxr_core::Thread, Vec<mxr_core::Envelope>), MxrError>,
1603 },
1604 MutationResult(Result<app::MutationEffect, MxrError>),
1605 ComposeReady(Result<ComposeReadyData, MxrError>),
1606 ExportResult(Result<String, MxrError>),
1607 Unsubscribe(Result<UnsubscribeResultData, MxrError>),
1608 DaemonEvent(DaemonEvent),
1609}
1610
1611struct ComposeReadyData {
1612 draft_path: std::path::PathBuf,
1613 cursor_line: usize,
1614 initial_content: String,
1615}
1616
1617struct SearchResultData {
1618 envelopes: Vec<mxr_core::types::Envelope>,
1619 scores: std::collections::HashMap<mxr_core::MessageId, f32>,
1620 has_more: bool,
1621}
1622
1623struct StatusSnapshot {
1624 uptime_secs: u64,
1625 daemon_pid: Option<u32>,
1626 accounts: Vec<String>,
1627 total_messages: u32,
1628 sync_statuses: Vec<mxr_protocol::AccountSyncStatus>,
1629}
1630
1631struct UnsubscribeResultData {
1632 archived_ids: Vec<mxr_core::MessageId>,
1633 message: String,
1634}
1635
1636async fn handle_compose_action(
1637 bg: &mpsc::UnboundedSender<IpcRequest>,
1638 action: ComposeAction,
1639) -> Result<ComposeReadyData, MxrError> {
1640 let from = get_account_email(bg).await?;
1641
1642 let kind = match action {
1643 ComposeAction::EditDraft(path) => {
1644 let cursor_line = 1;
1646 return Ok(ComposeReadyData {
1647 draft_path: path.clone(),
1648 cursor_line,
1649 initial_content: std::fs::read_to_string(&path)
1650 .map_err(|e| MxrError::Ipc(e.to_string()))?,
1651 });
1652 }
1653 ComposeAction::New => mxr_compose::ComposeKind::New,
1654 ComposeAction::NewWithTo(to) => mxr_compose::ComposeKind::NewWithTo { to },
1655 ComposeAction::Reply { message_id } => {
1656 let resp = ipc_call(
1657 bg,
1658 Request::PrepareReply {
1659 message_id,
1660 reply_all: false,
1661 },
1662 )
1663 .await?;
1664 match resp {
1665 Response::Ok {
1666 data: ResponseData::ReplyContext { context },
1667 } => mxr_compose::ComposeKind::Reply {
1668 in_reply_to: context.in_reply_to,
1669 references: context.references,
1670 to: context.reply_to,
1671 cc: context.cc,
1672 subject: context.subject,
1673 thread_context: context.thread_context,
1674 },
1675 Response::Error { message } => return Err(MxrError::Ipc(message)),
1676 _ => return Err(MxrError::Ipc("unexpected response".into())),
1677 }
1678 }
1679 ComposeAction::ReplyAll { message_id } => {
1680 let resp = ipc_call(
1681 bg,
1682 Request::PrepareReply {
1683 message_id,
1684 reply_all: true,
1685 },
1686 )
1687 .await?;
1688 match resp {
1689 Response::Ok {
1690 data: ResponseData::ReplyContext { context },
1691 } => mxr_compose::ComposeKind::Reply {
1692 in_reply_to: context.in_reply_to,
1693 references: context.references,
1694 to: context.reply_to,
1695 cc: context.cc,
1696 subject: context.subject,
1697 thread_context: context.thread_context,
1698 },
1699 Response::Error { message } => return Err(MxrError::Ipc(message)),
1700 _ => return Err(MxrError::Ipc("unexpected response".into())),
1701 }
1702 }
1703 ComposeAction::Forward { message_id } => {
1704 let resp = ipc_call(bg, Request::PrepareForward { message_id }).await?;
1705 match resp {
1706 Response::Ok {
1707 data: ResponseData::ForwardContext { context },
1708 } => mxr_compose::ComposeKind::Forward {
1709 subject: context.subject,
1710 original_context: context.forwarded_content,
1711 },
1712 Response::Error { message } => return Err(MxrError::Ipc(message)),
1713 _ => return Err(MxrError::Ipc("unexpected response".into())),
1714 }
1715 }
1716 };
1717
1718 let (path, cursor_line) =
1719 mxr_compose::create_draft_file(kind, &from).map_err(|e| MxrError::Ipc(e.to_string()))?;
1720
1721 Ok(ComposeReadyData {
1722 draft_path: path.clone(),
1723 cursor_line,
1724 initial_content: std::fs::read_to_string(&path)
1725 .map_err(|e| MxrError::Ipc(e.to_string()))?,
1726 })
1727}
1728
1729async fn get_account_email(bg: &mpsc::UnboundedSender<IpcRequest>) -> Result<String, MxrError> {
1730 let resp = ipc_call(bg, Request::ListAccounts).await?;
1731 match resp {
1732 Response::Ok {
1733 data: ResponseData::Accounts { mut accounts },
1734 } => {
1735 if let Some(index) = accounts.iter().position(|account| account.is_default) {
1736 Ok(accounts.remove(index).email)
1737 } else {
1738 accounts
1739 .into_iter()
1740 .next()
1741 .map(|account| account.email)
1742 .ok_or_else(|| MxrError::Ipc("No runtime account configured".into()))
1743 }
1744 }
1745 Response::Error { message } => Err(MxrError::Ipc(message)),
1746 _ => Err(MxrError::Ipc("Unexpected account response".into())),
1747 }
1748}
1749
1750fn pending_send_from_edited_draft(data: &ComposeReadyData) -> Result<Option<PendingSend>, String> {
1751 let content = std::fs::read_to_string(&data.draft_path)
1752 .map_err(|e| format!("Failed to read draft: {e}"))?;
1753 let unchanged = content == data.initial_content;
1754
1755 let (fm, body) = mxr_compose::frontmatter::parse_compose_file(&content)
1756 .map_err(|e| format!("Parse error: {e}"))?;
1757 let issues = mxr_compose::validate_draft(&fm, &body);
1758 let has_errors = issues.iter().any(|issue| issue.is_error());
1759 if has_errors {
1760 let msgs: Vec<String> = issues.iter().map(|issue| issue.to_string()).collect();
1761 return Err(format!("Draft errors: {}", msgs.join("; ")));
1762 }
1763
1764 Ok(Some(PendingSend {
1765 fm,
1766 body,
1767 draft_path: data.draft_path.clone(),
1768 allow_send: !unchanged,
1769 }))
1770}
1771
1772fn daemon_socket_path() -> std::path::PathBuf {
1773 config_socket_path()
1774}
1775
1776async fn request_account_operation(
1777 bg: &mpsc::UnboundedSender<IpcRequest>,
1778 request: Request,
1779) -> Result<mxr_protocol::AccountOperationResult, MxrError> {
1780 let resp = ipc_call(bg, request).await;
1781 match resp {
1782 Ok(Response::Ok {
1783 data: ResponseData::AccountOperation { result },
1784 }) => Ok(result),
1785 Ok(Response::Error { message }) => Err(MxrError::Ipc(message)),
1786 Err(e) => Err(e),
1787 _ => Err(MxrError::Ipc("unexpected response".into())),
1788 }
1789}
1790
1791async fn run_account_save_workflow(
1792 bg: &mpsc::UnboundedSender<IpcRequest>,
1793 account: mxr_protocol::AccountConfigData,
1794) -> Result<mxr_protocol::AccountOperationResult, MxrError> {
1795 let mut result = if matches!(
1796 account.sync,
1797 Some(mxr_protocol::AccountSyncConfigData::Gmail { .. })
1798 ) {
1799 request_account_operation(
1800 bg,
1801 Request::AuthorizeAccountConfig {
1802 account: account.clone(),
1803 reauthorize: false,
1804 },
1805 )
1806 .await?
1807 } else {
1808 empty_account_operation_result()
1809 };
1810
1811 if result.auth.as_ref().is_some_and(|step| !step.ok) {
1812 return Ok(result);
1813 }
1814
1815 let save_result = request_account_operation(
1816 bg,
1817 Request::UpsertAccountConfig {
1818 account: account.clone(),
1819 },
1820 )
1821 .await?;
1822 merge_account_operation_result(&mut result, save_result);
1823
1824 if result.save.as_ref().is_some_and(|step| !step.ok) {
1825 return Ok(result);
1826 }
1827
1828 let test_result = request_account_operation(bg, Request::TestAccountConfig { account }).await?;
1829 merge_account_operation_result(&mut result, test_result);
1830
1831 Ok(result)
1832}
1833
1834fn empty_account_operation_result() -> mxr_protocol::AccountOperationResult {
1835 mxr_protocol::AccountOperationResult {
1836 ok: true,
1837 summary: String::new(),
1838 save: None,
1839 auth: None,
1840 sync: None,
1841 send: None,
1842 }
1843}
1844
1845fn merge_account_operation_result(
1846 base: &mut mxr_protocol::AccountOperationResult,
1847 next: mxr_protocol::AccountOperationResult,
1848) {
1849 base.ok &= next.ok;
1850 if !next.summary.is_empty() {
1851 if base.summary.is_empty() {
1852 base.summary = next.summary;
1853 } else {
1854 base.summary = format!("{} | {}", base.summary, next.summary);
1855 }
1856 }
1857 if next.save.is_some() {
1858 base.save = next.save;
1859 }
1860 if next.auth.is_some() {
1861 base.auth = next.auth;
1862 }
1863 if next.sync.is_some() {
1864 base.sync = next.sync;
1865 }
1866 if next.send.is_some() {
1867 base.send = next.send;
1868 }
1869}
1870
1871fn handle_daemon_event(app: &mut App, event: DaemonEvent) {
1872 match event {
1873 DaemonEvent::SyncCompleted {
1874 messages_synced, ..
1875 } => {
1876 app.pending_labels_refresh = true;
1877 app.pending_all_envelopes_refresh = true;
1878 app.pending_subscriptions_refresh = true;
1879 app.pending_status_refresh = true;
1880 if let Some(label_id) = app.active_label.clone() {
1881 app.pending_label_fetch = Some(label_id);
1882 }
1883 if messages_synced > 0 {
1884 app.status_message = Some(format!("Synced {messages_synced} messages"));
1885 }
1886 }
1887 DaemonEvent::LabelCountsUpdated { counts } => {
1888 for count in &counts {
1889 if let Some(label) = app
1890 .labels
1891 .iter_mut()
1892 .find(|label| label.id == count.label_id)
1893 {
1894 label.unread_count = count.unread_count;
1895 label.total_count = count.total_count;
1896 }
1897 }
1898 }
1899 _ => {}
1900 }
1901}
1902
1903fn apply_all_envelopes_refresh(app: &mut App, envelopes: Vec<mxr_core::Envelope>) {
1904 app.all_envelopes = envelopes;
1905 if app.active_label.is_none() && app.pending_active_label.is_none() && !app.search_active {
1906 app.envelopes = app
1907 .all_envelopes
1908 .iter()
1909 .filter(|envelope| !envelope.flags.contains(mxr_core::MessageFlags::TRASH))
1910 .cloned()
1911 .collect();
1912 if app.mailbox_view == app::MailboxView::Messages {
1913 let selected_id = app.selected_mail_row().map(|row| row.representative.id);
1914 restore_mail_list_selection(app, selected_id);
1915 } else {
1916 app.selected_index = app
1917 .selected_index
1918 .min(app.subscriptions_page.entries.len().saturating_sub(1));
1919 }
1920 app.queue_body_window();
1921 }
1922}
1923
1924fn restore_mail_list_selection(app: &mut App, selected_id: Option<mxr_core::MessageId>) {
1925 let row_count = app.mail_list_rows().len();
1926 if row_count == 0 {
1927 app.selected_index = 0;
1928 app.scroll_offset = 0;
1929 return;
1930 }
1931
1932 if let Some(id) = selected_id {
1933 if let Some(position) = app
1934 .mail_list_rows()
1935 .iter()
1936 .position(|row| row.representative.id == id)
1937 {
1938 app.selected_index = position;
1939 } else {
1940 app.selected_index = app.selected_index.min(row_count.saturating_sub(1));
1941 }
1942 } else {
1943 app.selected_index = 0;
1944 }
1945
1946 let visible_height = app.visible_height.max(1);
1947 if app.selected_index < app.scroll_offset {
1948 app.scroll_offset = app.selected_index;
1949 } else if app.selected_index >= app.scroll_offset + visible_height {
1950 app.scroll_offset = app.selected_index + 1 - visible_height;
1951 }
1952}
1953
1954async fn load_accounts_page_accounts(
1955 bg: &mpsc::UnboundedSender<IpcRequest>,
1956) -> Result<Vec<mxr_protocol::AccountSummaryData>, MxrError> {
1957 match ipc_call(bg, Request::ListAccounts).await {
1958 Ok(Response::Ok {
1959 data: ResponseData::Accounts { accounts },
1960 }) if !accounts.is_empty() => Ok(accounts),
1961 Ok(Response::Ok {
1962 data: ResponseData::Accounts { .. },
1963 })
1964 | Ok(Response::Error { .. })
1965 | Err(_) => load_config_account_summaries(bg).await,
1966 Ok(_) => Err(MxrError::Ipc("unexpected response".into())),
1967 }
1968}
1969
1970async fn load_config_account_summaries(
1971 bg: &mpsc::UnboundedSender<IpcRequest>,
1972) -> Result<Vec<mxr_protocol::AccountSummaryData>, MxrError> {
1973 let resp = ipc_call(bg, Request::ListAccountsConfig).await?;
1974 match resp {
1975 Response::Ok {
1976 data: ResponseData::AccountsConfig { accounts },
1977 } => Ok(accounts
1978 .into_iter()
1979 .map(account_config_to_summary)
1980 .collect()),
1981 Response::Error { message } => Err(MxrError::Ipc(message)),
1982 _ => Err(MxrError::Ipc("unexpected response".into())),
1983 }
1984}
1985
1986fn account_config_to_summary(
1987 account: mxr_protocol::AccountConfigData,
1988) -> mxr_protocol::AccountSummaryData {
1989 let provider_kind = account
1990 .sync
1991 .as_ref()
1992 .map(account_sync_kind_label)
1993 .or_else(|| account.send.as_ref().map(account_send_kind_label))
1994 .unwrap_or_else(|| "unknown".to_string());
1995 let account_id = mxr_core::AccountId::from_provider_id(&provider_kind, &account.email);
1996
1997 mxr_protocol::AccountSummaryData {
1998 account_id,
1999 key: Some(account.key),
2000 name: account.name,
2001 email: account.email,
2002 provider_kind,
2003 sync_kind: account.sync.as_ref().map(account_sync_kind_label),
2004 send_kind: account.send.as_ref().map(account_send_kind_label),
2005 enabled: true,
2006 is_default: account.is_default,
2007 source: mxr_protocol::AccountSourceData::Config,
2008 editable: mxr_protocol::AccountEditModeData::Full,
2009 sync: account.sync,
2010 send: account.send,
2011 }
2012}
2013
2014fn account_sync_kind_label(sync: &mxr_protocol::AccountSyncConfigData) -> String {
2015 match sync {
2016 mxr_protocol::AccountSyncConfigData::Gmail { .. } => "gmail".to_string(),
2017 mxr_protocol::AccountSyncConfigData::Imap { .. } => "imap".to_string(),
2018 }
2019}
2020
2021fn account_send_kind_label(send: &mxr_protocol::AccountSendConfigData) -> String {
2022 match send {
2023 mxr_protocol::AccountSendConfigData::Gmail => "gmail".to_string(),
2024 mxr_protocol::AccountSendConfigData::Smtp { .. } => "smtp".to_string(),
2025 }
2026}
2027
2028#[cfg(test)]
2029mod tests {
2030 use super::action::Action;
2031 use super::app::{
2032 ActivePane, App, BodySource, BodyViewState, LayoutMode, MutationEffect,
2033 PendingSearchRequest, Screen, SearchPane, SearchTarget, SidebarItem, SEARCH_PAGE_SIZE,
2034 };
2035 use super::input::InputHandler;
2036 use super::ui::command_palette::default_commands;
2037 use super::ui::command_palette::CommandPalette;
2038 use super::ui::search_bar::SearchBar;
2039 use super::ui::status_bar;
2040 use super::{
2041 apply_all_envelopes_refresh, handle_daemon_event, pending_send_from_edited_draft,
2042 ComposeReadyData, PendingSend,
2043 };
2044 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
2045 use mxr_config::RenderConfig;
2046 use mxr_core::id::*;
2047 use mxr_core::types::*;
2048 use mxr_core::MxrError;
2049 use mxr_protocol::{DaemonEvent, MutationCommand, Request};
2050
2051 fn make_test_envelopes(count: usize) -> Vec<Envelope> {
2052 (0..count)
2053 .map(|i| Envelope {
2054 id: MessageId::new(),
2055 account_id: AccountId::new(),
2056 provider_id: format!("fake-{}", i),
2057 thread_id: ThreadId::new(),
2058 message_id_header: None,
2059 in_reply_to: None,
2060 references: vec![],
2061 from: Address {
2062 name: Some(format!("User {}", i)),
2063 email: format!("user{}@example.com", i),
2064 },
2065 to: vec![],
2066 cc: vec![],
2067 bcc: vec![],
2068 subject: format!("Subject {}", i),
2069 date: chrono::Utc::now(),
2070 flags: if i % 2 == 0 {
2071 MessageFlags::READ
2072 } else {
2073 MessageFlags::empty()
2074 },
2075 snippet: format!("Snippet {}", i),
2076 has_attachments: false,
2077 size_bytes: 1000,
2078 unsubscribe: UnsubscribeMethod::None,
2079 label_provider_ids: vec![],
2080 })
2081 .collect()
2082 }
2083
2084 fn make_unsubscribe_envelope(
2085 account_id: AccountId,
2086 sender_email: &str,
2087 unsubscribe: UnsubscribeMethod,
2088 ) -> Envelope {
2089 Envelope {
2090 id: MessageId::new(),
2091 account_id,
2092 provider_id: "unsub-fixture".into(),
2093 thread_id: ThreadId::new(),
2094 message_id_header: None,
2095 in_reply_to: None,
2096 references: vec![],
2097 from: Address {
2098 name: Some("Newsletter".into()),
2099 email: sender_email.into(),
2100 },
2101 to: vec![],
2102 cc: vec![],
2103 bcc: vec![],
2104 subject: "Newsletter".into(),
2105 date: chrono::Utc::now(),
2106 flags: MessageFlags::empty(),
2107 snippet: "newsletter".into(),
2108 has_attachments: false,
2109 size_bytes: 42,
2110 unsubscribe,
2111 label_provider_ids: vec![],
2112 }
2113 }
2114
2115 #[test]
2116 fn input_j_moves_down() {
2117 let mut h = InputHandler::new();
2118 assert_eq!(
2119 h.handle_key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)),
2120 Some(Action::MoveDown)
2121 );
2122 }
2123
2124 #[test]
2125 fn input_k_moves_up() {
2126 let mut h = InputHandler::new();
2127 assert_eq!(
2128 h.handle_key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)),
2129 Some(Action::MoveUp)
2130 );
2131 }
2132
2133 #[test]
2134 fn input_gg_jumps_top() {
2135 let mut h = InputHandler::new();
2136 assert_eq!(
2137 h.handle_key(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE)),
2138 None
2139 );
2140 assert_eq!(
2141 h.handle_key(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE)),
2142 Some(Action::JumpTop)
2143 );
2144 }
2145
2146 #[test]
2147 fn input_zz_centers() {
2148 let mut h = InputHandler::new();
2149 assert_eq!(
2150 h.handle_key(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE)),
2151 None
2152 );
2153 assert_eq!(
2154 h.handle_key(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE)),
2155 Some(Action::CenterCurrent)
2156 );
2157 }
2158
2159 #[test]
2160 fn input_enter_opens() {
2161 let mut h = InputHandler::new();
2162 assert_eq!(
2163 h.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
2164 Some(Action::OpenSelected)
2165 );
2166 }
2167
2168 #[test]
2169 fn input_o_opens() {
2170 let mut h = InputHandler::new();
2171 assert_eq!(
2172 h.handle_key(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)),
2173 Some(Action::OpenSelected)
2174 );
2175 }
2176
2177 #[test]
2178 fn input_escape_back() {
2179 let mut h = InputHandler::new();
2180 assert_eq!(
2181 h.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
2182 Some(Action::Back)
2183 );
2184 }
2185
2186 #[test]
2187 fn input_q_quits() {
2188 let mut h = InputHandler::new();
2189 assert_eq!(
2190 h.handle_key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)),
2191 Some(Action::QuitView)
2192 );
2193 }
2194
2195 #[test]
2196 fn input_hml_viewport() {
2197 let mut h = InputHandler::new();
2198 assert_eq!(
2199 h.handle_key(KeyEvent::new(KeyCode::Char('H'), KeyModifiers::SHIFT)),
2200 Some(Action::ViewportTop)
2201 );
2202 assert_eq!(
2203 h.handle_key(KeyEvent::new(KeyCode::Char('M'), KeyModifiers::SHIFT)),
2204 Some(Action::ViewportMiddle)
2205 );
2206 assert_eq!(
2207 h.handle_key(KeyEvent::new(KeyCode::Char('L'), KeyModifiers::SHIFT)),
2208 Some(Action::ViewportBottom)
2209 );
2210 }
2211
2212 #[test]
2213 fn input_ctrl_du_page() {
2214 let mut h = InputHandler::new();
2215 assert_eq!(
2216 h.handle_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)),
2217 Some(Action::PageDown)
2218 );
2219 assert_eq!(
2220 h.handle_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)),
2221 Some(Action::PageUp)
2222 );
2223 }
2224
2225 #[test]
2226 fn app_move_down() {
2227 let mut app = App::new();
2228 app.envelopes = make_test_envelopes(5);
2229 app.apply(Action::MoveDown);
2230 assert_eq!(app.selected_index, 1);
2231 }
2232
2233 #[test]
2234 fn app_move_up_at_zero() {
2235 let mut app = App::new();
2236 app.envelopes = make_test_envelopes(5);
2237 app.apply(Action::MoveUp);
2238 assert_eq!(app.selected_index, 0);
2239 }
2240
2241 #[test]
2242 fn app_jump_top() {
2243 let mut app = App::new();
2244 app.envelopes = make_test_envelopes(10);
2245 app.selected_index = 5;
2246 app.apply(Action::JumpTop);
2247 assert_eq!(app.selected_index, 0);
2248 }
2249
2250 #[test]
2251 fn app_switch_pane() {
2252 let mut app = App::new();
2253 assert_eq!(app.active_pane, ActivePane::MailList);
2254 app.apply(Action::SwitchPane);
2255 assert_eq!(app.active_pane, ActivePane::Sidebar);
2256 app.apply(Action::SwitchPane);
2257 assert_eq!(app.active_pane, ActivePane::MailList);
2258 }
2259
2260 #[test]
2261 fn app_quit() {
2262 let mut app = App::new();
2263 app.apply(Action::QuitView);
2264 assert!(app.should_quit);
2265 }
2266
2267 #[test]
2268 fn app_new_uses_default_reader_mode() {
2269 let app = App::new();
2270 assert!(app.reader_mode);
2271 }
2272
2273 #[test]
2274 fn app_from_render_config_respects_reader_mode() {
2275 let config = RenderConfig {
2276 reader_mode: false,
2277 ..Default::default()
2278 };
2279 let app = App::from_render_config(&config);
2280 assert!(!app.reader_mode);
2281 }
2282
2283 #[test]
2284 fn apply_runtime_config_updates_tui_settings() {
2285 let mut app = App::new();
2286 let mut config = mxr_config::MxrConfig::default();
2287 config.render.reader_mode = false;
2288 config.snooze.morning_hour = 7;
2289 config.appearance.theme = "light".into();
2290
2291 app.apply_runtime_config(&config);
2292
2293 assert!(!app.reader_mode);
2294 assert_eq!(app.snooze_config.morning_hour, 7);
2295 assert_eq!(
2296 app.theme.selection_fg,
2297 crate::theme::Theme::light().selection_fg
2298 );
2299 }
2300
2301 #[test]
2302 fn edit_config_action_sets_pending_flag() {
2303 let mut app = App::new();
2304
2305 app.apply(Action::EditConfig);
2306
2307 assert!(app.pending_config_edit);
2308 assert_eq!(
2309 app.status_message.as_deref(),
2310 Some("Opening config in editor...")
2311 );
2312 }
2313
2314 #[test]
2315 fn open_logs_action_sets_pending_flag() {
2316 let mut app = App::new();
2317
2318 app.apply(Action::OpenLogs);
2319
2320 assert!(app.pending_log_open);
2321 assert_eq!(
2322 app.status_message.as_deref(),
2323 Some("Opening log file in editor...")
2324 );
2325 }
2326
2327 #[test]
2328 fn app_move_down_bounds() {
2329 let mut app = App::new();
2330 app.envelopes = make_test_envelopes(3);
2331 app.apply(Action::MoveDown);
2332 app.apply(Action::MoveDown);
2333 app.apply(Action::MoveDown);
2334 assert_eq!(app.selected_index, 2);
2335 }
2336
2337 #[test]
2338 fn layout_mode_switching() {
2339 let mut app = App::new();
2340 app.envelopes = make_test_envelopes(3);
2341 assert_eq!(app.layout_mode, LayoutMode::TwoPane);
2342 app.apply(Action::OpenMessageView);
2343 assert_eq!(app.layout_mode, LayoutMode::ThreePane);
2344 app.apply(Action::CloseMessageView);
2345 assert_eq!(app.layout_mode, LayoutMode::TwoPane);
2346 }
2347
2348 #[test]
2349 fn command_palette_toggle() {
2350 let mut p = CommandPalette::default();
2351 assert!(!p.visible);
2352 p.toggle();
2353 assert!(p.visible);
2354 p.toggle();
2355 assert!(!p.visible);
2356 }
2357
2358 #[test]
2359 fn command_palette_fuzzy_filter() {
2360 let mut p = CommandPalette::default();
2361 p.toggle();
2362 p.on_char('i');
2363 p.on_char('n');
2364 p.on_char('b');
2365 let labels: Vec<&str> = p
2366 .filtered
2367 .iter()
2368 .map(|&i| p.commands[i].label.as_str())
2369 .collect();
2370 assert!(labels.contains(&"Go to Inbox"));
2371 }
2372
2373 #[test]
2374 fn command_palette_shortcut_filter_finds_edit_config() {
2375 let mut p = CommandPalette::default();
2376 p.toggle();
2377 p.on_char('g');
2378 p.on_char('c');
2379 let labels: Vec<&str> = p
2380 .filtered
2381 .iter()
2382 .map(|&i| p.commands[i].label.as_str())
2383 .collect();
2384 assert!(labels.contains(&"Edit Config"));
2385 }
2386
2387 #[test]
2388 fn unsubscribe_opens_confirm_modal_and_scopes_archive_to_sender_and_account() {
2389 let mut app = App::new();
2390 let account_id = AccountId::new();
2391 let other_account_id = AccountId::new();
2392 let target = make_unsubscribe_envelope(
2393 account_id.clone(),
2394 "news@example.com",
2395 UnsubscribeMethod::HttpLink {
2396 url: "https://example.com/unsub".into(),
2397 },
2398 );
2399 let same_sender_same_account = make_unsubscribe_envelope(
2400 account_id.clone(),
2401 "news@example.com",
2402 UnsubscribeMethod::None,
2403 );
2404 let same_sender_other_account = make_unsubscribe_envelope(
2405 other_account_id,
2406 "news@example.com",
2407 UnsubscribeMethod::None,
2408 );
2409 let different_sender_same_account =
2410 make_unsubscribe_envelope(account_id, "other@example.com", UnsubscribeMethod::None);
2411
2412 app.envelopes = vec![target.clone()];
2413 app.all_envelopes = vec![
2414 target.clone(),
2415 same_sender_same_account.clone(),
2416 same_sender_other_account,
2417 different_sender_same_account,
2418 ];
2419
2420 app.apply(Action::Unsubscribe);
2421
2422 let pending = app
2423 .pending_unsubscribe_confirm
2424 .as_ref()
2425 .expect("unsubscribe modal should open");
2426 assert_eq!(pending.sender_email, "news@example.com");
2427 assert_eq!(pending.method_label, "browser link");
2428 assert_eq!(pending.archive_message_ids.len(), 2);
2429 assert!(pending.archive_message_ids.contains(&target.id));
2430 assert!(pending
2431 .archive_message_ids
2432 .contains(&same_sender_same_account.id));
2433 }
2434
2435 #[test]
2436 fn unsubscribe_without_method_sets_status_error() {
2437 let mut app = App::new();
2438 let env = make_unsubscribe_envelope(
2439 AccountId::new(),
2440 "news@example.com",
2441 UnsubscribeMethod::None,
2442 );
2443 app.envelopes = vec![env];
2444
2445 app.apply(Action::Unsubscribe);
2446
2447 assert!(app.pending_unsubscribe_confirm.is_none());
2448 assert_eq!(
2449 app.status_message.as_deref(),
2450 Some("No unsubscribe option found for this message")
2451 );
2452 }
2453
2454 #[test]
2455 fn unsubscribe_confirm_archive_populates_pending_action() {
2456 let mut app = App::new();
2457 let env = make_unsubscribe_envelope(
2458 AccountId::new(),
2459 "news@example.com",
2460 UnsubscribeMethod::OneClick {
2461 url: "https://example.com/one-click".into(),
2462 },
2463 );
2464 app.envelopes = vec![env.clone()];
2465 app.all_envelopes = vec![env.clone()];
2466 app.apply(Action::Unsubscribe);
2467 app.apply(Action::ConfirmUnsubscribeAndArchiveSender);
2468
2469 let pending = app
2470 .pending_unsubscribe_action
2471 .as_ref()
2472 .expect("unsubscribe action should be queued");
2473 assert_eq!(pending.message_id, env.id);
2474 assert_eq!(pending.archive_message_ids.len(), 1);
2475 assert_eq!(pending.sender_email, "news@example.com");
2476 }
2477
2478 #[test]
2479 fn search_input_lifecycle() {
2480 let mut bar = SearchBar::default();
2481 bar.activate();
2482 assert!(bar.active);
2483 bar.on_char('h');
2484 bar.on_char('e');
2485 bar.on_char('l');
2486 bar.on_char('l');
2487 bar.on_char('o');
2488 assert_eq!(bar.query, "hello");
2489 let q = bar.submit();
2490 assert_eq!(q, "hello");
2491 assert!(!bar.active);
2492 }
2493
2494 #[test]
2495 fn search_bar_cycles_modes() {
2496 let mut bar = SearchBar::default();
2497 assert_eq!(bar.mode, mxr_core::SearchMode::Lexical);
2498 bar.cycle_mode();
2499 assert_eq!(bar.mode, mxr_core::SearchMode::Hybrid);
2500 bar.cycle_mode();
2501 assert_eq!(bar.mode, mxr_core::SearchMode::Semantic);
2502 bar.cycle_mode();
2503 assert_eq!(bar.mode, mxr_core::SearchMode::Lexical);
2504 }
2505
2506 #[test]
2507 fn reopening_active_search_preserves_query() {
2508 let mut app = App::new();
2509 app.search_active = true;
2510 app.search_bar.query = "deploy".to_string();
2511 app.search_bar.cursor_pos = 0;
2512
2513 app.apply(Action::OpenSearch);
2514
2515 assert!(app.search_bar.active);
2516 assert_eq!(app.search_bar.query, "deploy");
2517 assert_eq!(app.search_bar.cursor_pos, "deploy".len());
2518 }
2519
2520 #[test]
2521 fn g_prefix_navigation() {
2522 let mut h = InputHandler::new();
2523 assert_eq!(
2524 h.handle_key(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE)),
2525 None
2526 );
2527 assert_eq!(
2528 h.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)),
2529 Some(Action::GoToInbox)
2530 );
2531 assert_eq!(
2532 h.handle_key(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE)),
2533 None
2534 );
2535 assert_eq!(
2536 h.handle_key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE)),
2537 Some(Action::GoToStarred)
2538 );
2539 }
2540
2541 #[test]
2542 fn status_bar_sync_formats() {
2543 assert_eq!(
2544 status_bar::format_sync_status(12, Some("synced 2m ago")),
2545 "[INBOX] 12 unread | synced 2m ago"
2546 );
2547 assert_eq!(
2548 status_bar::format_sync_status(0, None),
2549 "[INBOX] 0 unread | not synced"
2550 );
2551 }
2552
2553 fn make_test_labels() -> Vec<Label> {
2554 vec![
2555 Label {
2556 id: LabelId::from_provider_id("test", "INBOX"),
2557 account_id: AccountId::new(),
2558 name: "INBOX".to_string(),
2559 kind: LabelKind::System,
2560 color: None,
2561 provider_id: "INBOX".to_string(),
2562 unread_count: 3,
2563 total_count: 10,
2564 },
2565 Label {
2566 id: LabelId::from_provider_id("test", "STARRED"),
2567 account_id: AccountId::new(),
2568 name: "STARRED".to_string(),
2569 kind: LabelKind::System,
2570 color: None,
2571 provider_id: "STARRED".to_string(),
2572 unread_count: 0,
2573 total_count: 2,
2574 },
2575 Label {
2576 id: LabelId::from_provider_id("test", "SENT"),
2577 account_id: AccountId::new(),
2578 name: "SENT".to_string(),
2579 kind: LabelKind::System,
2580 color: None,
2581 provider_id: "SENT".to_string(),
2582 unread_count: 0,
2583 total_count: 5,
2584 },
2585 Label {
2586 id: LabelId::from_provider_id("test", "DRAFT"),
2587 account_id: AccountId::new(),
2588 name: "DRAFT".to_string(),
2589 kind: LabelKind::System,
2590 color: None,
2591 provider_id: "DRAFT".to_string(),
2592 unread_count: 0,
2593 total_count: 0,
2594 },
2595 Label {
2596 id: LabelId::from_provider_id("test", "ARCHIVE"),
2597 account_id: AccountId::new(),
2598 name: "ARCHIVE".to_string(),
2599 kind: LabelKind::System,
2600 color: None,
2601 provider_id: "ARCHIVE".to_string(),
2602 unread_count: 0,
2603 total_count: 0,
2604 },
2605 Label {
2606 id: LabelId::from_provider_id("test", "SPAM"),
2607 account_id: AccountId::new(),
2608 name: "SPAM".to_string(),
2609 kind: LabelKind::System,
2610 color: None,
2611 provider_id: "SPAM".to_string(),
2612 unread_count: 0,
2613 total_count: 0,
2614 },
2615 Label {
2616 id: LabelId::from_provider_id("test", "TRASH"),
2617 account_id: AccountId::new(),
2618 name: "TRASH".to_string(),
2619 kind: LabelKind::System,
2620 color: None,
2621 provider_id: "TRASH".to_string(),
2622 unread_count: 0,
2623 total_count: 0,
2624 },
2625 Label {
2627 id: LabelId::from_provider_id("test", "CHAT"),
2628 account_id: AccountId::new(),
2629 name: "CHAT".to_string(),
2630 kind: LabelKind::System,
2631 color: None,
2632 provider_id: "CHAT".to_string(),
2633 unread_count: 0,
2634 total_count: 0,
2635 },
2636 Label {
2637 id: LabelId::from_provider_id("test", "IMPORTANT"),
2638 account_id: AccountId::new(),
2639 name: "IMPORTANT".to_string(),
2640 kind: LabelKind::System,
2641 color: None,
2642 provider_id: "IMPORTANT".to_string(),
2643 unread_count: 0,
2644 total_count: 5,
2645 },
2646 Label {
2648 id: LabelId::from_provider_id("test", "Work"),
2649 account_id: AccountId::new(),
2650 name: "Work".to_string(),
2651 kind: LabelKind::User,
2652 color: None,
2653 provider_id: "Label_1".to_string(),
2654 unread_count: 2,
2655 total_count: 10,
2656 },
2657 Label {
2658 id: LabelId::from_provider_id("test", "Personal"),
2659 account_id: AccountId::new(),
2660 name: "Personal".to_string(),
2661 kind: LabelKind::User,
2662 color: None,
2663 provider_id: "Label_2".to_string(),
2664 unread_count: 0,
2665 total_count: 3,
2666 },
2667 Label {
2669 id: LabelId::from_provider_id("test", "CATEGORY_UPDATES"),
2670 account_id: AccountId::new(),
2671 name: "CATEGORY_UPDATES".to_string(),
2672 kind: LabelKind::System,
2673 color: None,
2674 provider_id: "CATEGORY_UPDATES".to_string(),
2675 unread_count: 0,
2676 total_count: 50,
2677 },
2678 ]
2679 }
2680
2681 #[test]
2684 fn threepane_l_loads_new_message() {
2685 let mut app = App::new();
2686 app.envelopes = make_test_envelopes(5);
2687 app.all_envelopes = app.envelopes.clone();
2688 app.apply(Action::OpenSelected);
2690 assert_eq!(app.layout_mode, LayoutMode::ThreePane);
2691 let first_id = app.viewing_envelope.as_ref().unwrap().id.clone();
2692 app.active_pane = ActivePane::MailList;
2694 app.apply(Action::MoveDown);
2696 app.apply(Action::OpenSelected);
2698 let second_id = app.viewing_envelope.as_ref().unwrap().id.clone();
2699 assert_ne!(
2700 first_id, second_id,
2701 "l should load the new message, not stay on old one"
2702 );
2703 assert_eq!(app.selected_index, 1);
2704 }
2705
2706 #[test]
2707 fn threepane_jk_auto_preview() {
2708 let mut app = App::new();
2709 app.envelopes = make_test_envelopes(5);
2710 app.all_envelopes = app.envelopes.clone();
2711 app.apply(Action::OpenSelected);
2713 assert_eq!(app.layout_mode, LayoutMode::ThreePane);
2714 let first_id = app.viewing_envelope.as_ref().unwrap().id.clone();
2715 app.active_pane = ActivePane::MailList;
2717 app.apply(Action::MoveDown);
2719 let preview_id = app.viewing_envelope.as_ref().unwrap().id.clone();
2720 assert_ne!(first_id, preview_id, "j/k should auto-preview in ThreePane");
2721 }
2724
2725 #[test]
2726 fn twopane_jk_no_auto_preview() {
2727 let mut app = App::new();
2728 app.envelopes = make_test_envelopes(5);
2729 app.all_envelopes = app.envelopes.clone();
2730 assert_eq!(app.layout_mode, LayoutMode::TwoPane);
2732 app.apply(Action::MoveDown);
2733 assert!(
2734 app.viewing_envelope.is_none(),
2735 "j/k should not auto-preview in TwoPane"
2736 );
2737 }
2739
2740 #[test]
2743 fn back_in_message_view_closes_preview_pane() {
2744 let mut app = App::new();
2745 app.envelopes = make_test_envelopes(3);
2746 app.all_envelopes = app.envelopes.clone();
2747 app.apply(Action::OpenSelected);
2748 assert_eq!(app.active_pane, ActivePane::MessageView);
2749 assert_eq!(app.layout_mode, LayoutMode::ThreePane);
2750 app.apply(Action::Back);
2751 assert_eq!(app.active_pane, ActivePane::MailList);
2752 assert_eq!(app.layout_mode, LayoutMode::TwoPane);
2753 assert!(app.viewing_envelope.is_none());
2754 }
2755
2756 #[test]
2757 fn back_in_mail_list_clears_label_filter() {
2758 let mut app = App::new();
2759 app.envelopes = make_test_envelopes(5);
2760 app.all_envelopes = app.envelopes.clone();
2761 app.labels = make_test_labels();
2762 let inbox_id = app
2763 .labels
2764 .iter()
2765 .find(|l| l.name == "INBOX")
2766 .unwrap()
2767 .id
2768 .clone();
2769 app.active_label = Some(inbox_id);
2771 app.envelopes = vec![app.envelopes[0].clone()]; app.apply(Action::Back);
2774 assert!(app.active_label.is_none(), "Esc should clear label filter");
2775 assert_eq!(app.envelopes.len(), 5, "Should restore all envelopes");
2776 }
2777
2778 #[test]
2779 fn back_in_mail_list_closes_threepane_when_no_filter() {
2780 let mut app = App::new();
2781 app.envelopes = make_test_envelopes(3);
2782 app.all_envelopes = app.envelopes.clone();
2783 app.apply(Action::OpenSelected); app.active_pane = ActivePane::MailList; app.apply(Action::Back);
2787 assert_eq!(app.layout_mode, LayoutMode::TwoPane);
2788 }
2789
2790 #[test]
2793 fn sidebar_system_labels_before_user_labels() {
2794 let mut app = App::new();
2795 app.labels = make_test_labels();
2796 let ordered = app.ordered_visible_labels();
2797 let first_user_idx = ordered.iter().position(|l| l.kind == LabelKind::User);
2799 let last_system_idx = ordered.iter().rposition(|l| l.kind == LabelKind::System);
2800 if let (Some(first_user), Some(last_system)) = (first_user_idx, last_system_idx) {
2801 assert!(
2802 last_system < first_user,
2803 "All system labels should come before user labels"
2804 );
2805 }
2806 }
2807
2808 #[test]
2809 fn sidebar_system_labels_in_correct_order() {
2810 let mut app = App::new();
2811 app.labels = make_test_labels();
2812 let ordered = app.ordered_visible_labels();
2813 let system_names: Vec<&str> = ordered
2814 .iter()
2815 .filter(|l| l.kind == LabelKind::System)
2816 .map(|l| l.name.as_str())
2817 .collect();
2818 assert_eq!(system_names[0], "INBOX");
2820 assert_eq!(system_names[1], "STARRED");
2821 assert_eq!(system_names[2], "SENT");
2822 assert_eq!(system_names[3], "DRAFT");
2823 assert_eq!(system_names[4], "ARCHIVE");
2824 }
2825
2826 #[test]
2827 fn sidebar_items_put_inbox_before_all_mail() {
2828 let mut app = App::new();
2829 app.labels = make_test_labels();
2830
2831 let items = app.sidebar_items();
2832 let all_mail_index = items
2833 .iter()
2834 .position(|item| matches!(item, SidebarItem::AllMail))
2835 .unwrap();
2836
2837 assert!(matches!(
2838 items.first(),
2839 Some(SidebarItem::Label(label)) if label.name == "INBOX"
2840 ));
2841 assert!(all_mail_index > 0);
2842 }
2843
2844 #[test]
2845 fn sidebar_hidden_labels_not_shown() {
2846 let mut app = App::new();
2847 app.labels = make_test_labels();
2848 let ordered = app.ordered_visible_labels();
2849 let names: Vec<&str> = ordered.iter().map(|l| l.name.as_str()).collect();
2850 assert!(
2851 !names.contains(&"CATEGORY_UPDATES"),
2852 "Gmail categories should be hidden"
2853 );
2854 }
2855
2856 #[test]
2857 fn sidebar_empty_system_labels_hidden_except_primary() {
2858 let mut app = App::new();
2859 app.labels = make_test_labels();
2860 let ordered = app.ordered_visible_labels();
2861 let names: Vec<&str> = ordered.iter().map(|l| l.name.as_str()).collect();
2862 assert!(
2864 !names.contains(&"CHAT"),
2865 "Empty non-primary system labels should be hidden"
2866 );
2867 assert!(
2869 names.contains(&"DRAFT"),
2870 "Primary system labels shown even if empty"
2871 );
2872 assert!(
2873 names.contains(&"ARCHIVE"),
2874 "Archive should be shown as a primary system label even if empty"
2875 );
2876 assert!(
2878 names.contains(&"IMPORTANT"),
2879 "Non-empty system labels should be shown"
2880 );
2881 }
2882
2883 #[test]
2884 fn sidebar_user_labels_alphabetical() {
2885 let mut app = App::new();
2886 app.labels = make_test_labels();
2887 let ordered = app.ordered_visible_labels();
2888 let user_names: Vec<&str> = ordered
2889 .iter()
2890 .filter(|l| l.kind == LabelKind::User)
2891 .map(|l| l.name.as_str())
2892 .collect();
2893 assert_eq!(user_names, vec!["Personal", "Work"]);
2895 }
2896
2897 #[test]
2900 fn goto_inbox_sets_active_label() {
2901 let mut app = App::new();
2902 app.envelopes = make_test_envelopes(5);
2903 app.all_envelopes = app.envelopes.clone();
2904 app.labels = make_test_labels();
2905 app.apply(Action::GoToInbox);
2906 let label = app.labels.iter().find(|l| l.name == "INBOX").unwrap();
2907 assert!(
2908 app.active_label.is_none(),
2909 "GoToInbox should wait for fetch success before swapping active label"
2910 );
2911 assert_eq!(app.pending_active_label.as_ref().unwrap(), &label.id);
2912 assert!(
2913 app.pending_label_fetch.is_some(),
2914 "Should trigger label fetch"
2915 );
2916 }
2917
2918 #[test]
2919 fn goto_inbox_without_labels_records_desired_mailbox() {
2920 let mut app = App::new();
2921 app.apply(Action::GoToInbox);
2922 assert_eq!(app.desired_system_mailbox.as_deref(), Some("INBOX"));
2923 assert!(app.pending_label_fetch.is_none());
2924 assert!(app.pending_active_label.is_none());
2925 }
2926
2927 #[test]
2928 fn labels_refresh_resolves_desired_inbox() {
2929 let mut app = App::new();
2930 app.desired_system_mailbox = Some("INBOX".into());
2931 app.labels = make_test_labels();
2932
2933 app.resolve_desired_system_mailbox();
2934
2935 let inbox_id = app
2936 .labels
2937 .iter()
2938 .find(|label| label.name == "INBOX")
2939 .unwrap()
2940 .id
2941 .clone();
2942 assert_eq!(app.pending_active_label.as_ref(), Some(&inbox_id));
2943 assert_eq!(app.pending_label_fetch.as_ref(), Some(&inbox_id));
2944 assert!(app.active_label.is_none());
2945 }
2946
2947 #[test]
2948 fn sync_completed_requests_live_refresh_even_without_active_label() {
2949 let mut app = App::new();
2950
2951 handle_daemon_event(
2952 &mut app,
2953 DaemonEvent::SyncCompleted {
2954 account_id: AccountId::new(),
2955 messages_synced: 5,
2956 },
2957 );
2958
2959 assert!(app.pending_labels_refresh);
2960 assert!(app.pending_all_envelopes_refresh);
2961 assert!(app.pending_status_refresh);
2962 assert!(app.pending_label_fetch.is_none());
2963 assert_eq!(app.status_message.as_deref(), Some("Synced 5 messages"));
2964 }
2965
2966 #[test]
2967 fn status_bar_uses_label_counts_instead_of_loaded_window() {
2968 let mut app = App::new();
2969 let mut envelopes = make_test_envelopes(5);
2970 if let Some(first) = envelopes.first_mut() {
2971 first.flags.remove(MessageFlags::READ);
2972 first.flags.insert(MessageFlags::STARRED);
2973 }
2974 app.envelopes = envelopes.clone();
2975 app.all_envelopes = envelopes;
2976 app.labels = make_test_labels();
2977 let inbox = app
2978 .labels
2979 .iter()
2980 .find(|label| label.name == "INBOX")
2981 .unwrap()
2982 .id
2983 .clone();
2984 app.active_label = Some(inbox);
2985 app.last_sync_status = Some("synced just now".into());
2986
2987 let state = app.status_bar_state();
2988
2989 assert_eq!(state.mailbox_name, "INBOX");
2990 assert_eq!(state.total_count, 10);
2991 assert_eq!(state.unread_count, 3);
2992 assert_eq!(state.starred_count, 2);
2993 assert_eq!(state.sync_status.as_deref(), Some("synced just now"));
2994 }
2995
2996 #[test]
2997 fn all_envelopes_refresh_updates_visible_all_mail() {
2998 let mut app = App::new();
2999 let envelopes = make_test_envelopes(4);
3000 app.active_label = None;
3001 app.search_active = false;
3002
3003 apply_all_envelopes_refresh(&mut app, envelopes.clone());
3004
3005 assert_eq!(app.all_envelopes.len(), 4);
3006 assert_eq!(app.envelopes.len(), 4);
3007 assert_eq!(app.selected_index, 0);
3008 }
3009
3010 #[test]
3011 fn all_envelopes_refresh_preserves_selection_when_possible() {
3012 let mut app = App::new();
3013 app.visible_height = 3;
3014 let initial = make_test_envelopes(4);
3015 app.all_envelopes = initial.clone();
3016 app.envelopes = initial.clone();
3017 app.selected_index = 2;
3018 app.scroll_offset = 1;
3019
3020 let mut refreshed = initial.clone();
3021 refreshed.push(make_test_envelopes(1).remove(0));
3022
3023 apply_all_envelopes_refresh(&mut app, refreshed);
3024
3025 assert_eq!(app.selected_index, 2);
3026 assert_eq!(app.envelopes[app.selected_index].id, initial[2].id);
3027 assert_eq!(app.scroll_offset, 1);
3028 }
3029
3030 #[test]
3031 fn all_envelopes_refresh_preserves_pending_label_view() {
3032 let mut app = App::new();
3033 let labels = make_test_labels();
3034 let inbox_id = labels
3035 .iter()
3036 .find(|label| label.name == "INBOX")
3037 .unwrap()
3038 .id
3039 .clone();
3040 let initial = make_test_envelopes(2);
3041 let refreshed = make_test_envelopes(5);
3042 app.labels = labels;
3043 app.envelopes = initial.clone();
3044 app.all_envelopes = initial;
3045 app.pending_active_label = Some(inbox_id);
3046
3047 apply_all_envelopes_refresh(&mut app, refreshed.clone());
3048
3049 assert_eq!(app.all_envelopes.len(), refreshed.len());
3050 assert_eq!(app.all_envelopes[0].id, refreshed[0].id);
3051 assert_eq!(app.envelopes.len(), 2);
3052 }
3053
3054 #[test]
3055 fn label_counts_refresh_can_follow_empty_boot() {
3056 let mut app = App::new();
3057 app.desired_system_mailbox = Some("INBOX".into());
3058
3059 handle_daemon_event(
3060 &mut app,
3061 DaemonEvent::SyncCompleted {
3062 account_id: AccountId::new(),
3063 messages_synced: 0,
3064 },
3065 );
3066
3067 assert!(app.pending_labels_refresh);
3068 assert!(app.pending_all_envelopes_refresh);
3069 assert_eq!(app.desired_system_mailbox.as_deref(), Some("INBOX"));
3070 }
3071
3072 #[test]
3073 fn clear_filter_restores_all_envelopes() {
3074 let mut app = App::new();
3075 app.envelopes = make_test_envelopes(10);
3076 app.all_envelopes = app.envelopes.clone();
3077 app.labels = make_test_labels();
3078 let inbox_id = app
3079 .labels
3080 .iter()
3081 .find(|l| l.name == "INBOX")
3082 .unwrap()
3083 .id
3084 .clone();
3085 app.active_label = Some(inbox_id);
3086 app.envelopes = vec![app.envelopes[0].clone()]; app.selected_index = 0;
3088 app.apply(Action::ClearFilter);
3089 assert!(app.active_label.is_none());
3090 assert_eq!(app.envelopes.len(), 10, "Should restore full list");
3091 }
3092
3093 #[test]
3096 fn archive_removes_from_list() {
3097 let mut app = App::new();
3098 app.envelopes = make_test_envelopes(5);
3099 app.all_envelopes = app.envelopes.clone();
3100 let removed_id = app.envelopes[0].id.clone();
3101 app.apply(Action::Archive);
3102 assert!(!app.pending_mutation_queue.is_empty());
3103 assert_eq!(app.envelopes.len(), 4);
3104 assert!(!app
3105 .envelopes
3106 .iter()
3107 .any(|envelope| envelope.id == removed_id));
3108 }
3109
3110 #[test]
3111 fn star_updates_flags_in_place() {
3112 let mut app = App::new();
3113 app.envelopes = make_test_envelopes(3);
3114 app.all_envelopes = app.envelopes.clone();
3115 assert!(!app.envelopes[0].flags.contains(MessageFlags::STARRED));
3117 app.apply(Action::Star);
3118 assert!(!app.pending_mutation_queue.is_empty());
3119 assert_eq!(app.pending_mutation_count, 1);
3120 assert!(app.envelopes[0].flags.contains(MessageFlags::STARRED));
3121 }
3122
3123 #[test]
3124 fn bulk_mark_read_applies_flags_when_confirmed() {
3125 let mut app = App::new();
3126 let mut envelopes = make_test_envelopes(3);
3127 for envelope in &mut envelopes {
3128 envelope.flags.remove(MessageFlags::READ);
3129 }
3130 app.envelopes = envelopes.clone();
3131 app.all_envelopes = envelopes.clone();
3132 app.selected_set = envelopes
3133 .iter()
3134 .map(|envelope| envelope.id.clone())
3135 .collect();
3136
3137 app.apply(Action::MarkRead);
3138 assert!(app.pending_mutation_queue.is_empty());
3139 assert!(app.pending_bulk_confirm.is_some());
3140 assert!(app
3141 .envelopes
3142 .iter()
3143 .all(|envelope| !envelope.flags.contains(MessageFlags::READ)));
3144
3145 app.apply(Action::OpenSelected);
3146
3147 assert_eq!(app.pending_mutation_queue.len(), 1);
3148 assert_eq!(app.pending_mutation_count, 1);
3149 assert!(app.pending_bulk_confirm.is_none());
3150 assert!(app
3151 .envelopes
3152 .iter()
3153 .all(|envelope| envelope.flags.contains(MessageFlags::READ)));
3154 assert_eq!(
3155 app.pending_mutation_status.as_deref(),
3156 Some("Marking 3 messages as read...")
3157 );
3158 }
3159
3160 #[test]
3161 fn status_bar_shows_pending_mutation_indicator_after_other_actions() {
3162 let mut app = App::new();
3163 let mut envelopes = make_test_envelopes(2);
3164 for envelope in &mut envelopes {
3165 envelope.flags.remove(MessageFlags::READ);
3166 }
3167 app.envelopes = envelopes.clone();
3168 app.all_envelopes = envelopes;
3169
3170 app.apply(Action::MarkRead);
3171 app.apply(Action::MoveDown);
3172
3173 let state = app.status_bar_state();
3174 assert_eq!(state.pending_mutation_count, 1);
3175 assert_eq!(
3176 state.pending_mutation_status.as_deref(),
3177 Some("Marking 1 message as read...")
3178 );
3179 }
3180
3181 #[test]
3182 fn mark_read_and_archive_removes_message_optimistically_and_queues_mutation() {
3183 let mut app = App::new();
3184 let mut envelopes = make_test_envelopes(1);
3185 envelopes[0].flags.remove(MessageFlags::READ);
3186 app.envelopes = envelopes.clone();
3187 app.all_envelopes = envelopes;
3188 let message_id = app.envelopes[0].id.clone();
3189
3190 app.apply(Action::MarkReadAndArchive);
3191
3192 assert!(app.envelopes.is_empty());
3193 assert!(app.all_envelopes.is_empty());
3194 assert_eq!(app.pending_mutation_queue.len(), 1);
3195 match &app.pending_mutation_queue[0].0 {
3196 Request::Mutation(MutationCommand::ReadAndArchive { message_ids }) => {
3197 assert_eq!(message_ids, &vec![message_id]);
3198 }
3199 other => panic!("expected read-and-archive mutation, got {other:?}"),
3200 }
3201 }
3202
3203 #[test]
3204 fn bulk_mark_read_and_archive_removes_messages_when_confirmed() {
3205 let mut app = App::new();
3206 let mut envelopes = make_test_envelopes(3);
3207 for envelope in &mut envelopes {
3208 envelope.flags.remove(MessageFlags::READ);
3209 }
3210 app.envelopes = envelopes.clone();
3211 app.all_envelopes = envelopes.clone();
3212 app.selected_set = envelopes
3213 .iter()
3214 .map(|envelope| envelope.id.clone())
3215 .collect();
3216
3217 app.apply(Action::MarkReadAndArchive);
3218 assert!(app.pending_bulk_confirm.is_some());
3219 assert_eq!(app.envelopes.len(), 3);
3220
3221 app.apply(Action::OpenSelected);
3222
3223 assert!(app.pending_bulk_confirm.is_none());
3224 assert_eq!(app.pending_mutation_queue.len(), 1);
3225 assert_eq!(app.pending_mutation_count, 1);
3226 assert!(app.envelopes.is_empty());
3227 assert!(app.all_envelopes.is_empty());
3228 assert_eq!(
3229 app.pending_mutation_status.as_deref(),
3230 Some("Marking 3 messages as read and archiving...")
3231 );
3232 }
3233
3234 #[test]
3235 fn mutation_failure_opens_error_modal_and_refreshes_mailbox() {
3236 let mut app = App::new();
3237
3238 app.show_mutation_failure(&MxrError::Ipc("boom".into()));
3239 app.refresh_mailbox_after_mutation_failure();
3240
3241 assert!(app.error_modal.is_some());
3242 assert_eq!(
3243 app.error_modal.as_ref().map(|modal| modal.title.as_str()),
3244 Some("Mutation Failed")
3245 );
3246 assert!(app.pending_labels_refresh);
3247 assert!(app.pending_all_envelopes_refresh);
3248 assert!(app.pending_status_refresh);
3249 assert!(app.pending_subscriptions_refresh);
3250 }
3251
3252 #[test]
3253 fn mutation_failure_reloads_pending_label_fetch() {
3254 let mut app = App::new();
3255 let inbox_id = LabelId::new();
3256 app.pending_active_label = Some(inbox_id.clone());
3257
3258 app.refresh_mailbox_after_mutation_failure();
3259
3260 assert_eq!(app.pending_label_fetch.as_ref(), Some(&inbox_id));
3261 }
3262
3263 #[test]
3264 fn archive_viewing_message_effect() {
3265 let mut app = App::new();
3266 app.envelopes = make_test_envelopes(3);
3267 app.all_envelopes = app.envelopes.clone();
3268 app.apply(Action::OpenSelected);
3270 assert!(app.viewing_envelope.is_some());
3271 let viewing_id = app.viewing_envelope.as_ref().unwrap().id.clone();
3272 app.apply(Action::Archive);
3275 let (_, effect) = app.pending_mutation_queue.remove(0);
3276 match &effect {
3278 MutationEffect::RemoveFromList(id) => {
3279 assert_eq!(*id, viewing_id);
3280 }
3281 _ => panic!("Expected RemoveFromList"),
3282 }
3283 }
3284
3285 #[test]
3286 fn archive_keeps_reader_open_and_selects_next_message() {
3287 let mut app = App::new();
3288 app.envelopes = make_test_envelopes(3);
3289 app.all_envelopes = app.envelopes.clone();
3290
3291 app.apply(Action::OpenSelected);
3292 let removed_id = app.viewing_envelope.as_ref().unwrap().id.clone();
3293 let next_id = app.envelopes[1].id.clone();
3294
3295 app.apply_removed_message_ids(&[removed_id]);
3296
3297 assert_eq!(app.layout_mode, LayoutMode::ThreePane);
3298 assert_eq!(app.selected_index, 0);
3299 assert_eq!(app.active_pane, ActivePane::MessageView);
3300 assert_eq!(
3301 app.viewing_envelope
3302 .as_ref()
3303 .map(|envelope| envelope.id.clone()),
3304 Some(next_id)
3305 );
3306 }
3307
3308 #[test]
3309 fn archive_keeps_mail_list_focus_when_reader_was_visible() {
3310 let mut app = App::new();
3311 app.envelopes = make_test_envelopes(3);
3312 app.all_envelopes = app.envelopes.clone();
3313
3314 app.apply(Action::OpenSelected);
3315 app.active_pane = ActivePane::MailList;
3316 let removed_id = app.viewing_envelope.as_ref().unwrap().id.clone();
3317 let next_id = app.envelopes[1].id.clone();
3318
3319 app.apply_removed_message_ids(&[removed_id]);
3320
3321 assert_eq!(app.layout_mode, LayoutMode::ThreePane);
3322 assert_eq!(app.active_pane, ActivePane::MailList);
3323 assert_eq!(
3324 app.viewing_envelope
3325 .as_ref()
3326 .map(|envelope| envelope.id.clone()),
3327 Some(next_id)
3328 );
3329 }
3330
3331 #[test]
3332 fn archive_last_visible_message_closes_reader() {
3333 let mut app = App::new();
3334 app.envelopes = make_test_envelopes(1);
3335 app.all_envelopes = app.envelopes.clone();
3336
3337 app.apply(Action::OpenSelected);
3338 let removed_id = app.viewing_envelope.as_ref().unwrap().id.clone();
3339
3340 app.apply_removed_message_ids(&[removed_id]);
3341
3342 assert_eq!(app.layout_mode, LayoutMode::TwoPane);
3343 assert_eq!(app.active_pane, ActivePane::MailList);
3344 assert!(app.viewing_envelope.is_none());
3345 assert!(app.envelopes.is_empty());
3346 }
3347
3348 #[test]
3351 fn mail_list_title_shows_message_count() {
3352 let mut app = App::new();
3353 app.envelopes = make_test_envelopes(5);
3354 app.all_envelopes = app.envelopes.clone();
3355 let title = app.mail_list_title();
3356 assert!(title.contains("5"), "Title should show message count");
3357 assert!(
3358 title.contains("Threads"),
3359 "Default title should say Threads"
3360 );
3361 }
3362
3363 #[test]
3364 fn mail_list_title_shows_label_name() {
3365 let mut app = App::new();
3366 app.envelopes = make_test_envelopes(5);
3367 app.all_envelopes = app.envelopes.clone();
3368 app.labels = make_test_labels();
3369 let inbox_id = app
3370 .labels
3371 .iter()
3372 .find(|l| l.name == "INBOX")
3373 .unwrap()
3374 .id
3375 .clone();
3376 app.active_label = Some(inbox_id);
3377 let title = app.mail_list_title();
3378 assert!(
3379 title.contains("Inbox"),
3380 "Title should show humanized label name"
3381 );
3382 }
3383
3384 #[test]
3385 fn mail_list_title_shows_search_query() {
3386 let mut app = App::new();
3387 app.envelopes = make_test_envelopes(5);
3388 app.all_envelopes = app.envelopes.clone();
3389 app.search_active = true;
3390 app.search_bar.query = "deployment".to_string();
3391 let title = app.mail_list_title();
3392 assert!(
3393 title.contains("deployment"),
3394 "Title should show search query"
3395 );
3396 assert!(title.contains("Search"), "Title should indicate search");
3397 }
3398
3399 #[test]
3400 fn message_view_body_display() {
3401 let mut app = App::new();
3402 app.envelopes = make_test_envelopes(3);
3403 app.all_envelopes = app.envelopes.clone();
3404 app.apply(Action::OpenMessageView);
3405 assert_eq!(app.layout_mode, LayoutMode::ThreePane);
3406 app.body_view_state = BodyViewState::Ready {
3407 raw: "Hello".into(),
3408 rendered: "Hello".into(),
3409 source: BodySource::Plain,
3410 };
3411 assert_eq!(app.body_view_state.display_text(), Some("Hello"));
3412 app.apply(Action::CloseMessageView);
3413 assert!(matches!(app.body_view_state, BodyViewState::Empty { .. }));
3414 }
3415
3416 #[test]
3417 fn close_message_view_preserves_reader_mode() {
3418 let mut app = App::new();
3419 app.envelopes = make_test_envelopes(1);
3420 app.all_envelopes = app.envelopes.clone();
3421 app.apply(Action::OpenMessageView);
3422
3423 app.apply(Action::CloseMessageView);
3424
3425 assert!(app.reader_mode);
3426 }
3427
3428 #[test]
3429 fn open_selected_populates_visible_thread_messages() {
3430 let mut app = App::new();
3431 app.envelopes = make_test_envelopes(3);
3432 let shared_thread = ThreadId::new();
3433 app.envelopes[0].thread_id = shared_thread.clone();
3434 app.envelopes[1].thread_id = shared_thread;
3435 app.envelopes[0].date = chrono::Utc::now() - chrono::Duration::minutes(5);
3436 app.envelopes[1].date = chrono::Utc::now();
3437 app.all_envelopes = app.envelopes.clone();
3438
3439 app.apply(Action::OpenSelected);
3440
3441 assert_eq!(app.viewed_thread_messages.len(), 2);
3442 assert_eq!(app.viewed_thread_messages[0].id, app.envelopes[0].id);
3443 assert_eq!(app.viewed_thread_messages[1].id, app.envelopes[1].id);
3444 }
3445
3446 #[test]
3447 fn mail_list_defaults_to_threads() {
3448 let mut app = App::new();
3449 app.envelopes = make_test_envelopes(3);
3450 let shared_thread = ThreadId::new();
3451 app.envelopes[0].thread_id = shared_thread.clone();
3452 app.envelopes[1].thread_id = shared_thread;
3453 app.all_envelopes = app.envelopes.clone();
3454
3455 assert_eq!(app.mail_list_rows().len(), 2);
3456 assert_eq!(
3457 app.selected_mail_row().map(|row| row.message_count),
3458 Some(2)
3459 );
3460 }
3461
3462 #[test]
3463 fn open_thread_focuses_latest_unread_message() {
3464 let mut app = App::new();
3465 app.envelopes = make_test_envelopes(3);
3466 let shared_thread = ThreadId::new();
3467 app.envelopes[0].thread_id = shared_thread.clone();
3468 app.envelopes[1].thread_id = shared_thread;
3469 app.envelopes[0].date = chrono::Utc::now() - chrono::Duration::minutes(10);
3470 app.envelopes[1].date = chrono::Utc::now();
3471 app.envelopes[0].flags = MessageFlags::READ;
3472 app.envelopes[1].flags = MessageFlags::empty();
3473 app.all_envelopes = app.envelopes.clone();
3474
3475 app.apply(Action::OpenSelected);
3476
3477 assert_eq!(app.thread_selected_index, 1);
3478 assert_eq!(
3479 app.focused_thread_envelope().map(|env| env.id.clone()),
3480 Some(app.envelopes[1].id.clone())
3481 );
3482 }
3483
3484 #[test]
3485 fn open_selected_marks_unread_message_read_after_dwell() {
3486 let mut app = App::new();
3487 app.envelopes = make_test_envelopes(1);
3488 app.envelopes[0].flags = MessageFlags::empty();
3489 app.all_envelopes = app.envelopes.clone();
3490
3491 app.apply(Action::OpenSelected);
3492
3493 assert!(!app.envelopes[0].flags.contains(MessageFlags::READ));
3494 assert!(!app.all_envelopes[0].flags.contains(MessageFlags::READ));
3495 assert!(!app.viewed_thread_messages[0]
3496 .flags
3497 .contains(MessageFlags::READ));
3498 assert!(!app
3499 .viewing_envelope
3500 .as_ref()
3501 .unwrap()
3502 .flags
3503 .contains(MessageFlags::READ));
3504 assert!(app.pending_mutation_queue.is_empty());
3505
3506 app.expire_pending_preview_read_for_tests();
3507 app.tick();
3508
3509 assert!(app.envelopes[0].flags.contains(MessageFlags::READ));
3510 assert!(app.all_envelopes[0].flags.contains(MessageFlags::READ));
3511 assert!(app.viewed_thread_messages[0]
3512 .flags
3513 .contains(MessageFlags::READ));
3514 assert!(app
3515 .viewing_envelope
3516 .as_ref()
3517 .unwrap()
3518 .flags
3519 .contains(MessageFlags::READ));
3520 assert_eq!(app.pending_mutation_queue.len(), 1);
3521 match &app.pending_mutation_queue[0].0 {
3522 Request::Mutation(MutationCommand::SetRead { message_ids, read }) => {
3523 assert!(*read);
3524 assert_eq!(message_ids, &vec![app.envelopes[0].id.clone()]);
3525 }
3526 other => panic!("expected set-read mutation, got {other:?}"),
3527 }
3528 }
3529
3530 #[test]
3531 fn open_selected_on_read_message_does_not_queue_read_mutation() {
3532 let mut app = App::new();
3533 app.envelopes = make_test_envelopes(1);
3534 app.envelopes[0].flags = MessageFlags::READ;
3535 app.all_envelopes = app.envelopes.clone();
3536
3537 app.apply(Action::OpenSelected);
3538 app.expire_pending_preview_read_for_tests();
3539 app.tick();
3540
3541 assert!(app.pending_mutation_queue.is_empty());
3542 }
3543
3544 #[test]
3545 fn reopening_same_message_does_not_queue_duplicate_read_mutation() {
3546 let mut app = App::new();
3547 app.envelopes = make_test_envelopes(1);
3548 app.envelopes[0].flags = MessageFlags::empty();
3549 app.all_envelopes = app.envelopes.clone();
3550
3551 app.apply(Action::OpenSelected);
3552 app.apply(Action::OpenSelected);
3553
3554 assert!(app.pending_mutation_queue.is_empty());
3555 app.expire_pending_preview_read_for_tests();
3556 app.tick();
3557 assert_eq!(app.pending_mutation_queue.len(), 1);
3558 }
3559
3560 #[test]
3561 fn thread_move_down_changes_reply_target() {
3562 let mut app = App::new();
3563 app.envelopes = make_test_envelopes(2);
3564 let shared_thread = ThreadId::new();
3565 app.envelopes[0].thread_id = shared_thread.clone();
3566 app.envelopes[1].thread_id = shared_thread;
3567 app.envelopes[0].date = chrono::Utc::now() - chrono::Duration::minutes(5);
3568 app.envelopes[1].date = chrono::Utc::now();
3569 app.envelopes[0].flags = MessageFlags::empty();
3570 app.envelopes[1].flags = MessageFlags::READ;
3571 app.all_envelopes = app.envelopes.clone();
3572
3573 app.apply(Action::OpenSelected);
3574 assert_eq!(
3575 app.focused_thread_envelope().map(|env| env.id.clone()),
3576 Some(app.envelopes[0].id.clone())
3577 );
3578
3579 let _ = app.handle_key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
3580
3581 assert_eq!(
3582 app.focused_thread_envelope().map(|env| env.id.clone()),
3583 Some(app.envelopes[1].id.clone())
3584 );
3585 app.apply(Action::Reply);
3586 assert_eq!(
3587 app.pending_compose,
3588 Some(super::app::ComposeAction::Reply {
3589 message_id: app.envelopes[1].id.clone()
3590 })
3591 );
3592 }
3593
3594 #[test]
3595 fn thread_focus_change_marks_newly_focused_unread_message_read_after_dwell() {
3596 let mut app = App::new();
3597 app.envelopes = make_test_envelopes(2);
3598 let shared_thread = ThreadId::new();
3599 app.envelopes[0].thread_id = shared_thread.clone();
3600 app.envelopes[1].thread_id = shared_thread;
3601 app.envelopes[0].date = chrono::Utc::now() - chrono::Duration::minutes(5);
3602 app.envelopes[1].date = chrono::Utc::now();
3603 app.envelopes[0].flags = MessageFlags::empty();
3604 app.envelopes[1].flags = MessageFlags::empty();
3605 app.all_envelopes = app.envelopes.clone();
3606
3607 app.apply(Action::OpenSelected);
3608 assert_eq!(app.thread_selected_index, 1);
3609 assert!(app.pending_mutation_queue.is_empty());
3610
3611 let _ = app.handle_key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE));
3612
3613 assert_eq!(app.thread_selected_index, 0);
3614 assert!(!app.viewed_thread_messages[0]
3615 .flags
3616 .contains(MessageFlags::READ));
3617 assert!(app.pending_mutation_queue.is_empty());
3618
3619 app.expire_pending_preview_read_for_tests();
3620 app.tick();
3621
3622 assert!(app.viewed_thread_messages[0]
3623 .flags
3624 .contains(MessageFlags::READ));
3625 assert!(app
3626 .viewing_envelope
3627 .as_ref()
3628 .unwrap()
3629 .flags
3630 .contains(MessageFlags::READ));
3631 assert_eq!(app.pending_mutation_queue.len(), 1);
3632 match &app.pending_mutation_queue[0].0 {
3633 Request::Mutation(MutationCommand::SetRead { message_ids, read }) => {
3634 assert!(*read);
3635 assert_eq!(message_ids, &vec![app.envelopes[0].id.clone()]);
3636 }
3637 other => panic!("expected set-read mutation, got {other:?}"),
3638 }
3639 }
3640
3641 #[test]
3642 fn preview_navigation_only_marks_message_read_after_settling() {
3643 let mut app = App::new();
3644 app.envelopes = make_test_envelopes(2);
3645 app.envelopes[0].flags = MessageFlags::empty();
3646 app.envelopes[1].flags = MessageFlags::empty();
3647 app.envelopes[0].thread_id = ThreadId::new();
3648 app.envelopes[1].thread_id = ThreadId::new();
3649 app.envelopes[0].date = chrono::Utc::now() - chrono::Duration::minutes(1);
3650 app.envelopes[1].date = chrono::Utc::now();
3651 app.all_envelopes = app.envelopes.clone();
3652
3653 app.apply(Action::OpenSelected);
3654 app.apply(Action::MoveDown);
3655
3656 assert!(!app.envelopes[0].flags.contains(MessageFlags::READ));
3657 assert!(!app.envelopes[1].flags.contains(MessageFlags::READ));
3658 assert!(app.pending_mutation_queue.is_empty());
3659
3660 app.expire_pending_preview_read_for_tests();
3661 app.tick();
3662
3663 assert!(!app.envelopes[0].flags.contains(MessageFlags::READ));
3664 assert!(app.envelopes[1].flags.contains(MessageFlags::READ));
3665 assert_eq!(app.pending_mutation_queue.len(), 1);
3666 match &app.pending_mutation_queue[0].0 {
3667 Request::Mutation(MutationCommand::SetRead { message_ids, read }) => {
3668 assert!(*read);
3669 assert_eq!(message_ids, &vec![app.envelopes[1].id.clone()]);
3670 }
3671 other => panic!("expected set-read mutation, got {other:?}"),
3672 }
3673 }
3674
3675 #[test]
3676 fn help_action_toggles_modal_state() {
3677 let mut app = App::new();
3678
3679 app.apply(Action::Help);
3680 assert!(app.help_modal_open);
3681
3682 app.apply(Action::Help);
3683 assert!(!app.help_modal_open);
3684 }
3685
3686 #[test]
3687 fn open_search_screen_activates_dedicated_search_workspace() {
3688 let mut app = App::new();
3689 app.apply(Action::OpenSearchScreen);
3690 assert_eq!(app.screen, Screen::Search);
3691 assert!(app.search_page.editing);
3692 }
3693
3694 #[test]
3695 fn search_screen_typing_updates_results_and_queues_search() {
3696 let mut app = App::new();
3697 let mut envelopes = make_test_envelopes(2);
3698 envelopes[0].subject = "crates.io release".into();
3699 envelopes[0].snippet = "mxr publish".into();
3700 envelopes[1].subject = "support request".into();
3701 envelopes[1].snippet = "billing".into();
3702 app.envelopes = envelopes.clone();
3703 app.all_envelopes = envelopes;
3704
3705 app.apply(Action::OpenSearchScreen);
3706 app.search_page.query.clear();
3707 app.search_page.results = app.all_envelopes.clone();
3708
3709 for ch in "crate".chars() {
3710 let action = app.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
3711 assert!(action.is_none());
3712 }
3713
3714 assert_eq!(app.search_page.query, "crate");
3715 assert!(app.search_page.results.is_empty());
3716 assert!(app.search_page.loading_more);
3717 assert_eq!(
3718 app.pending_search,
3719 Some(PendingSearchRequest {
3720 query: "crate".into(),
3721 mode: mxr_core::SearchMode::Lexical,
3722 sort: mxr_core::SortOrder::DateDesc,
3723 limit: SEARCH_PAGE_SIZE,
3724 offset: 0,
3725 target: SearchTarget::SearchPage,
3726 append: false,
3727 session_id: app.search_page.session_id,
3728 })
3729 );
3730 }
3731
3732 #[test]
3733 fn open_search_screen_preserves_existing_search_session() {
3734 let mut app = App::new();
3735 let results = make_test_envelopes(2);
3736 app.search_bar.query = "stale overlay".into();
3737 app.search_page.query = "deploy".into();
3738 app.search_page.results = results.clone();
3739 app.search_page.session_active = true;
3740 app.search_page.selected_index = 1;
3741 app.search_page.active_pane = SearchPane::Preview;
3742 app.viewing_envelope = Some(results[1].clone());
3743
3744 app.apply(Action::OpenRulesScreen);
3745 app.apply(Action::OpenSearchScreen);
3746
3747 assert_eq!(app.screen, Screen::Search);
3748 assert_eq!(app.search_page.query, "deploy");
3749 assert_eq!(app.search_page.results.len(), 2);
3750 assert_eq!(app.search_page.selected_index, 1);
3751 assert_eq!(app.search_page.active_pane, SearchPane::Preview);
3752 assert_eq!(
3753 app.viewing_envelope.as_ref().map(|env| env.id.clone()),
3754 Some(results[1].id.clone())
3755 );
3756 assert!(app.pending_search.is_none());
3757 }
3758
3759 #[test]
3760 fn search_open_selected_keeps_search_screen_and_focuses_preview() {
3761 let mut app = App::new();
3762 let results = make_test_envelopes(2);
3763 app.screen = Screen::Search;
3764 app.search_page.query = "deploy".into();
3765 app.search_page.results = results.clone();
3766 app.search_page.session_active = true;
3767 app.search_page.selected_index = 1;
3768
3769 app.apply(Action::OpenSelected);
3770
3771 assert_eq!(app.screen, Screen::Search);
3772 assert_eq!(app.search_page.active_pane, SearchPane::Preview);
3773 assert_eq!(
3774 app.viewing_envelope.as_ref().map(|env| env.id.clone()),
3775 Some(results[1].id.clone())
3776 );
3777 }
3778
3779 #[test]
3780 fn search_jump_bottom_loads_remaining_pages() {
3781 let mut app = App::new();
3782 app.screen = Screen::Search;
3783 app.search_page.query = "deploy".into();
3784 app.search_page.results = make_test_envelopes(3);
3785 app.search_page.session_active = true;
3786 app.search_page.has_more = true;
3787 app.search_page.loading_more = false;
3788 app.search_page.session_id = 9;
3789
3790 app.apply(Action::JumpBottom);
3791
3792 assert!(app.search_page.load_to_end);
3793 assert!(app.search_page.loading_more);
3794 assert_eq!(
3795 app.pending_search,
3796 Some(PendingSearchRequest {
3797 query: "deploy".into(),
3798 mode: mxr_core::SearchMode::Lexical,
3799 sort: mxr_core::SortOrder::DateDesc,
3800 limit: SEARCH_PAGE_SIZE,
3801 offset: 3,
3802 target: SearchTarget::SearchPage,
3803 append: true,
3804 session_id: 9,
3805 })
3806 );
3807 }
3808
3809 #[test]
3810 fn search_escape_routes_back_to_inbox() {
3811 let mut app = App::new();
3812 app.screen = Screen::Search;
3813 app.search_page.session_active = true;
3814 app.search_page.query = "deploy".into();
3815 app.search_page.results = make_test_envelopes(2);
3816 app.search_page.active_pane = SearchPane::Results;
3817
3818 let action = app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
3819
3820 assert_eq!(action, Some(Action::GoToInbox));
3821 }
3822
3823 #[test]
3824 fn open_rules_screen_marks_refresh_pending() {
3825 let mut app = App::new();
3826 app.apply(Action::OpenRulesScreen);
3827 assert_eq!(app.screen, Screen::Rules);
3828 assert!(app.rules_page.refresh_pending);
3829 }
3830
3831 #[test]
3832 fn open_diagnostics_screen_marks_refresh_pending() {
3833 let mut app = App::new();
3834 app.apply(Action::OpenDiagnosticsScreen);
3835 assert_eq!(app.screen, Screen::Diagnostics);
3836 assert!(app.diagnostics_page.refresh_pending);
3837 }
3838
3839 #[test]
3840 fn open_accounts_screen_marks_refresh_pending() {
3841 let mut app = App::new();
3842 app.apply(Action::OpenAccountsScreen);
3843 assert_eq!(app.screen, Screen::Accounts);
3844 assert!(app.accounts_page.refresh_pending);
3845 }
3846
3847 #[test]
3848 fn new_account_form_opens_from_accounts_screen() {
3849 let mut app = App::new();
3850 app.apply(Action::OpenAccountsScreen);
3851 app.apply(Action::OpenAccountFormNew);
3852
3853 assert_eq!(app.screen, Screen::Accounts);
3854 assert!(app.accounts_page.form.visible);
3855 assert_eq!(
3856 app.accounts_page.form.mode,
3857 crate::app::AccountFormMode::Gmail
3858 );
3859 }
3860
3861 #[test]
3862 fn app_from_empty_config_enters_account_onboarding() {
3863 let config = mxr_config::MxrConfig::default();
3864 let app = App::from_config(&config);
3865
3866 assert_eq!(app.screen, Screen::Accounts);
3867 assert!(app.accounts_page.refresh_pending);
3868 assert!(app.accounts_page.onboarding_required);
3869 assert!(app.accounts_page.onboarding_modal_open);
3870 }
3871
3872 #[test]
3873 fn onboarding_confirm_opens_new_account_form() {
3874 let config = mxr_config::MxrConfig::default();
3875 let mut app = App::from_config(&config);
3876
3877 app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
3878
3879 assert_eq!(app.screen, Screen::Accounts);
3880 assert!(app.accounts_page.form.visible);
3881 assert!(!app.accounts_page.onboarding_modal_open);
3882 }
3883
3884 #[test]
3885 fn onboarding_q_quits() {
3886 let config = mxr_config::MxrConfig::default();
3887 let mut app = App::from_config(&config);
3888
3889 let action = app.handle_key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE));
3890
3891 assert_eq!(action, Some(Action::QuitView));
3892 }
3893
3894 #[test]
3895 fn onboarding_blocks_mailbox_screen_until_account_exists() {
3896 let config = mxr_config::MxrConfig::default();
3897 let mut app = App::from_config(&config);
3898
3899 app.apply(Action::OpenMailboxScreen);
3900
3901 assert_eq!(app.screen, Screen::Accounts);
3902 assert!(app.accounts_page.onboarding_required);
3903 }
3904
3905 #[test]
3906 fn account_form_h_and_l_switch_modes_from_any_field() {
3907 let mut app = App::new();
3908 app.apply(Action::OpenAccountFormNew);
3909 app.accounts_page.form.active_field = 2;
3910
3911 app.handle_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE));
3912 assert_eq!(
3913 app.accounts_page.form.mode,
3914 crate::app::AccountFormMode::ImapSmtp
3915 );
3916
3917 app.handle_key(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE));
3918 assert_eq!(
3919 app.accounts_page.form.mode,
3920 crate::app::AccountFormMode::Gmail
3921 );
3922 }
3923
3924 #[test]
3925 fn account_form_tab_on_mode_cycles_modes() {
3926 let mut app = App::new();
3927 app.apply(Action::OpenAccountFormNew);
3928 app.accounts_page.form.active_field = 0;
3929
3930 app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
3931 assert_eq!(
3932 app.accounts_page.form.mode,
3933 crate::app::AccountFormMode::ImapSmtp
3934 );
3935
3936 app.handle_key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT));
3937 assert_eq!(
3938 app.accounts_page.form.mode,
3939 crate::app::AccountFormMode::Gmail
3940 );
3941 }
3942
3943 #[test]
3944 fn account_form_mode_switch_with_input_requires_confirmation() {
3945 let mut app = App::new();
3946 app.apply(Action::OpenAccountFormNew);
3947 app.accounts_page.form.key = "work".into();
3948
3949 app.handle_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE));
3950
3951 assert_eq!(
3952 app.accounts_page.form.mode,
3953 crate::app::AccountFormMode::Gmail
3954 );
3955 assert_eq!(
3956 app.accounts_page.form.pending_mode_switch,
3957 Some(crate::app::AccountFormMode::ImapSmtp)
3958 );
3959 }
3960
3961 #[test]
3962 fn account_form_mode_switch_confirmation_applies_mode_change() {
3963 let mut app = App::new();
3964 app.apply(Action::OpenAccountFormNew);
3965 app.accounts_page.form.key = "work".into();
3966
3967 app.handle_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE));
3968 app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
3969
3970 assert_eq!(
3971 app.accounts_page.form.mode,
3972 crate::app::AccountFormMode::ImapSmtp
3973 );
3974 assert!(app.accounts_page.form.pending_mode_switch.is_none());
3975 }
3976
3977 #[test]
3978 fn account_form_mode_switch_confirmation_cancel_keeps_mode() {
3979 let mut app = App::new();
3980 app.apply(Action::OpenAccountFormNew);
3981 app.accounts_page.form.key = "work".into();
3982
3983 app.handle_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE));
3984 app.handle_key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
3985
3986 assert_eq!(
3987 app.accounts_page.form.mode,
3988 crate::app::AccountFormMode::Gmail
3989 );
3990 assert!(app.accounts_page.form.pending_mode_switch.is_none());
3991 }
3992
3993 #[test]
3994 fn flattened_sidebar_navigation_reaches_saved_searches() {
3995 let mut app = App::new();
3996 app.labels = vec![Label {
3997 id: LabelId::new(),
3998 account_id: AccountId::new(),
3999 provider_id: "inbox".into(),
4000 name: "INBOX".into(),
4001 kind: LabelKind::System,
4002 color: None,
4003 unread_count: 1,
4004 total_count: 3,
4005 }];
4006 app.saved_searches = vec![SavedSearch {
4007 id: SavedSearchId::new(),
4008 account_id: None,
4009 name: "Unread".into(),
4010 query: "is:unread".into(),
4011 search_mode: SearchMode::Lexical,
4012 sort: SortOrder::DateDesc,
4013 icon: None,
4014 position: 0,
4015 created_at: chrono::Utc::now(),
4016 }];
4017 app.active_pane = ActivePane::Sidebar;
4018
4019 let _ = app.handle_key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
4020 let _ = app.handle_key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
4021 let _ = app.handle_key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
4022
4023 assert!(matches!(
4024 app.selected_sidebar_item(),
4025 Some(super::app::SidebarItem::SavedSearch(_))
4026 ));
4027 }
4028
4029 #[test]
4030 fn toggle_select_advances_cursor_and_updates_preview() {
4031 let mut app = App::new();
4032 app.envelopes = make_test_envelopes(2);
4033 app.all_envelopes = app.envelopes.clone();
4034 app.apply(Action::OpenSelected);
4035
4036 app.apply(Action::ToggleSelect);
4037
4038 assert_eq!(app.selected_index, 1);
4039 assert_eq!(
4040 app.viewing_envelope.as_ref().map(|env| env.id.clone()),
4041 Some(app.envelopes[1].id.clone())
4042 );
4043 assert!(matches!(
4044 app.body_view_state,
4045 BodyViewState::Loading { ref preview }
4046 if preview.as_deref() == Some("Snippet 1")
4047 ));
4048 }
4049
4050 #[test]
4051 fn opening_search_result_keeps_search_workspace_open() {
4052 let mut app = App::new();
4053 app.screen = Screen::Search;
4054 app.search_page.results = make_test_envelopes(2);
4055 app.search_page.selected_index = 1;
4056
4057 app.apply(Action::OpenSelected);
4058
4059 assert_eq!(app.screen, Screen::Search);
4060 assert_eq!(app.search_page.active_pane, SearchPane::Preview);
4061 assert_eq!(
4062 app.viewing_envelope.as_ref().map(|env| env.id.clone()),
4063 Some(app.search_page.results[1].id.clone())
4064 );
4065 }
4066
4067 #[test]
4068 fn attachment_list_opens_modal_for_current_message() {
4069 let mut app = App::new();
4070 app.envelopes = make_test_envelopes(1);
4071 app.all_envelopes = app.envelopes.clone();
4072 let env = app.envelopes[0].clone();
4073 app.body_cache.insert(
4074 env.id.clone(),
4075 MessageBody {
4076 message_id: env.id.clone(),
4077 text_plain: Some("hello".into()),
4078 text_html: None,
4079 attachments: vec![AttachmentMeta {
4080 id: AttachmentId::new(),
4081 message_id: env.id.clone(),
4082 filename: "report.pdf".into(),
4083 mime_type: "application/pdf".into(),
4084 size_bytes: 1024,
4085 local_path: None,
4086 provider_id: "att-1".into(),
4087 }],
4088 fetched_at: chrono::Utc::now(),
4089 metadata: Default::default(),
4090 },
4091 );
4092
4093 app.apply(Action::OpenSelected);
4094 app.apply(Action::AttachmentList);
4095
4096 assert!(app.attachment_panel.visible);
4097 assert_eq!(app.attachment_panel.attachments.len(), 1);
4098 assert_eq!(app.attachment_panel.attachments[0].filename, "report.pdf");
4099 }
4100
4101 #[test]
4102 fn unchanged_editor_result_disables_send_actions() {
4103 let temp = std::env::temp_dir().join(format!(
4104 "mxr-compose-test-{}-{}.md",
4105 std::process::id(),
4106 chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
4107 ));
4108 let content = "---\nto: a@example.com\ncc: \"\"\nbcc: \"\"\nsubject: Hello\nfrom: me@example.com\nattach: []\n---\n\nBody\n";
4109 std::fs::write(&temp, content).unwrap();
4110
4111 let pending = pending_send_from_edited_draft(&ComposeReadyData {
4112 draft_path: temp.clone(),
4113 cursor_line: 1,
4114 initial_content: content.to_string(),
4115 })
4116 .unwrap()
4117 .expect("pending send should exist");
4118
4119 assert!(!pending.allow_send);
4120
4121 let _ = std::fs::remove_file(temp);
4122 }
4123
4124 #[test]
4125 fn send_key_is_ignored_for_unchanged_draft_confirmation() {
4126 let mut app = App::new();
4127 app.pending_send_confirm = Some(PendingSend {
4128 fm: mxr_compose::frontmatter::ComposeFrontmatter {
4129 to: "a@example.com".into(),
4130 cc: String::new(),
4131 bcc: String::new(),
4132 subject: "Hello".into(),
4133 from: "me@example.com".into(),
4134 in_reply_to: None,
4135 references: vec![],
4136 attach: vec![],
4137 },
4138 body: "Body".into(),
4139 draft_path: std::path::PathBuf::from("/tmp/draft.md"),
4140 allow_send: false,
4141 });
4142
4143 let _ = app.handle_key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE));
4144
4145 assert!(app.pending_send_confirm.is_some());
4146 assert!(app.pending_mutation_queue.is_empty());
4147 }
4148
4149 #[test]
4150 fn mail_list_l_opens_label_picker_not_message() {
4151 let mut app = App::new();
4152 app.active_pane = ActivePane::MailList;
4153
4154 let action = app.handle_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE));
4155
4156 assert_eq!(action, Some(Action::ApplyLabel));
4157 }
4158
4159 #[test]
4160 fn input_gc_opens_config_editor() {
4161 let mut h = InputHandler::new();
4162
4163 assert_eq!(
4164 h.handle_key(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE)),
4165 None
4166 );
4167 assert_eq!(
4168 h.handle_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)),
4169 Some(Action::EditConfig)
4170 );
4171 }
4172
4173 #[test]
4174 fn input_g_shift_l_opens_logs() {
4175 let mut h = InputHandler::new();
4176
4177 assert_eq!(
4178 h.handle_key(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE)),
4179 None
4180 );
4181 assert_eq!(
4182 h.handle_key(KeyEvent::new(KeyCode::Char('L'), KeyModifiers::SHIFT)),
4183 Some(Action::OpenLogs)
4184 );
4185 }
4186
4187 #[test]
4188 fn input_m_marks_read_and_archives() {
4189 let mut app = App::new();
4190
4191 let action = app.handle_key(KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE));
4192
4193 assert_eq!(action, Some(Action::MarkReadAndArchive));
4194 }
4195
4196 #[test]
4197 fn reconnect_detection_treats_connection_refused_as_recoverable() {
4198 let result = Err(MxrError::Ipc(
4199 "IPC error: Connection refused (os error 61)".into(),
4200 ));
4201
4202 assert!(crate::should_reconnect_ipc(&result));
4203 }
4204
4205 #[test]
4206 fn autostart_detection_handles_refused_and_missing_socket() {
4207 let refused = std::io::Error::from(std::io::ErrorKind::ConnectionRefused);
4208 let missing = std::io::Error::from(std::io::ErrorKind::NotFound);
4209 let other = std::io::Error::from(std::io::ErrorKind::PermissionDenied);
4210
4211 assert!(crate::should_autostart_daemon(&refused));
4212 assert!(crate::should_autostart_daemon(&missing));
4213 assert!(!crate::should_autostart_daemon(&other));
4214 }
4215
4216 #[test]
4217 fn diagnostics_shift_l_opens_logs() {
4218 let mut app = App::new();
4219 app.screen = Screen::Diagnostics;
4220
4221 let action = app.handle_key(KeyEvent::new(KeyCode::Char('L'), KeyModifiers::SHIFT));
4222
4223 assert_eq!(action, Some(Action::OpenLogs));
4224 }
4225
4226 #[test]
4227 fn diagnostics_tab_cycles_selected_pane() {
4228 let mut app = App::new();
4229 app.screen = Screen::Diagnostics;
4230
4231 let action = app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
4232
4233 assert!(action.is_none());
4234 assert_eq!(
4235 app.diagnostics_page.selected_pane,
4236 crate::app::DiagnosticsPaneKind::Data
4237 );
4238 }
4239
4240 #[test]
4241 fn diagnostics_enter_toggles_fullscreen_for_selected_pane() {
4242 let mut app = App::new();
4243 app.screen = Screen::Diagnostics;
4244 app.diagnostics_page.selected_pane = crate::app::DiagnosticsPaneKind::Logs;
4245
4246 assert!(app
4247 .handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
4248 .is_none());
4249 assert_eq!(
4250 app.diagnostics_page.fullscreen_pane,
4251 Some(crate::app::DiagnosticsPaneKind::Logs)
4252 );
4253 assert!(app
4254 .handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
4255 .is_none());
4256 assert_eq!(app.diagnostics_page.fullscreen_pane, None);
4257 }
4258
4259 #[test]
4260 fn diagnostics_d_opens_selected_pane_details() {
4261 let mut app = App::new();
4262 app.screen = Screen::Diagnostics;
4263 app.diagnostics_page.selected_pane = crate::app::DiagnosticsPaneKind::Events;
4264
4265 let action = app.handle_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
4266
4267 assert_eq!(action, Some(Action::OpenDiagnosticsPaneDetails));
4268 }
4269
4270 #[test]
4271 fn back_clears_selection_before_other_mail_list_back_behavior() {
4272 let mut app = App::new();
4273 app.envelopes = make_test_envelopes(2);
4274 app.all_envelopes = app.envelopes.clone();
4275 app.selected_set.insert(app.envelopes[0].id.clone());
4276
4277 app.apply(Action::Back);
4278
4279 assert!(app.selected_set.is_empty());
4280 assert_eq!(app.status_message.as_deref(), Some("Selection cleared"));
4281 }
4282
4283 #[test]
4284 fn bulk_archive_requires_confirmation_before_queueing() {
4285 let mut app = App::new();
4286 app.envelopes = make_test_envelopes(3);
4287 app.all_envelopes = app.envelopes.clone();
4288 app.selected_set = app.envelopes.iter().map(|env| env.id.clone()).collect();
4289
4290 app.apply(Action::Archive);
4291
4292 assert!(app.pending_mutation_queue.is_empty());
4293 assert!(app.pending_bulk_confirm.is_some());
4294 }
4295
4296 #[test]
4297 fn confirming_bulk_archive_queues_mutation_and_clears_selection() {
4298 let mut app = App::new();
4299 app.envelopes = make_test_envelopes(3);
4300 app.all_envelopes = app.envelopes.clone();
4301 app.selected_set = app.envelopes.iter().map(|env| env.id.clone()).collect();
4302 app.apply(Action::Archive);
4303
4304 app.apply(Action::OpenSelected);
4305
4306 assert!(app.pending_bulk_confirm.is_none());
4307 assert_eq!(app.pending_mutation_queue.len(), 1);
4308 assert!(app.selected_set.is_empty());
4309 }
4310
4311 #[test]
4312 fn command_palette_includes_major_mail_actions() {
4313 let labels: Vec<String> = default_commands()
4314 .into_iter()
4315 .map(|cmd| cmd.label)
4316 .collect();
4317 assert!(labels.contains(&"Reply".to_string()));
4318 assert!(labels.contains(&"Reply All".to_string()));
4319 assert!(labels.contains(&"Archive".to_string()));
4320 assert!(labels.contains(&"Delete".to_string()));
4321 assert!(labels.contains(&"Apply Label".to_string()));
4322 assert!(labels.contains(&"Snooze".to_string()));
4323 assert!(labels.contains(&"Clear Selection".to_string()));
4324 assert!(labels.contains(&"Open Accounts Page".to_string()));
4325 assert!(labels.contains(&"New IMAP/SMTP Account".to_string()));
4326 assert!(labels.contains(&"Set Default Account".to_string()));
4327 assert!(labels.contains(&"Edit Config".to_string()));
4328 }
4329
4330 #[test]
4331 fn local_label_changes_update_open_message() {
4332 let mut app = App::new();
4333 app.labels = make_test_labels();
4334 app.envelopes = make_test_envelopes(1);
4335 app.all_envelopes = app.envelopes.clone();
4336 app.apply(Action::OpenSelected);
4337
4338 let user_label = app
4339 .labels
4340 .iter()
4341 .find(|label| label.name == "Work")
4342 .unwrap()
4343 .clone();
4344 let message_id = app.envelopes[0].id.clone();
4345
4346 app.apply_local_label_refs(
4347 std::slice::from_ref(&message_id),
4348 std::slice::from_ref(&user_label.name),
4349 &[],
4350 );
4351
4352 assert!(app
4353 .viewing_envelope
4354 .as_ref()
4355 .unwrap()
4356 .label_provider_ids
4357 .contains(&user_label.provider_id));
4358 }
4359
4360 #[test]
4361 fn snooze_action_opens_modal_then_queues_request() {
4362 let mut app = App::new();
4363 app.envelopes = make_test_envelopes(1);
4364 app.all_envelopes = app.envelopes.clone();
4365
4366 app.apply(Action::Snooze);
4367 assert!(app.snooze_panel.visible);
4368
4369 app.apply(Action::Snooze);
4370 assert!(!app.snooze_panel.visible);
4371 assert_eq!(app.pending_mutation_queue.len(), 1);
4372 match &app.pending_mutation_queue[0].0 {
4373 Request::Snooze {
4374 message_id,
4375 wake_at,
4376 } => {
4377 assert_eq!(message_id, &app.envelopes[0].id);
4378 assert!(*wake_at > chrono::Utc::now());
4379 }
4380 other => panic!("expected snooze request, got {other:?}"),
4381 }
4382 }
4383
4384 #[test]
4385 fn open_selected_cache_miss_enters_loading_with_snippet_preview() {
4386 let mut app = App::new();
4387 app.envelopes = make_test_envelopes(1);
4388 app.all_envelopes = app.envelopes.clone();
4389
4390 app.apply(Action::OpenSelected);
4391
4392 assert!(matches!(
4393 app.body_view_state,
4394 BodyViewState::Loading { ref preview }
4395 if preview.as_deref() == Some("Snippet 0")
4396 ));
4397 assert_eq!(app.queued_body_fetches, vec![app.envelopes[0].id.clone()]);
4398 assert!(app.in_flight_body_requests.contains(&app.envelopes[0].id));
4399 }
4400
4401 #[test]
4402 fn cached_plain_body_resolves_ready_state() {
4403 let mut app = App::new();
4404 app.envelopes = make_test_envelopes(1);
4405 app.all_envelopes = app.envelopes.clone();
4406 let env = app.envelopes[0].clone();
4407
4408 app.body_cache.insert(
4409 env.id.clone(),
4410 MessageBody {
4411 message_id: env.id.clone(),
4412 text_plain: Some("Plain body".into()),
4413 text_html: None,
4414 attachments: vec![],
4415 fetched_at: chrono::Utc::now(),
4416 metadata: Default::default(),
4417 },
4418 );
4419
4420 app.apply(Action::OpenSelected);
4421
4422 assert!(matches!(
4423 app.body_view_state,
4424 BodyViewState::Ready {
4425 ref raw,
4426 ref rendered,
4427 source: BodySource::Plain,
4428 } if raw == "Plain body" && rendered == "Plain body"
4429 ));
4430 }
4431
4432 #[test]
4433 fn cached_html_only_body_resolves_ready_state() {
4434 let mut app = App::new();
4435 app.envelopes = make_test_envelopes(1);
4436 app.all_envelopes = app.envelopes.clone();
4437 let env = app.envelopes[0].clone();
4438
4439 app.body_cache.insert(
4440 env.id.clone(),
4441 MessageBody {
4442 message_id: env.id.clone(),
4443 text_plain: None,
4444 text_html: Some("<p>Hello html</p>".into()),
4445 attachments: vec![],
4446 fetched_at: chrono::Utc::now(),
4447 metadata: Default::default(),
4448 },
4449 );
4450
4451 app.apply(Action::OpenSelected);
4452
4453 assert!(matches!(
4454 app.body_view_state,
4455 BodyViewState::Ready {
4456 ref raw,
4457 ref rendered,
4458 source: BodySource::Html,
4459 } if raw == "<p>Hello html</p>"
4460 && rendered.contains("Hello html")
4461 && !rendered.contains("<p>")
4462 ));
4463 }
4464
4465 #[test]
4466 fn cached_empty_body_resolves_empty_not_loading() {
4467 let mut app = App::new();
4468 app.envelopes = make_test_envelopes(1);
4469 app.all_envelopes = app.envelopes.clone();
4470 let env = app.envelopes[0].clone();
4471
4472 app.body_cache.insert(
4473 env.id.clone(),
4474 MessageBody {
4475 message_id: env.id.clone(),
4476 text_plain: None,
4477 text_html: None,
4478 attachments: vec![],
4479 fetched_at: chrono::Utc::now(),
4480 metadata: Default::default(),
4481 },
4482 );
4483
4484 app.apply(Action::OpenSelected);
4485
4486 assert!(matches!(
4487 app.body_view_state,
4488 BodyViewState::Empty { ref preview }
4489 if preview.as_deref() == Some("Snippet 0")
4490 ));
4491 }
4492
4493 #[test]
4494 fn body_fetch_error_resolves_error_not_loading() {
4495 let mut app = App::new();
4496 app.envelopes = make_test_envelopes(1);
4497 app.all_envelopes = app.envelopes.clone();
4498 app.apply(Action::OpenSelected);
4499 let env = app.envelopes[0].clone();
4500
4501 app.resolve_body_fetch_error(&env.id, "boom".into());
4502
4503 assert!(matches!(
4504 app.body_view_state,
4505 BodyViewState::Error { ref message, ref preview }
4506 if message == "boom" && preview.as_deref() == Some("Snippet 0")
4507 ));
4508 assert!(!app.in_flight_body_requests.contains(&env.id));
4509 }
4510
4511 #[test]
4512 fn stale_body_response_does_not_clobber_current_view() {
4513 let mut app = App::new();
4514 app.envelopes = make_test_envelopes(2);
4515 app.all_envelopes = app.envelopes.clone();
4516
4517 app.apply(Action::OpenSelected);
4518 let first = app.envelopes[0].clone();
4519 app.active_pane = ActivePane::MailList;
4520 app.apply(Action::MoveDown);
4521 let second = app.envelopes[1].clone();
4522
4523 app.resolve_body_success(MessageBody {
4524 message_id: first.id.clone(),
4525 text_plain: Some("Old body".into()),
4526 text_html: None,
4527 attachments: vec![],
4528 fetched_at: chrono::Utc::now(),
4529 metadata: Default::default(),
4530 });
4531
4532 assert_eq!(
4533 app.viewing_envelope.as_ref().map(|env| env.id.clone()),
4534 Some(second.id)
4535 );
4536 assert!(matches!(
4537 app.body_view_state,
4538 BodyViewState::Loading { ref preview }
4539 if preview.as_deref() == Some("Snippet 1")
4540 ));
4541 }
4542
4543 #[test]
4544 fn reader_mode_toggle_shows_raw_html_when_disabled() {
4545 let mut app = App::new();
4546 app.envelopes = make_test_envelopes(1);
4547 app.all_envelopes = app.envelopes.clone();
4548 let env = app.envelopes[0].clone();
4549 app.body_cache.insert(
4550 env.id.clone(),
4551 MessageBody {
4552 message_id: env.id.clone(),
4553 text_plain: None,
4554 text_html: Some("<p>Hello html</p>".into()),
4555 attachments: vec![],
4556 fetched_at: chrono::Utc::now(),
4557 metadata: Default::default(),
4558 },
4559 );
4560
4561 app.apply(Action::OpenSelected);
4562
4563 match &app.body_view_state {
4564 BodyViewState::Ready { raw, rendered, .. } => {
4565 assert_eq!(raw, "<p>Hello html</p>");
4566 assert_ne!(rendered, raw);
4567 assert!(rendered.contains("Hello html"));
4568 }
4569 other => panic!("expected ready state, got {other:?}"),
4570 }
4571
4572 app.apply(Action::ToggleReaderMode);
4573
4574 match &app.body_view_state {
4575 BodyViewState::Ready { raw, rendered, .. } => {
4576 assert_eq!(raw, "<p>Hello html</p>");
4577 assert_eq!(rendered, raw);
4578 }
4579 other => panic!("expected ready state, got {other:?}"),
4580 }
4581
4582 app.apply(Action::ToggleReaderMode);
4583
4584 match &app.body_view_state {
4585 BodyViewState::Ready { raw, rendered, .. } => {
4586 assert_eq!(raw, "<p>Hello html</p>");
4587 assert_ne!(rendered, raw);
4588 assert!(rendered.contains("Hello html"));
4589 }
4590 other => panic!("expected ready state, got {other:?}"),
4591 }
4592 }
4593}