Skip to main content

mxr_tui/app/
actions.rs

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        // Clear status message on any action
11        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                    // ThreePane: Sidebar → MailList → MessageView → Sidebar
183                    (LayoutMode::ThreePane, ActivePane::Sidebar) => ActivePane::MailList,
184                    (LayoutMode::ThreePane, ActivePane::MailList) => ActivePane::MessageView,
185                    (LayoutMode::ThreePane, ActivePane::MessageView) => ActivePane::Sidebar,
186                    // TwoPane: Sidebar → MailList → Sidebar
187                    (_, 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            // Search
252            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                    // Return focus to mail list so j/k navigates results
273                    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                // Restore full envelope list
286                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            // Navigation
305            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            // Command palette
376            Action::OpenCommandPalette => {
377                self.command_palette.toggle();
378            }
379            Action::CloseCommandPalette => {
380                self.command_palette.visible = false;
381            }
382            // Sync
383            Action::SyncNow => {
384                self.queue_mutation(
385                    Request::SyncNow { account_id: None },
386                    MutationEffect::RefreshList,
387                    "Syncing...".into(),
388                );
389            }
390            // Message view
391            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            // Phase 2: Email actions (Gmail-native A005)
544            Action::Compose => {
545                // Build contacts from known envelopes (senders we've seen)
546                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                    // For single selection, toggle. For multi, always star.
660                    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                    // Label picker confirmed — dispatch mutation
778                    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                    // Open label picker
806                    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                    // Label picker confirmed — dispatch move
813                    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                    // Open label picker
835                    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            // Phase 2: Reader mode
933            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            // Phase 2: Batch operations (A007)
946            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                    // Move to next after toggling
955                    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                    // Exit visual mode
967                    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                    // Add current to selection
974                    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            // Phase 2: Other actions
1031            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}