use std::{
fmt::Debug,
path::{Path, PathBuf},
};
use crate::{
error::Result,
git::{Commit, DiffLine},
};
trait DiffLoader: Debug {
fn load_diff(
&self,
repo_root: &Path,
commit_hash: &str,
repo_path: &Path,
) -> Result<Vec<DiffLine>>;
}
#[derive(Debug)]
struct GitDiffLoader;
impl DiffLoader for GitDiffLoader {
fn load_diff(
&self,
repo_root: &Path,
commit_hash: &str,
repo_path: &Path,
) -> Result<Vec<DiffLine>> {
crate::git::load_diff(repo_root, commit_hash, repo_path)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Mode {
List,
Diff,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Action {
Quit,
Cancel,
Help,
Down,
Up,
Open,
NextCommit,
PreviousCommit,
Top,
Bottom,
PageDown,
PageUp,
}
#[derive(Debug)]
pub(crate) struct App {
repo_root: PathBuf,
file_path: PathBuf,
commits: Vec<Commit>,
selected: usize,
mode: Mode,
diff_lines: Vec<DiffLine>,
diff_scroll: usize,
diff_view_height: usize,
error: Option<String>,
show_help: bool,
should_quit: bool,
diff_loader: Box<dyn DiffLoader>,
}
impl App {
pub(crate) fn new(repo_root: PathBuf, file_path: PathBuf, commits: Vec<Commit>) -> Self {
Self::with_diff_loader(repo_root, file_path, commits, Box::new(GitDiffLoader))
}
fn with_diff_loader(
repo_root: PathBuf,
file_path: PathBuf,
commits: Vec<Commit>,
diff_loader: Box<dyn DiffLoader>,
) -> Self {
Self {
repo_root,
file_path,
commits,
selected: 0,
mode: Mode::List,
diff_lines: Vec::new(),
diff_scroll: 0,
diff_view_height: 0,
error: None,
show_help: false,
should_quit: false,
diff_loader,
}
}
pub(crate) fn apply(&mut self, action: Action) {
if action == Action::Quit {
self.should_quit = true;
return;
}
if self.error.is_some() {
if action == Action::Cancel {
self.error = None;
}
return;
}
if action == Action::Help {
self.show_help = !self.show_help;
return;
}
if self.show_help {
if action == Action::Cancel {
self.show_help = false;
}
return;
}
match self.mode {
Mode::List => self.apply_list_action(action),
Mode::Diff => self.apply_diff_action(action),
}
}
#[must_use]
pub(crate) fn mode(&self) -> Mode {
self.mode
}
#[must_use]
pub(crate) fn should_quit(&self) -> bool {
self.should_quit
}
#[must_use]
pub(crate) fn error(&self) -> Option<&str> {
self.error.as_deref()
}
#[must_use]
pub(crate) fn show_help(&self) -> bool {
self.show_help
}
#[must_use]
pub(crate) fn file_path(&self) -> &Path {
&self.file_path
}
#[must_use]
pub(crate) fn commits(&self) -> &[Commit] {
&self.commits
}
#[must_use]
pub(crate) fn selected_index(&self) -> Option<usize> {
(!self.commits.is_empty()).then_some(self.selected)
}
#[must_use]
pub(crate) fn selected_commit(&self) -> Option<&Commit> {
self.commits.get(self.selected)
}
#[must_use]
pub(crate) fn diff_lines(&self) -> &[DiffLine] {
&self.diff_lines
}
#[must_use]
pub(crate) fn diff_scroll(&self) -> usize {
self.diff_scroll
}
pub(crate) fn set_diff_view_height(&mut self, height: usize) {
self.diff_view_height = height;
self.clamp_diff_scroll();
}
#[must_use]
pub(crate) fn scroll_percent(&self) -> u16 {
let max = self.max_diff_scroll();
if max == 0 {
return 0;
}
self.diff_scroll
.saturating_mul(100)
.checked_div(max)
.map_or(0, |percent| percent as u16)
}
fn apply_list_action(&mut self, action: Action) {
match action {
Action::Cancel => self.should_quit = true,
Action::Down => self.select_next_wrapping(),
Action::Up => self.select_previous_wrapping(),
Action::NextCommit => self.select_next_wrapping(),
Action::PreviousCommit => self.select_previous_wrapping(),
Action::Open => self.open_diff_at(self.selected, true),
_ => {}
}
}
fn apply_diff_action(&mut self, action: Action) {
match action {
Action::Cancel => self.mode = Mode::List,
Action::Down => self.scroll_diff_down(),
Action::Up => self.scroll_diff_up(),
Action::NextCommit => self.open_relative_diff(1),
Action::PreviousCommit => self.open_relative_diff(-1),
Action::Top => self.diff_scroll = 0,
Action::Bottom => self.diff_scroll = self.max_diff_scroll(),
Action::PageDown => {
let step = self.diff_view_height.max(1);
self.diff_scroll = (self.diff_scroll + step).min(self.max_diff_scroll());
}
Action::PageUp => {
let step = self.diff_view_height.max(1);
self.diff_scroll = self.diff_scroll.saturating_sub(step);
}
_ => {}
}
}
fn select(&mut self, index: usize) {
if self.commits.is_empty() {
self.selected = 0;
return;
}
self.selected = index.min(self.commits.len() - 1);
}
fn select_next_wrapping(&mut self) {
if !self.commits.is_empty() {
self.select((self.selected + 1) % self.commits.len());
}
}
fn select_previous_wrapping(&mut self) {
if self.commits.is_empty() {
return;
}
let next = if self.selected == 0 {
self.commits.len() - 1
} else {
self.selected - 1
};
self.select(next);
}
fn max_diff_scroll(&self) -> usize {
self.diff_lines.len().saturating_sub(self.diff_view_height)
}
fn clamp_diff_scroll(&mut self) {
self.diff_scroll = self.diff_scroll.min(self.max_diff_scroll());
}
fn scroll_diff_down(&mut self) {
self.diff_scroll = (self.diff_scroll + 1).min(self.max_diff_scroll());
}
fn scroll_diff_up(&mut self) {
self.diff_scroll = self.diff_scroll.saturating_sub(1);
}
fn open_relative_diff(&mut self, offset: isize) {
if self.commits.is_empty() {
return;
}
let target = self
.selected
.saturating_add_signed(offset)
.min(self.commits.len() - 1);
if target != self.selected {
self.open_diff_at(target, false);
}
}
fn open_diff_at(&mut self, index: usize, reset_scroll: bool) {
if self.commits.is_empty() {
return;
}
let index = index.min(self.commits.len() - 1);
let previous_index = self.selected;
self.select(index);
let hash = &self.commits[index].hash;
let scroll = if reset_scroll { 0 } else { self.diff_scroll };
match self
.diff_loader
.load_diff(&self.repo_root, hash, &self.file_path)
{
Ok(diff_lines) => {
self.diff_lines = diff_lines;
self.diff_scroll = scroll;
self.mode = Mode::Diff;
self.error = None;
self.clamp_diff_scroll();
}
Err(err) => {
let error = format!("failed to load diff for {hash}: {err}");
self.select(previous_index);
self.error = Some(error);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::AppError;
use crate::git::DiffLineKind;
const HASH_A: &str = "0123456789abcdef0123456789abcdef01234567";
const HASH_B: &str = "abcdef0123456789abcdef0123456789abcdef01";
#[derive(Debug, Default)]
struct TestDiffLoader {
failing_hash: Option<String>,
}
impl DiffLoader for TestDiffLoader {
fn load_diff(
&self,
_repo_root: &Path,
commit_hash: &str,
_repo_path: &Path,
) -> Result<Vec<DiffLine>> {
if self.failing_hash.as_deref() == Some(commit_hash) {
return Err(AppError::message("test diff load failed"));
}
Ok(vec![DiffLine {
text: format!("diff {commit_hash}"),
kind: DiffLineKind::Context,
}])
}
}
fn commit(hash: &str) -> Commit {
Commit {
hash: hash.to_string(),
subject: format!("subject {hash}"),
description: "Author, 1 day ago".to_string(),
}
}
fn app_with_commits(commits: Vec<Commit>) -> App {
App::with_diff_loader(
PathBuf::from("."),
PathBuf::from("file.rs"),
commits,
Box::new(TestDiffLoader::default()),
)
}
fn app_with_failing_diff(commits: Vec<Commit>, failing_hash: &str) -> App {
App::with_diff_loader(
PathBuf::from("."),
PathBuf::from("file.rs"),
commits,
Box::new(TestDiffLoader {
failing_hash: Some(failing_hash.to_string()),
}),
)
}
#[test]
fn open_loads_diff_through_adapter() {
let mut app = app_with_commits(vec![commit(HASH_A)]);
app.apply(Action::Open);
assert_eq!(app.mode(), Mode::Diff);
assert_eq!(app.diff_lines()[0].text, format!("diff {HASH_A}"));
assert!(app.error().is_none());
}
#[test]
fn list_selection_wraps_in_list_mode() {
let mut app = app_with_commits(vec![commit("a"), commit("b")]);
app.apply(Action::Up);
assert_eq!(app.selected_index(), Some(1));
app.apply(Action::Down);
assert_eq!(app.selected_index(), Some(0));
}
#[test]
fn commit_switch_actions_move_selection_in_list_mode() {
let mut app = app_with_commits(vec![commit("a"), commit("b")]);
app.apply(Action::PreviousCommit);
assert_eq!(app.selected_index(), Some(1));
app.apply(Action::NextCommit);
assert_eq!(app.selected_index(), Some(0));
}
#[test]
fn diff_scroll_is_clamped_to_view_height() {
let mut app = app_with_commits(vec![commit("a")]);
app.mode = Mode::Diff;
app.diff_lines = (0..10)
.map(|idx| DiffLine {
text: idx.to_string(),
kind: DiffLineKind::Context,
})
.collect();
app.set_diff_view_height(3);
app.apply(Action::Bottom);
assert_eq!(app.diff_scroll(), 7);
app.set_diff_view_height(5);
assert_eq!(app.diff_scroll(), 5);
app.apply(Action::Top);
assert_eq!(app.diff_scroll(), 0);
}
#[test]
fn scroll_percent_returns_zero_when_no_scroll_is_available() {
let mut app = app_with_commits(vec![commit("a")]);
app.mode = Mode::Diff;
app.diff_lines = (0..3)
.map(|idx| DiffLine {
text: idx.to_string(),
kind: DiffLineKind::Context,
})
.collect();
app.set_diff_view_height(5);
assert_eq!(app.scroll_percent(), 0);
}
#[test]
fn scroll_percent_returns_hundred_at_bottom() {
let mut app = app_with_commits(vec![commit("a")]);
app.mode = Mode::Diff;
app.diff_lines = (0..10)
.map(|idx| DiffLine {
text: idx.to_string(),
kind: DiffLineKind::Context,
})
.collect();
app.set_diff_view_height(5);
app.apply(Action::Bottom);
assert_eq!(app.scroll_percent(), 100);
}
#[test]
fn cancel_quits_list_but_returns_from_diff() {
let mut app = app_with_commits(vec![commit("a")]);
app.apply(Action::Cancel);
assert!(app.should_quit());
let mut app = app_with_commits(vec![commit("a")]);
app.mode = Mode::Diff;
app.apply(Action::Cancel);
assert_eq!(app.mode(), Mode::List);
assert!(!app.should_quit());
}
#[test]
fn failed_relative_diff_load_preserves_previous_selection() {
let mut app = app_with_failing_diff(vec![commit(HASH_A), commit(HASH_B)], HASH_B);
app.mode = Mode::Diff;
app.diff_lines = vec![DiffLine {
text: "old diff".to_string(),
kind: DiffLineKind::Context,
}];
app.apply(Action::NextCommit);
assert_eq!(app.selected_index(), Some(0));
assert_eq!(app.diff_lines()[0].text, "old diff");
assert!(app.error().is_some());
}
#[test]
fn help_does_not_block_error_dismissal() {
let mut app = app_with_failing_diff(vec![commit(HASH_A), commit(HASH_B)], HASH_B);
app.mode = Mode::Diff;
app.apply(Action::NextCommit);
assert!(app.error().is_some());
app.apply(Action::Help);
app.apply(Action::Cancel);
assert!(app.error().is_none());
assert!(!app.show_help());
}
#[test]
fn help_toggles_and_blocks_navigation_until_dismissed() {
let mut app = app_with_commits(vec![commit("a"), commit("b")]);
app.apply(Action::Help);
assert!(app.show_help());
app.apply(Action::Down);
assert_eq!(app.selected_index(), Some(0));
app.apply(Action::Cancel);
assert!(!app.show_help());
app.apply(Action::Down);
assert_eq!(app.selected_index(), Some(1));
}
}