Skip to main content

remendo/
update.rs

1//! Application state updater (TEA `update` function).
2//!
3//! The `Message` enum defines every action the app can take.
4//! The `update()` function is a pure state transition — it takes
5//! a mutable `App` reference and a `Message`, modifies state,
6//! and returns a `Cmd` describing any async side-effects to perform.
7
8use crate::app::{
9    App, FocusPanel, InputMode, ListContent, RunningState, SidebarSection, SortColumn,
10    SortDirection, ViewMode,
11};
12use crate::client::ApiError;
13use crate::client::types::ListParams;
14use crate::cmd::Cmd;
15use crate::models::{
16    EmailMessage, MailingList, Paginated, PatchId, Patchset, PatchsetDetail, ServerStats,
17};
18
19/// Every action the application can take.
20///
21/// Produced by [`crate::event::handle_event`] from raw terminal
22/// events, or by background tasks delivering API responses.
23#[derive(Debug)]
24pub enum Message {
25    /// Exit the application.
26    Quit,
27    /// Application should perform initial data load.
28    Init,
29    /// User requested a refresh of the current view.
30    Refresh,
31    /// Periodic tick for background work.
32    Tick,
33    /// Time to render a new frame.
34    Render,
35    /// Terminal was resized.
36    Resize(u16, u16),
37    /// Patchset list loaded from API.
38    PatchsetsLoaded(Result<Paginated<Patchset>, ApiError>),
39    /// Patchset detail loaded from API.
40    PatchsetDetailLoaded(Box<Result<PatchsetDetail, ApiError>>),
41    /// Server stats loaded from API.
42    StatsLoaded(Result<ServerStats, ApiError>),
43    /// Mailing lists loaded from API.
44    ListsLoaded(Result<Vec<MailingList>, ApiError>),
45    /// Move selection down one row.
46    ScrollDown,
47    /// Move selection up one row.
48    ScrollUp,
49    /// Move selection down half a page.
50    HalfPageDown,
51    /// Move selection up half a page.
52    HalfPageUp,
53    /// Select the currently highlighted patchset.
54    Select,
55    /// Switch to the next remote/mailbox.
56    NextMailbox,
57    /// Switch to the previous remote/mailbox.
58    PrevMailbox,
59    /// Toggle focus between sidebar and main pane.
60    ToggleFocus,
61    /// Navigate back: dismiss overlay, or return from detail to list.
62    Back,
63    /// Toggle the help overlay visibility.
64    ToggleHelp,
65    /// Navigate to the next page of patchsets.
66    NextPage,
67    /// Navigate to the previous page of patchsets.
68    PrevPage,
69    /// Enter search input mode.
70    SearchStart,
71    /// Insert a character into the search buffer.
72    SearchInput(char),
73    /// Submit the search query (Enter).
74    SearchSubmit,
75    /// Cancel search mode (Esc).
76    SearchCancel,
77    /// Open the raw review log in the configured editor.
78    ViewRawLog,
79    /// Toggle bookmark on the currently selected patchset.
80    BookmarkToggle,
81    /// Bookmarks were persisted to disk.
82    BookmarksPersisted(Result<(), String>),
83    /// Jump to the next comment in the detail view.
84    NextComment,
85    /// Jump to the previous comment in the detail view.
86    PrevComment,
87    /// Open the baseline application log in the configured editor.
88    ViewBaselineLog,
89    /// Toggle between patchset and message list views.
90    ToggleListContent,
91    /// Message list loaded from API.
92    MessagesLoaded(Result<Paginated<EmailMessage>, ApiError>),
93    /// Message detail loaded from API.
94    MessageDetailLoaded(Box<Result<EmailMessage, ApiError>>),
95    /// Toggle the bookmark-only filter in the list view.
96    ToggleBookmarkFilter,
97    /// Cycle to the next sort column.
98    CycleSort,
99    /// Reverse the current sort direction.
100    ReverseSort,
101}
102
103/// Apply a message to the application state and return any
104/// commands (async side-effects) to execute.
105///
106/// This is the core TEA update function — a pure state transition.
107/// It never performs I/O directly; instead it returns `Cmd` values
108/// that the runtime executes asynchronously.
109///
110/// # Examples
111///
112/// ```
113/// use remendo::app::App;
114/// use remendo::config::Config;
115/// use remendo::update::{update, Message};
116/// use remendo::cmd::Cmd;
117/// use remendo::app::RunningState;
118///
119/// let mut app = App::new(Config::default());
120/// let cmd = update(&mut app, Message::Quit);
121/// assert_eq!(app.running_state, RunningState::Done);
122/// assert!(matches!(cmd, Cmd::None));
123/// ```
124pub fn update(app: &mut App, msg: Message) -> Cmd {
125    log_message(&msg, app);
126    match msg {
127        Message::Quit => {
128            tracing::info!("app quit requested");
129            app.running_state = RunningState::Done;
130            Cmd::None
131        }
132        Message::Init => Cmd::Batch(vec![
133            Cmd::FetchLists,
134            Cmd::FetchPatchsets(app.list_params.clone()),
135            Cmd::FetchStats,
136        ]),
137        Message::Refresh => {
138            let content_fetch = match app.list_content {
139                ListContent::Patchsets => Cmd::FetchPatchsets(app.list_params.clone()),
140                ListContent::Messages => Cmd::FetchMessages(app.list_params.clone()),
141            };
142            Cmd::ClearCacheAndBatch(vec![content_fetch, Cmd::FetchLists, Cmd::FetchStats])
143        }
144        Message::Tick | Message::Render => Cmd::None,
145        Message::Resize(_w, h) => {
146            app.terminal_height = h;
147            Cmd::None
148        }
149        Message::PatchsetsLoaded(result) => handle_patchsets_loaded(app, result),
150        Message::PatchsetDetailLoaded(result) => handle_detail_loaded(app, *result),
151        Message::StatsLoaded(result) => handle_stats_loaded(app, result),
152        Message::ListsLoaded(result) => handle_lists_loaded(app, result),
153        Message::ScrollDown | Message::ScrollUp | Message::HalfPageDown | Message::HalfPageUp => {
154            handle_scroll(app, &msg)
155        }
156        Message::Select => handle_select(app),
157        Message::NextMailbox => {
158            if app.config.remotes.is_empty() {
159                return Cmd::None;
160            }
161            let new_idx = (app.active_remote_index + 1) % app.config.remotes.len();
162            switch_remote(app, new_idx)
163        }
164        Message::PrevMailbox => {
165            if app.config.remotes.is_empty() {
166                return Cmd::None;
167            }
168            let len = app.config.remotes.len();
169            let new_idx = app.active_remote_index.checked_sub(1).unwrap_or(len - 1);
170            switch_remote(app, new_idx)
171        }
172        Message::ToggleFocus => {
173            app.focus = match app.focus {
174                FocusPanel::PatchsetList => FocusPanel::Sidebar,
175                FocusPanel::Sidebar => FocusPanel::PatchsetList,
176            };
177            Cmd::None
178        }
179        Message::Back => handle_back(app),
180        Message::ToggleHelp => {
181            app.show_help = !app.show_help;
182            Cmd::None
183        }
184        Message::NextPage | Message::PrevPage => handle_page_nav(app, &msg),
185        Message::SearchStart
186        | Message::SearchInput(_)
187        | Message::SearchSubmit
188        | Message::SearchCancel => handle_search(app, &msg),
189        Message::ViewRawLog => handle_view_raw_log(app),
190        Message::BookmarkToggle => handle_bookmark_toggle(app),
191        Message::BookmarksPersisted(result) => {
192            if let Err(ref e) = result {
193                tracing::error!(error = %e, "bookmark persist failed");
194            }
195            Cmd::None
196        }
197        Message::NextComment => handle_comment_nav(app, true),
198        Message::PrevComment => handle_comment_nav(app, false),
199        Message::ViewBaselineLog => handle_view_baseline_log(app),
200        Message::ToggleListContent => handle_toggle_list_content(app),
201        Message::MessagesLoaded(result) => handle_messages_loaded(app, result),
202        Message::MessageDetailLoaded(result) => handle_message_detail_loaded(app, *result),
203        Message::ToggleBookmarkFilter => handle_bookmark_filter_toggle(app),
204        Message::CycleSort => handle_sort_cycle(app),
205        Message::ReverseSort => handle_reverse_sort(app),
206    }
207}
208
209/// Log a message at the appropriate tracing level.
210fn log_message(msg: &Message, app: &App) {
211    match msg {
212        Message::Init => tracing::info!("init"),
213        Message::Refresh => tracing::debug!("refresh"),
214        Message::Select => {
215            tracing::debug!(index = app.selected_index, "select");
216        }
217        Message::NextMailbox => tracing::debug!("next mailbox"),
218        Message::PrevMailbox => tracing::debug!("prev mailbox"),
219        Message::NextPage => tracing::debug!(page = app.list_params.page, "next page"),
220        Message::PrevPage => tracing::debug!(page = app.list_params.page, "prev page"),
221        Message::Back => tracing::debug!("back"),
222        Message::ToggleFocus => tracing::debug!("toggle focus"),
223        Message::ToggleHelp => tracing::debug!("toggle help"),
224        Message::SearchStart => tracing::debug!("search start"),
225        Message::SearchSubmit => tracing::debug!("search submit"),
226        Message::SearchCancel => tracing::debug!("search cancel"),
227        Message::ViewRawLog => tracing::debug!("view raw log"),
228        Message::Tick | Message::Render => tracing::trace!("tick/render"),
229        Message::Resize(w, h) => tracing::trace!(w, h, "resize"),
230        Message::ScrollDown | Message::ScrollUp | Message::HalfPageDown | Message::HalfPageUp => {
231            tracing::trace!("scroll");
232        }
233        Message::SearchInput(_) => tracing::trace!("search input"),
234        Message::BookmarkToggle => tracing::debug!("bookmark toggle"),
235        Message::NextComment => tracing::debug!("next comment"),
236        Message::PrevComment => tracing::debug!("prev comment"),
237        Message::ViewBaselineLog => tracing::debug!("view baseline log"),
238        Message::ToggleListContent => tracing::debug!("toggle list content"),
239        Message::ToggleBookmarkFilter => tracing::debug!("toggle bookmark filter"),
240        Message::CycleSort => tracing::debug!("cycle sort"),
241        Message::ReverseSort => tracing::debug!("reverse sort"),
242        // API results logged in their handlers; Quit logged in update()
243        Message::PatchsetsLoaded(_)
244        | Message::PatchsetDetailLoaded(_)
245        | Message::StatsLoaded(_)
246        | Message::ListsLoaded(_)
247        | Message::BookmarksPersisted(_)
248        | Message::MessagesLoaded(_)
249        | Message::MessageDetailLoaded(_)
250        | Message::Quit => {}
251    }
252}
253
254/// Handle `Message::ViewRawLog` — collect review content and open in editor.
255fn handle_view_raw_log(app: &App) -> Cmd {
256    use std::fmt::Write;
257
258    let Some(ref detail) = app.selected_detail else {
259        return Cmd::None;
260    };
261
262    let mut log = String::new();
263    for review in &detail.reviews {
264        let _ = writeln!(
265            log,
266            "=== Review {} (patch {}) ===",
267            review.id, review.patch_id
268        );
269        let _ = writeln!(
270            log,
271            "Status: {} | Model: {}",
272            review.status,
273            review.model.as_deref().unwrap_or("?")
274        );
275        if let Some(ref summary) = review.summary {
276            let _ = write!(log, "\n--- Summary ---\n{summary}\n");
277        }
278        if let Some(ref inline) = review.inline_review {
279            let _ = write!(log, "\n--- Inline Review ---\n{inline}\n");
280        }
281        log.push_str("\n\n");
282    }
283
284    if log.is_empty() {
285        return Cmd::None;
286    }
287
288    let editor = app.config.resolved_editor();
289    Cmd::OpenEditor {
290        content: log,
291        editor,
292    }
293}
294
295/// Handle `Message::Back` — close help, clear search filter, or return from detail.
296fn handle_back(app: &mut App) -> Cmd {
297    if app.show_help {
298        app.show_help = false;
299        return Cmd::None;
300    }
301    // If we're in list view with an active filter, clear it
302    if app.view_mode == ViewMode::List && app.list_params.search.is_some() {
303        app.list_params.search = None;
304        app.list_params.page = 1;
305        app.selected_index = 0;
306        return match app.list_content {
307            ListContent::Patchsets => Cmd::FetchPatchsets(app.list_params.clone()),
308            ListContent::Messages => Cmd::FetchMessages(app.list_params.clone()),
309        };
310    }
311    if app.view_mode == ViewMode::Detail || app.view_mode == ViewMode::Loading {
312        app.view_mode = ViewMode::List;
313        app.selected_detail = None;
314        app.selected_message = None;
315        app.loading_context = None;
316        app.detail_scroll_offset = 0;
317        app.comment_positions.clear();
318        app.current_comment_index = None;
319    }
320    Cmd::None
321}
322
323/// Handle `Message::ToggleListContent` — switch between patchsets and messages.
324fn handle_toggle_list_content(app: &mut App) -> Cmd {
325    app.selected_index = 0;
326    app.list_params.page = 1;
327    // Preserve search and mailing_list filters (both APIs accept the same params)
328    match app.list_content {
329        ListContent::Patchsets => {
330            app.list_content = ListContent::Messages;
331            Cmd::FetchMessages(app.list_params.clone())
332        }
333        ListContent::Messages => {
334            app.list_content = ListContent::Patchsets;
335            Cmd::FetchPatchsets(app.list_params.clone())
336        }
337    }
338}
339
340/// Handle `Message::MessagesLoaded` — store message list.
341fn handle_messages_loaded(app: &mut App, result: Result<Paginated<EmailMessage>, ApiError>) -> Cmd {
342    match result {
343        Ok(paginated) => {
344            tracing::info!(
345                count = paginated.items.len(),
346                total = paginated.total,
347                "messages loaded"
348            );
349            app.messages = paginated;
350            app.error_state = None;
351        }
352        Err(ref e) => {
353            tracing::error!(error = %e, "messages load failed");
354            app.error_state = Some(e.to_string());
355        }
356    }
357    Cmd::None
358}
359
360/// Handle `Message::MessageDetailLoaded` — store and show message detail.
361fn handle_message_detail_loaded(app: &mut App, result: Result<EmailMessage, ApiError>) -> Cmd {
362    match result {
363        Ok(msg) => {
364            tracing::info!(id = msg.id, "message detail loaded");
365            app.selected_message = Some(msg);
366            app.view_mode = ViewMode::Detail;
367            app.loading_context = None;
368            app.detail_scroll_offset = 0;
369            app.error_state = None;
370        }
371        Err(ref e) => {
372            tracing::error!(error = %e, "message detail load failed");
373            app.error_state = Some(e.to_string());
374            app.view_mode = ViewMode::List;
375            app.loading_context = None;
376        }
377    }
378    Cmd::None
379}
380
381/// Handle `Message::ViewBaselineLog` — open baseline logs in editor.
382fn handle_view_baseline_log(app: &App) -> Cmd {
383    let content = app
384        .selected_detail
385        .as_ref()
386        .and_then(|d| d.baseline_logs.as_ref())
387        .filter(|s| !s.is_empty());
388    match content {
389        Some(logs) => {
390            let editor = app.config.resolved_editor();
391            Cmd::OpenEditor {
392                content: logs.clone(),
393                editor,
394            }
395        }
396        None => Cmd::None,
397    }
398}
399
400/// Handle page navigation (`NextPage` / `PrevPage`).
401fn handle_page_nav(app: &mut App, msg: &Message) -> Cmd {
402    let paginated_total = match app.list_content {
403        ListContent::Patchsets => app.patchsets.total_pages(),
404        ListContent::Messages => app.messages.total_pages(),
405    };
406    let fetch = |app: &App| match app.list_content {
407        ListContent::Patchsets => Cmd::FetchPatchsets(app.list_params.clone()),
408        ListContent::Messages => Cmd::FetchMessages(app.list_params.clone()),
409    };
410    match *msg {
411        Message::NextPage => {
412            if paginated_total > 0 && app.list_params.page < paginated_total {
413                app.list_params.page += 1;
414                app.selected_index = 0;
415                fetch(app)
416            } else {
417                Cmd::None
418            }
419        }
420        Message::PrevPage => {
421            if app.list_params.page > 1 {
422                app.list_params.page -= 1;
423                app.selected_index = 0;
424                fetch(app)
425            } else {
426                Cmd::None
427            }
428        }
429        _ => Cmd::None,
430    }
431}
432
433/// Handle `Message::BookmarkToggle` — toggle bookmark on the selected patchset.
434fn handle_bookmark_toggle(app: &mut App) -> Cmd {
435    if app.view_mode != ViewMode::List || app.focus != FocusPanel::PatchsetList {
436        return Cmd::None;
437    }
438    let Some(patchset) = app.patchsets.items.get(app.selected_index) else {
439        return Cmd::None;
440    };
441    let id = patchset.id;
442    let is_bookmarked = app.bookmarks.toggle(&app.active_remote, id);
443    tracing::debug!(
444        remote = %app.active_remote,
445        patchset_id = id,
446        bookmarked = is_bookmarked,
447        "bookmark toggle"
448    );
449    Cmd::PersistBookmarks {
450        bookmarks: app.bookmarks.clone(),
451        path: app.bookmarks_path.clone(),
452    }
453}
454
455/// Compute line positions of comment boundaries in a patchset detail.
456///
457/// Mirrors the line-counting logic of `detail_header_lines`, `detail_patches_lines`,
458/// and `detail_thread_lines` without building styled Line objects.
459fn compute_comment_positions(detail: &PatchsetDetail) -> Vec<usize> {
460    let mut positions = Vec::new();
461    let mut line = 0;
462
463    // Header: status+author+date (1 line)
464    line += 1;
465    // Failed reason (conditional)
466    if detail.failed_reason.is_some() {
467        line += 1;
468    }
469    // Parts + subsystems (conditional)
470    if detail.total_parts.is_some() && detail.received_parts.is_some() {
471        line += 1;
472    }
473    // Baseline (conditional)
474    if detail.baseline.is_some() {
475        line += 1;
476    }
477    // Model/provider (conditional)
478    if detail.model_name.is_some() {
479        line += 1;
480    }
481    // Blank line
482    line += 1;
483
484    // Patches section header
485    line += 1;
486
487    if detail.patches.is_empty() {
488        line += 1; // "(no patches)"
489    } else {
490        for patch in &detail.patches {
491            positions.push(line); // patch row is a comment position
492            line += 1; // patch row
493
494            let review = detail.reviews.iter().find(|r| r.patch_id == patch.id);
495            if let Some(rev) = review {
496                if rev.summary.is_some() {
497                    line += 1; // "  Summary: ..."
498                }
499                if let Some(ref inline) = rev.inline_review {
500                    line += 1; // blank line before inline
501                    line += inline.lines().count(); // inline review lines
502                    line += 1; // blank line after inline
503                }
504            } else {
505                line += 1; // "(no review)"
506            }
507        }
508    }
509
510    // Blank line between sections
511    line += 1;
512
513    // Thread section header
514    line += 1;
515
516    if detail.thread.is_empty() {
517        // "(no messages)" — no comment position
518    } else {
519        for _msg in &detail.thread {
520            positions.push(line); // thread message is a comment position
521            line += 2; // author+date line + subject line
522        }
523    }
524
525    positions
526}
527
528/// Handle comment navigation (n/N) in the detail view.
529fn handle_comment_nav(app: &mut App, forward: bool) -> Cmd {
530    if app.view_mode != ViewMode::Detail || app.comment_positions.is_empty() {
531        return Cmd::None;
532    }
533
534    let positions = &app.comment_positions;
535    let offset = app.detail_scroll_offset;
536
537    let target = match app.current_comment_index {
538        Some(i) => {
539            if forward {
540                (i + 1).min(positions.len() - 1)
541            } else {
542                i.saturating_sub(1)
543            }
544        }
545        None => {
546            if forward {
547                // Find first position >= current offset
548                positions
549                    .iter()
550                    .position(|&p| p >= offset)
551                    .unwrap_or(positions.len() - 1)
552            } else {
553                // Find last position < current offset
554                positions.iter().rposition(|&p| p < offset).unwrap_or(0)
555            }
556        }
557    };
558
559    app.detail_scroll_offset = positions[target];
560    app.current_comment_index = Some(target);
561    Cmd::None
562}
563
564/// Handle search lifecycle messages.
565fn handle_search(app: &mut App, msg: &Message) -> Cmd {
566    match *msg {
567        Message::SearchStart => {
568            app.input_mode = InputMode::Search;
569            app.search_buffer = app.list_params.search.clone().unwrap_or_default();
570            app.search_cursor = app.search_buffer.len();
571            Cmd::None
572        }
573        Message::SearchInput(c) => {
574            if c == '\x08' {
575                // Backspace sentinel
576                if app.search_cursor > 0 {
577                    app.search_cursor -= 1;
578                    app.search_buffer.remove(app.search_cursor);
579                }
580            } else {
581                app.search_buffer.insert(app.search_cursor, c);
582                app.search_cursor += c.len_utf8();
583            }
584            Cmd::None
585        }
586        Message::SearchSubmit => {
587            app.input_mode = InputMode::Normal;
588            app.list_params.search = if app.search_buffer.is_empty() {
589                None
590            } else {
591                Some(app.search_buffer.clone())
592            };
593            app.list_params.page = 1;
594            app.selected_index = 0;
595            app.search_buffer.clear();
596            app.search_cursor = 0;
597            match app.list_content {
598                ListContent::Patchsets => Cmd::FetchPatchsets(app.list_params.clone()),
599                ListContent::Messages => Cmd::FetchMessages(app.list_params.clone()),
600            }
601        }
602        Message::SearchCancel => {
603            app.input_mode = InputMode::Normal;
604            app.search_buffer.clear();
605            app.search_cursor = 0;
606            Cmd::None
607        }
608        _ => Cmd::None,
609    }
610}
611
612/// Handle API response for patchset list.
613fn handle_patchsets_loaded(app: &mut App, result: Result<Paginated<Patchset>, ApiError>) -> Cmd {
614    match result {
615        Ok(paginated) => {
616            tracing::info!(
617                count = paginated.items.len(),
618                total = paginated.total,
619                page = paginated.page,
620                "patchsets loaded"
621            );
622            app.patchsets = paginated;
623            app.error_state = None;
624            apply_sort(app);
625        }
626        Err(ref e) => {
627            tracing::error!(error = %e, "patchsets load failed");
628            app.error_state = Some(e.to_string());
629        }
630    }
631    Cmd::None
632}
633
634/// Handle `Message::Select` — dispatch based on focus panel.
635fn handle_select(app: &mut App) -> Cmd {
636    if app.view_mode == ViewMode::Loading {
637        return Cmd::None;
638    }
639    match app.focus {
640        FocusPanel::PatchsetList => match app.list_content {
641            ListContent::Patchsets => {
642                let Some(patchset) = app.patchsets.items.get(app.selected_index) else {
643                    return Cmd::None;
644                };
645                app.loading_context = Some(crate::app::LoadingContext {
646                    patchset_id: patchset.id,
647                    subject: patchset.subject().to_string(),
648                    status: patchset.status.to_string(),
649                });
650                app.view_mode = ViewMode::Loading;
651                app.detail_scroll_offset = 0;
652                app.comment_positions.clear();
653                app.current_comment_index = None;
654                Cmd::FetchPatchsetDetail(PatchId::Numeric(patchset.id))
655            }
656            ListContent::Messages => {
657                let Some(msg) = app.messages.items.get(app.selected_index) else {
658                    return Cmd::None;
659                };
660                app.loading_context = Some(crate::app::LoadingContext {
661                    patchset_id: msg.id,
662                    subject: msg.subject.as_deref().unwrap_or("(no subject)").to_string(),
663                    status: String::new(),
664                });
665                app.view_mode = ViewMode::Loading;
666                app.detail_scroll_offset = 0;
667                Cmd::FetchMessageDetail(PatchId::Numeric(msg.id))
668            }
669        },
670        FocusPanel::Sidebar => handle_sidebar_select(app),
671    }
672}
673
674/// Handle API response for patchset detail.
675fn handle_detail_loaded(app: &mut App, result: Result<PatchsetDetail, ApiError>) -> Cmd {
676    match result {
677        Ok(detail) => {
678            tracing::info!(
679                id = detail.id,
680                patches = detail.patches.len(),
681                reviews = detail.reviews.len(),
682                "patchset detail loaded"
683            );
684            app.comment_positions = compute_comment_positions(&detail);
685            app.current_comment_index = None;
686            app.selected_detail = Some(detail);
687            app.view_mode = ViewMode::Detail;
688            app.loading_context = None;
689            app.error_state = None;
690        }
691        Err(ref e) => {
692            tracing::error!(error = %e, "patchset detail load failed");
693            app.error_state = Some(e.to_string());
694            // Return to list view on error
695            app.view_mode = ViewMode::List;
696            app.loading_context = None;
697        }
698    }
699    Cmd::None
700}
701
702/// Handle API response for server stats.
703fn handle_stats_loaded(app: &mut App, result: Result<ServerStats, ApiError>) -> Cmd {
704    match result {
705        Ok(stats) => {
706            tracing::info!(version = %stats.version, pending = stats.pending, "stats loaded");
707            app.stats = Some(stats);
708            app.error_state = None;
709        }
710        Err(ref e) => {
711            tracing::error!(error = %e, "stats load failed");
712            app.error_state = Some(e.to_string());
713        }
714    }
715    Cmd::None
716}
717
718/// Handle API response for mailing lists.
719fn handle_lists_loaded(app: &mut App, result: Result<Vec<MailingList>, ApiError>) -> Cmd {
720    match result {
721        Ok(lists) => {
722            tracing::info!(count = lists.len(), "mailing lists loaded");
723            app.mailing_lists = lists;
724            app.error_state = None;
725        }
726        Err(ref e) => {
727            tracing::error!(error = %e, "mailing lists load failed");
728            app.error_state = Some(e.to_string());
729        }
730    }
731    Cmd::None
732}
733
734/// Handle scroll/selection messages, routed by focus panel.
735fn handle_scroll(app: &mut App, msg: &Message) -> Cmd {
736    // Block all scroll during loading
737    if app.view_mode == ViewMode::Loading {
738        return Cmd::None;
739    }
740    match app.focus {
741        FocusPanel::PatchsetList => {}
742        FocusPanel::Sidebar => return handle_sidebar_scroll(app, msg),
743    }
744    // Detail view: scroll the detail content
745    if app.view_mode == ViewMode::Detail {
746        match *msg {
747            Message::ScrollDown => app.detail_scroll_offset += 1,
748            Message::ScrollUp => {
749                app.detail_scroll_offset = app.detail_scroll_offset.saturating_sub(1);
750            }
751            Message::HalfPageDown => {
752                app.detail_scroll_offset += usize::from(app.terminal_height / 2).max(1);
753            }
754            Message::HalfPageUp => {
755                let half = usize::from(app.terminal_height / 2).max(1);
756                app.detail_scroll_offset = app.detail_scroll_offset.saturating_sub(half);
757            }
758            _ => {}
759        }
760        app.current_comment_index = None; // free scroll clears comment anchor
761        return Cmd::None;
762    }
763    // List view: scroll the selected index
764    match *msg {
765        Message::ScrollDown => {
766            let len = app.patchsets.items.len();
767            if len > 0 {
768                app.selected_index = (app.selected_index + 1).min(len - 1);
769            }
770        }
771        Message::ScrollUp => {
772            app.selected_index = app.selected_index.saturating_sub(1);
773        }
774        Message::HalfPageDown => {
775            let len = app.patchsets.items.len();
776            let half = usize::from(app.terminal_height / 2).max(1);
777            if len > 0 {
778                app.selected_index = (app.selected_index + half).min(len - 1);
779            }
780        }
781        Message::HalfPageUp => {
782            let half = usize::from(app.terminal_height / 2).max(1);
783            app.selected_index = app.selected_index.saturating_sub(half);
784        }
785        _ => {}
786    }
787    Cmd::None
788}
789
790/// Handle scroll within the sidebar — navigate between remotes and mailing lists.
791fn handle_sidebar_scroll(app: &mut App, msg: &Message) -> Cmd {
792    let is_down = matches!(*msg, Message::ScrollDown | Message::HalfPageDown);
793    let is_up = matches!(*msg, Message::ScrollUp | Message::HalfPageUp);
794
795    match app.sidebar_section {
796        SidebarSection::Remotes => {
797            if is_down {
798                if app.active_remote_index + 1 < app.config.remotes.len() {
799                    app.active_remote_index += 1;
800                } else if !app.mailing_lists.is_empty() {
801                    // Transition to mailing lists
802                    app.sidebar_section = SidebarSection::MailingLists;
803                    app.sidebar_list_index = 0;
804                }
805            } else if is_up {
806                app.active_remote_index = app.active_remote_index.saturating_sub(1);
807            }
808        }
809        SidebarSection::MailingLists => {
810            // +1 for the "All" entry at index 0
811            let max_index = app.mailing_lists.len(); // 0=All, 1..len=lists
812            if is_down {
813                if app.sidebar_list_index < max_index {
814                    app.sidebar_list_index += 1;
815                }
816            } else if is_up {
817                if app.sidebar_list_index > 0 {
818                    app.sidebar_list_index -= 1;
819                } else {
820                    // Transition back to remotes
821                    app.sidebar_section = SidebarSection::Remotes;
822                    app.active_remote_index = app.config.remotes.len().saturating_sub(1);
823                }
824            }
825        }
826    }
827    Cmd::None
828}
829
830/// Handle Enter in the sidebar — switch remote or apply mailing list filter.
831fn handle_sidebar_select(app: &mut App) -> Cmd {
832    match app.sidebar_section {
833        SidebarSection::Remotes => switch_remote(app, app.active_remote_index),
834        SidebarSection::MailingLists => {
835            if app.sidebar_list_index == 0 {
836                // "All" — clear filter
837                app.list_params.mailing_list = None;
838            } else {
839                let idx = app.sidebar_list_index - 1;
840                if let Some(list) = app.mailing_lists.get(idx) {
841                    app.list_params.mailing_list =
842                        Some(list.group.clone().unwrap_or_else(|| list.name.clone()));
843                }
844            }
845            app.list_params.page = 1;
846            app.selected_index = 0;
847            Cmd::FetchPatchsets(app.list_params.clone())
848        }
849    }
850}
851
852/// Toggle the bookmark-only display filter.
853fn handle_bookmark_filter_toggle(app: &mut App) -> Cmd {
854    app.show_bookmarks_only = !app.show_bookmarks_only;
855    app.selected_index = 0;
856    Cmd::None
857}
858
859/// Map a `PatchsetStatus` to a sort key (lifecycle priority order).
860fn status_sort_key(s: crate::models::PatchsetStatus) -> u8 {
861    use crate::models::PatchsetStatus;
862    match s {
863        PatchsetStatus::FailedToApply => 0,
864        PatchsetStatus::Failed => 1,
865        PatchsetStatus::Incomplete => 2,
866        PatchsetStatus::Pending => 3,
867        PatchsetStatus::InReview => 4,
868        PatchsetStatus::Cancelled => 5,
869        PatchsetStatus::Skipped => 6,
870        PatchsetStatus::Reviewed => 7,
871        PatchsetStatus::Unknown => 8,
872    }
873}
874
875/// Sort `app.patchsets.items` in-place by the active sort column and direction.
876pub fn apply_sort(app: &mut App) {
877    let asc = app.sort_direction == SortDirection::Ascending;
878    match app.sort_column {
879        SortColumn::Default => {} // preserve API order
880        SortColumn::Status => app.patchsets.items.sort_by(|a, b| {
881            let ord = status_sort_key(a.status).cmp(&status_sort_key(b.status));
882            if asc { ord } else { ord.reverse() }
883        }),
884        SortColumn::Date => app.patchsets.items.sort_by(|a, b| {
885            // None sorts last regardless of direction
886            let ord = match (a.date, b.date) {
887                (None, None) => std::cmp::Ordering::Equal,
888                (None, Some(_)) => std::cmp::Ordering::Greater,
889                (Some(_), None) => std::cmp::Ordering::Less,
890                (Some(da), Some(db)) => da.cmp(&db),
891            };
892            if asc { ord } else { ord.reverse() }
893        }),
894        SortColumn::Findings => app.patchsets.items.sort_by(|a, b| {
895            let ord = a.findings.total().cmp(&b.findings.total());
896            if asc { ord } else { ord.reverse() }
897        }),
898        SortColumn::Author => app.patchsets.items.sort_by(|a, b| {
899            let ord = a.author().cmp(b.author());
900            if asc { ord } else { ord.reverse() }
901        }),
902    }
903}
904
905/// Advance the sort column to the next variant and reset direction.
906fn handle_sort_cycle(app: &mut App) -> Cmd {
907    app.sort_column = match app.sort_column {
908        SortColumn::Default => SortColumn::Status,
909        SortColumn::Status => SortColumn::Date,
910        SortColumn::Date => SortColumn::Findings,
911        SortColumn::Findings => SortColumn::Author,
912        SortColumn::Author => SortColumn::Default,
913    };
914    app.sort_direction = SortDirection::Ascending;
915    app.selected_index = 0;
916    apply_sort(app);
917    Cmd::None
918}
919
920/// Toggle the sort direction without changing the column.
921fn handle_reverse_sort(app: &mut App) -> Cmd {
922    app.sort_direction = match app.sort_direction {
923        SortDirection::Ascending => SortDirection::Descending,
924        SortDirection::Descending => SortDirection::Ascending,
925    };
926    app.selected_index = 0;
927    apply_sort(app);
928    Cmd::None
929}
930
931/// Switch the active remote to the given index, clear patchset data,
932/// and return a fetch command batch.
933fn switch_remote(app: &mut App, new_index: usize) -> Cmd {
934    let Some(remote) = app.config.remotes.get(new_index) else {
935        return Cmd::None;
936    };
937    app.active_remote_index = new_index;
938    app.active_remote = remote.name.clone();
939    app.patchsets.items.clear();
940    app.patchsets.total = 0;
941    app.selected_index = 0;
942    app.error_state = None;
943    app.stats = None;
944    app.view_mode = ViewMode::List;
945    app.selected_detail = None;
946    app.loading_context = None;
947    app.detail_scroll_offset = 0;
948    app.list_params = ListParams::default();
949    app.input_mode = InputMode::Normal;
950    app.search_buffer.clear();
951    app.search_cursor = 0;
952    app.sidebar_section = SidebarSection::Remotes;
953    app.sidebar_list_index = 0;
954    app.comment_positions.clear();
955    app.current_comment_index = None;
956    app.list_content = ListContent::Patchsets;
957    app.messages.items.clear();
958    app.messages.total = 0;
959    app.selected_message = None;
960    app.sort_column = SortColumn::Default;
961    app.sort_direction = SortDirection::Ascending;
962    app.show_bookmarks_only = false;
963    Cmd::Batch(vec![
964        Cmd::FetchPatchsets(app.list_params.clone()),
965        Cmd::FetchLists,
966        Cmd::FetchStats,
967    ])
968}
969
970#[cfg(test)]
971#[allow(clippy::expect_used, clippy::panic)]
972mod tests {
973    use super::*;
974    use crate::config::Config;
975
976    #[test]
977    fn quit_returns_none() {
978        let mut app = App::new(Config::default());
979        let cmd = update(&mut app, Message::Quit);
980        assert_eq!(app.running_state, RunningState::Done);
981        assert!(matches!(cmd, Cmd::None));
982    }
983
984    #[test]
985    fn init_returns_batch_with_three_fetches() {
986        let mut app = App::new(Config::default());
987        let cmd = update(&mut app, Message::Init);
988        match cmd {
989            Cmd::Batch(cmds) => {
990                assert_eq!(cmds.len(), 3);
991                assert!(matches!(cmds[0], Cmd::FetchLists));
992                assert!(matches!(cmds[1], Cmd::FetchPatchsets(_)));
993                assert!(matches!(cmds[2], Cmd::FetchStats));
994            }
995            other => panic!("expected Cmd::Batch, got {other:?}"),
996        }
997    }
998
999    #[test]
1000    fn refresh_clears_cache_and_fetches() {
1001        let mut app = App::new(Config::default());
1002        let cmd = update(&mut app, Message::Refresh);
1003        match cmd {
1004            Cmd::ClearCacheAndBatch(cmds) => {
1005                assert_eq!(cmds.len(), 3);
1006                assert!(matches!(cmds[0], Cmd::FetchPatchsets(_)));
1007                assert!(matches!(cmds[1], Cmd::FetchLists));
1008                assert!(matches!(cmds[2], Cmd::FetchStats));
1009            }
1010            other => panic!("expected Cmd::ClearCacheAndBatch, got {other:?}"),
1011        }
1012    }
1013
1014    #[test]
1015    fn tick_and_render_return_none() {
1016        let mut app = App::new(Config::default());
1017        assert!(matches!(update(&mut app, Message::Tick), Cmd::None));
1018        assert!(matches!(update(&mut app, Message::Render), Cmd::None));
1019    }
1020
1021    #[test]
1022    fn resize_returns_none() {
1023        let mut app = App::new(Config::default());
1024        assert!(matches!(
1025            update(&mut app, Message::Resize(120, 40)),
1026            Cmd::None
1027        ));
1028    }
1029
1030    #[test]
1031    fn patchsets_loaded_success() {
1032        let mut app = App::new(Config::default());
1033        let paginated = Paginated {
1034            items: vec![Patchset::fixture()],
1035            total: 1,
1036            page: 1,
1037            per_page: 50,
1038        };
1039        let cmd = update(&mut app, Message::PatchsetsLoaded(Ok(paginated)));
1040        assert_eq!(app.patchsets.items.len(), 1);
1041        assert!(app.error_state.is_none());
1042        assert!(matches!(cmd, Cmd::None));
1043    }
1044
1045    #[test]
1046    fn patchsets_loaded_error() {
1047        let mut app = App::new(Config::default());
1048        let err = ApiError::Network {
1049            source: "connection refused".into(),
1050            remote: "test".to_string(),
1051        };
1052        let cmd = update(&mut app, Message::PatchsetsLoaded(Err(err)));
1053        assert!(app.error_state.is_some());
1054        assert!(matches!(cmd, Cmd::None));
1055    }
1056
1057    #[test]
1058    fn lists_loaded_success() {
1059        let mut app = App::new(Config::default());
1060        let lists = vec![MailingList {
1061            name: "LKML".to_string(),
1062            group: Some("org.kernel.vger.linux-kernel".to_string()),
1063        }];
1064        let cmd = update(&mut app, Message::ListsLoaded(Ok(lists)));
1065        assert_eq!(app.mailing_lists.len(), 1);
1066        assert!(matches!(cmd, Cmd::None));
1067    }
1068
1069    #[test]
1070    fn error_state_set_on_api_failure() {
1071        let mut app = App::new(Config::default());
1072        let err = ApiError::Timeout {
1073            endpoint: "/api/stats".to_string(),
1074            duration: std::time::Duration::from_secs(5),
1075        };
1076        let cmd = update(&mut app, Message::StatsLoaded(Err(err)));
1077        assert!(app.error_state.is_some());
1078        assert!(matches!(cmd, Cmd::None));
1079    }
1080
1081    fn app_with_patchsets(count: usize) -> App {
1082        let mut app = App::new(Config::default());
1083        app.patchsets = Paginated {
1084            items: (0..count)
1085                .map(|i| {
1086                    let mut ps = Patchset::fixture();
1087                    ps.id = i64::try_from(i).expect("test count fits i64");
1088                    ps
1089                })
1090                .collect(),
1091            total: u32::try_from(count).expect("test count fits u32"),
1092            page: 1,
1093            per_page: 50,
1094        };
1095        app.terminal_height = 24;
1096        app
1097    }
1098
1099    #[test]
1100    fn scroll_down_increments_index() {
1101        let mut app = app_with_patchsets(10);
1102        assert_eq!(app.selected_index, 0);
1103        let cmd = update(&mut app, Message::ScrollDown);
1104        assert_eq!(app.selected_index, 1);
1105        assert!(matches!(cmd, Cmd::None));
1106    }
1107
1108    #[test]
1109    fn scroll_down_clamps_at_last_item() {
1110        let mut app = app_with_patchsets(5);
1111        app.selected_index = 4; // last item
1112        let cmd = update(&mut app, Message::ScrollDown);
1113        assert_eq!(app.selected_index, 4); // stays clamped
1114        assert!(matches!(cmd, Cmd::None));
1115    }
1116
1117    #[test]
1118    fn scroll_down_empty_list_is_noop() {
1119        let mut app = app_with_patchsets(0);
1120        let cmd = update(&mut app, Message::ScrollDown);
1121        assert_eq!(app.selected_index, 0);
1122        assert!(matches!(cmd, Cmd::None));
1123    }
1124
1125    #[test]
1126    fn scroll_up_decrements_index() {
1127        let mut app = app_with_patchsets(10);
1128        app.selected_index = 5;
1129        let cmd = update(&mut app, Message::ScrollUp);
1130        assert_eq!(app.selected_index, 4);
1131        assert!(matches!(cmd, Cmd::None));
1132    }
1133
1134    #[test]
1135    fn scroll_up_clamps_at_zero() {
1136        let mut app = app_with_patchsets(10);
1137        app.selected_index = 0;
1138        let cmd = update(&mut app, Message::ScrollUp);
1139        assert_eq!(app.selected_index, 0);
1140        assert!(matches!(cmd, Cmd::None));
1141    }
1142
1143    #[test]
1144    fn half_page_down_advances_by_half_height() {
1145        let mut app = app_with_patchsets(50);
1146        app.terminal_height = 24;
1147        app.selected_index = 0;
1148        let cmd = update(&mut app, Message::HalfPageDown);
1149        assert_eq!(app.selected_index, 12); // 24/2 = 12
1150        assert!(matches!(cmd, Cmd::None));
1151    }
1152
1153    #[test]
1154    fn half_page_down_clamps_at_last_item() {
1155        let mut app = app_with_patchsets(10);
1156        app.terminal_height = 24;
1157        app.selected_index = 5;
1158        let cmd = update(&mut app, Message::HalfPageDown);
1159        assert_eq!(app.selected_index, 9); // clamped to last
1160        assert!(matches!(cmd, Cmd::None));
1161    }
1162
1163    #[test]
1164    fn half_page_up_subtracts_half_height() {
1165        let mut app = app_with_patchsets(50);
1166        app.terminal_height = 24;
1167        app.selected_index = 20;
1168        let cmd = update(&mut app, Message::HalfPageUp);
1169        assert_eq!(app.selected_index, 8); // 20 - 12 = 8
1170        assert!(matches!(cmd, Cmd::None));
1171    }
1172
1173    #[test]
1174    fn half_page_up_clamps_at_zero() {
1175        let mut app = app_with_patchsets(50);
1176        app.terminal_height = 24;
1177        app.selected_index = 3;
1178        let cmd = update(&mut app, Message::HalfPageUp);
1179        assert_eq!(app.selected_index, 0); // clamped to 0
1180        assert!(matches!(cmd, Cmd::None));
1181    }
1182
1183    #[test]
1184    fn select_returns_fetch_detail() {
1185        let mut app = app_with_patchsets(5);
1186        app.selected_index = 2;
1187        let cmd = update(&mut app, Message::Select);
1188        assert_eq!(app.selected_index, 2); // unchanged
1189        // Should return FetchPatchsetDetail for the selected patchset
1190        assert!(matches!(cmd, Cmd::FetchPatchsetDetail(_)));
1191    }
1192
1193    #[test]
1194    fn select_empty_list_returns_none() {
1195        let mut app = app_with_patchsets(0);
1196        let cmd = update(&mut app, Message::Select);
1197        assert!(matches!(cmd, Cmd::None));
1198    }
1199
1200    #[test]
1201    fn detail_loaded_stores_detail_and_switches_view() {
1202        let mut app = App::new(Config::default());
1203        let detail = PatchsetDetail::fixture();
1204        let cmd = update(
1205            &mut app,
1206            Message::PatchsetDetailLoaded(Box::new(Ok(detail))),
1207        );
1208        assert!(matches!(cmd, Cmd::None));
1209        assert_eq!(app.view_mode, ViewMode::Detail);
1210        assert!(app.selected_detail.is_some());
1211        assert!(app.error_state.is_none());
1212    }
1213
1214    #[test]
1215    fn detail_loaded_error_sets_error_state() {
1216        let mut app = App::new(Config::default());
1217        let err = ApiError::Network {
1218            source: "timeout".into(),
1219            remote: "test".to_string(),
1220        };
1221        let cmd = update(&mut app, Message::PatchsetDetailLoaded(Box::new(Err(err))));
1222        assert!(matches!(cmd, Cmd::None));
1223        assert!(app.error_state.is_some());
1224        assert!(app.selected_detail.is_none());
1225    }
1226
1227    #[test]
1228    fn resize_stores_terminal_height() {
1229        let mut app = App::new(Config::default());
1230        assert_eq!(app.terminal_height, 0);
1231        let cmd = update(&mut app, Message::Resize(120, 40));
1232        assert_eq!(app.terminal_height, 40);
1233        assert!(matches!(cmd, Cmd::None));
1234    }
1235
1236    fn app_with_remotes(names: &[&str]) -> App {
1237        let mut config = Config::default();
1238        for name in names {
1239            config
1240                .remotes
1241                .push(crate::config::RemoteConfig::fixture(name));
1242        }
1243        App::new(config)
1244    }
1245
1246    #[test]
1247    fn next_mailbox_cycles_forward() {
1248        let mut app = app_with_remotes(&["upstream", "staging", "local"]);
1249        assert_eq!(app.active_remote_index, 0);
1250        assert_eq!(app.active_remote, "upstream");
1251
1252        let cmd = update(&mut app, Message::NextMailbox);
1253        assert_eq!(app.active_remote_index, 1);
1254        assert_eq!(app.active_remote, "staging");
1255        assert!(matches!(cmd, Cmd::Batch(_)));
1256    }
1257
1258    #[test]
1259    fn next_mailbox_wraps_to_first() {
1260        let mut app = app_with_remotes(&["upstream", "staging"]);
1261        app.active_remote_index = 1;
1262        app.active_remote = "staging".to_string();
1263
1264        let cmd = update(&mut app, Message::NextMailbox);
1265        assert_eq!(app.active_remote_index, 0);
1266        assert_eq!(app.active_remote, "upstream");
1267        assert!(matches!(cmd, Cmd::Batch(_)));
1268    }
1269
1270    #[test]
1271    fn prev_mailbox_cycles_backward() {
1272        let mut app = app_with_remotes(&["upstream", "staging", "local"]);
1273        app.active_remote_index = 2;
1274        app.active_remote = "local".to_string();
1275
1276        let cmd = update(&mut app, Message::PrevMailbox);
1277        assert_eq!(app.active_remote_index, 1);
1278        assert_eq!(app.active_remote, "staging");
1279        assert!(matches!(cmd, Cmd::Batch(_)));
1280    }
1281
1282    #[test]
1283    fn prev_mailbox_wraps_to_last() {
1284        let mut app = app_with_remotes(&["upstream", "staging", "local"]);
1285        assert_eq!(app.active_remote_index, 0);
1286
1287        let cmd = update(&mut app, Message::PrevMailbox);
1288        assert_eq!(app.active_remote_index, 2);
1289        assert_eq!(app.active_remote, "local");
1290        assert!(matches!(cmd, Cmd::Batch(_)));
1291    }
1292
1293    #[test]
1294    fn next_mailbox_no_remotes_is_noop() {
1295        let mut app = App::new(Config::default());
1296        let cmd = update(&mut app, Message::NextMailbox);
1297        assert_eq!(app.active_remote_index, 0);
1298        assert!(matches!(cmd, Cmd::None));
1299    }
1300
1301    #[test]
1302    fn prev_mailbox_no_remotes_is_noop() {
1303        let mut app = App::new(Config::default());
1304        let cmd = update(&mut app, Message::PrevMailbox);
1305        assert_eq!(app.active_remote_index, 0);
1306        assert!(matches!(cmd, Cmd::None));
1307    }
1308
1309    #[test]
1310    fn mailbox_switch_clears_patchsets_and_resets_selection() {
1311        let mut app = app_with_remotes(&["upstream", "staging"]);
1312        // Simulate loaded patchsets
1313        app.patchsets = Paginated {
1314            items: vec![Patchset::fixture()],
1315            total: 1,
1316            page: 1,
1317            per_page: 50,
1318        };
1319        app.selected_index = 5;
1320        app.error_state = Some("old error".to_string());
1321
1322        update(&mut app, Message::NextMailbox);
1323        assert!(app.patchsets.items.is_empty());
1324        assert_eq!(app.patchsets.total, 0);
1325        assert_eq!(app.selected_index, 0);
1326        assert!(app.error_state.is_none());
1327    }
1328
1329    #[test]
1330    fn mailbox_switch_returns_fetch_batch() {
1331        let mut app = app_with_remotes(&["upstream", "staging"]);
1332        let cmd = update(&mut app, Message::NextMailbox);
1333        match cmd {
1334            Cmd::Batch(cmds) => {
1335                assert_eq!(cmds.len(), 3);
1336                assert!(matches!(cmds[0], Cmd::FetchPatchsets(_)));
1337                assert!(matches!(cmds[1], Cmd::FetchLists));
1338                assert!(matches!(cmds[2], Cmd::FetchStats));
1339            }
1340            other => panic!("expected Cmd::Batch, got {other:?}"),
1341        }
1342    }
1343
1344    #[test]
1345    fn toggle_focus_switches_panels() {
1346        let mut app = App::new(Config::default());
1347        assert_eq!(app.focus, FocusPanel::PatchsetList);
1348
1349        let cmd = update(&mut app, Message::ToggleFocus);
1350        assert_eq!(app.focus, FocusPanel::Sidebar);
1351        assert!(matches!(cmd, Cmd::None));
1352
1353        let cmd = update(&mut app, Message::ToggleFocus);
1354        assert_eq!(app.focus, FocusPanel::PatchsetList);
1355        assert!(matches!(cmd, Cmd::None));
1356    }
1357
1358    #[test]
1359    fn scroll_ignored_when_sidebar_focused() {
1360        let mut app = app_with_patchsets(10);
1361        app.focus = FocusPanel::Sidebar;
1362        app.selected_index = 0;
1363
1364        update(&mut app, Message::ScrollDown);
1365        assert_eq!(app.selected_index, 0); // unchanged
1366
1367        update(&mut app, Message::ScrollUp);
1368        assert_eq!(app.selected_index, 0);
1369
1370        update(&mut app, Message::HalfPageDown);
1371        assert_eq!(app.selected_index, 0);
1372
1373        update(&mut app, Message::HalfPageUp);
1374        assert_eq!(app.selected_index, 0);
1375    }
1376
1377    // --- Detail scroll tests ---
1378
1379    #[test]
1380    fn detail_scroll_adjusts_offset() {
1381        let mut app = app_with_patchsets(5);
1382        app.view_mode = ViewMode::Detail;
1383        app.terminal_height = 24;
1384        assert_eq!(app.detail_scroll_offset, 0);
1385
1386        update(&mut app, Message::ScrollDown);
1387        assert_eq!(app.detail_scroll_offset, 1);
1388
1389        update(&mut app, Message::ScrollDown);
1390        assert_eq!(app.detail_scroll_offset, 2);
1391
1392        update(&mut app, Message::ScrollUp);
1393        assert_eq!(app.detail_scroll_offset, 1);
1394    }
1395
1396    #[test]
1397    fn detail_half_page_scroll() {
1398        let mut app = app_with_patchsets(5);
1399        app.view_mode = ViewMode::Detail;
1400        app.terminal_height = 24;
1401
1402        update(&mut app, Message::HalfPageDown);
1403        assert_eq!(app.detail_scroll_offset, 12);
1404
1405        update(&mut app, Message::HalfPageUp);
1406        assert_eq!(app.detail_scroll_offset, 0);
1407    }
1408
1409    #[test]
1410    fn detail_scroll_up_clamps_at_zero() {
1411        let mut app = app_with_patchsets(5);
1412        app.view_mode = ViewMode::Detail;
1413
1414        update(&mut app, Message::ScrollUp);
1415        assert_eq!(app.detail_scroll_offset, 0);
1416    }
1417
1418    // --- Sidebar mailing list selection tests ---
1419
1420    #[test]
1421    fn sidebar_select_mailing_list_filters() {
1422        let mut app = app_with_remotes(&["upstream"]);
1423        app.mailing_lists = vec![crate::models::MailingList {
1424            name: "LKML".to_string(),
1425            group: Some("org.kernel.vger.linux-kernel".to_string()),
1426        }];
1427        app.focus = FocusPanel::Sidebar;
1428        app.sidebar_section = SidebarSection::MailingLists;
1429        app.sidebar_list_index = 1; // first mailing list (0 = "All")
1430
1431        let cmd = update(&mut app, Message::Select);
1432        assert_eq!(
1433            app.list_params.mailing_list,
1434            Some("org.kernel.vger.linux-kernel".to_string())
1435        );
1436        assert_eq!(app.list_params.page, 1);
1437        assert!(matches!(cmd, Cmd::FetchPatchsets(_)));
1438    }
1439
1440    #[test]
1441    fn sidebar_select_all_clears_filter() {
1442        let mut app = app_with_remotes(&["upstream"]);
1443        app.mailing_lists = vec![crate::models::MailingList::fixture()];
1444        app.list_params.mailing_list = Some("old-filter".to_string());
1445        app.focus = FocusPanel::Sidebar;
1446        app.sidebar_section = SidebarSection::MailingLists;
1447        app.sidebar_list_index = 0; // "All"
1448
1449        let cmd = update(&mut app, Message::Select);
1450        assert!(app.list_params.mailing_list.is_none());
1451        assert!(matches!(cmd, Cmd::FetchPatchsets(_)));
1452    }
1453
1454    // --- Search flow tests ---
1455
1456    #[test]
1457    fn search_start_enters_search_mode() {
1458        let mut app = App::new(Config::default());
1459        update(&mut app, Message::SearchStart);
1460        assert_eq!(app.input_mode, InputMode::Search);
1461    }
1462
1463    #[test]
1464    fn search_input_appends_char() {
1465        let mut app = App::new(Config::default());
1466        update(&mut app, Message::SearchStart);
1467        update(&mut app, Message::SearchInput('h'));
1468        update(&mut app, Message::SearchInput('i'));
1469        assert_eq!(app.search_buffer, "hi");
1470        assert_eq!(app.search_cursor, 2);
1471    }
1472
1473    #[test]
1474    fn search_backspace_deletes_char() {
1475        let mut app = App::new(Config::default());
1476        update(&mut app, Message::SearchStart);
1477        update(&mut app, Message::SearchInput('a'));
1478        update(&mut app, Message::SearchInput('b'));
1479        update(&mut app, Message::SearchInput('\x08')); // backspace
1480        assert_eq!(app.search_buffer, "a");
1481    }
1482
1483    #[test]
1484    fn search_submit_sets_filter_and_fetches() {
1485        let mut app = App::new(Config::default());
1486        app.list_params.page = 5;
1487        update(&mut app, Message::SearchStart);
1488        update(&mut app, Message::SearchInput('f'));
1489        update(&mut app, Message::SearchInput('i'));
1490        update(&mut app, Message::SearchInput('x'));
1491
1492        let cmd = update(&mut app, Message::SearchSubmit);
1493        assert_eq!(app.input_mode, InputMode::Normal);
1494        assert_eq!(app.list_params.search, Some("fix".to_string()));
1495        assert_eq!(app.list_params.page, 1); // reset
1496        assert!(matches!(cmd, Cmd::FetchPatchsets(_)));
1497    }
1498
1499    #[test]
1500    fn search_cancel_discards_buffer() {
1501        let mut app = App::new(Config::default());
1502        app.list_params.search = Some("old".to_string());
1503        update(&mut app, Message::SearchStart);
1504        update(&mut app, Message::SearchInput('n'));
1505        update(&mut app, Message::SearchInput('e'));
1506        update(&mut app, Message::SearchInput('w'));
1507
1508        let cmd = update(&mut app, Message::SearchCancel);
1509        assert_eq!(app.input_mode, InputMode::Normal);
1510        assert_eq!(app.list_params.search, Some("old".to_string())); // preserved
1511        assert!(app.search_buffer.is_empty());
1512        assert!(matches!(cmd, Cmd::None));
1513    }
1514
1515    #[test]
1516    fn search_empty_submit_clears_filter() {
1517        let mut app = App::new(Config::default());
1518        app.list_params.search = Some("old query".to_string());
1519        update(&mut app, Message::SearchStart);
1520        // Buffer starts with existing query; clear it manually
1521        app.search_buffer.clear();
1522        app.search_cursor = 0;
1523
1524        let cmd = update(&mut app, Message::SearchSubmit);
1525        assert!(app.list_params.search.is_none());
1526        assert!(matches!(cmd, Cmd::FetchPatchsets(_)));
1527    }
1528
1529    #[test]
1530    fn back_clears_filter_in_list_view() {
1531        let mut app = App::new(Config::default());
1532        app.view_mode = ViewMode::List;
1533        app.list_params.search = Some("test query".to_string());
1534        app.list_params.page = 3;
1535        app.selected_index = 5;
1536
1537        let cmd = update(&mut app, Message::Back);
1538        assert!(app.list_params.search.is_none());
1539        assert_eq!(app.list_params.page, 1);
1540        assert_eq!(app.selected_index, 0);
1541        assert!(matches!(cmd, Cmd::FetchPatchsets(_)));
1542    }
1543
1544    #[test]
1545    fn back_does_nothing_without_filter() {
1546        let mut app = App::new(Config::default());
1547        app.view_mode = ViewMode::List;
1548        app.list_params.search = None;
1549
1550        let cmd = update(&mut app, Message::Back);
1551        assert!(app.list_params.search.is_none());
1552        assert!(matches!(cmd, Cmd::None));
1553    }
1554
1555    // --- ViewRawLog tests ---
1556
1557    #[test]
1558    fn view_raw_log_without_detail_is_noop() {
1559        let mut app = App::new(Config::default());
1560        let cmd = update(&mut app, Message::ViewRawLog);
1561        assert!(matches!(cmd, Cmd::None));
1562    }
1563
1564    #[test]
1565    fn view_raw_log_with_detail_returns_open_editor() {
1566        let mut app = App::new(Config::default());
1567        app.selected_detail = Some(PatchsetDetail::fixture());
1568        let cmd = update(&mut app, Message::ViewRawLog);
1569        assert!(matches!(cmd, Cmd::OpenEditor { .. }));
1570    }
1571
1572    #[test]
1573    fn view_raw_log_with_no_reviews_is_noop() {
1574        let mut app = App::new(Config::default());
1575        let mut detail = PatchsetDetail::fixture();
1576        detail.reviews.clear();
1577        app.selected_detail = Some(detail);
1578        let cmd = update(&mut app, Message::ViewRawLog);
1579        assert!(matches!(cmd, Cmd::None));
1580    }
1581
1582    // --- column-sorting tests ---
1583
1584    #[test]
1585    fn cycle_sort_advances_column() {
1586        let mut app = App::new(Config::default());
1587        assert_eq!(app.sort_column, SortColumn::Default);
1588
1589        update(&mut app, Message::CycleSort);
1590        assert_eq!(app.sort_column, SortColumn::Status);
1591
1592        update(&mut app, Message::CycleSort);
1593        assert_eq!(app.sort_column, SortColumn::Date);
1594
1595        update(&mut app, Message::CycleSort);
1596        assert_eq!(app.sort_column, SortColumn::Findings);
1597
1598        update(&mut app, Message::CycleSort);
1599        assert_eq!(app.sort_column, SortColumn::Author);
1600
1601        update(&mut app, Message::CycleSort);
1602        assert_eq!(app.sort_column, SortColumn::Default);
1603    }
1604
1605    #[test]
1606    fn cycle_sort_resets_direction() {
1607        let mut app = App::new(Config::default());
1608        app.sort_direction = SortDirection::Descending;
1609        update(&mut app, Message::CycleSort);
1610        assert_eq!(app.sort_direction, SortDirection::Ascending);
1611    }
1612
1613    #[test]
1614    fn cycle_sort_resets_selected_index() {
1615        let mut app = App::new(Config::default());
1616        app.selected_index = 5;
1617        update(&mut app, Message::CycleSort);
1618        assert_eq!(app.selected_index, 0);
1619    }
1620
1621    #[test]
1622    fn reverse_sort_toggles_direction() {
1623        let mut app = App::new(Config::default());
1624        assert_eq!(app.sort_direction, SortDirection::Ascending);
1625
1626        update(&mut app, Message::ReverseSort);
1627        assert_eq!(app.sort_direction, SortDirection::Descending);
1628
1629        update(&mut app, Message::ReverseSort);
1630        assert_eq!(app.sort_direction, SortDirection::Ascending);
1631    }
1632
1633    #[test]
1634    fn reverse_sort_keeps_column() {
1635        let mut app = App::new(Config::default());
1636        app.sort_column = SortColumn::Date;
1637        update(&mut app, Message::ReverseSort);
1638        assert_eq!(app.sort_column, SortColumn::Date);
1639    }
1640
1641    #[test]
1642    fn reverse_sort_resets_selected_index() {
1643        let mut app = App::new(Config::default());
1644        app.selected_index = 3;
1645        update(&mut app, Message::ReverseSort);
1646        assert_eq!(app.selected_index, 0);
1647    }
1648
1649    #[test]
1650    fn sort_applied_on_patchsets_loaded() {
1651        let mut app = App::new(Config::default());
1652        app.sort_column = SortColumn::Date;
1653        app.sort_direction = SortDirection::Ascending;
1654
1655        let mut ps1 = Patchset::fixture();
1656        ps1.date = Some(200);
1657        let mut ps2 = Patchset::fixture();
1658        ps2.date = Some(100);
1659
1660        let paginated = Paginated {
1661            items: vec![ps1, ps2],
1662            total: 2,
1663            page: 1,
1664            per_page: 50,
1665        };
1666        update(&mut app, Message::PatchsetsLoaded(Ok(paginated)));
1667        // After sort by date ascending, date=100 should be first
1668        assert_eq!(app.patchsets.items[0].date, Some(100));
1669        assert_eq!(app.patchsets.items[1].date, Some(200));
1670    }
1671
1672    #[test]
1673    fn switch_remote_resets_sort() {
1674        let mut config = Config::default();
1675        config
1676            .remotes
1677            .push(crate::config::RemoteConfig::fixture("r1"));
1678        config
1679            .remotes
1680            .push(crate::config::RemoteConfig::fixture("r2"));
1681        let mut app = App::new(config);
1682        app.sort_column = SortColumn::Status;
1683        app.sort_direction = SortDirection::Descending;
1684        update(&mut app, Message::NextMailbox);
1685        assert_eq!(app.sort_column, SortColumn::Default);
1686        assert_eq!(app.sort_direction, SortDirection::Ascending);
1687    }
1688
1689    #[test]
1690    fn status_sort_key_is_exhaustive_and_ordered() {
1691        use crate::models::PatchsetStatus;
1692        let keys: Vec<u8> = vec![
1693            status_sort_key(PatchsetStatus::FailedToApply),
1694            status_sort_key(PatchsetStatus::Failed),
1695            status_sort_key(PatchsetStatus::Incomplete),
1696            status_sort_key(PatchsetStatus::Pending),
1697            status_sort_key(PatchsetStatus::InReview),
1698            status_sort_key(PatchsetStatus::Cancelled),
1699            status_sort_key(PatchsetStatus::Skipped),
1700            status_sort_key(PatchsetStatus::Reviewed),
1701            status_sort_key(PatchsetStatus::Unknown),
1702        ];
1703        // All distinct
1704        let mut sorted = keys.clone();
1705        sorted.sort_unstable();
1706        sorted.dedup();
1707        assert_eq!(sorted.len(), 9);
1708        // Monotonically increasing
1709        for i in 1..keys.len() {
1710            assert!(keys[i] > keys[i - 1]);
1711        }
1712    }
1713
1714    #[test]
1715    fn apply_sort_default_is_noop() {
1716        let mut app = App::new(Config::default());
1717        let mut ps1 = Patchset::fixture();
1718        ps1.id = 1;
1719        ps1.date = Some(200);
1720        let mut ps2 = Patchset::fixture();
1721        ps2.id = 2;
1722        ps2.date = Some(100);
1723        app.patchsets.items = vec![ps1, ps2];
1724        app.sort_column = SortColumn::Default;
1725        apply_sort(&mut app);
1726        // Order preserved
1727        assert_eq!(app.patchsets.items[0].id, 1);
1728        assert_eq!(app.patchsets.items[1].id, 2);
1729    }
1730
1731    #[test]
1732    fn sort_by_status_ascending() {
1733        let mut app = App::new(Config::default());
1734        let mut ps1 = Patchset::fixture();
1735        ps1.status = crate::models::PatchsetStatus::Reviewed; // key 7
1736        let mut ps2 = Patchset::fixture();
1737        ps2.status = crate::models::PatchsetStatus::FailedToApply; // key 0
1738        app.patchsets.items = vec![ps1, ps2];
1739        app.sort_column = SortColumn::Status;
1740        app.sort_direction = SortDirection::Ascending;
1741        apply_sort(&mut app);
1742        assert_eq!(
1743            app.patchsets.items[0].status,
1744            crate::models::PatchsetStatus::FailedToApply
1745        );
1746        assert_eq!(
1747            app.patchsets.items[1].status,
1748            crate::models::PatchsetStatus::Reviewed
1749        );
1750    }
1751
1752    #[test]
1753    fn sort_by_findings_descending() {
1754        let mut app = App::new(Config::default());
1755        let mut ps1 = Patchset::fixture();
1756        ps1.findings = crate::models::FindingCounts {
1757            low: 1,
1758            medium: 0,
1759            high: 0,
1760            critical: 0,
1761        }; // total=1
1762        let mut ps2 = Patchset::fixture();
1763        ps2.findings = crate::models::FindingCounts {
1764            low: 5,
1765            medium: 3,
1766            high: 0,
1767            critical: 0,
1768        }; // total=8
1769        app.patchsets.items = vec![ps1, ps2];
1770        app.sort_column = SortColumn::Findings;
1771        app.sort_direction = SortDirection::Descending;
1772        apply_sort(&mut app);
1773        assert_eq!(app.patchsets.items[0].findings.total(), 8);
1774        assert_eq!(app.patchsets.items[1].findings.total(), 1);
1775    }
1776
1777    #[test]
1778    fn sort_by_author_ascending() {
1779        let mut app = App::new(Config::default());
1780        let mut ps1 = Patchset::fixture();
1781        ps1.author = Some("zebra@example.com".to_string());
1782        let mut ps2 = Patchset::fixture();
1783        ps2.author = Some("alice@example.com".to_string());
1784        app.patchsets.items = vec![ps1, ps2];
1785        app.sort_column = SortColumn::Author;
1786        app.sort_direction = SortDirection::Ascending;
1787        apply_sort(&mut app);
1788        assert_eq!(
1789            app.patchsets.items[0].author.as_deref(),
1790            Some("alice@example.com")
1791        );
1792        assert_eq!(
1793            app.patchsets.items[1].author.as_deref(),
1794            Some("zebra@example.com")
1795        );
1796    }
1797
1798    #[test]
1799    fn sort_by_date_none_sorts_last() {
1800        let mut app = App::new(Config::default());
1801        let mut ps1 = Patchset::fixture();
1802        ps1.id = 1;
1803        ps1.date = None;
1804        let mut ps2 = Patchset::fixture();
1805        ps2.id = 2;
1806        ps2.date = Some(100);
1807        let mut ps3 = Patchset::fixture();
1808        ps3.id = 3;
1809        ps3.date = Some(200);
1810        app.patchsets.items = vec![ps1, ps2, ps3];
1811        app.sort_column = SortColumn::Date;
1812        app.sort_direction = SortDirection::Ascending;
1813        apply_sort(&mut app);
1814        // date=100 first, date=200 second, None last
1815        assert_eq!(app.patchsets.items[0].date, Some(100));
1816        assert_eq!(app.patchsets.items[1].date, Some(200));
1817        assert_eq!(app.patchsets.items[2].date, None);
1818    }
1819
1820    #[test]
1821    fn sort_stability_equal_keys() {
1822        let mut app = App::new(Config::default());
1823        let mut ps1 = Patchset::fixture();
1824        ps1.id = 10;
1825        ps1.date = Some(100);
1826        let mut ps2 = Patchset::fixture();
1827        ps2.id = 20;
1828        ps2.date = Some(100); // same date
1829        let mut ps3 = Patchset::fixture();
1830        ps3.id = 30;
1831        ps3.date = Some(100); // same date
1832        app.patchsets.items = vec![ps1, ps2, ps3];
1833        app.sort_column = SortColumn::Date;
1834        app.sort_direction = SortDirection::Ascending;
1835        apply_sort(&mut app);
1836        // Stable sort preserves original API order for equal keys
1837        assert_eq!(app.patchsets.items[0].id, 10);
1838        assert_eq!(app.patchsets.items[1].id, 20);
1839        assert_eq!(app.patchsets.items[2].id, 30);
1840    }
1841
1842    // --- bookmark-filtering tests ---
1843
1844    #[test]
1845    fn bookmark_filter_toggle_on() {
1846        let mut app = App::new(Config::default());
1847        assert!(!app.show_bookmarks_only);
1848        update(&mut app, Message::ToggleBookmarkFilter);
1849        assert!(app.show_bookmarks_only);
1850    }
1851
1852    #[test]
1853    fn bookmark_filter_toggle_off() {
1854        let mut app = App::new(Config::default());
1855        app.show_bookmarks_only = true;
1856        update(&mut app, Message::ToggleBookmarkFilter);
1857        assert!(!app.show_bookmarks_only);
1858    }
1859
1860    #[test]
1861    fn bookmark_filter_resets_selected_index() {
1862        let mut app = App::new(Config::default());
1863        app.selected_index = 4;
1864        update(&mut app, Message::ToggleBookmarkFilter);
1865        assert_eq!(app.selected_index, 0);
1866    }
1867
1868    #[test]
1869    fn bookmark_filter_returns_cmd_none() {
1870        let mut app = App::new(Config::default());
1871        let cmd = update(&mut app, Message::ToggleBookmarkFilter);
1872        assert!(matches!(cmd, Cmd::None));
1873    }
1874
1875    #[test]
1876    fn switch_remote_resets_bookmark_filter() {
1877        let mut config = Config::default();
1878        config
1879            .remotes
1880            .push(crate::config::RemoteConfig::fixture("r1"));
1881        config
1882            .remotes
1883            .push(crate::config::RemoteConfig::fixture("r2"));
1884        let mut app = App::new(config);
1885        app.show_bookmarks_only = true;
1886        update(&mut app, Message::NextMailbox);
1887        assert!(!app.show_bookmarks_only);
1888    }
1889
1890    // ── handle_page_nav tests ──
1891
1892    #[test]
1893    fn next_page_advances_when_more_pages() {
1894        let mut app = App::new(Config::default());
1895        app.patchsets.total = 100;
1896        app.patchsets.per_page = 50;
1897        app.list_params.page = 1;
1898        app.selected_index = 5;
1899        let cmd = update(&mut app, Message::NextPage);
1900        assert_eq!(app.list_params.page, 2);
1901        assert_eq!(app.selected_index, 0);
1902        assert!(matches!(cmd, Cmd::FetchPatchsets(_)));
1903    }
1904
1905    #[test]
1906    fn next_page_noop_on_last_page() {
1907        let mut app = App::new(Config::default());
1908        app.patchsets.total = 50;
1909        app.patchsets.per_page = 50;
1910        app.list_params.page = 1;
1911        let cmd = update(&mut app, Message::NextPage);
1912        assert_eq!(app.list_params.page, 1);
1913        assert!(matches!(cmd, Cmd::None));
1914    }
1915
1916    #[test]
1917    fn prev_page_goes_back() {
1918        let mut app = App::new(Config::default());
1919        app.list_params.page = 3;
1920        app.selected_index = 5;
1921        let cmd = update(&mut app, Message::PrevPage);
1922        assert_eq!(app.list_params.page, 2);
1923        assert_eq!(app.selected_index, 0);
1924        assert!(matches!(cmd, Cmd::FetchPatchsets(_)));
1925    }
1926
1927    #[test]
1928    fn prev_page_noop_on_first_page() {
1929        let mut app = App::new(Config::default());
1930        app.list_params.page = 1;
1931        let cmd = update(&mut app, Message::PrevPage);
1932        assert_eq!(app.list_params.page, 1);
1933        assert!(matches!(cmd, Cmd::None));
1934    }
1935
1936    // ── handle_comment_nav tests ──
1937
1938    #[test]
1939    fn comment_nav_noop_without_detail() {
1940        let mut app = App::new(Config::default());
1941        let cmd = update(&mut app, Message::NextComment);
1942        assert!(matches!(cmd, Cmd::None));
1943    }
1944
1945    #[test]
1946    fn comment_nav_forward_from_none() {
1947        let mut app = App::new(Config::default());
1948        app.view_mode = crate::app::ViewMode::Detail;
1949        app.comment_positions = vec![0, 10, 20];
1950        app.detail_scroll_offset = 5;
1951        app.current_comment_index = None;
1952        update(&mut app, Message::NextComment);
1953        // First position >= 5 is index 1 (value 10)
1954        assert_eq!(app.current_comment_index, Some(1));
1955        assert_eq!(app.detail_scroll_offset, 10);
1956    }
1957
1958    #[test]
1959    fn comment_nav_backward_from_none() {
1960        let mut app = App::new(Config::default());
1961        app.view_mode = crate::app::ViewMode::Detail;
1962        app.comment_positions = vec![0, 10, 20];
1963        app.detail_scroll_offset = 15;
1964        app.current_comment_index = None;
1965        update(&mut app, Message::PrevComment);
1966        // Last position < 15 is index 1 (value 10)
1967        assert_eq!(app.current_comment_index, Some(1));
1968        assert_eq!(app.detail_scroll_offset, 10);
1969    }
1970
1971    #[test]
1972    fn comment_nav_forward_advances() {
1973        let mut app = App::new(Config::default());
1974        app.view_mode = crate::app::ViewMode::Detail;
1975        app.comment_positions = vec![0, 10, 20];
1976        app.current_comment_index = Some(0);
1977        update(&mut app, Message::NextComment);
1978        assert_eq!(app.current_comment_index, Some(1));
1979        assert_eq!(app.detail_scroll_offset, 10);
1980    }
1981
1982    #[test]
1983    fn comment_nav_forward_clamps_at_last() {
1984        let mut app = App::new(Config::default());
1985        app.view_mode = crate::app::ViewMode::Detail;
1986        app.comment_positions = vec![0, 10, 20];
1987        app.current_comment_index = Some(2);
1988        update(&mut app, Message::NextComment);
1989        assert_eq!(app.current_comment_index, Some(2));
1990        assert_eq!(app.detail_scroll_offset, 20);
1991    }
1992
1993    #[test]
1994    fn comment_nav_backward_clamps_at_first() {
1995        let mut app = App::new(Config::default());
1996        app.view_mode = crate::app::ViewMode::Detail;
1997        app.comment_positions = vec![0, 10, 20];
1998        app.current_comment_index = Some(0);
1999        update(&mut app, Message::PrevComment);
2000        assert_eq!(app.current_comment_index, Some(0));
2001        assert_eq!(app.detail_scroll_offset, 0);
2002    }
2003
2004    // ── handle_sidebar_scroll tests ──
2005
2006    #[test]
2007    fn sidebar_scroll_down_through_remotes() {
2008        let mut config = Config::default();
2009        config
2010            .remotes
2011            .push(crate::config::RemoteConfig::fixture("r1"));
2012        config
2013            .remotes
2014            .push(crate::config::RemoteConfig::fixture("r2"));
2015        let mut app = App::new(config);
2016        app.focus = crate::app::FocusPanel::Sidebar;
2017        app.active_remote_index = 0;
2018        update(&mut app, Message::ScrollDown);
2019        assert_eq!(app.active_remote_index, 1);
2020    }
2021
2022    #[test]
2023    fn sidebar_scroll_down_transitions_to_mailing_lists() {
2024        let mut config = Config::default();
2025        config
2026            .remotes
2027            .push(crate::config::RemoteConfig::fixture("r1"));
2028        let mut app = App::new(config);
2029        app.focus = crate::app::FocusPanel::Sidebar;
2030        app.active_remote_index = 0;
2031        app.mailing_lists = vec![crate::models::MailingList::fixture()];
2032        update(&mut app, Message::ScrollDown);
2033        assert_eq!(
2034            app.sidebar_section,
2035            crate::app::SidebarSection::MailingLists
2036        );
2037        assert_eq!(app.sidebar_list_index, 0);
2038    }
2039
2040    #[test]
2041    fn sidebar_scroll_up_in_remotes_clamps() {
2042        let mut config = Config::default();
2043        config
2044            .remotes
2045            .push(crate::config::RemoteConfig::fixture("r1"));
2046        let mut app = App::new(config);
2047        app.focus = crate::app::FocusPanel::Sidebar;
2048        app.active_remote_index = 0;
2049        update(&mut app, Message::ScrollUp);
2050        assert_eq!(app.active_remote_index, 0);
2051    }
2052
2053    #[test]
2054    fn sidebar_scroll_down_in_mailing_lists() {
2055        let mut config = Config::default();
2056        config
2057            .remotes
2058            .push(crate::config::RemoteConfig::fixture("r1"));
2059        let mut app = App::new(config);
2060        app.focus = crate::app::FocusPanel::Sidebar;
2061        app.sidebar_section = crate::app::SidebarSection::MailingLists;
2062        app.mailing_lists = vec![crate::models::MailingList::fixture()];
2063        app.sidebar_list_index = 0;
2064        update(&mut app, Message::ScrollDown);
2065        assert_eq!(app.sidebar_list_index, 1);
2066    }
2067
2068    #[test]
2069    fn sidebar_scroll_up_transitions_back_to_remotes() {
2070        let mut config = Config::default();
2071        config
2072            .remotes
2073            .push(crate::config::RemoteConfig::fixture("r1"));
2074        let mut app = App::new(config);
2075        app.focus = crate::app::FocusPanel::Sidebar;
2076        app.sidebar_section = crate::app::SidebarSection::MailingLists;
2077        app.sidebar_list_index = 0;
2078        update(&mut app, Message::ScrollUp);
2079        assert_eq!(app.sidebar_section, crate::app::SidebarSection::Remotes);
2080    }
2081
2082    // ── Additional update() branch coverage ──
2083
2084    #[test]
2085    fn back_clears_detail_view() {
2086        let mut app = App::new(Config::default());
2087        app.view_mode = crate::app::ViewMode::Detail;
2088        app.selected_detail = Some(crate::models::PatchsetDetail::fixture());
2089        app.detail_scroll_offset = 10;
2090        app.comment_positions = vec![1, 2, 3];
2091        app.current_comment_index = Some(1);
2092        let cmd = update(&mut app, Message::Back);
2093        assert_eq!(app.view_mode, crate::app::ViewMode::List);
2094        assert!(app.selected_detail.is_none());
2095        assert_eq!(app.detail_scroll_offset, 0);
2096        assert!(app.comment_positions.is_empty());
2097        assert!(app.current_comment_index.is_none());
2098        assert!(matches!(cmd, Cmd::None));
2099    }
2100
2101    #[test]
2102    fn back_closes_help_overlay() {
2103        let mut app = App::new(Config::default());
2104        app.show_help = true;
2105        let cmd = update(&mut app, Message::Back);
2106        assert!(!app.show_help);
2107        assert!(matches!(cmd, Cmd::None));
2108    }
2109
2110    #[test]
2111    fn toggle_help_on_and_off() {
2112        let mut app = App::new(Config::default());
2113        update(&mut app, Message::ToggleHelp);
2114        assert!(app.show_help);
2115        update(&mut app, Message::ToggleHelp);
2116        assert!(!app.show_help);
2117    }
2118
2119    #[test]
2120    fn bookmarks_persisted_error_is_noop() {
2121        let mut app = App::new(Config::default());
2122        let cmd = update(
2123            &mut app,
2124            Message::BookmarksPersisted(Err("test error".to_string())),
2125        );
2126        assert!(matches!(cmd, Cmd::None));
2127    }
2128
2129    #[test]
2130    fn bookmarks_persisted_ok_is_noop() {
2131        let mut app = App::new(Config::default());
2132        let cmd = update(&mut app, Message::BookmarksPersisted(Ok(())));
2133        assert!(matches!(cmd, Cmd::None));
2134    }
2135
2136    // ── update() branch coverage for CRAP reduction ──
2137
2138    #[test]
2139    fn refresh_in_messages_mode_fetches_messages() {
2140        let mut app = App::new(Config::default());
2141        app.list_content = ListContent::Messages;
2142        let cmd = update(&mut app, Message::Refresh);
2143        assert!(matches!(cmd, Cmd::ClearCacheAndBatch(_)));
2144    }
2145
2146    #[test]
2147    fn view_baseline_log_dispatches() {
2148        let mut app = App::new(Config::default());
2149        let cmd = update(&mut app, Message::ViewBaselineLog);
2150        assert!(matches!(cmd, Cmd::None));
2151    }
2152
2153    #[test]
2154    fn toggle_list_content_switches_mode() {
2155        let mut app = App::new(Config::default());
2156        assert_eq!(app.list_content, ListContent::Patchsets);
2157        let cmd = update(&mut app, Message::ToggleListContent);
2158        assert_eq!(app.list_content, ListContent::Messages);
2159        assert!(matches!(cmd, Cmd::FetchMessages(_)));
2160    }
2161
2162    #[test]
2163    fn messages_loaded_stores_messages() {
2164        let mut app = App::new(Config::default());
2165        let paginated = Paginated {
2166            items: vec![],
2167            total: 0,
2168            page: 1,
2169            per_page: 50,
2170        };
2171        let cmd = update(&mut app, Message::MessagesLoaded(Ok(paginated)));
2172        assert!(matches!(cmd, Cmd::None));
2173    }
2174
2175    #[test]
2176    fn messages_loaded_error_sets_error_state() {
2177        let mut app = App::new(Config::default());
2178        let err = crate::client::ApiError::HttpStatus {
2179            status: 500,
2180            body: None,
2181            remote: "test".to_string(),
2182        };
2183        let cmd = update(&mut app, Message::MessagesLoaded(Err(err)));
2184        assert!(matches!(cmd, Cmd::None));
2185        assert!(app.error_state.is_some());
2186    }
2187
2188    #[test]
2189    fn message_detail_loaded_stores_detail() {
2190        let mut app = App::new(Config::default());
2191        app.view_mode = crate::app::ViewMode::Loading;
2192        let msg = crate::models::EmailMessage::fixture();
2193        let cmd = update(&mut app, Message::MessageDetailLoaded(Box::new(Ok(msg))));
2194        assert!(matches!(cmd, Cmd::None));
2195        assert!(app.selected_message.is_some());
2196    }
2197
2198    #[test]
2199    fn bookmark_toggle_in_list_view() {
2200        let mut app = app_with_patchsets(3);
2201        app.selected_index = 0;
2202        let cmd = update(&mut app, Message::BookmarkToggle);
2203        // Should return a persist command or none depending on bookmarks_path
2204        assert!(
2205            matches!(cmd, Cmd::None | Cmd::PersistBookmarks { .. }),
2206            "unexpected cmd: {cmd:?}"
2207        );
2208    }
2209}