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