1use crate::app::{App, AppEvent, InputMode, Mode};
2use crate::infra::channel::{self, Receiver};
3use crate::infra::terminal::TerminalSessionGuard;
4use crate::preview::{self, structured_log};
5use crate::preview::{PreviewRequest, PreviewSource};
6use crate::runtime::interactive::handlers;
7use crate::runtime::interactive::input::InputEvent;
8use crate::search::controller::SearchController;
9use crate::search::matcher::MatcherCommand;
10use crate::ui;
11use crossterm::{
12 execute, queue,
13 terminal::{BeginSynchronizedUpdate, EndSynchronizedUpdate},
14};
15use ratatui::{backend::CrosstermBackend, Terminal};
16use std::io;
17use std::time::{Duration, Instant};
18
19const MAX_PENDING_EVENTS_PER_FRAME: usize = 64;
20const PERIODIC_RENDER_INTERVAL: Duration = Duration::from_millis(120);
21
22pub fn run_event_loop(
23 app: &mut App,
24 terminal: &mut Terminal<CrosstermBackend<io::Stderr>>,
25 terminal_session: &mut TerminalSessionGuard,
26 rx_main: &channel::DefaultReceiver<AppEvent>,
27 tx_preview_req: &channel::DefaultSender<PreviewRequest>,
28 tx_cmd_noop: &channel::DefaultSender<MatcherCommand>,
29 search_sessions: &mut Option<SearchController>,
30 log_max_entries: usize,
31) -> anyhow::Result<()> {
32 let mut item_limit = 100;
33
34 render_frame(app, terminal, terminal_session)?;
35 let mut last_render_at = Instant::now();
36
37 loop {
38 let event = match rx_main.recv() {
39 Ok(event) => event,
40 Err(channel::ChannelError::Disconnected) => return Ok(()),
41 Err(err) => return Err(err.into()),
42 };
43 let mut saw_tick = matches!(event, AppEvent::Input(InputEvent::Tick));
44 let mut render_requested = handle_app_event(
45 app,
46 event,
47 tx_preview_req,
48 tx_cmd_noop,
49 search_sessions,
50 log_max_entries,
51 );
52
53 for _ in 0..MAX_PENDING_EVENTS_PER_FRAME {
54 let event = match rx_main.try_recv() {
55 Ok(Some(event)) => event,
56 Ok(None) | Err(channel::ChannelError::Disconnected) => break,
57 Err(err) => return Err(err.into()),
58 };
59 saw_tick |= matches!(event, AppEvent::Input(InputEvent::Tick));
60 render_requested |= handle_app_event(
61 app,
62 event,
63 tx_preview_req,
64 tx_cmd_noop,
65 search_sessions,
66 log_max_entries,
67 );
68 }
69
70 if let Some(search_sessions) = search_sessions.as_mut() {
71 search_sessions.reconcile(app, &mut item_limit);
72 }
73
74 if let Some(search_sessions) = search_sessions.as_ref() {
75 if let Some(tx_cmd) = search_sessions.command_sender() {
76 handlers::check_infinite_scroll(app, &mut item_limit, tx_cmd);
77 }
78 }
79
80 if app.ui.should_quit {
81 return Ok(());
82 }
83
84 if saw_tick
85 && !render_requested
86 && should_render_periodically(app)
87 && last_render_at.elapsed() >= PERIODIC_RENDER_INTERVAL
88 {
89 render_requested = true;
90 }
91
92 if render_requested {
93 render_frame(app, terminal, terminal_session)?;
94 last_render_at = Instant::now();
95 }
96 }
97}
98
99fn render_frame(
100 app: &mut App,
101 terminal: &mut Terminal<CrosstermBackend<io::Stderr>>,
102 terminal_session: &mut TerminalSessionGuard,
103) -> anyhow::Result<()> {
104 app.refresh_viewports();
105 sync_cursor_style(app, terminal_session);
106
107 queue!(terminal.backend_mut(), BeginSynchronizedUpdate).ok();
108 terminal.draw(|f| ui::draw(f, app))?;
109 execute!(terminal.backend_mut(), EndSynchronizedUpdate).ok();
110
111 Ok(())
112}
113
114fn should_render_periodically(app: &App) -> bool {
115 app.search_session.search.working
116 || app
117 .preview_session
118 .preview
119 .state
120 .status_message
121 .as_ref()
122 .is_some_and(|(_, shown_at)| shown_at.elapsed().as_secs() < 3)
123}
124
125fn sync_cursor_style(app: &App, terminal_session: &mut TerminalSessionGuard) {
126 let should_be_bar =
127 app.ui.mode == Mode::Preview && app.preview_session.preview.state.mode == InputMode::Insert;
128 terminal_session.sync_cursor_style(should_be_bar);
129}
130
131fn handle_app_event(
132 app: &mut App,
133 event: AppEvent,
134 tx_preview_req: &channel::DefaultSender<PreviewRequest>,
135 tx_cmd_noop: &channel::DefaultSender<MatcherCommand>,
136 search_sessions: &Option<SearchController>,
137 log_max_entries: usize,
138) -> bool {
139 match event {
140 AppEvent::Input(input) => match input {
141 InputEvent::Key(key) => {
142 let tx_cmd = search_sessions
143 .as_ref()
144 .and_then(SearchController::command_sender)
145 .unwrap_or(tx_cmd_noop);
146 handlers::handle_input(app, key, tx_cmd, tx_preview_req);
147 true
148 }
149 InputEvent::Resize(width, height) => {
150 app.set_terminal_size(width, height);
151 app.refresh_viewports();
152 true
153 }
154 InputEvent::Tick => false,
155 },
156 AppEvent::Matcher(state, epoch) => {
157 if search_sessions
158 .as_ref()
159 .is_some_and(|search_sessions| search_sessions.accepts_epoch(epoch))
160 {
161 apply_matcher_state(app, state, tx_preview_req);
162 true
163 } else {
164 false
165 }
166 }
167 AppEvent::Preview(source, text) => {
168 apply_preview_event(app, source, text);
169 true
170 }
171 AppEvent::LogAppend(path, entries) => {
172 structured_log::apply_append(app, &path, entries, log_max_entries);
173 true
174 }
175 }
176}
177
178fn apply_matcher_state(
179 app: &mut App,
180 state: crate::search::matcher::MatcherState,
181 tx_preview: &channel::DefaultSender<PreviewRequest>,
182) {
183 app.search_session.search.results = state.results;
184 app.search_session.search.total_matches = state.total_matches;
185 app.search_session.search.total_items = state.total_items;
186 app.search_session.search.working = state.working;
187 app.search_session.search.update_selection();
188 handlers::sync_preview(app, tx_preview);
189}
190
191pub fn apply_preview_event(app: &mut App, source: PreviewSource, text: preview::PreviewContent) {
192 if app.preview_session.preview.source.as_ref() != Some(&source) {
193 return;
194 }
195
196 let preview_is_rich_text = matches!(text, preview::PreviewContent::RichText(_))
197 && !matches!(source, PreviewSource::GitHistory { .. });
198 let preview_is_log = matches!(text, preview::PreviewContent::StructuredLog(_));
199 app.preview_session.preview.content = Some(text);
200
201 if app.ui.mode == Mode::Preview && !preview_is_rich_text && !preview_is_log {
202 app.ui.mode = Mode::Search;
203 app.preview_session.preview.state.search_active = false;
204 app.preview_session.preview.state.status_message = Some((
205 "Preview is read-only for this file type".to_string(),
206 std::time::Instant::now(),
207 ));
208 }
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214 use crate::config::LoadedAppConfig;
215 use crate::infra::channel::{unbounded_default, Receiver};
216 use crate::runtime::config::RunConfig;
217 use crate::search::sources::git::{GitSearchMode, GitSearchScope};
218 use crate::search::types::{
219 MatcherMode, SearchConfig, SearchItem, SearchMode, SearchResult, SearchSettings,
220 };
221 use std::path::{Path, PathBuf};
222
223 fn run_config() -> RunConfig {
224 RunConfig {
225 headless: false,
226 output_format: crate::cli::args::OutputFormat::Plain,
227 output_file: None,
228 stdin: false,
229 log: false,
230 diff: None,
231 preview_command: None,
232 preview_delimiter: ":".to_string(),
233 split: None,
234 log_files: Vec::new(),
235 }
236 }
237
238 fn search_config() -> SearchConfig {
239 SearchConfig {
240 query: None,
241 locations: vec![],
242 search_pdf: false,
243 no_hidden: false,
244 no_git_ignore: false,
245 no_ignore: false,
246 no_default_ignore_dirs: false,
247 git_search_scope: None,
248 settings: SearchSettings {
249 mode: SearchMode::Path,
250 matcher: MatcherMode::Fuzzy,
251 },
252 }
253 }
254
255 fn app() -> App {
256 App::from_configs(run_config(), search_config(), LoadedAppConfig::default())
257 }
258
259 #[test]
260 fn periodic_render_runs_while_search_is_working() {
261 let mut app = app();
262 app.search_session.search.working = true;
263
264 assert!(should_render_periodically(&app));
265 }
266
267 #[test]
268 fn periodic_render_stops_after_status_message_expires() {
269 let mut app = app();
270 app.preview_session.preview.state.status_message =
271 Some(("Saved".to_string(), Instant::now() - Duration::from_secs(4)));
272
273 assert!(!should_render_periodically(&app));
274 }
275
276 #[test]
277 fn git_history_rich_text_preview_stays_read_only() {
278 let mut search_config = search_config();
279 search_config.git_search_scope = Some(GitSearchScope {
280 repo_root: PathBuf::from("/repo"),
281 mode: GitSearchMode::History {
282 file: PathBuf::from("Architecture.md"),
283 },
284 display_path: Some("Architecture.md".to_string()),
285 });
286 let mut app = App::from_configs(run_config(), search_config, LoadedAppConfig::default());
287 app.ui.mode = Mode::Preview;
288 let source = PreviewSource::GitHistory {
289 commit: "HEAD".to_string(),
290 path: "Architecture.md".to_string(),
291 line: 2,
292 };
293 app.preview_session.preview.source = Some(source.clone());
294
295 apply_preview_event(
296 &mut app,
297 source,
298 preview::PreviewContent::RichText(preview::create_rich_text_document(
299 "fn main() {}\n".to_string(),
300 Path::new("Architecture.md"),
301 )),
302 );
303
304 assert_eq!(app.ui.mode, Mode::Search);
305 assert!(matches!(
306 app.preview_session.preview.content,
307 Some(preview::PreviewContent::RichText(_))
308 ));
309 assert!(app.preview_session.preview.state.status_message.is_some());
310 }
311
312 #[test]
313 fn grep_matcher_state_syncs_preview_request_and_highlight() {
314 let mut app = App::from_configs(run_config(), search_config(), LoadedAppConfig::default());
315 app.search_session.settings.mode = SearchMode::Grep;
316 app.set_terminal_size(120, 40);
317 app.refresh_viewports();
318
319 let (tx_preview, rx_preview) = unbounded_default();
320 apply_matcher_state(
321 &mut app,
322 crate::search::matcher::MatcherState {
323 results: vec![SearchResult {
324 item: SearchItem::grep("src/main.rs", 24, "fn main()"),
325 indices: vec![],
326 column: Some(4),
327 }],
328 total_matches: 1,
329 total_items: 1,
330 working: false,
331 },
332 &tx_preview,
333 );
334
335 let request = rx_preview.recv().expect("preview request");
336 assert_eq!(
337 request,
338 PreviewRequest::Grep {
339 source: PreviewSource::SearchItem(SearchItem::grep("src/main.rs", 24, "fn main()")),
340 path: "src/main.rs".to_string(),
341 line: 24,
342 text: "fn main()".to_string(),
343 }
344 );
345 assert_eq!(app.preview_session.preview.state.highlight_line, Some(24));
346 }
347
348 #[test]
349 fn git_history_matcher_state_syncs_history_preview_request() {
350 let mut search_config = search_config();
351 search_config.git_search_scope = Some(GitSearchScope {
352 repo_root: Path::new("/repo").to_path_buf(),
353 mode: GitSearchMode::History {
354 file: Path::new("Architecture.md").to_path_buf(),
355 },
356 display_path: Some("Architecture.md".to_string()),
357 });
358 let mut app = App::from_configs(run_config(), search_config, LoadedAppConfig::default());
359 app.search_session.settings.mode = SearchMode::GitHistory;
360 app.set_terminal_size(120, 40);
361 app.refresh_viewports();
362
363 let (tx_preview, rx_preview) = unbounded_default();
364 apply_matcher_state(
365 &mut app,
366 crate::search::matcher::MatcherState {
367 results: vec![SearchResult {
368 item: SearchItem::history_line("abc123", "Architecture.md", 24, "fn main()"),
369 indices: vec![],
370 column: Some(4),
371 }],
372 total_matches: 1,
373 total_items: 1,
374 working: false,
375 },
376 &tx_preview,
377 );
378
379 let request = rx_preview.recv().expect("preview request");
380 assert_eq!(
381 request,
382 PreviewRequest::GitHistory {
383 source: PreviewSource::GitHistory {
384 commit: "abc123".to_string(),
385 path: "Architecture.md".to_string(),
386 line: 24,
387 },
388 repo_root: "/repo".to_string(),
389 commit: "abc123".to_string(),
390 path: "Architecture.md".to_string(),
391 line: 24,
392 }
393 );
394 assert_eq!(app.preview_session.preview.state.highlight_line, Some(24));
395 }
396}