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.clone(),
663                        Some(effect),
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 effect = remove_from_list_effect(&ids);
673                    self.queue_or_confirm_bulk_action(
674                        "Mark messages as read and archive",
675                        bulk_message_detail("mark as read and archive", ids.len()),
676                        Request::Mutation(MutationCommand::ReadAndArchive {
677                            message_ids: ids.clone(),
678                        }),
679                        effect.clone(),
680                        Some(effect),
681                        format!(
682                            "Marking {} {} as read and archiving...",
683                            ids.len(),
684                            pluralize_messages(ids.len())
685                        ),
686                        ids.len(),
687                    );
688                }
689            }
690            Action::Trash => {
691                let ids = self.mutation_target_ids();
692                if !ids.is_empty() {
693                    let effect = remove_from_list_effect(&ids);
694                    self.queue_or_confirm_bulk_action(
695                        "Delete messages",
696                        bulk_message_detail("delete", ids.len()),
697                        Request::Mutation(MutationCommand::Trash {
698                            message_ids: ids.clone(),
699                        }),
700                        effect.clone(),
701                        Some(effect),
702                        "Trashing...".into(),
703                        ids.len(),
704                    );
705                }
706            }
707            Action::Spam => {
708                let ids = self.mutation_target_ids();
709                if !ids.is_empty() {
710                    let effect = remove_from_list_effect(&ids);
711                    self.queue_or_confirm_bulk_action(
712                        "Mark as spam",
713                        bulk_message_detail("mark as spam", ids.len()),
714                        Request::Mutation(MutationCommand::Spam {
715                            message_ids: ids.clone(),
716                        }),
717                        effect.clone(),
718                        Some(effect),
719                        "Marking as spam...".into(),
720                        ids.len(),
721                    );
722                }
723            }
724            Action::Star => {
725                let ids = self.mutation_target_ids();
726                if !ids.is_empty() {
727                    // For single selection, toggle. For multi, always star.
728                    let starred = if ids.len() == 1 {
729                        if let Some(env) = self.context_envelope() {
730                            !env.flags.contains(MessageFlags::STARRED)
731                        } else {
732                            true
733                        }
734                    } else {
735                        true
736                    };
737                    let updates = self.flag_updates_for_ids(&ids, |mut flags| {
738                        if starred {
739                            flags.insert(MessageFlags::STARRED);
740                        } else {
741                            flags.remove(MessageFlags::STARRED);
742                        }
743                        flags
744                    });
745                    let optimistic_effect = (!updates.is_empty())
746                        .then_some(MutationEffect::UpdateFlagsMany { updates });
747                    let verb = if starred { "star" } else { "unstar" };
748                    let status = if starred {
749                        format!(
750                            "Starring {} {}...",
751                            ids.len(),
752                            pluralize_messages(ids.len())
753                        )
754                    } else {
755                        format!(
756                            "Unstarring {} {}...",
757                            ids.len(),
758                            pluralize_messages(ids.len())
759                        )
760                    };
761                    self.queue_or_confirm_bulk_action(
762                        if starred {
763                            "Star messages"
764                        } else {
765                            "Unstar messages"
766                        },
767                        bulk_message_detail(verb, ids.len()),
768                        Request::Mutation(MutationCommand::Star {
769                            message_ids: ids.clone(),
770                            starred,
771                        }),
772                        MutationEffect::StatusOnly(if starred {
773                            format!("Starred {} {}", ids.len(), pluralize_messages(ids.len()))
774                        } else {
775                            format!("Unstarred {} {}", ids.len(), pluralize_messages(ids.len()))
776                        }),
777                        optimistic_effect,
778                        status,
779                        ids.len(),
780                    );
781                }
782            }
783            Action::MarkRead => {
784                let ids = self.mutation_target_ids();
785                if !ids.is_empty() {
786                    let updates = self.flag_updates_for_ids(&ids, |mut flags| {
787                        flags.insert(MessageFlags::READ);
788                        flags
789                    });
790                    self.queue_or_confirm_bulk_action(
791                        "Mark messages as read",
792                        bulk_message_detail("mark as read", ids.len()),
793                        Request::Mutation(MutationCommand::SetRead {
794                            message_ids: ids.clone(),
795                            read: true,
796                        }),
797                        MutationEffect::StatusOnly(format!(
798                            "Marked {} {} as read",
799                            ids.len(),
800                            pluralize_messages(ids.len())
801                        )),
802                        (!updates.is_empty())
803                            .then_some(MutationEffect::UpdateFlagsMany { updates }),
804                        format!(
805                            "Marking {} {} as read...",
806                            ids.len(),
807                            pluralize_messages(ids.len())
808                        ),
809                        ids.len(),
810                    );
811                }
812            }
813            Action::MarkUnread => {
814                let ids = self.mutation_target_ids();
815                if !ids.is_empty() {
816                    let updates = self.flag_updates_for_ids(&ids, |mut flags| {
817                        flags.remove(MessageFlags::READ);
818                        flags
819                    });
820                    self.queue_or_confirm_bulk_action(
821                        "Mark messages as unread",
822                        bulk_message_detail("mark as unread", ids.len()),
823                        Request::Mutation(MutationCommand::SetRead {
824                            message_ids: ids.clone(),
825                            read: false,
826                        }),
827                        MutationEffect::StatusOnly(format!(
828                            "Marked {} {} as unread",
829                            ids.len(),
830                            pluralize_messages(ids.len())
831                        )),
832                        (!updates.is_empty())
833                            .then_some(MutationEffect::UpdateFlagsMany { updates }),
834                        format!(
835                            "Marking {} {} as unread...",
836                            ids.len(),
837                            pluralize_messages(ids.len())
838                        ),
839                        ids.len(),
840                    );
841                }
842            }
843            Action::ApplyLabel => {
844                if let Some((_, ref label_name)) = self.pending_label_action.take() {
845                    // Label picker confirmed — dispatch mutation
846                    let ids = self.mutation_target_ids();
847                    if !ids.is_empty() {
848                        self.queue_or_confirm_bulk_action(
849                            "Apply label",
850                            format!(
851                                "You are about to apply '{}' to {} {}.",
852                                label_name,
853                                ids.len(),
854                                pluralize_messages(ids.len())
855                            ),
856                            Request::Mutation(MutationCommand::ModifyLabels {
857                                message_ids: ids.clone(),
858                                add: vec![label_name.clone()],
859                                remove: vec![],
860                            }),
861                            MutationEffect::ModifyLabels {
862                                message_ids: ids.clone(),
863                                add: vec![label_name.clone()],
864                                remove: vec![],
865                                status: format!("Applied label '{}'", label_name),
866                            },
867                            None,
868                            format!("Applying label '{}'...", label_name),
869                            ids.len(),
870                        );
871                    }
872                } else {
873                    // Open label picker
874                    self.label_picker
875                        .open(self.labels.clone(), LabelPickerMode::Apply);
876                }
877            }
878            Action::MoveToLabel => {
879                if let Some((_, ref label_name)) = self.pending_label_action.take() {
880                    // Label picker confirmed — dispatch move
881                    let ids = self.mutation_target_ids();
882                    if !ids.is_empty() {
883                        self.queue_or_confirm_bulk_action(
884                            "Move messages",
885                            format!(
886                                "You are about to move {} {} to '{}'.",
887                                ids.len(),
888                                pluralize_messages(ids.len()),
889                                label_name
890                            ),
891                            Request::Mutation(MutationCommand::Move {
892                                message_ids: ids.clone(),
893                                target_label: label_name.clone(),
894                            }),
895                            remove_from_list_effect(&ids),
896                            None,
897                            format!("Moving to '{}'...", label_name),
898                            ids.len(),
899                        );
900                    }
901                } else {
902                    // Open label picker
903                    self.label_picker
904                        .open(self.labels.clone(), LabelPickerMode::Move);
905                }
906            }
907            Action::Unsubscribe => {
908                if let Some(env) = self.context_envelope() {
909                    if matches!(env.unsubscribe, UnsubscribeMethod::None) {
910                        self.status_message =
911                            Some("No unsubscribe option found for this message".into());
912                    } else {
913                        let sender_email = env.from.email.clone();
914                        let archive_message_ids = self
915                            .all_envelopes
916                            .iter()
917                            .filter(|candidate| {
918                                candidate.account_id == env.account_id
919                                    && candidate.from.email.eq_ignore_ascii_case(&sender_email)
920                            })
921                            .map(|candidate| candidate.id.clone())
922                            .collect();
923                        self.pending_unsubscribe_confirm = Some(PendingUnsubscribeConfirm {
924                            message_id: env.id.clone(),
925                            account_id: env.account_id.clone(),
926                            sender_email,
927                            method_label: unsubscribe_method_label(&env.unsubscribe).to_string(),
928                            archive_message_ids,
929                        });
930                    }
931                }
932            }
933            Action::ConfirmUnsubscribeOnly => {
934                if let Some(pending) = self.pending_unsubscribe_confirm.take() {
935                    self.pending_unsubscribe_action = Some(PendingUnsubscribeAction {
936                        message_id: pending.message_id,
937                        archive_message_ids: Vec::new(),
938                        sender_email: pending.sender_email,
939                    });
940                    self.status_message = Some("Unsubscribing...".into());
941                }
942            }
943            Action::ConfirmUnsubscribeAndArchiveSender => {
944                if let Some(pending) = self.pending_unsubscribe_confirm.take() {
945                    self.pending_unsubscribe_action = Some(PendingUnsubscribeAction {
946                        message_id: pending.message_id,
947                        archive_message_ids: pending.archive_message_ids,
948                        sender_email: pending.sender_email,
949                    });
950                    self.status_message = Some("Unsubscribing and archiving sender...".into());
951                }
952            }
953            Action::CancelUnsubscribe => {
954                self.pending_unsubscribe_confirm = None;
955                self.status_message = Some("Unsubscribe cancelled".into());
956            }
957            Action::Snooze => {
958                if self.snooze_panel.visible {
959                    if let Some(env) = self.context_envelope() {
960                        let wake_at = resolve_snooze_preset(
961                            snooze_presets()[self.snooze_panel.selected_index],
962                            &self.snooze_config,
963                        );
964                        self.queue_mutation(
965                            Request::Snooze {
966                                message_id: env.id.clone(),
967                                wake_at,
968                            },
969                            MutationEffect::StatusOnly(format!(
970                                "Snoozed until {}",
971                                wake_at
972                                    .with_timezone(&chrono::Local)
973                                    .format("%a %b %e %H:%M")
974                            )),
975                            "Snoozing...".into(),
976                        );
977                    }
978                    self.snooze_panel.visible = false;
979                } else if self.context_envelope().is_some() {
980                    self.snooze_panel.visible = true;
981                    self.snooze_panel.selected_index = 0;
982                } else {
983                    self.status_message = Some("No message selected".into());
984                }
985            }
986            Action::OpenInBrowser => {
987                if let Some(env) = self.context_envelope() {
988                    let url = format!(
989                        "https://mail.google.com/mail/u/0/#inbox/{}",
990                        env.provider_id
991                    );
992                    #[cfg(target_os = "macos")]
993                    let _ = std::process::Command::new("open").arg(&url).spawn();
994                    #[cfg(target_os = "linux")]
995                    let _ = std::process::Command::new("xdg-open").arg(&url).spawn();
996                    self.status_message = Some("Opened in browser".into());
997                }
998            }
999
1000            // Phase 2: Reader mode
1001            Action::ToggleReaderMode => {
1002                if let BodyViewState::Ready { .. } = self.body_view_state {
1003                    self.reader_mode = !self.reader_mode;
1004                    if let Some(env) = self.viewing_envelope.clone() {
1005                        self.body_view_state = self.resolve_body_view_state(&env);
1006                    }
1007                }
1008            }
1009            Action::ToggleSignature => {
1010                self.signature_expanded = !self.signature_expanded;
1011            }
1012
1013            // Phase 2: Batch operations (A007)
1014            Action::ToggleSelect => {
1015                if let Some(env) = self.context_envelope() {
1016                    let id = env.id.clone();
1017                    if self.selected_set.contains(&id) {
1018                        self.selected_set.remove(&id);
1019                    } else {
1020                        self.selected_set.insert(id);
1021                    }
1022                    // Move to next after toggling
1023                    if self.screen == Screen::Search {
1024                        if self.search_page.selected_index + 1 < self.search_row_count() {
1025                            self.search_page.selected_index += 1;
1026                            self.ensure_search_visible();
1027                            self.auto_preview_search();
1028                            self.maybe_load_more_search_results();
1029                        }
1030                    } else if self.selected_index + 1 < self.mail_row_count() {
1031                        self.selected_index += 1;
1032                        self.ensure_visible();
1033                        self.auto_preview();
1034                    }
1035                    let count = self.selected_set.len();
1036                    self.status_message = Some(format!("{count} selected"));
1037                }
1038            }
1039            Action::VisualLineMode => {
1040                if self.visual_mode {
1041                    // Exit visual mode
1042                    self.visual_mode = false;
1043                    self.visual_anchor = None;
1044                    self.status_message = Some("Visual mode off".into());
1045                } else {
1046                    self.visual_mode = true;
1047                    self.visual_anchor = Some(if self.screen == Screen::Search {
1048                        self.search_page.selected_index
1049                    } else {
1050                        self.selected_index
1051                    });
1052                    // Add current to selection
1053                    if let Some(env) = self.context_envelope() {
1054                        self.selected_set.insert(env.id.clone());
1055                    }
1056                    self.status_message = Some("-- VISUAL LINE --".into());
1057                }
1058            }
1059            Action::PatternSelect(pattern) => {
1060                let envelopes = if self.screen == Screen::Search {
1061                    &self.search_page.results
1062                } else {
1063                    &self.envelopes
1064                };
1065                match pattern {
1066                    PatternKind::All => {
1067                        self.selected_set = envelopes.iter().map(|e| e.id.clone()).collect();
1068                    }
1069                    PatternKind::None => {
1070                        self.selected_set.clear();
1071                        self.visual_mode = false;
1072                        self.visual_anchor = None;
1073                    }
1074                    PatternKind::Read => {
1075                        self.selected_set = envelopes
1076                            .iter()
1077                            .filter(|e| e.flags.contains(MessageFlags::READ))
1078                            .map(|e| e.id.clone())
1079                            .collect();
1080                    }
1081                    PatternKind::Unread => {
1082                        self.selected_set = envelopes
1083                            .iter()
1084                            .filter(|e| !e.flags.contains(MessageFlags::READ))
1085                            .map(|e| e.id.clone())
1086                            .collect();
1087                    }
1088                    PatternKind::Starred => {
1089                        self.selected_set = envelopes
1090                            .iter()
1091                            .filter(|e| e.flags.contains(MessageFlags::STARRED))
1092                            .map(|e| e.id.clone())
1093                            .collect();
1094                    }
1095                    PatternKind::Thread => {
1096                        if let Some(env) = self.context_envelope() {
1097                            let tid = env.thread_id.clone();
1098                            self.selected_set = envelopes
1099                                .iter()
1100                                .filter(|e| e.thread_id == tid)
1101                                .map(|e| e.id.clone())
1102                                .collect();
1103                        }
1104                    }
1105                }
1106                let count = self.selected_set.len();
1107                self.status_message = Some(format!("{count} selected"));
1108            }
1109
1110            // Phase 2: Other actions
1111            Action::AttachmentList => {
1112                if self.attachment_panel.visible {
1113                    self.close_attachment_panel();
1114                } else {
1115                    self.open_attachment_panel();
1116                }
1117            }
1118            Action::OpenLinks => {
1119                self.open_url_modal();
1120            }
1121            Action::ToggleFullscreen => {
1122                if self.layout_mode == LayoutMode::FullScreen {
1123                    self.layout_mode = LayoutMode::ThreePane;
1124                } else if self.viewing_envelope.is_some() {
1125                    self.layout_mode = LayoutMode::FullScreen;
1126                }
1127            }
1128            Action::ExportThread => {
1129                if let Some(env) = self.context_envelope() {
1130                    self.pending_export_thread = Some(env.thread_id.clone());
1131                    self.status_message = Some("Exporting thread...".into());
1132                } else {
1133                    self.status_message = Some("No message selected".into());
1134                }
1135            }
1136            Action::Help => {
1137                self.help_modal_open = !self.help_modal_open;
1138                if self.help_modal_open {
1139                    self.help_scroll_offset = 0;
1140                }
1141            }
1142            Action::Noop => {}
1143        }
1144    }
1145}