1use super::*;
2
3impl App {
4 pub fn tick(&mut self) {
5 self.input.check_timeout();
6 self.process_pending_preview_read();
7 }
8
9 pub fn apply(&mut self, action: Action) {
10 self.status_message = None;
12
13 match action {
14 Action::OpenMailboxScreen => {
15 if self.accounts_page.onboarding_required {
16 self.screen = Screen::Accounts;
17 self.accounts_page.onboarding_modal_open =
18 self.accounts_page.accounts.is_empty() && !self.accounts_page.form.visible;
19 return;
20 }
21 self.screen = Screen::Mailbox;
22 self.active_pane = if self.layout_mode == LayoutMode::ThreePane {
23 ActivePane::MailList
24 } else {
25 self.active_pane
26 };
27 }
28 Action::OpenSearchScreen => {
29 self.pending_preview_read = None;
30 self.screen = Screen::Search;
31 self.search_page.editing = true;
32 self.search_page.query = self.search_bar.query.clone();
33 self.trigger_live_search();
34 }
35 Action::OpenRulesScreen => {
36 self.pending_preview_read = None;
37 self.screen = Screen::Rules;
38 self.rules_page.refresh_pending = true;
39 }
40 Action::OpenDiagnosticsScreen => {
41 self.pending_preview_read = None;
42 self.screen = Screen::Diagnostics;
43 self.diagnostics_page.refresh_pending = true;
44 }
45 Action::OpenAccountsScreen => {
46 self.pending_preview_read = None;
47 self.screen = Screen::Accounts;
48 self.accounts_page.refresh_pending = true;
49 }
50 Action::RefreshAccounts => {
51 self.accounts_page.refresh_pending = true;
52 }
53 Action::OpenAccountFormNew => {
54 self.accounts_page.form = AccountFormState::default();
55 self.accounts_page.form.visible = true;
56 self.accounts_page.onboarding_modal_open = false;
57 self.refresh_account_form_derived_fields();
58 self.screen = Screen::Accounts;
59 }
60 Action::SaveAccountForm => {
61 let is_default = self
62 .selected_account()
63 .is_some_and(|account| account.is_default)
64 || self.accounts_page.accounts.is_empty();
65 self.accounts_page.last_result = None;
66 self.accounts_page.form.last_result = None;
67 self.pending_account_save = Some(self.account_form_data(is_default));
68 self.accounts_page.status = Some("Saving account...".into());
69 }
70 Action::TestAccountForm => {
71 let account = if self.accounts_page.form.visible {
72 self.account_form_data(false)
73 } else if let Some(account) = self.selected_account_config() {
74 account
75 } else {
76 self.accounts_page.status = Some("No editable account selected.".into());
77 return;
78 };
79 self.accounts_page.last_result = None;
80 self.accounts_page.form.last_result = None;
81 self.pending_account_test = Some(account);
82 self.accounts_page.status = Some("Testing account...".into());
83 }
84 Action::ReauthorizeAccountForm => {
85 let account = self.account_form_data(false);
86 self.accounts_page.last_result = None;
87 self.accounts_page.form.last_result = None;
88 self.pending_account_authorize = Some((account, true));
89 self.accounts_page.status = Some("Authorizing Gmail account...".into());
90 }
91 Action::SetDefaultAccount => {
92 if let Some(key) = self
93 .selected_account()
94 .and_then(|account| account.key.clone())
95 {
96 self.pending_account_set_default = Some(key);
97 self.accounts_page.status = Some("Setting default account...".into());
98 } else {
99 self.accounts_page.status =
100 Some("Runtime-only account cannot be set default from TUI.".into());
101 }
102 }
103 Action::MoveDown => {
104 if self.screen == Screen::Search {
105 if self.search_page.selected_index + 1 < self.search_row_count() {
106 self.search_page.selected_index += 1;
107 self.ensure_search_visible();
108 self.auto_preview_search();
109 }
110 return;
111 }
112 if self.selected_index + 1 < self.mail_row_count() {
113 self.selected_index += 1;
114 }
115 self.ensure_visible();
116 self.update_visual_selection();
117 self.auto_preview();
118 }
119 Action::MoveUp => {
120 if self.screen == Screen::Search {
121 if self.search_page.selected_index > 0 {
122 self.search_page.selected_index -= 1;
123 self.ensure_search_visible();
124 self.auto_preview_search();
125 }
126 return;
127 }
128 if self.selected_index > 0 {
129 self.selected_index -= 1;
130 }
131 self.ensure_visible();
132 self.update_visual_selection();
133 self.auto_preview();
134 }
135 Action::JumpTop => {
136 self.selected_index = 0;
137 self.scroll_offset = 0;
138 self.auto_preview();
139 }
140 Action::JumpBottom => {
141 if self.mail_row_count() > 0 {
142 self.selected_index = self.mail_row_count() - 1;
143 }
144 self.ensure_visible();
145 self.auto_preview();
146 }
147 Action::PageDown => {
148 let page = self.visible_height.max(1);
149 self.selected_index =
150 (self.selected_index + page).min(self.mail_row_count().saturating_sub(1));
151 self.ensure_visible();
152 self.auto_preview();
153 }
154 Action::PageUp => {
155 let page = self.visible_height.max(1);
156 self.selected_index = self.selected_index.saturating_sub(page);
157 self.ensure_visible();
158 self.auto_preview();
159 }
160 Action::ViewportTop => {
161 self.selected_index = self.scroll_offset;
162 self.auto_preview();
163 }
164 Action::ViewportMiddle => {
165 let visible_height = 20;
166 self.selected_index = (self.scroll_offset + visible_height / 2)
167 .min(self.mail_row_count().saturating_sub(1));
168 self.auto_preview();
169 }
170 Action::ViewportBottom => {
171 let visible_height = 20;
172 self.selected_index = (self.scroll_offset + visible_height)
173 .min(self.mail_row_count().saturating_sub(1));
174 self.auto_preview();
175 }
176 Action::CenterCurrent => {
177 let visible_height = 20;
178 self.scroll_offset = self.selected_index.saturating_sub(visible_height / 2);
179 }
180 Action::SwitchPane => {
181 self.active_pane = match (self.layout_mode, self.active_pane) {
182 (LayoutMode::ThreePane, ActivePane::Sidebar) => ActivePane::MailList,
184 (LayoutMode::ThreePane, ActivePane::MailList) => ActivePane::MessageView,
185 (LayoutMode::ThreePane, ActivePane::MessageView) => ActivePane::Sidebar,
186 (_, ActivePane::Sidebar) => ActivePane::MailList,
188 (_, ActivePane::MailList) => ActivePane::Sidebar,
189 (_, ActivePane::MessageView) => ActivePane::Sidebar,
190 };
191 }
192 Action::OpenSelected => {
193 if let Some(pending) = self.pending_bulk_confirm.take() {
194 if let Some(effect) = pending.optimistic_effect.as_ref() {
195 self.apply_local_mutation_effect(effect);
196 }
197 self.queue_mutation(pending.request, pending.effect, pending.status_message);
198 self.clear_selection();
199 return;
200 }
201 if self.screen == Screen::Search {
202 if let Some(env) = self.selected_search_envelope().cloned() {
203 self.open_envelope(env);
204 self.screen = Screen::Mailbox;
205 self.layout_mode = LayoutMode::ThreePane;
206 self.active_pane = ActivePane::MessageView;
207 }
208 return;
209 }
210 if self.mailbox_view == MailboxView::Subscriptions {
211 if let Some(entry) = self.selected_subscription_entry().cloned() {
212 self.open_envelope(entry.envelope);
213 self.layout_mode = LayoutMode::ThreePane;
214 self.active_pane = ActivePane::MessageView;
215 }
216 return;
217 }
218 if let Some(row) = self.selected_mail_row() {
219 self.open_envelope(row.representative);
220 self.layout_mode = LayoutMode::ThreePane;
221 self.active_pane = ActivePane::MessageView;
222 }
223 }
224 Action::Back => match self.active_pane {
225 _ if self.screen != Screen::Mailbox => {
226 self.screen = Screen::Mailbox;
227 }
228 ActivePane::MessageView => {
229 self.apply(Action::CloseMessageView);
230 }
231 ActivePane::MailList => {
232 if !self.selected_set.is_empty() {
233 self.apply(Action::ClearSelection);
234 } else if self.search_active {
235 self.apply(Action::CloseSearch);
236 } else if self.active_label.is_some() {
237 self.apply(Action::ClearFilter);
238 } else if self.layout_mode == LayoutMode::ThreePane {
239 self.apply(Action::CloseMessageView);
240 }
241 }
242 ActivePane::Sidebar => {}
243 },
244 Action::QuitView => {
245 self.should_quit = true;
246 }
247 Action::ClearSelection => {
248 self.clear_selection();
249 self.status_message = Some("Selection cleared".into());
250 }
251 Action::OpenSearch => {
253 if self.search_active {
254 self.search_bar.activate_existing();
255 } else {
256 self.search_bar.activate();
257 }
258 }
259 Action::SubmitSearch => {
260 if self.screen == Screen::Search {
261 self.search_page.editing = false;
262 self.search_bar.query = self.search_page.query.clone();
263 self.pending_search =
264 Some((self.search_page.query.clone(), self.search_bar.mode));
265 } else {
266 let query = self.search_bar.query.clone();
267 self.search_bar.deactivate();
268 if !query.is_empty() {
269 self.pending_search = Some((query, self.search_bar.mode));
270 self.search_active = true;
271 }
272 self.active_pane = ActivePane::MailList;
274 }
275 }
276 Action::CycleSearchMode => {
277 self.search_bar.cycle_mode();
278 if self.screen == Screen::Search || self.search_bar.active {
279 self.trigger_live_search();
280 }
281 }
282 Action::CloseSearch => {
283 self.search_bar.deactivate();
284 self.search_active = false;
285 self.envelopes = self.all_mail_envelopes();
287 self.selected_index = 0;
288 self.scroll_offset = 0;
289 }
290 Action::NextSearchResult => {
291 if self.search_active && self.selected_index + 1 < self.envelopes.len() {
292 self.selected_index += 1;
293 self.ensure_visible();
294 self.auto_preview();
295 }
296 }
297 Action::PrevSearchResult => {
298 if self.search_active && self.selected_index > 0 {
299 self.selected_index -= 1;
300 self.ensure_visible();
301 self.auto_preview();
302 }
303 }
304 Action::GoToInbox => {
306 if let Some(label) = self.labels.iter().find(|l| l.name == "INBOX") {
307 self.apply(Action::SelectLabel(label.id.clone()));
308 } else {
309 self.desired_system_mailbox = Some("INBOX".into());
310 }
311 }
312 Action::GoToStarred => {
313 if let Some(label) = self.labels.iter().find(|l| l.name == "STARRED") {
314 self.apply(Action::SelectLabel(label.id.clone()));
315 } else {
316 self.desired_system_mailbox = Some("STARRED".into());
317 }
318 }
319 Action::GoToSent => {
320 if let Some(label) = self.labels.iter().find(|l| l.name == "SENT") {
321 self.apply(Action::SelectLabel(label.id.clone()));
322 } else {
323 self.desired_system_mailbox = Some("SENT".into());
324 }
325 }
326 Action::GoToDrafts => {
327 if let Some(label) = self.labels.iter().find(|l| l.name == "DRAFT") {
328 self.apply(Action::SelectLabel(label.id.clone()));
329 } else {
330 self.desired_system_mailbox = Some("DRAFT".into());
331 }
332 }
333 Action::GoToAllMail => {
334 self.mailbox_view = MailboxView::Messages;
335 self.apply(Action::ClearFilter);
336 }
337 Action::OpenSubscriptions => {
338 self.mailbox_view = MailboxView::Subscriptions;
339 self.active_label = None;
340 self.pending_active_label = None;
341 self.pending_label_fetch = None;
342 self.pending_preview_read = None;
343 self.desired_system_mailbox = None;
344 self.search_active = false;
345 self.screen = Screen::Mailbox;
346 self.active_pane = ActivePane::MailList;
347 self.selected_index = self
348 .selected_index
349 .min(self.subscriptions_page.entries.len().saturating_sub(1));
350 self.scroll_offset = 0;
351 if self.subscriptions_page.entries.is_empty() {
352 self.pending_subscriptions_refresh = true;
353 }
354 self.auto_preview();
355 }
356 Action::GoToLabel => {
357 self.mailbox_view = MailboxView::Messages;
358 self.apply(Action::ClearFilter);
359 }
360 Action::OpenTab1 => {
361 self.apply(Action::OpenMailboxScreen);
362 }
363 Action::OpenTab2 => {
364 self.apply(Action::OpenSearchScreen);
365 }
366 Action::OpenTab3 => {
367 self.apply(Action::OpenRulesScreen);
368 }
369 Action::OpenTab4 => {
370 self.apply(Action::OpenAccountsScreen);
371 }
372 Action::OpenTab5 => {
373 self.apply(Action::OpenDiagnosticsScreen);
374 }
375 Action::OpenCommandPalette => {
377 self.command_palette.toggle();
378 }
379 Action::CloseCommandPalette => {
380 self.command_palette.visible = false;
381 }
382 Action::SyncNow => {
384 self.queue_mutation(
385 Request::SyncNow { account_id: None },
386 MutationEffect::RefreshList,
387 "Syncing...".into(),
388 );
389 }
390 Action::OpenMessageView => {
392 if self.mailbox_view == MailboxView::Subscriptions {
393 if let Some(entry) = self.selected_subscription_entry().cloned() {
394 self.open_envelope(entry.envelope);
395 self.layout_mode = LayoutMode::ThreePane;
396 }
397 } else if let Some(row) = self.selected_mail_row() {
398 self.open_envelope(row.representative);
399 self.layout_mode = LayoutMode::ThreePane;
400 }
401 }
402 Action::CloseMessageView => {
403 self.close_attachment_panel();
404 self.layout_mode = LayoutMode::TwoPane;
405 self.active_pane = ActivePane::MailList;
406 self.pending_preview_read = None;
407 self.viewing_envelope = None;
408 self.viewed_thread = None;
409 self.viewed_thread_messages.clear();
410 self.thread_selected_index = 0;
411 self.pending_thread_fetch = None;
412 self.in_flight_thread_fetch = None;
413 self.message_scroll_offset = 0;
414 self.body_view_state = BodyViewState::Empty { preview: None };
415 }
416 Action::ToggleMailListMode => {
417 if self.mailbox_view == MailboxView::Subscriptions {
418 return;
419 }
420 self.mail_list_mode = match self.mail_list_mode {
421 MailListMode::Threads => MailListMode::Messages,
422 MailListMode::Messages => MailListMode::Threads,
423 };
424 self.selected_index = self
425 .selected_index
426 .min(self.mail_row_count().saturating_sub(1));
427 }
428 Action::RefreshRules => {
429 self.rules_page.refresh_pending = true;
430 if let Some(id) = self.selected_rule().and_then(|rule| rule["id"].as_str()) {
431 self.pending_rule_detail = Some(id.to_string());
432 }
433 }
434 Action::ToggleRuleEnabled => {
435 if let Some(rule) = self.selected_rule().cloned() {
436 let mut updated = rule.clone();
437 if let Some(enabled) = updated.get("enabled").and_then(|v| v.as_bool()) {
438 updated["enabled"] = serde_json::Value::Bool(!enabled);
439 self.pending_rule_upsert = Some(updated);
440 self.rules_page.status = Some(if enabled {
441 "Disabling rule...".into()
442 } else {
443 "Enabling rule...".into()
444 });
445 }
446 }
447 }
448 Action::DeleteRule => {
449 if let Some(rule_id) = self
450 .selected_rule()
451 .and_then(|rule| rule["id"].as_str())
452 .map(ToString::to_string)
453 {
454 self.pending_rule_delete = Some(rule_id.clone());
455 self.rules_page.status = Some(format!("Deleting {rule_id}..."));
456 }
457 }
458 Action::ShowRuleHistory => {
459 self.rules_page.panel = RulesPanel::History;
460 self.pending_rule_history = self
461 .selected_rule()
462 .and_then(|rule| rule["id"].as_str())
463 .map(ToString::to_string);
464 }
465 Action::ShowRuleDryRun => {
466 self.rules_page.panel = RulesPanel::DryRun;
467 self.pending_rule_dry_run = self
468 .selected_rule()
469 .and_then(|rule| rule["id"].as_str())
470 .map(ToString::to_string);
471 }
472 Action::OpenRuleFormNew => {
473 self.rules_page.form = RuleFormState {
474 visible: true,
475 enabled: true,
476 priority: "100".to_string(),
477 active_field: 0,
478 ..RuleFormState::default()
479 };
480 self.rules_page.panel = RulesPanel::Form;
481 }
482 Action::OpenRuleFormEdit => {
483 if let Some(rule_id) = self
484 .selected_rule()
485 .and_then(|rule| rule["id"].as_str())
486 .map(ToString::to_string)
487 {
488 self.pending_rule_form_load = Some(rule_id);
489 }
490 }
491 Action::SaveRuleForm => {
492 self.rules_page.status = Some("Saving rule...".into());
493 self.pending_rule_form_save = true;
494 }
495 Action::RefreshDiagnostics => {
496 self.diagnostics_page.refresh_pending = true;
497 }
498 Action::GenerateBugReport => {
499 self.diagnostics_page.status = Some("Generating bug report...".into());
500 self.pending_bug_report = true;
501 }
502 Action::OpenLogs => {
503 self.pending_log_open = true;
504 self.status_message = Some("Opening log file in editor...".into());
505 }
506 Action::OpenDiagnosticsPaneDetails => {
507 self.pending_diagnostics_details = Some(self.diagnostics_page.active_pane());
508 self.status_message = Some("Opening diagnostics details...".into());
509 }
510 Action::SelectLabel(label_id) => {
511 self.mailbox_view = MailboxView::Messages;
512 self.pending_label_fetch = Some(label_id);
513 self.pending_active_label = self.pending_label_fetch.clone();
514 self.desired_system_mailbox = None;
515 self.active_pane = ActivePane::MailList;
516 self.screen = Screen::Mailbox;
517 }
518 Action::SelectSavedSearch(query, mode) => {
519 self.mailbox_view = MailboxView::Messages;
520 if self.screen == Screen::Search {
521 self.search_page.query = query.clone();
522 self.search_page.editing = false;
523 } else {
524 self.search_active = true;
525 self.active_pane = ActivePane::MailList;
526 }
527 self.search_bar.query = query.clone();
528 self.search_bar.mode = mode;
529 self.pending_search = Some((query, mode));
530 }
531 Action::ClearFilter => {
532 self.mailbox_view = MailboxView::Messages;
533 self.active_label = None;
534 self.pending_active_label = None;
535 self.pending_preview_read = None;
536 self.desired_system_mailbox = None;
537 self.search_active = false;
538 self.envelopes = self.all_mail_envelopes();
539 self.selected_index = 0;
540 self.scroll_offset = 0;
541 }
542
543 Action::Compose => {
545 let mut seen = std::collections::HashMap::new();
547 for env in &self.all_envelopes {
548 seen.entry(env.from.email.clone()).or_insert_with(|| {
549 crate::ui::compose_picker::Contact {
550 name: env.from.name.clone().unwrap_or_default(),
551 email: env.from.email.clone(),
552 }
553 });
554 }
555 let mut contacts: Vec<_> = seen.into_values().collect();
556 contacts.sort_by(|a, b| a.email.to_lowercase().cmp(&b.email.to_lowercase()));
557 self.compose_picker.open(contacts);
558 }
559 Action::Reply => {
560 if let Some(env) = self.context_envelope() {
561 self.pending_compose = Some(ComposeAction::Reply {
562 message_id: env.id.clone(),
563 });
564 }
565 }
566 Action::ReplyAll => {
567 if let Some(env) = self.context_envelope() {
568 self.pending_compose = Some(ComposeAction::ReplyAll {
569 message_id: env.id.clone(),
570 });
571 }
572 }
573 Action::Forward => {
574 if let Some(env) = self.context_envelope() {
575 self.pending_compose = Some(ComposeAction::Forward {
576 message_id: env.id.clone(),
577 });
578 }
579 }
580 Action::Archive => {
581 let ids = self.mutation_target_ids();
582 if !ids.is_empty() {
583 let effect = remove_from_list_effect(&ids);
584 self.queue_or_confirm_bulk_action(
585 "Archive messages",
586 bulk_message_detail("archive", ids.len()),
587 Request::Mutation(MutationCommand::Archive {
588 message_ids: ids.clone(),
589 }),
590 effect,
591 None,
592 "Archiving...".into(),
593 ids.len(),
594 );
595 }
596 }
597 Action::MarkReadAndArchive => {
598 let ids = self.mutation_target_ids();
599 if !ids.is_empty() {
600 let updates = self.flag_updates_for_ids(&ids, |mut flags| {
601 flags.insert(MessageFlags::READ);
602 flags
603 });
604 self.queue_or_confirm_bulk_action(
605 "Mark messages as read and archive",
606 bulk_message_detail("mark as read and archive", ids.len()),
607 Request::Mutation(MutationCommand::ReadAndArchive {
608 message_ids: ids.clone(),
609 }),
610 remove_from_list_effect(&ids),
611 (!updates.is_empty())
612 .then_some(MutationEffect::UpdateFlagsMany { updates }),
613 format!(
614 "Marking {} {} as read and archiving...",
615 ids.len(),
616 pluralize_messages(ids.len())
617 ),
618 ids.len(),
619 );
620 }
621 }
622 Action::Trash => {
623 let ids = self.mutation_target_ids();
624 if !ids.is_empty() {
625 let effect = remove_from_list_effect(&ids);
626 self.queue_or_confirm_bulk_action(
627 "Delete messages",
628 bulk_message_detail("delete", ids.len()),
629 Request::Mutation(MutationCommand::Trash {
630 message_ids: ids.clone(),
631 }),
632 effect,
633 None,
634 "Trashing...".into(),
635 ids.len(),
636 );
637 }
638 }
639 Action::Spam => {
640 let ids = self.mutation_target_ids();
641 if !ids.is_empty() {
642 let effect = remove_from_list_effect(&ids);
643 self.queue_or_confirm_bulk_action(
644 "Mark as spam",
645 bulk_message_detail("mark as spam", ids.len()),
646 Request::Mutation(MutationCommand::Spam {
647 message_ids: ids.clone(),
648 }),
649 effect,
650 None,
651 "Marking as spam...".into(),
652 ids.len(),
653 );
654 }
655 }
656 Action::Star => {
657 let ids = self.mutation_target_ids();
658 if !ids.is_empty() {
659 let starred = if ids.len() == 1 {
661 if let Some(env) = self.context_envelope() {
662 !env.flags.contains(MessageFlags::STARRED)
663 } else {
664 true
665 }
666 } else {
667 true
668 };
669 let updates = self.flag_updates_for_ids(&ids, |mut flags| {
670 if starred {
671 flags.insert(MessageFlags::STARRED);
672 } else {
673 flags.remove(MessageFlags::STARRED);
674 }
675 flags
676 });
677 let optimistic_effect = (!updates.is_empty())
678 .then_some(MutationEffect::UpdateFlagsMany { updates });
679 let verb = if starred { "star" } else { "unstar" };
680 let status = if starred {
681 format!(
682 "Starring {} {}...",
683 ids.len(),
684 pluralize_messages(ids.len())
685 )
686 } else {
687 format!(
688 "Unstarring {} {}...",
689 ids.len(),
690 pluralize_messages(ids.len())
691 )
692 };
693 self.queue_or_confirm_bulk_action(
694 if starred {
695 "Star messages"
696 } else {
697 "Unstar messages"
698 },
699 bulk_message_detail(verb, ids.len()),
700 Request::Mutation(MutationCommand::Star {
701 message_ids: ids.clone(),
702 starred,
703 }),
704 MutationEffect::StatusOnly(if starred {
705 format!("Starred {} {}", ids.len(), pluralize_messages(ids.len()))
706 } else {
707 format!("Unstarred {} {}", ids.len(), pluralize_messages(ids.len()))
708 }),
709 optimistic_effect,
710 status,
711 ids.len(),
712 );
713 }
714 }
715 Action::MarkRead => {
716 let ids = self.mutation_target_ids();
717 if !ids.is_empty() {
718 let updates = self.flag_updates_for_ids(&ids, |mut flags| {
719 flags.insert(MessageFlags::READ);
720 flags
721 });
722 self.queue_or_confirm_bulk_action(
723 "Mark messages as read",
724 bulk_message_detail("mark as read", ids.len()),
725 Request::Mutation(MutationCommand::SetRead {
726 message_ids: ids.clone(),
727 read: true,
728 }),
729 MutationEffect::StatusOnly(format!(
730 "Marked {} {} as read",
731 ids.len(),
732 pluralize_messages(ids.len())
733 )),
734 (!updates.is_empty())
735 .then_some(MutationEffect::UpdateFlagsMany { updates }),
736 format!(
737 "Marking {} {} as read...",
738 ids.len(),
739 pluralize_messages(ids.len())
740 ),
741 ids.len(),
742 );
743 }
744 }
745 Action::MarkUnread => {
746 let ids = self.mutation_target_ids();
747 if !ids.is_empty() {
748 let updates = self.flag_updates_for_ids(&ids, |mut flags| {
749 flags.remove(MessageFlags::READ);
750 flags
751 });
752 self.queue_or_confirm_bulk_action(
753 "Mark messages as unread",
754 bulk_message_detail("mark as unread", ids.len()),
755 Request::Mutation(MutationCommand::SetRead {
756 message_ids: ids.clone(),
757 read: false,
758 }),
759 MutationEffect::StatusOnly(format!(
760 "Marked {} {} as unread",
761 ids.len(),
762 pluralize_messages(ids.len())
763 )),
764 (!updates.is_empty())
765 .then_some(MutationEffect::UpdateFlagsMany { updates }),
766 format!(
767 "Marking {} {} as unread...",
768 ids.len(),
769 pluralize_messages(ids.len())
770 ),
771 ids.len(),
772 );
773 }
774 }
775 Action::ApplyLabel => {
776 if let Some((_, ref label_name)) = self.pending_label_action.take() {
777 let ids = self.mutation_target_ids();
779 if !ids.is_empty() {
780 self.queue_or_confirm_bulk_action(
781 "Apply label",
782 format!(
783 "You are about to apply '{}' to {} {}.",
784 label_name,
785 ids.len(),
786 pluralize_messages(ids.len())
787 ),
788 Request::Mutation(MutationCommand::ModifyLabels {
789 message_ids: ids.clone(),
790 add: vec![label_name.clone()],
791 remove: vec![],
792 }),
793 MutationEffect::ModifyLabels {
794 message_ids: ids.clone(),
795 add: vec![label_name.clone()],
796 remove: vec![],
797 status: format!("Applied label '{}'", label_name),
798 },
799 None,
800 format!("Applying label '{}'...", label_name),
801 ids.len(),
802 );
803 }
804 } else {
805 self.label_picker
807 .open(self.labels.clone(), LabelPickerMode::Apply);
808 }
809 }
810 Action::MoveToLabel => {
811 if let Some((_, ref label_name)) = self.pending_label_action.take() {
812 let ids = self.mutation_target_ids();
814 if !ids.is_empty() {
815 self.queue_or_confirm_bulk_action(
816 "Move messages",
817 format!(
818 "You are about to move {} {} to '{}'.",
819 ids.len(),
820 pluralize_messages(ids.len()),
821 label_name
822 ),
823 Request::Mutation(MutationCommand::Move {
824 message_ids: ids.clone(),
825 target_label: label_name.clone(),
826 }),
827 remove_from_list_effect(&ids),
828 None,
829 format!("Moving to '{}'...", label_name),
830 ids.len(),
831 );
832 }
833 } else {
834 self.label_picker
836 .open(self.labels.clone(), LabelPickerMode::Move);
837 }
838 }
839 Action::Unsubscribe => {
840 if let Some(env) = self.context_envelope() {
841 if matches!(env.unsubscribe, UnsubscribeMethod::None) {
842 self.status_message =
843 Some("No unsubscribe option found for this message".into());
844 } else {
845 let sender_email = env.from.email.clone();
846 let archive_message_ids = self
847 .all_envelopes
848 .iter()
849 .filter(|candidate| {
850 candidate.account_id == env.account_id
851 && candidate.from.email.eq_ignore_ascii_case(&sender_email)
852 })
853 .map(|candidate| candidate.id.clone())
854 .collect();
855 self.pending_unsubscribe_confirm = Some(PendingUnsubscribeConfirm {
856 message_id: env.id.clone(),
857 account_id: env.account_id.clone(),
858 sender_email,
859 method_label: unsubscribe_method_label(&env.unsubscribe).to_string(),
860 archive_message_ids,
861 });
862 }
863 }
864 }
865 Action::ConfirmUnsubscribeOnly => {
866 if let Some(pending) = self.pending_unsubscribe_confirm.take() {
867 self.pending_unsubscribe_action = Some(PendingUnsubscribeAction {
868 message_id: pending.message_id,
869 archive_message_ids: Vec::new(),
870 sender_email: pending.sender_email,
871 });
872 self.status_message = Some("Unsubscribing...".into());
873 }
874 }
875 Action::ConfirmUnsubscribeAndArchiveSender => {
876 if let Some(pending) = self.pending_unsubscribe_confirm.take() {
877 self.pending_unsubscribe_action = Some(PendingUnsubscribeAction {
878 message_id: pending.message_id,
879 archive_message_ids: pending.archive_message_ids,
880 sender_email: pending.sender_email,
881 });
882 self.status_message = Some("Unsubscribing and archiving sender...".into());
883 }
884 }
885 Action::CancelUnsubscribe => {
886 self.pending_unsubscribe_confirm = None;
887 self.status_message = Some("Unsubscribe cancelled".into());
888 }
889 Action::Snooze => {
890 if self.snooze_panel.visible {
891 if let Some(env) = self.context_envelope() {
892 let wake_at = resolve_snooze_preset(
893 snooze_presets()[self.snooze_panel.selected_index],
894 &self.snooze_config,
895 );
896 self.queue_mutation(
897 Request::Snooze {
898 message_id: env.id.clone(),
899 wake_at,
900 },
901 MutationEffect::StatusOnly(format!(
902 "Snoozed until {}",
903 wake_at
904 .with_timezone(&chrono::Local)
905 .format("%a %b %e %H:%M")
906 )),
907 "Snoozing...".into(),
908 );
909 }
910 self.snooze_panel.visible = false;
911 } else if self.context_envelope().is_some() {
912 self.snooze_panel.visible = true;
913 self.snooze_panel.selected_index = 0;
914 } else {
915 self.status_message = Some("No message selected".into());
916 }
917 }
918 Action::OpenInBrowser => {
919 if let Some(env) = self.context_envelope() {
920 let url = format!(
921 "https://mail.google.com/mail/u/0/#inbox/{}",
922 env.provider_id
923 );
924 #[cfg(target_os = "macos")]
925 let _ = std::process::Command::new("open").arg(&url).spawn();
926 #[cfg(target_os = "linux")]
927 let _ = std::process::Command::new("xdg-open").arg(&url).spawn();
928 self.status_message = Some("Opened in browser".into());
929 }
930 }
931
932 Action::ToggleReaderMode => {
934 if let BodyViewState::Ready { .. } = self.body_view_state {
935 self.reader_mode = !self.reader_mode;
936 if let Some(env) = self.viewing_envelope.clone() {
937 self.body_view_state = self.resolve_body_view_state(&env);
938 }
939 }
940 }
941 Action::ToggleSignature => {
942 self.signature_expanded = !self.signature_expanded;
943 }
944
945 Action::ToggleSelect => {
947 if let Some(env) = self.selected_envelope() {
948 let id = env.id.clone();
949 if self.selected_set.contains(&id) {
950 self.selected_set.remove(&id);
951 } else {
952 self.selected_set.insert(id);
953 }
954 if self.selected_index + 1 < self.mail_row_count() {
956 self.selected_index += 1;
957 self.ensure_visible();
958 self.auto_preview();
959 }
960 let count = self.selected_set.len();
961 self.status_message = Some(format!("{count} selected"));
962 }
963 }
964 Action::VisualLineMode => {
965 if self.visual_mode {
966 self.visual_mode = false;
968 self.visual_anchor = None;
969 self.status_message = Some("Visual mode off".into());
970 } else {
971 self.visual_mode = true;
972 self.visual_anchor = Some(self.selected_index);
973 if let Some(env) = self.selected_envelope() {
975 self.selected_set.insert(env.id.clone());
976 }
977 self.status_message = Some("-- VISUAL LINE --".into());
978 }
979 }
980 Action::PatternSelect(pattern) => {
981 match pattern {
982 PatternKind::All => {
983 self.selected_set = self.envelopes.iter().map(|e| e.id.clone()).collect();
984 }
985 PatternKind::None => {
986 self.selected_set.clear();
987 self.visual_mode = false;
988 self.visual_anchor = None;
989 }
990 PatternKind::Read => {
991 self.selected_set = self
992 .envelopes
993 .iter()
994 .filter(|e| e.flags.contains(MessageFlags::READ))
995 .map(|e| e.id.clone())
996 .collect();
997 }
998 PatternKind::Unread => {
999 self.selected_set = self
1000 .envelopes
1001 .iter()
1002 .filter(|e| !e.flags.contains(MessageFlags::READ))
1003 .map(|e| e.id.clone())
1004 .collect();
1005 }
1006 PatternKind::Starred => {
1007 self.selected_set = self
1008 .envelopes
1009 .iter()
1010 .filter(|e| e.flags.contains(MessageFlags::STARRED))
1011 .map(|e| e.id.clone())
1012 .collect();
1013 }
1014 PatternKind::Thread => {
1015 if let Some(env) = self.context_envelope() {
1016 let tid = env.thread_id.clone();
1017 self.selected_set = self
1018 .envelopes
1019 .iter()
1020 .filter(|e| e.thread_id == tid)
1021 .map(|e| e.id.clone())
1022 .collect();
1023 }
1024 }
1025 }
1026 let count = self.selected_set.len();
1027 self.status_message = Some(format!("{count} selected"));
1028 }
1029
1030 Action::AttachmentList => {
1032 if self.attachment_panel.visible {
1033 self.close_attachment_panel();
1034 } else {
1035 self.open_attachment_panel();
1036 }
1037 }
1038 Action::OpenLinks => {
1039 self.open_url_modal();
1040 }
1041 Action::ToggleFullscreen => {
1042 if self.layout_mode == LayoutMode::FullScreen {
1043 self.layout_mode = LayoutMode::ThreePane;
1044 } else if self.viewing_envelope.is_some() {
1045 self.layout_mode = LayoutMode::FullScreen;
1046 }
1047 }
1048 Action::ExportThread => {
1049 if let Some(env) = self.context_envelope() {
1050 self.pending_export_thread = Some(env.thread_id.clone());
1051 self.status_message = Some("Exporting thread...".into());
1052 } else {
1053 self.status_message = Some("No message selected".into());
1054 }
1055 }
1056 Action::Help => {
1057 self.help_modal_open = !self.help_modal_open;
1058 if self.help_modal_open {
1059 self.help_scroll_offset = 0;
1060 }
1061 }
1062 Action::Noop => {}
1063 }
1064 }
1065}