parley-cli 0.2.0

Terminal-first review tool for AI-generated code changes
Documentation
//! Git file heatmap state and background loading.

use super::*;

impl TuiApp {
    pub(crate) fn start_file_heatmap(&mut self) {
        self.dismiss_ai_progress_popup();
        if self.file_heatmap_task.is_some() {
            self.status_line = "file heatmap already loading".into();
            return;
        }
        self.file_heatmap = Some(FileHeatmapState {
            entries: Vec::new(),
            scroll: 0,
            sort_mode: FileHeatmapSortMode::Churn,
            sort_descending: true,
            loaded_at: None,
        });
        self.file_heatmap_started_at = Some(Instant::now());
        self.file_heatmap_task = Some(task::spawn_blocking(file_heatmap));
        self.status_line = "loading git file heatmap".into();
    }

    pub(crate) async fn poll_file_heatmap(&mut self) -> Result<bool> {
        let Some(task) = self.file_heatmap_task.as_ref() else {
            return Ok(false);
        };
        if !task.is_finished() {
            return Ok(false);
        }

        let task = self
            .file_heatmap_task
            .take()
            .context("file heatmap task missing")?;
        let entries = task.await.context("failed to join file heatmap task")??;
        let count = entries.len();
        let mut heatmap = FileHeatmapState {
            entries,
            scroll: 0,
            sort_mode: FileHeatmapSortMode::Churn,
            sort_descending: true,
            loaded_at: Some(Instant::now()),
        };
        sort_file_heatmap_entries(
            &mut heatmap.entries,
            heatmap.sort_mode,
            heatmap.sort_descending,
        );
        self.file_heatmap = Some(heatmap);
        self.file_heatmap_started_at = None;
        self.status_line = format!("loaded git file heatmap for {count} file(s)");
        Ok(true)
    }

    pub(crate) fn close_file_heatmap(&mut self) {
        if let Some(task) = self.file_heatmap_task.take() {
            task.abort();
        }
        self.file_heatmap_started_at = None;
        self.file_heatmap = None;
        self.status_line = "file heatmap closed".into();
    }

    pub(crate) fn scroll_file_heatmap(&mut self, delta: isize) {
        let Some(heatmap) = self.file_heatmap.as_mut() else {
            return;
        };
        if delta.is_negative() {
            heatmap.scroll = heatmap.scroll.saturating_sub(delta.unsigned_abs());
        } else {
            heatmap.scroll = heatmap.scroll.saturating_add(delta as usize);
        }
    }

    pub(crate) fn file_heatmap_is_loading(&self) -> bool {
        self.file_heatmap_task.is_some()
    }

    pub(crate) fn cycle_file_heatmap_sort(&mut self) {
        let Some(heatmap) = self.file_heatmap.as_mut() else {
            return;
        };
        heatmap.sort_mode = heatmap.sort_mode.next();
        heatmap.scroll = 0;
        sort_file_heatmap_entries(
            &mut heatmap.entries,
            heatmap.sort_mode,
            heatmap.sort_descending,
        );
        self.status_line = format!(
            "file heatmap sort: {} {}",
            heatmap.sort_mode.label(),
            heatmap.sort_direction_label()
        );
    }

    pub(crate) fn toggle_file_heatmap_sort_direction(&mut self) {
        let Some(heatmap) = self.file_heatmap.as_mut() else {
            return;
        };
        heatmap.sort_descending = !heatmap.sort_descending;
        heatmap.scroll = 0;
        sort_file_heatmap_entries(
            &mut heatmap.entries,
            heatmap.sort_mode,
            heatmap.sort_descending,
        );
        self.status_line = format!(
            "file heatmap sort: {} {}",
            heatmap.sort_mode.label(),
            heatmap.sort_direction_label()
        );
    }
}

impl FileHeatmapState {
    pub(crate) fn sort_direction_label(&self) -> &'static str {
        if self.sort_descending { "desc" } else { "asc" }
    }
}

impl FileHeatmapSortMode {
    fn next(self) -> Self {
        match self {
            Self::Churn => Self::Added,
            Self::Added => Self::Removed,
            Self::Removed => Self::Commits,
            Self::Commits => Self::NetGrowth,
            Self::NetGrowth => Self::NetShrink,
            Self::NetShrink => Self::Volatility,
            Self::Volatility => Self::Path,
            Self::Path => Self::Churn,
        }
    }

