Skip to main content

binocular/ui/search/
results.rs

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    // Strip leading "./" — it wastes space and adds no information.
226    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}