Skip to main content

mxr_tui/app/
actions.rs

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