Skip to main content

git_same/tui/
event.rs

1//! Event system: merges terminal input and backend notifications.
2
3use crossterm::event::{self, Event as CtEvent, KeyEvent};
4use std::time::Duration;
5use tokio::sync::mpsc;
6use tracing::warn;
7
8use crate::setup::state::OrgEntry;
9use crate::types::{OpSummary, OwnedRepo};
10
11use super::app::{CheckEntry, Operation, RepoEntry};
12
13/// Events that the TUI loop processes.
14#[derive(Debug)]
15pub enum AppEvent {
16    /// A keyboard event from the terminal.
17    Terminal(KeyEvent),
18    /// Terminal resize.
19    Resize(u16, u16),
20    /// Backend sent a progress update.
21    Backend(BackendMessage),
22    /// Periodic tick for animations/spinners.
23    Tick,
24}
25
26/// Messages from backend async operations.
27#[derive(Debug, Clone)]
28pub enum BackendMessage {
29    /// Discovery: orgs found.
30    OrgsDiscovered(usize),
31    /// Discovery: processing an org.
32    OrgStarted(String),
33    /// Discovery: org complete with N repos.
34    OrgComplete(String, usize),
35    /// Discovery complete with full repo list.
36    DiscoveryComplete(Vec<OwnedRepo>),
37    /// Discovery failed.
38    DiscoveryError(String),
39    /// Setup wizard org discovery complete.
40    SetupOrgsDiscovered(Vec<OrgEntry>),
41    /// Setup wizard org discovery failed.
42    SetupOrgsError(String),
43    /// Operation phase started with total and per-phase breakdown.
44    OperationStarted {
45        operation: Operation,
46        total: usize,
47        to_clone: usize,
48        to_sync: usize,
49    },
50    /// A repo started processing (for live worker slots).
51    RepoStarted { repo_name: String },
52    /// Operation progress: one repo processed.
53    RepoProgress {
54        repo_name: String,
55        success: bool,
56        skipped: bool,
57        message: String,
58        /// Whether this repo had new commits.
59        had_updates: bool,
60        /// Whether this was a clone (not a sync).
61        is_clone: bool,
62        /// Number of new commits fetched (if known).
63        new_commits: Option<u32>,
64        /// Structured skip reason (if skipped).
65        skip_reason: Option<String>,
66    },
67    /// Commit log for a specific repo (post-sync deep dive).
68    RepoCommitLog {
69        repo_name: String,
70        commits: Vec<String>,
71    },
72    /// Operation complete.
73    OperationComplete(OpSummary),
74    /// Operation error.
75    OperationError(String),
76    /// Status scan results.
77    StatusResults(Vec<RepoEntry>),
78    /// Setup wizard requirement check results.
79    SetupCheckResults(Vec<CheckEntry>),
80    /// Default workspace was set/cleared successfully.
81    DefaultWorkspaceUpdated(Option<String>),
82    /// Default workspace operation failed.
83    DefaultWorkspaceError(String),
84    /// Requirement check results (background).
85    CheckResults(Vec<CheckEntry>),
86}
87
88/// Spawn the terminal event reader in a blocking thread.
89/// Returns a receiver for AppEvents and a sender for backend to push messages.
90pub fn spawn_event_loop(
91    tick_rate: Duration,
92) -> (
93    mpsc::UnboundedReceiver<AppEvent>,
94    mpsc::UnboundedSender<AppEvent>,
95) {
96    let (tx, rx) = mpsc::unbounded_channel();
97    let event_tx = tx.clone();
98
99    // Terminal event reader (crossterm is blocking)
100    tokio::task::spawn_blocking(move || {
101        loop {
102            let has_event = match event::poll(tick_rate) {
103                Ok(v) => v,
104                Err(e) => {
105                    warn!(error = %e, "Terminal poll failed; stopping event loop");
106                    break;
107                }
108            };
109
110            if has_event {
111                if let Ok(ev) = event::read() {
112                    let app_event = match ev {
113                        CtEvent::Key(key) => AppEvent::Terminal(key),
114                        CtEvent::Resize(w, h) => AppEvent::Resize(w, h),
115                        _ => continue,
116                    };
117                    if event_tx.send(app_event).is_err() {
118                        break;
119                    }
120                }
121            } else {
122                // Tick on timeout
123                if event_tx.send(AppEvent::Tick).is_err() {
124                    break;
125                }
126            }
127        }
128    });
129
130    (rx, tx)
131}
132
133#[cfg(test)]
134#[path = "event_tests.rs"]
135mod tests;