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