    pub(crate) fn label(self) -> &'static str {
        match self {
            Self::Churn => "churn",
            Self::Added => "added",
            Self::Removed => "removed",
            Self::Commits => "commits",
            Self::NetGrowth => "net-growth",
            Self::NetShrink => "net-shrink",
            Self::Volatility => "volatility",
            Self::Path => "path",
        }
    }
}

fn sort_file_heatmap_entries(
    entries: &mut [FileHeatmapEntry],
    mode: FileHeatmapSortMode,
    descending: bool,
) {
    entries.sort_by(|left, right| {
        let order = match mode {
            FileHeatmapSortMode::Churn => left.changes.cmp(&right.changes),
            FileHeatmapSortMode::Added => left.insertions.cmp(&right.insertions),
            FileHeatmapSortMode::Removed => left.deletions.cmp(&right.deletions),
            FileHeatmapSortMode::Commits => left.commits.cmp(&right.commits),
            FileHeatmapSortMode::NetGrowth => net_growth(left).cmp(&net_growth(right)),
            FileHeatmapSortMode::NetShrink => net_shrink(left).cmp(&net_shrink(right)),
            FileHeatmapSortMode::Volatility => volatility(left).cmp(&volatility(right)),
            FileHeatmapSortMode::Path => right.path.cmp(&left.path),
        };
        if descending {
            order.reverse().then_with(|| left.path.cmp(&right.path))
        } else {
            order.then_with(|| left.path.cmp(&right.path))
        }
    });
}

fn net_growth(entry: &FileHeatmapEntry) -> isize {
    entry.insertions as isize - entry.deletions as isize
}

fn net_shrink(entry: &FileHeatmapEntry) -> isize {
    entry.deletions as isize - entry.insertions as isize
}

fn volatility(entry: &FileHeatmapEntry) -> usize {
    entry.changes.saturating_mul(entry.commits)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::git::history::FileHeatmapEntry;
    use crate::tui::app::state::tests::make_test_app;

    #[test]
    fn file_heatmap_is_not_loaded_on_startup() -> Result<()> {
        let app = make_test_app(vec!["src/a.rs"], Vec::new())?;

        assert!(app.file_heatmap.is_none());
        assert!(app.file_heatmap_task.is_none());
        assert!(app.file_heatmap_started_at.is_none());
        Ok(())
    }

    #[test]
    fn sort_file_heatmap_entries_orders_by_selected_metric() {
        let mut entries = vec![
            heatmap_entry("src/a.rs", 2, 10, 1),
            heatmap_entry("src/b.rs", 1, 3, 20),
            heatmap_entry("src/c.rs", 5, 2, 2),
        ];

        sort_file_heatmap_entries(&mut entries, FileHeatmapSortMode::Removed, true);

        assert_eq!(entries[0].path, "src/b.rs");
    }

    #[test]
    fn cycle_file_heatmap_sort_resets_scroll_and_reorders_entries() -> Result<()> {
        let mut app = make_test_app(vec!["src/a.rs"], Vec::new())?;
        app.file_heatmap = Some(FileHeatmapState {
            entries: vec![
                heatmap_entry("src/a.rs", 2, 10, 1),
                heatmap_entry("src/b.rs", 1, 3, 20),
            ],
            scroll: 9,
            sort_mode: FileHeatmapSortMode::Churn,
            sort_descending: true,
            loaded_at: None,
        });

        app.cycle_file_heatmap_sort();

        let heatmap = app.file_heatmap.as_ref().context("missing heatmap")?;
        assert_eq!(heatmap.sort_mode, FileHeatmapSortMode::Added);
        assert_eq!(heatmap.scroll, 0);
        assert_eq!(heatmap.entries[0].path, "src/a.rs");
        Ok(())
    }

    fn heatmap_entry(
        path: &str,
        commits: usize,
        insertions: usize,
        deletions: usize,
    ) -> FileHeatmapEntry {
        FileHeatmapEntry {
            path: path.to_string(),
            commits,
            changes: insertions + deletions,
            insertions,
            deletions,
        }
    }
}