1use super::{AppAction, HelpState, HelpTab, InputMode, LayoutState, Mode};
2use crate::config::{Keybindings, LoadedAppConfig};
3use crate::output::SelectionOutput;
4use crate::preview::rich_text::TextUndoFrame;
5use crate::preview::{PreviewContent, PreviewSource};
6use crate::runtime::config::RunConfig;
7use crate::search::types::{
8 MatcherMode, SearchConfig, SearchItem, SearchMode, SearchResult, SearchSettings,
9};
10use ratatui::layout::Rect;
11use ratatui::widgets::ListState;
12use std::collections::HashMap;
13
14use super::layout::ViewportMetrics;
15
16pub struct PreviewState {
17 pub scroll: usize,
18 pub scroll_char: usize,
19 pub highlight_line: Option<usize>,
20 pub cursor_line: usize,
21 pub cursor_char: usize,
22 pub search_query: String,
23 pub search_active: bool,
24 pub input_buffer: String,
25 pub command_buffer: String,
26 pub waiting_for_char_search: Option<(bool, usize)>,
27 pub last_char_search: Option<(char, bool)>,
28 pub mode: InputMode,
29 pub selection_start: Option<(usize, usize)>,
30 pub pending_object_modifier: Option<char>,
31 pub pending_operator: Option<char>,
32 pub status_message: Option<(String, std::time::Instant)>,
33 pub undo_stack: Vec<TextUndoFrame>,
34 pub redo_stack: Vec<TextUndoFrame>,
35}
36
37impl Default for PreviewState {
38 fn default() -> Self {
39 Self {
40 scroll: 0,
41 scroll_char: 0,
42 highlight_line: None,
43 cursor_line: 0,
44 cursor_char: 0,
45 search_query: String::new(),
46 search_active: false,
47 input_buffer: String::new(),
48 command_buffer: String::new(),
49 waiting_for_char_search: None,
50 last_char_search: None,
51 mode: InputMode::Normal,
52 selection_start: None,
53 pending_object_modifier: None,
54 pending_operator: None,
55 status_message: None,
56 undo_stack: Vec::new(),
57 redo_stack: Vec::new(),
58 }
59 }
60}
61
62pub struct Query {
63 pub text: String,
64 pub cursor: usize,
66 pub count_buffer: String,
68 pub mode: InputMode,
70 pub pending_op: Option<char>,
72 pub pending_modifier: Option<char>,
74}
75
76impl Default for Query {
77 fn default() -> Self {
78 Self {
79 text: String::new(),
80 cursor: 0,
81 count_buffer: String::new(),
82 mode: InputMode::Insert,
83 pending_op: None,
84 pending_modifier: None,
85 }
86 }
87}
88
89pub struct Search {
90 pub results: Vec<SearchResult>,
91 pub total_matches: u64,
92 pub total_items: u64,
93 pub selection: usize,
94 pub selected_item: Option<SearchItem>,
95 pub marked_items: HashMap<SearchItem, Option<usize>>,
96 pub diff_marked_items: std::collections::HashSet<SearchItem>,
97 pub scroll_state: ListState,
98 pub working: bool,
99}
100
101impl Default for Search {
102 fn default() -> Self {
103 let mut scroll_state = ListState::default();
104 scroll_state.select(Some(0));
105 Self {
106 results: Vec::new(),
107 total_matches: 0,
108 total_items: 0,
109 selection: 0,
110 selected_item: None,
111 marked_items: HashMap::new(),
112 diff_marked_items: std::collections::HashSet::new(),
113 scroll_state,
114 working: false,
115 }
116 }
117}
118
119impl Search {
120 pub fn update_selection(&mut self) {
121 if self.results.is_empty() {
122 self.selection = 0;
123 self.selected_item = None;
124 self.scroll_state.select(None);
125 return;
126 }
127
128 if let Some(ref selected) = self.selected_item {
129 if let Some(new_idx) = self
130 .results
131 .iter()
132 .position(|result| &result.item == selected)
133 {
134 self.selection = new_idx;
135 self.scroll_state.select(Some(new_idx));
136 return;
137 }
138 }
139
140 if self.selection >= self.results.len() {
141 self.selection = self.results.len().saturating_sub(1);
142 }
143 self.scroll_state.select(Some(self.selection));
144
145 if let Some(result) = self.results.get(self.selection) {
146 self.selected_item = Some(result.item.clone());
147 }
148 }
149
150 pub fn next(&mut self) {
151 if self.total_matches > 0 && self.selection < self.total_matches as usize - 1 {
152 self.selection += 1;
153 self.scroll_state.select(Some(self.selection));
154 if let Some(result) = self.results.get(self.selection) {
155 self.selected_item = Some(result.item.clone());
156 }
157 }
158 }
159
160 pub fn previous(&mut self) {
161 if self.selection > 0 {
162 self.selection -= 1;
163 self.scroll_state.select(Some(self.selection));
164 if let Some(result) = self.results.get(self.selection) {
165 self.selected_item = Some(result.item.clone());
166 }
167 }
168 }
169}
170
171pub struct Preview {
172 pub content: Option<PreviewContent>,
173 pub source: Option<PreviewSource>,
174 pub state: PreviewState,
175}
176
177impl Default for Preview {
178 fn default() -> Self {
179 Self {
180 content: None,
181 source: None,
182 state: PreviewState::default(),
183 }
184 }
185}
186
187pub struct RuntimeConfig {
188 pub run: RunConfig,
189 pub search: SearchConfig,
190 pub app_config: LoadedAppConfig,
191}
192
193pub struct SearchSessionState {
194 pub settings: SearchSettings,
195 pub query: Query,
196 pub search: Search,
197}
198
199pub struct PreviewSessionState {
200 pub preview: Preview,
201}
202
203pub struct UiState {
204 pub help: HelpState,
205 pub layout: LayoutState,
206 pub mode: Mode,
207 pub should_quit: bool,
208 pub restart_search: bool,
209 pub(crate) viewport: ViewportMetrics,
210}
211
212pub struct App {
213 pub runtime: RuntimeConfig,
214 pub search_session: SearchSessionState,
215 pub preview_session: PreviewSessionState,
216 pub ui: UiState,
217 selected_output: Vec<SelectionOutput>,
218}
219
220impl App {
221 pub fn from_configs(
222 run_config: RunConfig,
223 search_config: SearchConfig,
224 app_config: LoadedAppConfig,
225 ) -> Self {
226 let log_mode = run_config.log;
227 let direct_diff_mode = run_config.diff.is_some();
228 let search_settings = search_config.settings;
229 let query = if let Some(ref initial) = search_config.query {
230 let len = initial.chars().count();
231 Query {
232 text: initial.clone(),
233 cursor: len,
234 ..Query::default()
235 }
236 } else {
237 Query::default()
238 };
239 Self {
240 runtime: RuntimeConfig {
241 run: run_config,
242 search: search_config,
243 app_config,
244 },
245 search_session: SearchSessionState {
246 settings: search_settings,
247 query,
248 search: Search::default(),
249 },
250 preview_session: PreviewSessionState {
251 preview: Preview::default(),
252 },
253 ui: UiState {
254 help: HelpState::default(),
255 layout: LayoutState::default(),
256 should_quit: false,
257 mode: if log_mode || direct_diff_mode {
258 Mode::Preview
259 } else {
260 Mode::Search
261 },
262 restart_search: false,
263 viewport: ViewportMetrics::default(),
264 },
265 selected_output: Vec::new(),
266 }
267 }
268
269 pub fn apply_action(&mut self, action: AppAction) {
270 match action {
271 AppAction::Quit => self.ui.should_quit = true,
272 AppAction::FocusSearch => self.ui.mode = Mode::Search,
273 AppAction::FocusPreview => self.ui.mode = Mode::Preview,
274 AppAction::ToggleHelp => {
275 self.ui.help.visible = !self.ui.help.visible;
276 if self.ui.help.visible {
277 self.ui.help.tab = if self.ui.mode == Mode::Preview {
278 HelpTab::Preview
279 } else {
280 HelpTab::Overview
281 };
282 }
283 }
284 AppAction::CloseHelp => self.ui.help.visible = false,
285 AppAction::ShowHelpTab(tab) => self.ui.help.tab = tab,
286 AppAction::NextHelpTab => self.ui.help.tab = self.ui.help.tab.next(),
287 AppAction::PreviousHelpTab => self.ui.help.tab = self.ui.help.tab.previous(),
288 AppAction::TogglePreviewFullscreen => {
289 self.ui.layout.preview_fullscreen = !self.ui.layout.preview_fullscreen;
290 if self.ui.layout.preview_fullscreen && self.ui.mode == Mode::Search {
291 self.ui.mode = Mode::Preview;
292 }
293 }
294 AppAction::SwapPanes => {
295 self.ui.layout.panes_swapped = !self.ui.layout.panes_swapped;
296 }
297 AppAction::AdjustPreviewWidth(delta) => {
298 if delta.is_positive() {
299 self.ui.layout.preview_percent =
300 (self.ui.layout.preview_percent + delta as u16).min(80);
301 } else {
302 self.ui.layout.preview_percent = self
303 .ui
304 .layout
305 .preview_percent
306 .saturating_sub(delta.unsigned_abs())
307 .max(20);
308 }
309 }
310 AppAction::ToggleSearchBarPosition => {
311 self.ui.layout.search_bar_at_bottom = !self.ui.layout.search_bar_at_bottom;
312 }
313 AppAction::TogglePreviewVisibility => {
314 self.ui.layout.preview_hidden = !self.ui.layout.preview_hidden;
315 if self.ui.layout.preview_hidden && self.ui.mode == Mode::Preview {
316 self.ui.mode = Mode::Search;
317 }
318 }
319 AppAction::ToggleExactMatcher => {
320 self.search_session.settings.matcher =
321 self.search_session.settings.matcher.toggle();
322 self.ui.restart_search = true;
323 }
324 AppAction::SetSearchMode(mode) => {
325 self.search_session.settings.mode = mode;
326 self.ui.restart_search = true;
327 }
328 AppAction::RequestSearchRestart => self.ui.restart_search = true,
329 }
330 }
331
332 pub fn show_preview(&self) -> bool {
333 if self.runtime.run.diff.is_some() || self.runtime.search.git_search_scope.is_some() {
334 return true;
335 }
336 let naturally_visible = !self.runtime.run.stdin || self.runtime.run.has_preview_command();
337 naturally_visible && !self.ui.layout.preview_hidden
338 }
339
340 pub fn keybindings(&self) -> &Keybindings {
341 &self.runtime.app_config.keybindings
342 }
343
344 pub fn log_max_entries(&self) -> usize {
345 self.runtime.app_config.log.max_entries
346 }
347
348 pub fn is_content_mode(&self) -> bool {
349 self.search_session.settings.mode == SearchMode::Grep
350 || self.search_session.settings.mode == SearchMode::GitHistory
351 }
352
353 pub fn is_git_mode(&self) -> bool {
354 matches!(
355 self.search_session.settings.mode,
356 SearchMode::GitHistory | SearchMode::GitBranches | SearchMode::GitCommits
357 )
358 }
359
360 pub fn is_dir_mode(&self) -> bool {
361 self.search_session.settings.mode == SearchMode::Dirs
362 }
363
364 pub fn is_file_name_mode(&self) -> bool {
365 self.search_session.settings.mode == SearchMode::Files
366 }
367
368 pub fn is_exact_mode(&self) -> bool {
369 self.search_session.settings.matcher == MatcherMode::Exact
370 }
371
372 pub fn search_config(&self) -> SearchConfig {
373 let mut search_config = self
374 .runtime
375 .search
376 .with_settings(self.search_session.settings);
377 search_config.query = Some(self.search_session.query.text.clone());
378 search_config
379 }
380
381 pub fn preview_file_path(&self) -> Option<&str> {
382 self.preview_session
383 .preview
384 .source
385 .as_ref()
386 .and_then(PreviewSource::file_path)
387 }
388
389 pub fn set_selected_output(&mut self, output: Vec<SelectionOutput>) {
390 self.selected_output = output;
391 }
392
393 pub fn take_selected_output(&mut self) -> Vec<SelectionOutput> {
394 std::mem::take(&mut self.selected_output)
395 }
396
397 pub fn set_terminal_area(&mut self, area: Rect) {
398 self.ui.viewport.terminal_width = area.width;
399 self.ui.viewport.terminal_height = area.height;
400 }
401
402 pub fn set_terminal_size(&mut self, width: u16, height: u16) {
403 self.ui.viewport.terminal_width = width;
404 self.ui.viewport.terminal_height = height;
405 }
406
407 pub fn refresh_viewports(&mut self) {
408 let (preview_width, preview_height) = if self.runtime.run.log
409 || self.runtime.run.diff.is_some()
410 || self.runtime.search.git_search_scope.is_some()
411 {
412 (
413 self.ui.viewport.terminal_width,
414 self.ui.viewport.terminal_height,
415 )
416 } else if !self.show_preview() {
417 (0, 0)
418 } else if self.ui.layout.preview_fullscreen {
419 (
420 self.ui.viewport.terminal_width,
421 self.ui.viewport.terminal_height,
422 )
423 } else {
424 (
425 self.ui.viewport.terminal_width * self.ui.layout.preview_percent / 100,
426 self.ui.viewport.terminal_height,
427 )
428 };
429
430 self.ui.viewport.preview_width = preview_width;
431 self.ui.viewport.preview_height = preview_height;
432 }
433
434 pub fn preview_width(&self) -> u16 {
435 self.ui.viewport.preview_width
436 }
437
438 pub fn preview_height(&self) -> u16 {
439 self.ui.viewport.preview_height
440 }
441}
442
443#[cfg(test)]
444mod tests {
445 use super::*;
446 use crate::cli::args::OutputFormat;
447 use crate::config::LoadedAppConfig;
448 use crate::runtime::config::RunConfig;
449
450 fn run_config() -> RunConfig {
451 RunConfig {
452 headless: false,
453 output_format: OutputFormat::Plain,
454 output_file: None,
455 stdin: false,
456 log: false,
457 diff: None,
458 preview_command: None,
459 preview_delimiter: ":".to_string(),
460 split: None,
461 log_files: Vec::new(),
462 }
463 }
464
465 fn search_config() -> SearchConfig {
466 SearchConfig {
467 query: None,
468 locations: vec![],
469 search_pdf: false,
470 no_hidden: false,
471 no_git_ignore: false,
472 no_ignore: false,
473 no_default_ignore_dirs: false,
474 git_search_scope: None,
475 settings: SearchSettings {
476 mode: SearchMode::Path,
477 matcher: MatcherMode::Fuzzy,
478 },
479 }
480 }
481
482 #[test]
483 fn file_mode_starts_in_search_with_preview_visible() {
484 let app = App::from_configs(run_config(), search_config(), LoadedAppConfig::default());
485 assert_eq!(app.ui.mode, Mode::Search);
486 assert!(app.show_preview());
487 assert_eq!(app.search_session.settings.mode, SearchMode::Path);
488 }
489
490 #[test]
491 fn grep_mode_sets_content_state() {
492 let mut search_config = search_config();
493 search_config.settings.mode = SearchMode::Grep;
494
495 let app = App::from_configs(run_config(), search_config, LoadedAppConfig::default());
496 assert!(app.is_content_mode());
497 assert_eq!(app.search_session.settings.mode, SearchMode::Grep);
498 }
499
500 #[test]
501 fn stdin_mode_without_preview_command_hides_preview() {
502 let mut run_config = run_config();
503 run_config.stdin = true;
504
505 let app = App::from_configs(run_config, search_config(), LoadedAppConfig::default());
506 assert!(!app.show_preview());
507 }
508
509 #[test]
510 fn log_mode_starts_in_preview() {
511 let mut run_config = run_config();
512 run_config.log = true;
513
514 let app = App::from_configs(run_config, search_config(), LoadedAppConfig::default());
515 assert_eq!(app.ui.mode, Mode::Preview);
516 }
517
518 #[test]
519 fn direct_diff_mode_starts_in_preview() {
520 let mut run_config = run_config();
521 run_config.diff = Some(["left.txt".into(), "right.txt".into()]);
522
523 let app = App::from_configs(run_config, search_config(), LoadedAppConfig::default());
524 assert_eq!(app.ui.mode, Mode::Preview);
525 assert!(app.show_preview());
526 }
527}