Skip to main content

git_same/tui/
app.rs

1//! TUI application state (the "Model" in Elm architecture).
2
3use crate::config::{Config, WorkspaceConfig};
4use crate::setup::state::{self, SetupState};
5use crate::types::{OpSummary, OwnedRepo};
6use ratatui::widgets::TableState;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::PathBuf;
10use std::time::Instant;
11
12/// Which screen is active.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum Screen {
15    WorkspaceSetup,
16    Workspaces,
17    Dashboard,
18    Sync,
19    Settings,
20}
21
22/// Focused pane on the Workspace screen.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum WorkspacePane {
25    Left,
26    Right,
27}
28
29/// Which operation is running or was last selected.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum Operation {
32    Sync,
33    Status,
34}
35
36impl std::fmt::Display for Operation {
37    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38        match self {
39            Operation::Sync => write!(f, "Sync"),
40            Operation::Status => write!(f, "Status"),
41        }
42    }
43}
44
45/// State of an ongoing async operation.
46#[derive(Debug, Clone)]
47pub enum OperationState {
48    Idle,
49    Discovering {
50        operation: Operation,
51        message: String,
52    },
53    Running {
54        operation: Operation,
55        total: usize,
56        completed: usize,
57        failed: usize,
58        skipped: usize,
59        current_repo: String,
60        /// Repos that had new commits (updated or cloned).
61        with_updates: usize,
62        /// New repos cloned so far.
63        cloned: usize,
64        /// Existing repos synced so far.
65        synced: usize,
66        /// Planned clone count (for phase indicator).
67        to_clone: usize,
68        /// Planned sync count (for phase indicator).
69        to_sync: usize,
70        /// Aggregate new commits fetched.
71        total_new_commits: u32,
72        /// When the operation started (for elapsed/ETA).
73        started_at: Instant,
74        /// Repos currently being processed (for worker slots).
75        active_repos: Vec<String>,
76        /// Throughput samples (repos completed per second window).
77        throughput_samples: Vec<u64>,
78        /// Completed count at last throughput sample.
79        last_sample_completed: usize,
80    },
81    Finished {
82        operation: Operation,
83        summary: OpSummary,
84        /// Repos that had new commits.
85        with_updates: usize,
86        /// New repos cloned.
87        cloned: usize,
88        /// Existing repos synced.
89        synced: usize,
90        /// Aggregate new commits fetched.
91        total_new_commits: u32,
92        /// Wall-clock duration in seconds.
93        duration_secs: f64,
94    },
95}
96
97/// A structured log entry from a sync operation.
98#[derive(Debug, Clone)]
99pub struct SyncLogEntry {
100    pub repo_name: String,
101    pub status: SyncLogStatus,
102    pub message: String,
103    pub had_updates: bool,
104    pub is_clone: bool,
105    pub new_commits: Option<u32>,
106    pub path: Option<PathBuf>,
107}
108
109/// Status classification for a sync log entry.
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub enum SyncLogStatus {
112    Success,
113    Updated,
114    Cloned,
115    Failed,
116    Skipped,
117}
118
119/// Filter for post-sync log view.
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub enum LogFilter {
122    All,
123    Updated,
124    Failed,
125    Skipped,
126    Changelog,
127}
128
129/// A summary entry for sync history.
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct SyncHistoryEntry {
132    pub timestamp: String,
133    pub duration_secs: f64,
134    pub success: usize,
135    pub failed: usize,
136    pub skipped: usize,
137    pub with_updates: usize,
138    pub cloned: usize,
139    pub total_new_commits: u32,
140}
141
142/// A local repo with its computed status.
143#[derive(Debug, Clone)]
144pub struct RepoEntry {
145    pub owner: String,
146    pub name: String,
147    pub full_name: String,
148    pub path: PathBuf,
149    pub branch: Option<String>,
150    pub is_uncommitted: bool,
151    pub ahead: usize,
152    pub behind: usize,
153    pub staged_count: usize,
154    pub unstaged_count: usize,
155    pub untracked_count: usize,
156}
157
158/// A requirement check result for the init check screen.
159#[derive(Debug, Clone)]
160pub struct CheckEntry {
161    pub name: String,
162    pub passed: bool,
163    pub message: String,
164    pub suggestion: Option<String>,
165    pub critical: bool,
166}
167
168/// The application model (all TUI state).
169pub struct App {
170    /// Whether the user has requested quit.
171    pub should_quit: bool,
172
173    /// Active screen.
174    pub screen: Screen,
175
176    /// Screen history for back navigation.
177    pub screen_stack: Vec<Screen>,
178
179    /// Loaded configuration.
180    pub config: Config,
181
182    /// Available workspaces.
183    pub workspaces: Vec<WorkspaceConfig>,
184
185    /// Active workspace (selected or auto-selected).
186    pub active_workspace: Option<WorkspaceConfig>,
187
188    /// Selected index in workspace selector.
189    pub workspace_index: usize,
190
191    /// Focused pane in the Workspaces screen.
192    pub workspace_pane: WorkspacePane,
193
194    /// Base path for repos (derived from active workspace).
195    pub base_path: Option<PathBuf>,
196
197    /// Discovered repos grouped by org.
198    pub repos_by_org: HashMap<String, Vec<OwnedRepo>>,
199
200    /// All discovered repos (flat list).
201    pub all_repos: Vec<OwnedRepo>,
202
203    /// Org names (sorted).
204    pub orgs: Vec<String>,
205
206    /// Local repo entries with status.
207    pub local_repos: Vec<RepoEntry>,
208
209    /// Current async operation state.
210    pub operation_state: OperationState,
211
212    /// Operation log lines (last N events).
213    pub log_lines: Vec<String>,
214
215    // -- Selection state --
216    /// Selected repo index in current view.
217    pub repo_index: usize,
218
219    /// Scroll offset for tables.
220    pub scroll_offset: usize,
221
222    /// Filter/search text.
223    pub filter_text: String,
224
225    /// Whether filter input is active.
226    pub filter_active: bool,
227
228    /// Whether dry-run is toggled in command picker.
229    pub dry_run: bool,
230
231    /// Error message to display (clears on next keypress).
232    pub error_message: Option<String>,
233
234    /// Requirement check results (populated on InitCheck screen).
235    pub check_results: Vec<CheckEntry>,
236
237    /// Whether checks are still running.
238    pub checks_loading: bool,
239
240    /// Whether to use pull mode for sync (vs fetch).
241    pub sync_pull: bool,
242
243    /// Setup wizard state (active when on SetupWizard screen).
244    pub setup_state: Option<SetupState>,
245
246    /// Whether status scan is in progress.
247    pub status_loading: bool,
248
249    /// When the last status scan completed (for auto-refresh cooldown).
250    pub last_status_scan: Option<std::time::Instant>,
251
252    /// Selected stat box index on dashboard (0-5) for ←/→ navigation.
253    pub stat_index: usize,
254
255    /// Table state for dashboard tab content (tracks selection + scroll offset).
256    pub dashboard_table_state: TableState,
257
258    /// Selected category index in settings screen (0 = Requirements, 1 = Options, 2+ = Workspaces).
259    pub settings_index: usize,
260
261    /// Whether the config TOML section is expanded in workspace detail.
262    pub settings_config_expanded: bool,
263
264    /// Scroll offset for the workspace detail right pane.
265    pub workspace_detail_scroll: u16,
266
267    /// Tick counter for driving animations on the Progress screen.
268    pub tick_count: u64,
269
270    /// Structured sync log entries (enriched data).
271    pub sync_log_entries: Vec<SyncLogEntry>,
272
273    /// Active log filter for post-sync view.
274    pub log_filter: LogFilter,
275
276    /// Sync history (last N summaries for comparison).
277    pub sync_history: Vec<SyncHistoryEntry>,
278
279    /// Whether sync history overlay is visible.
280    pub show_sync_history: bool,
281
282    /// Expanded repo in post-sync view (for commit deep dive).
283    pub expanded_repo: Option<String>,
284
285    /// Commit log for expanded repo.
286    pub repo_commits: Vec<String>,
287
288    /// Selected index in the post-sync filterable log.
289    pub sync_log_index: usize,
290
291    /// Aggregated commits per repo for changelog view.
292    pub changelog_commits: HashMap<String, Vec<String>>,
293
294    /// Total number of repos to fetch commits for in changelog.
295    pub changelog_total: usize,
296
297    /// Number of repos whose commits have been loaded for changelog.
298    pub changelog_loaded: usize,
299
300    /// Scroll offset for the changelog view.
301    pub changelog_scroll: usize,
302}
303
304impl App {
305    /// Create a new App with the given config and workspaces.
306    pub fn new(config: Config, workspaces: Vec<WorkspaceConfig>, config_was_created: bool) -> Self {
307        let (screen, active_workspace, base_path) = match workspaces.len() {
308            0 => (Screen::WorkspaceSetup, None, None),
309            1 => {
310                let ws = workspaces[0].clone();
311                let bp = Some(ws.expanded_base_path());
312                (Screen::Dashboard, Some(ws), bp)
313            }
314            _ => {
315                // Check for default workspace
316                if let Some(ref default_path) = config.default_workspace {
317                    let expanded = shellexpand::tilde(default_path.as_str());
318                    let default_root = std::path::PathBuf::from(expanded.as_ref());
319                    if let Some(ws) = workspaces.iter().find(|w| w.root_path == default_root) {
320                        let bp = Some(ws.expanded_base_path());
321                        (Screen::Dashboard, Some(ws.clone()), bp)
322                    } else {
323                        (Screen::Workspaces, None, None)
324                    }
325                } else {
326                    (Screen::Workspaces, None, None)
327                }
328            }
329        };
330
331        let sync_history = active_workspace
332            .as_ref()
333            .and_then(|ws| {
334                crate::cache::SyncHistoryManager::for_workspace(&ws.root_path)
335                    .and_then(|m| m.load())
336                    .ok()
337            })
338            .unwrap_or_default();
339
340        Self {
341            should_quit: false,
342            screen,
343            screen_stack: Vec::new(),
344            config,
345            workspaces,
346            active_workspace,
347            workspace_index: 0,
348            workspace_pane: WorkspacePane::Left,
349            base_path,
350            repos_by_org: HashMap::new(),
351            all_repos: Vec::new(),
352            orgs: Vec::new(),
353            local_repos: Vec::new(),
354            operation_state: OperationState::Idle,
355            log_lines: Vec::new(),
356            repo_index: 0,
357            scroll_offset: 0,
358            filter_text: String::new(),
359            filter_active: false,
360            dry_run: false,
361            error_message: None,
362            check_results: Vec::new(),
363            checks_loading: false,
364            sync_pull: false,
365            setup_state: if screen == Screen::WorkspaceSetup {
366                let default_path = std::env::current_dir()
367                    .map(|p| state::tilde_collapse(&p.to_string_lossy()))
368                    .unwrap_or_else(|_| "~/Git-Same/GitHub".to_string());
369                let mut setup = SetupState::with_first_setup(&default_path, config_was_created);
370                setup.config_was_created = config_was_created;
371                Some(setup)
372            } else {
373                None
374            },
375            status_loading: false,
376            last_status_scan: None,
377            stat_index: 0,
378            dashboard_table_state: TableState::default().with_selected(0),
379            settings_index: 0,
380            settings_config_expanded: false,
381            workspace_detail_scroll: 0,
382            tick_count: 0,
383            sync_log_entries: Vec::new(),
384            log_filter: LogFilter::All,
385            sync_history,
386            show_sync_history: false,
387            expanded_repo: None,
388            repo_commits: Vec::new(),
389            sync_log_index: 0,
390            changelog_commits: HashMap::new(),
391            changelog_total: 0,
392            changelog_loaded: 0,
393            changelog_scroll: 0,
394        }
395    }
396
397    /// Select a workspace and navigate to dashboard.
398    pub fn select_workspace(&mut self, index: usize) {
399        if let Some(ws) = self.workspaces.get(index).cloned() {
400            self.base_path = Some(ws.expanded_base_path());
401            // Load sync history for this workspace
402            self.sync_history = crate::cache::SyncHistoryManager::for_workspace(&ws.root_path)
403                .and_then(|m| m.load())
404                .unwrap_or_default();
405            self.active_workspace = Some(ws);
406            // Reset discovered data when switching workspace
407            self.repos_by_org.clear();
408            self.all_repos.clear();
409            self.orgs.clear();
410            self.local_repos.clear();
411            self.last_status_scan = None;
412        }
413    }
414
415    /// Navigate to a new screen, pushing current onto the stack.
416    pub fn navigate_to(&mut self, screen: Screen) {
417        self.screen_stack.push(self.screen);
418        self.screen = screen;
419        self.repo_index = 0;
420        self.scroll_offset = 0;
421    }
422
423    /// Go back to previous screen.
424    pub fn go_back(&mut self) {
425        if let Some(prev) = self.screen_stack.pop() {
426            self.screen = prev;
427        }
428    }
429}
430
431#[cfg(test)]
432#[path = "app_tests.rs"]
433mod tests;