use crossterm::event::{KeyCode, KeyEvent};
use diffler_core::model::DiffModel;
use super::{App, DiffRow, Flow, Pane, Screen, tree_row_label};
use crate::graph::GraphView;
use crate::search::{Search, find_matches};
impl App {
pub(super) fn search_start(&mut self) {
let origin = self.focused_cursor_row();
let rows = self.focused_search_rows();
let mut search = Search::start(origin);
search.recompute(&rows);
self.search = Some(search);
}
pub(super) fn handle_search_key(&mut self, key: &KeyEvent) -> Flow {
match key.code {
KeyCode::Esc => self.search_cancel(),
KeyCode::Enter => self.search_commit(),
KeyCode::Backspace => self.search_edit(Search::backspace),
KeyCode::Char(c) => self.search_edit(|s| s.insert(c)),
_ => {}
}
Flow::Continue
}
pub(super) fn search_edit(&mut self, edit: impl FnOnce(&mut Search)) {
if let Some(s) = self.search.as_mut() {
edit(s);
}
let rows = self.focused_search_rows();
if let Some(s) = self.search.as_mut() {
s.recompute(&rows);
}
if let Some(row) = self.search.as_ref().and_then(Search::current_row) {
self.focus_searched_row(row);
}
}
pub(super) fn search_step_or_follow(&mut self, forward: bool) {
if self.search.is_none()
&& self.screen() == Screen::Graph
&& let Some(graph) = self.graph.as_mut()
{
graph.follow_edge(forward);
return;
}
self.search_step(forward);
}
pub(super) fn search_step(&mut self, forward: bool) {
let row = match self.search.as_mut() {
Some(s) if !s.open => {
if forward {
s.next_match()
} else {
s.prev_match()
}
}
_ => return,
};
if let Some(row) = row {
self.focus_searched_row(row);
self.open_searched_fold(forward);
}
}
pub(super) fn search_commit(&mut self) {
let Some(search) = self.search.as_mut() else {
return;
};
if search.query().is_empty() {
self.search = None;
return;
}
if let Some(row) = search.commit() {
self.focus_searched_row(row);
self.open_searched_fold(true);
}
}
fn open_searched_fold(&mut self, forward: bool) {
if self.screen() != Screen::Diff {
return;
}
let Some(query) = self.search.as_ref().map(|s| s.query().to_owned()) else {
return;
};
let Some(diff) = self.diff.as_ref().filter(|diff| diff.focus == Pane::Diff) else {
return;
};
let Some(DiffRow::Fold { group, .. }) = diff.rows().get(diff.cursor).copied() else {
return;
};
let model = diff.model_for_rows(&self.review);
let Some(file) = model.files.get(diff.selected) else {
return;
};
let lines = diff.fold_lines(file, group);
let texts: Vec<(usize, String)> = lines
.iter()
.enumerate()
.map(|(index, (_, diff_line))| (index, diff_line.text.clone()))
.collect();
let matched = find_matches(&texts, &query);
let hit = if forward {
matched.first()
} else {
matched.last()
};
let Some(target) = hit.and_then(|m| lines.get(m.row)).map(|(line, _)| *line) else {
return;
};
let Some(at) = self
.diff
.as_mut()
.and_then(|diff| diff.reveal_line(&self.review, target))
else {
return;
};
let rows = self.diff_search_rows();
if let Some(search) = self.search.as_mut() {
search.reseat(&rows, at, forward);
}
}
pub(super) fn search_cancel(&mut self) {
if let Some(origin) = self.search.take().map(|s| s.origin_row()) {
self.focus_searched_row(origin);
}
}
pub(super) fn focused_cursor_row(&self) -> usize {
match self.screen() {
Screen::Status => self.status.cursor,
Screen::Log => self.log.as_ref().map_or(0, |l| l.cursor),
Screen::Diff => self.diff.as_ref().map_or(0, |d| match d.focus {
Pane::List => d.tree_cursor,
Pane::Comments => d.comments_cursor(),
Pane::Diff => d.cursor,
}),
Screen::CiLog => self.ci_log.as_ref().map_or(0, |v| v.cursor),
Screen::File => self.file.as_ref().map_or(0, |v| v.cursor),
Screen::Runs => self.runs_cursor,
Screen::Prs => self.prs_cursor,
Screen::Graph => self.graph.as_ref().map_or(0, GraphView::selected_index),
Screen::Stats => self.stats.as_ref().map_or(0, |view| view.cursor),
}
}
pub(super) fn focused_search_rows(&self) -> Vec<(usize, String)> {
match self.screen() {
Screen::Status => self.status_search_rows(),
Screen::Log => self.log.as_ref().map_or_else(Vec::new, |log| {
log.entries
.iter()
.enumerate()
.map(|(i, e)| (i, e.subject.clone()))
.collect()
}),
Screen::Diff => self.diff_search_rows(),
Screen::Runs => self
.runs
.iter()
.enumerate()
.map(|(i, run)| (i, format!("{} {}", run.name, run.title)))
.collect(),
Screen::Prs => self
.prs
.iter()
.enumerate()
.map(|(i, pr)| (i, format!("#{} {} {}", pr.number, pr.title, pr.author)))
.collect(),
Screen::CiLog => self.ci_log.as_ref().map_or_else(Vec::new, |view| {
view.rows()
.iter()
.enumerate()
.map(|(i, row)| (i, view.line_text(*row).to_owned()))
.collect()
}),
Screen::File => self.file.as_ref().map_or_else(Vec::new, |view| {
view.lines.iter().cloned().enumerate().collect()
}),
Screen::Graph => self
.graph
.as_ref()
.map_or_else(Vec::new, GraphView::search_rows),
Screen::Stats => Vec::new(),
}
}
pub(super) fn diff_search_rows(&self) -> Vec<(usize, String)> {
let Some(diff) = self.diff.as_ref() else {
return Vec::new();
};
let model_cow = diff.model_for_rows(&self.review);
let model: &DiffModel = &model_cow;
match diff.focus {
Pane::List => diff
.tree_rows(model, self.review.session_for(&diff.source))
.iter()
.enumerate()
.map(|(i, r)| (i, tree_row_label(&r.node)))
.collect(),
Pane::Comments => {
let session = self.review.session_for(&diff.source);
self.comment_rows()
.into_iter()
.enumerate()
.filter_map(|(index, row)| match row {
crate::app::CommentPaneRow::Header { label, .. } => Some((index, label)),
crate::app::CommentPaneRow::Item { id, .. } => {
let comment = session.comment(&id)?;
let line = comment
.anchor
.line
.map_or(String::new(), |line| format!(":{line}"));
Some((
index,
format!("{}{line} {}", comment.anchor.file, comment.body),
))
}
})
.collect()
}
Pane::Diff => {
let file = model.files.get(diff.selected);
diff.rows()
.iter()
.enumerate()
.filter_map(|(i, row)| diff.row_search_text(file, row).map(|text| (i, text)))
.collect()
}
}
}
pub(super) fn focus_searched_row(&mut self, row: usize) {
match self.screen() {
Screen::Status => self.status.cursor = row,
Screen::Runs => self.runs_cursor = row,
Screen::Prs => self.prs_cursor = row,
Screen::Log => {
if let Some(l) = self.log.as_mut() {
l.cursor = row;
}
}
Screen::Diff => match self.diff.as_ref().map(|d| d.focus) {
Some(Pane::List) => self.diff_tree_to(row),
Some(Pane::Comments) => self.comments_to(row),
Some(Pane::Diff) => {
if let Some(d) = self.diff.as_mut() {
d.cursor = row;
}
}
None => {}
},
Screen::CiLog => {
if let Some(v) = self.ci_log.as_mut() {
v.cursor = row;
}
}
Screen::File => {
if let Some(v) = self.file.as_mut() {
v.cursor = row;
}
}
Screen::Graph => {
if let Some(g) = self.graph.as_mut() {
g.select_nth(row);
}
}
Screen::Stats => {
if let Some(v) = self.stats.as_mut() {
v.cursor = row;
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::LoadedConfig;
use crate::test_support::{Fixture, esc_key, key, standard_fixture};
fn app() -> (Fixture, App) {
let fixture = standard_fixture();
let app = App::new(fixture.review(), LoadedConfig::default());
(fixture, app)
}
#[test]
fn slash_search_filters_status_rows_and_moves_the_cursor_live() {
let (_fixture, mut app) = app();
app.handle(key('/'));
assert!(app.search.as_ref().expect("search open").open);
for c in "lib".chars() {
app.handle(key(c));
}
assert_eq!(app.search.as_ref().unwrap().query(), "lib");
assert_eq!(
app.status.cursor, 3,
"typing narrows the match onto src/lib.rs live"
);
}
#[test]
fn enter_commits_the_status_search_and_seats_the_cursor_on_the_match() {
let (_fixture, mut app) = app();
app.handle(key('/'));
for c in "ci.yml".chars() {
app.handle(key(c));
}
app.handle(key('\n'));
assert!(
!app.search.as_ref().unwrap().open,
"the prompt closes on commit"
);
assert_eq!(app.status.cursor, 5, "cursor lands on the matched row");
}
#[test]
fn escape_cancels_the_status_search_and_restores_the_origin_cursor() {
let (_fixture, mut app) = app();
app.status.cursor = 2; app.handle(key('/'));
for c in "ci.yml".chars() {
app.handle(key(c));
}
assert_eq!(app.status.cursor, 5, "typing moved off the origin");
app.handle(esc_key());
assert!(app.search.is_none());
assert_eq!(app.status.cursor, 2, "escape restores the origin row");
}
#[test]
fn slash_search_filters_diff_list_rows_and_moves_the_tree_cursor() {
let (_fixture, mut app) = app();
app.open_working_tree_diff(None);
assert_eq!(app.screen(), Screen::Diff);
let target = app
.diff_search_rows()
.into_iter()
.find(|(_, text)| text.contains("lib.rs"))
.map(|(row, _)| row)
.expect("src/lib.rs is in the diff file list");
app.handle(key('/'));
for c in "lib".chars() {
app.handle(key(c));
}
assert_eq!(app.diff.as_ref().unwrap().tree_cursor, target);
assert_eq!(
app.diff.as_ref().and_then(|d| d.selected_path(&app.review)),
Some("src/lib.rs".to_owned()),
"landing on a file row opens it, as moving onto it would"
);
}
#[test]
fn search_in_the_comments_sidebar_walks_comments_and_selects_them() {
let (_fixture, mut app) = app();
app.open_working_tree_diff(None);
for (line, body) in [(1, "first note"), (2, "the widget leaks")] {
app.review.session.add_comment(
diffler_core::session::Anchor {
file: "src/lib.rs".to_owned(),
line: Some(line),
line_end: None,
on_old_side: false,
line_text: None,
},
"reviewer",
body,
);
}
app.handle(key('C'));
assert_eq!(app.diff.as_ref().unwrap().focus, Pane::Comments);
app.handle(key('/'));
for c in "widget".chars() {
app.handle(key(c));
}
let diff = app.diff.as_ref().expect("diff");
assert_eq!(diff.comments_cursor(), 1, "the matching comment is current");
assert!(
matches!(
diff.rows().get(diff.cursor),
Some(crate::app::DiffRow::Comment { line: 0, .. })
),
"and the diff cursor sits on it, so the pane verbs reach it"
);
}
#[test]
fn enter_commits_the_diff_search_and_seats_the_cursor_on_the_match() {
let (_fixture, mut app) = app();
app.open_working_tree_diff(None);
let target = app
.diff_search_rows()
.into_iter()
.find(|(_, text)| text.contains("lib.rs"))
.map(|(row, _)| row)
.expect("src/lib.rs is in the diff file list");
app.handle(key('/'));
for c in "lib".chars() {
app.handle(key(c));
}
app.handle(key('\n'));
assert!(!app.search.as_ref().unwrap().open);
assert_eq!(app.diff.as_ref().unwrap().tree_cursor, target);
}
#[test]
fn escape_cancels_the_diff_search_and_restores_the_origin_cursor() {
let (_fixture, mut app) = app();
app.open_working_tree_diff(None);
let origin = app.diff.as_ref().unwrap().tree_cursor;
app.handle(key('/'));
for c in "lib".chars() {
app.handle(key(c));
}
app.handle(esc_key());
assert!(app.search.is_none());
assert_eq!(
app.diff.as_ref().unwrap().tree_cursor,
origin,
"escape restores the origin row"
);
}
}