mod input;
mod render;
use crate::model::{FileState, Status};
use crate::ui::navigation;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum StatusInputMode {
#[default]
Normal,
CommitInput,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StatusAction {
ShowFileDiff {
change_id: String,
file_path: String,
},
OpenBlame {
file_path: String,
},
Commit { message: String },
JumpToConflict,
RestoreFile { file_path: String },
RestoreAll,
DiffEdit { file_path: String },
None,
}
#[derive(Debug)]
pub struct StatusView {
pub(super) status: Option<Status>,
pub(super) selected_index: usize,
pub(super) scroll_offset: usize,
pub input_mode: StatusInputMode,
pub input_buffer: String,
}
impl Default for StatusView {
fn default() -> Self {
Self::new()
}
}
impl StatusView {
pub(super) const DEFAULT_VISIBLE_COUNT: usize = 20;
pub fn new() -> Self {
Self {
status: None,
selected_index: 0,
scroll_offset: 0,
input_mode: StatusInputMode::Normal,
input_buffer: String::new(),
}
}
pub fn start_commit_input(&mut self) {
self.input_mode = StatusInputMode::CommitInput;
self.input_buffer.clear();
}
pub fn cancel_input(&mut self) {
self.input_mode = StatusInputMode::Normal;
self.input_buffer.clear();
}
pub fn set_status(&mut self, status: Status) {
self.status = Some(status);
if let Some(ref s) = self.status {
if self.selected_index >= s.files.len() {
self.selected_index = 0;
self.scroll_offset = 0;
}
if self.scroll_offset >= s.files.len() {
self.scroll_offset = 0;
}
}
}
pub fn selected_file_path(&self) -> Option<&str> {
self.status
.as_ref()
.and_then(|s| s.files.get(self.selected_index))
.map(|f| f.path.as_str())
}
pub fn working_copy_id(&self) -> Option<&str> {
self.status
.as_ref()
.map(|s| s.working_copy_change_id.as_str())
}
#[allow(dead_code)] pub fn has_conflicts(&self) -> bool {
self.status.as_ref().is_some_and(|s| s.has_conflicts)
}
fn jump_to_first_conflict(&mut self) -> bool {
if let Some(ref status) = self.status
&& let Some(idx) = status
.files
.iter()
.position(|f| matches!(f.state, FileState::Conflicted))
{
self.selected_index = idx;
self.scroll_offset = navigation::adjust_scroll(
self.selected_index,
self.scroll_offset,
Self::DEFAULT_VISIBLE_COUNT,
);
return true;
}
false
}
fn move_down(&mut self, visible_count: usize) {
if let Some(ref status) = self.status {
let max = status.files.len().saturating_sub(1);
self.selected_index = navigation::select_next(self.selected_index, max);
self.scroll_offset =
navigation::adjust_scroll(self.selected_index, self.scroll_offset, visible_count);
}
}
fn move_up(&mut self, visible_count: usize) {
self.selected_index = navigation::select_prev(self.selected_index);
self.scroll_offset =
navigation::adjust_scroll(self.selected_index, self.scroll_offset, visible_count);
}
fn jump_to_top(&mut self) {
self.selected_index = 0;
self.scroll_offset = 0;
}
fn jump_to_bottom(&mut self, visible_count: usize) {
if let Some(ref status) = self.status
&& !status.files.is_empty()
{
self.selected_index = status.files.len() - 1;
self.scroll_offset =
navigation::adjust_scroll(self.selected_index, self.scroll_offset, visible_count);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{ChangeId, FileStatus};
use crossterm::event::{KeyCode, KeyEvent};
fn sample_status() -> Status {
Status {
files: vec![
FileStatus {
path: "src/main.rs".to_string(),
state: FileState::Modified,
},
FileStatus {
path: "src/new.rs".to_string(),
state: FileState::Added,
},
FileStatus {
path: "old.rs".to_string(),
state: FileState::Deleted,
},
],
has_conflicts: false,
working_copy_change_id: ChangeId::new("abc12345".to_string()),
parent_change_id: ChangeId::new("xyz98765".to_string()),
}
}
#[test]
fn test_new_status_view() {
let view = StatusView::new();
assert!(view.status.is_none());
assert_eq!(view.selected_index, 0);
}
#[test]
fn test_set_status() {
let mut view = StatusView::new();
view.set_status(sample_status());
assert!(view.status.is_some());
assert_eq!(view.status.as_ref().unwrap().files.len(), 3);
}
#[test]
fn test_move_down() {
let mut view = StatusView::new();
view.set_status(sample_status());
assert_eq!(view.selected_index, 0);
view.move_down(20);
assert_eq!(view.selected_index, 1);
view.move_down(20);
assert_eq!(view.selected_index, 2);
view.move_down(20); assert_eq!(view.selected_index, 2);
}
#[test]
fn test_move_up() {
let mut view = StatusView::new();
view.set_status(sample_status());
view.selected_index = 2;
view.move_up(20);
assert_eq!(view.selected_index, 1);
view.move_up(20);
assert_eq!(view.selected_index, 0);
view.move_up(20); assert_eq!(view.selected_index, 0);
}
#[test]
fn test_jump_to_top_bottom() {
let mut view = StatusView::new();
view.set_status(sample_status());
view.selected_index = 1;
view.jump_to_bottom(20);
assert_eq!(view.selected_index, 2);
view.jump_to_top();
assert_eq!(view.selected_index, 0);
}
#[test]
fn test_selected_file_path() {
let mut view = StatusView::new();
view.set_status(sample_status());
assert_eq!(view.selected_file_path(), Some("src/main.rs"));
view.selected_index = 1;
assert_eq!(view.selected_file_path(), Some("src/new.rs"));
}
#[test]
fn test_working_copy_id() {
let mut view = StatusView::new();
view.set_status(sample_status());
assert_eq!(view.working_copy_id(), Some("abc12345"));
}
#[test]
fn test_handle_key_navigation() {
let mut view = StatusView::new();
view.set_status(sample_status());
let action = view.handle_key(KeyEvent::from(KeyCode::Char('j')));
assert_eq!(action, StatusAction::None);
assert_eq!(view.selected_index, 1);
let action = view.handle_key(KeyEvent::from(KeyCode::Char('k')));
assert_eq!(action, StatusAction::None);
assert_eq!(view.selected_index, 0);
}
#[test]
fn test_handle_key_open_diff() {
let mut view = StatusView::new();
view.set_status(sample_status());
let action = view.handle_key(KeyEvent::from(KeyCode::Enter));
match action {
StatusAction::ShowFileDiff {
change_id,
file_path,
} => {
assert_eq!(change_id, "abc12345");
assert_eq!(file_path, "src/main.rs");
}
_ => panic!("Expected ShowFileDiff action"),
}
}
#[test]
fn test_empty_status() {
let mut view = StatusView::new();
let empty_status = Status {
files: vec![],
has_conflicts: false,
working_copy_change_id: ChangeId::new("abc".to_string()),
parent_change_id: ChangeId::new("xyz".to_string()),
};
view.set_status(empty_status);
assert!(view.status.as_ref().unwrap().is_clean());
}
#[test]
fn test_has_conflicts() {
let mut view = StatusView::new();
assert!(!view.has_conflicts());
let no_conflict_status = Status {
files: vec![],
has_conflicts: false,
working_copy_change_id: ChangeId::new("abc".to_string()),
parent_change_id: ChangeId::new("xyz".to_string()),
};
view.set_status(no_conflict_status);
assert!(!view.has_conflicts());
let conflict_status = Status {
files: vec![],
has_conflicts: true,
working_copy_change_id: ChangeId::new("abc".to_string()),
parent_change_id: ChangeId::new("xyz".to_string()),
};
view.set_status(conflict_status);
assert!(view.has_conflicts());
}
fn status_with_conflicts() -> Status {
Status {
files: vec![
FileStatus {
path: "src/main.rs".to_string(),
state: FileState::Modified,
},
FileStatus {
path: "src/conflict.rs".to_string(),
state: FileState::Conflicted,
},
FileStatus {
path: "src/other.rs".to_string(),
state: FileState::Added,
},
],
has_conflicts: true,
working_copy_change_id: ChangeId::new("abc12345".to_string()),
parent_change_id: ChangeId::new("xyz98765".to_string()),
}
}
#[test]
fn test_jump_to_first_conflict() {
let mut view = StatusView::new();
view.set_status(status_with_conflicts());
assert_eq!(view.selected_index, 0);
assert!(view.jump_to_first_conflict());
assert_eq!(view.selected_index, 1); }
#[test]
fn test_jump_to_first_conflict_no_conflicts() {
let mut view = StatusView::new();
view.set_status(sample_status());
assert_eq!(view.selected_index, 0);
assert!(!view.jump_to_first_conflict());
assert_eq!(view.selected_index, 0); }
#[test]
fn test_f_key_with_conflicts() {
let mut view = StatusView::new();
view.set_status(status_with_conflicts());
let action = view.handle_key(KeyEvent::from(KeyCode::Char('f')));
assert_eq!(action, StatusAction::JumpToConflict);
assert_eq!(view.selected_index, 1);
}
#[test]
fn test_r_key_returns_restore_file() {
let mut view = StatusView::new();
view.set_status(sample_status());
let action = view.handle_key(KeyEvent::from(KeyCode::Char('r')));
match action {
StatusAction::RestoreFile { file_path } => {
assert_eq!(file_path, "src/main.rs");
}
_ => panic!("Expected RestoreFile action, got {:?}", action),
}
}
#[test]
fn test_r_uppercase_returns_restore_all() {
let mut view = StatusView::new();
view.set_status(sample_status());
let action = view.handle_key(KeyEvent::from(KeyCode::Char('R')));
assert_eq!(action, StatusAction::RestoreAll);
}
#[test]
fn test_r_key_different_from_r_uppercase() {
let mut view = StatusView::new();
view.set_status(sample_status());
let action_file = view.handle_key(KeyEvent::from(KeyCode::Char('r')));
assert!(matches!(action_file, StatusAction::RestoreFile { .. }));
let action_all = view.handle_key(KeyEvent::from(KeyCode::Char('R')));
assert_eq!(action_all, StatusAction::RestoreAll);
}
#[test]
fn test_r_key_no_file_selected() {
let mut view = StatusView::new();
let action = view.handle_key(KeyEvent::from(KeyCode::Char('r')));
assert_eq!(action, StatusAction::None);
}
#[test]
fn test_r_key_empty_status() {
let mut view = StatusView::new();
let empty_status = Status {
files: vec![],
has_conflicts: false,
working_copy_change_id: ChangeId::new("abc".to_string()),
parent_change_id: ChangeId::new("xyz".to_string()),
};
view.set_status(empty_status);
let action = view.handle_key(KeyEvent::from(KeyCode::Char('r')));
assert_eq!(action, StatusAction::None);
}
#[test]
fn test_restore_all_empty_status() {
let mut view = StatusView::new();
let empty_status = Status {
files: vec![],
has_conflicts: false,
working_copy_change_id: ChangeId::new("abc".to_string()),
parent_change_id: ChangeId::new("xyz".to_string()),
};
view.set_status(empty_status);
let action = view.handle_key(KeyEvent::from(KeyCode::Char('R')));
assert_eq!(action, StatusAction::None);
}
#[test]
fn test_restore_keys_ignored_in_commit_input_mode() {
let mut view = StatusView::new();
view.set_status(sample_status());
view.start_commit_input();
let action = view.handle_key(KeyEvent::from(KeyCode::Char('r')));
assert_eq!(action, StatusAction::None);
assert_eq!(view.input_buffer, "r");
}
#[test]
fn test_e_uppercase_returns_diffedit() {
let mut view = StatusView::new();
view.set_status(sample_status());
let action = view.handle_key(KeyEvent::from(KeyCode::Char('E')));
match action {
StatusAction::DiffEdit { file_path } => {
assert_eq!(file_path, "src/main.rs");
}
_ => panic!("Expected DiffEdit action, got {:?}", action),
}
}
#[test]
fn test_f_key_without_conflicts() {
let mut view = StatusView::new();
view.set_status(sample_status());
let action = view.handle_key(KeyEvent::from(KeyCode::Char('f')));
assert_eq!(action, StatusAction::None);
assert_eq!(view.selected_index, 0);
}
}