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