use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, Padding, Paragraph};
use ratatui::Frame;
use gitkraft_core::FileStatus;
use crate::app::{ActivePane, App, InputMode, InputPurpose, StagingFocus};
use crate::utils::pad_right;
pub fn render(app: &mut App, frame: &mut Frame, area: Rect) {
let is_active = app.active_pane == ActivePane::Staging;
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Ratio(1, 3), Constraint::Ratio(1, 3), Constraint::Ratio(1, 3), ])
.split(area);
render_unstaged(app, frame, cols[0], is_active);
render_staged(app, frame, cols[1], is_active);
render_commit_or_hints(app, frame, cols[2], is_active);
}
fn render_unstaged(app: &mut App, frame: &mut Frame, area: Rect, pane_active: bool) {
let theme = app.theme();
let is_focused = pane_active && app.tab().staging_focus == StagingFocus::Unstaged;
let border_color = if is_focused {
theme.border_active
} else if pane_active {
theme.accent
} else {
theme.border_inactive
};
let title = format!(" Unstaged ({}) ", app.tab().unstaged_changes.len());
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.style(Style::default().bg(theme.bg));
if app.tab().unstaged_changes.is_empty() {
let items: Vec<ListItem> = vec![ListItem::new(Line::from(Span::styled(
" No unstaged changes",
Style::default().fg(theme.text_muted),
)))];
let list = List::new(items).block(block);
frame.render_widget(list, area);
return;
}
let selected = app.tab().selected_unstaged.clone();
let mut sorted_selected: Vec<usize> = selected.iter().copied().collect();
sorted_selected.sort_unstable();
let multi = sorted_selected.len() >= 2;
let items: Vec<ListItem> = app
.tab()
.unstaged_changes
.iter()
.enumerate()
.map(|(idx, diff)| {
let file_name = diff.display_path().to_owned();
let (status_char, status_color) = status_display(&diff.status, &theme);
let is_selected = selected.contains(&idx);
let badge = if let Some(pos) = sorted_selected.iter().position(|&i| i == idx) {
if multi {
format!("{:<2}", pos + 1)
} else {
"● ".to_string()
}
} else {
" ".to_string()
};
let name_style = if is_selected {
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.text_primary)
};
let line = Line::from(vec![
Span::styled(badge, Style::default().fg(theme.accent)),
Span::styled(
format!("{} ", status_char),
Style::default()
.fg(status_color)
.add_modifier(Modifier::BOLD),
),
Span::styled(file_name, name_style),
]);
ListItem::new(line)
})
.collect();
let list = List::new(items)
.block(block)
.highlight_style(
Style::default()
.bg(theme.sel_bg)
.add_modifier(Modifier::REVERSED),
)
.highlight_symbol("▶ ");
let tab = app.tab_mut();
frame.render_stateful_widget(list, area, &mut tab.unstaged_list_state);
}
fn render_staged(app: &mut App, frame: &mut Frame, area: Rect, pane_active: bool) {
let theme = app.theme();
let is_focused = pane_active && app.tab().staging_focus == StagingFocus::Staged;
let border_color = if is_focused {
theme.border_active
} else if pane_active {
theme.accent
} else {
theme.border_inactive
};
let title = format!(" Staged ({}) ", app.tab().staged_changes.len());
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.style(Style::default().bg(theme.bg));
if app.tab().staged_changes.is_empty() {
let items: Vec<ListItem> = vec![ListItem::new(Line::from(Span::styled(
" No staged changes",
Style::default().fg(theme.text_muted),
)))];
let list = List::new(items).block(block);
frame.render_widget(list, area);
return;
}
let selected = app.tab().selected_staged.clone();
let mut sorted_selected: Vec<usize> = selected.iter().copied().collect();
sorted_selected.sort_unstable();
let multi = sorted_selected.len() >= 2;
let items: Vec<ListItem> = app
.tab()
.staged_changes
.iter()
.enumerate()
.map(|(idx, diff)| {
let file_name = diff.display_path().to_owned();
let (status_char, status_color) = status_display(&diff.status, &theme);
let is_selected = selected.contains(&idx);
let badge = if let Some(pos) = sorted_selected.iter().position(|&i| i == idx) {
if multi {
format!("{:<2}", pos + 1)
} else {
"● ".to_string()
}
} else {
" ".to_string()
};
let name_style = if is_selected {
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.text_primary)
};
let line = Line::from(vec![
Span::styled(badge, Style::default().fg(theme.accent)),
Span::styled(
format!("{} ", status_char),
Style::default()
.fg(status_color)
.add_modifier(Modifier::BOLD),
),
Span::styled(file_name, name_style),
]);
ListItem::new(line)
})
.collect();
let list = List::new(items)
.block(block)
.highlight_style(
Style::default()
.bg(theme.sel_bg)
.add_modifier(Modifier::REVERSED),
)
.highlight_symbol("▶ ");
let tab = app.tab_mut();
frame.render_stateful_widget(list, area, &mut tab.staged_list_state);
}
fn render_commit_or_hints(app: &mut App, frame: &mut Frame, area: Rect, pane_active: bool) {
let theme = app.theme();
let border_color = if pane_active {
theme.border_active
} else {
theme.border_inactive
};
let is_commit_input =
app.input_mode == InputMode::Input && app.input_purpose == InputPurpose::CommitMessage;
if is_commit_input {
let block = Block::default()
.title(" Commit Message ")
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.warning))
.style(Style::default().bg(theme.bg));
let cursor_char = if app.tick_count % 10 < 5 { "█" } else { " " };
let lines = vec![
Line::from(""),
Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled(&app.input_buffer, Style::default().fg(theme.text_primary)),
Span::styled(
cursor_char,
Style::default()
.fg(theme.warning)
.add_modifier(Modifier::BOLD),
),
]),
Line::from(""),
Line::from(Span::styled(
" Enter: commit │ Esc: cancel",
Style::default().fg(theme.text_muted),
)),
];
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, area);
} else {
let outer_block = Block::default()
.title(Line::from(vec![
Span::styled("⚡", Style::default().fg(theme.accent)),
Span::styled(
"Actions",
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
),
]))
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.style(Style::default().bg(theme.bg))
.padding(Padding::new(1, 1, 0, 0));
let inner_area = outer_block.inner(area);
frame.render_widget(outer_block, area);
let key_style = Style::default()
.fg(theme.warning)
.add_modifier(Modifier::BOLD);
let desc_style = Style::default().fg(theme.text_primary);
let value_style = Style::default().fg(theme.accent);
let section_title = Style::default().fg(theme.text_muted);
let sections = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(6), Constraint::Length(7), Constraint::Length(5), Constraint::Length(5), Constraint::Min(2), ])
.split(inner_area);
{
let block = Block::default()
.title(Span::styled(" Staging ", section_title))
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.border_inactive))
.style(Style::default().bg(theme.bg));
let lines = vec![
Line::from(vec![
Span::styled(pad_right("s", 8), key_style),
Span::styled(pad_right("stage", 12), desc_style),
Span::styled(pad_right("u", 8), key_style),
Span::styled("unstage", desc_style),
]),
Line::from(vec![
Span::styled(pad_right("S", 8), key_style),
Span::styled(pad_right("stage all", 12), desc_style),
Span::styled(pad_right("U", 8), key_style),
Span::styled("unstage all", desc_style),
]),
Line::from(vec![
Span::styled(pad_right("Space", 8), key_style),
Span::styled(pad_right("toggle", 12), desc_style),
Span::styled(pad_right("J/K", 8), key_style),
Span::styled("range select", desc_style),
]),
Line::from(vec![
Span::styled(pad_right("E", 8), key_style),
Span::styled(pad_right("editor", 12), desc_style),
Span::styled(pad_right("", 8), key_style),
Span::styled("", desc_style),
]),
];
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, sections[0]);
}
{
let block = Block::default()
.title(Span::styled(" Git ", section_title))
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.border_inactive))
.style(Style::default().bg(theme.bg));
let lines = vec![
Line::from(vec![
Span::styled(pad_right("c", 8), key_style),
Span::styled(pad_right("commit", 12), desc_style),
Span::styled(pad_right("z", 8), key_style),
Span::styled("stash", desc_style),
]),
Line::from(vec![
Span::styled(pad_right("d", 8), key_style),
Span::styled(pad_right("discard", 12), desc_style),
Span::styled(pad_right("Z", 8), key_style),
Span::styled("stash pop", desc_style),
]),
Line::from(vec![
Span::styled(pad_right("p", 8), key_style),
Span::styled(pad_right("pull", 12), desc_style),
Span::styled(pad_right("P", 8), key_style),
Span::styled("push", desc_style),
]),
Line::from(vec![
Span::styled(pad_right("F", 8), key_style),
Span::styled(pad_right("force push", 12), desc_style),
Span::styled(pad_right("f", 8), key_style),
Span::styled("fetch", desc_style),
]),
];
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, sections[1]);
}
{
let block = Block::default()
.title(Span::styled(" Branch Actions ", section_title))
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.border_inactive))
.style(Style::default().bg(theme.bg));
let lines = vec![
Line::from(vec![
Span::styled(pad_right("m", 8), key_style),
Span::styled(pad_right("merge", 12), desc_style),
Span::styled(pad_right("R", 8), key_style),
Span::styled("rebase onto", desc_style),
]),
Line::from(vec![
Span::styled(pad_right("D", 8), key_style),
Span::styled(pad_right("delete", 12), desc_style),
Span::styled(pad_right("b", 8), key_style),
Span::styled("new branch", desc_style),
]),
];
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, sections[2]);
}
{
let block = Block::default()
.title(Span::styled(" Commit Actions ", section_title))
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.border_inactive))
.style(Style::default().bg(theme.bg));
let lines = vec![
Line::from(vec![
Span::styled(pad_right("e", 8), key_style),
Span::styled(pad_right("revert", 12), desc_style),
Span::styled(pad_right("x", 8), key_style),
Span::styled("reset soft", desc_style),
]),
Line::from(vec![
Span::styled(pad_right("X", 8), key_style),
Span::styled(pad_right("reset hard", 12), desc_style),
]),
];
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, sections[3]);
}
{
let mut lines = vec![Line::from(vec![
Span::styled(" Tab", key_style),
Span::styled(" focus ", desc_style),
Span::styled("Enter", key_style),
Span::styled(" diff ", desc_style),
Span::styled("E", key_style),
Span::styled(" editor ", value_style),
Span::styled("T", key_style),
Span::styled(" theme ", value_style),
Span::styled("O", key_style),
Span::styled(" options", value_style),
])];
if app.tab().confirm_discard {
lines.push(Line::from(Span::styled(
" ⚠ Press d again to confirm discard",
Style::default()
.fg(theme.error)
.add_modifier(Modifier::BOLD),
)));
}
let paragraph = Paragraph::new(lines);
frame.render_widget(paragraph, sections[4]);
}
}
}
fn status_display(
status: &FileStatus,
theme: &crate::features::theme::palette::UiTheme,
) -> (&'static str, ratatui::style::Color) {
match status {
FileStatus::Modified => ("M", theme.warning),
FileStatus::New => ("A", theme.success),
FileStatus::Deleted => ("D", theme.error),
FileStatus::Renamed => ("R", theme.accent),
FileStatus::Copied => ("C", theme.accent),
FileStatus::Typechange => ("T", theme.text_secondary),
FileStatus::Untracked => ("?", theme.text_secondary),
}
}