use super::*;
use regex::RegexBuilder;
impl App {
pub fn file_passes_filter(&self, file: &DiffFile) -> bool {
let path = file.display_path().to_string_lossy().to_string();
if let Some(include) = &self.file_filter.include
&& !include.regex.is_match(&path)
{
return false;
}
if let Some(exclude) = &self.file_filter.exclude
&& exclude.regex.is_match(&path)
{
return false;
}
true
}
pub fn file_idx_passes_filter(&self, file_idx: usize) -> bool {
self.diff_files
.get(file_idx)
.is_none_or(|file| self.file_passes_filter(file))
}
pub fn file_filter_active(&self) -> bool {
self.file_filter.include.is_some() || self.file_filter.exclude.is_some()
}
pub fn filtered_file_indices(&self) -> Vec<usize> {
self.diff_files
.iter()
.enumerate()
.filter(|(_, file)| self.file_passes_filter(file))
.map(|(idx, _)| idx)
.collect()
}
pub fn begin_file_tree_prompt(&mut self, prompt: FileTreePrompt) {
let buffer = match prompt {
FileTreePrompt::Include => self
.file_filter
.include
.as_ref()
.map(|p| p.source.clone())
.unwrap_or_default(),
FileTreePrompt::Exclude => self
.file_filter
.exclude
.as_ref()
.map(|p| p.source.clone())
.unwrap_or_default(),
FileTreePrompt::Search => self.file_filter.search.clone().unwrap_or_default(),
};
self.file_filter.draft = Some(FileTreeDraft { prompt, buffer });
}
pub fn file_tree_prompt_editing(&self) -> bool {
self.file_filter.draft.is_some()
}
pub fn file_tree_draft(&self) -> Option<&FileTreeDraft> {
self.file_filter.draft.as_ref()
}
pub fn file_tree_prompt_insert_char(&mut self, ch: char) {
if let Some(draft) = self.file_filter.draft.as_mut() {
draft.buffer.push(ch);
}
}
pub fn file_tree_prompt_insert_str(&mut self, text: &str) {
if let Some(draft) = self.file_filter.draft.as_mut() {
for ch in text.chars() {
if !matches!(ch, '\n' | '\r') {
draft.buffer.push(ch);
}
}
}
}
pub fn file_tree_prompt_delete_char(&mut self) {
if let Some(draft) = self.file_filter.draft.as_mut() {
draft.buffer.pop();
}
}
pub fn file_tree_prompt_delete_word(&mut self) {
if let Some(draft) = self.file_filter.draft.as_mut() {
while draft
.buffer
.chars()
.next_back()
.is_some_and(char::is_whitespace)
{
draft.buffer.pop();
}
while draft
.buffer
.chars()
.next_back()
.is_some_and(|c| !c.is_whitespace())
{
draft.buffer.pop();
}
}
}
pub fn file_tree_prompt_clear_line(&mut self) {
if let Some(draft) = self.file_filter.draft.as_mut() {
draft.buffer.clear();
}
}
pub fn cancel_file_tree_prompt(&mut self) {
self.file_filter.draft = None;
}
pub fn commit_file_tree_prompt(&mut self) {
let Some(draft) = self.file_filter.draft.clone() else {
return;
};
let pattern = draft.buffer.trim().to_string();
match draft.prompt {
FileTreePrompt::Search => {
self.file_filter.draft = None;
if pattern.is_empty() {
self.file_filter.search = None;
return;
}
self.file_filter.search = Some(pattern);
self.step_file_tree_search(true, true);
}
FileTreePrompt::Include | FileTreePrompt::Exclude => {
if pattern.is_empty() {
self.file_filter.draft = None;
match draft.prompt {
FileTreePrompt::Include => self.clear_include_filter(),
_ => self.clear_exclude_filter(),
}
return;
}
let compiled = match RegexBuilder::new(&pattern).case_insensitive(true).build() {
Ok(regex) => FilePattern {
source: pattern,
regex,
},
Err(err) => {
self.set_error(format!(
"Invalid regex: {}",
regex_reason(&err.to_string())
));
return;
}
};
self.file_filter.draft = None;
let label = draft.prompt.label();
let source = compiled.source.clone();
match draft.prompt {
FileTreePrompt::Include => self.file_filter.include = Some(compiled),
_ => self.file_filter.exclude = Some(compiled),
}
self.apply_file_filter_change();
let matched = self.filtered_file_indices().len();
if matched == 0 {
self.set_warning(format!(
"No files match {label} \"{source}\" \u{2014} I clears include, E clears exclude"
));
} else {
let total = self.diff_files.len();
self.set_message(format!(
"Filter {label} \"{source}\" \u{00b7} {matched}/{total} files"
));
}
}
}
}
pub fn clear_include_filter(&mut self) {
if self.file_filter.include.take().is_none() {
self.set_message("No include filter set");
return;
}
self.apply_file_filter_change();
self.report_filter_cleared("include");
}
pub fn clear_exclude_filter(&mut self) {
if self.file_filter.exclude.take().is_none() {
self.set_message("No exclude filter set");
return;
}
self.apply_file_filter_change();
self.report_filter_cleared("exclude");
}
fn report_filter_cleared(&mut self, label: &str) {
let shown = self.filtered_file_indices().len();
let total = self.diff_files.len();
if shown == total {
self.set_message(format!("Cleared {label} filter \u{00b7} {total} files"));
} else {
self.set_message(format!(
"Cleared {label} filter \u{00b7} {shown}/{total} files"
));
}
}
fn apply_file_filter_change(&mut self) {
self.rebuild_annotations();
let visible = self.filtered_file_indices();
if visible.is_empty() {
self.diff_state.current_file_idx = 0;
self.diff_state.cursor_line = 0;
self.diff_state.scroll_offset = 0;
self.file_list_state.select(0);
return;
}
if visible.contains(&self.diff_state.current_file_idx) {
self.jump_to_file(self.diff_state.current_file_idx);
} else {
self.jump_to_file(visible[0]);
}
}
pub fn file_tree_search_active(&self) -> bool {
self.file_filter.search.is_some()
}
pub fn file_tree_search_next(&mut self) {
self.step_file_tree_search(true, false);
}
pub fn file_tree_search_prev(&mut self) {
self.step_file_tree_search(false, false);
}
fn step_file_tree_search(&mut self, forward: bool, include_current: bool) {
let Some(query) = self.file_filter.search.clone() else {
self.set_message("No file tree search active");
return;
};
let needle = query.to_lowercase();
let candidates: Vec<usize> = self
.filtered_file_indices()
.into_iter()
.filter(|&idx| {
self.diff_files[idx]
.display_path()
.to_string_lossy()
.to_lowercase()
.contains(&needle)
})
.collect();
if candidates.is_empty() {
self.set_message(format!("No files matching \"{query}\""));
return;
}
let anchor = self.selected_tree_file_idx();
let target = if forward {
candidates
.iter()
.find(|&&idx| {
if include_current {
idx >= anchor
} else {
idx > anchor
}
})
.copied()
.unwrap_or(candidates[0])
} else {
candidates
.iter()
.rev()
.find(|&&idx| {
if include_current {
idx <= anchor
} else {
idx < anchor
}
})
.copied()
.unwrap_or(*candidates.last().expect("candidates is non-empty"))
};
self.select_file_in_tree(target);
let path = self.diff_files[target].display_path().display().to_string();
let position = candidates
.iter()
.position(|&idx| idx == target)
.unwrap_or(0)
+ 1;
self.set_message(format!(
"{path} \u{00b7} {position}/{} for \"{query}\"",
candidates.len()
));
}
fn selected_tree_file_idx(&self) -> usize {
match self.get_selected_tree_item() {
Some(FileTreeItem::File { file_idx, .. }) => file_idx,
_ => self.diff_state.current_file_idx,
}
}
pub(in crate::app) fn select_file_in_tree(&mut self, file_idx: usize) {
use std::path::Path;
let Some(file) = self.diff_files.get(file_idx) else {
return;
};
let path = file.display_path().clone();
let mut current = path.parent();
while let Some(parent) = current {
if parent != Path::new("") {
self.expanded_dirs
.insert(parent.to_string_lossy().to_string());
}
current = parent.parent();
}
if let Some(tree_idx) = self.file_idx_to_tree_idx(file_idx) {
self.file_list_state.select(tree_idx);
}
}
}
fn regex_reason(message: &str) -> String {
if let Some(reason) = message
.lines()
.map(str::trim)
.filter_map(|line| line.strip_prefix("error:"))
.next_back()
{
return reason.trim().to_string();
}
message
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(test)]
mod tests {
use super::regex_reason;
#[test]
fn should_take_the_trailing_reason_line_over_the_header() {
let raw = "regex parse error:\n [unclosed\n ^\nerror: unclosed character class";
assert_eq!(regex_reason(raw), "unclosed character class");
}
#[test]
fn should_flatten_errors_that_carry_no_reason_line() {
assert_eq!(regex_reason("something\n broke"), "something broke");
}
}