use ratatui::{
layout::Rect,
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, List, ListItem, Paragraph},
Frame,
};
use super::helpers::{
detail_separator_line, display_width, score_bar_spans, skip_width, truncate_to_width,
truncate_to_width_with_ellipsis,
};
use super::theme;
use crate::app::{App, SidebarPanel, StatusMessageLevel};
use crate::event::GitEventKind;
use crate::git::{FileChangeStatus, FileStatusKind};
use crate::i18n::Language;
use crate::intent::classify_intent;
use crate::risk::RiskLevel;
use crate::stats::{AlertSeverity, ConfidenceLevel, HealthAlertKind};
use crate::topology::{BranchStatus, RecommendedAction};
const COMMIT_ITEM_PADDING: u16 = 12;
const BRANCH_GONE_LABEL_WIDTH: u16 = 11;
const BRANCH_ITEM_PADDING: u16 = 4;
const FILE_LIST_PREFIX_WIDTH: usize = 4;
const DIFF_LINENO_WIDTH: usize = 4;
const HEALTH_LABEL_WIDTH: usize = 16;
const HEALTH_BAR_MIN: usize = 10;
const HEALTH_BAR_MAX: usize = 25;
const MAX_RECENT_COMMITS: usize = 15;
const RECENT_HASH_WIDTH: usize = 8;
const STASH_FOOTER_LINES: u16 = 3;
const STASH_DISPLAY_PADDING: u16 = 14;
fn dashboard_panel_block(panel: SidebarPanel, is_active: bool, lang: Language) -> Block<'static> {
let title = format!(" {} [{}] ", panel.label(lang), panel.number());
let (border_color, title_color) = if is_active {
(theme::PANEL_BORDER_ACTIVE, theme::PANEL_TITLE_ACTIVE)
} else {
(theme::PANEL_BORDER_INACTIVE, theme::PANEL_TITLE_INACTIVE)
};
Block::bordered()
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(border_color))
.title(Span::styled(
title,
Style::default().fg(title_color).add_modifier(if is_active {
Modifier::BOLD
} else {
Modifier::empty()
}),
))
}
pub(crate) fn render_dashboard_status_panel(
frame: &mut Frame,
area: Rect,
app: &App,
is_active: bool,
) {
let block = dashboard_panel_block(SidebarPanel::Status, is_active, app.language);
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.height == 0 || inner.width < 4 {
return;
}
let mut lines = Vec::new();
let lang = app.language;
if let Some(ref info) = app.repo_info {
lines.push(Line::from(vec![
Span::styled(lang.repo_label(), Style::default().fg(theme::SUBTEXT0)),
Span::styled(
info.name.clone(),
Style::default()
.fg(theme::TEXT)
.add_modifier(Modifier::BOLD),
),
]));
}
if let Some(ref info) = app.repo_info {
let mut spans = vec![
Span::styled(lang.on_label(), Style::default().fg(theme::SUBTEXT0)),
Span::styled(
info.branch.clone(),
Style::default()
.fg(theme::BLUE)
.add_modifier(Modifier::BOLD),
),
];
if app.watch_mode {
spans.push(Span::styled(
format!(" {}", lang.watch_mode_on()),
Style::default()
.fg(theme::GREEN)
.add_modifier(Modifier::BOLD),
));
}
lines.push(Line::from(spans));
}
let paragraph = Paragraph::new(lines);
frame.render_widget(paragraph, inner);
}
pub(crate) fn render_dashboard_commits_panel(
frame: &mut Frame,
area: Rect,
app: &App,
is_active: bool,
) {
let block = dashboard_panel_block(SidebarPanel::Commits, is_active, app.language);
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.height == 0 || inner.width < 4 {
return;
}
let events: Vec<_> = app.events().collect();
let visible_count = inner.height as usize;
let offset = if app.selected_index >= visible_count {
app.selected_index - visible_count + 1
} else {
0
};
let mut items: Vec<ListItem> = Vec::new();
for (display_idx, event) in events.iter().enumerate().skip(offset).take(visible_count) {
if display_idx > 0 {
if let (Some(prev_event), Some(sessions)) =
(events.get(display_idx - 1), app.session_cache.as_ref())
{
let prev_sid = prev_event.session_id;
let curr_sid = event.session_id;
if prev_sid != curr_sid {
if let Some(sid) = curr_sid {
if let Some(session) = sessions.iter().find(|s| s.id == sid) {
let tool_name = session.tool.as_deref().unwrap_or("AI");
let sep_text = format!(
" Session #{} ({}, {} commits) ",
sid, tool_name, session.stats.commit_count
);
let sep_width = inner.width as usize;
let dashes_total = sep_width.saturating_sub(display_width(&sep_text));
let left_dashes = dashes_total / 2;
let right_dashes = dashes_total.saturating_sub(left_dashes);
let separator = format!(
"{}{}{}",
"\u{2500}".repeat(left_dashes),
sep_text,
"\u{2500}".repeat(right_dashes)
);
items.push(ListItem::new(Line::from(Span::styled(
separator,
Style::default().fg(theme::SEPARATOR),
))));
}
}
}
}
}
let is_selected = display_idx == app.selected_index;
let kind_marker = match event.kind {
GitEventKind::Commit => "●",
GitEventKind::Merge => "◆",
GitEventKind::BranchSwitch => "○",
};
let intent = event
.inferred_intent
.unwrap_or_else(|| classify_intent(&event.message, &[]));
let intent_icon = intent.icon();
let hash = &event.short_hash;
let msg = truncate_to_width_with_ellipsis(
&event.message,
inner.width.saturating_sub(COMMIT_ITEM_PADDING + 2) as usize,
);
let spans = vec![
Span::styled(
format!("{} ", kind_marker),
Style::default().fg(match event.kind {
GitEventKind::Commit => theme::GREEN,
GitEventKind::Merge => theme::MAUVE,
GitEventKind::BranchSwitch => theme::BLUE,
}),
),
Span::styled(format!("{} ", hash), Style::default().fg(theme::PEACH)),
Span::styled(
format!("{} ", intent_icon),
Style::default().fg(theme::LAVENDER),
),
Span::styled(msg, Style::default().fg(theme::TEXT)),
];
let style = if is_selected && is_active {
Style::default().bg(theme::SELECTED_LINE_BG)
} else if is_selected {
Style::default().bg(theme::SELECTED_LINE_BG_INACTIVE)
} else {
Style::default()
};
items.push(ListItem::new(Line::from(spans)).style(style));
}
let list = List::new(items);
frame.render_widget(list, inner);
}
pub(crate) fn render_dashboard_branches_panel(
frame: &mut Frame,
area: Rect,
app: &App,
is_active: bool,
) {
let block = dashboard_panel_block(SidebarPanel::Branches, is_active, app.language);
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.height == 0 || inner.width < 4 {
return;
}
let current_branch = app.repo_info.as_ref().map(|info| info.branch.as_str());
let visible_count = inner.height as usize;
let offset = if app.branch_selected_index >= visible_count {
app.branch_selected_index - visible_count + 1
} else {
0
};
let first_gone_index = app.branches.iter().position(|b| b.is_gone);
let mut items: Vec<ListItem> = Vec::new();
for (i, branch_info) in app
.branches
.iter()
.enumerate()
.skip(offset)
.take(visible_count)
{
if Some(i) == first_gone_index {
let sep = "─".repeat(inner.width.saturating_sub(2) as usize);
items.push(ListItem::new(Line::from(Span::styled(
format!(" {}", sep),
Style::default().fg(theme::SEPARATOR),
))));
}
let is_current = current_branch == Some(branch_info.name.as_str());
let marker = if is_current { "● " } else { " " };
let (branch_display, gone_suffix) = if branch_info.is_gone {
let max_w = inner.width.saturating_sub(BRANCH_GONE_LABEL_WIDTH) as usize;
(
truncate_to_width_with_ellipsis(&branch_info.name, max_w),
" [gone]",
)
} else {
(
truncate_to_width_with_ellipsis(
&branch_info.name,
inner.width.saturating_sub(BRANCH_ITEM_PADDING) as usize,
),
"",
)
};
let branch_color = if branch_info.is_gone {
theme::SUBTEXT0
} else if is_current {
theme::GREEN
} else {
theme::BLUE
};
let mut spans = vec![
Span::styled(
marker.to_string(),
Style::default().fg(if is_current {
theme::GREEN
} else {
theme::SUBTEXT0
}),
),
Span::styled(branch_display, Style::default().fg(branch_color)),
];
if branch_info.is_gone {
spans.push(Span::styled(
gone_suffix.to_string(),
Style::default().fg(theme::OVERLAY0),
));
}
let style = if i == app.branch_selected_index && is_active {
Style::default().bg(theme::SELECTED_LINE_BG)
} else if i == app.branch_selected_index {
Style::default().bg(theme::SELECTED_LINE_BG_INACTIVE)
} else {
Style::default()
};
items.push(ListItem::new(Line::from(spans)).style(style));
}
let list = List::new(items);
frame.render_widget(list, inner);
}
pub(crate) fn render_dashboard_files_panel(
frame: &mut Frame,
area: Rect,
app: &App,
is_active: bool,
) {
let block = if let Some(ref risk_level) = app.staged_risk_level {
let panel = SidebarPanel::Files;
let title = format!(" {} [{}] ", panel.label(app.language), panel.number());
let (border_color, title_color) = if is_active {
(theme::PANEL_BORDER_ACTIVE, theme::PANEL_TITLE_ACTIVE)
} else {
(theme::PANEL_BORDER_INACTIVE, theme::PANEL_TITLE_INACTIVE)
};
let risk_color = match risk_level {
RiskLevel::Low => theme::GREEN,
RiskLevel::Medium => theme::YELLOW,
RiskLevel::High => theme::MAROON,
};
Block::bordered()
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(border_color))
.title(Line::from(vec![
Span::styled(
title,
Style::default().fg(title_color).add_modifier(if is_active {
Modifier::BOLD
} else {
Modifier::empty()
}),
),
Span::styled(
format!("[Risk: {}]", risk_level.label()),
Style::default().fg(risk_color).add_modifier(Modifier::BOLD),
),
]))
} else {
dashboard_panel_block(SidebarPanel::Files, is_active, app.language)
};
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.height == 0 || inner.width < 4 {
return;
}
let visible = inner.height as usize;
let scroll_offset = if app.status_selected_index >= visible {
app.status_selected_index - visible + 1
} else {
0
};
let mut items: Vec<ListItem> = Vec::new();
for (i, status) in app
.file_statuses
.iter()
.enumerate()
.skip(scroll_offset)
.take(visible)
{
let (icon, color) = match status.kind {
FileStatusKind::StagedNew => ("A", theme::FILE_ADDED),
FileStatusKind::StagedModified => ("M", theme::GREEN),
FileStatusKind::StagedDeleted => ("D", theme::GREEN),
FileStatusKind::Modified => ("M", theme::FILE_MODIFIED),
FileStatusKind::Deleted => ("D", theme::FILE_DELETED),
FileStatusKind::Untracked => ("?", theme::SUBTEXT0),
};
let path =
truncate_to_width_with_ellipsis(&status.path, inner.width.saturating_sub(4) as usize);
let spans = vec![
Span::styled(format!("{} ", icon), Style::default().fg(color)),
Span::styled(path, Style::default().fg(theme::TEXT)),
];
let style = if i == app.status_selected_index && is_active {
Style::default().bg(theme::SELECTED_LINE_BG)
} else if i == app.status_selected_index {
Style::default().bg(theme::SELECTED_LINE_BG_INACTIVE)
} else {
Style::default()
};
items.push(ListItem::new(Line::from(spans)).style(style));
}
let list = List::new(items);
frame.render_widget(list, inner);
}
pub(crate) fn render_dashboard_stash_panel(
frame: &mut Frame,
area: Rect,
app: &App,
is_active: bool,
) {
let block = dashboard_panel_block(SidebarPanel::Stash, is_active, app.language);
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.height == 0 || inner.width < 4 {
return;
}
let mut items: Vec<ListItem> = Vec::new();
if let Some(ref stash_entries) = app.stash_view.cache {
for (i, stash) in stash_entries.iter().enumerate().take(inner.height as usize) {
let label = truncate_to_width_with_ellipsis(
&format!("stash@{{{}}}: {}", stash.index, stash.message),
inner.width.saturating_sub(2) as usize,
);
let style = if i == app.stash_view.nav.selected_index && is_active {
Style::default().fg(theme::TEXT).bg(theme::SELECTED_LINE_BG)
} else if i == app.stash_view.nav.selected_index {
Style::default()
.fg(theme::TEXT)
.bg(theme::SELECTED_LINE_BG_INACTIVE)
} else {
Style::default().fg(theme::SUBTEXT1)
};
items.push(ListItem::new(label).style(style));
}
}
if items.is_empty() {
items.push(ListItem::new(Span::styled(
"(empty)",
Style::default().fg(theme::OVERLAY0),
)));
}
let list = List::new(items);
frame.render_widget(list, inner);
}
pub(crate) fn render_dashboard_main_panel(frame: &mut Frame, area: Rect, app: &App) {
let is_active = !app.sidebar_focused;
let (border_color, title_color) = if is_active {
(theme::PANEL_BORDER_ACTIVE, theme::PANEL_TITLE_ACTIVE)
} else {
(theme::PANEL_BORDER_INACTIVE, theme::PANEL_TITLE_INACTIVE)
};
let lang = app.language;
let title = match app.active_sidebar_panel {
SidebarPanel::Status => lang.main_summary(),
SidebarPanel::Commits => lang.main_diff(),
SidebarPanel::Branches => lang.main_branch_info(),
SidebarPanel::Files => lang.main_file_diff(),
SidebarPanel::Stash => lang.main_stash_detail(),
};
let block = Block::bordered()
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(border_color))
.title(Span::styled(
title,
Style::default().fg(title_color).add_modifier(if is_active {
Modifier::BOLD
} else {
Modifier::empty()
}),
));
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.height == 0 || inner.width < 4 {
return;
}
match app.active_sidebar_panel {
SidebarPanel::Commits => {
render_dashboard_commit_detail(frame, inner, app);
}
SidebarPanel::Status => {
render_dashboard_status_detail(frame, inner, app);
}
SidebarPanel::Branches => {
render_dashboard_branch_detail(frame, inner, app);
}
SidebarPanel::Files => {
render_dashboard_file_detail(frame, inner, app);
}
SidebarPanel::Stash => {
render_dashboard_stash_detail(frame, inner, app);
}
}
}
fn change_bar_spans(insertions: usize, deletions: usize, max: usize) -> Vec<Span<'static>> {
const BAR_WIDTH: usize = 5;
let total = insertions + deletions;
if total == 0 {
return vec![Span::styled(
"░".repeat(BAR_WIDTH),
Style::default().fg(theme::OVERLAY0),
)];
}
let ratio = total as f64 / max as f64;
let filled = ((ratio * BAR_WIDTH as f64).round() as usize).clamp(1, BAR_WIDTH);
let add_blocks = if insertions > 0 && deletions > 0 {
((insertions as f64 / total as f64) * filled as f64).round() as usize
} else if insertions > 0 {
filled
} else {
0
};
let add_blocks = add_blocks.min(filled);
let del_blocks = filled.saturating_sub(add_blocks);
let empty = BAR_WIDTH - filled;
let mut spans = Vec::new();
if add_blocks > 0 {
spans.push(Span::styled(
"█".repeat(add_blocks),
Style::default().fg(theme::GREEN),
));
}
if del_blocks > 0 {
spans.push(Span::styled(
"█".repeat(del_blocks),
Style::default().fg(theme::RED),
));
}
if empty > 0 {
spans.push(Span::styled(
"░".repeat(empty),
Style::default().fg(theme::OVERLAY0),
));
}
spans
}
fn render_dashboard_commit_detail(frame: &mut Frame, area: Rect, app: &App) {
let selected = app.selected_event();
if let Some(event) = selected {
let mut lines = Vec::new();
lines.push(Line::from(vec![
Span::styled("commit ", Style::default().fg(theme::SUBTEXT0)),
Span::styled(
event.short_hash.clone(),
Style::default()
.fg(theme::PEACH)
.add_modifier(Modifier::BOLD),
),
]));
lines.push(Line::from(vec![
Span::styled("Author: ", Style::default().fg(theme::SUBTEXT0)),
Span::styled(event.author.clone(), Style::default().fg(theme::BLUE)),
]));
lines.push(Line::from(vec![
Span::styled("Date: ", Style::default().fg(theme::SUBTEXT0)),
Span::styled(
event.timestamp.format("%Y-%m-%d %H:%M:%S").to_string(),
Style::default().fg(theme::SUBTEXT1),
),
]));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
event.message.clone(),
Style::default()
.fg(theme::TEXT)
.add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(
format!("+{}", event.files_added),
Style::default().fg(theme::DIFF_ADD),
),
Span::styled(" / ", Style::default().fg(theme::SUBTEXT0)),
Span::styled(
format!("-{}", event.files_deleted),
Style::default().fg(theme::DIFF_DEL),
),
]));
lines.push(Line::from(""));
if let Some(ref diff) = app.detail_diff_cache {
let inner_width = area.width.saturating_sub(1) as usize;
lines.push(Line::from(Span::styled(
format!("{} files changed", diff.files.len()),
Style::default().fg(theme::SUBTEXT0),
)));
lines.push(detail_separator_line(area.width));
let max_changes = diff
.files
.iter()
.map(|f| f.insertions + f.deletions)
.max()
.unwrap_or(1)
.max(1);
let lineno_width = DIFF_LINENO_WIDTH;
for (file_idx, file_change) in diff.files.iter().enumerate() {
let is_selected = file_idx == app.commit_detail.selected_file;
let is_expanded = app.commit_detail.expanded_files.contains(&file_idx);
let status_str = match file_change.status {
FileChangeStatus::Added => "A",
FileChangeStatus::Modified => "M",
FileChangeStatus::Deleted => "D",
FileChangeStatus::Renamed => "R",
};
let status_color = match file_change.status {
FileChangeStatus::Added => theme::DIFF_ADD,
FileChangeStatus::Modified => theme::YELLOW,
FileChangeStatus::Deleted => theme::DIFF_DEL,
FileChangeStatus::Renamed => theme::BLUE,
};
let line_bg = if is_selected {
Style::default().bg(theme::SELECTED_LINE_BG)
} else {
Style::default()
};
let expand_icon = if is_expanded { "▾ " } else { "▸ " };
let expand_style = Style::default().fg(theme::SUBTEXT0);
let name_style = if is_selected {
Style::default()
.fg(theme::YELLOW)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(theme::TEXT)
.add_modifier(Modifier::BOLD)
};
let stats_str = format!(
" +{:<4} -{:<4}",
file_change.insertions, file_change.deletions
);
let bar_width = 5;
let prefix_width = FILE_LIST_PREFIX_WIDTH;
let stats_width = display_width(&stats_str);
let path_max =
inner_width.saturating_sub(prefix_width + stats_width + bar_width + 1);
let mut spans = vec![
Span::styled(expand_icon.to_string(), expand_style),
Span::styled(
format!("{} ", status_str),
Style::default().fg(status_color),
),
Span::styled(
truncate_to_width_with_ellipsis(&file_change.path, path_max),
name_style,
),
Span::styled(stats_str, Style::default().fg(theme::SUBTEXT0)),
];
spans.extend(change_bar_spans(
file_change.insertions,
file_change.deletions,
max_changes,
));
lines.push(Line::from(spans).style(line_bg));
if is_expanded {
if let Some(patch) = diff.patches.iter().find(|p| p.path == file_change.path) {
let min_indent = patch
.lines
.iter()
.filter(|l| !l.content.trim().is_empty())
.map(|l| l.content.len() - l.content.trim_start().len())
.min()
.unwrap_or(0);
for line in &patch.lines {
let (style, prefix) = match line.origin {
'+' => (Style::default().fg(theme::GREEN), "+"),
'-' => (Style::default().fg(theme::RED), "-"),
_ => (Style::default().fg(theme::SUBTEXT1), " "),
};
let lineno_str = match line.new_lineno {
Some(n) => format!("{:>width$}", n, width = lineno_width),
None => match line.old_lineno {
Some(n) => format!("{:>width$}", n, width = lineno_width),
None => " ".repeat(lineno_width),
},
};
let dedented = if line.content.len() > min_indent {
&line.content[min_indent..]
} else {
&line.content
};
lines.push(Line::from(vec![
Span::styled(
format!("{} ", lineno_str),
Style::default().fg(theme::OVERLAY0),
),
Span::styled(prefix.to_string(), style),
Span::styled(
skip_width(dedented, app.commit_detail.h_scroll),
style,
),
]));
}
}
}
}
}
let scroll = app.commit_detail.scroll;
let paragraph = Paragraph::new(lines).scroll((scroll as u16, 0));
frame.render_widget(paragraph, area);
} else {
let msg = Paragraph::new(Span::styled(
app.language.no_commit_selected(),
Style::default().fg(theme::OVERLAY0),
));
frame.render_widget(msg, area);
}
}
fn render_dashboard_status_detail(frame: &mut Frame, area: Rect, app: &App) {
let mut lines = Vec::new();
let lang = app.language;
if let Some(ref info) = app.repo_info {
lines.push(Line::from(Span::styled(
lang.repository_summary(),
Style::default()
.fg(theme::LAVENDER)
.add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(lang.dashboard_name(), Style::default().fg(theme::SUBTEXT0)),
Span::styled(info.name.clone(), Style::default().fg(theme::TEXT)),
]));
lines.push(Line::from(vec![
Span::styled(
lang.dashboard_branch(),
Style::default().fg(theme::SUBTEXT0),
),
Span::styled(info.branch.clone(), Style::default().fg(theme::BLUE)),
]));
lines.push(Line::from(vec![
Span::styled(
lang.dashboard_commits(),
Style::default().fg(theme::SUBTEXT0),
),
Span::styled(
format!("{}", app.all_events().len()),
Style::default().fg(theme::TEXT),
),
]));
lines.push(Line::from(vec![
Span::styled(
lang.dashboard_branches(),
Style::default().fg(theme::SUBTEXT0),
),
Span::styled(
format!("{}", app.branches.len()),
Style::default().fg(theme::TEXT),
),
]));
if let Some(ref stashes) = app.stash_view.cache {
lines.push(Line::from(vec![
Span::styled(
lang.dashboard_stashes(),
Style::default().fg(theme::SUBTEXT0),
),
Span::styled(
format!("{}", stashes.len()),
Style::default().fg(theme::TEXT),
),
]));
}
}
if !app.file_statuses.is_empty() {
lines.push(detail_separator_line(area.width));
lines.push(Line::from(Span::styled(
lang.file_changes(),
Style::default()
.fg(theme::LAVENDER)
.add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(""));
let staged = app
.file_statuses
.iter()
.filter(|s| s.kind.is_staged())
.count();
let modified = app
.file_statuses
.iter()
.filter(|s| s.kind == FileStatusKind::Modified)
.count();
let deleted = app
.file_statuses
.iter()
.filter(|s| s.kind == FileStatusKind::Deleted)
.count();
let untracked = app
.file_statuses
.iter()
.filter(|s| s.kind == FileStatusKind::Untracked)
.count();
if staged > 0 {
lines.push(Line::from(vec![
Span::styled(lang.staged_label(), Style::default().fg(theme::SUBTEXT0)),
Span::styled(format!("{}", staged), Style::default().fg(theme::GREEN)),
]));
}
if modified > 0 {
lines.push(Line::from(vec![
Span::styled(lang.modified_label(), Style::default().fg(theme::SUBTEXT0)),
Span::styled(format!("{}", modified), Style::default().fg(theme::YELLOW)),
]));
}
if deleted > 0 {
lines.push(Line::from(vec![
Span::styled(lang.deleted_label(), Style::default().fg(theme::SUBTEXT0)),
Span::styled(format!("{}", deleted), Style::default().fg(theme::RED)),
]));
}
if untracked > 0 {
lines.push(Line::from(vec![
Span::styled(lang.untracked_label(), Style::default().fg(theme::SUBTEXT0)),
Span::styled(
format!("{}", untracked),
Style::default().fg(theme::OVERLAY0),
),
]));
}
}
if let Some(ref health) = app.health_view.cache {
lines.push(detail_separator_line(area.width));
lines.push(Line::from(Span::styled(
lang.project_health_title(),
Style::default()
.fg(theme::LAVENDER)
.add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(""));
let score_color = match health.overall_score {
90..=100 => theme::GREEN,
75..=89 => theme::TEAL,
60..=74 => theme::SAPPHIRE,
45..=59 => theme::YELLOW,
30..=44 => theme::PEACH,
_ => theme::RED,
};
lines.push(Line::from(vec![
Span::styled(
lang.health_score_label(),
Style::default().fg(theme::SUBTEXT0),
),
Span::styled(
format!("{}/100 ({})", health.overall_score, health.level()),
Style::default()
.fg(score_color)
.add_modifier(Modifier::BOLD),
),
]));
lines.push(Line::from(""));
let panel_inner = area.width.saturating_sub(2) as usize;
let label_overhead = 2 + HEALTH_LABEL_WIDTH + 1 + 5; let bar_width = panel_inner
.saturating_sub(label_overhead)
.clamp(HEALTH_BAR_MIN, HEALTH_BAR_MAX);
let pad_label = |s: &str, target_width: usize| -> String {
let current_width: usize = s.chars().map(|c| if c.is_ascii() { 1 } else { 2 }).sum();
let padding = target_width.saturating_sub(current_width);
format!("{}{}", s, " ".repeat(padding))
};
let label_width = HEALTH_LABEL_WIDTH;
let mut components: Vec<(String, f64)> = vec![
(
pad_label(lang.health_quality(), label_width),
health.quality.score,
),
(
pad_label(lang.health_test(), label_width),
health.test_health.score,
),
];
if health.total_authors > 1 {
components.push((
pad_label(lang.health_bus_factor(), label_width),
health.bus_factor_risk.score,
));
}
components.extend(vec![
(
pad_label(lang.health_tech_debt(), label_width),
health.tech_debt.score,
),
(
pad_label(lang.health_code_churn(), label_width),
health.code_churn.score,
),
(
pad_label(lang.health_commit_cadence(), label_width),
health.commit_cadence.score,
),
]);
for (label, score) in &components {
let mut spans = vec![Span::styled(
format!(" {}", label),
Style::default().fg(theme::SUBTEXT0),
)];
spans.extend(score_bar_spans(*score, bar_width));
spans.push(Span::styled(
format!(" {:.0}%", score * 100.0),
Style::default().fg(theme::TEXT),
));
lines.push(Line::from(spans));
}
lines.push(Line::from(""));
let conf_color = match health.confidence.level {
ConfidenceLevel::High => theme::GREEN,
ConfidenceLevel::Medium => theme::YELLOW,
ConfidenceLevel::Low => theme::RED,
};
let conf_label = match health.confidence.level {
ConfidenceLevel::High => lang.confidence_high(),
ConfidenceLevel::Medium => lang.confidence_medium(),
ConfidenceLevel::Low => lang.confidence_low(),
};
lines.push(Line::from(vec![
Span::styled(
format!(" {}", lang.health_confidence()),
Style::default().fg(theme::SUBTEXT0),
),
Span::styled(
conf_label,
Style::default().fg(conf_color).add_modifier(Modifier::BOLD),
),
]));
if !health.alerts.is_empty() {
lines.push(Line::from(""));
for alert in &health.alerts {
let icon_color = match alert.severity {
AlertSeverity::Info => theme::BLUE,
AlertSeverity::Warning => theme::YELLOW,
AlertSeverity::Critical => theme::RED,
};
let alert_msg = match alert.kind {
HealthAlertKind::LowCommitQuality => {
lang.alert_low_commit_quality().to_string()
}
HealthAlertKind::LowTestCoverage => lang.alert_low_test_coverage().to_string(),
HealthAlertKind::HighBusFactorRisk => {
lang.alert_high_bus_factor_risk().to_string()
}
HealthAlertKind::ModerateBusFactorRisk => {
lang.alert_moderate_bus_factor_risk().to_string()
}
HealthAlertKind::HighTechDebt => lang.alert_high_tech_debt().to_string(),
HealthAlertKind::HighCodeChurn => lang.alert_high_code_churn().to_string(),
HealthAlertKind::Other => alert.message.clone(),
};
lines.push(Line::from(vec![Span::styled(
format!(" - {}", alert_msg),
Style::default().fg(icon_color),
)]));
}
}
}
let events = app.all_events();
if !events.is_empty() {
lines.push(detail_separator_line(area.width));
lines.push(Line::from(Span::styled(
lang.recent_commits(),
Style::default()
.fg(theme::LAVENDER)
.add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(""));
let remaining = area.height.saturating_sub(lines.len() as u16) as usize;
let max_commits = remaining.min(MAX_RECENT_COMMITS);
let inner_w = area.width.saturating_sub(1) as usize;
for event in events.iter().take(max_commits) {
let hash_width = RECENT_HASH_WIDTH;
let date_str = event.timestamp.format("%m/%d").to_string();
let date_width = date_str.len() + 1;
let msg_width = inner_w.saturating_sub(hash_width + date_width + 1);
let msg_truncated = truncate_to_width_with_ellipsis(&event.message, msg_width);
let current_width = display_width(&msg_truncated);
let padding = " ".repeat(msg_width.saturating_sub(current_width));
let msg = format!("{}{}", msg_truncated, padding);
lines.push(Line::from(vec![
Span::styled(
format!("{} ", event.short_hash),
Style::default().fg(theme::OVERLAY0),
),
Span::styled(msg, Style::default().fg(theme::TEXT)),
Span::styled(
format!(" {}", date_str),
Style::default().fg(theme::SUBTEXT0),
),
]));
}
}
let paragraph = Paragraph::new(lines);
frame.render_widget(paragraph, area);
}
fn render_dashboard_branch_detail(frame: &mut Frame, area: Rect, app: &App) {
let mut lines = Vec::new();
let lang = app.language;
let current_branch = app.repo_info.as_ref().map(|info| info.branch.as_str());
if let Some(branch_info) = app.branches.get(app.branch_selected_index) {
let is_current = current_branch == Some(branch_info.name.as_str());
lines.push(Line::from(Span::styled(
lang.branch_info(),
Style::default()
.fg(theme::LAVENDER)
.add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(
lang.branch_name_label(),
Style::default().fg(theme::SUBTEXT0),
),
Span::styled(
branch_info.name.clone(),
Style::default()
.fg(theme::BLUE)
.add_modifier(Modifier::BOLD),
),
]));
if is_current {
lines.push(Line::from(Span::styled(
lang.current_branch_label(),
Style::default().fg(theme::GREEN),
)));
}
if branch_info.is_gone {
lines.push(Line::from(Span::styled(
lang.gone_label(),
Style::default().fg(theme::OVERLAY0),
)));
}
if let Some(ref topology) = app.topology_cache {
if let Some(tb) = topology
.branches
.iter()
.find(|b| b.name == branch_info.name)
{
let status_label = tb.status.label();
if !status_label.is_empty() {
let status_color = match tb.status {
BranchStatus::Active => theme::GREEN,
BranchStatus::Stale => theme::OVERLAY0,
BranchStatus::Merged => theme::LAVENDER,
_ => theme::TEXT,
};
lines.push(Line::from(vec![
Span::styled(lang.status_label(), Style::default().fg(theme::SUBTEXT0)),
Span::styled(status_label.to_string(), Style::default().fg(status_color)),
]));
}
lines.push(Line::from(vec![
Span::styled(lang.active_label(), Style::default().fg(theme::SUBTEXT0)),
Span::styled(
tb.last_activity.format("%Y-%m-%d %H:%M").to_string(),
Style::default().fg(theme::TEXT),
),
]));
lines.push(Line::from(vec![
Span::styled(lang.commits_label(), Style::default().fg(theme::SUBTEXT0)),
Span::styled(
format!(" {}", tb.commit_count),
Style::default().fg(theme::TEXT),
),
]));
if let Some(ref rel) = tb.relation {
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(
format!("vs {}: ", rel.base),
Style::default().fg(theme::SUBTEXT0),
),
Span::styled(rel.summary(), Style::default().fg(theme::TEXT)),
]));
if rel.ahead_count > 0 || rel.behind_count > 0 {
lines.push(Line::from(vec![
Span::styled(
format!(" +{} ahead", rel.ahead_count),
Style::default().fg(theme::GREEN),
),
Span::styled(" / ", Style::default().fg(theme::SUBTEXT0)),
Span::styled(
format!("-{} behind", rel.behind_count),
Style::default().fg(theme::RED),
),
]));
}
}
if !tb.health.warnings.is_empty() {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
lang.health_warnings(),
Style::default()
.fg(theme::YELLOW)
.add_modifier(Modifier::BOLD),
)));
for warning in &tb.health.warnings {
lines.push(Line::from(vec![
Span::styled(
format!(" {} ", warning.icon()),
Style::default().fg(theme::YELLOW),
),
Span::styled(
warning.description().to_string(),
Style::default().fg(theme::TEXT),
),
]));
}
}
}
}
} else {
lines.push(Line::from(Span::styled(
lang.no_branch_selected(),
Style::default().fg(theme::OVERLAY0),
)));
}
if let Some(ref recs) = app.branch_recommendations_cache {
lines.push(detail_separator_line(area.width));
lines.push(Line::from(Span::styled(
lang.branch_recommendations(),
Style::default()
.fg(theme::LAVENDER)
.add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(""));
for rec in &recs.recommendations {
let action_color = match rec.action {
RecommendedAction::Delete => theme::RED,
RecommendedAction::Rebase => theme::YELLOW,
RecommendedAction::Merge => theme::GREEN,
RecommendedAction::Review => theme::BLUE,
RecommendedAction::Keep => theme::SUBTEXT1,
};
lines.push(Line::from(vec![
Span::styled(
format!("{:?}", rec.action),
Style::default()
.fg(action_color)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" \u{2192} {}", rec.branch_name),
Style::default().fg(theme::TEXT),
),
]));
}
}
let paragraph = Paragraph::new(lines);
frame.render_widget(paragraph, area);
}
fn render_dashboard_file_detail(frame: &mut Frame, area: Rect, app: &App) {
let lang = app.language;
let mut lines = Vec::new();
let inner_width = area.width.saturating_sub(1) as usize;
if let Some(status) = app.file_statuses.get(app.status_selected_index) {
lines.push(Line::from(Span::styled(
lang.file_detail_title(),
Style::default()
.fg(theme::LAVENDER)
.add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(""));
let (icon, color) = match status.kind {
FileStatusKind::StagedNew => (lang.file_staged_new(), theme::FILE_ADDED),
FileStatusKind::StagedModified => (lang.file_staged_modified(), theme::GREEN),
FileStatusKind::StagedDeleted => (lang.file_staged_deleted(), theme::GREEN),
FileStatusKind::Modified => (lang.file_modified(), theme::FILE_MODIFIED),
FileStatusKind::Deleted => (lang.file_deleted(), theme::FILE_DELETED),
FileStatusKind::Untracked => (lang.file_untracked(), theme::SUBTEXT0),
};
lines.push(Line::from(vec![
Span::styled(lang.path_label(), Style::default().fg(theme::SUBTEXT0)),
Span::styled(status.path.clone(), Style::default().fg(theme::TEXT)),
]));
lines.push(Line::from(vec![
Span::styled(lang.status_label(), Style::default().fg(theme::SUBTEXT0)),
Span::styled(icon, Style::default().fg(color)),
]));
if let Some(ref patch) = app.file_diff.cache {
let insertions = patch.lines.iter().filter(|l| l.origin == '+').count();
let deletions = patch.lines.iter().filter(|l| l.origin == '-').count();
lines.push(Line::from(vec![
Span::styled(lang.diff_label(), Style::default().fg(theme::SUBTEXT0)),
Span::styled(
format!("+{}", insertions),
Style::default().fg(theme::GREEN),
),
Span::styled(" / ", Style::default().fg(theme::SUBTEXT0)),
Span::styled(format!("-{}", deletions), Style::default().fg(theme::RED)),
]));
lines.push(detail_separator_line(area.width));
let header_lines = lines.len();
let visible = area.height.saturating_sub(header_lines as u16) as usize;
let scroll = app.file_diff.scroll;
let end_idx = (scroll + visible).min(patch.lines.len());
let lineno_width = DIFF_LINENO_WIDTH;
for line in patch
.lines
.iter()
.skip(scroll)
.take(end_idx.saturating_sub(scroll))
{
let (style, prefix) = match line.origin {
'+' => (Style::default().fg(theme::GREEN), "+"),
'-' => (Style::default().fg(theme::RED), "-"),
_ => (Style::default().fg(theme::TEXT), " "),
};
let lineno_str = match line.new_lineno {
Some(n) => format!("{:>width$}", n, width = lineno_width),
None => match line.old_lineno {
Some(n) => format!("{:>width$}", n, width = lineno_width),
None => " ".repeat(lineno_width),
},
};
let content_max = inner_width.saturating_sub(lineno_width + 3);
let content = truncate_to_width(&line.content, content_max);
lines.push(Line::from(vec![
Span::styled(
format!("{} ", lineno_str),
Style::default().fg(theme::SUBTEXT0),
),
Span::styled(prefix.to_string(), style),
Span::styled(content, style),
]));
}
if patch.lines.is_empty() {
lines.push(Line::from(Span::styled(
lang.no_diff_available(),
Style::default().fg(theme::OVERLAY0),
)));
}
} else if status.kind != FileStatusKind::Untracked {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
lang.loading_diff(),
Style::default().fg(theme::OVERLAY0),
)));
}
} else {
lines.push(Line::from(Span::styled(
lang.no_file_selected(),
Style::default().fg(theme::OVERLAY0),
)));
}
let paragraph = Paragraph::new(lines);
frame.render_widget(paragraph, area);
}
fn render_dashboard_stash_detail(frame: &mut Frame, area: Rect, app: &App) {
let lang = app.language;
let mut lines = Vec::new();
if let Some(ref stash_entries) = app.stash_view.cache {
if stash_entries.is_empty() {
lines.push(Line::from(Span::styled(
lang.no_stash_entries(),
Style::default().fg(theme::OVERLAY0),
)));
} else {
lines.push(Line::from(Span::styled(
lang.stash_detail_title(),
Style::default()
.fg(theme::LAVENDER)
.add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(lang.stash_total(), Style::default().fg(theme::SUBTEXT0)),
Span::styled(
format!("{} stash entries", stash_entries.len()),
Style::default().fg(theme::TEXT),
),
]));
lines.push(Line::from(vec![
Span::styled(lang.stash_current(), Style::default().fg(theme::SUBTEXT0)),
Span::styled(
format!(
"{} / {}",
app.stash_view.nav.selected_index + 1,
stash_entries.len()
),
Style::default().fg(theme::PEACH),
),
]));
if let Some(stash) = stash_entries.get(app.stash_view.nav.selected_index) {
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(lang.stash_index(), Style::default().fg(theme::SUBTEXT0)),
Span::styled(
format!("stash@{{{}}}", stash.index),
Style::default()
.fg(theme::PEACH)
.add_modifier(Modifier::BOLD),
),
]));
lines.push(Line::from(vec![
Span::styled(lang.detail_message(), Style::default().fg(theme::SUBTEXT0)),
Span::styled(stash.message.clone(), Style::default().fg(theme::TEXT)),
]));
}
if stash_entries.len() > 1 {
lines.push(detail_separator_line(area.width));
lines.push(Line::from(Span::styled(
lang.all_stashes(),
Style::default()
.fg(theme::LAVENDER)
.add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(""));
let max_display = area
.height
.saturating_sub(lines.len() as u16 + STASH_FOOTER_LINES)
as usize;
for (i, entry) in stash_entries.iter().enumerate().take(max_display) {
let style = if i == app.stash_view.nav.selected_index {
Style::default()
.fg(theme::PEACH)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme::SUBTEXT1)
};
let msg = truncate_to_width_with_ellipsis(
&entry.message,
area.width.saturating_sub(STASH_DISPLAY_PADDING) as usize,
);
lines.push(Line::from(Span::styled(
format!(" stash@{{{}}}: {}", entry.index, msg),
style,
)));
}
}
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled("a", Style::default().fg(theme::YELLOW)),
Span::styled(lang.hint_apply(), Style::default().fg(theme::SUBTEXT0)),
Span::styled("p", Style::default().fg(theme::YELLOW)),
Span::styled(lang.hint_pop(), Style::default().fg(theme::SUBTEXT0)),
Span::styled("d", Style::default().fg(theme::YELLOW)),
Span::styled(lang.hint_drop(), Style::default().fg(theme::SUBTEXT0)),
]));
}
} else {
lines.push(Line::from(Span::styled(
lang.no_stash_entries(),
Style::default().fg(theme::OVERLAY0),
)));
}
let paragraph = Paragraph::new(lines);
frame.render_widget(paragraph, area);
}
pub(crate) fn render_dashboard_footer(frame: &mut Frame, area: Rect, app: &App) {
let lang = app.language;
let hints: Vec<(&str, &str)> = match app.active_sidebar_panel {
SidebarPanel::Status => vec![(".", lang.footer_quick_action())],
SidebarPanel::Commits => vec![
("Enter", lang.footer_detail()),
("y", lang.footer_copy_hash()),
("/", lang.footer_filter()),
],
SidebarPanel::Branches => vec![
("Enter", lang.footer_switch()),
("n", lang.footer_new()),
("d", lang.footer_delete()),
],
SidebarPanel::Files => vec![
("Enter", lang.footer_diff()),
("a", lang.footer_stage_all()),
("c", lang.footer_commit()),
],
SidebarPanel::Stash => vec![("Enter", lang.footer_apply()), ("d", lang.footer_drop())],
};
let mut key_spans = Vec::new();
for (i, (key, desc)) in hints.iter().enumerate() {
if i > 0 {
key_spans.push(Span::styled(" ", Style::default()));
}
key_spans.push(Span::styled(
key.to_string(),
Style::default()
.fg(theme::FOOTER_KEY)
.add_modifier(Modifier::BOLD),
));
key_spans.push(Span::styled(
format!(":{}", desc),
Style::default().fg(theme::FOOTER_DESC),
));
}
key_spans.push(Span::styled(" ", Style::default()));
key_spans.push(Span::styled(
".".to_string(),
Style::default()
.fg(theme::FOOTER_KEY)
.add_modifier(Modifier::BOLD),
));
key_spans.push(Span::styled(
format!(":{}", lang.footer_actions()),
Style::default().fg(theme::FOOTER_DESC),
));
key_spans.push(Span::styled(" ", Style::default()));
key_spans.push(Span::styled("1-5", Style::default().fg(theme::FOOTER_KEY)));
key_spans.push(Span::styled(
format!(":{}", lang.footer_panel()),
Style::default().fg(theme::FOOTER_DESC),
));
key_spans.push(Span::styled(" ", Style::default()));
key_spans.push(Span::styled("Tab", Style::default().fg(theme::FOOTER_KEY)));
key_spans.push(Span::styled(
format!(":{}", lang.footer_focus()),
Style::default().fg(theme::FOOTER_DESC),
));
key_spans.push(Span::styled(" ", Style::default()));
key_spans.push(Span::styled(
"W",
Style::default()
.fg(theme::FOOTER_KEY)
.add_modifier(Modifier::BOLD),
));
key_spans.push(Span::styled(
format!(":{}", lang.footer_watch()),
Style::default().fg(theme::FOOTER_DESC),
));
key_spans.push(Span::styled(" ", Style::default()));
key_spans.push(Span::styled(
"P",
Style::default()
.fg(theme::FOOTER_KEY)
.add_modifier(Modifier::BOLD),
));
key_spans.push(Span::styled(
format!(":{}", lang.footer_pr()),
Style::default().fg(theme::FOOTER_DESC),
));
key_spans.push(Span::styled(" ", Style::default()));
key_spans.push(Span::styled(
"R",
Style::default()
.fg(theme::FOOTER_KEY)
.add_modifier(Modifier::BOLD),
));
key_spans.push(Span::styled(
format!(":{}", lang.footer_review()),
Style::default().fg(theme::FOOTER_DESC),
));
key_spans.push(Span::styled(" ", Style::default()));
key_spans.push(Span::styled("?", Style::default().fg(theme::FOOTER_KEY)));
key_spans.push(Span::styled(
format!(":{}", lang.footer_help()),
Style::default().fg(theme::FOOTER_DESC),
));
key_spans.push(Span::styled(" ", Style::default()));
key_spans.push(Span::styled("q", Style::default().fg(theme::FOOTER_KEY)));
key_spans.push(Span::styled(
format!(":{}", lang.footer_quit()),
Style::default().fg(theme::FOOTER_DESC),
));
key_spans.push(Span::styled(" ", Style::default()));
key_spans.push(Span::styled("L", Style::default().fg(theme::FOOTER_KEY)));
key_spans.push(Span::styled(
format!(":{}", lang.label()),
Style::default().fg(theme::FOOTER_DESC),
));
let status_line = if let Some(ref msg) = app.status_message {
let color = match app.status_message_level {
StatusMessageLevel::Success => theme::GREEN,
StatusMessageLevel::Error => theme::RED,
StatusMessageLevel::Info => theme::BLUE,
};
Line::from(Span::styled(
truncate_to_width_with_ellipsis(msg, area.width.saturating_sub(1) as usize),
Style::default().fg(color),
))
} else {
Line::from("")
};
let lines = vec![status_line, Line::from(key_spans)];
let paragraph = Paragraph::new(lines).style(Style::default().bg(theme::SURFACE1));
frame.render_widget(paragraph, area);
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::{backend::TestBackend, Terminal};
#[test]
fn change_bar_spans_additions_only() {
let spans = change_bar_spans(100, 0, 100);
let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(text.chars().count(), 5);
assert!(text.contains('█'));
}
#[test]
fn change_bar_spans_deletions_only() {
let spans = change_bar_spans(0, 100, 100);
let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(text.chars().count(), 5);
assert!(text.contains('█'));
}
#[test]
fn change_bar_spans_mixed() {
let spans = change_bar_spans(50, 50, 100);
let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(text.chars().count(), 5);
}
#[test]
fn change_bar_spans_zero() {
let spans = change_bar_spans(0, 0, 100);
let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(text.chars().count(), 5);
assert!(text.contains('░'));
}
#[test]
fn change_bar_spans_large_values() {
let spans = change_bar_spans(999999, 999999, 999999);
let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(text.chars().count(), 5);
}
#[test]
fn render_dashboard_empty_events_no_panic() {
let app = App::new();
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
let area = frame.area();
render_dashboard_main_panel(frame, area, &app);
})
.unwrap();
}
#[test]
fn render_dashboard_footer_no_panic() {
let app = App::new();
let backend = TestBackend::new(80, 2);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
let area = frame.area();
render_dashboard_footer(frame, area, &app);
})
.unwrap();
}
#[test]
fn render_dashboard_minimal_terminal_no_panic() {
let app = App::new();
let backend = TestBackend::new(10, 5);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
let area = frame.area();
render_dashboard_main_panel(frame, area, &app);
})
.unwrap();
}
#[test]
fn render_dashboard_sidebar_panels_no_panic() {
let app = App::new();
let backend = TestBackend::new(40, 20);
let mut terminal = Terminal::new(backend).unwrap();
let area = Rect::new(0, 0, 40, 20);
for panel in [
SidebarPanel::Status,
SidebarPanel::Commits,
SidebarPanel::Branches,
SidebarPanel::Files,
SidebarPanel::Stash,
] {
terminal
.draw(|frame| match panel {
SidebarPanel::Status => render_dashboard_status_panel(frame, area, &app, true),
SidebarPanel::Commits => {
render_dashboard_commits_panel(frame, area, &app, true)
}
SidebarPanel::Branches => {
render_dashboard_branches_panel(frame, area, &app, true)
}
SidebarPanel::Files => render_dashboard_files_panel(frame, area, &app, true),
SidebarPanel::Stash => render_dashboard_stash_panel(frame, area, &app, true),
})
.unwrap();
}
}
}