use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
};
use crate::app::{App, FocusedPanel, RemotePanel};
use crate::ui::{commits_panel, conversation_panel, description_panel, status_bar, styles};
pub(super) fn render_commit_select(frame: &mut Frame, app: &mut App) {
let area = frame.area();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Min(0), Constraint::Length(1), ])
.split(area);
let header = Paragraph::new(" Select commits to review ")
.style(styles::header_style(&app.theme))
.block(Block::default().style(styles::panel_style(&app.theme)));
frame.render_widget(header, chunks[0]);
let block = Block::default()
.title(" Recent Commits ")
.borders(Borders::ALL)
.style(styles::panel_style(&app.theme))
.border_style(styles::border_style(&app.theme, true));
let inner = block.inner(chunks[1]);
frame.render_widget(block, chunks[1]);
app.commit_select.viewport_height = inner.height as usize;
let range = app.commit_select.selection_range;
let total_commits = app.commit_select.list.len();
let visible_count = app.commit_select.visible_count.min(total_commits);
let mut items: Vec<Line> = app
.commit_select
.list
.iter()
.take(visible_count)
.enumerate()
.map(|(i, commit)| {
let is_selected = app.is_commit_selected(i);
let is_cursor = i == app.commit_select.cursor;
let range_marker = match range {
Some((start, end)) if i == start && i == end => "─",
Some((start, _)) if i == start => "┌",
Some((_, end)) if i == end => "└",
Some((start, end)) if i > start && i < end => "│",
_ => " ",
};
let checkbox = if is_selected { "[x]" } else { "[ ]" };
let pointer = if is_cursor { ">" } else { " " };
let style = if is_cursor {
styles::selected_style(&app.theme)
} else if is_selected {
Style::default().fg(app.theme.fg_secondary)
} else {
Style::default()
};
let checkbox_style = if is_selected {
styles::reviewed_style(&app.theme)
} else {
styles::pending_style(&app.theme)
};
let range_style = if is_selected {
styles::reviewed_style(&app.theme)
} else {
Style::default().fg(app.theme.fg_secondary)
};
let time_str = commit.time.format("%Y-%m-%d").to_string();
let mut spans = vec![
Span::styled(format!("{pointer} "), style),
Span::styled(format!("{range_marker} "), range_style),
Span::styled(format!("{checkbox} "), checkbox_style),
Span::styled(
format!("{} ", commit.short_id),
styles::hash_style(&app.theme),
),
];
if commit.id == crate::app::STAGED_SELECTION_ID
|| commit.id == crate::app::UNSTAGED_SELECTION_ID
{
spans.push(Span::styled(&commit.summary, style));
return Line::from(spans);
}
if let Some(branch_name) = &commit.branch_name {
spans.push(Span::styled(
format!("[{}] ", truncate_str(branch_name, 20)),
styles::branch_style(&app.theme),
));
}
spans.push(Span::styled(truncate_str(&commit.summary, 50), style));
spans.push(Span::styled(
format!(" ({}, {})", commit.author, time_str),
Style::default().fg(app.theme.fg_secondary),
));
Line::from(spans)
})
.collect();
if app.can_show_more_commits() {
let is_cursor = app.commit_select.cursor == visible_count;
let style = if is_cursor {
styles::selected_style(&app.theme)
} else {
Style::default().fg(app.theme.fg_secondary)
};
items.push(Line::from(vec![
Span::styled(if is_cursor { "> " } else { " " }, style),
Span::styled(" ... show more commits ...", style),
]));
}
let visible_items: Vec<Line> = items
.into_iter()
.skip(app.commit_select.scroll_offset)
.take(inner.height as usize)
.collect();
let list = Paragraph::new(visible_items).style(styles::panel_style(&app.theme));
frame.render_widget(list, inner);
let theme = &app.theme;
let mode_span = Span::styled(" SELECT ", styles::mode_style(theme));
let selected_count = match app.commit_select.selection_range {
Some((start, end)) => end - start + 1,
None => 0,
};
let selection_info = if selected_count > 0 {
format!(" ({selected_count} selected)")
} else {
String::new()
};
let hints = format!(" j/k:navigate Space:select range Enter:confirm q:quit{selection_info}");
let hints_span = Span::styled(hints, Style::default().fg(theme.fg_secondary));
let left_spans = vec![mode_span, hints_span];
let (message_span, message_width) = status_bar::build_message_span(app.message.as_ref(), theme);
let spans = status_bar::build_right_aligned_spans(
left_spans,
message_span,
message_width,
chunks[2].width as usize,
);
let footer = Paragraph::new(Line::from(spans))
.style(styles::status_bar_style(theme))
.block(Block::default());
frame.render_widget(footer, chunks[2]);
}
fn truncate_str(s: &str, max_len: usize) -> String {
if s.len() <= max_len {
s.to_string()
} else {
let truncate_at = max_len.saturating_sub(3);
let end = s
.char_indices()
.map(|(i, _)| i)
.take_while(|&i| i <= truncate_at)
.last()
.unwrap_or(0);
format!("{}...", &s[..end])
}
}
pub(super) fn render_remote_right_panel(frame: &mut Frame, app: &mut App, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(0)])
.split(area);
render_remote_tab_bar(frame, app, chunks[0]);
let panel = app
.remote()
.map(|r| r.remote_panel)
.unwrap_or(RemotePanel::Files);
match panel {
RemotePanel::Files => {
app.ui_layout.diff_area = Some(chunks[1]);
super::app_layout::render_diff_view(frame, app, chunks[1]);
}
RemotePanel::Description => {
description_panel::render(frame, app, chunks[1]);
}
RemotePanel::Conversation => {
conversation_panel::render(frame, app, chunks[1]);
}
RemotePanel::Commits => {
commits_panel::render(frame, app, chunks[1]);
}
RemotePanel::Sparring => {
super::sparring_panel::render(frame, app, chunks[1]);
}
}
}
fn render_remote_tab_bar(frame: &mut Frame, app: &App, area: Rect) {
let active = app
.remote()
.map(|r| r.remote_panel)
.unwrap_or(RemotePanel::Files);
let theme = &app.theme;
let active_style = Style::default()
.fg(theme.fg_primary)
.add_modifier(Modifier::BOLD);
let inactive_style = Style::default().fg(theme.fg_dim);
let sep_style = Style::default().fg(theme.fg_dim);
let mut tabs: Vec<(RemotePanel, &'static str)> = vec![
(RemotePanel::Files, "1:Files"),
(RemotePanel::Description, "2:Description"),
(RemotePanel::Conversation, "3:Conversation"),
(RemotePanel::Commits, "4:Commits"),
];
if app.spar_mode {
tabs.push((RemotePanel::Sparring, "5:Sparring"));
}
let mut spans = vec![Span::raw(" ")];
for (i, (panel, label)) in tabs.iter().enumerate() {
if i > 0 {
spans.push(Span::styled(" | ", sep_style));
}
let style = if *panel == active {
active_style
} else {
inactive_style
};
spans.push(Span::styled(*label, style));
}
let line = Line::from(spans);
let paragraph = Paragraph::new(line).style(styles::header_style(theme));
frame.render_widget(paragraph, area);
}
pub(super) fn render_inline_commit_selector(frame: &mut Frame, app: &mut App, area: Rect) {
let focused = app.nav.focused_panel == FocusedPanel::CommitSelector;
let block = Block::default()
.title(" Commits ")
.borders(Borders::ALL)
.border_style(styles::border_style(&app.theme, focused));
let inner = block.inner(area);
frame.render_widget(block, area);
app.commit_select.viewport_height = inner.height as usize;
{
let range = app.commit_select.selection_range;
let total_commits = app.inline_selector.commits.len();
let items: Vec<Line> = app
.inline_selector
.commits
.iter()
.take(total_commits)
.enumerate()
.map(|(i, commit)| {
let is_selected = app.is_commit_selected(i);
let is_cursor = i == app.commit_select.cursor;
let range_marker = match range {
Some((start, end)) if i == start && i == end => "\u{2500}",
Some((start, _)) if i == start => "\u{250c}",
Some((_, end)) if i == end => "\u{2514}",
Some((start, end)) if i > start && i < end => "\u{2502}",
_ => " ",
};
let checkbox = if is_selected { "[x]" } else { "[ ]" };
let style = if is_cursor {
styles::selected_style(&app.theme)
} else if is_selected {
Style::default().fg(app.theme.fg_secondary)
} else {
Style::default()
};
let checkbox_style = if is_selected {
styles::reviewed_style(&app.theme)
} else {
styles::pending_style(&app.theme)
};
let range_style = if is_selected {
styles::reviewed_style(&app.theme)
} else {
Style::default().fg(app.theme.fg_secondary)
};
let pointer = if is_cursor { "> " } else { " " };
let time_str = commit.time.format("%Y-%m-%d").to_string();
let mut spans = vec![
Span::styled(pointer.to_string(), style),
Span::styled(format!("{range_marker} "), range_style),
Span::styled(format!("{checkbox} "), checkbox_style),
Span::styled(
format!("{} ", commit.short_id),
styles::hash_style(&app.theme),
),
];
if let Some(branch_name) = &commit.branch_name {
spans.push(Span::styled(
format!("[{}] ", truncate_str(branch_name, 20)),
styles::branch_style(&app.theme),
));
}
spans.push(Span::styled(truncate_str(&commit.summary, 50), style));
spans.push(Span::styled(
format!(" ({}, {})", commit.author, time_str),
Style::default().fg(app.theme.fg_secondary),
));
Line::from(spans)
})
.collect();
let visible_items: Vec<Line> = items
.into_iter()
.skip(app.commit_select.scroll_offset)
.take(inner.height as usize)
.collect();
let paragraph = Paragraph::new(visible_items);
frame.render_widget(paragraph, inner);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn should_return_string_unchanged_when_within_max_len() {
let s = "hello";
let result = truncate_str(s, 10);
assert_eq!(result, "hello");
}
#[test]
fn should_truncate_ascii_string_with_ellipsis() {
let s = "hello world this is long";
let result = truncate_str(s, 10);
assert_eq!(result, "hello w...");
}
#[test]
fn should_truncate_without_panicking_on_multibyte_chars() {
let s = "Resolve \"SD : Envoi en validation manuelle après 3 rejet de la fiche employé\"";
let result = truncate_str(s, 47);
assert!(result.ends_with("..."));
assert!(result.len() <= 47);
}
#[test]
fn should_handle_string_of_only_multibyte_chars() {
let s = "ééééééééé";
let result = truncate_str(s, 5);
assert!(result.ends_with("..."));
assert!(result.is_char_boundary(result.len()));
}
}