Skip to main content

remendo/
app.rs

1//! Application state and lifecycle.
2//!
3//! Defines the top-level `App` struct (all application state) and
4//! the `RunningState` enum controlling the main event loop.
5
6use crate::bookmarks::BookmarkStore;
7use crate::client::types::ListParams;
8use crate::config::Config;
9use crate::models::{EmailMessage, MailingList, Paginated, Patchset, PatchsetDetail, ServerStats};
10use std::path::PathBuf;
11
12/// Top-level application state.
13///
14/// All mutable state the TUI needs lives here. The `update()` function
15/// in [`crate::update`] is the sole mutator — the TEA pattern keeps
16/// state transitions pure and testable.
17pub struct App {
18    /// Whether the app is running or exiting.
19    pub running_state: RunningState,
20    /// Loaded configuration.
21    pub config: Config,
22    /// Currently displayed patchset list.
23    pub patchsets: Paginated<Patchset>,
24    /// Name of the active remote/mailbox.
25    pub active_remote: String,
26    /// Current error message to display, if any.
27    pub error_state: Option<String>,
28    /// Available mailing lists from the active remote.
29    pub mailing_lists: Vec<MailingList>,
30    /// Index of the currently selected patchset in the list.
31    pub selected_index: usize,
32    /// Cached terminal height for half-page scroll calculation.
33    pub terminal_height: u16,
34    /// Index of the currently active remote in `config.remotes`.
35    pub active_remote_index: usize,
36    /// Which panel currently has keyboard focus.
37    pub focus: FocusPanel,
38    /// Current view mode (list vs. detail).
39    pub view_mode: ViewMode,
40    /// Loaded patchset detail for the detail view.
41    pub selected_detail: Option<PatchsetDetail>,
42    /// Context for the loading screen (patchset being fetched).
43    pub loading_context: Option<LoadingContext>,
44    /// Cached server stats from the last successful `/api/stats` response.
45    pub stats: Option<ServerStats>,
46    /// Whether the help overlay is currently visible.
47    pub show_help: bool,
48    /// Whether the list view is filtered to bookmarked patchsets only.
49    pub show_bookmarks_only: bool,
50    /// Vertical scroll offset for the detail view content.
51    pub detail_scroll_offset: usize,
52    /// Current query parameters for the patchset list.
53    /// Shared by pagination, search, and mailing list filter.
54    pub list_params: ListParams,
55    /// Current keyboard input mode.
56    pub input_mode: InputMode,
57    /// Contents of the search input buffer (while typing).
58    pub search_buffer: String,
59    /// Cursor position within `search_buffer` (char index).
60    pub search_cursor: usize,
61    /// Which section of the sidebar has focus.
62    pub sidebar_section: SidebarSection,
63    /// Scroll index within the mailing list section (0 = "All").
64    pub sidebar_list_index: usize,
65    /// Set of bookmarked patchsets.
66    pub bookmarks: BookmarkStore,
67    /// Path to the bookmarks persistence file.
68    pub bookmarks_path: PathBuf,
69    /// Cached line positions of comment boundaries in the detail view.
70    pub comment_positions: Vec<usize>,
71    /// Index into `comment_positions` for the current comment.
72    pub current_comment_index: Option<usize>,
73    /// What content the list view is displaying (patchsets or messages).
74    pub list_content: ListContent,
75    /// Currently displayed message list (when `list_content == Messages`).
76    pub messages: Paginated<EmailMessage>,
77    /// Loaded message detail for the message detail view.
78    pub selected_message: Option<EmailMessage>,
79    /// Which column the patchset list is sorted by.
80    pub sort_column: SortColumn,
81    /// Current sort direction.
82    pub sort_direction: SortDirection,
83}
84
85impl App {
86    /// Create a new application with the given configuration.
87    #[must_use]
88    pub fn new(config: Config) -> Self {
89        let active_remote = config
90            .remotes
91            .first()
92            .map(|r| r.name.clone())
93            .unwrap_or_default();
94
95        Self {
96            running_state: RunningState::Running,
97            config,
98            patchsets: Paginated {
99                items: vec![],
100                total: 0,
101                page: 1,
102                per_page: 50,
103            },
104            active_remote,
105            error_state: None,
106            mailing_lists: vec![],
107            selected_index: 0,
108            terminal_height: 0,
109            active_remote_index: 0,
110            focus: FocusPanel::default(),
111            view_mode: ViewMode::default(),
112            selected_detail: None,
113            loading_context: None,
114            stats: None,
115            show_help: false,
116            show_bookmarks_only: false,
117            detail_scroll_offset: 0,
118            list_params: ListParams::default(),
119            input_mode: InputMode::default(),
120            search_buffer: String::new(),
121            search_cursor: 0,
122            sidebar_section: SidebarSection::default(),
123            sidebar_list_index: 0,
124            bookmarks: BookmarkStore::new(),
125            bookmarks_path: PathBuf::new(),
126            comment_positions: vec![],
127            current_comment_index: None,
128            list_content: ListContent::default(),
129            messages: Paginated {
130                items: vec![],
131                total: 0,
132                page: 1,
133                per_page: 50,
134            },
135            selected_message: None,
136            sort_column: SortColumn::Default,
137            sort_direction: SortDirection::Ascending,
138        }
139    }
140
141    /// Whether the application should continue running.
142    #[must_use]
143    pub fn is_running(&self) -> bool {
144        self.running_state == RunningState::Running
145    }
146}
147
148/// Which panel currently has keyboard focus.
149#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
150pub enum FocusPanel {
151    /// The patchset list table (main pane).
152    #[default]
153    PatchsetList,
154    /// The remote/mailbox sidebar.
155    Sidebar,
156}
157
158/// Which view the main pane is displaying.
159#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
160pub enum ViewMode {
161    /// Showing the patchset list table.
162    #[default]
163    List,
164    /// Loading a patchset detail (shows confirmation dialog).
165    Loading,
166    /// Showing the detail view for a selected patchset.
167    Detail,
168}
169
170/// Context for the loading screen — summary of the patchset being fetched.
171#[derive(Debug, Clone, Default)]
172pub struct LoadingContext {
173    /// Database ID of the patchset being loaded.
174    pub patchset_id: i64,
175    /// Subject line for visual confirmation.
176    pub subject: String,
177    /// Status text.
178    pub status: String,
179}
180
181/// Which section of the sidebar has focus when the sidebar is active.
182#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
183pub enum SidebarSection {
184    /// The remotes list.
185    #[default]
186    Remotes,
187    /// The mailing lists.
188    MailingLists,
189}
190
191/// Which column the patchset list is sorted by.
192#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
193pub enum SortColumn {
194    /// API-returned order, no comparator applied.
195    #[default]
196    Default,
197    /// Sort by patchset status (lifecycle priority).
198    Status,
199    /// Sort by date.
200    Date,
201    /// Sort by total finding count.
202    Findings,
203    /// Sort by author name (lexicographic).
204    Author,
205}
206
207/// Sort direction for the patchset list.
208#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
209pub enum SortDirection {
210    /// Ascending order (A→Z, oldest→newest, lowest→highest).
211    #[default]
212    Ascending,
213    /// Descending order (Z→A, newest→oldest, highest→lowest).
214    Descending,
215}
216
217/// What content the list view is displaying.
218#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
219pub enum ListContent {
220    /// Showing patchsets (default).
221    #[default]
222    Patchsets,
223    /// Showing mailing list messages.
224    Messages,
225}
226
227/// What mode the keyboard is operating in.
228#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
229pub enum InputMode {
230    /// Normal keybinding mode.
231    #[default]
232    Normal,
233    /// Text input mode for the search bar.
234    Search,
235}
236
237/// Controls the main event loop lifecycle.
238#[derive(Debug, Clone, Copy, PartialEq, Eq)]
239pub enum RunningState {
240    /// The application is actively running.
241    Running,
242    /// The application should exit after the current frame.
243    Done,
244}
245
246#[cfg(test)]
247#[allow(clippy::expect_used)]
248mod tests {
249    use super::*;
250
251    #[test]
252    fn default_app_is_running() {
253        let app = App::new(Config::default());
254        assert_eq!(app.running_state, RunningState::Running);
255        assert!(app.is_running());
256    }
257
258    #[test]
259    fn app_with_remotes_sets_active() {
260        let mut config = Config::default();
261        config
262            .remotes
263            .push(crate::config::RemoteConfig::fixture("upstream"));
264        let app = App::new(config);
265        assert_eq!(app.active_remote, "upstream");
266    }
267
268    #[test]
269    fn app_without_remotes_has_empty_active() {
270        let app = App::new(Config::default());
271        assert!(app.active_remote.is_empty());
272    }
273
274    #[test]
275    fn running_state_variants() {
276        assert_ne!(RunningState::Running, RunningState::Done);
277    }
278}