1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
//! Terminal event pump: crossterm's async stream plus a periodic tick,
//! multiplexed onto one channel. Kept thin: all decisions live in
//! `App::handle`, which is what the tests drive.
use std::time::Duration;
use crossterm::event::{Event, EventStream, KeyEvent, MouseEvent};
use futures_util::StreamExt as _;
use tokio::sync::mpsc::UnboundedSender;
use tokio::task::JoinHandle;
use crate::mcp::McpRequest;
#[derive(Debug)]
pub enum AppEvent {
Key(KeyEvent),
Mouse(MouseEvent),
Resize,
/// The terminal gained or lost focus (DEC mode 1004). Terminals without
/// focus reporting never send it, so the app treats itself as focused.
Focus(bool),
Tick,
/// Debounced filesystem change from the watcher (`watch` module).
RepoChanged,
/// A background enrichment (emphasis/highlight/scope) finished.
Enriched(Box<crate::app::enrich::EnrichOutcome>),
/// A file the view asked for came back loaded and blamed, or failed to.
FileLoaded {
result: Box<Result<crate::app::file::FileView, String>>,
/// Line the request wanted the cursor on, 1-based.
line: Option<u32>,
/// The request this answers; a stale one is dropped on arrival.
token: u64,
},
/// An off-thread repo refresh finished (status + working diff, plus the
/// open three-dot diff when one is up), or failed.
RefreshDone(Box<Result<diffler_core::review::Refreshed, String>>),
/// Agent tool call routed through the event channel so the app stays
/// the single owner of the review state (`mcp` module).
Mcp(McpRequest),
/// A shelled-out network git op finished (`app::GitOp`). The result returns
/// as an event so the run loop keeps drawing while the process runs.
GitDone {
label: String,
ok: bool,
output: String,
},
/// Branch-scoped CI run list from a provider poll.
CiRuns(Vec<crate::ci::CiRun>),
/// The checked-out branch's PR, fetched once per branch (not every poll).
PrComments {
number: u64,
comments: Vec<crate::ci::PrComment>,
/// The PR as the forge sees it now, so a force-push is noticed on the
/// same poll that syncs comments. `None` when the lookup failed.
pr: Option<crate::ci::PullRequest>,
},
PrPosted {
post: Box<crate::app::pr::PrPost>,
result: Result<Option<crate::ci::PrComment>, String>,
},
CiPrs(Vec<crate::ci::PullRequest>),
CiPr(Option<crate::ci::PullRequest>),
/// A composed pull request came back from the forge, or failed to.
PrCreated(Box<Result<crate::ci::PullRequest, String>>),
/// A run's jobs + dependency DAG, mapped onto the graph view.
CiRunDetail(crate::ci::RunDetail),
/// A run's artifacts + annotations for the graph page's extras panel.
CiExtras(crate::ci::RunExtras),
/// An incremental job-log slice from a provider poll, with the job's step
/// boundaries (empty when the provider exposes none).
CiLog {
text: String,
steps: Vec<crate::ci::LogStepMeta>,
next_offset: u64,
done: bool,
},
/// A CI provider call failed; surfaced as a status-bar message.
CiError(String),
/// A failed PR-list fetch, told apart from every other CI failure so the
/// single-fetch-in-flight guard frees only its own slot.
CiPrsError(String),
Quit,
}
const TICK: Duration = Duration::from_millis(250);
/// Forward terminal events and ticks into `tx` until the terminal stream
/// ends or the receiver is dropped.
pub fn spawn_event_loop(tx: UnboundedSender<AppEvent>) -> JoinHandle<()> {
tokio::spawn(async move {
let mut events = EventStream::new();
let mut tick = tokio::time::interval(TICK);
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
let event = tokio::select! {
_ = tick.tick() => Some(AppEvent::Tick),
event = events.next() => match event {
Some(Ok(Event::Key(key))) => Some(AppEvent::Key(key)),
Some(Ok(Event::Mouse(mouse))) => Some(AppEvent::Mouse(mouse)),
Some(Ok(Event::Resize(_, _))) => Some(AppEvent::Resize),
Some(Ok(Event::FocusGained)) => Some(AppEvent::Focus(true)),
Some(Ok(Event::FocusLost)) => Some(AppEvent::Focus(false)),
Some(Ok(_)) => None,
Some(Err(_)) | None => {
let _ = tx.send(AppEvent::Quit);
return;
}
},
};
if let Some(event) = event
&& tx.send(event).is_err()
{
return;
}
}
})
}