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),
}
#[derive(Clone, Debug)]
pub struct FileHistory {
pub file: PathBuf,
pub commits: Vec<crate::git::CommitInfo>,
pub idx: usize,
pub baseline_scope: CommentScope,
}
#[derive(Clone, Debug)]
pub struct SearchState {
pub query: String,
pub matches: Vec<usize>,
pub cur: usize,
}
const MAX_FILE_HISTORY: usize = 50;
pub const MAX_CONTEXT_LINES: u32 = 50;
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,
Comments,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CommentRow {
Header(crate::comments::CommentStatus, usize),
Item(usize),
}
#[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 select_anchor: Option<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 commit_limit: usize,
pub commit_stats: std::collections::HashMap<String, crate::git::CommitStat>,
pub open_commit: Option<String>,
pub commit_files: Vec<FileChange>,
pub show_help: bool,
pub comment_scope: CommentScope,
pub show_comments: bool,
pub comment_selected: usize,
pub theme: crate::theme::Theme,
pub split_diff: bool,
pub history: Option<FileHistory>,
pub search: Option<SearchState>,
pub search_input: Option<String>,
}
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,
select_anchor: None,
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,
commit_limit: crate::COMMIT_PAGE,
commit_stats: std::collections::HashMap::new(),
open_commit: None,
commit_files: Vec::new(),
show_help: false,
comment_scope: CommentScope::Worktree,
show_comments: false,
comment_selected: 0,
theme: crate::theme::Theme::Dark,
split_diff: false,
history: None,
search: None,
search_input: None,
};
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_theme(&mut self) {
self.theme = match self.theme {
crate::theme::Theme::Dark => crate::theme::Theme::Light,
crate::theme::Theme::Light => crate::theme::Theme::Dark,
};
}
pub fn toggle_split(&mut self) {
self.split_diff = !self.split_diff;
}
pub fn palette(&self) -> crate::theme::Palette {
crate::theme::Palette::for_theme(self.theme)
}
pub fn toggle_files(&mut self) {
self.show_files = !self.show_files;
if !self.show_files && self.focus == Pane::Files {
self.focus = Pane::Diff;
}
}
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;
self.search = None;
self.search_input = None;
}
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;
let mut pos = (self.diff_cursor as isize + delta).clamp(0, max);
let skip = |k: LineKind| -> bool {
k == LineKind::Hunk || (self.split_diff && k == LineKind::Del)
};
let step = if delta >= 0 { 1 } else { -1 };
while skip(self.diff[pos as usize].kind) {
let next = pos + step;
if next < 0 || next > max {
let mut back = pos - step;
while (0..=max).contains(&back) && skip(self.diff[back as usize].kind) {
back -= step;
}
if (0..=max).contains(&back) {
pos = back;
}
break;
}
pos = next;
}
self.diff_cursor = pos as usize;
}
pub fn select_active(&self) -> bool {
self.select_anchor.is_some()
}
pub fn start_select(&mut self) {
self.select_anchor = Some(self.diff_cursor);
}
pub fn cancel_select(&mut self) {
self.select_anchor = None;
}
pub fn select_range(&self) -> (usize, usize) {
match self.select_anchor {
Some(a) => (a.min(self.diff_cursor), a.max(self.diff_cursor)),
None => (self.diff_cursor, self.diff_cursor),
}
}
pub fn selection_text(&self) -> String {
let (lo, hi) = self.select_range();
let mut out = String::new();
for dl in &self.diff[lo..=hi] {
if dl.kind == LineKind::Hunk {
continue;
}
out.push_str(&dl.text);
out.push('\n');
}
out
}
pub fn to_top(&mut self) {
match self.focus {
Pane::Files => self.selected = 0,
Pane::Diff => self.diff_cursor = 0,
Pane::Comments => self.comment_selected = 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),
Pane::Comments => {
let len = self.comment_rows().len();
self.comment_selected = len.saturating_sub(1);
}
}
}
pub fn toggle_focus(&mut self) {
let panes: Vec<Pane> = [Pane::Files, Pane::Diff, Pane::Comments]
.iter()
.copied()
.filter(|&p| match p {
Pane::Files => self.show_files,
Pane::Diff => true,
Pane::Comments => self.show_comments,
})
.collect();
if panes.len() <= 1 {
return; }
let current = panes.iter().position(|&p| p == self.focus).unwrap_or(0);
self.focus = panes[(current + 1) % panes.len()];
}
pub fn toggle_comment_pane(&mut self) {
self.show_comments = !self.show_comments;
if self.show_comments {
self.focus = Pane::Comments;
} else if self.focus == Pane::Comments {
self.focus = Pane::Diff;
}
}
pub fn comment_rows(&self) -> Vec<CommentRow> {
use crate::comments::CommentStatus;
let order = [
CommentStatus::Open,
CommentStatus::NeedsInfo,
CommentStatus::Wontfix,
CommentStatus::Resolved,
];
let mut rows = Vec::new();
for status in &order {
let mut indices: Vec<usize> = self
.comments
.items
.iter()
.enumerate()
.filter(|(_, c)| &c.status == status)
.map(|(i, _)| i)
.collect();
if !indices.is_empty() {
indices.sort_by(|&a, &b| {
self.comments.items[b]
.updated
.cmp(&self.comments.items[a].updated)
});
rows.push(CommentRow::Header(*status, indices.len()));
for i in indices {
rows.push(CommentRow::Item(i));
}
}
}
rows
}
pub fn move_comment_selection(&mut self, delta: isize) {
let len = self.comment_rows().len();
if len == 0 {
self.comment_selected = 0;
return;
}
let max = len as isize - 1;
self.comment_selected = (self.comment_selected as isize + delta).clamp(0, max) as usize;
}
pub fn selected_comment(&self) -> Option<&crate::comments::Comment> {
match self.comment_rows().get(self.comment_selected) {
Some(CommentRow::Item(i)) => self.comments.items.get(*i),
_ => None,
}
}
pub fn select_row_for_path(&mut self, path: &Path) -> bool {
for (i, row) in self.rows.iter().enumerate() {
if let RowKind::File {
section,
file_index,
} = &row.kind
{
if let Some(fc) = self.section_files(*section).get(*file_index) {
if fc.path == path {
self.selected = i;
return true;
}
}
}
}
false
}
pub fn cursor_lineno(&self) -> Option<u32> {
self.diff
.get(self.diff_cursor)
.and_then(|l| l.new_lineno.or(l.old_lineno))
}
pub fn diff_has_lineno(&self, lineno: u32) -> bool {
self.diff
.iter()
.any(|l| l.new_lineno == Some(lineno) || l.old_lineno == Some(lineno))
}
pub fn move_cursor_to_lineno(&mut self, lineno: u32) {
for (i, dl) in self.diff.iter().enumerate() {
if dl.new_lineno == Some(lineno) {
self.diff_cursor = i;
return;
}
}
for (i, dl) in self.diff.iter().enumerate() {
if dl.old_lineno == Some(lineno) {
self.diff_cursor = i;
return;
}
}
self.diff_cursor = 0;
}
pub fn move_cursor_to_line(&mut self, new_lineno: u32) {
self.move_cursor_to_lineno(new_lineno);
}
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();
}
}
}
fn active_sections(&self) -> Vec<Section> {
if self.in_commit_detail() {
vec![Section::Commit]
} else {
vec![Section::Unstaged, Section::Staged]
}
}
fn all_dir_keys(&self) -> HashSet<(Section, PathBuf)> {
let mut keys = HashSet::new();
for section in self.active_sections() {
for fc in self.section_files(section) {
let mut acc = PathBuf::new();
let comps: Vec<_> = fc.path.components().collect();
for comp in comps.iter().take(comps.len().saturating_sub(1)) {
acc.push(comp);
keys.insert((section, acc.clone()));
}
}
}
keys
}
pub fn toggle_fold_all(&mut self) {
let dirs = self.all_dir_keys();
if dirs.is_empty() {
return;
}
let any_expanded = dirs.iter().any(|k| !self.collapsed.contains(k));
if any_expanded {
for k in dirs {
self.collapsed.insert(k);
}
} else {
for k in &dirs {
self.collapsed.remove(k);
}
}
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 const MAX_FILE_HISTORY: usize = MAX_FILE_HISTORY;
pub const MAX_CONTEXT_LINES: u32 = MAX_CONTEXT_LINES;
pub fn start_history(&mut self, commits: Vec<crate::git::CommitInfo>) -> bool {
if commits.is_empty() {
return false;
}
let Some(file) = self.selected_path().cloned() else {
return false;
};
self.history = Some(FileHistory {
file,
commits,
idx: 1,
baseline_scope: self.comment_scope.clone(),
});
true
}
pub fn history_step(&mut self, delta: isize) {
if let Some(h) = self.history.as_mut() {
let max = h.commits.len() as isize;
h.idx = (h.idx as isize + delta).clamp(0, max) as usize;
}
}
pub fn history_current_commit(&self) -> Option<&crate::git::CommitInfo> {
let h = self.history.as_ref()?;
if h.idx == 0 {
None
} else {
h.commits.get(h.idx - 1)
}
}
pub fn exit_history(&mut self) {
if let Some(h) = self.history.take() {
self.comment_scope = h.baseline_scope;
}
}
pub fn history_active(&self) -> bool {
self.history.is_some()
}
pub fn search_start(&mut self) {
self.search_input = Some(String::new());
}
pub fn search_input_active(&self) -> bool {
self.search_input.is_some()
}
pub fn search_active(&self) -> bool {
self.search.is_some()
}
pub fn search_input_push(&mut self, ch: char) {
if let Some(buf) = self.search_input.as_mut() {
buf.push(ch);
}
}
pub fn search_input_backspace(&mut self) {
if let Some(buf) = self.search_input.as_mut() {
buf.pop();
}
}
pub fn search_input_cancel(&mut self) {
self.search_input = None;
}
pub fn search_clear(&mut self) {
self.search = None;
self.search_input = None;
}
pub fn search_commit(&mut self) -> bool {
let query = match self.search_input.take() {
Some(q) if !q.is_empty() => q.to_lowercase(),
_ => return false,
};
let matches: Vec<usize> = self
.diff
.iter()
.enumerate()
.filter(|(_, l)| l.text.to_lowercase().contains(&query))
.map(|(i, _)| i)
.collect();
if matches.is_empty() {
self.search = None;
return false;
}
let cur = matches
.iter()
.position(|&i| i >= self.diff_cursor)
.unwrap_or(0);
self.diff_cursor = matches[cur];
self.search = Some(SearchState {
query,
matches,
cur,
});
true
}
pub fn search_next(&mut self, delta: isize) {
if let Some(s) = self.search.as_mut() {
let len = s.matches.len() as isize;
if len == 0 {
return;
}
s.cur = ((s.cur as isize + delta) % len + len) as usize % len as usize;
self.diff_cursor = s.matches[s.cur];
}
}
pub fn inc_context(&mut self) {
if self.full_file {
return;
}
if self.context_lines >= MAX_CONTEXT_LINES {
self.full_file = true;
} else {
self.context_lines = (self.context_lines + 5).min(MAX_CONTEXT_LINES);
}
}
pub fn dec_context(&mut self) {
if self.full_file {
self.full_file = false;
return;
}
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 move_diff_cursor_skips_hunk_headers() {
let mut app = sample();
app.focus = Pane::Diff;
app.set_diff(vec![
DiffLine {
kind: LineKind::Context,
text: "c0".into(),
old_lineno: Some(1),
new_lineno: Some(1),
},
DiffLine {
kind: LineKind::Hunk,
text: "@@ a @@".into(),
old_lineno: None,
new_lineno: None,
},
DiffLine {
kind: LineKind::Add,
text: "a2".into(),
old_lineno: None,
new_lineno: Some(2),
},
DiffLine {
kind: LineKind::Hunk,
text: "@@ b @@".into(),
old_lineno: None,
new_lineno: None,
},
DiffLine {
kind: LineKind::Context,
text: "c4".into(),
old_lineno: Some(5),
new_lineno: Some(5),
},
]);
app.diff_cursor = 0;
app.move_diff_cursor(1);
assert_eq!(app.diff_cursor, 2);
app.move_diff_cursor(1);
assert_eq!(app.diff_cursor, 4);
app.move_diff_cursor(-1);
assert_eq!(app.diff_cursor, 2);
}
#[test]
fn move_diff_cursor_split_skips_del_lines() {
let mut app = sample();
app.focus = Pane::Diff;
app.split_diff = true;
app.set_diff(vec![
DiffLine {
kind: LineKind::Context,
text: "c0".into(),
old_lineno: Some(1),
new_lineno: Some(1),
},
DiffLine {
kind: LineKind::Del,
text: "old".into(),
old_lineno: Some(2),
new_lineno: None,
},
DiffLine {
kind: LineKind::Add,
text: "new".into(),
old_lineno: None,
new_lineno: Some(2),
},
DiffLine {
kind: LineKind::Context,
text: "c3".into(),
old_lineno: Some(3),
new_lineno: Some(3),
},
]);
app.diff_cursor = 0;
app.move_diff_cursor(1);
assert_eq!(app.diff_cursor, 2);
app.move_diff_cursor(1);
assert_eq!(app.diff_cursor, 3);
app.move_diff_cursor(-1);
assert_eq!(app.diff_cursor, 2);
}
#[test]
fn move_diff_cursor_unified_does_not_skip_del() {
let mut app = sample();
app.focus = Pane::Diff;
app.split_diff = false; app.set_diff(vec![
DiffLine {
kind: LineKind::Context,
text: "c0".into(),
old_lineno: Some(1),
new_lineno: Some(1),
},
DiffLine {
kind: LineKind::Del,
text: "old".into(),
old_lineno: Some(2),
new_lineno: None,
},
]);
app.diff_cursor = 0;
app.move_diff_cursor(1);
assert_eq!(app.diff_cursor, 1);
}
#[test]
fn move_diff_cursor_all_hunks_does_not_hang() {
let mut app = sample();
app.focus = Pane::Diff;
app.set_diff(vec![
DiffLine {
kind: LineKind::Hunk,
text: "@@ a @@".into(),
old_lineno: None,
new_lineno: None,
},
DiffLine {
kind: LineKind::Hunk,
text: "@@ b @@".into(),
old_lineno: None,
new_lineno: None,
},
]);
app.diff_cursor = 0;
app.move_diff_cursor(1);
assert!(app.diff_cursor <= 1);
}
fn three_line_diff() -> Vec<DiffLine> {
vec![
DiffLine {
kind: LineKind::Hunk,
text: "@@ a @@".into(),
old_lineno: None,
new_lineno: None,
},
DiffLine {
kind: LineKind::Add,
text: "alpha".into(),
old_lineno: None,
new_lineno: Some(1),
},
DiffLine {
kind: LineKind::Add,
text: "beta".into(),
old_lineno: None,
new_lineno: Some(2),
},
]
}
#[test]
fn select_range_single_when_no_anchor() {
let mut app = sample();
app.set_diff(three_line_diff());
app.diff_cursor = 2;
assert!(!app.select_active());
assert_eq!(app.select_range(), (2, 2));
}
#[test]
fn select_range_orders_anchor_and_cursor() {
let mut app = sample();
app.set_diff(three_line_diff());
app.diff_cursor = 2;
app.start_select();
app.diff_cursor = 1;
assert!(app.select_active());
assert_eq!(app.select_range(), (1, 2));
}
#[test]
fn selection_text_joins_with_trailing_newline() {
let mut app = sample();
app.set_diff(three_line_diff());
app.diff_cursor = 1;
app.start_select();
app.diff_cursor = 2;
assert_eq!(app.selection_text(), "alpha\nbeta\n");
}
#[test]
fn selection_text_skips_hunk_lines() {
let mut app = sample();
app.set_diff(three_line_diff());
app.diff_cursor = 0;
app.start_select();
app.diff_cursor = 2;
assert_eq!(app.selection_text(), "alpha\nbeta\n");
}
#[test]
fn cancel_select_clears_anchor() {
let mut app = sample();
app.set_diff(three_line_diff());
app.start_select();
assert!(app.select_active());
app.cancel_select();
assert!(!app.select_active());
assert_eq!(app.select_range(), (app.diff_cursor, app.diff_cursor));
}
#[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 fold_all_collapses_then_expands_every_dir() {
let files = vec![
FileChange {
path: PathBuf::from("src/a/x.rs"),
status: Status::Modified,
},
FileChange {
path: PathBuf::from("src/b/y.rs"),
status: Status::Modified,
},
FileChange {
path: PathBuf::from("top.rs"),
status: Status::Modified,
},
];
let mut app = App::new(files, vec![], PathBuf::from("/repo"));
assert_eq!(app.rows.len(), 8);
app.toggle_fold_all();
assert_eq!(app.rows.len(), 4);
assert!(app
.collapsed
.contains(&(Section::Unstaged, PathBuf::from("src"))));
assert!(app
.collapsed
.contains(&(Section::Unstaged, PathBuf::from("src/a"))));
assert!(app
.collapsed
.contains(&(Section::Unstaged, PathBuf::from("src/b"))));
app.toggle_fold_all();
assert_eq!(app.rows.len(), 8);
assert!(app.collapsed.is_empty());
}
#[test]
fn fold_all_with_partial_collapse_collapses_remaining() {
let files = vec![
FileChange {
path: PathBuf::from("src/a/x.rs"),
status: Status::Modified,
},
FileChange {
path: PathBuf::from("lib/b.rs"),
status: Status::Modified,
},
];
let mut app = App::new(files, vec![], PathBuf::from("/repo"));
app.collapsed
.insert((Section::Unstaged, PathBuf::from("lib")));
app.toggle_fold_all();
assert!(app
.collapsed
.contains(&(Section::Unstaged, PathBuf::from("src"))));
assert!(app
.collapsed
.contains(&(Section::Unstaged, PathBuf::from("src/a"))));
assert!(app
.collapsed
.contains(&(Section::Unstaged, PathBuf::from("lib"))));
}
#[test]
fn fold_all_no_dirs_is_noop() {
let mut app = sample(); let before = app.collapsed.len();
app.toggle_fold_all();
assert_eq!(app.collapsed.len(), before);
}
#[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, MAX_CONTEXT_LINES);
assert!(app.full_file);
app.dec_context();
assert!(!app.full_file);
assert_eq!(app.context_lines, MAX_CONTEXT_LINES);
for _ in 0..60 {
app.dec_context();
}
assert_eq!(app.context_lines, 0);
app.dec_context();
assert_eq!(app.context_lines, 0);
}
#[test]
fn inc_context_at_max_context_enables_full_file() {
let mut app = sample();
app.context_lines = MAX_CONTEXT_LINES;
app.inc_context();
assert!(app.full_file);
assert_eq!(app.context_lines, MAX_CONTEXT_LINES);
app.inc_context(); assert!(app.full_file);
}
#[test]
fn dec_context_from_full_file_via_f_keeps_context_lines() {
let mut app = sample();
app.context_lines = 8;
app.toggle_full_file();
app.dec_context();
assert!(!app.full_file);
assert_eq!(app.context_lines, 8);
}
#[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::Diff);
}
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![],
0,
);
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);
}
#[test]
fn toggle_theme_flips_dark_light() {
let mut app = sample();
assert_eq!(app.theme, crate::theme::Theme::Dark);
app.toggle_theme();
assert_eq!(app.theme, crate::theme::Theme::Light);
app.toggle_theme();
assert_eq!(app.theme, crate::theme::Theme::Dark);
}
#[test]
fn toggle_split_flips_flag() {
let mut app = sample();
assert!(!app.split_diff);
app.toggle_split();
assert!(app.split_diff);
app.toggle_split();
assert!(!app.split_diff);
}
#[test]
fn palette_returns_matching_palette_for_theme() {
let mut app = sample();
let dark_pal = app.palette();
assert_eq!(
dark_pal.accent,
crate::theme::Palette::for_theme(crate::theme::Theme::Dark).accent
);
app.toggle_theme();
let light_pal = app.palette();
assert_eq!(
light_pal.accent,
crate::theme::Palette::for_theme(crate::theme::Theme::Light).accent
);
assert_ne!(dark_pal.accent, light_pal.accent);
}
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 start_history_empty_commits_returns_false() {
let mut app = sample();
app.selected = 1; let ok = app.start_history(vec![]);
assert!(!ok);
assert!(app.history.is_none());
}
#[test]
fn start_history_sets_state_at_rev_one() {
let mut app = sample();
app.selected = 1; let ok = app.start_history(vec![make_commit_info("c1"), make_commit_info("c2")]);
assert!(ok);
let h = app.history.as_ref().unwrap();
assert_eq!(h.file, std::path::PathBuf::from("a.rs"));
assert_eq!(h.commits.len(), 2);
assert_eq!(h.idx, 1); assert_eq!(h.baseline_scope, CommentScope::Worktree);
}
#[test]
fn history_step_clamps_to_zero_and_len() {
let mut app = sample();
app.selected = 1;
app.start_history(vec![make_commit_info("c1"), make_commit_info("c2")]);
app.history_step(1); assert_eq!(app.history.as_ref().unwrap().idx, 2);
app.history_step(1); assert_eq!(app.history.as_ref().unwrap().idx, 2);
app.history_step(-1); app.history_step(-1); assert_eq!(app.history.as_ref().unwrap().idx, 0);
app.history_step(-1); assert_eq!(app.history.as_ref().unwrap().idx, 0);
}
#[test]
fn history_current_commit_none_at_baseline() {
let mut app = sample();
app.selected = 1;
app.start_history(vec![make_commit_info("c1")]);
assert!(app.history_current_commit().is_some()); app.history_step(-1); assert!(app.history_current_commit().is_none());
}
#[test]
fn exit_history_restores_scope_and_clears() {
let mut app = sample();
app.selected = 1;
app.comment_scope = CommentScope::Worktree;
app.start_history(vec![make_commit_info("c1")]);
app.comment_scope = CommentScope::Commit("abcdef1234567890".into());
app.exit_history();
assert!(app.history.is_none());
assert_eq!(app.comment_scope, CommentScope::Worktree);
}
fn app_with_search_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::Context,
text: "fn alpha() {".into(),
old_lineno: Some(1),
new_lineno: Some(1),
},
DiffLine {
kind: LineKind::Add,
text: "let Beta = 2;".into(),
old_lineno: None,
new_lineno: Some(2),
},
DiffLine {
kind: LineKind::Context,
text: "let gamma = 3;".into(),
old_lineno: Some(3),
new_lineno: Some(3),
},
DiffLine {
kind: LineKind::Add,
text: "return beta;".into(),
old_lineno: None,
new_lineno: Some(4),
},
]);
app
}
#[test]
fn search_commit_finds_case_insensitive_matches() {
let mut app = app_with_search_diff();
app.search_start();
for ch in "beta".chars() {
app.search_input_push(ch);
}
let ok = app.search_commit();
assert!(ok);
let s = app.search.as_ref().unwrap();
assert_eq!(s.matches, vec![1, 3]);
assert_eq!(app.diff_cursor, 1);
}
#[test]
fn search_commit_no_match_returns_false() {
let mut app = app_with_search_diff();
app.search_start();
for ch in "zzz".chars() {
app.search_input_push(ch);
}
let ok = app.search_commit();
assert!(!ok);
assert!(app.search.is_none());
}
#[test]
fn search_next_wraps_forward_and_back() {
let mut app = app_with_search_diff();
app.search_start();
for ch in "beta".chars() {
app.search_input_push(ch);
}
app.search_commit(); app.search_next(1); assert_eq!(app.diff_cursor, 3);
app.search_next(1); assert_eq!(app.diff_cursor, 1);
app.search_next(-1); assert_eq!(app.diff_cursor, 3);
}
#[test]
fn set_diff_clears_active_search() {
let mut app = app_with_search_diff();
app.search_start();
for ch in "beta".chars() {
app.search_input_push(ch);
}
app.search_commit();
assert!(app.search.is_some());
app.set_diff(vec![DiffLine::context("x", 1, 1)]);
assert!(app.search.is_none());
assert!(app.search_input.is_none());
}
#[test]
fn search_input_push_backspace_cancel() {
let mut app = app_with_search_diff();
app.search_start();
app.search_input_push('a');
app.search_input_push('b');
assert_eq!(app.search_input.as_deref(), Some("ab"));
app.search_input_backspace();
assert_eq!(app.search_input.as_deref(), Some("a"));
app.search_input_cancel();
assert!(app.search_input.is_none());
assert!(app.search.is_none());
}
#[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"));
}
fn sample_with_comments() -> App {
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.comments.set(
PathBuf::from("a.rs"),
1,
"@@".to_string(),
"open note".to_string(),
"fn a()".to_string(),
vec![],
vec![],
0,
);
app.comments.set(
PathBuf::from("a.rs"),
5,
"@@".to_string(),
"resolved note".to_string(),
"fn b()".to_string(),
vec![],
vec![],
0,
);
app.comments.items[1].status = crate::comments::CommentStatus::Resolved;
app.comments.set(
PathBuf::from("b.rs"),
3,
"@@".to_string(),
"wontfix note".to_string(),
"fn c()".to_string(),
vec![],
vec![],
0,
);
app.comments.items[2].status = crate::comments::CommentStatus::Wontfix;
app.comments.set(
PathBuf::from("b.rs"),
7,
"@@".to_string(),
"needs info note".to_string(),
"fn d()".to_string(),
vec![],
vec![],
0,
);
app.comments.items[3].status = crate::comments::CommentStatus::NeedsInfo;
app
}
#[test]
fn comment_rows_groups_by_status_open_first() {
let app = sample_with_comments();
let rows = app.comment_rows();
assert!(!rows.is_empty(), "comment_rows must not be empty");
assert!(matches!(
rows[0],
CommentRow::Header(crate::comments::CommentStatus::Open, 1)
));
assert!(matches!(rows[1], CommentRow::Item(0)));
let ni_pos = rows.iter().position(|r| {
matches!(
r,
CommentRow::Header(crate::comments::CommentStatus::NeedsInfo, 1)
)
});
assert!(ni_pos.is_some(), "NeedsInfo header must be present");
let wf_pos = rows.iter().position(|r| {
matches!(
r,
CommentRow::Header(crate::comments::CommentStatus::Wontfix, 1)
)
});
assert!(wf_pos.is_some(), "Wontfix header must be present");
let res_pos = rows.iter().position(|r| {
matches!(
r,
CommentRow::Header(crate::comments::CommentStatus::Resolved, 1)
)
});
assert!(res_pos.is_some(), "Resolved header must be present");
let open_pos = rows
.iter()
.position(|r| {
matches!(
r,
CommentRow::Header(crate::comments::CommentStatus::Open, _)
)
})
.unwrap();
assert!(
open_pos < ni_pos.unwrap(),
"Open must come before NeedsInfo"
);
assert!(
ni_pos.unwrap() < wf_pos.unwrap(),
"NeedsInfo must come before Wontfix"
);
assert!(
wf_pos.unwrap() < res_pos.unwrap(),
"Wontfix must come before Resolved"
);
}
#[test]
fn comment_rows_skips_empty_groups() {
let mut app = sample();
app.comments.set(
PathBuf::from("a.rs"),
1,
"@@".to_string(),
"only open".to_string(),
"fn a()".to_string(),
vec![],
vec![],
0,
);
let rows = app.comment_rows();
let has_resolved = rows.iter().any(|r| {
matches!(
r,
CommentRow::Header(crate::comments::CommentStatus::Resolved, _)
)
});
assert!(
!has_resolved,
"Resolved header should not appear when no resolved comments"
);
assert_eq!(rows.len(), 2);
}
#[test]
fn move_comment_selection_clamps() {
let mut app = sample_with_comments();
let row_count = app.comment_rows().len();
assert_eq!(row_count, 8);
app.move_comment_selection(100);
assert_eq!(app.comment_selected, row_count - 1);
app.move_comment_selection(-100);
assert_eq!(app.comment_selected, 0);
app.move_comment_selection(1);
assert_eq!(app.comment_selected, 1);
}
#[test]
fn selected_comment_returns_none_on_header_some_on_item() {
let mut app = sample_with_comments();
app.comment_selected = 0;
assert!(
app.selected_comment().is_none(),
"selected_comment must be None for a header row"
);
app.comment_selected = 1;
assert!(
app.selected_comment().is_some(),
"selected_comment must be Some for an item row"
);
}
#[test]
fn select_row_for_path_finds_file_row() {
let mut app = sample();
let found = app.select_row_for_path(Path::new("b.rs"));
assert!(
found,
"select_row_for_path must return true for an existing file path"
);
assert_eq!(app.selected_path(), Some(&PathBuf::from("b.rs")));
}
#[test]
fn select_row_for_path_returns_false_for_missing() {
let mut app = sample();
let found = app.select_row_for_path(Path::new("nonexistent.rs"));
assert!(
!found,
"select_row_for_path must return false for a path not in rows"
);
}
#[test]
fn cursor_lineno_prefers_new_over_old() {
let mut app = sample();
app.set_diff(vec![DiffLine {
kind: LineKind::Del,
text: "gone".into(),
old_lineno: Some(7),
new_lineno: None,
}]);
app.diff_cursor = 0;
assert_eq!(app.cursor_lineno(), Some(7));
}
#[test]
fn diff_has_lineno_matches_new_or_old() {
let mut app = sample();
app.set_diff(vec![
DiffLine {
kind: LineKind::Del,
text: "gone".into(),
old_lineno: Some(7),
new_lineno: None,
},
DiffLine {
kind: LineKind::Add,
text: "new".into(),
old_lineno: None,
new_lineno: Some(9),
},
]);
assert!(app.diff_has_lineno(7));
assert!(app.diff_has_lineno(9));
assert!(!app.diff_has_lineno(1));
}
#[test]
fn move_cursor_to_lineno_falls_back_to_old_lineno() {
let mut app = sample();
app.set_diff(vec![DiffLine {
kind: LineKind::Del,
text: "gone".into(),
old_lineno: Some(7),
new_lineno: None,
}]);
app.move_cursor_to_lineno(7);
assert_eq!(app.diff_cursor, 0);
}
#[test]
fn move_cursor_to_line_positions_diff_cursor() {
let files = vec![FileChange {
path: PathBuf::from("a.rs"),
status: Status::Modified,
}];
let mut app = App::new(files, vec![], PathBuf::from("/repo"));
app.set_diff(vec![
DiffLine {
kind: LineKind::Context,
text: "x".into(),
old_lineno: Some(1),
new_lineno: Some(1),
},
DiffLine {
kind: LineKind::Context,
text: "y".into(),
old_lineno: Some(2),
new_lineno: Some(2),
},
DiffLine {
kind: LineKind::Add,
text: "z".into(),
old_lineno: None,
new_lineno: Some(5),
},
]);
app.move_cursor_to_line(5);
assert_eq!(
app.diff_cursor, 2,
"diff_cursor must point to the line with new_lineno==5"
);
app.move_cursor_to_line(99);
assert_eq!(
app.diff_cursor, 0,
"cursor should reset to 0 when line not found"
);
}
#[test]
fn toggle_focus_cycles_through_visible_panes() {
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);
app.show_comments = true;
app.toggle_focus();
assert_eq!(app.focus, Pane::Diff);
app.toggle_focus();
assert_eq!(app.focus, Pane::Comments);
app.toggle_focus();
assert_eq!(app.focus, Pane::Files);
}
#[test]
fn toggle_focus_only_diff_visible_stays_diff() {
let mut app = sample();
app.show_files = false;
app.show_comments = false;
app.focus = Pane::Diff;
app.toggle_focus();
assert_eq!(app.focus, Pane::Diff);
}
#[test]
fn toggle_comment_pane_flips_show_comments() {
let mut app = sample();
assert!(!app.show_comments);
app.toggle_comment_pane();
assert!(app.show_comments);
app.toggle_comment_pane();
assert!(!app.show_comments);
}
#[test]
fn toggle_comment_pane_focuses_comments_when_opening() {
let mut app = sample();
app.focus = Pane::Files;
app.toggle_comment_pane(); assert!(app.show_comments);
assert_eq!(app.focus, Pane::Comments);
}
#[test]
fn toggle_comment_pane_moves_focus_when_hiding_comments() {
let mut app = sample();
app.show_comments = true;
app.focus = Pane::Comments;
app.toggle_comment_pane();
assert!(!app.show_comments);
assert_eq!(app.focus, Pane::Diff);
}
#[test]
fn comment_rows_within_open_group_sorted_by_updated_desc() {
let mut app = sample();
app.comments.set(
PathBuf::from("a.rs"),
1,
"@@".to_string(),
"older comment".to_string(),
"fn a()".to_string(),
vec![],
vec![],
500,
);
app.comments.set(
PathBuf::from("a.rs"),
2,
"@@".to_string(),
"newer comment".to_string(),
"fn b()".to_string(),
vec![],
vec![],
1500,
);
let rows = app.comment_rows();
assert_eq!(rows.len(), 3, "Header + 2 items");
assert!(matches!(
rows[0],
CommentRow::Header(crate::comments::CommentStatus::Open, 2)
));
match rows[1] {
CommentRow::Item(idx) => {
assert_eq!(
app.comments.items[idx].text, "newer comment",
"first item in group must be the newer-updated comment"
);
}
_ => panic!("expected Item at rows[1]"),
}
match rows[2] {
CommentRow::Item(idx) => {
assert_eq!(
app.comments.items[idx].text, "older comment",
"second item in group must be the older-updated comment"
);
}
_ => panic!("expected Item at rows[2]"),
}
}
#[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");
}
}