1use crate::app::{InputMode, Mode};
2use crate::search::sources::git::{is_current_commit, HISTORY_PATH_SEPARATOR};
3use crate::search::types::{SearchItem, SearchResult};
4use crate::text::truncate_str_chars;
5use crate::ui::search::search_border_style;
6use crate::ui::shortcuts::{render_hints_line, search_results_hints};
7use ratatui::{
8 layout::Rect,
9 style::{Color, Modifier, Style},
10 text::{Line, Span},
11 widgets::{Block, BorderType, Borders, HighlightSpacing, List, ListItem, ListState},
12 Frame,
13};
14use std::borrow::Cow;
15use std::collections::HashMap;
16
17pub struct SearchResultsView<'a> {
18 pub app_mode: Mode,
19 pub query_mode: InputMode,
20 pub show_preview: bool,
21 pub is_content_mode: bool,
22 pub stdin_mode: bool,
23 pub query_is_empty: bool,
24 pub total_matches: u64,
25 pub total_items: u64,
26 pub working: bool,
27 pub marked_count: usize,
28 pub diff_marked_count: usize,
29 pub results: &'a [SearchResult],
30 pub marked_items: &'a HashMap<SearchItem, Option<usize>>,
31 pub diff_marked_items: &'a std::collections::HashSet<SearchItem>,
32}
33
34pub fn render_search_results(
35 f: &mut Frame,
36 view: &SearchResultsView<'_>,
37 scroll_state: &mut ListState,
38 area: Rect,
39) {
40 let visible_rows = area.height.saturating_sub(2) as usize;
41 let (start, end, selected) = visible_result_range(scroll_state, view.results.len(), visible_rows);
42 let items: Vec<ListItem> = view
43 .results
44 .get(start..end)
45 .unwrap_or(&[])
46 .iter()
47 .map(|result| build_result_item(result, view))
48 .collect();
49
50 let border_style = search_border_style(view.app_mode, view.query_mode);
51
52 let hints = render_hints_line(search_results_hints(
53 view.app_mode,
54 view.query_mode,
55 view.show_preview,
56 ));
57
58 let is_filtering = !view.query_is_empty;
59 let count_badge = build_count_badge(
60 view.total_matches,
61 view.total_items,
62 view.marked_count,
63 view.diff_marked_count,
64 view.working,
65 is_filtering,
66 );
67
68 let list = List::new(items)
69 .block(
70 Block::default()
71 .borders(Borders::ALL)
72 .border_type(BorderType::Rounded)
73 .title(
74 Line::from(vec![
75 Span::raw(" "),
76 Span::styled("Results", Style::default().add_modifier(Modifier::BOLD)),
77 Span::raw(" "),
78 ])
79 .centered(),
80 )
81 .title_top(count_badge.right_aligned())
82 .title_bottom(hints.right_aligned())
83 .border_style(border_style),
84 )
85 .style(Style::default().bg(Color::Reset))
86 .highlight_style(Style::default().bg(Color::DarkGray))
87 .highlight_symbol("▌ ")
88 .highlight_spacing(HighlightSpacing::Always);
89
90 let mut visible_state = ListState::default().with_selected(selected);
91 f.render_stateful_widget(list, area, &mut visible_state);
92}
93
94fn visible_result_range(
95 scroll_state: &mut ListState,
96 result_count: usize,
97 visible_rows: usize,
98) -> (usize, usize, Option<usize>) {
99 if result_count == 0 || visible_rows == 0 {
100 *scroll_state.offset_mut() = 0;
101 return (0, 0, None);
102 }
103
104 let selected = scroll_state.selected().unwrap_or(0).min(result_count - 1);
105 let max_offset = result_count.saturating_sub(visible_rows);
106 let mut offset = scroll_state.offset().min(max_offset);
107
108 if selected < offset {
109 offset = selected;
110 } else if selected >= offset + visible_rows {
111 offset = selected + 1 - visible_rows;
112 }
113
114 *scroll_state.offset_mut() = offset;
115 let end = (offset + visible_rows).min(result_count);
116
117 (offset, end, Some(selected - offset))
118}
119
120fn build_count_badge(
121 total: u64,
122 total_items: u64,
123 marked: usize,
124 diff_marked: usize,
125 working: bool,
126 _is_filtering: bool,
127) -> Line<'static> {
128 let bold = Style::default().add_modifier(Modifier::BOLD);
129 let mut spans = Vec::new();
130
131 spans.push(Span::raw(" "));
132 spans.push(Span::styled("[", bold));
133
134 if working {
135 const FRAMES: [&str; 6] = ["◜", "◠", "◝", "◞", "◡", "◟"];
136 let ms = std::time::SystemTime::now()
137 .duration_since(std::time::UNIX_EPOCH)
138 .unwrap_or_default()
139 .as_millis();
140 let frame = FRAMES[(ms / 120) as usize % FRAMES.len()];
141 spans.push(Span::styled(
142 format!("{} ", frame),
143 Style::default().fg(Color::Blue),
144 ));
145 }
146
147 spans.push(Span::styled(format!("{}/{}", total, total_items), bold));
148
149 if marked > 0 {
150 spans.push(Span::styled(
151 format!(" {}◆", marked),
152 Style::default()
153 .fg(Color::Green)
154 .add_modifier(Modifier::BOLD),
155 ));
156 }
157
158 if diff_marked > 0 {
159 spans.push(Span::styled(
160 format!(" {}◈", diff_marked),
161 Style::default()
162 .fg(Color::Yellow)
163 .add_modifier(Modifier::BOLD),
164 ));
165 }
166 spans.push(Span::styled("] ", bold));
167
168 Line::from(spans)
169}
170
171pub(crate) fn build_result_item(
172 result: &SearchResult,
173 view: &SearchResultsView<'_>,
174) -> ListItem<'static> {
175 if let SearchItem::GitBranch {
176 branch, is_head, ..
177 } = &result.item
178 {
179 let spans = build_git_branch_item_spans(branch, *is_head, &result.indices);
180 return build_list_item(
181 spans,
182 view.marked_items.contains_key(&result.item),
183 view.diff_marked_items.contains(&result.item),
184 );
185 }
186
187 if let SearchItem::GitCommit {
188 short_commit,
189 subject,
190 author,
191 date,
192 refs,
193 ..
194 } = &result.item
195 {
196 let spans =
197 build_git_commit_item_spans(short_commit, refs, subject, date, author, &result.indices);
198 return build_list_item(
199 spans,
200 view.marked_items.contains_key(&result.item),
201 view.diff_marked_items.contains(&result.item),
202 );
203 }
204
205 if let SearchItem::GitHistory {
206 commit,
207 path,
208 line,
209 text,
210 } = &result.item
211 {
212 let spans = build_git_history_item_spans(commit, path, *line, text, &result.indices);
213 return build_list_item(
214 spans,
215 view.marked_items.contains_key(&result.item),
216 view.diff_marked_items.contains(&result.item),
217 );
218 }
219
220 let original_text = result.item.display_text();
221 let original_text = original_text.as_ref();
222 let is_marked = view.marked_items.contains_key(&result.item);
223 let is_diff_marked = view.diff_marked_items.contains(&result.item);
224
225 let (base_text, base_indices): (&str, Cow<[u32]>) = if original_text.starts_with("./") {
227 let shifted: Vec<u32> = result
228 .indices
229 .iter()
230 .filter(|&&i| i >= 2)
231 .map(|&i| i - 2)
232 .collect();
233 (&original_text[2..], Cow::Owned(shifted))
234 } else {
235 (original_text, Cow::Borrowed(&result.indices))
236 };
237
238 let (display_text, adjusted_indices) =
239 insert_column_if_needed(base_text, &base_indices, result.column);
240
241 let spans = if view.is_content_mode && !view.stdin_mode {
242 build_grep_spans(&display_text, adjusted_indices.as_ref())
243 } else {
244 build_path_spans(&display_text, adjusted_indices.as_ref())
245 };
246
247 build_list_item(spans, is_marked, is_diff_marked)
248}
249
250fn build_list_item(
251 spans: Vec<Span<'static>>,
252 is_marked: bool,
253 is_diff_marked: bool,
254) -> ListItem<'static> {
255 if is_marked || is_diff_marked {
256 let mut marked_spans = Vec::new();
257 if is_marked {
258 marked_spans.push(Span::styled("◆ ", Style::default().fg(Color::Green)));
259 }
260 if is_diff_marked {
261 marked_spans.push(Span::styled("◈ ", Style::default().fg(Color::Yellow)));
262 }
263 marked_spans.extend(spans);
264 ListItem::new(Line::from(marked_spans))
265 } else {
266 ListItem::new(Line::from(spans))
267 }
268}
269
270struct GrepParts<'a> {
271 path: &'a str,
272 line: &'a str,
273 col: Option<&'a str>,
274 content: &'a str,
275}
276
277fn parse_grep_display(text: &str) -> Option<GrepParts<'_>> {
278 let first_colon = text.find(':')?;
279 let path = &text[..first_colon];
280 let after_path = &text[first_colon + 1..];
281
282 let second_colon_rel = after_path.find(':')?;
283 let line = &after_path[..second_colon_rel];
284 if line.is_empty() || !line.chars().all(|c| c.is_ascii_digit()) {
285 return None;
286 }
287 let after_line = &after_path[second_colon_rel + 1..];
288
289 if let Some(third_colon_rel) = after_line.find(':') {
290 let maybe_col = &after_line[..third_colon_rel];
291 if !maybe_col.is_empty() && maybe_col.chars().all(|c| c.is_ascii_digit()) {
292 return Some(GrepParts {
293 path,
294 line,
295 col: Some(maybe_col),
296 content: &after_line[third_colon_rel + 1..],
297 });
298 }
299 }
300
301 Some(GrepParts {
302 path,
303 line,
304 col: None,
305 content: after_line,
306 })
307}
308
309const MAX_CONTENT_DISPLAY_CHARS: usize = 500;
310
311fn build_grep_spans(text: &str, indices: &[u32]) -> Vec<Span<'static>> {
312 let Some(parts) = parse_grep_display(text) else {
313 return build_path_spans(text, indices);
314 };
315
316 let sep = Style::default().fg(Color::DarkGray);
317 let path = Style::default().fg(Color::Blue);
318 let line = Style::default().fg(Color::Yellow);
319 let col = Style::default().fg(Color::DarkGray);
320 let content_style = Style::default();
321
322 let content_trimmed = parts.content.trim_end_matches(['\n', '\r']);
323
324 let (content_display, was_truncated) =
325 truncate_str_chars(content_trimmed, MAX_CONTENT_DISPLAY_CHARS);
326
327 let mut segments: Vec<(&str, Style)> = vec![
328 (parts.path, path),
329 (":", sep),
330 (parts.line, line),
331 (":", sep),
332 ];
333 if let Some(c) = parts.col {
334 segments.push((c, col));
335 segments.push((":", sep));
336 }
337 segments.push((content_display, content_style));
338 if was_truncated {
339 segments.push(("…", Style::default().fg(Color::DarkGray)));
340 }
341
342 colored_spans(&segments, indices)
343}
344
345pub(crate) fn build_git_history_item_spans(
346 commit: &str,
347 path: &str,
348 line: usize,
349 content: &str,
350 indices: &[u32],
351) -> Vec<Span<'static>> {
352 let commit_style = Style::default().fg(Color::Magenta);
353 let current_commit_style = Style::default()
354 .fg(Color::Green)
355 .add_modifier(Modifier::BOLD);
356 let path_style = Style::default().fg(Color::Blue);
357 let line_style = Style::default().fg(Color::Yellow);
358 let sep_style = Style::default().fg(Color::DarkGray);
359 let content_style = Style::default();
360 let line_string = line.to_string();
361 let display_path = path.replace(HISTORY_PATH_SEPARATOR, "/");
362 let (content_display, was_truncated) = truncate_str_chars(content, MAX_CONTENT_DISPLAY_CHARS);
363 let commit_style = if is_current_commit(commit) {
364 current_commit_style
365 } else {
366 commit_style
367 };
368
369 let mut segments: Vec<(&str, Style)> = vec![
370 (commit, commit_style),
371 (": ", sep_style),
372 (&display_path, path_style),
373 (":", sep_style),
374 (&line_string, line_style),
375 (":", sep_style),
376 (content_display, content_style),
377 ];
378 if was_truncated {
379 segments.push(("…", sep_style));
380 }
381
382 colored_spans(&segments, indices)
383}
384
385fn build_git_branch_item_spans(branch: &str, is_head: bool, indices: &[u32]) -> Vec<Span<'static>> {
386 let style = if is_head {
387 Style::default()
388 .fg(Color::Green)
389 .add_modifier(Modifier::BOLD)
390 } else {
391 Style::default().fg(Color::Blue)
392 };
393 colored_spans(&[(branch, style)], indices)
394}
395
396fn build_git_commit_item_spans(
397 short_commit: &str,
398 refs: &str,
399 subject: &str,
400 date: &str,
401 author: &str,
402 indices: &[u32],
403) -> Vec<Span<'static>> {
404 let short_hash = truncate_commit_hash(short_commit);
405 let refs = refs.trim();
406 let hash_style = Style::default().fg(Color::DarkGray);
407 let ref_style = Style::default().fg(Color::Green);
408 let subject_style = Style::default();
409 let meta_style = Style::default().fg(Color::DarkGray);
410
411 let mut segments: Vec<(&str, Style)> = vec![(&short_hash, hash_style), (" - ", meta_style)];
412 if !refs.is_empty() {
413 segments.push(("(", meta_style));
414 segments.push((refs, ref_style));
415 segments.push((") ", meta_style));
416 }
417 segments.push((subject, subject_style));
418 segments.push((" (", meta_style));
419 segments.push((date, meta_style));
420 segments.push((") <", meta_style));
421 segments.push((author, meta_style));
422 segments.push((">", meta_style));
423 colored_spans(&segments, indices)
424}
425
426fn truncate_commit_hash(hash: &str) -> String {
427 let short: String = hash.chars().take(5).collect();
428 format!("[{short}]")
429}
430
431fn build_path_spans(text: &str, indices: &[u32]) -> Vec<Span<'static>> {
432 let dir_style = Style::default().fg(Color::Gray);
433 let file_style = Style::default().fg(Color::Blue);
434
435 let last_sep = text.rfind('/').or_else(|| text.rfind('\\'));
436 let segments: Vec<(&str, Style)> = if let Some(pos) = last_sep {
437 vec![
438 (&text[..pos + 1], dir_style),
439 (&text[pos + 1..], file_style),
440 ]
441 } else {
442 vec![(text, file_style)]
443 };
444
445 colored_spans(&segments, indices)
446}
447
448fn colored_spans(segments: &[(&str, Style)], match_chars: &[u32]) -> Vec<Span<'static>> {
449 let highlight = Style::default()
450 .fg(Color::Cyan)
451 .add_modifier(Modifier::BOLD);
452
453 let mut spans: Vec<Span<'static>> = Vec::new();
454 let mut global_char = 0usize;
455 let mut match_idx = 0usize;
456
457 let is_match = |char_idx: usize, match_idx: &mut usize| -> bool {
458 while *match_idx < match_chars.len() && (match_chars[*match_idx] as usize) < char_idx {
459 *match_idx += 1;
460 }
461 *match_idx < match_chars.len() && match_chars[*match_idx] as usize == char_idx
462 };
463
464 for &(seg_text, base_style) in segments {
465 if seg_text.is_empty() {
466 continue;
467 }
468
469 let chars: Vec<(usize, char)> = seg_text.char_indices().collect();
470 let mut span_start_byte = 0usize;
471 let mut cur_style = if is_match(global_char, &mut match_idx) {
472 highlight
473 } else {
474 base_style
475 };
476
477 for (local_idx, &(byte_pos, _ch)) in chars.iter().enumerate() {
478 let eff = if is_match(global_char + local_idx, &mut match_idx) {
479 highlight
480 } else {
481 base_style
482 };
483
484 if eff != cur_style {
485 let text = seg_text[span_start_byte..byte_pos].to_string();
486 if !text.is_empty() {
487 spans.push(Span::styled(text, cur_style));
488 }
489 span_start_byte = byte_pos;
490 cur_style = eff;
491 }
492 }
493
494 let tail = seg_text[span_start_byte..].to_string();
495 if !tail.is_empty() {
496 spans.push(Span::styled(tail, cur_style));
497 }
498
499 global_char += chars.len();
500 }
501
502 spans
503}
504
505fn insert_column_if_needed<'a>(
506 original_text: &str,
507 indices: &'a [u32],
508 column: Option<usize>,
509) -> (String, Cow<'a, [u32]>) {
510 let Some(col) = column else {
511 return (original_text.to_string(), Cow::Borrowed(indices));
512 };
513
514 let Some(insert_pos) = grep_content_prefix_end(original_text) else {
515 return (original_text.to_string(), Cow::Borrowed(indices));
516 };
517
518 let (prefix, suffix) = original_text.split_at(insert_pos);
519 let col_str = format!("{}:", col);
520 let new_text = format!("{}{}{}", prefix, col_str, suffix);
521
522 let insert_char_pos = original_text[..insert_pos].chars().count();
523 let shift = col_str.chars().count() as u32;
524
525 let new_indices = indices
526 .iter()
527 .map(|&idx| {
528 if idx as usize >= insert_char_pos {
529 idx + shift
530 } else {
531 idx
532 }
533 })
534 .collect();
535
536 (new_text, Cow::Owned(new_indices))
537}
538
539fn grep_content_prefix_end(text: &str) -> Option<usize> {
540 let bytes = text.as_bytes();
541 let mut first_colon = None;
542
543 for (i, &b) in bytes.iter().enumerate() {
544 if b != b':' {
545 continue;
546 }
547
548 if let Some(start) = first_colon {
549 if i > start + 1 {
550 let potential_num = &text[start + 1..i];
551 if potential_num.chars().all(|c| c.is_ascii_digit()) {
552 return Some(i + 1);
553 }
554 }
555 first_colon = Some(i);
556 } else {
557 first_colon = Some(i);
558 }
559 }
560
561 None
562}
563
564#[cfg(test)]
565mod tests {
566 use super::*;
567
568 #[test]
569 fn current_git_history_hash_uses_distinct_style() {
570 let spans = build_git_history_item_spans("HEAD", "Architecture.md", 12, "hello world", &[]);
571 assert_eq!(spans[0].content, "HEAD");
572 assert_eq!(spans[0].style.fg, Some(Color::Green));
573 assert!(spans[0].style.add_modifier.contains(Modifier::BOLD));
574 }
575
576 #[test]
577 fn git_branch_results_show_only_branch_name() {
578 let spans = build_git_branch_item_spans("feature/test", false, &[]);
579 let rendered = spans
580 .iter()
581 .map(|span| span.content.as_ref())
582 .collect::<String>();
583 assert_eq!(rendered, "feature/test");
584 }
585
586 #[test]
587 fn git_commit_results_use_short_bracketed_hash_and_metadata() {
588 let spans = build_git_commit_item_spans(
589 "abcdef1234",
590 "main, tag: v1.0",
591 "improve preview rendering",
592 "2 days ago",
593 "jpcrs",
594 &[],
595 );
596 let rendered = spans
597 .iter()
598 .map(|span| span.content.as_ref())
599 .collect::<String>();
600 assert_eq!(
601 rendered,
602 "[abcde] - (main, tag: v1.0) improve preview rendering (2 days ago) <jpcrs>"
603 );
604 }
605
606 #[test]
607 fn visible_result_range_keeps_selected_row_in_view() {
608 let mut state = ListState::default().with_selected(Some(15)).with_offset(0);
609
610 let (start, end, selected) = visible_result_range(&mut state, 100, 8);
611
612 assert_eq!((start, end, selected), (8, 16, Some(7)));
613 assert_eq!(state.offset(), 8);
614 }
615
616 #[test]
617 fn visible_result_range_clamps_offset_when_results_shrink() {
618 let mut state = ListState::default().with_selected(Some(2)).with_offset(50);
619
620 let (start, end, selected) = visible_result_range(&mut state, 5, 10);
621
622 assert_eq!((start, end, selected), (0, 5, Some(2)));
623 assert_eq!(state.offset(), 0);
624 }
625}