use std::collections::{BTreeMap, HashMap, HashSet};
use ratatui::prelude::*;
use tui_tree_widget::TreeItem;
use crate::git::GitStatus;
use crate::scanner::ScannedFile;
use crate::sync::SyncStatus;
pub struct BuiltTree<'a> {
pub items: Vec<TreeItem<'a, String>>,
pub identifiers: Vec<String>,
pub file_ids: HashSet<String>,
}
#[derive(Default)]
struct Node<'a> {
children: BTreeMap<String, Node<'a>>,
files: Vec<&'a ScannedFile>,
}
pub fn build_tree<'a>(
files: &[ScannedFile],
status_cache: &HashMap<String, SyncStatus>,
git_statuses: &HashMap<String, GitStatus>,
) -> BuiltTree<'a> {
let mut root_node: Node = Node::default();
for file in files {
let mut parts = file.rel_path.split('/').collect::<Vec<_>>();
let _name = parts.pop();
let mut cur = &mut root_node;
for dir in parts {
cur = cur.children.entry(dir.to_string()).or_default();
}
cur.files.push(file);
}
let mut dir_counts: HashMap<String, [usize; 5]> = HashMap::new();
tally_dirs(&root_node, "", status_cache, &mut dir_counts);
let mut identifiers = Vec::new();
let mut file_ids: HashSet<String> = HashSet::new();
let row_state = RowState {
status_cache,
git_statuses,
dir_counts: &dir_counts,
};
let items = render_node(&root_node, "", &row_state, &mut identifiers, &mut file_ids);
BuiltTree {
items,
identifiers,
file_ids,
}
}
const STATUS_ORDER: [SyncStatus; 5] = [
SyncStatus::Synced,
SyncStatus::LocalNewer,
SyncStatus::RemoteNewer,
SyncStatus::Conflict,
SyncStatus::NotGisted,
];
fn status_index(s: SyncStatus) -> usize {
match s {
SyncStatus::Synced => 0,
SyncStatus::LocalNewer => 1,
SyncStatus::RemoteNewer => 2,
SyncStatus::Conflict => 3,
SyncStatus::NotGisted => 4,
}
}
fn tally_dirs(
node: &Node,
prefix: &str,
cache: &HashMap<String, SyncStatus>,
out: &mut HashMap<String, [usize; 5]>,
) -> [usize; 5] {
let mut total = [0usize; 5];
for (name, child) in &node.children {
let dir_id = if prefix.is_empty() {
name.clone()
} else {
format!("{prefix}/{name}")
};
let sub = tally_dirs(child, &dir_id, cache, out);
for i in 0..5 {
total[i] += sub[i];
}
out.insert(dir_id, sub);
}
for f in &node.files {
let s = cache
.get(&f.rel_path)
.copied()
.unwrap_or(SyncStatus::NotGisted);
total[status_index(s)] += 1;
}
total
}
struct RowState<'s> {
status_cache: &'s HashMap<String, SyncStatus>,
git_statuses: &'s HashMap<String, GitStatus>,
dir_counts: &'s HashMap<String, [usize; 5]>,
}
fn render_node<'a>(
node: &Node,
prefix: &str,
state: &RowState<'_>,
identifiers: &mut Vec<String>,
file_ids: &mut HashSet<String>,
) -> Vec<TreeItem<'a, String>> {
let mut items = Vec::new();
for (name, child) in &node.children {
let dir_id = if prefix.is_empty() {
name.clone()
} else {
format!("{prefix}/{name}")
};
let children = render_node(child, &dir_id, state, identifiers, file_ids);
let counts = state.dir_counts.get(&dir_id).copied().unwrap_or([0; 5]);
let label = format_directory(name, &counts);
identifiers.push(dir_id.clone());
items.push(TreeItem::new(dir_id, label, children).expect("unique tree item id"));
}
for file in &node.files {
let status = state
.status_cache
.get(&file.rel_path)
.copied()
.unwrap_or(SyncStatus::NotGisted);
let git = state.git_statuses.get(&file.rel_path).copied();
let label = format_leaf(&file.rel_path, status, git);
let id = file.rel_path.clone();
identifiers.push(id.clone());
file_ids.insert(id.clone());
items.push(TreeItem::new_leaf(id, label));
}
items
}
fn format_leaf(rel_path: &str, status: SyncStatus, git: Option<GitStatus>) -> Line<'static> {
let basename = rel_path.rsplit('/').next().unwrap_or(rel_path);
let name = match basename.rfind('.') {
Some(idx) if idx > 0 => &basename[..idx],
_ => basename,
};
let g = crate::glyphs::glyphs();
let icon = format!("{} ", status.icon());
let (git_glyph, git_color) = match git {
Some(s) if s.staged && s.modified => {
(g.git_modified, Color::Yellow)
}
Some(s) if s.staged => (g.git_staged, Color::Green),
Some(s) if s.modified => (g.git_modified, Color::Yellow),
Some(s) if s.untracked => (g.git_untracked, Color::DarkGray),
_ => (g.git_clean, Color::DarkGray),
};
Line::from(vec![
Span::raw(icon),
Span::styled(format!("{git_glyph} "), Style::default().fg(git_color)),
Span::styled(name.to_string(), Style::default().fg(status.color())),
])
}
fn format_directory(name: &str, counts: &[usize; 5]) -> Line<'static> {
let g = crate::glyphs::glyphs();
let mut spans = vec![
Span::raw(format!("{} ", g.dir)),
Span::styled(
name.to_string(),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
];
if counts.iter().any(|&n| n > 0) {
let dim = Style::default().fg(Color::DarkGray);
spans.push(Span::styled(" [", dim));
let mut first = true;
for st in STATUS_ORDER {
let n = counts[status_index(st)];
if n == 0 {
continue;
}
if !first {
spans.push(Span::raw(" "));
}
spans.push(Span::styled(
n.to_string(),
Style::default().fg(st.color()).add_modifier(Modifier::BOLD),
));
first = false;
}
spans.push(Span::styled("]", dim));
}
Line::from(spans)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use std::time::SystemTime;
fn sf(rel: &str) -> ScannedFile {
ScannedFile {
rel_path: rel.to_string(),
abs_path: PathBuf::from(rel),
modified: SystemTime::UNIX_EPOCH,
}
}
#[test]
fn deep_paths_nest_recursively() {
let files = vec![sf("a/b/c/d.md"), sf("a/b/c/e.md"), sf("a/f.md")];
let built = build_tree(&files, &HashMap::new(), &HashMap::new());
assert_eq!(built.items.len(), 1);
assert!(built.identifiers.contains(&"a".to_string()));
assert!(built.identifiers.contains(&"a/b".to_string()));
assert!(built.identifiers.contains(&"a/b/c".to_string()));
assert!(built.file_ids.contains("a/b/c/d.md"));
assert!(built.file_ids.contains("a/f.md"));
}
#[test]
fn dir_counts_roll_up_recursively_and_omit_zeros() {
let files = vec![sf("a/b/c/d.md"), sf("a/b/c/e.md"), sf("a/f.md")];
let mut cache = HashMap::new();
cache.insert("a/b/c/d.md".to_string(), SyncStatus::Synced);
cache.insert("a/b/c/e.md".to_string(), SyncStatus::LocalNewer);
cache.insert("a/f.md".to_string(), SyncStatus::NotGisted);
let mut root = Node::default();
for f in &files {
let mut parts: Vec<&str> = f.rel_path.split('/').collect();
parts.pop();
let mut cur = &mut root;
for d in parts {
cur = cur.children.entry(d.to_string()).or_default();
}
cur.files.push(f);
}
let mut out = HashMap::new();
let total = tally_dirs(&root, "", &cache, &mut out);
assert_eq!(total, [1, 1, 0, 0, 1]);
assert_eq!(out["a"], [1, 1, 0, 0, 1]);
assert_eq!(out["a/b/c"], [1, 1, 0, 0, 0]);
}
#[test]
fn root_level_files_appear_at_top() {
let files = vec![sf("README.md"), sf("notes/today.md")];
let built = build_tree(&files, &HashMap::new(), &HashMap::new());
assert!(built.file_ids.contains("README.md"));
assert!(built.file_ids.contains("notes/today.md"));
}
}