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