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(Box<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(), |event| {
72                AppEvent::RemoteCompletion(Box::new(event))
73            });
74        }
75        if let Some(rx) = self.containers.take_rx() {
76            forward(rx, tx.clone(), AppEvent::Container);
77        }
78        if let Some(rx) = self.shell_rx.take() {
79            forward(rx, tx.clone(), AppEvent::Shell);
80        }
81        if let Some(rx) = self.git_rx.take() {
82            forward(rx, tx.clone(), AppEvent::Git);
83        }
84        if let Some(rx) = self.clip_rx.take() {
85            forward(rx, tx.clone(), AppEvent::Clipboard);
86        }
87        if let Some(rx) = self.preview_rx.take() {
88            forward(rx, tx.clone(), AppEvent::Preview);
89        }
90        if let Some(rx) = self.picker_ranking.rx.take() {
91            forward(rx, tx.clone(), AppEvent::PickerRanking);
92        }
93        if let Some(rx) = self.analysis.rx.take() {
94            forward(rx, tx.clone(), AppEvent::Analysis);
95        }
96        if let Some(rx) = self.resolution.rx.take() {
97            forward(rx, tx.clone(), AppEvent::Resolution);
98        }
99        self.connect_picker_stream(&tx);
100        for srv in &mut self.lsp_servers {
101            let rx = std::mem::replace(&mut srv.rx, std::sync::mpsc::channel().1);
102            forward(rx, tx.clone(), AppEvent::Lsp);
103        }
104        let rx = self.lsp_state.attach.take_rx();
105        forward(rx, tx.clone(), AppEvent::LspAttach);
106        self.app_tx = Some(tx);
107    }
108
109    /// Route one event to its handler (the per-event halves of the old
110    /// drain loops; the drains call these in a try_recv loop).
111    pub fn handle_app_event(&mut self, ev: AppEvent) {
112        match ev {
113            AppEvent::Terminal(key) => self.feed(key),
114            AppEvent::Resize { .. } => {} // the loop redraws after every event
115            AppEvent::Paste(text) => {
116                strop_trace::record_with(strop_trace::EventKind::Paste, || {
117                    serde_json::json!({
118                        "bytes":text.len(),"text":strop_trace::capture_content().then_some(text.as_str()),
119                    })
120                });
121                if self.resolution.blocked() || !self.resolution.queue.is_empty() {
122                    self.resolution
123                        .queue
124                        .push_back(super::resolution::DeferredInput::Paste(text));
125                    return;
126                }
127                self.paste_bracketed(&text);
128            }
129            AppEvent::QuitIntent => {
130                strop_trace::record_with(
131                    strop_trace::EventKind::Input,
132                    || serde_json::json!({"action":"quit_intent","source":"external"}),
133                );
134                self.resolution.cancel();
135                self.resolution.queue.clear();
136                if self.ctrl_c_quit() {
137                    self.should_quit = true;
138                }
139            }
140            AppEvent::Lsp(event) => self.handle_lsp_event(event),
141            AppEvent::LspAttach(record) => self.handle_lsp_attach(record),
142            AppEvent::Shell(r) => self.handle_shell_result(r),
143            AppEvent::Io(event) => self.handle_io(event),
144            AppEvent::RemoteCompletion(event) => self.handle_remote_completion(*event),
145            AppEvent::Container(event) => self.handle_container_event(event),
146            AppEvent::Git(job) => self.handle_git_job(job),
147            AppEvent::Picker(event) => self.handle_picker_event(event),
148            AppEvent::PickerRanking(event) => self.handle_picker_ranking(event),
149            AppEvent::Analysis(event) => self.handle_analysis(event),
150            AppEvent::Resolution(event) => self.handle_resolution(event),
151            AppEvent::ResumeInput => self.resume_resolution_input(),
152            AppEvent::Preview(result) => self.handle_preview(result),
153            AppEvent::Clipboard(content) => self.handle_clipboard(content),
154        }
155    }
156}
157
158impl Editor {
159    /// Outstanding finite work, independent of whether channels are forwarded.
160    pub fn async_pending(&self) -> bool {
161        use strop_core::worker::Load;
162        self.io_pending()
163            || !self.shell_requests.is_empty()
164            || self.clip_paste_pending.is_some()
165            || self
166                .picker
167                .as_ref()
168                .is_some_and(|glue| glue.picker.streaming || glue.rank_pending.is_some())
169            || !self.picker_ranking.retiring.is_empty()
170            || self
171                .picker_source
172                .as_ref()
173                .is_some_and(strop_picker::SourceWorker::busy)
174            || self.analysis.pending()
175            || self.resolution.pending()
176            || self
177                .preview_loads
178                .values()
179                .any(|load| matches!(load, Load::Running(_)))
180            || matches!(self.git_discovery, Load::Running(_))
181            || matches!(self.hunk_load, Load::Running(_))
182            || !self.log_requests.is_empty()
183            || !self.dive_requests.is_empty()
184            || self.card_request.is_some()
185            || self.git_mutation.is_some()
186            || !self.git_mutations.is_empty()
187            || self
188                .blame_gutters
189                .values()
190                .any(|gutter| gutter.request.is_some())
191            || self.containers.pending.is_some()
192            || !self.lsp_state.attach.pending.is_empty()
193            || self.remote_completion.pending.is_some()
194            || (!self.finishing
195                && (self.lsp_state.hover.is_some()
196                    || self.lsp_state.navigation.is_some()
197                    || self.lsp_servers.iter().any(|server| !server.ready)))
198    }
199
200    /// Finish is an explicit action in both modes. Preserve accepted writes;
201    /// cancel observational work so shutdown cannot depend on a slow reader.
202    pub(crate) fn finish_background_work(&mut self) {
203        self.finishing = true;
204        self.lsp_state.attach.enabled = false;
205        self.stop_remote_work();
206        self.close_picker();
207        if let Some(source) = self.picker_source.as_ref() {
208            source.close();
209        }
210        self.cancel_review_preparation();
211        self.analysis.stop();
212        self.resolution.stop();
213        self.git_mutations.clear();
214        self.request_session_save();
215        let cancel: Vec<_> = self
216            .worker_handles
217            .keys()
218            .copied()
219            .filter(|id| !self.io_write_pending(*id))
220            .collect();
221        for request in cancel {
222            if let Some(handle) = self.worker_handles.remove(&request) {
223                handle.cancel(strop_core::worker::CancelReason::Shutdown);
224            }
225        }
226    }
227
228    /// Report admitted effects that did not reach a confirmed shutdown outcome.
229    pub fn take_shutdown_error(&mut self) -> Option<String> {
230        match (
231            self.io.session_error.take(),
232            self.filesystem_shutdown_error(),
233        ) {
234            (Some(session), Some(filesystem)) => Some(format!("{session}\n{filesystem}")),
235            (Some(error), None) | (None, Some(error)) => Some(error),
236            (None, None) => None,
237        }
238    }
239}