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