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}