use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::time::Duration;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::layout::{Constraint, Rect};
use ratatui::widgets::{Row, TableState};
use ratatui_image::picker::Picker;
use crate::action::{self, Action};
use crate::collect::LayerResult;
use crate::model::{
self, ChurnData, CodeData, Layer, Lens, NodeId, NodeKind, SortDir, StatusData, SubKey, Tree,
};
use crate::scan::ScanOutcome;
use crate::ui::codeview::CodeView;
use crate::ui::preview::Preview;
use crate::ui::reader::{Handoff, Reader, ReaderExit};
const WHEEL_TREE_ROWS: i64 = 1;
const WHEEL_PREVIEW_LINES: i32 = 3;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Focus {
#[default]
Tree,
Preview,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct PaneRects {
pub tree: Rect,
pub preview: Option<Rect>,
}
impl PaneRects {
pub fn hit(&self, col: u16, row: u16) -> Option<Focus> {
if self.preview.is_some_and(|r| contains(r, col, row)) {
Some(Focus::Preview)
} else if contains(self.tree, col, row) {
Some(Focus::Tree)
} else {
None
}
}
}
fn contains(r: Rect, col: u16, row: u16) -> bool {
col >= r.x
&& col < r.x.saturating_add(r.width)
&& row >= r.y
&& row < r.y.saturating_add(r.height)
}
pub struct RowCache {
pub(crate) rows: Vec<Row<'static>>,
pub(crate) header: Row<'static>,
pub(crate) widths: Vec<Constraint>,
pub(crate) width: u16,
pub(crate) rev: u64,
pub(crate) computing: bool,
}
pub enum Screen {
Loading,
Loaded(Box<Loaded>),
Reader(Box<Reader>),
Error(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
Normal,
Filter,
}
pub struct Loaded {
pub tree: Tree,
pub visible: Vec<NodeId>,
pub table_state: TableState,
pub viewport_rows: usize,
pub duration: Duration,
pub show_detail: bool,
pub show_preview: bool,
pub preview_for: Option<NodeId>,
pub preview: Preview,
pub preview_view: CodeView,
pub focus: Focus,
pub panes: PaneRects,
pub filter: String,
pub hide_zeros: bool,
pub active_lens: Lens,
pub sort_key: SubKey,
pub sort_dir: SortDir,
pub inaccurate: bool,
pub rebuild_rev: u64,
pub row_cache: Option<RowCache>,
pub code: Layer<CodeData>,
pub churn: Layer<ChurnData>,
pub status: Layer<StatusData>,
}
pub struct App {
pub root: PathBuf,
pub root_label: String,
pub screen: Screen,
pub mode: Mode,
pub show_help: bool,
pub spinner: usize,
pub elapsed: Duration,
pub should_quit: bool,
pub pending_edit: Option<PathBuf>,
pub pending_compute: Option<Lens>,
pub pending_capture: Option<bool>,
pub repo: bool,
pub picker: Option<Picker>,
}
impl App {
pub fn new(root: PathBuf, root_label: String) -> Self {
Self {
root,
root_label,
screen: Screen::Loading,
mode: Mode::Normal,
show_help: false,
spinner: 0,
elapsed: Duration::ZERO,
should_quit: false,
pending_edit: None,
pending_compute: None,
pending_capture: None,
repo: false,
picker: None,
}
}
pub fn is_busy(&self) -> bool {
match &self.screen {
Screen::Loading => true,
Screen::Loaded(loaded) => loaded.any_computing(),
Screen::Reader(reader) => reader.loaded.any_computing(),
Screen::Error(_) => false,
}
}
fn loaded_mut(&mut self) -> Option<&mut Loaded> {
match &mut self.screen {
Screen::Loaded(loaded) => Some(loaded),
Screen::Reader(reader) => Some(&mut reader.loaded),
_ => None,
}
}
pub fn on_scan(&mut self, outcome: ScanOutcome) {
self.repo = outcome.repo;
let mut loaded = Loaded::new(outcome.tree, outcome.duration);
loaded.apply_sort();
loaded.mark_computing(loaded.active_lens);
self.pending_compute = Some(loaded.active_lens);
self.screen = Screen::Loaded(Box::new(loaded));
}
pub fn on_scan_failed(&mut self, message: impl Into<String>) {
self.screen = Screen::Error(message.into());
}
pub fn on_rescan(&mut self, outcome: ScanOutcome) {
let Some(loaded) = self.loaded_mut() else {
self.on_scan(outcome);
return;
};
if loaded.same_skeleton(&outcome.tree) {
return;
}
loaded.reload(outcome.tree, outcome.duration);
let active = loaded.active_lens;
let needs_layer = active.has_layer();
if needs_layer {
loaded.mark_computing(active);
}
self.repo = outcome.repo;
if needs_layer {
self.pending_compute = Some(active);
}
}
pub fn on_layer(&mut self, result: LayerResult) {
if let Some(loaded) = self.loaded_mut() {
loaded.apply_layer(result);
}
}
pub fn handle_key(&mut self, key: KeyEvent) {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
if ctrl && key.code == KeyCode::Char('c') {
self.should_quit = true;
return;
}
if let Screen::Reader(reader) = &mut self.screen {
if matches!(reader.handle_key(key), ReaderExit::ToTree) {
self.close_reader();
}
return;
}
if self.show_help {
if matches!(key.code, KeyCode::Char('?' | 'q') | KeyCode::Esc) {
self.show_help = false;
}
return;
}
if self.mode == Mode::Filter {
self.handle_filter_key(key);
return;
}
match key.code {
KeyCode::Char('?') => self.show_help = true,
KeyCode::Char('/') => self.mode = Mode::Filter,
KeyCode::Char('d') if !ctrl => self.toggle_detail(),
KeyCode::Tab => self.toggle_detail(),
KeyCode::Esc => self.clear_filter(),
_ => self.update(action::map_key(key)),
}
}
fn handle_filter_key(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Esc => {
self.set_filter(String::new());
self.mode = Mode::Normal;
}
KeyCode::Enter => self.mode = Mode::Normal,
KeyCode::Backspace => {
if let Screen::Loaded(loaded) = &self.screen {
let mut filter = loaded.filter.clone();
filter.pop();
self.set_filter(filter);
}
}
KeyCode::Char(c) => {
if let Screen::Loaded(loaded) = &self.screen {
let mut filter = loaded.filter.clone();
filter.push(c);
self.set_filter(filter);
}
}
_ => {}
}
}
fn toggle_detail(&mut self) {
if let Screen::Loaded(loaded) = &mut self.screen {
loaded.show_detail = !loaded.show_detail;
}
}
fn toggle_preview(&mut self) {
let root = self.root.clone();
let picker = self.picker.as_ref();
if let Screen::Loaded(loaded) = &mut self.screen {
loaded.show_preview = !loaded.show_preview;
if loaded.show_preview {
loaded.ensure_preview(&root, picker);
}
}
}
pub fn preview_target_id(&self) -> Option<NodeId> {
let Screen::Loaded(loaded) = &self.screen else {
return None;
};
if loaded.show_preview && loaded.panes.preview.is_some() {
let sel = loaded.selected_id();
if sel != loaded.preview_for {
return sel;
}
}
None
}
pub fn refresh_preview(&mut self) {
let root = self.root.clone();
let picker = self.picker.as_ref();
if let Screen::Loaded(loaded) = &mut self.screen {
loaded.ensure_preview(&root, picker);
}
}
fn cycle_focus(&mut self) {
if let Screen::Loaded(loaded) = &mut self.screen {
loaded.focus = match loaded.focus {
Focus::Tree if loaded.panes.preview.is_some() => Focus::Preview,
_ => Focus::Tree,
};
}
}
fn yank(&mut self) {
let Screen::Loaded(loaded) = &self.screen else {
return;
};
let text = match loaded.focus {
Focus::Preview => loaded.preview_view.visible_text(),
Focus::Tree => loaded
.selected_id()
.map(|id| loaded.tree.nodes[id].rel_path.display().to_string())
.unwrap_or_default(),
};
if !text.is_empty() {
crate::clipboard::osc52_copy(&text);
}
}
fn toggle_mouse_capture(&mut self) {
self.pending_capture = Some(!crate::tui::mouse_captured());
}
pub fn handle_scroll(&mut self, col: u16, row: u16, delta: i32) {
match &mut self.screen {
Screen::Loaded(loaded) => match loaded.panes.hit(col, row) {
Some(Focus::Preview) => {
loaded.focus = Focus::Preview;
loaded.preview_view.scroll_by(delta * WHEEL_PREVIEW_LINES);
}
Some(Focus::Tree) => {
loaded.focus = Focus::Tree;
loaded.move_by(delta as i64 * WHEEL_TREE_ROWS);
}
None => {}
},
Screen::Reader(reader) => reader.scroll(delta * WHEEL_PREVIEW_LINES),
_ => {}
}
}
pub fn handle_click(&mut self, col: u16, row: u16) {
let mut activate = false;
if let Screen::Loaded(loaded) = &mut self.screen
&& let Some(focus) = loaded.panes.hit(col, row)
{
loaded.focus = focus;
if focus == Focus::Tree
&& let Some(index) = loaded.row_at(row)
{
if loaded.table_state.selected() == Some(index) {
activate = true;
} else {
loaded.move_to(index);
}
}
}
if activate {
self.open_selected();
}
}
fn clear_filter(&mut self) {
if matches!(&self.screen, Screen::Loaded(l) if !l.filter.is_empty()) {
self.set_filter(String::new());
}
}
fn set_filter(&mut self, filter: String) {
if let Screen::Loaded(loaded) = &mut self.screen {
loaded.filter = filter;
loaded.rebuild();
}
}
pub fn update(&mut self, action: Action) {
match action {
Action::None => {}
Action::Quit => self.should_quit = true,
Action::Open => self.open_selected(),
Action::Edit => self.edit_selected(),
Action::TogglePreview => self.toggle_preview(),
Action::CycleLens => self.cycle_lens(),
Action::JumpLens(n) => self.jump_lens(n),
Action::CycleFocus => self.cycle_focus(),
Action::Yank => self.yank(),
Action::ToggleMouseCapture => self.toggle_mouse_capture(),
other => {
if let Screen::Loaded(loaded) = &mut self.screen {
loaded.handle(other);
}
}
}
}
fn cycle_lens(&mut self) {
if let Some(lens) = self.next_available_lens() {
self.set_lens(lens);
}
}
fn jump_lens(&mut self, n: u8) {
let idx = (n as usize).wrapping_sub(1);
if let Some(&lens) = Lens::ALL.get(idx)
&& lens.is_available(self.repo)
{
self.set_lens(lens);
}
}
fn next_available_lens(&self) -> Option<Lens> {
let Screen::Loaded(loaded) = &self.screen else {
return None;
};
let mut lens = loaded.active_lens;
for _ in 0..Lens::ALL.len() {
lens = lens.next();
if lens != loaded.active_lens && lens.is_available(self.repo) {
return Some(lens);
}
}
None
}
fn set_lens(&mut self, lens: Lens) {
if let Screen::Loaded(loaded) = &mut self.screen {
loaded.set_lens(lens);
if lens.has_layer() && loaded.layer_not_computed(lens) {
loaded.mark_computing(lens);
self.pending_compute = Some(lens);
}
}
}
fn open_selected(&mut self) {
let Screen::Loaded(loaded) = &mut self.screen else {
return;
};
let Some(id) = loaded.selected_id() else {
return;
};
if loaded.tree.nodes[id].is_dir() {
loaded.expand_or_descend();
return;
}
let rel_path = loaded.tree.nodes[id].rel_path.clone();
self.open_reader(self.root.join(rel_path));
}
fn open_reader(&mut self, path: PathBuf) {
let Screen::Loaded(loaded) = std::mem::replace(&mut self.screen, Screen::Loading) else {
return; };
let reader = Reader::open(loaded, path, self.picker.as_ref());
self.screen = Screen::Reader(Box::new(reader));
}
fn close_reader(&mut self) {
if let Screen::Reader(reader) = std::mem::replace(&mut self.screen, Screen::Loading) {
self.screen = Screen::Loaded(reader.loaded);
}
}
pub fn take_reader_handoff(&mut self) -> Option<Handoff> {
match &mut self.screen {
Screen::Reader(reader) => reader.pending_handoff.take(),
_ => None,
}
}
fn edit_selected(&mut self) {
if let Some(path) = self.selected_file_path() {
self.pending_edit = Some(path);
}
}
fn selected_file_path(&self) -> Option<PathBuf> {
let Screen::Loaded(loaded) = &self.screen else {
return None;
};
let id = loaded.selected_id()?;
if loaded.tree.nodes[id].is_dir() {
return None;
}
let rel_path = loaded.tree.nodes[id].rel_path.clone();
Some(self.root.join(rel_path))
}
}
impl Loaded {
fn new(tree: Tree, duration: Duration) -> Self {
Self {
tree,
visible: Vec::new(),
table_state: TableState::default(),
viewport_rows: 1,
duration,
show_detail: false,
show_preview: true,
preview_for: None,
preview: Preview::Empty,
preview_view: CodeView::default(),
focus: Focus::Tree,
panes: PaneRects::default(),
filter: String::new(),
hide_zeros: false,
active_lens: Lens::Code,
sort_key: Lens::Code.default_sub_key(),
sort_dir: SortDir::Desc,
inaccurate: false,
rebuild_rev: 0,
row_cache: None,
code: Layer::NotComputed,
churn: Layer::NotComputed,
status: Layer::NotComputed,
}
}
pub fn selected_id(&self) -> Option<NodeId> {
self.table_state
.selected()
.and_then(|i| self.visible.get(i).copied())
}
pub fn ensure_preview(&mut self, root: &Path, picker: Option<&Picker>) {
let id = self.selected_id();
if id == self.preview_for {
return;
}
self.preview_for = id;
self.preview = match id {
None => Preview::Empty,
Some(id) if self.tree.nodes[id].is_dir() => {
Preview::Info(format!("{} — directory", self.display_name(id)))
}
Some(id) => {
let path = root.join(&self.tree.nodes[id].rel_path);
crate::ui::preview::load(&path, picker)
}
};
self.preview_view = match &self.preview {
Preview::Text(lines) => CodeView::new(lines.clone()),
_ => CodeView::default(),
};
}
pub fn value(&self, key: SubKey, id: NodeId) -> u128 {
match key {
SubKey::Bytes => self.tree.nodes[id].bytes as u128,
SubKey::Files => self.tree.nodes[id].files as u128,
SubKey::Name => 0,
SubKey::Lines => self.code_at(id).map_or(0, |c| c.num.lines()) as u128,
SubKey::Code => self.code_at(id).map_or(0, |c| c.num.code) as u128,
SubKey::Comments => self.code_at(id).map_or(0, |c| c.num.comments) as u128,
SubKey::Blanks => self.code_at(id).map_or(0, |c| c.num.blanks) as u128,
SubKey::Added => self.churn_at(id).map_or(0, |c| c.added) as u128,
SubKey::Deleted => self.churn_at(id).map_or(0, |c| c.deleted) as u128,
SubKey::Churn => self.churn_at(id).map_or(0, |c| c.churn()) as u128,
SubKey::Commits => self.churn_at(id).map_or(0, |c| c.commits) as u128,
SubKey::StatusAdded => self.status_at(id).map_or(0, |s| s.added) as u128,
SubKey::StatusModified => self.status_at(id).map_or(0, |s| s.modified) as u128,
SubKey::StatusDeleted => self.status_at(id).map_or(0, |s| s.deleted) as u128,
SubKey::StatusTotal => self.status_at(id).map_or(0, |s| s.total()) as u128,
}
}
pub fn primary_value(&self, id: NodeId) -> u128 {
self.value(self.active_lens.primary().key, id)
}
pub fn display_name(&self, id: NodeId) -> String {
model::view::row_name(&self.tree, id)
}
pub fn display_depth(&self, id: NodeId) -> usize {
model::view::display_depth(&self.tree, id)
}
pub fn code_at(&self, id: NodeId) -> Option<&CodeData> {
self.code.ready().map(|values| &values[id])
}
pub fn churn_at(&self, id: NodeId) -> Option<&ChurnData> {
self.churn.ready().map(|values| &values[id])
}
pub fn status_at(&self, id: NodeId) -> Option<&StatusData> {
self.status.ready().map(|values| &values[id])
}
pub fn any_computing(&self) -> bool {
self.code.is_computing() || self.churn.is_computing() || self.status.is_computing()
}
pub fn active_computing(&self) -> bool {
match self.active_lens {
Lens::Code => self.code.is_computing(),
Lens::Churn => self.churn.is_computing(),
Lens::Status => self.status.is_computing(),
Lens::Size => false,
}
}
fn layer_not_computed(&self, lens: Lens) -> bool {
match lens {
Lens::Code => matches!(self.code, Layer::NotComputed),
Lens::Churn => matches!(self.churn, Layer::NotComputed),
Lens::Status => matches!(self.status, Layer::NotComputed),
Lens::Size => false,
}
}
fn mark_computing(&mut self, lens: Lens) {
match lens {
Lens::Code => self.code = Layer::Computing,
Lens::Churn => self.churn = Layer::Computing,
Lens::Status => self.status = Layer::Computing,
Lens::Size => {}
}
}
fn apply_layer(&mut self, result: LayerResult) {
let lens = result.lens();
match result {
LayerResult::Code { files, inaccurate } => {
self.code = Layer::Ready(model::aggregate_code(&self.tree, &files));
self.inaccurate = inaccurate;
}
LayerResult::Churn(files) => {
self.churn = Layer::Ready(model::aggregate(&self.tree, &files));
}
LayerResult::Status(files) => {
self.status = Layer::Ready(model::aggregate(&self.tree, &files));
}
}
if lens == self.active_lens {
self.apply_sort();
}
}
fn set_lens(&mut self, lens: Lens) {
self.active_lens = lens;
self.sort_key = lens.default_sub_key();
self.apply_sort();
}
fn same_skeleton(&self, other: &Tree) -> bool {
if self.tree.nodes.len() != other.nodes.len() || self.tree.index.len() != other.index.len()
{
return false;
}
self.tree.index.iter().all(|(path, &id)| {
other
.index
.get(path)
.is_some_and(|&oid| other.nodes[oid].bytes == self.tree.nodes[id].bytes)
})
}
fn reload(&mut self, tree: Tree, duration: Duration) {
let expanded: HashSet<PathBuf> = self
.tree
.nodes
.iter()
.filter(|n| n.is_dir() && n.expanded)
.map(|n| n.rel_path.clone())
.collect();
let selected_path = self
.selected_id()
.map(|id| self.tree.nodes[id].rel_path.clone());
self.tree = tree;
self.duration = duration;
self.inaccurate = false;
self.code = Layer::NotComputed;
self.churn = Layer::NotComputed;
self.status = Layer::NotComputed;
for node in &mut self.tree.nodes {
if node.is_dir() && expanded.contains(&node.rel_path) {
node.expanded = true;
}
}
self.apply_sort();
if let Some(path) = selected_path
&& let Some(&id) = self.tree.index.get(&path)
&& let Some(pos) = self.visible.iter().position(|&n| n == id)
{
self.table_state.select(Some(pos));
}
}
fn apply_sort(&mut self) {
if self.sort_key == SubKey::Name {
model::view::sort_by_name(&mut self.tree, self.sort_dir);
} else {
let values: Vec<u128> = (0..self.tree.nodes.len())
.map(|id| self.value(self.sort_key, id))
.collect();
model::view::sort_by_values(&mut self.tree, &values, self.sort_dir);
}
self.rebuild();
}
fn rebuild(&mut self) {
self.rebuild_rev = self.rebuild_rev.wrapping_add(1);
let previous_id = self.selected_id();
let previous_index = self.table_state.selected();
let mut visible = if self.filter.is_empty() {
model::view::flatten_visible(&self.tree)
} else {
model::view::flatten_filtered(&self.tree, &self.filter)
};
if self.hide_zeros {
visible.retain(|&id| self.primary_value(id) > 0);
}
self.visible = visible;
if self.visible.is_empty() {
self.table_state.select(None);
return;
}
let index = previous_id
.and_then(|id| self.visible.iter().position(|&n| n == id))
.or(previous_index)
.unwrap_or(0)
.min(self.visible.len() - 1);
self.table_state.select(Some(index));
}
fn handle(&mut self, action: Action) {
if self.focus == Focus::Preview && self.handle_preview_action(action) {
return;
}
match action {
Action::Down => self.move_by(1),
Action::Up => self.move_by(-1),
Action::First => self.move_to(0),
Action::Last => self.move_to(self.visible.len().saturating_sub(1)),
Action::PageDown => self.move_by(self.viewport_rows.max(1) as i64),
Action::PageUp => self.move_by(-(self.viewport_rows.max(1) as i64)),
Action::Expand => self.expand_or_descend(),
Action::Collapse => self.collapse_or_parent(),
Action::Toggle => self.toggle(),
Action::ExpandAll => self.set_all_expanded(true),
Action::CollapseAll => self.collapse_all(),
Action::CycleSort => {
self.sort_key = self.active_lens.next_sub_key(self.sort_key);
self.apply_sort();
}
Action::ReverseSort => {
self.sort_dir = self.sort_dir.flip();
self.apply_sort();
}
Action::ToggleZeros => {
self.hide_zeros = !self.hide_zeros;
self.rebuild();
}
_ => {}
}
}
fn handle_preview_action(&mut self, action: Action) -> bool {
match action {
Action::Down => self.preview_view.scroll_by(1),
Action::Up => self.preview_view.scroll_by(-1),
Action::PageDown => self.preview_view.page(1),
Action::PageUp => self.preview_view.page(-1),
Action::First => self.preview_view.goto_top(),
Action::Last => self.preview_view.goto_bottom(),
Action::Collapse => self.preview_view.scroll_h(-4),
Action::Expand => self.preview_view.scroll_h(4),
_ => return false,
}
true
}
fn move_by(&mut self, delta: i64) {
if self.visible.is_empty() {
return;
}
let last = self.visible.len() as i64 - 1;
let current = self.table_state.selected().unwrap_or(0) as i64;
let next = (current + delta).clamp(0, last);
self.table_state.select(Some(next as usize));
}
fn move_to(&mut self, index: usize) {
if self.visible.is_empty() {
return;
}
self.table_state
.select(Some(index.min(self.visible.len() - 1)));
}
fn row_at(&self, row: u16) -> Option<usize> {
let first = self.panes.tree.y.saturating_add(2);
if row < first {
return None; }
let in_view = (row - first) as usize;
if in_view >= self.viewport_rows {
return None; }
let index = self.table_state.offset() + in_view;
(index < self.visible.len()).then_some(index)
}
fn expand_or_descend(&mut self) {
let Some(id) = self.selected_id() else {
return;
};
if !self.tree.nodes[id].is_dir() {
return;
}
if !self.tree.nodes[id].expanded {
self.tree.nodes[id].expanded = true;
self.rebuild();
} else {
let tail = model::view::segment_tail(&self.tree, id);
if let Some(&first) = self.tree.nodes[tail].children.first()
&& let Some(pos) = self.visible.iter().position(|&n| n == first)
{
self.table_state.select(Some(pos));
}
}
}
fn collapse_or_parent(&mut self) {
let Some(id) = self.selected_id() else {
return;
};
if self.tree.nodes[id].is_dir() && self.tree.nodes[id].expanded {
self.tree.nodes[id].expanded = false;
self.rebuild();
} else if let Some(parent) = self.tree.nodes[id].parent {
let target = model::view::segment_head(&self.tree, parent);
if target != self.tree.root
&& let Some(pos) = self.visible.iter().position(|&n| n == target)
{
self.table_state.select(Some(pos));
}
}
}
fn toggle(&mut self) {
let Some(id) = self.selected_id() else {
return;
};
if self.tree.nodes[id].is_dir() {
self.tree.nodes[id].expanded = !self.tree.nodes[id].expanded;
self.rebuild();
}
}
fn set_all_expanded(&mut self, expanded: bool) {
for node in &mut self.tree.nodes {
if node.kind == NodeKind::Dir {
node.expanded = expanded;
}
}
self.tree.nodes[self.tree.root].expanded = true;
self.rebuild();
}
fn collapse_all(&mut self) {
let root = self.tree.root;
for (id, node) in self.tree.nodes.iter_mut().enumerate() {
if node.kind == NodeKind::Dir {
node.expanded = id == root;
}
}
self.rebuild();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::action::Action;
use crate::collect::LayerResult;
use crate::model::{CodeData, CodeNum, build_skeleton};
use std::collections::HashMap;
use std::path::PathBuf;
fn sample_app() -> App {
let files = vec![
(PathBuf::from("src/main.rs"), 1000),
(PathBuf::from("README.md"), 200),
];
let dirs = vec![PathBuf::from("src")];
let tree = build_skeleton(&files, &dirs, "proj".into());
let mut app = App::new(PathBuf::from("/proj"), "proj".into());
app.on_scan(ScanOutcome {
tree,
duration: Duration::ZERO,
repo: false,
});
app.pending_compute = None;
app
}
fn chained_app() -> App {
let files = vec![
(PathBuf::from("a/b/c/deep.rs"), 1000),
(PathBuf::from("top.rs"), 10),
];
let dirs = vec![
PathBuf::from("a"),
PathBuf::from("a/b"),
PathBuf::from("a/b/c"),
];
let tree = build_skeleton(&files, &dirs, "proj".into());
let mut app = App::new(PathBuf::from("/proj"), "proj".into());
app.on_scan(ScanOutcome {
tree,
duration: Duration::ZERO,
repo: false,
});
app.pending_compute = None;
app
}
fn select(app: &mut App, name: &str) {
let Screen::Loaded(loaded) = &mut app.screen else {
panic!("expected a loaded screen");
};
let index = loaded
.visible
.iter()
.position(|&id| loaded.tree.nodes[id].name == name)
.unwrap_or_else(|| panic!("{name} is not visible"));
loaded.table_state.select(Some(index));
}
fn is_visible(app: &App, name: &str) -> bool {
let Screen::Loaded(loaded) = &app.screen else {
return false;
};
loaded
.visible
.iter()
.any(|&id| loaded.tree.nodes[id].name == name)
}
fn active_lens(app: &App) -> Lens {
let Screen::Loaded(loaded) = &app.screen else {
panic!("not loaded");
};
loaded.active_lens
}
fn set_panes(app: &mut App) {
if let Screen::Loaded(loaded) = &mut app.screen {
loaded.panes = PaneRects {
tree: Rect::new(0, 0, 40, 20),
preview: Some(Rect::new(80, 0, 40, 20)),
};
}
}
fn set_viewport(app: &mut App, rows: usize) {
if let Screen::Loaded(loaded) = &mut app.screen {
loaded.viewport_rows = rows;
}
}
fn focus(app: &App) -> Focus {
let Screen::Loaded(loaded) = &app.screen else {
panic!("not loaded");
};
loaded.focus
}
fn selected_index(app: &App) -> usize {
let Screen::Loaded(loaded) = &app.screen else {
panic!("not loaded");
};
loaded.table_state.selected().unwrap()
}
fn rebuild_rev(app: &App) -> u64 {
let Screen::Loaded(loaded) = &app.screen else {
panic!("not loaded");
};
loaded.rebuild_rev
}
#[test]
fn pane_hit_test_maps_point_to_focus() {
let panes = PaneRects {
tree: Rect::new(0, 0, 40, 20),
preview: Some(Rect::new(80, 0, 40, 20)),
};
assert_eq!(panes.hit(5, 5), Some(Focus::Tree));
assert_eq!(panes.hit(90, 5), Some(Focus::Preview));
assert_eq!(panes.hit(60, 5), None); assert_eq!(panes.hit(5, 50), None); }
#[test]
fn click_focuses_the_pane_under_the_cursor() {
let mut app = sample_app();
set_panes(&mut app);
app.handle_click(90, 5);
assert_eq!(focus(&app), Focus::Preview);
app.handle_click(5, 5);
assert_eq!(focus(&app), Focus::Tree);
app.handle_click(90, 5);
app.handle_click(60, 5);
assert_eq!(focus(&app), Focus::Preview);
}
#[test]
fn click_selects_the_clicked_tree_row() {
let mut app = sample_app();
set_panes(&mut app);
set_viewport(&mut app, 17);
select(&mut app, "src");
app.update(Action::Expand); app.update(Action::First); app.handle_click(5, 4);
assert_eq!(selected_index(&app), 2);
assert_eq!(focus(&app), Focus::Tree);
}
#[test]
fn click_on_the_header_or_border_only_focuses() {
let mut app = sample_app();
set_panes(&mut app);
set_viewport(&mut app, 17);
app.update(Action::First);
let before = selected_index(&app);
app.handle_click(5, 0); assert_eq!(focus(&app), Focus::Tree);
assert_eq!(selected_index(&app), before);
app.handle_click(5, 1); assert_eq!(selected_index(&app), before);
}
#[test]
fn clicking_the_selected_directory_row_activates_it() {
let mut app = sample_app();
set_panes(&mut app);
set_viewport(&mut app, 17);
select(&mut app, "src");
assert!(!is_visible(&app, "main.rs"));
let row = 2 + selected_index(&app) as u16;
app.handle_click(5, row);
assert!(is_visible(&app, "main.rs"));
}
#[test]
fn clicking_the_selected_file_row_opens_the_reader() {
let mut app = sample_app();
set_panes(&mut app);
set_viewport(&mut app, 17);
select(&mut app, "README.md");
let row = 2 + selected_index(&app) as u16;
app.handle_click(5, row);
assert!(matches!(app.screen, Screen::Reader(_)));
}
#[test]
fn content_changes_bump_the_rebuild_rev_but_navigation_does_not() {
let mut app = sample_app();
select(&mut app, "src");
let base = rebuild_rev(&app);
app.update(Action::Down);
app.update(Action::Up);
assert_eq!(rebuild_rev(&app), base, "navigation must not bump the rev");
select(&mut app, "src");
app.update(Action::Expand);
let after_expand = rebuild_rev(&app);
assert!(after_expand > base, "expansion should bump the rev");
app.update(Action::CycleSort);
let after_sort = rebuild_rev(&app);
assert!(after_sort > after_expand, "sorting should bump the rev");
app.set_filter("rs".into());
assert!(
rebuild_rev(&app) > after_sort,
"filtering should bump the rev"
);
}
#[test]
fn clicking_a_new_tree_row_selects_without_activating() {
let mut app = sample_app();
set_panes(&mut app);
set_viewport(&mut app, 17);
select(&mut app, "src");
let readme = {
let Screen::Loaded(loaded) = &app.screen else {
panic!("not loaded");
};
loaded
.visible
.iter()
.position(|&id| loaded.tree.nodes[id].name == "README.md")
.unwrap()
};
app.handle_click(5, 2 + readme as u16);
assert!(matches!(app.screen, Screen::Loaded(_)));
assert_eq!(selected_index(&app), readme);
}
#[test]
fn wheel_moves_by_notch_count_and_focuses_the_pane() {
let mut app = sample_app();
set_panes(&mut app);
select(&mut app, "src");
app.update(Action::Expand); app.update(Action::First); let top = selected_index(&app);
app.handle_scroll(5, 5, 2);
assert_eq!(selected_index(&app), top + 2);
assert_eq!(focus(&app), Focus::Tree);
app.update(Action::Last);
let last = selected_index(&app);
app.update(Action::First);
app.handle_scroll(5, 5, 99);
assert_eq!(selected_index(&app), last);
app.handle_scroll(5, 5, -99);
assert_eq!(selected_index(&app), top);
let now = selected_index(&app);
app.handle_scroll(90, 5, 1);
assert_eq!(selected_index(&app), now);
assert_eq!(focus(&app), Focus::Preview);
}
#[test]
fn preview_refresh_is_gated_until_the_selection_settles() {
let mut app = sample_app();
set_panes(&mut app);
select(&mut app, "src");
assert!(app.preview_target_id().is_some());
app.refresh_preview();
assert_eq!(app.preview_target_id(), None);
app.update(Action::TogglePreview);
select(&mut app, "README.md");
assert_eq!(app.preview_target_id(), None);
}
#[test]
fn navigation_scrolls_the_preview_when_it_is_focused() {
let mut app = sample_app();
select(&mut app, "src");
app.update(Action::Expand);
select(&mut app, "src");
let before = selected_index(&app);
if let Screen::Loaded(loaded) = &mut app.screen {
loaded.focus = Focus::Preview;
}
app.update(Action::Down);
assert_eq!(
selected_index(&app),
before,
"tree selection must not move while the preview is focused"
);
if let Screen::Loaded(loaded) = &mut app.screen {
loaded.focus = Focus::Tree;
}
app.update(Action::Down);
assert_eq!(selected_index(&app), before + 1);
}
#[test]
fn cycle_focus_requires_a_visible_preview() {
let mut app = sample_app();
app.update(Action::CycleFocus);
assert_eq!(focus(&app), Focus::Tree); set_panes(&mut app);
app.update(Action::CycleFocus);
assert_eq!(focus(&app), Focus::Preview);
app.update(Action::CycleFocus);
assert_eq!(focus(&app), Focus::Tree);
}
#[test]
fn toggle_mouse_capture_queues_a_flip() {
let mut app = sample_app();
app.update(Action::ToggleMouseCapture);
assert_eq!(app.pending_capture, Some(true));
}
#[test]
fn on_scan_requests_the_default_code_lens() {
let tree = build_skeleton(&[(PathBuf::from("a.rs"), 1)], &[], "p".into());
let mut app = App::new(PathBuf::from("/p"), "p".into());
app.on_scan(ScanOutcome {
tree,
duration: Duration::ZERO,
repo: false,
});
assert_eq!(app.pending_compute, Some(Lens::Code));
assert!(app.is_busy());
}
#[test]
fn enter_on_a_file_opens_the_reader() {
let mut app = sample_app();
select(&mut app, "README.md");
app.update(Action::Open);
assert!(app.pending_edit.is_none(), "should not shell out to $PAGER");
match &app.screen {
Screen::Reader(reader) => {
assert_eq!(reader.path, PathBuf::from("/proj/README.md"));
}
_ => panic!("expected the reader screen"),
}
}
#[test]
fn closing_the_reader_restores_the_tree() {
let mut app = sample_app();
select(&mut app, "src");
app.update(Action::Expand);
assert!(is_visible(&app, "main.rs"));
select(&mut app, "README.md");
app.update(Action::Open);
assert!(matches!(app.screen, Screen::Reader(_)));
app.handle_key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE));
assert!(matches!(app.screen, Screen::Loaded(_)));
assert!(is_visible(&app, "main.rs"), "expansion should be preserved");
}
#[test]
fn edit_on_a_file_queues_it_for_editing() {
let mut app = sample_app();
select(&mut app, "README.md");
app.update(Action::Edit);
assert_eq!(app.pending_edit, Some(PathBuf::from("/proj/README.md")));
}
#[test]
fn edit_on_a_directory_is_a_noop() {
let mut app = sample_app();
select(&mut app, "src");
app.update(Action::Edit);
assert!(app.pending_edit.is_none());
assert!(!is_visible(&app, "main.rs"));
}
#[test]
fn enter_on_a_directory_expands_without_opening_the_reader() {
let mut app = sample_app();
assert!(!is_visible(&app, "main.rs"));
select(&mut app, "src");
app.update(Action::Open);
assert!(matches!(app.screen, Screen::Loaded(_)));
assert!(is_visible(&app, "main.rs"));
}
#[test]
fn cycle_lens_advances_to_size_and_does_not_recompute() {
let mut app = sample_app(); app.update(Action::CycleLens);
assert_eq!(active_lens(&app), Lens::Size);
assert!(app.pending_compute.is_none());
}
#[test]
fn jump_to_unavailable_git_lens_is_a_noop() {
let mut app = sample_app(); app.update(Action::JumpLens(3));
assert_eq!(active_lens(&app), Lens::Code);
}
#[test]
fn git_lens_is_available_and_requested_in_a_repo() {
let mut app = sample_app();
app.repo = true;
app.pending_compute = None;
app.update(Action::JumpLens(3)); assert_eq!(active_lens(&app), Lens::Churn);
assert_eq!(app.pending_compute, Some(Lens::Churn));
}
#[test]
fn reactivating_a_computed_lens_does_not_request_again() {
let mut app = sample_app();
app.update(Action::CycleLens); app.update(Action::JumpLens(1)); assert_eq!(active_lens(&app), Lens::Code);
assert!(app.pending_compute.is_none());
}
#[test]
fn on_layer_caches_code_and_sorts_by_lines() {
let mut app = sample_app();
let mut files = HashMap::new();
let main = CodeData {
num: CodeNum {
code: 100,
comments: 0,
blanks: 0,
},
..Default::default()
};
files.insert(PathBuf::from("src/main.rs"), main);
app.on_layer(LayerResult::Code {
files,
inaccurate: false,
});
let Screen::Loaded(loaded) = &app.screen else {
panic!("not loaded");
};
assert!(loaded.code_at(loaded.tree.root).is_some());
let src_id = loaded.tree.index[&PathBuf::from("src")];
assert_eq!(loaded.value(SubKey::Lines, src_id), 100);
}
#[test]
fn chained_segment_expands_and_collapses_as_one_unit() {
let mut app = chained_app();
assert!(is_visible(&app, "a"));
assert!(!is_visible(&app, "b"));
assert!(!is_visible(&app, "c"));
assert!(!is_visible(&app, "deep.rs"));
let Screen::Loaded(loaded) = &app.screen else {
panic!("not loaded");
};
let a = loaded.tree.index[&PathBuf::from("a")];
assert_eq!(loaded.display_name(a), "a/b/c");
select(&mut app, "a");
app.update(Action::Expand);
assert!(is_visible(&app, "deep.rs"));
assert!(!is_visible(&app, "b"));
assert!(!is_visible(&app, "c"));
select(&mut app, "a");
app.update(Action::Collapse);
assert!(!is_visible(&app, "deep.rs"));
}
#[test]
fn collapse_from_chained_child_jumps_to_the_segment_row() {
let mut app = chained_app();
select(&mut app, "a");
app.update(Action::Expand);
select(&mut app, "deep.rs");
app.update(Action::Collapse);
let Screen::Loaded(loaded) = &app.screen else {
panic!("not loaded");
};
let selected = loaded.selected_id().unwrap();
assert_eq!(loaded.tree.nodes[selected].name, "a");
}
#[test]
fn toggle_zeros_hides_zero_value_rows() {
let mut app = sample_app(); app.update(Action::ToggleZeros);
assert!(!is_visible(&app, "src"));
app.update(Action::ToggleZeros);
assert!(is_visible(&app, "src"));
}
#[test]
fn on_rescan_preserves_view_and_invalidates_layers() {
let mut app = sample_app();
select(&mut app, "src");
app.update(Action::Expand);
select(&mut app, "main.rs");
let files = vec![
(PathBuf::from("src/main.rs"), 1000),
(PathBuf::from("README.md"), 200),
(PathBuf::from("src/new.rs"), 50),
];
let tree = build_skeleton(&files, &[PathBuf::from("src")], "proj".into());
app.on_rescan(ScanOutcome {
tree,
duration: Duration::ZERO,
repo: false,
});
assert!(is_visible(&app, "main.rs"));
assert!(is_visible(&app, "new.rs"));
let Screen::Loaded(loaded) = &app.screen else {
panic!("not loaded");
};
let selected = loaded.selected_id().unwrap();
assert_eq!(loaded.tree.nodes[selected].name, "main.rs");
assert!(loaded.code.is_computing());
assert_eq!(app.pending_compute, Some(Lens::Code));
}
#[test]
fn on_rescan_is_a_noop_when_the_skeleton_is_unchanged() {
let mut app = sample_app();
let mut files = HashMap::new();
files.insert(
PathBuf::from("src/main.rs"),
CodeData {
num: CodeNum {
code: 10,
comments: 0,
blanks: 0,
},
..Default::default()
},
);
app.on_layer(LayerResult::Code {
files,
inaccurate: false,
});
app.pending_compute = None;
let files = vec![
(PathBuf::from("src/main.rs"), 1000),
(PathBuf::from("README.md"), 200),
];
let tree = build_skeleton(&files, &[PathBuf::from("src")], "proj".into());
app.on_rescan(ScanOutcome {
tree,
duration: Duration::ZERO,
repo: false,
});
let Screen::Loaded(loaded) = &app.screen else {
panic!("not loaded");
};
assert!(loaded.code_at(loaded.tree.root).is_some());
assert!(app.pending_compute.is_none());
}
}