use ratatui::style::Modifier;
use ratatui::text::{Line, Span};
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SearchKind {
FileFind,
Grep,
}
impl SearchKind {
pub fn title(self) -> &'static str {
match self {
SearchKind::FileFind => " find file ",
SearchKind::Grep => " grep ",
}
}
pub fn slug(self) -> &'static str {
match self {
SearchKind::FileFind => "find",
SearchKind::Grep => "grep",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Scope {
Repo,
Org,
Global,
}
impl Scope {
pub fn stored(self) -> &'static str {
match self {
Scope::Repo => "repo",
Scope::Org => "org",
Scope::Global => "global",
}
}
pub fn from_stored(s: &str) -> Option<Scope> {
match s {
"repo" => Some(Scope::Repo),
"org" => Some(Scope::Org),
"global" => Some(Scope::Global),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RawHit {
pub repo: String,
pub path: String,
pub sha: String,
pub branch: String,
pub line: u32,
pub preview: Vec<(u32, String)>,
pub match_count: u32,
pub stale: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SearchHit {
pub repo: String,
pub path: String,
pub sha: String,
pub branch: String,
pub line: u32,
pub preview: Vec<(u32, Line<'static>)>,
pub match_count: u32,
pub body: String,
pub stale: bool,
pub unlocatable: bool,
}
impl SearchHit {
pub fn plain(
repo: &str,
path: &str,
line: u32,
preview: Vec<(u32, String)>,
match_count: u32,
body: String,
) -> Self {
SearchHit {
repo: repo.into(),
path: path.into(),
sha: String::new(),
branch: "main".into(),
line,
preview: preview
.into_iter()
.map(|(no, text)| (no, Line::from(Span::raw(text))))
.collect(),
match_count,
body,
stale: false,
unlocatable: false,
}
}
pub fn from_raw(raw: RawHit) -> Self {
let mut hit = SearchHit::plain(
&raw.repo,
&raw.path,
raw.line,
raw.preview,
raw.match_count,
String::new(),
);
hit.sha = raw.sha;
hit.branch = raw.branch;
hit.stale = raw.stale;
hit
}
pub fn merge(&mut self, later: SearchHit) {
self.sha = later.sha;
self.branch = later.branch;
if later.line > 0 && (self.line == 0 || later.line < self.line) {
self.line = later.line;
}
self.match_count = self.match_count.saturating_add(later.match_count);
self.preview.extend(later.preview);
self.preview.sort_by_key(|(no, _)| *no);
self.preview.dedup_by_key(|(no, _)| *no);
self.stale &= later.stale;
self.unlocatable &= later.unlocatable;
if later.body.len() > self.body.len() {
self.body = later.body;
}
}
pub fn preview_text(&self) -> String {
self.preview
.iter()
.map(|(_, line)| {
line.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
pub fn with_highlighted(self, lines: Vec<Line<'static>>) -> Self {
let preview = self
.preview
.into_iter()
.zip(lines)
.map(|((no, _), line)| (no, line))
.collect();
SearchHit { preview, ..self }
}
}
pub(super) fn line_text(line: &Line) -> String {
line.spans.iter().map(|s| s.content.as_ref()).collect()
}
pub fn highlight_matches(
hits: &mut [SearchHit],
query: &str,
match_bg: ratatui::style::Color,
match_fg: ratatui::style::Color,
) {
let needle = query.to_lowercase();
if needle.is_empty() {
return;
}
for hit in hits {
for (_, line) in &mut hit.preview {
chip_line(line, &needle, match_bg, match_fg);
}
}
}
pub fn chip_line(
line: &mut Line<'static>,
needle: &str,
bg: ratatui::style::Color,
fg: ratatui::style::Color,
) {
if needle.is_empty() || line.spans.is_empty() {
return;
}
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
let lower = text.to_lowercase();
if lower.len() != text.len() {
return;
}
let mut matches: Vec<(usize, usize)> = Vec::new();
let mut rest = lower.as_str();
let mut at = 0;
while let Some(pos) = rest.find(needle) {
matches.push((at + pos, at + pos + needle.len()));
at += pos + needle.len();
rest = &lower[at..];
}
if matches.is_empty() {
return;
}
let chip = ratatui::style::Style::default()
.fg(fg)
.bg(bg)
.add_modifier(Modifier::BOLD);
let mut spans: Vec<Span<'static>> = Vec::new();
let mut offset = 0usize;
for span in std::mem::take(&mut line.spans) {
let content = span.content;
let len = content.len();
let (start, end) = (offset, offset + len);
let mut piece_start = start;
for (m0, m1) in &matches {
let (m0, m1) = (*m0, *m1);
if m1 <= piece_start || m0 >= end {
continue;
}
let cut_lo = m0.max(piece_start);
if cut_lo > piece_start {
spans.push(Span::styled(
content[piece_start - start..cut_lo - start].to_string(),
span.style,
));
}
let cut_hi = m1.min(end);
spans.push(Span::styled(
content[cut_lo - start..cut_hi - start].to_string(),
chip,
));
piece_start = cut_hi;
}
if piece_start < end {
spans.push(Span::styled(
content[piece_start - start..].to_string(),
span.style,
));
}
offset = end;
}
line.spans = spans;
}
#[cfg(test)]
mod tests {
use super::*;
use crate::components::global_search::mock;
use ratatui::style::Color;
#[test]
fn highlight_matches_chips_query_spans() {
let mut hits = mock::hits(SearchKind::Grep, "query", "");
highlight_matches(&mut hits, "query", Color::Yellow, Color::Black);
let hit = hits.iter().find(|h| h.path == "src/terminal.rs").unwrap();
let line = &hit.preview[2].1;
let chipped: Vec<&Span> = line
.spans
.iter()
.filter(|s| s.style.bg == Some(Color::Yellow))
.collect();
assert_eq!(chipped.len(), 2);
assert!(chipped.iter().all(|s| s.content.as_ref() == "query"));
}
#[test]
fn chip_line_splits_at_match_edges_across_spans() {
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
let mut line = Line::from(vec![
Span::styled("// comm".to_string(), Style::default().fg(Color::Green)),
Span::styled(
"ent struct x".to_string(),
Style::default().fg(Color::Green),
),
]);
chip_line(&mut line, "struct", Color::Yellow, Color::Black);
let joined: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(joined, "// comment struct x", "text unchanged");
let chipped: Vec<&Span> = line
.spans
.iter()
.filter(|s| s.style.bg == Some(Color::Yellow))
.collect();
assert_eq!(chipped.len(), 1, "one chip: {:?}", line.spans);
assert_eq!(chipped[0].content, "struct");
assert!(chipped[0].style.add_modifier.contains(Modifier::BOLD));
assert!(
line.spans
.iter()
.all(|s| { s.style.bg == Some(Color::Yellow) || s.style.fg == Some(Color::Green) })
);
let mut line = Line::from(vec![
Span::styled("// comm".to_string(), Style::default().fg(Color::Green)),
Span::styled(
"ent struct x".to_string(),
Style::default().fg(Color::Green),
),
]);
chip_line(&mut line, "ent str", Color::Yellow, Color::Black);
let chipped: Vec<&Span> = line
.spans
.iter()
.filter(|s| s.style.bg == Some(Color::Yellow))
.collect();
assert_eq!(
chipped.len(),
1,
"straddling needle still chips: {:?}",
line.spans
);
assert_eq!(chipped[0].content, "ent str");
let mut line = Line::from(vec![Span::raw("plain".to_string())]);
chip_line(&mut line, "zzz", Color::Yellow, Color::Black);
assert_eq!(line.spans.len(), 1);
assert_eq!(line.spans[0].content, "plain");
}
}