Skip to main content

strop_engine/editor/
events.rs

1//! The unified event source (0018 §services): every async producer —
2//! terminal, LSP, git, shell, picker, clipboard — lands on ONE channel
3//! as a typed `AppEvent`. The main loop parks on it; workers wake the
4//! loop the instant they post. Gone: the 500ms poll latency between a
5//! job finishing and the UI noticing.
6//!
7//! Forwarder threads move each job channel into the app channel. The
8//! headless harness keeps the raw channels (no forwarders) and drives
9//! the same per-event handlers through the drains.
10
11use std::sync::mpsc::Receiver;
12mod channel;
13pub use channel::{channel, EventSender, EVENTS_PER_TURN, TURN_BUDGET};
14
15use super::{Editor, Key, ShellResult};
16
17/// One app event. Terminal input is already translated to editor keys
18/// by the reader thread.
19#[derive(serde::Serialize, serde::Deserialize)]
20pub enum AppEvent {
21    Terminal(Key),
22    /// Terminal resized — a redraw is owed even with no input (0020 §12).
23    Resize {
24        columns: u16,
25        rows: u16,
26    },
27    /// Bracketed paste: one text payload, never a key stream.
28    Paste(String),
29    /// ctrl-c: the quit intent (0015's policy lives in the editor).
30    QuitIntent,
31    Lsp(strop_lsp::LspEvent),
32    LspAttach(super::lsp::attach::AttachRecord),
33    Shell(ShellResult),
34    Io(super::io::IoEvent),
35    RemoteCompletion(super::remote_completion::RemoteCompletionEvent),
36    Container(super::containers::ContainerEvent),
37    Git(super::GitJob),
38    Picker(super::picker::PickerEvent),
39    PickerRanking(super::picker::ranking::Event),
40    Analysis(super::analysis::AnalysisEvent),
41    Resolution(super::resolution::ResolutionEvent),
42    ResumeInput,
43    Preview(super::picker::PreviewResult),
44    Clipboard(super::ClipboardResult),
45}
46
47/// A forwarder: move every item of a job channel onto the app channel.
48fn forward<T: Send + 'static>(
49    rx: Receiver<T>,
50    tx: EventSender,
51    wrap: impl Fn(T) -> AppEvent + Send + 'static,
52) {
53    std::thread::spawn(move || {
54        while let Ok(item) = rx.recv() {
55            if tx.send(wrap(item)).is_err() {
56                break;
57            }
58        }
59    });
60}
61
62impl Editor {
63    /// Connect the editor's job channels to the app event channel
64    /// (TUI only — headless keeps the raw channels for its drains).
65    /// Late-attaching LSP servers forward through the retained sender.
66    pub fn connect_events(&mut self, tx: EventSender) {
67        if let Some(rx) = self.io.rx.take() {
68            forward(rx, tx.clone(), AppEvent::Io);
69        }
70        if let Some(rx) = self.remote_completion.rx.take() {
71            forward(rx, tx.clone(), AppEvent::RemoteCompletion);
72        }
73        if let Some(rx) = self.containers.take_rx() {
74            forward(rx, tx.clone(), AppEvent::Container);
75        }
76        if let Some(rx) = self.shell_rx.take() {
77            forward(rx, tx.clone(), AppEvent::Shell);
78        }
79        if let Some(rx) = self.git_rx.take() {
80            forward(rx, tx.clone(), AppEvent::Git);
81        }
82        if let Some(rx) = self.clip_rx.take() {
83            forward(rx, tx.clone(), AppEvent::Clipboard);
84        }
85        if let Some(rx) = self.preview_rx.take() {
86            forward(rx, tx.clone(), AppEvent::Preview);
87        }
88        if let Some(rx) = self.picker_ranking.rx.take() {
89            forward(rx, tx.clone(), AppEvent::PickerRanking);
90        }
91        if let Some(rx) = self.analysis.rx.take() {
92            forward(rx, tx.clone(), AppEvent::Analysis);
93        }
94        if let Some(rx) = self.resolution.rx.take() {
95            forward(rx, tx.clone(), AppEvent::Resolution);
96        }
97        self.connect_picker_stream(&tx);
98        for srv in &mut self.lsp_servers {
99            let rx = std::mem::replace(&mut srv.rx, std::sync::mpsc::channel().1);
100            forward(rx, tx.clone(), AppEvent::Lsp);
101        }
102        let rx = self.lsp_state.attach.take_rx();
103        forward(rx, tx.clone(), AppEvent::LspAttach);
104        self.app_tx = Some(tx);
105    }
106
107    /// Route one event to its handler (the per-event halves of the old
108    /// drain loops; the drains call these in a try_recv loop).
109    pub fn handle_app_event(&mut self, ev: AppEvent) {
110        match ev {
111            AppEvent::Terminal(key) => self.feed(key),
112            AppEvent::Resize { .. } => {} // the loop redraws after every event
113            AppEvent::Paste(text) => {
114                strop_trace::record_with(strop_trace::EventKind::Paste, || {
115                    serde_json::json!({
116                        "bytes":text.len(),"text":strop_trace::capture_content().then_some(text.as_str()),
117                    })
118                });
119                if self.resolution.blocked() || !self.resolution.queue.is_empty() {
120                    self.resolution
121                        .queue
122                        .push_back(super::resolution::DeferredInput::Paste(text));
123                    return;
124                }
125                self.paste_bracketed(&text);
126            }
127            AppEvent::QuitIntent => {
128                strop_trace::record_with(
129                    strop_trace::EventKind::Input,
130                    || serde_json::json!({"action":"quit_intent","source":"external"}),
131                );
132                self.resolution.cancel();
133                self.resolution.queue.clear();
134                if self.ctrl_c_quit() {
135                    self.should_quit = true;
136                }
137            }
138            AppEvent::Lsp(event) => self.handle_lsp_event(event),
139            AppEvent::LspAttach(record) => self.handle_lsp_attach(record),
140            AppEvent::Shell(r) => self.handle_shell_result(r),
141            AppEvent::Io(event) => self.handle_io(event),
142            AppEvent::RemoteCompletion(event) => self.handle_remote_completion(event),
143            AppEvent::Container(event) => self.handle_container_event(event),
144            AppEvent::Git(job) => self.handle_git_job(job),
145            AppEvent::Picker(event) => self.handle_picker_event(event),
146            AppEvent::PickerRanking(event) => self.handle_picker_ranking(event),
147            AppEvent::Analysis(event) => self.handle_analysis(event),
148            AppEvent::Resolution(event) => self.handle_resolution(event),
149            AppEvent::ResumeInput => self.resume_resolution_input(),
150            AppEvent::Preview(result) => self.handle_preview(result),
151            AppEvent::Clipboard(content) => self.handle_clipboard(content),
152        }
153    }
154}
155
156impl Editor {
157    /// Outstanding finite work, independent of whether channels are forwarded.
158    pub fn async_pending(&self) -> bool {
159        use strop_core::worker::Load;
160        self.io_pending()
161            || !self.shell_requests.is_empty()
162            || self.clip_paste_pending.is_some()
163            || self
164                .picker
165                .as_ref()
166                .is_some_and(|glue| glue.picker.streaming || glue.rank_pending.is_some())
167            || !self.picker_ranking.retiring.is_empty()
168            || self.analysis.pending()
169            || self.resolution.pending()
170            || self
171                .preview_loads
172                .values()
173                .any(|load| matches!(load, Load::Running(_)))
174            || matches!(self.git_discovery, Load::Running(_))
175            || matches!(self.hunk_load, Load::Running(_))
176            || !self.log_requests.is_empty()
177            || !self.dive_requests.is_empty()
178            || self.card_request.is_some()
179            || self.git_mutation.is_some()
180            || !self.git_mutations.is_empty()
181            || self
182                .blame_gutters
183                .values()
184                .any(|gutter| gutter.request.is_some())
185            || self.containers.pending.is_some()
186            || !self.lsp_state.attach.pending.is_empty()
187            || self.remote_completion.pending.is_some()
188            || (!self.finishing
189                && (self.lsp_state.hover.is_some()
190                    || self.lsp_state.navigation.is_some()
191                    || self.lsp_servers.iter().any(|server| !server.ready)))
192    }
193
194    /// Finish is an explicit action in both modes. Preserve accepted writes;
195    /// cancel observational work so shutdown cannot depend on a slow reader.
196    pub(crate) fn finish_background_work(&mut self) {
197        self.finishing = true;
198        self.lsp_state.attach.enabled = false;
199        self.stop_remote_work();
200        self.close_picker();
201        self.cancel_review_preparation();
202        self.analysis.stop();
203        self.resolution.stop();
204        self.git_mutations.clear();
205        self.request_session_save();
206        let cancel: Vec<_> = self
207            .worker_handles
208            .keys()
209            .copied()
210            .filter(|id| !self.io_write_pending(*id))
211            .collect();
212        for request in cancel {
213            if let Some(handle) = self.worker_handles.remove(&request) {
214                handle.cancel(strop_core::worker::CancelReason::Shutdown);
215            }
216        }
217    }
218}