Skip to main content

mxr_tui/app/
actions.rs

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