use std::collections::HashSet;
use std::path::{Path, PathBuf};
use crate::comments::Comments;
use crate::tree::{Row, RowKind};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CommentScope {
Worktree,
Commit(String),
}
const MAX_HSCROLL: usize = 500;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Section {
Unstaged,
Staged,
Commit,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Mode {
Unstaged,
Staged,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Pane {
Files,
Diff,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ViewMode {
Changes,
Commits,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Status {
Added,
Modified,
Deleted,
Renamed,
Other,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LineKind {
Hunk,
Add,
Del,
Context,
}
#[derive(Clone, Debug)]
pub struct FileChange {
pub path: PathBuf,
pub status: Status,
}
#[derive(Clone, Debug)]
pub struct DiffLine {
pub kind: LineKind,
pub text: String,
pub old_lineno: Option<u32>,
pub new_lineno: Option<u32>,
}
impl DiffLine {
pub fn context(text: &str, old: u32, new: u32) -> Self {
DiffLine { kind: LineKind::Context, text: text.into(), old_lineno: Some(old), new_lineno: Some(new) }
}
}
pub struct InputState {
pub buffer: String, pub target_file: PathBuf,
pub target_line: u32,
pub target_hunk: String,
pub anchor_line_text: String,
pub anchor_before: Vec<String>,
pub anchor_after: Vec<String>,
}
pub struct CommittedComment {
pub file: PathBuf,
pub line: u32,
pub hunk: String,
pub text: String,
pub line_text: String,
pub context_before: Vec<String>,
pub context_after: Vec<String>,
}
pub struct App {
pub repo_root: PathBuf,
pub focus: Pane,
pub view: ViewMode,
pub unstaged: Vec<FileChange>,
pub staged: Vec<FileChange>,
pub selected: usize,
pub diff: Vec<DiffLine>,
pub diff_cursor: usize,
pub diff_hscroll: usize,
pub reviewed: HashSet<PathBuf>,
pub status_msg: Option<String>,
pub collapsed: HashSet<(Section, PathBuf)>,
pub rows: Vec<Row>,
pub hide_reviewed: bool,
pub context_lines: u32,
pub full_file: bool,
pub show_files: bool,
pub file_pane_pct: u16,
pub comments: Comments,
pub input: Option<InputState>,
pub commits: Vec<crate::git::CommitInfo>,
pub selected_commit: usize,
pub open_commit: Option<String>,
pub commit_files: Vec<FileChange>,
pub show_help: bool,
pub comment_scope: CommentScope,
}
enum RowId {
File(Section, std::path::PathBuf),
Dir(Section, std::path::PathBuf),
}
impl App {
pub fn new(unstaged: Vec<FileChange>, staged: Vec<FileChange>, repo_root: PathBuf) -> Self {
let mut app = App {
repo_root,
focus: Pane::Files,
view: ViewMode::Changes,
unstaged,
staged,
selected: 0,
diff: Vec::new(),
diff_cursor: 0,
diff_hscroll: 0,
reviewed: HashSet::new(),
status_msg: None,
collapsed: HashSet::new(),
rows: Vec::new(),
hide_reviewed: false,
context_lines: 3,
full_file: false,
show_files: true,
file_pane_pct: 25,
comments: Comments::default(),
input: None,
commits: Vec::new(),
selected_commit: 0,
open_commit: None,
commit_files: Vec::new(),
show_help: false,
comment_scope: CommentScope::Worktree,
};
app.rebuild_rows();
app
}
pub fn toggle_full_file(&mut self) {
self.full_file = !self.full_file;
}
pub fn toggle_help(&mut self) {
self.show_help = !self.show_help;
}
pub fn toggle_files(&mut self) {
self.show_files = !self.show_files;
if !self.show_files {
self.focus = Pane::Diff;
} else {
self.focus = Pane::Files;
}
}
pub fn widen_files(&mut self) {
self.file_pane_pct = (self.file_pane_pct + 5).min(60);
}
pub fn narrow_files(&mut self) {
self.file_pane_pct = self.file_pane_pct.saturating_sub(5).max(10);
}
pub fn effective_context(&self) -> u32 {
if self.full_file { u32::MAX } else { self.context_lines }
}
pub fn rebuild_rows(&mut self) {
let prev = self.selected_identity();
let empty = HashSet::new();
let hidden = if self.hide_reviewed { &self.reviewed } else { &empty };
self.rows = if self.view == ViewMode::Commits && self.open_commit.is_some() {
crate::tree::build_commit_rows(&self.commit_files, &self.collapsed, hidden)
} else {
crate::tree::build_rows(&self.unstaged, &self.staged, &self.collapsed, hidden)
};
self.selected = match prev.and_then(|id| self.find_row(&id)) {
Some(i) => i,
None => self.selected.min(self.rows.len().saturating_sub(1)),
};
}
fn selected_identity(&self) -> Option<RowId> {
let row = self.rows.get(self.selected)?;
match &row.kind {
RowKind::File { section, file_index } => {
return self
.section_files(*section)
.get(*file_index)
.map(|f| RowId::File(*section, f.path.clone()));
}
RowKind::Dir { section, path, .. } => Some(RowId::Dir(*section, path.clone())),
RowKind::Header { .. } => None,
}
}
fn find_row(&self, id: &RowId) -> Option<usize> {
self.rows.iter().position(|r| match (&r.kind, id) {
(RowKind::File { section: rs, file_index }, RowId::File(s, p)) if rs == s => {
self.section_files(*rs).get(*file_index).map_or(false, |f| &f.path == p)
}
(RowKind::Dir { section: rs, path, .. }, RowId::Dir(s, p)) if rs == s => path == p,
_ => false,
})
}
pub fn section_files(&self, section: Section) -> &[FileChange] {
match section {
Section::Unstaged => &self.unstaged,
Section::Staged => &self.staged,
Section::Commit => &self.commit_files,
}
}
pub fn toggle_hide_reviewed(&mut self) {
self.hide_reviewed = !self.hide_reviewed;
self.rebuild_rows();
}
pub fn selected_path(&self) -> Option<&PathBuf> {
match self.rows.get(self.selected) {
Some(Row { kind: RowKind::File { section, file_index }, .. }) => {
self.section_files(*section).get(*file_index).map(|f| &f.path)
}
_ => None,
}
}
pub fn selected_section(&self) -> Option<Section> {
match self.rows.get(self.selected) {
Some(Row { kind: RowKind::File { section, .. }, .. }) => Some(*section),
_ => None,
}
}
pub fn selected_file_index(&self) -> Option<usize> {
match self.rows.get(self.selected) {
Some(Row { kind: RowKind::File { file_index, .. }, .. }) => Some(*file_index),
_ => None,
}
}
pub fn set_diff(&mut self, diff: Vec<DiffLine>) {
self.diff = diff;
self.diff_cursor = 0;
self.diff_hscroll = 0;
}
pub fn move_selection(&mut self, delta: isize) {
if self.rows.is_empty() {
return;
}
let max = self.rows.len() as isize - 1;
let next = (self.selected as isize + delta).clamp(0, max);
self.selected = next as usize;
}
pub fn scroll_h(&mut self, delta: isize) {
let next = (self.diff_hscroll as isize + delta).max(0);
self.diff_hscroll = (next as usize).min(MAX_HSCROLL);
}
pub fn move_diff_cursor(&mut self, delta: isize) {
if self.diff.is_empty() {
self.diff_cursor = 0;
return;
}
let max = self.diff.len() as isize - 1;
self.diff_cursor = (self.diff_cursor as isize + delta).clamp(0, max) as usize;
}
pub fn to_top(&mut self) {
match self.focus {
Pane::Files => self.selected = 0,
Pane::Diff => self.diff_cursor = 0,
}
}
pub fn to_bottom(&mut self) {
match self.focus {
Pane::Files => self.selected = self.rows.len().saturating_sub(1),
Pane::Diff => self.diff_cursor = self.diff.len().saturating_sub(1),
}
}
pub fn toggle_focus(&mut self) {
self.focus = match self.focus {
Pane::Files => Pane::Diff,
Pane::Diff => Pane::Files,
};
}
pub fn toggle_reviewed(&mut self) {
let Some(path) = self.selected_path().cloned() else {
return;
};
if !self.reviewed.remove(&path) {
self.reviewed.insert(path);
}
self.rebuild_rows();
}
pub fn is_reviewed_path(&self, path: &Path) -> bool {
self.reviewed.contains(path)
}
pub fn is_reviewed(&self, idx: usize) -> bool {
self.unstaged
.get(idx)
.map(|f| self.reviewed.contains(&f.path))
.unwrap_or(false)
}
pub fn toggle_collapse(&mut self) {
if let Some(row) = self.rows.get(self.selected) {
if let RowKind::Dir { section, path, .. } = &row.kind {
let key = (*section, path.clone());
if self.collapsed.contains(&key) {
self.collapsed.remove(&key);
} else {
self.collapsed.insert(key);
}
self.rebuild_rows();
}
}
}
pub fn next_view(&mut self) {
let prev = self.view;
self.view = match self.view {
ViewMode::Changes => ViewMode::Commits,
ViewMode::Commits => ViewMode::Changes,
};
if prev == ViewMode::Commits && self.view == ViewMode::Changes {
self.open_commit = None;
self.commit_files.clear();
self.comment_scope = CommentScope::Worktree;
}
self.rebuild_rows();
}
pub fn prev_view(&mut self) {
self.next_view();
}
pub fn move_commit_selection(&mut self, delta: isize) {
if self.commits.is_empty() {
return;
}
let max = self.commits.len() as isize - 1;
let next = (self.selected_commit as isize + delta).clamp(0, max);
self.selected_commit = next as usize;
}
pub fn selected_commit_info(&self) -> Option<&crate::git::CommitInfo> {
self.commits.get(self.selected_commit)
}
pub fn open_commit(&mut self, id: String, files: Vec<FileChange>) {
self.comment_scope = CommentScope::Commit(id.clone());
self.commit_files = files;
self.open_commit = Some(id);
self.selected = 0;
self.rebuild_rows();
}
pub fn close_commit(&mut self) {
self.open_commit = None;
self.commit_files.clear();
self.comment_scope = CommentScope::Worktree;
self.rebuild_rows();
}
pub fn scope_label(&self) -> String {
match &self.comment_scope {
CommentScope::Worktree => "worktree".to_string(),
CommentScope::Commit(sha) => format!("commit:{}", sha),
}
}
pub fn in_commit_detail(&self) -> bool {
self.view == ViewMode::Commits && self.open_commit.is_some()
}
pub fn open_commit_short(&self) -> Option<&str> {
let id = self.open_commit.as_deref()?;
self.commits.iter().find(|c| c.id == id).map(|c| c.short.as_str())
}
pub fn inc_context(&mut self) {
self.context_lines = (self.context_lines + 5).min(50);
}
pub fn dec_context(&mut self) {
self.context_lines = self.context_lines.saturating_sub(5);
}
pub fn current_diff_line(&self) -> Option<&DiffLine> {
self.diff.get(self.diff_cursor)
}
pub fn current_hunk_header(&self) -> String {
let end = self.diff_cursor.min(self.diff.len().saturating_sub(1));
for i in (0..=end).rev() {
if self.diff[i].kind == LineKind::Hunk {
return self.diff[i].text.clone();
}
}
String::new()
}
pub fn start_comment(&mut self) {
if self.focus != Pane::Diff {
return;
}
let Some(file) = self.selected_path().cloned() else {
self.status_msg = Some("comment: no file selected".to_string());
return;
};
let Some(dl) = self.diff.get(self.diff_cursor) else {
self.status_msg = Some("comment: place cursor on a line".to_string());
return;
};
if dl.kind == LineKind::Hunk {
self.status_msg = Some("comment: place cursor on a line".to_string());
return;
}
let Some(line_no) = dl.new_lineno else {
self.status_msg = Some("comment: place cursor on a line".to_string());
return;
};
let hunk = self.current_hunk_header();
let existing = self.comments.get(&file, line_no).map(|c| c.text.clone()).unwrap_or_default();
let (anchor_line_text, anchor_before, anchor_after) = self.comment_anchor();
self.input = Some(InputState {
buffer: existing,
target_file: file,
target_line: line_no,
target_hunk: hunk,
anchor_line_text,
anchor_before,
anchor_after,
});
}
pub fn input_active(&self) -> bool {
self.input.is_some()
}
pub fn input_push(&mut self, ch: char) {
if let Some(ref mut s) = self.input {
s.buffer.push(ch);
}
}
pub fn input_backspace(&mut self) {
if let Some(ref mut s) = self.input {
s.buffer.pop();
}
}
pub fn input_newline(&mut self) {
if let Some(ref mut s) = self.input {
s.buffer.push('\n');
}
}
pub fn input_cancel(&mut self) {
self.input = None;
}
pub fn input_commit(&mut self) -> Option<CommittedComment> {
let s = self.input.take()?;
Some(CommittedComment {
file: s.target_file,
line: s.target_line,
hunk: s.target_hunk,
text: s.buffer,
line_text: s.anchor_line_text,
context_before: s.anchor_before,
context_after: s.anchor_after,
})
}
pub fn comment_anchor(&self) -> (String, Vec<String>, Vec<String>) {
let raw_trimmed = self.diff.get(self.diff_cursor)
.map(|l| l.text.trim().to_string())
.unwrap_or_default();
let line_text = if raw_trimmed.is_empty() {
"\u{0}".to_string()
} else {
raw_trimmed
};
let cursor = self.diff_cursor.min(self.diff.len());
let before: Vec<String> = self.diff[..cursor]
.iter()
.rev()
.filter(|l| l.kind != LineKind::Hunk)
.take(2)
.map(|l| l.text.trim().to_string())
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
let after: Vec<String> = self.diff.get(self.diff_cursor + 1..)
.unwrap_or(&[])
.iter()
.filter(|l| l.kind != LineKind::Hunk)
.take(2)
.map(|l| l.text.trim().to_string())
.collect();
(line_text, before, after)
}
pub fn comment_for<'a>(&'a self, line: &DiffLine) -> Option<&'a crate::comments::Comment> {
let n = line.new_lineno?;
let file = self.selected_path()?;
self.comments.get(file, n)
}
pub fn has_comment(&self, line: &DiffLine) -> bool {
if let Some(n) = line.new_lineno {
if let Some(file) = self.selected_path() {
return self.comments.get(file, n).is_some();
}
}
false
}
pub fn comment_text_for<'a>(&'a self, line: &DiffLine) -> Option<&'a str> {
let n = line.new_lineno?;
let file = self.selected_path()?;
self.comments.get(file, n).map(|c| c.text.as_str())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn sample() -> App {
let files = vec![
FileChange { path: PathBuf::from("a.rs"), status: Status::Modified },
FileChange { path: PathBuf::from("b.rs"), status: Status::Added },
FileChange { path: PathBuf::from("c.rs"), status: Status::Deleted },
];
App::new(files, vec![], PathBuf::from("/repo"))
}
#[test]
fn selection_moves_and_clamps() {
let mut app = sample();
assert_eq!(app.selected, 0);
app.move_selection(1);
assert_eq!(app.selected, 1);
app.move_selection(-5);
assert_eq!(app.selected, 0); app.move_selection(99);
assert_eq!(app.selected, 4); }
#[test]
fn focus_toggle() {
let mut app = sample();
assert_eq!(app.focus, Pane::Files);
app.toggle_focus();
assert_eq!(app.focus, Pane::Diff);
app.toggle_focus();
assert_eq!(app.focus, Pane::Files);
}
#[test]
fn gg_g_on_files_pane_moves_selection() {
let mut app = sample();
app.focus = Pane::Files;
app.to_bottom();
assert_eq!(app.selected, 4);
app.to_top();
assert_eq!(app.selected, 0);
}
#[test]
fn set_diff_resets_cursor() {
let mut app = sample();
app.set_diff(vec![DiffLine::context("x", 1, 1); 5]);
app.focus = Pane::Diff;
app.move_diff_cursor(3);
assert_eq!(app.diff_cursor, 3);
app.set_diff(vec![DiffLine::context("y", 1, 1); 2]);
assert_eq!(app.diff_cursor, 0);
}
#[test]
fn diff_cursor_moves_and_clamps() {
let mut app = sample();
app.set_diff(vec![DiffLine::context("x", 1, 1); 5]);
app.focus = Pane::Diff;
app.move_diff_cursor(-3);
assert_eq!(app.diff_cursor, 0);
app.move_diff_cursor(100);
assert_eq!(app.diff_cursor, 4);
app.to_top();
assert_eq!(app.diff_cursor, 0);
app.to_bottom();
assert_eq!(app.diff_cursor, 4);
}
#[test]
fn toggle_reviewed_tracks_selected_file() {
let mut app = sample();
app.selected = 1;
assert!(!app.is_reviewed(0));
app.toggle_reviewed();
assert!(app.is_reviewed(0));
app.toggle_reviewed();
assert!(!app.is_reviewed(0));
}
#[test]
fn hscroll_clamps_at_zero_and_resets_on_set_diff() {
let mut app = sample();
app.scroll_h(-5);
assert_eq!(app.diff_hscroll, 0); app.scroll_h(3);
assert_eq!(app.diff_hscroll, 3);
app.set_diff(vec![DiffLine::context("x", 1, 1); 2]);
assert_eq!(app.diff_hscroll, 0); }
#[test]
fn flat_files_selected_path_resolves_via_rows() {
let mut app = sample();
app.selected = 1; assert_eq!(app.selected_path(), Some(&PathBuf::from("a.rs")));
}
#[test]
fn selected_file_index_returns_correct_index() {
let mut app = sample();
app.selected = 2;
assert_eq!(app.selected_file_index(), Some(1));
}
#[test]
fn move_selection_clamps_against_rows_length() {
let mut app = sample();
app.move_selection(99);
assert_eq!(app.selected, 4); }
#[test]
fn toggle_collapse_hides_and_shows_dir_children() {
let files = vec![
FileChange { path: PathBuf::from("src/main.rs"), status: Status::Modified },
FileChange { path: PathBuf::from("src/ui.rs"), status: Status::Modified },
FileChange { path: PathBuf::from("README.md"), status: Status::Modified },
];
let mut app = App::new(files, vec![], PathBuf::from("/repo"));
assert_eq!(app.rows.len(), 6);
app.selected = 1;
app.toggle_collapse();
assert_eq!(app.rows.len(), 4);
assert!(matches!(app.rows[1].kind, crate::tree::RowKind::Dir { collapsed: true, .. }));
app.selected = 1;
app.toggle_collapse();
assert_eq!(app.rows.len(), 6);
}
#[test]
fn toggle_collapse_on_file_row_does_nothing() {
let mut app = sample(); let initial_len = app.rows.len();
app.selected = 1; app.toggle_collapse();
assert_eq!(app.rows.len(), initial_len);
}
#[test]
fn selected_path_returns_none_for_dir_row() {
let files = vec![
FileChange { path: PathBuf::from("src/main.rs"), status: Status::Modified },
];
let mut app = App::new(files, vec![], PathBuf::from("/repo"));
app.selected = 1; assert_eq!(app.selected_path(), None);
}
#[test]
fn rebuild_rows_called_after_app_new() {
let app = sample();
assert!(!app.rows.is_empty());
assert_eq!(app.rows.len(), 5);
}
#[test]
fn to_bottom_uses_rows_length() {
let mut app = sample();
app.focus = Pane::Files;
app.to_bottom();
assert_eq!(app.selected, app.rows.len() - 1);
}
#[test]
fn hide_reviewed_hides_reviewed_file() {
let files = vec![
FileChange { path: PathBuf::from("a.rs"), status: Status::Modified },
FileChange { path: PathBuf::from("b.rs"), status: Status::Added },
];
let mut app = App::new(files, vec![], PathBuf::from("/repo"));
app.selected = 1;
app.toggle_reviewed();
assert!(app.is_reviewed(0));
assert_eq!(app.rows.len(), 4);
app.toggle_hide_reviewed();
assert_eq!(app.rows.len(), 3);
app.toggle_hide_reviewed();
assert_eq!(app.rows.len(), 4); }
#[test]
fn rebuild_preserves_selection_identity() {
let files = vec![
FileChange { path: PathBuf::from("a.rs"), status: Status::Modified },
FileChange { path: PathBuf::from("b.rs"), status: Status::Added },
FileChange { path: PathBuf::from("c.rs"), status: Status::Deleted },
];
let mut app = App::new(files, vec![], PathBuf::from("/repo"));
app.selected = 3;
app.rebuild_rows();
assert_eq!(app.selected_path().unwrap(), &PathBuf::from("c.rs"));
}
#[test]
fn toggle_reviewed_on_dir_row_does_nothing() {
let files = vec![
FileChange { path: PathBuf::from("src/main.rs"), status: Status::Modified },
];
let mut app = App::new(files, vec![], PathBuf::from("/repo"));
app.selected = 1; app.toggle_reviewed();
assert!(app.reviewed.is_empty());
}
#[test]
fn reviewing_in_hide_mode_drops_file_and_keeps_valid_selection() {
let files = vec![
FileChange { path: PathBuf::from("a.rs"), status: Status::Modified },
FileChange { path: PathBuf::from("b.rs"), status: Status::Added },
];
let mut app = App::new(files, vec![], PathBuf::from("/repo"));
app.toggle_hide_reviewed(); assert_eq!(app.rows.len(), 4);
app.selected = 1;
app.toggle_reviewed();
assert_eq!(app.rows.len(), 3);
assert_eq!(app.selected_path().unwrap(), &PathBuf::from("b.rs"));
}
#[test]
fn selected_section_returns_correct_section() {
let unstaged = vec![FileChange { path: PathBuf::from("a.rs"), status: Status::Modified }];
let staged = vec![FileChange { path: PathBuf::from("b.rs"), status: Status::Added }];
let mut app = App::new(unstaged, staged, PathBuf::from("/repo"));
app.selected = 1;
assert_eq!(app.selected_section(), Some(Section::Unstaged));
app.selected = 3;
assert_eq!(app.selected_section(), Some(Section::Staged));
app.selected = 0; assert_eq!(app.selected_section(), None);
}
#[test]
fn effective_context_returns_context_lines_or_max() {
let mut app = sample();
assert_eq!(app.effective_context(), app.context_lines);
app.toggle_full_file();
assert_eq!(app.effective_context(), u32::MAX);
app.toggle_full_file();
assert_eq!(app.effective_context(), app.context_lines);
}
#[test]
fn context_lines_inc_dec_clamp() {
let mut app = sample();
assert_eq!(app.context_lines, 3);
app.inc_context();
assert_eq!(app.context_lines, 8);
app.dec_context();
assert_eq!(app.context_lines, 3);
for _ in 0..60 {
app.inc_context();
}
assert_eq!(app.context_lines, 50);
for _ in 0..60 {
app.dec_context();
}
assert_eq!(app.context_lines, 0);
app.dec_context();
assert_eq!(app.context_lines, 0);
}
#[test]
fn file_pane_resize_clamps() {
let mut app = sample();
assert_eq!(app.file_pane_pct, 25);
for _ in 0..20 {
app.widen_files();
}
assert_eq!(app.file_pane_pct, 60);
for _ in 0..20 {
app.narrow_files();
}
assert_eq!(app.file_pane_pct, 10);
}
#[test]
fn toggle_files_sets_focus() {
let mut app = sample();
assert_eq!(app.show_files, true);
assert_eq!(app.focus, Pane::Files);
app.toggle_files();
assert_eq!(app.show_files, false);
assert_eq!(app.focus, Pane::Diff);
app.toggle_files();
assert_eq!(app.show_files, true);
assert_eq!(app.focus, Pane::Files);
}
fn app_with_add_diff() -> App {
let files = vec![FileChange { path: PathBuf::from("a.rs"), status: Status::Modified }];
let mut app = App::new(files, vec![], PathBuf::from("/repo"));
app.selected = 1; app.focus = Pane::Diff;
app.set_diff(vec![
DiffLine {
kind: LineKind::Hunk,
text: "@@ -1,4 +1,8 @@".into(),
old_lineno: None,
new_lineno: None,
},
DiffLine {
kind: LineKind::Add,
text: "let x = 1;".into(),
old_lineno: None,
new_lineno: Some(2),
},
]);
app.diff_cursor = 1; app
}
#[test]
fn start_comment_sets_input_with_correct_target() {
let mut app = app_with_add_diff();
app.start_comment();
assert!(app.input.is_some());
let input = app.input.as_ref().unwrap();
assert_eq!(input.target_file, PathBuf::from("a.rs"));
assert_eq!(input.target_line, 2);
assert_eq!(input.target_hunk, "@@ -1,4 +1,8 @@");
assert_eq!(input.buffer, "");
}
#[test]
fn start_comment_prefills_buffer_with_existing_comment() {
let mut app = app_with_add_diff();
app.comments.set(
PathBuf::from("a.rs"),
2,
"@@ -1,4 +1,8 @@".to_string(),
"existing note".to_string(),
"let x = 1;".to_string(),
vec![],
vec![],
);
app.start_comment();
let input = app.input.as_ref().unwrap();
assert_eq!(input.buffer, "existing note");
}
#[test]
fn start_comment_does_nothing_on_hunk_line() {
let mut app = app_with_add_diff();
app.diff_cursor = 0; app.start_comment();
assert!(app.input.is_none());
}
#[test]
fn start_comment_does_nothing_when_not_diff_focused() {
let mut app = app_with_add_diff();
app.focus = Pane::Files;
app.start_comment();
assert!(app.input.is_none());
}
#[test]
fn input_push_backspace_newline_mutate_buffer() {
let mut app = app_with_add_diff();
app.start_comment();
app.input_push('h');
app.input_push('i');
assert_eq!(app.input.as_ref().unwrap().buffer, "hi");
app.input_newline();
assert_eq!(app.input.as_ref().unwrap().buffer, "hi\n");
app.input_push('!');
assert_eq!(app.input.as_ref().unwrap().buffer, "hi\n!");
app.input_backspace();
assert_eq!(app.input.as_ref().unwrap().buffer, "hi\n");
app.input_backspace();
assert_eq!(app.input.as_ref().unwrap().buffer, "hi");
}
#[test]
fn input_commit_returns_committed_comment_and_clears_input() {
let mut app = app_with_add_diff();
app.start_comment();
app.input_push('g');
app.input_push('o');
let result = app.input_commit();
assert!(result.is_some());
let committed = result.unwrap();
assert_eq!(committed.file, PathBuf::from("a.rs"));
assert_eq!(committed.line, 2);
assert_eq!(committed.hunk, "@@ -1,4 +1,8 @@");
assert_eq!(committed.text, "go");
assert_eq!(committed.line_text, "let x = 1;");
assert!(app.input.is_none());
}
#[test]
fn current_hunk_header_finds_preceding_hunk() {
let mut app = app_with_add_diff();
app.diff_cursor = 1; let hunk = app.current_hunk_header();
assert_eq!(hunk, "@@ -1,4 +1,8 @@");
}
#[test]
fn current_hunk_header_returns_empty_when_no_hunk() {
let files = vec![FileChange { path: PathBuf::from("a.rs"), status: Status::Modified }];
let mut app = App::new(files, vec![], PathBuf::from("/repo"));
app.focus = Pane::Diff;
app.selected = 1;
app.set_diff(vec![
DiffLine { kind: LineKind::Context, text: "ctx".into(), old_lineno: Some(1), new_lineno: Some(1) },
]);
app.diff_cursor = 0;
assert_eq!(app.current_hunk_header(), "");
}
#[test]
fn comment_anchor_captures_line_text_and_context() {
let files = vec![FileChange { path: PathBuf::from("a.rs"), status: Status::Modified }];
let mut app = App::new(files, vec![], PathBuf::from("/repo"));
app.focus = Pane::Diff;
app.selected = 1;
app.set_diff(vec![
DiffLine { kind: LineKind::Hunk, text: "@@ -1 +1 @@".into(), old_lineno: None, new_lineno: None },
DiffLine { kind: LineKind::Context, text: " let a = 1; ".into(), old_lineno: Some(1), new_lineno: Some(1) },
DiffLine { kind: LineKind::Add, text: " fn target() ".into(), old_lineno: None, new_lineno: Some(2) },
DiffLine { kind: LineKind::Context, text: " let b = 2; ".into(), old_lineno: Some(3), new_lineno: Some(3) },
]);
app.diff_cursor = 2;
let (line_text, before, after) = app.comment_anchor();
assert_eq!(line_text, "fn target()"); assert_eq!(before, vec!["let a = 1;"]);
assert_eq!(after, vec!["let b = 2;"]);
}
#[test]
fn comment_anchor_skips_hunk_lines_in_context() {
let files = vec![FileChange { path: PathBuf::from("a.rs"), status: Status::Modified }];
let mut app = App::new(files, vec![], PathBuf::from("/repo"));
app.focus = Pane::Diff;
app.selected = 1;
app.set_diff(vec![
DiffLine { kind: LineKind::Hunk, text: "@@ -1 +1 @@".into(), old_lineno: None, new_lineno: None },
DiffLine { kind: LineKind::Add, text: "first".into(), old_lineno: None, new_lineno: Some(1) },
DiffLine { kind: LineKind::Hunk, text: "@@ -5 +5 @@".into(), old_lineno: None, new_lineno: None },
DiffLine { kind: LineKind::Add, text: "target".into(), old_lineno: None, new_lineno: Some(5) },
DiffLine { kind: LineKind::Context, text: "after".into(), old_lineno: Some(6), new_lineno: Some(6) },
]);
app.diff_cursor = 3;
let (line_text, before, after) = app.comment_anchor();
assert_eq!(line_text, "target");
assert_eq!(before, vec!["first"]);
assert_eq!(after, vec!["after"]);
}
#[test]
fn start_comment_captures_anchor_in_input_state() {
let files = vec![FileChange { path: PathBuf::from("a.rs"), status: Status::Modified }];
let mut app = App::new(files, vec![], PathBuf::from("/repo"));
app.selected = 1;
app.focus = Pane::Diff;
app.set_diff(vec![
DiffLine { kind: LineKind::Context, text: " let a = 1; ".into(), old_lineno: Some(1), new_lineno: Some(1) },
DiffLine { kind: LineKind::Add, text: " fn target() ".into(), old_lineno: None, new_lineno: Some(2) },
DiffLine { kind: LineKind::Context, text: " let b = 2; ".into(), old_lineno: Some(3), new_lineno: Some(3) },
]);
app.diff_cursor = 1; app.start_comment();
let input = app.input.as_ref().expect("input should be active");
assert_eq!(input.anchor_line_text, "fn target()");
assert_eq!(input.anchor_before, vec!["let a = 1;"]);
assert_eq!(input.anchor_after, vec!["let b = 2;"]);
}
#[test]
fn toggle_help_flips_show_help() {
let mut app = sample();
assert!(!app.show_help);
app.toggle_help();
assert!(app.show_help);
app.toggle_help();
assert!(!app.show_help);
}
fn make_commit_info(summary: &str) -> crate::git::CommitInfo {
crate::git::CommitInfo {
id: "abcdef1234567890".to_string(),
short: "abcdef12".to_string(),
summary: summary.to_string(),
author: "test".to_string(),
time: "2024-01-01".to_string(),
}
}
#[test]
fn view_mode_toggles_with_next_and_prev_view() {
let mut app = sample();
assert_eq!(app.view, ViewMode::Changes);
app.next_view();
assert_eq!(app.view, ViewMode::Commits);
app.next_view();
assert_eq!(app.view, ViewMode::Changes);
app.prev_view();
assert_eq!(app.view, ViewMode::Commits);
app.prev_view();
assert_eq!(app.view, ViewMode::Changes);
}
#[test]
fn move_commit_selection_clamps() {
let mut app = sample();
app.commits = vec![
make_commit_info("first"),
make_commit_info("second"),
make_commit_info("third"),
];
assert_eq!(app.selected_commit, 0);
app.move_commit_selection(1);
assert_eq!(app.selected_commit, 1);
app.move_commit_selection(99);
assert_eq!(app.selected_commit, 2); app.move_commit_selection(-99);
assert_eq!(app.selected_commit, 0); }
#[test]
fn input_commit_includes_anchor_fields() {
let files = vec![FileChange { path: PathBuf::from("a.rs"), status: Status::Modified }];
let mut app = App::new(files, vec![], PathBuf::from("/repo"));
app.selected = 1;
app.focus = Pane::Diff;
app.set_diff(vec![
DiffLine { kind: LineKind::Context, text: "before_line".into(), old_lineno: Some(1), new_lineno: Some(1) },
DiffLine { kind: LineKind::Add, text: "the_target_line".into(), old_lineno: None, new_lineno: Some(2) },
DiffLine { kind: LineKind::Context, text: "after_line".into(), old_lineno: Some(3), new_lineno: Some(3) },
]);
app.diff_cursor = 1;
app.start_comment();
app.input_push('n');
app.input_push('o');
app.input_push('t');
app.input_push('e');
let result = app.input_commit();
assert!(result.is_some());
let committed = result.unwrap();
assert_eq!(committed.file, PathBuf::from("a.rs"));
assert_eq!(committed.line, 2);
assert_eq!(committed.hunk, ""); assert_eq!(committed.text, "note");
assert_eq!(committed.line_text, "the_target_line");
assert_eq!(committed.context_before, vec!["before_line"]);
assert_eq!(committed.context_after, vec!["after_line"]);
assert!(app.input.is_none());
}
fn make_commit_files(paths: &[&str]) -> Vec<FileChange> {
paths.iter().map(|p| FileChange { path: PathBuf::from(p), status: Status::Modified }).collect()
}
#[test]
fn open_commit_sets_state_and_builds_commit_rows() {
let mut app = sample();
app.view = ViewMode::Commits;
app.commits = vec![crate::git::CommitInfo {
id: "aaaa1111bbbb2222".to_string(),
short: "aaaa1111".to_string(),
summary: "test commit".to_string(),
author: "tester".to_string(),
time: "2024-01-01".to_string(),
}];
let files = make_commit_files(&["src/main.rs", "lib.rs"]);
app.open_commit("aaaa1111bbbb2222".to_string(), files);
assert_eq!(app.open_commit, Some("aaaa1111bbbb2222".to_string()));
assert_eq!(app.commit_files.len(), 2);
assert_eq!(app.selected, 0);
assert!(app.in_commit_detail());
assert!(app.rows.len() >= 3, "expected at least 3 rows (header + files), got {}", app.rows.len());
assert!(matches!(app.rows[0].kind, crate::tree::RowKind::Header { section: Section::Commit, .. }));
let has_commit_file = app.rows.iter().any(|r| matches!(&r.kind, crate::tree::RowKind::File { section: Section::Commit, .. }));
assert!(has_commit_file, "expected File rows with Section::Commit");
}
#[test]
fn close_commit_clears_state_and_rebuilds() {
let mut app = sample();
app.view = ViewMode::Commits;
let files = make_commit_files(&["a.rs"]);
app.open_commit("deadbeef12345678".to_string(), files);
assert!(app.in_commit_detail());
app.close_commit();
assert_eq!(app.open_commit, None);
assert!(app.commit_files.is_empty());
assert!(!app.in_commit_detail());
assert_eq!(app.rows.len(), 5);
}
#[test]
fn switching_view_from_commit_detail_rebuilds_rows() {
let mut app = sample();
app.view = ViewMode::Commits;
let files = make_commit_files(&["a.rs", "b.rs"]);
app.open_commit("deadbeef12345678".to_string(), files);
assert!(app.in_commit_detail());
app.next_view();
assert_eq!(app.open_commit, None);
assert!(app.commit_files.is_empty());
assert!(!app.rows.iter().any(|r| matches!(
&r.kind,
crate::tree::RowKind::File { section: Section::Commit, .. }
| crate::tree::RowKind::Header { section: Section::Commit, .. }
)));
assert!(app.selected < app.rows.len().max(1));
}
#[test]
fn section_files_commit_returns_commit_files() {
let mut app = sample();
let files = make_commit_files(&["x.rs", "y.rs", "z.rs"]);
app.commit_files = files.clone();
let result = app.section_files(Section::Commit);
assert_eq!(result.len(), 3);
assert_eq!(result[0].path, PathBuf::from("x.rs"));
assert_eq!(result[1].path, PathBuf::from("y.rs"));
assert_eq!(result[2].path, PathBuf::from("z.rs"));
}
#[test]
fn open_commit_sets_comment_scope_to_commit() {
let mut app = sample();
app.view = ViewMode::Commits;
assert_eq!(app.comment_scope, CommentScope::Worktree);
let files = make_commit_files(&["a.rs"]);
app.open_commit("deadbeef1234567890abcdef".to_string(), files);
assert_eq!(
app.comment_scope,
CommentScope::Commit("deadbeef1234567890abcdef".to_string())
);
}
#[test]
fn close_commit_resets_comment_scope_to_worktree() {
let mut app = sample();
app.view = ViewMode::Commits;
let files = make_commit_files(&["a.rs"]);
app.open_commit("deadbeef1234567890abcdef".to_string(), files);
assert_eq!(
app.comment_scope,
CommentScope::Commit("deadbeef1234567890abcdef".to_string())
);
app.close_commit();
assert_eq!(app.comment_scope, CommentScope::Worktree);
}
#[test]
fn next_view_from_commits_resets_scope_to_worktree() {
let mut app = sample();
app.view = ViewMode::Commits;
let files = make_commit_files(&["a.rs"]);
app.open_commit("aabbccdd11223344".to_string(), files);
assert_eq!(
app.comment_scope,
CommentScope::Commit("aabbccdd11223344".to_string())
);
app.next_view();
assert_eq!(app.view, ViewMode::Changes);
assert_eq!(app.comment_scope, CommentScope::Worktree);
}
#[test]
fn scope_label_worktree() {
let app = sample();
assert_eq!(app.scope_label(), "worktree");
}
#[test]
fn scope_label_commit() {
let mut app = sample();
app.view = ViewMode::Commits;
let files = make_commit_files(&["a.rs"]);
app.open_commit("abc123def456".to_string(), files);
assert_eq!(app.scope_label(), "commit:abc123def456");
}
}