use crate::comments::model::CommentStore;
use crate::diff::model::{Changeset, DiffFile};
use crate::ui::theme::theme;
use ratatui::style::Color;
use std::collections::HashSet;
use std::path::Path;
pub enum SbRow {
Dir {
path: String,
name: String,
depth: usize,
},
File {
idx: usize,
depth: usize,
},
}
pub fn file_status(f: &DiffFile) -> (char, Color) {
let added = f.old_path == "/dev/null" || f.old_path.is_empty();
let deleted = f.new_path == "/dev/null" || f.new_path.is_empty();
if added {
('A', theme().added)
} else if deleted {
('D', theme().removed)
} else if f.old_path != f.new_path {
('R', theme().accent)
} else {
('M', theme().warn)
}
}
pub fn dir_of(path: &str) -> &str {
match path.rfind('/') {
Some(i) => &path[..i],
None => "",
}
}
pub fn base_of(path: &str) -> &str {
path.rsplit('/').next().unwrap_or(path)
}
pub fn build_sidebar_rows(
changeset: &Changeset,
collapsed: &HashSet<String>,
) -> (Vec<SbRow>, Vec<usize>) {
let mut rows = Vec::new();
let mut map = vec![usize::MAX; changeset.files.len()];
let mut prev: Vec<String> = Vec::new();
for (i, f) in changeset.files.iter().enumerate() {
let dir = dir_of(f.display_path());
let segs: Vec<String> = if dir.is_empty() {
Vec::new()
} else {
dir.split('/').map(|s| s.to_string()).collect()
};
let mut common = 0;
while common < segs.len() && common < prev.len() && segs[common] == prev[common] {
common += 1;
}
for d in common..segs.len() {
let ancestor_collapsed = (0..d).any(|a| collapsed.contains(&segs[..=a].join("/")));
if ancestor_collapsed {
continue;
}
rows.push(SbRow::Dir {
path: segs[..=d].join("/"),
name: segs[d].clone(),
depth: d,
});
}
prev = segs.clone();
let hidden = (0..segs.len()).any(|d| collapsed.contains(&segs[..=d].join("/")));
if hidden {
continue;
}
map[i] = rows.len();
rows.push(SbRow::File {
idx: i,
depth: segs.len(),
});
}
(rows, map)
}
pub fn file_comment_state(comments: &CommentStore, path: &str) -> Option<bool> {
let p = Path::new(path);
let mut any = false;
let mut open = false;
for t in comments.threads.iter().filter(|t| t.file == p) {
any = true;
if !t.resolved {
open = true;
}
}
any.then_some(open)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::diff::model::DiffFile;
fn file(path: &str) -> DiffFile {
DiffFile {
old_path: path.into(),
new_path: path.into(),
is_binary: false,
hunks: vec![],
}
}
fn cs(paths: &[&str]) -> Changeset {
Changeset {
files: paths.iter().map(|p| file(p)).collect(),
}
}
#[test]
fn dir_and_base_split() {
assert_eq!(dir_of("a/b/c.rs"), "a/b");
assert_eq!(dir_of("top.rs"), "");
assert_eq!(base_of("a/b/c.rs"), "c.rs");
assert_eq!(base_of("top.rs"), "top.rs");
}
#[test]
fn nests_dirs_and_shares_common_prefix() {
let changeset = cs(&["src/a.rs", "src/b.rs", "src/ui/c.rs"]);
let (rows, map) = build_sidebar_rows(&changeset, &HashSet::new());
let dirs: Vec<&str> = rows
.iter()
.filter_map(|r| match r {
SbRow::Dir { path, .. } => Some(path.as_str()),
_ => None,
})
.collect();
assert_eq!(dirs, vec!["src", "src/ui"]);
assert!(map.iter().all(|&r| r != usize::MAX));
assert_eq!(map.len(), 3);
}
#[test]
fn collapsed_dir_hides_its_subtree() {
let changeset = cs(&["src/a.rs", "src/ui/c.rs", "top.rs"]);
let mut collapsed = HashSet::new();
collapsed.insert("src".to_string());
let (rows, map) = build_sidebar_rows(&changeset, &collapsed);
assert!(rows
.iter()
.any(|r| matches!(r, SbRow::Dir { path, .. } if path == "src")));
assert!(!rows
.iter()
.any(|r| matches!(r, SbRow::Dir { path, .. } if path == "src/ui")));
assert_eq!(map[0], usize::MAX); assert_eq!(map[1], usize::MAX); assert_ne!(map[2], usize::MAX); }
#[test]
fn file_status_classifies_add_delete_rename_modify() {
assert_eq!(file_status(&file("a.rs")).0, 'M');
let mut added = file("a.rs");
added.old_path = "/dev/null".into();
assert_eq!(file_status(&added).0, 'A');
let mut deleted = file("a.rs");
deleted.new_path = "/dev/null".into();
assert_eq!(file_status(&deleted).0, 'D');
let renamed = DiffFile {
old_path: "a.rs".into(),
new_path: "b.rs".into(),
is_binary: false,
hunks: vec![],
};
assert_eq!(file_status(&renamed).0, 'R');
}
}