use ratatui::{
layout::Rect,
style::{Color, Style},
text::{Line, Span},
widgets::Paragraph,
Frame,
};
use crate::app::{App, ViewMode};
use crate::vcs::DiffBase;
use super::selection::{apply_selection_to_span, get_line_selection_range};
fn view_mode_label(app: &App) -> String {
match app.view.view_mode {
ViewMode::Full => " [all lines]".to_string(),
ViewMode::Context => " [context]".to_string(),
ViewMode::ChangesOnly => " [changed lines only]".to_string(),
ViewMode::CommitOnly => format!(" [commit {}]", app.comparison.to_label),
ViewMode::BookmarkOnly => match &app.comparison.bookmark_name {
Some(name) => format!(" [bookmark {}]", name),
None => " [bookmark]".to_string(),
},
}
}
fn repo_name(app: &App) -> String {
app.repo_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("repo")
.to_string()
}
fn branch_info(app: &App) -> String {
let mode_suffix = match app.diff_base {
DiffBase::ForkPoint => " [fork]",
DiffBase::TrunkTip => " [tip]",
};
let base = format!(
"{} | {} vs {}{}",
repo_name(app),
app.comparison.to_label,
app.comparison.from_label,
mode_suffix,
);
let with_stack = match app.comparison.stack_position {
Some(pos) if pos.head_count > 1 => {
format!("{base} [{}/{} head 1/{}]", pos.current, pos.total, pos.head_count)
}
Some(pos) => format!("{base} [{}/{}]", pos.current, pos.total),
None => base,
};
match &app.comparison.divergence {
Some(div) if div.behind_count > 0 => {
format!("{with_stack} [↑{}]", div.behind_count)
}
_ => with_stack,
}
}
pub fn status_bar_height(app: &App, width: u16) -> u16 {
let width = width as usize;
let help = " q:quit j/k:files g/G:top/bottom ?:help ";
let branch_info = branch_info(app);
let mode = view_mode_label(app);
let stats = format!(
"{} file{} | +{} -{}{} | {}%",
app.files.len(),
if app.files.len() == 1 { "" } else { "s" },
app.additions_count(),
app.deletions_count(),
mode,
app.scroll_percentage()
);
let full_status = format!("{} | {}", branch_info, stats);
if full_status.len() + help.len() + 2 <= width {
1
} else {
2
}
}
fn build_stats_spans(app: &App) -> Vec<Span<'static>> {
let file_count = app.files.len();
let mode = view_mode_label(app);
let mut spans = vec![
Span::styled(
format!("{} file{} | ", file_count, if file_count == 1 { "" } else { "s" }),
Style::default().fg(Color::Cyan),
),
Span::styled(format!("+{}", app.additions_count()), Style::default().fg(Color::LightGreen)),
Span::styled(" ", Style::default().fg(Color::Cyan)),
Span::styled(format!("-{}", app.deletions_count()), Style::default().fg(Color::Red)),
Span::styled(
format!("{} | {}%", mode, app.scroll_percentage()),
Style::default().fg(Color::Cyan),
),
];
if let Some(ref warning) = app.performance_warning {
spans.push(Span::styled(
format!(" [{}]", warning),
Style::default().fg(Color::Yellow),
));
}
spans
}
fn build_full_status_spans(app: &App) -> Vec<Span<'static>> {
let branch_info = branch_info(app);
let file_count = app.files.len();
let mode = view_mode_label(app);
let mut spans = vec![
Span::styled(
branch_info.clone(),
Style::default().fg(Color::Cyan),
),
];
if let Some(ref div) = app.comparison.divergence
&& div.behind_count > 0
&& let Some(last) = spans.last_mut()
{
let content = last.content.to_string();
let marker = format!(" [↑{}]", div.behind_count);
if let Some(stripped) = content.strip_suffix(&marker) {
*last = Span::styled(stripped.to_string(), Style::default().fg(Color::Cyan));
spans.push(Span::styled(marker, Style::default().fg(Color::Yellow)));
}
}
spans.push(Span::styled(
format!(" | {} file{} | ", file_count, if file_count == 1 { "" } else { "s" }),
Style::default().fg(Color::Cyan),
));
spans.extend([
Span::styled(format!("+{}", app.additions_count()), Style::default().fg(Color::LightGreen)),
Span::styled(" ", Style::default().fg(Color::Cyan)),
Span::styled(format!("-{}", app.deletions_count()), Style::default().fg(Color::Red)),
Span::styled(
format!("{} | {}%", mode, app.scroll_percentage()),
Style::default().fg(Color::Cyan),
),
]);
if let Some(ref warning) = app.performance_warning {
spans.push(Span::styled(
format!(" [{}]", warning),
Style::default().fg(Color::Yellow),
));
}
spans
}
pub fn truncate_with_ellipsis(s: &str, max_len: usize) -> String {
let char_count = s.chars().count();
if char_count <= max_len {
s.to_string()
} else if max_len <= 3 {
".".repeat(max_len)
} else {
let truncated: String = s.chars().take(max_len - 3).collect();
format!("{}...", truncated)
}
}
fn apply_status_bar_selection(line: Line<'static>, selection: &Option<crate::app::Selection>, virtual_row: usize) -> Line<'static> {
let Some((sel_start, sel_end)) = get_line_selection_range(selection, virtual_row) else {
return line;
};
let mut new_spans = Vec::new();
let mut char_offset = 0;
for span in line.spans {
let span_len = span.content.chars().count();
let result = apply_selection_to_span(span, char_offset, sel_start, sel_end);
new_spans.extend(result);
char_offset += span_len;
}
Line::from(new_spans)
}
pub fn draw_status_bar(frame: &mut Frame, app: &App, area: Rect) {
let width = area.width as usize;
let help = " q:quit j/k:files g/G:top/bottom ?:help ";
let help_short = " ?:help ";
let mode = view_mode_label(app);
let stats = format!(
"{} file{} | +{} -{}{} | {}%",
app.files.len(),
if app.files.len() == 1 { "" } else { "s" },
app.additions_count(),
app.deletions_count(),
mode,
app.scroll_percentage()
);
let branch_info = branch_info(app);
let full_status = format!("{} | {}", branch_info, stats);
if area.height >= 2 {
let line1_content = if branch_info.len() + help.len() + 2 <= width {
let padding = width.saturating_sub(branch_info.len() + help.len());
Line::from(vec![
Span::styled(branch_info.clone(), Style::default().fg(Color::Cyan)),
Span::raw(" ".repeat(padding)),
Span::styled(help, Style::default().fg(Color::DarkGray)),
])
} else if branch_info.len() + help_short.len() + 2 <= width {
let padding = width.saturating_sub(branch_info.len() + help_short.len());
Line::from(vec![
Span::styled(branch_info.clone(), Style::default().fg(Color::Cyan)),
Span::raw(" ".repeat(padding)),
Span::styled(help_short, Style::default().fg(Color::DarkGray)),
])
} else {
let max_branch_len = width.saturating_sub(help_short.len() + 1);
let truncated = truncate_with_ellipsis(&branch_info, max_branch_len);
let padding = width.saturating_sub(truncated.len() + help_short.len());
Line::from(vec![
Span::styled(truncated, Style::default().fg(Color::Cyan)),
Span::raw(" ".repeat(padding)),
Span::styled(help_short, Style::default().fg(Color::DarkGray)),
])
};
let line2_content = if stats.len() <= width {
Line::from(build_stats_spans(app))
} else {
let truncated = truncate_with_ellipsis(&stats, width);
Line::from(Span::styled(truncated, Style::default().fg(Color::Cyan)))
};
let row_map_len = app.view.row_map.len();
let line1_content = apply_status_bar_selection(line1_content, &app.view.selection, row_map_len);
let line2_content = apply_status_bar_selection(line2_content, &app.view.selection, row_map_len + 1);
let paragraph = Paragraph::new(vec![line1_content, line2_content]);
frame.render_widget(paragraph, area);
} else {
let line = if full_status.len() + help.len() + 2 <= width {
let padding = width.saturating_sub(full_status.len() + help.len());
let mut spans = build_full_status_spans(app);
spans.push(Span::raw(" ".repeat(padding)));
spans.push(Span::styled(help, Style::default().fg(Color::DarkGray)));
Line::from(spans)
} else if full_status.len() + help_short.len() + 2 <= width {
let padding = width.saturating_sub(full_status.len() + help_short.len());
let mut spans = build_full_status_spans(app);
spans.push(Span::raw(" ".repeat(padding)));
spans.push(Span::styled(help_short, Style::default().fg(Color::DarkGray)));
Line::from(spans)
} else if full_status.len() <= width {
Line::from(build_full_status_spans(app))
} else {
if stats.len() + 3 <= width {
let max_branch_len = width.saturating_sub(stats.len() + 4); let truncated_branch = truncate_with_ellipsis(&branch_info, max_branch_len);
let truncated_status = format!("{} | {}", truncated_branch, stats);
Line::from(Span::styled(truncated_status, Style::default().fg(Color::Cyan)))
} else {
let truncated = truncate_with_ellipsis(&full_status, width);
Line::from(Span::styled(truncated, Style::default().fg(Color::Cyan)))
}
};
let row_map_len = app.view.row_map.len();
let line = apply_status_bar_selection(line, &app.view.selection, row_map_len);
let paragraph = Paragraph::new(line);
frame.render_widget(paragraph, area);
}
}
pub fn status_bar_plain_text(app: &App, width: u16) -> Vec<String> {
let width = width as usize;
let help = " q:quit j/k:files g/G:top/bottom ?:help ";
let help_short = " ?:help ";
let mode = view_mode_label(app);
let stats = format!(
"{} file{} | +{} -{}{} | {}%",
app.files.len(),
if app.files.len() == 1 { "" } else { "s" },
app.additions_count(),
app.deletions_count(),
mode,
app.scroll_percentage()
);
let branch_info = branch_info(app);
let full_status = format!("{} | {}", branch_info, stats);
let height = status_bar_height(app, width as u16);
if height >= 2 {
let line1 = if branch_info.len() + help.len() + 2 <= width {
let padding = width.saturating_sub(branch_info.len() + help.len());
format!("{}{}{}", branch_info, " ".repeat(padding), help)
} else if branch_info.len() + help_short.len() + 2 <= width {
let padding = width.saturating_sub(branch_info.len() + help_short.len());
format!("{}{}{}", branch_info, " ".repeat(padding), help_short)
} else {
let max_branch_len = width.saturating_sub(help_short.len() + 1);
let truncated = truncate_with_ellipsis(&branch_info, max_branch_len);
let padding = width.saturating_sub(truncated.len() + help_short.len());
format!("{}{}{}", truncated, " ".repeat(padding), help_short)
};
let line2 = if stats.len() <= width {
stats
} else {
truncate_with_ellipsis(&stats, width)
};
vec![line1, line2]
} else {
let line = if full_status.len() + help.len() + 2 <= width {
let padding = width.saturating_sub(full_status.len() + help.len());
format!("{}{}{}", full_status, " ".repeat(padding), help)
} else if full_status.len() + help_short.len() + 2 <= width {
let padding = width.saturating_sub(full_status.len() + help_short.len());
format!("{}{}{}", full_status, " ".repeat(padding), help_short)
} else if full_status.len() <= width {
full_status
} else if stats.len() + 3 <= width {
let max_branch_len = width.saturating_sub(stats.len() + 4);
let truncated_branch = truncate_with_ellipsis(&branch_info, max_branch_len);
format!("{} | {}", truncated_branch, stats)
} else {
truncate_with_ellipsis(&full_status, width)
};
vec![line]
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::TestAppBuilder;
#[test]
fn test_repo_name_extracts_directory_name() {
let app = TestAppBuilder::new().build();
assert_eq!(repo_name(&app), "test");
}
#[test]
fn test_branch_info_includes_repo_name() {
let app = TestAppBuilder::new()
.with_current_branch(Some("feature"))
.with_base_branch("main")
.build();
let spans = build_full_status_spans(&app);
let combined: String = spans.iter().map(|s| s.content.to_string()).collect();
assert!(
combined.starts_with("test | feature vs main"),
"Expected branch info to start with 'test | feature vs main', got: {}",
combined
);
}
#[test]
fn test_branch_info_uses_head_when_no_current_branch() {
let app = TestAppBuilder::new()
.with_current_branch(None)
.with_base_branch("master")
.build();
let spans = build_full_status_spans(&app);
let combined: String = spans.iter().map(|s| s.content.to_string()).collect();
assert!(
combined.starts_with("test | HEAD vs master"),
"Expected branch info to start with 'test | HEAD vs master', got: {}",
combined
);
}
#[test]
fn test_branch_info_stack_position_linear() {
use crate::vcs::StackPosition;
let app = TestAppBuilder::new()
.with_current_branch(Some("qvkxrzts"))
.with_base_branch("main")
.with_stack_position(StackPosition { current: 2, total: 3, head_count: 1 })
.build();
let info = branch_info(&app);
assert!(info.contains("[2/3]"),
"linear stack should show [2/3], got: {info}");
assert!(!info.contains("head"),
"linear stack should not show head count, got: {info}");
}
#[test]
fn test_branch_info_stack_position_branching() {
use crate::vcs::StackPosition;
let app = TestAppBuilder::new()
.with_current_branch(Some("qvkxrzts"))
.with_base_branch("main")
.with_stack_position(StackPosition { current: 3, total: 5, head_count: 2 })
.build();
let info = branch_info(&app);
assert!(info.contains("[3/5 head 1/2]"),
"branching stack should show [3/5 head 1/2], got: {info}");
}
#[test]
fn test_branch_info_no_stack_position() {
let app = TestAppBuilder::new()
.with_current_branch(Some("feature"))
.with_base_branch("main")
.build();
let info = branch_info(&app);
assert!(info.ends_with("[fork]") || info.ends_with("[tip]"),
"without stack position, branch info should end with diff base indicator, got: {info}");
}
#[test]
fn test_truncate_with_ellipsis_no_truncation_needed() {
assert_eq!(truncate_with_ellipsis("hello", 10), "hello");
assert_eq!(truncate_with_ellipsis("hello", 5), "hello");
}
#[test]
fn test_truncate_with_ellipsis_truncates_with_dots() {
assert_eq!(truncate_with_ellipsis("hello world", 8), "hello...");
assert_eq!(truncate_with_ellipsis("hello world", 6), "hel...");
}
#[test]
fn test_truncate_with_ellipsis_very_short_max() {
assert_eq!(truncate_with_ellipsis("hello", 3), "...");
assert_eq!(truncate_with_ellipsis("hello", 2), "..");
assert_eq!(truncate_with_ellipsis("hello", 1), ".");
assert_eq!(truncate_with_ellipsis("hello", 0), "");
}
#[test]
fn test_truncate_with_ellipsis_exactly_at_boundary() {
assert_eq!(truncate_with_ellipsis("hello", 5), "hello");
assert_eq!(truncate_with_ellipsis("hello", 4), "h...");
}
#[test]
fn test_truncate_with_ellipsis_utf8_characters() {
assert_eq!(truncate_with_ellipsis("日本語", 3), "日本語"); assert_eq!(truncate_with_ellipsis("日本語", 2), "..");
assert_eq!(truncate_with_ellipsis("日本語です", 5), "日本語です");
assert_eq!(truncate_with_ellipsis("日本語です", 4), "日...");
assert_eq!(truncate_with_ellipsis("🎉🎊🎈", 3), "🎉🎊🎈");
assert_eq!(truncate_with_ellipsis("🎉🎊🎈", 2), "..");
assert_eq!(truncate_with_ellipsis("hello日本語", 10), "hello日本語");
assert_eq!(truncate_with_ellipsis("hello日本語", 8), "hello日本語");
assert_eq!(truncate_with_ellipsis("hello日本語", 7), "hell...");
}
#[test]
fn test_view_mode_label_commit_only_includes_to_label() {
let mut app = TestAppBuilder::new()
.with_current_branch(Some("knmq"))
.build();
app.view.view_mode = crate::app::ViewMode::CommitOnly;
assert_eq!(view_mode_label(&app), " [commit knmq]");
}
#[test]
fn test_view_mode_label_full_mode_is_empty() {
let app = TestAppBuilder::new().build();
assert_eq!(view_mode_label(&app), " [all lines]");
}
#[test]
fn test_view_mode_label_context_mode() {
let mut app = TestAppBuilder::new().build();
app.view.view_mode = crate::app::ViewMode::Context;
assert_eq!(view_mode_label(&app), " [context]");
}
#[test]
fn test_view_mode_label_changes_only_mode() {
let mut app = TestAppBuilder::new().build();
app.view.view_mode = crate::app::ViewMode::ChangesOnly;
assert_eq!(view_mode_label(&app), " [changed lines only]");
}
#[test]
fn test_view_mode_label_commit_only_with_bookmarks() {
let mut app = TestAppBuilder::new()
.with_current_branch(Some("knmq (main)"))
.build();
app.view.view_mode = crate::app::ViewMode::CommitOnly;
assert_eq!(view_mode_label(&app), " [commit knmq (main)]");
}
#[test]
fn test_view_mode_label_bookmark_only_with_name() {
let mut app = TestAppBuilder::new().build();
app.view.view_mode = crate::app::ViewMode::BookmarkOnly;
app.comparison.bookmark_name = Some("feat/abc".to_string());
assert_eq!(view_mode_label(&app), " [bookmark feat/abc]");
}
#[test]
fn test_view_mode_label_bookmark_only_without_name() {
let mut app = TestAppBuilder::new().build();
app.view.view_mode = crate::app::ViewMode::BookmarkOnly;
app.comparison.bookmark_name = None;
assert_eq!(view_mode_label(&app), " [bookmark]");
}
fn create_status_bar_test_app(
current_branch: Option<&str>,
base_branch: &str,
file_count: usize,
) -> crate::app::App {
use crate::diff::{DiffLine, FileDiff};
let files: Vec<FileDiff> = (0..file_count)
.map(|i| FileDiff::new(vec![DiffLine::file_header(&format!("file{}.rs", i))]))
.collect();
TestAppBuilder::new()
.with_files(files)
.with_base_branch(base_branch)
.with_current_branch(current_branch)
.build()
}
#[test]
fn test_status_bar_height_wide_terminal_uses_one_line() {
let app = create_status_bar_test_app(Some("feature-branch"), "main", 5);
assert_eq!(status_bar_height(&app, 120), 1);
}
#[test]
fn test_status_bar_height_narrow_terminal_uses_two_lines() {
let app = create_status_bar_test_app(Some("feature-branch"), "main", 5);
assert_eq!(status_bar_height(&app, 40), 2);
}
#[test]
fn test_status_bar_height_long_branch_name_needs_two_lines() {
let app = create_status_bar_test_app(
Some("very-long-feature-branch-name-that-takes-space"),
"main",
5,
);
assert_eq!(status_bar_height(&app, 80), 2);
}
#[test]
fn test_status_bar_height_no_current_branch_uses_head() {
let app = create_status_bar_test_app(None, "main", 5);
assert_eq!(status_bar_height(&app, 120), 1);
}
#[test]
fn test_status_bar_height_boundary_case() {
let app = create_status_bar_test_app(Some("feat"), "main", 1);
let help = " q:quit j/k:files g/G:top/bottom ?:help ";
let branch_info = "test | feat vs main [fork]";
let stats = format!(
"{} file{} | +{} -{}{} | {}%",
app.files.len(),
if app.files.len() == 1 { "" } else { "s" },
app.additions_count(),
app.deletions_count(),
" [all lines]",
app.scroll_percentage()
);
let full_status = format!("{} | {}", branch_info, stats);
let threshold = full_status.len() + help.len() + 2;
assert_eq!(status_bar_height(&app, threshold as u16), 1,
"At threshold width {} should use 1 line", threshold);
assert_eq!(status_bar_height(&app, (threshold - 1) as u16), 2,
"At width {} (one below threshold) should use 2 lines", threshold - 1);
}
#[test]
fn test_branch_info_shows_fork_mode_by_default() {
let app = create_status_bar_test_app(Some("feat"), "main", 1);
let info = branch_info(&app);
assert!(info.contains("[fork]"), "Should show fork mode: {}", info);
}
#[test]
fn test_branch_info_shows_tip_mode_after_toggle() {
let mut app = create_status_bar_test_app(Some("feat"), "main", 1);
app.toggle_diff_base();
let info = branch_info(&app);
assert!(info.contains("[tip]"), "Should show tip mode: {}", info);
}
#[test]
fn test_branch_info_shows_behind_count_when_diverged() {
use std::collections::HashSet;
let mut app = create_status_bar_test_app(Some("feat"), "main", 1);
app.comparison.divergence = Some(crate::vcs::UpstreamDivergence {
behind_count: 7,
upstream_files: HashSet::new(),
});
let info = branch_info(&app);
assert!(info.contains("[↑7]"), "Should show behind count: {}", info);
}
#[test]
fn test_branch_info_no_behind_count_when_zero() {
let app = create_status_bar_test_app(Some("feat"), "main", 1);
let info = branch_info(&app);
assert!(!info.contains("[↑"), "Should not show behind count: {}", info);
}
}