use std::collections::HashSet;
use ratatui::{
layout::{Constraint, Direction, Layout, Margin},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{
Block, Borders, List, ListItem, ListState, Paragraph, Scrollbar, ScrollbarOrientation,
ScrollbarState, Wrap,
},
Frame,
};
use super::common::render_rally_status_bar;
use super::diff_view;
use crate::app::{App, AppState, CommitLogState, FileStatus, GitOpsState, LeftPaneFocus, TreeRow};
use crate::github::{format_relative_time, PrCommit};
pub fn render(frame: &mut Frame, app: &mut App) {
let has_rally = app.has_background_rally();
let outer_constraints = if has_rally {
vec![Constraint::Min(0), Constraint::Length(1)]
} else {
vec![Constraint::Min(0)]
};
let outer_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(outer_constraints)
.split(frame.area());
let h_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(app.config.layout.left_panel_percent()),
Constraint::Percentage(app.config.layout.right_panel_percent()),
])
.split(outer_chunks[0]);
let is_diff_focused = app.state == AppState::GitOpsSplitDiff;
let left_focus = app
.git_ops_state
.as_ref()
.map(|ops| ops.left_focus)
.unwrap_or(LeftPaneFocus::Tree);
let is_tree_focused = !is_diff_focused && left_focus == LeftPaneFocus::Tree;
let is_commits_focused = !is_diff_focused && left_focus == LeftPaneFocus::Commits;
let left_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(70), Constraint::Percentage(30)])
.split(h_chunks[0]);
let diff_visible_lines = h_chunks[1].height.saturating_sub(8) as usize;
if let Some(ref mut ops) = app.git_ops_state {
ops.diff_scroll.set_visible_lines(diff_visible_lines);
ops.commit_log.diff_scroll.set_visible_lines(diff_visible_lines);
}
render_tree_pane(frame, app, left_chunks[0], is_tree_focused);
render_commits_pane(frame, app, left_chunks[1], is_commits_focused);
render_diff_pane(frame, &*app, h_chunks[1], is_diff_focused);
if has_rally {
render_rally_status_bar(frame, outer_chunks[1], app);
}
}
fn render_tree_pane(
frame: &mut Frame,
app: &mut App,
area: ratatui::layout::Rect,
is_focused: bool,
) {
let has_pending_confirm = app
.git_ops_state
.as_ref()
.is_some_and(|ops| ops.pending_confirm.is_some());
let border_color = if has_pending_confirm {
Color::Red
} else if is_focused {
Color::Yellow
} else {
Color::DarkGray
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0), Constraint::Length(3)])
.split(area);
let Some(ref ops) = app.git_ops_state else {
return;
};
let (staged_count, unstaged_count, untracked_count) = count_statuses(ops);
let header_text = format!(
"staged:{} unstaged:{} untracked:{}",
staged_count, unstaged_count, untracked_count,
);
let header = Paragraph::new(header_text).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.title("Git Status"),
);
frame.render_widget(header, chunks[0]);
if ops.entries.is_empty() {
let empty = Paragraph::new("No changes")
.style(Style::default().fg(Color::DarkGray))
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.title("Files (0)"),
);
frame.render_widget(empty, chunks[1]);
} else {
let total = ops.tree.visible_rows.len();
let selected = ops.tree.selected_row;
let items: Vec<ListItem> = ops
.tree
.visible_rows
.iter()
.enumerate()
.map(|(i, row)| build_tree_row_item(ops, row, i == selected))
.collect();
let list = List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.title(format!("Files ({})", ops.entries.len())),
)
.highlight_style(Style::default().bg(Color::DarkGray));
let mut list_state = ListState::default()
.with_offset(ops.tree.scroll_offset)
.with_selected(Some(selected));
frame.render_stateful_widget(list, chunks[1], &mut list_state);
if let Some(ref mut ops) = app.git_ops_state {
ops.tree.scroll_offset = list_state.offset();
}
if total > 1 {
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(Some("▲"))
.end_symbol(Some("▼"));
let mut scrollbar_state =
ScrollbarState::new(total.saturating_sub(1)).position(selected);
frame.render_stateful_widget(
scrollbar,
chunks[1].inner(Margin {
vertical: 1,
horizontal: 0,
}),
&mut scrollbar_state,
);
}
}
let confirm_text;
let pending = app
.git_ops_state
.as_ref()
.and_then(|ops| ops.pending_confirm.as_ref());
let help_text = if let Some(confirm) = pending {
match confirm {
crate::app::PendingGitOpsConfirm::Simple { ref op } => {
confirm_text = format!("{} (Y/n)", op.display_command());
confirm_text.as_str()
}
crate::app::PendingGitOpsConfirm::Simulating { .. } => {
let spinner = app.spinner_char();
confirm_text = format!("{} Simulating...", spinner);
confirm_text.as_str()
}
crate::app::PendingGitOpsConfirm::Previewing { .. } => {
""
}
}
} else {
let Some(ref ops) = app.git_ops_state else {
return;
};
if ops.pushing {
let spinner = app.spinner_char();
confirm_text = format!("{} Pushing...", spinner);
confirm_text.as_str()
} else if let Some((ref msg, _)) = ops.op_message {
msg.as_str()
} else if is_focused {
confirm_text = super::footer::footer_hint_back(&app.config.keybindings);
confirm_text.as_str()
} else {
"Tab/h: focus tree"
}
};
if has_pending_confirm {
let line = Line::from(Span::styled(
help_text,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
));
let footer = Paragraph::new(line).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Red)),
);
frame.render_widget(footer, chunks[2]);
} else {
let footer_line =
super::footer::build_footer_line_with_focus(app, help_text, is_focused);
let footer =
Paragraph::new(footer_line).block(super::footer::build_footer_block_with_focus(
app,
Style::default().fg(border_color),
is_focused,
));
frame.render_widget(footer, chunks[2]);
}
}
fn render_diff_pane(frame: &mut Frame, app: &App, area: ratatui::layout::Rect, is_focused: bool) {
let border_color = if is_focused {
Color::Yellow
} else {
Color::DarkGray
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0), Constraint::Length(3)])
.split(area);
let bg_color = app.config.diff.bg_color;
let Some(ref ops) = app.git_ops_state else {
return;
};
let is_commit_diff = ops.left_return_focus == LeftPaneFocus::Commits;
let header_text = if is_commit_diff {
ops.commit_log
.commits
.get(ops.commit_log.selected)
.map(|c| format!("{} {}", c.short_sha(), c.message))
.unwrap_or_else(|| "No commit selected".to_string())
} else {
ops.selected_path()
.map(|p| p.to_string())
.unwrap_or_else(|| "No file selected".to_string())
};
let header = Paragraph::new(header_text).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.title("Diff Preview"),
);
frame.render_widget(header, chunks[0]);
if is_commit_diff {
render_commit_diff_body(frame, &ops.commit_log, chunks[1], border_color, bg_color);
} else {
render_diff_body(frame, ops, chunks[1], border_color, bg_color);
}
let footer_text_owned;
let footer_text = if is_focused {
footer_text_owned = super::footer::footer_hint_back(&app.config.keybindings);
footer_text_owned.as_str()
} else {
"Enter/l: focus diff"
};
let footer_line = super::footer::build_footer_line_with_focus(app, footer_text, is_focused);
let footer = Paragraph::new(footer_line).block(super::footer::build_footer_block_with_focus(
app,
Style::default().fg(border_color),
is_focused,
));
frame.render_widget(footer, chunks[2]);
}
fn render_diff_body(
frame: &mut Frame,
ops: &GitOpsState,
area: ratatui::layout::Rect,
border_color: Color,
bg_color: bool,
) {
let lines: Vec<Line> = if let Some(ref cache) = ops.diff_store.current {
let visible_height = area.height.saturating_sub(2) as usize;
let line_count = cache.lines.len();
let visible_start = ops.diff_scroll.scroll_offset.saturating_sub(2).min(line_count);
let visible_end = (ops.diff_scroll.scroll_offset + visible_height + 5).min(line_count);
let empty_comments = HashSet::new();
diff_view::render_cached_lines(
cache,
visible_start..visible_end,
ops.diff_scroll.selected_line,
&empty_comments,
bg_color,
None,
)
} else {
vec![Line::from(Span::styled(
"Select a file to preview diff",
Style::default().fg(Color::DarkGray),
))]
};
let adjusted_scroll = if ops.diff_store.current.is_some() {
let visible_start = ops.diff_scroll.scroll_offset.saturating_sub(2);
(ops.diff_scroll.scroll_offset - visible_start) as u16
} else {
ops.diff_scroll.scroll_offset as u16
};
let diff_block = Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color)),
)
.wrap(Wrap { trim: false })
.scroll((adjusted_scroll, 0));
frame.render_widget(diff_block, area);
if let Some(ref cache) = ops.diff_store.current {
let total_lines = cache.lines.len();
let visible_height = area.height.saturating_sub(2) as usize;
let max_scroll = total_lines.saturating_sub(visible_height);
if max_scroll > 0 {
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(Some("▲"))
.end_symbol(Some("▼"));
let clamped_position = ops.diff_scroll.scroll_offset.min(max_scroll);
let mut scrollbar_state = ScrollbarState::new(max_scroll).position(clamped_position);
frame.render_stateful_widget(
scrollbar,
area.inner(Margin {
vertical: 1,
horizontal: 0,
}),
&mut scrollbar_state,
);
}
}
}
fn render_commit_diff_body(
frame: &mut Frame,
cl: &CommitLogState,
area: ratatui::layout::Rect,
border_color: Color,
bg_color: bool,
) {
let lines: Vec<Line> = if cl.diff_loading {
vec![Line::from(Span::styled(
"Loading diff...",
Style::default().fg(Color::Yellow),
))]
} else if let Some(ref error) = cl.diff_error {
vec![Line::from(Span::styled(
format!("Error: {}", error),
Style::default().fg(Color::Red),
))]
} else if let Some(ref cache) = cl.diff_store.current {
let visible_height = area.height.saturating_sub(2) as usize;
let line_count = cache.lines.len();
let visible_start = cl.diff_scroll.scroll_offset.saturating_sub(2).min(line_count);
let visible_end = (cl.diff_scroll.scroll_offset + visible_height + 5).min(line_count);
let empty_comments = HashSet::new();
diff_view::render_cached_lines(
cache,
visible_start..visible_end,
cl.diff_scroll.selected_line,
&empty_comments,
bg_color,
None,
)
} else {
vec![Line::from(Span::styled(
"Select a commit to preview diff",
Style::default().fg(Color::DarkGray),
))]
};
let adjusted_scroll = if cl.diff_store.current.is_some() {
let visible_start = cl.diff_scroll.scroll_offset.saturating_sub(2);
(cl.diff_scroll.scroll_offset - visible_start) as u16
} else {
cl.diff_scroll.scroll_offset as u16
};
let diff_block = Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color)),
)
.wrap(Wrap { trim: false })
.scroll((adjusted_scroll, 0));
frame.render_widget(diff_block, area);
if let Some(ref cache) = cl.diff_store.current {
let total_lines = cache.lines.len();
let visible_height = area.height.saturating_sub(2) as usize;
let max_scroll = total_lines.saturating_sub(visible_height);
if max_scroll > 0 {
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(Some("▲"))
.end_symbol(Some("▼"));
let clamped_position = cl.diff_scroll.scroll_offset.min(max_scroll);
let mut scrollbar_state = ScrollbarState::new(max_scroll).position(clamped_position);
frame.render_stateful_widget(
scrollbar,
area.inner(Margin {
vertical: 1,
horizontal: 0,
}),
&mut scrollbar_state,
);
}
}
}
fn render_commits_pane(
frame: &mut Frame,
app: &mut App,
area: ratatui::layout::Rect,
is_focused: bool,
) {
let border_color = if is_focused {
Color::Yellow
} else {
Color::DarkGray
};
let spinner = app.spinner_char().to_string();
let Some(ref mut ops) = app.git_ops_state else {
return;
};
let ahead_count = ops.ahead_count;
let cl = &mut ops.commit_log;
if cl.loading && cl.commits.is_empty() {
let loading = Paragraph::new(Line::from(Span::styled(
format!("{} Loading commits...", spinner),
Style::default().fg(Color::Yellow),
)))
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.title("Commits"),
);
frame.render_widget(loading, area);
return;
}
if let Some(ref error) = cl.error {
let err_msg = Paragraph::new(Span::styled(
format!("Error: {}", error),
Style::default().fg(Color::Red),
))
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.title("Commits"),
);
frame.render_widget(err_msg, area);
return;
}
if cl.commits.is_empty() {
let empty = Paragraph::new("No commits")
.style(Style::default().fg(Color::DarkGray))
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.title("Commits (0)"),
);
frame.render_widget(empty, area);
return;
}
let total = cl.commits.len();
let selected = cl.selected;
let mut items: Vec<ListItem> = cl
.commits
.iter()
.enumerate()
.map(|(i, commit)| build_commit_item(commit, i == selected))
.collect();
if cl.loading && !cl.commits.is_empty() {
items.push(ListItem::new(Line::from(Span::styled(
format!("{} Loading more...", spinner),
Style::default().fg(Color::Yellow),
))));
}
let count_str = if cl.has_more {
format!("{}+", total)
} else {
format!("{}", total)
};
let title = if ahead_count > 0 {
format!("Commits ({}) \u{2191}{}", count_str, ahead_count)
} else {
format!("Commits ({})", count_str)
};
let list = List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.title(title),
)
.highlight_style(Style::default().bg(Color::DarkGray));
let mut list_state = ListState::default()
.with_offset(cl.scroll_offset)
.with_selected(Some(selected));
frame.render_stateful_widget(list, area, &mut list_state);
cl.scroll_offset = list_state.offset();
if total > 1 {
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(Some("▲"))
.end_symbol(Some("▼"));
let mut scrollbar_state = ScrollbarState::new(total.saturating_sub(1)).position(selected);
frame.render_stateful_widget(
scrollbar,
area.inner(Margin {
vertical: 1,
horizontal: 0,
}),
&mut scrollbar_state,
);
}
}
fn build_commit_item<'a>(commit: &PrCommit, is_selected: bool) -> ListItem<'a> {
let style = if is_selected {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let relative_time = format_relative_time(&commit.date);
let author = commit
.author_login
.as_deref()
.unwrap_or(&commit.author_name);
let line = Line::from(vec![
Span::styled(
format!("{} ", commit.short_sha()),
Style::default().fg(Color::Cyan),
),
Span::styled(commit.message.clone(), style),
Span::styled(
format!(" ({}, {})", author, relative_time),
Style::default().fg(Color::DarkGray),
),
]);
ListItem::new(line)
}
fn build_tree_row_item<'a>(
ops: &GitOpsState,
row: &TreeRow,
is_selected: bool,
) -> ListItem<'a> {
match row {
TreeRow::Dir { ref path, depth, expanded } => {
let indent = " ".repeat(*depth);
let icon = if *expanded { "▼" } else { "▶" };
let dir_name = path
.rsplit_once('/')
.map(|(_, name)| name)
.unwrap_or(path);
let style = if is_selected {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Cyan)
};
let line = Line::from(vec![
Span::raw(indent),
Span::styled(format!("{} {}/", icon, dir_name), style),
]);
ListItem::new(line)
}
TreeRow::File { index, depth } => {
let Some(entry) = ops.entries.get(*index) else {
return ListItem::new(Line::from(""));
};
let indent = " ".repeat(*depth);
let label = entry.change_type_label();
let status_color = status_color_for_entry(entry);
let filename = entry
.path
.rsplit_once('/')
.map(|(_, name)| name)
.unwrap_or(&entry.path);
let file_style = if is_selected {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let mut spans = vec![
Span::raw(indent),
Span::styled(label, Style::default().fg(status_color)),
Span::raw(" "),
Span::styled(filename.to_string(), file_style),
];
let total_add = entry.additions + entry.staged_additions;
let total_del = entry.deletions + entry.staged_deletions;
if total_add > 0 || total_del > 0 {
spans.push(Span::raw(format!(" +{} -{}", total_add, total_del)));
}
ListItem::new(Line::from(spans))
}
}
}
fn status_color_for_entry(entry: &crate::app::GitStatusEntry) -> Color {
if entry.unmerged {
return Color::Red;
}
match (entry.index_status, entry.worktree_status) {
(_, FileStatus::Unmodified) if entry.is_staged() => Color::Green,
(FileStatus::Untracked, FileStatus::Untracked) => Color::Magenta,
(_, wt) if wt != FileStatus::Unmodified && wt != FileStatus::Ignored => Color::Red,
_ if entry.is_staged() && entry.has_worktree_changes() => Color::Yellow,
_ => Color::White,
}
}
fn count_statuses(ops: &GitOpsState) -> (usize, usize, usize) {
let mut staged = 0;
let mut unstaged = 0;
let mut untracked = 0;
for entry in &ops.entries {
if entry.index_status == FileStatus::Untracked
&& entry.worktree_status == FileStatus::Untracked
{
untracked += 1;
} else {
if entry.is_staged() {
staged += 1;
}
if entry.has_worktree_changes() {
unstaged += 1;
}
}
}
(staged, unstaged, untracked)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::{DestructiveOp, FileStatus, GitOpsState, GitStatusEntry, PendingGitOpsConfirm};
use crate::config::Config;
use insta::assert_snapshot;
use ratatui::backend::TestBackend;
use ratatui::layout::Rect;
use ratatui::Terminal;
fn make_app() -> (App, tokio::sync::mpsc::Sender<crate::loader::DataLoadResult>) {
let config = Config::default();
App::new_loading("owner/repo", 1, config)
}
fn entry(path: &str, index: FileStatus, worktree: FileStatus) -> GitStatusEntry {
GitStatusEntry {
path: path.to_string(),
index_status: index,
worktree_status: worktree,
additions: 0,
deletions: 0,
staged_additions: 0,
staged_deletions: 0,
orig_path: None,
unmerged: false,
}
}
fn rebuild_tree(ops: &mut GitOpsState) {
let paths: Vec<(usize, &str)> = ops
.entries
.iter()
.enumerate()
.map(|(i, e)| (i, e.path.as_str()))
.collect();
ops.tree.rebuild(&paths);
}
fn render_tree_pane_footer(app: &mut App, is_focused: bool) -> String {
let backend = TestBackend::new(100, 20);
let mut terminal = Terminal::new(backend).unwrap();
let area = Rect::new(0, 0, 100, 20);
terminal
.draw(|frame| {
render_tree_pane(frame, app, area, is_focused);
})
.unwrap();
let buf = terminal.backend().buffer();
let footer_start = area.height.saturating_sub(3) as usize;
let mut lines = Vec::new();
for y in footer_start..area.height as usize {
let mut line = String::new();
for x in 0..area.width as usize {
let cell = &buf[(x as u16, y as u16)];
line.push_str(cell.symbol());
}
lines.push(line.trim_end().to_string());
}
lines.join("\n")
}
fn render_tree_pane_border_color(app: &mut App, is_focused: bool) -> Color {
let backend = TestBackend::new(80, 20);
let mut terminal = Terminal::new(backend).unwrap();
let area = Rect::new(0, 0, 80, 20);
terminal
.draw(|frame| {
render_tree_pane(frame, app, area, is_focused);
})
.unwrap();
let buf = terminal.backend().buffer();
buf[(0u16, 0u16)].fg
}
#[test]
fn test_footer_discard_confirm_focused() {
let (mut app, _tx) = make_app();
let entries = vec![entry("src/main.rs", FileStatus::Unmodified, FileStatus::Modified)];
let mut ops = GitOpsState::new(entries);
rebuild_tree(&mut ops);
ops.pending_confirm = Some(PendingGitOpsConfirm::Simple {
op: DestructiveOp::Discard { path: "src/main.rs".to_string() },
});
app.git_ops_state = Some(ops);
assert_snapshot!(render_tree_pane_footer(&mut app, true), @"
┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│git restore -- src/main.rs (Y/n) │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘
");
}
#[test]
fn test_footer_discard_confirm_unfocused() {
let (mut app, _tx) = make_app();
let entries = vec![entry("a.rs", FileStatus::Unmodified, FileStatus::Modified)];
let mut ops = GitOpsState::new(entries);
rebuild_tree(&mut ops);
ops.pending_confirm = Some(PendingGitOpsConfirm::Simple {
op: DestructiveOp::Discard { path: "a.rs".to_string() },
});
app.git_ops_state = Some(ops);
assert_snapshot!(render_tree_pane_footer(&mut app, false), @"
┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│git restore -- a.rs (Y/n) │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘
");
}
#[test]
fn test_footer_undo_confirm_focused() {
let (mut app, _tx) = make_app();
let mut ops = GitOpsState::new(vec![
entry("a.rs", FileStatus::Unmodified, FileStatus::Modified),
]);
rebuild_tree(&mut ops);
ops.pending_confirm = Some(PendingGitOpsConfirm::Simple {
op: DestructiveOp::UndoStage { paths: vec!["a.rs".to_string()] },
});
app.git_ops_state = Some(ops);
assert_snapshot!(render_tree_pane_footer(&mut app, true), @"
┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│git reset -- a.rs (Y/n) │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘
");
}
#[test]
fn test_footer_undo_confirm_unfocused() {
let (mut app, _tx) = make_app();
let mut ops = GitOpsState::new(vec![
entry("a.rs", FileStatus::Unmodified, FileStatus::Modified),
]);
rebuild_tree(&mut ops);
ops.pending_confirm = Some(PendingGitOpsConfirm::Simple {
op: DestructiveOp::ResetSoft { sha: "abc1234".to_string(), head_offset: 1 },
});
app.git_ops_state = Some(ops);
assert_snapshot!(render_tree_pane_footer(&mut app, false), @"
┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│git reset --soft abc1234 (Y/n) │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘
");
}
#[test]
fn test_footer_no_confirm_focused() {
let (mut app, _tx) = make_app();
let entries = vec![entry("a.rs", FileStatus::Unmodified, FileStatus::Modified)];
let mut ops = GitOpsState::new(entries);
rebuild_tree(&mut ops);
app.git_ops_state = Some(ops);
assert_snapshot!(render_tree_pane_footer(&mut app, true), @"
┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│? Help | ! Shell | q/Esc Back │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘
");
}
#[test]
fn test_footer_no_confirm_unfocused() {
let (mut app, _tx) = make_app();
let entries = vec![entry("a.rs", FileStatus::Unmodified, FileStatus::Modified)];
let mut ops = GitOpsState::new(entries);
rebuild_tree(&mut ops);
app.git_ops_state = Some(ops);
assert_snapshot!(render_tree_pane_footer(&mut app, false), @"
┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│Tab/h: focus tree │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘
");
}
#[test]
fn test_border_red_on_pending_confirm() {
let (mut app, _tx) = make_app();
let entries = vec![entry("a.rs", FileStatus::Unmodified, FileStatus::Modified)];
let mut ops = GitOpsState::new(entries);
rebuild_tree(&mut ops);
ops.pending_confirm = Some(PendingGitOpsConfirm::Simple {
op: DestructiveOp::UndoStageAll { tree_hash: None },
});
app.git_ops_state = Some(ops);
assert_eq!(render_tree_pane_border_color(&mut app, false), Color::Red);
}
#[test]
fn test_border_not_red_without_confirm() {
let (mut app, _tx) = make_app();
let entries = vec![entry("a.rs", FileStatus::Unmodified, FileStatus::Modified)];
let mut ops = GitOpsState::new(entries);
rebuild_tree(&mut ops);
app.git_ops_state = Some(ops);
assert_eq!(
render_tree_pane_border_color(&mut app, true),
Color::Yellow
);
assert_eq!(
render_tree_pane_border_color(&mut app, false),
Color::DarkGray
);
}
#[test]
fn test_footer_op_message_focused() {
let (mut app, _tx) = make_app();
let entries = vec![entry("a.rs", FileStatus::Unmodified, FileStatus::Modified)];
let mut ops = GitOpsState::new(entries);
rebuild_tree(&mut ops);
ops.op_message = Some(("Pushed to origin/main".to_string(), std::time::Instant::now()));
app.git_ops_state = Some(ops);
assert_snapshot!(render_tree_pane_footer(&mut app, true), @r"
┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│Pushed to origin/main │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘
");
}
#[test]
fn test_footer_op_message_unfocused() {
let (mut app, _tx) = make_app();
let entries = vec![entry("a.rs", FileStatus::Unmodified, FileStatus::Modified)];
let mut ops = GitOpsState::new(entries);
rebuild_tree(&mut ops);
ops.op_message = Some(("Pushed to origin/main".to_string(), std::time::Instant::now()));
app.git_ops_state = Some(ops);
assert_snapshot!(render_tree_pane_footer(&mut app, false), @r"
┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│Pushed to origin/main │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘
");
}
#[test]
fn test_footer_op_message_empty_entries_focused() {
let (mut app, _tx) = make_app();
let mut ops = GitOpsState::new(Vec::new());
ops.op_message = Some(("Pushed to origin/main".to_string(), std::time::Instant::now()));
app.git_ops_state = Some(ops);
assert_snapshot!(render_tree_pane_footer(&mut app, true), @r"
┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│Pushed to origin/main │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘
");
}
fn make_commit(sha: &str, message: &str) -> crate::github::PrCommit {
crate::github::PrCommit {
sha: sha.to_string(),
message: message.to_string(),
author_name: "tester".to_string(),
author_login: None,
date: "2025-01-01T00:00:00Z".to_string(),
}
}
fn render_diff_pane_body(app: &App) -> String {
let backend = TestBackend::new(80, 15);
let mut terminal = Terminal::new(backend).unwrap();
let area = Rect::new(0, 0, 80, 15);
terminal
.draw(|frame| {
render_diff_pane(frame, app, area, false);
})
.unwrap();
let buf = terminal.backend().buffer();
let body_start = 3usize;
let body_end = 12usize;
let mut lines = Vec::new();
for y in body_start..body_end {
let mut line = String::new();
for x in 0..area.width as usize {
let cell = &buf[(x as u16, y as u16)];
line.push_str(cell.symbol());
}
lines.push(line.trim_end().to_string());
}
lines.join("\n")
}
#[test]
fn test_diff_pane_commit_loading() {
let (mut app, _tx) = make_app();
let mut ops = GitOpsState::new(Vec::new());
ops.commit_log.commits.push(make_commit("abc1234", "test commit"));
ops.commit_log.selected = 0;
ops.commit_log.diff_loading = true;
ops.left_return_focus = LeftPaneFocus::Commits;
app.git_ops_state = Some(ops);
assert_snapshot!(render_diff_pane_body(&app), @"
┌──────────────────────────────────────────────────────────────────────────────┐
│Loading diff... │
│ │
│ │
│ │
│ │
│ │
│ │
└──────────────────────────────────────────────────────────────────────────────┘
");
}
#[test]
fn test_diff_pane_commit_error() {
let (mut app, _tx) = make_app();
let mut ops = GitOpsState::new(Vec::new());
ops.commit_log.commits.push(make_commit("abc1234", "test commit"));
ops.commit_log.selected = 0;
ops.commit_log.diff_error = Some("gh: Not Found (HTTP 404)".to_string());
ops.left_return_focus = LeftPaneFocus::Commits;
app.git_ops_state = Some(ops);
assert_snapshot!(render_diff_pane_body(&app), @"
┌──────────────────────────────────────────────────────────────────────────────┐
│Error: gh: Not Found (HTTP 404) │
│ │
│ │
│ │
│ │
│ │
│ │
└──────────────────────────────────────────────────────────────────────────────┘
");
}
#[test]
fn test_diff_pane_no_commit_selected() {
let (mut app, _tx) = make_app();
let mut ops = GitOpsState::new(Vec::new());
ops.left_return_focus = LeftPaneFocus::Commits;
app.git_ops_state = Some(ops);
assert_snapshot!(render_diff_pane_body(&app), @"
┌──────────────────────────────────────────────────────────────────────────────┐
│Select a commit to preview diff │
│ │
│ │
│ │
│ │
│ │
│ │
└──────────────────────────────────────────────────────────────────────────────┘
");
}
#[tokio::test]
async fn test_diff_pane_after_commit_list_arrives_without_pr() {
let (mut app, _tx) = make_app();
app.pr_number = None;
let mut ops = GitOpsState::new(Vec::new());
let (tx, rx) = tokio::sync::mpsc::channel(1);
ops.commit_log.list_receiver = Some(rx);
ops.commit_log.loading = true;
ops.left_return_focus = LeftPaneFocus::Commits;
app.git_ops_state = Some(ops);
app.state = AppState::GitOpsSplitTree;
let page = crate::github::CommitListPage {
items: vec![make_commit("local_sha", "local commit")],
has_more: false,
};
tx.send(Ok(page)).await.unwrap();
app.poll_git_ops_updates();
let cl = &app.git_ops_state.as_ref().unwrap().commit_log;
assert!(cl.diff_loading);
assert!(cl.diff_receiver.is_some());
assert_snapshot!(render_diff_pane_body(&app), @"
┌──────────────────────────────────────────────────────────────────────────────┐
│Loading diff... │
│ │
│ │
│ │
│ │
│ │
│ │
└──────────────────────────────────────────────────────────────────────────────┘
");
}
#[test]
fn test_footer_pushing_spinner() {
let (mut app, _tx) = make_app();
let mut ops = GitOpsState::new(vec![
entry("a.rs", FileStatus::Unmodified, FileStatus::Modified),
]);
rebuild_tree(&mut ops);
ops.pushing = true;
app.git_ops_state = Some(ops);
let footer = render_tree_pane_footer(&mut app, true);
assert!(
footer.contains("Pushing..."),
"should show pushing spinner, got: {}",
footer
);
}
#[test]
fn test_footer_pushing_spinner_unfocused() {
let (mut app, _tx) = make_app();
let mut ops = GitOpsState::new(vec![
entry("a.rs", FileStatus::Unmodified, FileStatus::Modified),
]);
rebuild_tree(&mut ops);
ops.pushing = true;
app.git_ops_state = Some(ops);
let footer = render_tree_pane_footer(&mut app, false);
assert!(
footer.contains("Pushing..."),
"pushing spinner should show even when unfocused, got: {}",
footer
);
}
fn render_commits_pane_text(app: &mut App, is_focused: bool) -> String {
let backend = TestBackend::new(60, 10);
let mut terminal = Terminal::new(backend).unwrap();
let area = Rect::new(0, 0, 60, 10);
terminal
.draw(|frame| {
render_commits_pane(frame, app, area, is_focused);
})
.unwrap();
let buf = terminal.backend().buffer();
let mut lines = Vec::new();
for y in 0..area.height as usize {
let mut line = String::new();
for x in 0..area.width as usize {
let cell = &buf[(x as u16, y as u16)];
line.push_str(cell.symbol());
}
lines.push(line.trim_end().to_string());
}
lines.join("\n")
}
#[test]
fn test_commits_pane_title_with_ahead_count() {
let (mut app, _tx) = make_app();
let mut ops = GitOpsState::new(Vec::new());
ops.commit_log.commits.push(make_commit("abc123", "test commit"));
ops.commit_log.initialized = true;
ops.ahead_count = 3;
app.git_ops_state = Some(ops);
let output = render_commits_pane_text(&mut app, false);
assert!(
output.contains("\u{2191}3"),
"should show ↑3 in title, got: {}",
output
);
}
#[test]
fn test_commits_pane_title_without_ahead() {
let (mut app, _tx) = make_app();
let mut ops = GitOpsState::new(Vec::new());
ops.commit_log.commits.push(make_commit("abc123", "test commit"));
ops.commit_log.initialized = true;
ops.ahead_count = 0;
app.git_ops_state = Some(ops);
let output = render_commits_pane_text(&mut app, false);
assert!(
!output.contains("\u{2191}"),
"should not show ↑ when ahead=0, got: {}",
output
);
assert!(output.contains("Commits (1)"), "got: {}", output);
}
#[tokio::test]
async fn test_ahead_count_updates_after_commit() {
let (mut app, _tx) = make_app();
let mut ops = GitOpsState::new(Vec::new());
ops.commit_log.commits.push(make_commit("sha1", "initial"));
ops.commit_log.initialized = true;
ops.ahead_count = 0;
let (ahead_tx, ahead_rx) = tokio::sync::mpsc::channel(1);
ops.ahead_receiver = Some(ahead_rx);
app.git_ops_state = Some(ops);
app.state = AppState::GitOpsSplitTree;
let before = render_commits_pane_text(&mut app, false);
assert!(!before.contains("\u{2191}"), "before poll: no arrow, got: {}", before);
ahead_tx.send(2).await.unwrap();
app.poll_git_ops_updates();
let after = render_commits_pane_text(&mut app, false);
assert!(
after.contains("\u{2191}2"),
"after poll: should show ↑2, got: {}",
after
);
}
#[tokio::test]
async fn test_ahead_count_resets_after_push() {
let (mut app, _tx) = make_app();
let mut ops = GitOpsState::new(Vec::new());
ops.commit_log.commits.push(make_commit("sha1", "initial"));
ops.commit_log.initialized = true;
ops.ahead_count = 3;
ops.pushing = true;
let (op_tx, op_rx) = tokio::sync::mpsc::channel(1);
ops.op_receiver = Some(op_rx);
app.git_ops_state = Some(ops);
app.state = AppState::GitOpsSplitTree;
let before = render_commits_pane_text(&mut app, false);
assert!(before.contains("\u{2191}3"), "before push: got: {}", before);
op_tx.send(Ok("Pushed to origin/main".to_string())).await.unwrap();
app.poll_git_ops_updates();
let after = render_commits_pane_text(&mut app, false);
assert!(
!after.contains("\u{2191}"),
"after push: arrow should disappear, got: {}",
after
);
}
#[tokio::test]
async fn test_status_refresh_does_not_flash_diff_pane() {
let (mut app, _tx) = make_app();
let entries = vec![
entry("a.rs", FileStatus::Unmodified, FileStatus::Modified),
];
let mut ops = GitOpsState::new(entries);
rebuild_tree(&mut ops);
let cache = crate::app::DiffCache {
file_index: 0,
patch_hash: 42,
lines: vec![],
interner: lasso::Rodeo::new(),
highlighted: false,
markdown_rich: false,
};
ops.diff_store.set_current("a.rs".to_string(), cache);
let (tx, rx) = tokio::sync::mpsc::channel(1);
ops.status_receiver = Some(rx);
app.git_ops_state = Some(ops);
app.state = AppState::GitOpsSplitTree;
let new_entries = vec![
entry("a.rs", FileStatus::Modified, FileStatus::Unmodified),
];
tx.send(Ok(new_entries)).await.unwrap();
app.poll_git_ops_updates();
let ops = app.git_ops_state.as_ref().unwrap();
assert!(
ops.diff_store.current.is_some(),
"current diff cache should be preserved after status refresh"
);
assert_eq!(
ops.diff_store.store_len(),
0,
"store should be cleared for re-prefetch"
);
}
}