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 ratatui::{backend::CrosstermBackend, Terminal};
12use std::io;
13
14pub fn run_event_loop(
15 app: &mut App,
16 terminal: &mut Terminal<CrosstermBackend<io::Stderr>>,
17 terminal_session: &mut TerminalSessionGuard,
18 rx_main: &channel::DefaultReceiver<AppEvent>,
19 tx_preview_req: &channel::DefaultSender<PreviewRequest>,
20 tx_cmd_noop: &channel::DefaultSender<MatcherCommand>,
21 search_sessions: &mut Option<SearchController>,
22 log_max_entries: usize,
23) -> anyhow::Result<()> {
24 let mut item_limit = 100;
25
26 loop {
27 app.refresh_viewports();
28 sync_cursor_style(app, terminal_session);
29 terminal.draw(|f| ui::draw(f, app))?;
30
31 if let Ok(event) = rx_main.recv() {
32 handle_app_event(
33 app,
34 event,
35 tx_preview_req,
36 tx_cmd_noop,
37 search_sessions,
38 log_max_entries,
39 );
40 }
41
42 if let Some(search_sessions) = search_sessions.as_mut() {
43 search_sessions.reconcile(app, &mut item_limit);
44 }
45
46 if let Some(search_sessions) = search_sessions.as_ref() {
47 if let Some(tx_cmd) = search_sessions.command_sender() {
48 handlers::check_infinite_scroll(app, &mut item_limit, tx_cmd);
49 }
50 }
51
52 if app.ui.should_quit {
53 return Ok(());
54 }
55 }
56}
57
58fn sync_cursor_style(app: &App, terminal_session: &mut TerminalSessionGuard) {
59 let should_be_bar =
60 app.ui.mode == Mode::Preview && app.preview_session.preview.state.mode == InputMode::Insert;
61 terminal_session.sync_cursor_style(should_be_bar);
62}
63
64fn handle_app_event(
65 app: &mut App,
66 event: AppEvent,
67 tx_preview_req: &channel::DefaultSender<PreviewRequest>,
68 tx_cmd_noop: &channel::DefaultSender<MatcherCommand>,
69 search_sessions: &Option<SearchController>,
70 log_max_entries: usize,
71) {
72 match event {
73 AppEvent::Input(input) => match input {
74 InputEvent::Key(key) => {
75 let tx_cmd = search_sessions
76 .as_ref()
77 .and_then(SearchController::command_sender)
78 .unwrap_or(tx_cmd_noop);
79 handlers::handle_input(app, key, tx_cmd, tx_preview_req);
80 }
81 InputEvent::Resize(width, height) => {
82 app.set_terminal_size(width, height);
83 app.refresh_viewports();
84 }
85 InputEvent::Tick => {}
86 },
87 AppEvent::Matcher(state, epoch) => {
88 if search_sessions
89 .as_ref()
90 .is_some_and(|search_sessions| search_sessions.accepts_epoch(epoch))
91 {
92 apply_matcher_state(app, state, tx_preview_req);
93 }
94 }
95 AppEvent::Preview(source, text) => apply_preview_event(app, source, text),
96 AppEvent::LogAppend(path, entries) => {
97 structured_log::apply_append(app, &path, entries, log_max_entries);
98 }
99 }
100}
101
102fn apply_matcher_state(
103 app: &mut App,
104 state: crate::search::matcher::MatcherState,
105 tx_preview: &channel::DefaultSender<PreviewRequest>,
106) {
107 app.search_session.search.results = state.results;
108 app.search_session.search.total_matches = state.total_matches;
109 app.search_session.search.total_items = state.total_items;
110 app.search_session.search.working = state.working;
111 app.search_session.search.update_selection();
112 handlers::sync_preview(app, tx_preview);
113}
114
115pub fn apply_preview_event(app: &mut App, source: PreviewSource, text: preview::PreviewContent) {
116 if app.preview_session.preview.source.as_ref() != Some(&source) {
117 return;
118 }
119
120 let preview_is_rich_text = matches!(text, preview::PreviewContent::RichText(_))
121 && !matches!(source, PreviewSource::GitHistory { .. });
122 let preview_is_log = matches!(text, preview::PreviewContent::StructuredLog(_));
123 app.preview_session.preview.content = Some(text);
124
125 if app.ui.mode == Mode::Preview && !preview_is_rich_text && !preview_is_log {
126 app.ui.mode = Mode::Search;
127 app.preview_session.preview.state.search_active = false;
128 app.preview_session.preview.state.status_message = Some((
129 "Preview is read-only for this file type".to_string(),
130 std::time::Instant::now(),
131 ));
132 }
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138 use crate::config::LoadedAppConfig;
139 use crate::infra::channel::{unbounded_default, Receiver};
140 use crate::runtime::config::RunConfig;
141 use crate::search::sources::git::{GitSearchMode, GitSearchScope};
142 use crate::search::types::{
143 MatcherMode, SearchConfig, SearchItem, SearchMode, SearchResult, SearchSettings,
144 };
145 use std::path::{Path, PathBuf};
146
147 fn run_config() -> RunConfig {
148 RunConfig {
149 headless: false,
150 output_format: crate::cli::args::OutputFormat::Plain,
151 stdin: false,
152 log: false,
153 diff: None,
154 preview_command: None,
155 preview_delimiter: ":".to_string(),
156 split: None,
157 log_files: Vec::new(),
158 }
159 }
160
161 fn search_config() -> SearchConfig {
162 SearchConfig {
163 query: None,
164 locations: vec![],
165 search_pdf: false,
166 no_hidden: false,
167 no_git_ignore: false,
168 no_ignore: false,
169 no_default_ignore_dirs: false,
170 git_search_scope: None,
171 settings: SearchSettings {
172 mode: SearchMode::Path,
173 matcher: MatcherMode::Fuzzy,
174 },
175 }
176 }
177
178 #[test]
179 fn git_history_rich_text_preview_stays_read_only() {
180 let mut search_config = search_config();
181 search_config.git_search_scope = Some(GitSearchScope {
182 repo_root: PathBuf::from("/repo"),
183 mode: GitSearchMode::History {
184 file: PathBuf::from("Architecture.md"),
185 },
186 display_path: Some("Architecture.md".to_string()),
187 });
188 let mut app = App::from_configs(run_config(), search_config, LoadedAppConfig::default());
189 app.ui.mode = Mode::Preview;
190 let source = PreviewSource::GitHistory {
191 commit: "HEAD".to_string(),
192 path: "Architecture.md".to_string(),
193 line: 2,
194 };
195 app.preview_session.preview.source = Some(source.clone());
196
197 apply_preview_event(
198 &mut app,
199 source,
200 preview::PreviewContent::RichText(preview::create_rich_text_document(
201 "fn main() {}\n".to_string(),
202 Path::new("Architecture.md"),
203 )),
204 );
205
206 assert_eq!(app.ui.mode, Mode::Search);
207 assert!(matches!(
208 app.preview_session.preview.content,
209 Some(preview::PreviewContent::RichText(_))
210 ));
211 assert!(app.preview_session.preview.state.status_message.is_some());
212 }
213
214 #[test]
215 fn grep_matcher_state_syncs_preview_request_and_highlight() {
216 let mut app = App::from_configs(run_config(), search_config(), LoadedAppConfig::default());
217 app.search_session.settings.mode = SearchMode::Grep;
218 app.set_terminal_size(120, 40);
219 app.refresh_viewports();
220
221 let (tx_preview, rx_preview) = unbounded_default();
222 apply_matcher_state(
223 &mut app,
224 crate::search::matcher::MatcherState {
225 results: vec![SearchResult {
226 item: SearchItem::grep("src/main.rs", 24, "fn main()"),
227 indices: vec![],
228 column: Some(4),
229 }],
230 total_matches: 1,
231 total_items: 1,
232 working: false,
233 },
234 &tx_preview,
235 );
236
237 let request = rx_preview.recv().expect("preview request");
238 assert_eq!(
239 request,
240 PreviewRequest::Grep {
241 source: PreviewSource::SearchItem(SearchItem::grep("src/main.rs", 24, "fn main()")),
242 path: "src/main.rs".to_string(),
243 line: 24,
244 text: "fn main()".to_string(),
245 }
246 );
247 assert_eq!(app.preview_session.preview.state.highlight_line, Some(24));
248 }
249
250 #[test]
251 fn git_history_matcher_state_syncs_history_preview_request() {
252 let mut search_config = search_config();
253 search_config.git_search_scope = Some(GitSearchScope {
254 repo_root: Path::new("/repo").to_path_buf(),
255 mode: GitSearchMode::History {
256 file: Path::new("Architecture.md").to_path_buf(),
257 },
258 display_path: Some("Architecture.md".to_string()),
259 });
260 let mut app = App::from_configs(run_config(), search_config, LoadedAppConfig::default());
261 app.search_session.settings.mode = SearchMode::GitHistory;
262 app.set_terminal_size(120, 40);
263 app.refresh_viewports();
264
265 let (tx_preview, rx_preview) = unbounded_default();
266 apply_matcher_state(
267 &mut app,
268 crate::search::matcher::MatcherState {
269 results: vec![SearchResult {
270 item: SearchItem::history_line("abc123", "Architecture.md", 24, "fn main()"),
271 indices: vec![],
272 column: Some(4),
273 }],
274 total_matches: 1,
275 total_items: 1,
276 working: false,
277 },
278 &tx_preview,
279 );
280
281 let request = rx_preview.recv().expect("preview request");
282 assert_eq!(
283 request,
284 PreviewRequest::GitHistory {
285 source: PreviewSource::GitHistory {
286 commit: "abc123".to_string(),
287 path: "Architecture.md".to_string(),
288 line: 24,
289 },
290 repo_root: "/repo".to_string(),
291 commit: "abc123".to_string(),
292 path: "Architecture.md".to_string(),
293 line: 24,
294 }
295 );
296 assert_eq!(app.preview_session.preview.state.highlight_line, Some(24));
297 }
298}