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
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
//! 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>>,
/// Rows the request pointed at, 1-based and inclusive.
span: Option<(u32, u32)>,
/// The request this answers; a stale one is dropped on arrival.
token: u64,
},
/// The kinds sidebar's git-attribute lookup came back. Absent paths are
/// the ones the repo says nothing about.
DeclaredKinds {
kinds: std::collections::HashMap<String, diffler_core::classify::Kind>,
/// The request this answers; a stale one is dropped on arrival.
token: u64,
},
/// The language breakdown's repo scan came back.
RepoStats {
stats: Box<diffler_core::stats::RepoStats>,
/// The request this answers; a stale one is dropped on arrival.
token: u64,
},
/// The files the walkthrough's anchors name, read off-thread so its stops
/// and figures can resolve to lines.
WalkthroughAnchors {
contents: std::collections::HashMap<String, String>,
/// The pinned revision the walkthrough was read against no longer
/// resolves, so every file fell back to the worktree.
pin_broken: bool,
/// 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;
}
}
})
}