use ratatui::Frame;
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{
Block, BorderType, Borders, Cell, Clear, List, ListItem, ListState, Padding, Paragraph, Row,
Table, TableState, Wrap,
};
use crate::app::{DetailSection, Mode};
use crate::repo::{
CommitEntry, DiffLine, DiffLineKind, FileEntry, ItemDetail, RemoteInfo, RepoInfo,
WorktreeChanges,
};
use crate::ui::{
ACCENT, CARD_BORDER, DANGER, SUCCESS, WARNING, accent_style, muted_style, primary_style,
};
const FIELD_INDENT: &str = " ";
const FIELD_LABEL_WIDTH: usize = 9;
const FILE_INDENT: &str = " ";
const FILE_LABEL_WIDTH: usize = 2;
#[derive(Default, Clone, Copy)]
pub struct DetailAreas {
pub commits: Option<Rect>,
pub bottom_left: Option<Rect>,
pub staged_sub: Option<Rect>,
pub unstaged_sub: Option<Rect>,
pub conflicts_sub: Option<Rect>,
pub bottom_right: Option<Rect>,
pub commit_details: Option<Rect>,
pub local_branches: Option<Rect>,
pub remote_branches: Option<Rect>,
pub local_tags: Option<Rect>,
pub remote_tags: Option<Rect>,
pub tab_bar: Option<Rect>,
pub files: Option<Rect>,
pub file_content: Option<Rect>,
pub remotes: Option<Rect>,
pub stashes: Option<Rect>,
pub stashed_files: Option<Rect>,
pub inspect_horizontal_splitter: Option<Rect>,
pub inspect_vertical_splitter: Option<Rect>,
pub workspace_main_splitter: Option<Rect>,
pub files_horizontal_splitter: Option<Rect>,
pub branches_horizontal_splitter: Option<Rect>,
pub stashes_horizontal_splitter: Option<Rect>,
pub stashes_vertical_splitter: Option<Rect>,
pub overview_horizontal_splitter: Option<Rect>,
pub commits_inner: Option<Rect>,
pub staged_sub_inner: Option<Rect>,
pub unstaged_sub_inner: Option<Rect>,
pub conflicts_sub_inner: Option<Rect>,
pub changed_files_inner: Option<Rect>,
pub local_branches_inner: Option<Rect>,
pub remote_branches_inner: Option<Rect>,
pub local_tags_inner: Option<Rect>,
pub remotes_inner: Option<Rect>,
pub stashes_inner: Option<Rect>,
pub stashed_files_inner: Option<Rect>,
}
#[allow(clippy::too_many_arguments)]
pub fn draw(
f: &mut Frame,
item_name: &str,
detail: &ItemDetail,
mode: &Mode,
focus: &DetailSection,
last_staging_focus: DetailSection,
commit_selection: usize,
commit_search_query: &Option<String>,
file_selection: usize,
file_diff: &[DiffLine],
diff_scroll: usize,
staging_file_selection: usize,
commit_details_scroll: usize,
local_branch_selection: usize,
remote_branch_selection: usize,
local_tag_selection: usize,
remote_selection: usize,
remote_picker_selection: usize,
stash_selection: usize,
stash_file_selection: usize,
file_list_selection: usize,
file_content_scroll: usize,
visible_files: &[crate::app::FileTreeItem],
detail_tab: usize,
graph_scroll: usize,
help_scroll: usize,
areas: &mut DetailAreas,
input_buffer: &str,
commit_editing: bool,
branch_action_target: &Option<(String, bool)>,
tag_action_target_oid: &Option<String>,
tag_delete_target: &Option<(String, bool)>,
tag_push_target: &Option<String>,
discard_target: &Option<(String, bool)>,
stash_apply_delete_after: bool,
commit_amend: bool,
commit_input_scroll: usize,
inspect_horizontal_split_pct: u16,
inspect_vertical_split_pct: u16,
workspace_main_split_pct: u16,
files_horizontal_split_pct: u16,
branches_horizontal_split_pct: u16,
stashes_horizontal_split_pct: u16,
stashes_vertical_split_pct: u16,
overview_horizontal_split_pct: u16,
app: &crate::app::App,
area: Rect,
) {
if app.in_logs_ui
&& matches!(
mode,
Mode::Logs | Mode::LogsSearchInput | Mode::SearchColumnPicker
)
{
if let ItemDetail::Repo { info, .. } = detail {
let header_rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), ])
.split(area);
let header_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(header_rows[0]);
let branch = info.branch.as_ref();
let header_left = Paragraph::new(Line::from(vec![
Span::styled(
"▍ ",
Style::default().fg(ACCENT()).add_modifier(Modifier::BOLD),
),
Span::styled(
format!("Logs - {}", item_name),
primary_style().add_modifier(Modifier::BOLD),
),
]));
f.render_widget(header_left, header_chunks[0]);
if let Some(branch_name) = branch {
let header_right = Paragraph::new(Line::from(vec![
Span::styled(" ", muted_style()),
Span::styled(branch_name.as_str(), accent_style()),
Span::raw(" "),
]))
.alignment(Alignment::Right);
f.render_widget(header_right, header_chunks[1]);
}
{
let w = area.width as usize;
let outer = (w / 10).max(2);
let inner = (w / 8).max(3);
let centre = w.saturating_sub(outer * 2 + inner * 2);
let divider_line = Line::from(vec![
Span::styled(
" ".repeat(outer),
Style::default()
.fg(ratatui::style::Color::DarkGray)
.add_modifier(Modifier::DIM),
),
Span::styled("┄".repeat(inner), muted_style().add_modifier(Modifier::DIM)),
Span::styled("┈".repeat(centre), muted_style()),
Span::styled("┄".repeat(inner), muted_style().add_modifier(Modifier::DIM)),
Span::styled(
" ".repeat(outer),
Style::default()
.fg(ratatui::style::Color::DarkGray)
.add_modifier(Modifier::DIM),
),
]);
f.render_widget(Paragraph::new(divider_line), header_rows[1]);
}
areas.commits = Some(header_rows[2]);
draw_logs_view(
f,
info,
commit_selection,
commit_search_query,
app,
header_rows[2],
);
if matches!(mode, Mode::SearchColumnPicker) {
draw_search_column_picker(f, app, area);
}
}
return;
}
if mode == &Mode::Inspect {
if let ItemDetail::Repo { info, .. } = detail {
let dirty = !info.changes.staged.is_empty()
|| !info.changes.unstaged.is_empty()
|| !info.changes.untracked.is_empty()
|| !info.changes.conflicted.is_empty();
let is_uncommitted = dirty && commit_selection == 0 && !app.in_logs_ui;
if is_uncommitted {
draw_staging_panels(
f,
&info.changes,
*focus,
last_staging_focus,
staging_file_selection,
file_diff,
diff_scroll,
areas,
inspect_horizontal_split_pct,
inspect_vertical_split_pct,
app,
area,
);
return;
} else {
if let Some(commit) = app.get_selected_commit() {
draw_inspect_window(
f,
commit,
*focus,
file_selection,
file_diff,
diff_scroll,
commit_details_scroll,
areas,
inspect_horizontal_split_pct,
inspect_vertical_split_pct,
app,
area,
);
return;
}
}
}
}
let branch: Option<String> = match detail {
ItemDetail::Repo { info, .. } => info.branch.clone(),
_ => None,
};
let is_repo = matches!(detail, ItemDetail::Repo { .. });
let (header_area, tab_bar_area, body_area) = if is_repo {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(2), Constraint::Length(2), Constraint::Min(0), ])
.split(area);
(chunks[0], Some(chunks[1]), chunks[2])
} else {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(2), Constraint::Min(0), ])
.split(area);
(chunks[0], None, chunks[1])
};
let header_rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Length(1)])
.split(header_area);
let header_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(0), Constraint::Length(40)])
.split(header_rows[0]);
let header_left = Paragraph::new(Line::from(vec![
Span::raw(FIELD_INDENT),
Span::styled("⎇ ", muted_style().add_modifier(Modifier::BOLD)),
Span::styled(item_name.to_string(), accent_style()),
]));
f.render_widget(header_left, header_chunks[0]);
if let Some(ref branch_name) = branch {
let header_right = Paragraph::new(Line::from(vec![
Span::styled(" ", muted_style()),
Span::styled(branch_name, accent_style()),
Span::raw(" "),
]))
.alignment(Alignment::Right);
f.render_widget(header_right, header_chunks[1]);
}
{
let w = header_area.width as usize;
let outer = (w / 10).max(2);
let inner = (w / 8).max(3);
let centre = w.saturating_sub(outer * 2 + inner * 2);
let fade_outer = Style::default()
.fg(ratatui::style::Color::DarkGray)
.add_modifier(Modifier::DIM);
let fade_inner = muted_style().add_modifier(Modifier::DIM);
let solid = muted_style();
let divider_line = Line::from(vec![
Span::styled(" ".repeat(outer), fade_outer),
Span::styled("┄".repeat(inner), fade_inner),
Span::styled("┈".repeat(centre), solid),
Span::styled("┄".repeat(inner), fade_inner),
Span::styled(" ".repeat(outer), fade_outer),
]);
f.render_widget(Paragraph::new(divider_line), header_rows[1]);
}
if let Some(tab_area) = tab_bar_area {
let tabs_data = [
("Workspace", "W", 1),
("Files", "F", 2),
("Graph", "G", 3),
("Branches", "B", 4),
("Tags", "T", 5),
("Remotes", "R", 6),
("Stashes", "S", 7),
("Overview", "O", 8),
];
let use_short = tab_area.width < 124;
let mut spans = vec![Span::raw(" ")];
for (i, &(long_name, short_name, index)) in tabs_data.iter().enumerate() {
if i > 0 {
spans.push(Span::raw(" "));
}
let name = if use_short { short_name } else { long_name };
let bullet = if detail_tab == i { "┃" } else { "│" };
let style = if detail_tab == i {
Style::default().fg(ACCENT()).add_modifier(Modifier::BOLD)
} else {
Style::default().add_modifier(Modifier::DIM | Modifier::UNDERLINED)
};
spans.push(Span::styled(
format!("{} {} [{}] {}", bullet, name, index, bullet),
style,
));
}
let tab_line = Line::from(spans);
f.render_widget(Paragraph::new(tab_line), tab_area);
areas.tab_bar = Some(tab_area);
}
match detail {
ItemDetail::Repo { resolved, info } => {
if detail_tab == 0 {
let detail_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(workspace_main_split_pct),
Constraint::Percentage(100 - workspace_main_split_pct),
])
.split(body_area);
let split_row = body_area.y + detail_chunks[0].height;
areas.workspace_main_splitter = Some(Rect::new(
body_area.x,
split_row.saturating_sub(1),
body_area.width,
2,
));
let dirty = !info.changes.staged.is_empty()
|| !info.changes.unstaged.is_empty()
|| !info.changes.untracked.is_empty()
|| !info.changes.conflicted.is_empty();
let show_dirty = if dirty {
if let Some(query) = commit_search_query {
"<uncommitted>".contains(&query.to_lowercase())
} else {
true
}
} else {
false
};
let is_uncommitted_row = show_dirty && commit_selection == 0;
draw_detail_commits(
f,
info,
*focus,
commit_selection,
commit_search_query,
detail_chunks[0],
&app.commits_table_state,
areas,
);
areas.commits = Some(detail_chunks[0]);
if is_uncommitted_row {
draw_staging_panels(
f,
&info.changes,
*focus,
last_staging_focus,
staging_file_selection,
file_diff,
diff_scroll,
areas,
inspect_horizontal_split_pct,
inspect_vertical_split_pct,
app,
detail_chunks[1],
);
} else {
match app.get_selected_commit() {
Some(commit) => {
draw_commit_files_panel(
f,
commit,
*focus,
file_selection,
file_diff,
diff_scroll,
commit_details_scroll,
areas,
inspect_horizontal_split_pct,
inspect_vertical_split_pct,
app,
detail_chunks[1],
);
}
None => {
draw_staging_panels(
f,
&info.changes,
*focus,
last_staging_focus,
staging_file_selection,
file_diff,
diff_scroll,
areas,
inspect_horizontal_split_pct,
inspect_vertical_split_pct,
app,
detail_chunks[1],
);
}
}
}
} else if detail_tab == 1 {
draw_files_view(
f,
resolved,
info,
visible_files,
*focus,
file_list_selection,
file_content_scroll,
areas,
files_horizontal_split_pct,
app,
body_area,
);
} else if detail_tab == 2 {
let block = Block::default()
.borders(Borders::ALL)
.border_type(CARD_BORDER())
.border_style(muted_style())
.title(Line::from(vec![
Span::raw(" "),
Span::styled("Branch History Graph", primary_style()),
Span::raw(" "),
Span::styled(format!("({})", info.graph_lines.len()), muted_style()),
Span::raw(" "),
]))
.padding(Padding::uniform(1));
let inner = block.inner(body_area);
f.render_widget(block, body_area);
let visible_height = inner.height as usize;
let upper = (graph_scroll + visible_height).min(info.graph_lines.len());
let visible_lines = &info.graph_lines[graph_scroll..upper];
let mut list_lines = Vec::new();
for g_line in visible_lines {
list_lines.push(graph_line_spans(g_line));
}
let paragraph = Paragraph::new(list_lines).wrap(Wrap { trim: false });
f.render_widget(paragraph, inner);
} else if detail_tab == 3 {
draw_branches_view(
f,
info,
*focus,
local_branch_selection,
remote_branch_selection,
areas,
branches_horizontal_split_pct,
app,
body_area,
);
} else if detail_tab == 4 {
draw_tags_view(
f,
info,
*focus,
local_tag_selection,
info.remote_tags_loaded,
areas,
app,
body_area,
);
} else if detail_tab == 5 {
draw_remotes_view(f, info, *focus, remote_selection, areas, app, body_area);
} else if detail_tab == 6 {
draw_stashes_view(
f,
info,
*focus,
stash_selection,
stash_file_selection,
file_diff,
diff_scroll,
areas,
stashes_horizontal_split_pct,
stashes_vertical_split_pct,
app,
body_area,
);
} else {
draw_overview_tab(
f,
resolved,
info,
overview_horizontal_split_pct,
areas,
body_area,
);
}
if matches!(mode, Mode::DetailHelp) {
draw_detail_help_overlay(f, body_area, help_scroll);
}
if matches!(mode, Mode::CommitInput) {
draw_commit_popup(
f,
input_buffer,
commit_editing,
commit_amend,
commit_input_scroll,
body_area,
app.commit_popup_maximized,
);
}
if matches!(mode, Mode::SearchColumnPicker) {
draw_search_column_picker(f, app, body_area);
}
if matches!(mode, Mode::BranchCreateInput) {
draw_branch_create_popup(f, input_buffer, branch.as_deref(), body_area);
}
if matches!(mode, Mode::RemoteAddNameInput) {
draw_remote_add_name_popup(f, input_buffer, body_area);
}
if matches!(mode, Mode::RemoteAddUrlInput) {
draw_remote_add_url_popup(f, &app.remote_add_name, input_buffer, body_area);
}
if matches!(mode, Mode::RemoteDeleteConfirm) {
draw_remote_delete_popup(
f,
app.remote_action_target.as_deref().unwrap_or(""),
body_area,
);
}
if matches!(mode, Mode::TagCreateInput) {
draw_tag_create_popup(f, input_buffer, tag_action_target_oid.as_deref(), body_area);
}
if matches!(mode, Mode::StashCreateInput) {
draw_stash_create_popup(f, input_buffer, body_area);
}
if matches!(mode, Mode::BranchDeleteConfirm) {
draw_branch_delete_popup(f, branch_action_target, body_area);
}
if matches!(mode, Mode::BranchPushConfirm) {
draw_branch_push_popup(f, branch_action_target, body_area);
}
if matches!(mode, Mode::BranchMergeConfirm) {
draw_branch_merge_popup(f, branch_action_target, branch.as_deref(), body_area);
}
if matches!(mode, Mode::MergeAbortConfirm) {
draw_merge_abort_confirm_popup(f, body_area);
}
if matches!(mode, Mode::MergeContinueConfirm) {
draw_merge_continue_confirm_popup(f, body_area);
}
if matches!(mode, Mode::BranchRebaseConfirm) {
draw_branch_rebase_popup(f, branch_action_target, branch.as_deref(), body_area);
}
if matches!(mode, Mode::BranchInteractiveRebaseConfirm) {
draw_branch_interactive_rebase_popup(
f,
branch_action_target,
branch.as_deref(),
body_area,
);
}
if matches!(mode, Mode::TagDeleteConfirm) {
draw_tag_delete_popup(f, tag_delete_target, body_area);
}
if matches!(mode, Mode::TagPushConfirm) {
draw_tag_push_popup(f, tag_push_target, body_area);
}
if matches!(mode, Mode::TagPushAllConfirm) {
draw_tag_push_all_popup(f, body_area);
}
if matches!(mode, Mode::CherryPickConfirm) {
draw_cherry_pick_popup(f, &app.cherry_pick_target, branch.as_deref(), body_area);
}
if matches!(mode, Mode::RevertConfirm) {
draw_revert_popup(f, &app.revert_target, branch.as_deref(), body_area);
}
if matches!(mode, Mode::StashDeleteConfirm) {
let stash_name = match detail {
ItemDetail::Repo { info, .. } => info
.stashes
.get(stash_selection)
.map(|s| format!("stash@{{{}}}: {}", s.index, s.message)),
_ => None,
};
draw_stash_delete_popup(f, &stash_name, body_area);
}
if matches!(mode, Mode::StashApplyConfirm) {
let stash_name = match detail {
ItemDetail::Repo { info, .. } => info
.stashes
.get(stash_selection)
.map(|s| format!("stash@{{{}}}: {}", s.index, s.message)),
_ => None,
};
draw_stash_apply_popup(f, &stash_name, stash_apply_delete_after, body_area);
}
if matches!(mode, Mode::RemotePicker) {
if let ItemDetail::Repo { info, .. } = detail {
draw_remote_picker_popup(f, &info.remotes, remote_picker_selection, body_area);
}
}
if matches!(mode, Mode::DiscardChangesConfirm) {
draw_discard_changes_popup(f, discard_target, body_area);
}
if matches!(mode, Mode::BranchCheckoutConfirm) {
draw_branch_checkout_popup(f, branch_action_target, body_area);
}
if matches!(mode, Mode::TagCheckoutConfirm) {
draw_tag_checkout_popup(f, &app.tag_checkout_target, body_area);
}
}
_ => {
let body_lines = build_body(app, detail);
let body = Paragraph::new(body_lines)
.block(Block::default().padding(Padding::ZERO))
.wrap(Wrap { trim: false });
f.render_widget(body, body_area);
}
}
}
#[allow(clippy::too_many_arguments)]
fn draw_commit_files_panel(
f: &mut Frame,
commit: &CommitEntry,
focus: DetailSection,
file_selection: usize,
file_diff: &[DiffLine],
diff_scroll: usize,
commit_details_scroll: usize,
areas: &mut DetailAreas,
inspect_horizontal_split_pct: u16,
inspect_vertical_split_pct: u16,
app: &crate::app::App,
area: Rect,
) {
let panels = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(inspect_horizontal_split_pct),
Constraint::Percentage(100 - inspect_horizontal_split_pct),
])
.split(area);
areas.bottom_right = Some(panels[1]);
areas.staged_sub = None;
areas.unstaged_sub = None;
let split_col = area.x + panels[0].width;
areas.inspect_horizontal_splitter = Some(Rect::new(
split_col.saturating_sub(1),
area.y,
2,
area.height,
));
let left_focused = focus == DetailSection::Staged || focus == DetailSection::Unstaged;
let right_focused = focus == DetailSection::StagingDetails;
let left_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(inspect_vertical_split_pct),
Constraint::Percentage(100 - inspect_vertical_split_pct),
])
.split(panels[0]);
let split_row = panels[0].y + left_chunks[0].height;
areas.inspect_vertical_splitter = Some(Rect::new(
panels[0].x,
split_row.saturating_sub(1),
panels[0].width,
2,
));
areas.bottom_left = Some(left_chunks[0]);
areas.commit_details = Some(left_chunks[1]);
let left_border_style = if left_focused {
Style::default().fg(ACCENT())
} else {
muted_style()
};
let left_block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(left_border_style)
.title(Line::from(vec![
Span::raw(" "),
Span::styled("Changed Files", primary_style()),
Span::raw(" "),
Span::styled(format!("({})", commit.files.len()), muted_style()),
Span::raw(" "),
]));
let left_inner = left_block.inner(left_chunks[0]);
areas.changed_files_inner = Some(left_inner);
f.render_widget(left_block, left_chunks[0]);
if commit.files.is_empty() {
let v = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(40),
Constraint::Length(1),
Constraint::Min(0),
])
.split(left_inner);
f.render_widget(
Paragraph::new(Span::styled("No files changed", muted_style()))
.alignment(Alignment::Center),
v[1],
);
} else {
let items: Vec<ListItem> = commit
.files
.iter()
.map(|f| ListItem::new(file_entry_line(f)))
.collect();
let list =
List::new(items).highlight_style(Style::default().add_modifier(Modifier::REVERSED));
let mut state = app.changed_files_list_state.borrow_mut();
if left_focused {
state.select(Some(file_selection));
} else {
state.select(None);
}
f.render_stateful_widget(list, left_inner, &mut *state);
}
draw_commit_details_widget(
f,
commit,
focus == DetailSection::CommitDetails,
commit_details_scroll,
left_chunks[1],
);
areas.commit_details = Some(left_chunks[1]);
let right_border_style = if right_focused {
Style::default().fg(ACCENT())
} else {
muted_style()
};
let right_block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(right_border_style)
.title(Line::from(vec![
Span::raw(" "),
Span::styled("Diff", primary_style()),
if right_focused && diff_scroll > 0 {
Span::styled(format!(" ↕ line {}", diff_scroll + 1), muted_style())
} else {
Span::raw("")
},
if right_focused {
Span::styled(format!(" {} scroll", app.sym("up_down")), muted_style())
} else {
Span::raw("")
},
Span::raw(" "),
]));
let right_inner = right_block.inner(panels[1]);
f.render_widget(right_block, panels[1]);
if file_diff.is_empty() {
let v_center = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(45),
Constraint::Length(1),
Constraint::Min(0),
])
.split(right_inner);
f.render_widget(
Paragraph::new(Span::styled(
"Select a file to view its diff",
muted_style(),
))
.alignment(Alignment::Center),
v_center[1],
);
} else {
let diff_spans: Vec<Line> = file_diff
.iter()
.map(|line| {
let style = match line.kind {
DiffLineKind::Added => Style::default().fg(SUCCESS()),
DiffLineKind::Removed => Style::default().fg(DANGER()),
DiffLineKind::Header => Style::default().fg(ratatui::style::Color::Cyan),
DiffLineKind::Context => Style::default(),
DiffLineKind::ConflictOurs => {
Style::default().fg(ratatui::style::Color::LightRed)
}
DiffLineKind::ConflictTheirs => {
Style::default().fg(ratatui::style::Color::LightBlue)
}
DiffLineKind::ConflictSeparator => Style::default()
.fg(ratatui::style::Color::Yellow)
.add_modifier(ratatui::style::Modifier::BOLD),
};
Line::from(Span::styled(line.content.clone(), style))
})
.collect();
f.render_widget(
Paragraph::new(diff_spans)
.scroll((diff_scroll as u16, 0))
.wrap(Wrap { trim: false }),
right_inner,
);
}
}
#[allow(clippy::too_many_arguments)]
fn draw_staging_panels(
f: &mut Frame,
changes: &WorktreeChanges,
focus: DetailSection,
last_staging_focus: DetailSection,
staging_file_selection: usize,
file_diff: &[DiffLine],
diff_scroll: usize,
areas: &mut DetailAreas,
inspect_horizontal_split_pct: u16,
inspect_vertical_split_pct: u16,
app: &crate::app::App,
area: Rect,
) {
let right_focused =
focus == DetailSection::StagingDetails || focus == DetailSection::ConflictDiff;
let selected_file_name: Option<String> = {
let (files, idx) = match focus {
DetailSection::Staged => (Some(&changes.staged), staging_file_selection),
DetailSection::Unstaged => (Some(&changes.unstaged), staging_file_selection),
DetailSection::Conflicts => (Some(&changes.conflicted), app.conflict_file_selection),
_ => match last_staging_focus {
DetailSection::Staged => (Some(&changes.staged), staging_file_selection),
DetailSection::Unstaged => (Some(&changes.unstaged), staging_file_selection),
DetailSection::Conflicts => {
(Some(&changes.conflicted), app.conflict_file_selection)
}
_ => {
if !changes.conflicted.is_empty() {
(Some(&changes.conflicted), app.conflict_file_selection)
} else if !changes.staged.is_empty() {
(Some(&changes.staged), staging_file_selection)
} else if !changes.unstaged.is_empty() {
(Some(&changes.unstaged), staging_file_selection)
} else {
(None, 0)
}
}
},
};
files.and_then(|f| f.get(idx)).map(|e| e.path.clone())
};
let right_inner = if app.inspect_full_diff {
areas.bottom_left = None;
areas.bottom_right = Some(area);
areas.commit_details = None;
areas.inspect_horizontal_splitter = None;
areas.inspect_vertical_splitter = None;
areas.staged_sub = None;
areas.unstaged_sub = None;
let right_block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(ACCENT()))
.title(Line::from(vec![
Span::raw(" "),
Span::styled("Staging Details", primary_style()),
if let Some(ref name) = selected_file_name {
Span::styled(format!(" {} (Full Screen)", name), muted_style())
} else {
Span::raw("")
},
Span::raw(" "),
]));
let inner = right_block.inner(area);
f.render_widget(right_block, area);
inner
} else {
let panels = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(inspect_horizontal_split_pct),
Constraint::Percentage(100 - inspect_horizontal_split_pct),
])
.split(area);
areas.bottom_left = Some(panels[0]);
areas.bottom_right = Some(panels[1]);
areas.commit_details = None;
let split_col = area.x + panels[0].width;
areas.inspect_horizontal_splitter = Some(Rect::new(
split_col.saturating_sub(1),
area.y,
2,
area.height,
));
let left_focused = focus == DetailSection::Staged
|| focus == DetailSection::Unstaged
|| focus == DetailSection::Conflicts;
let left_border_style = if left_focused {
Style::default().fg(ACCENT())
} else {
muted_style()
};
let left_outer = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(left_border_style)
.title(Line::from(vec![
Span::raw(" "),
Span::styled("Staging Area", primary_style()),
Span::raw(" "),
]));
let left_inner = left_outer.inner(panels[0]);
f.render_widget(left_outer, panels[0]);
let has_conflicts = !changes.conflicted.is_empty();
let left_split = if has_conflicts {
Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
])
.split(left_inner)
} else {
Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(inspect_vertical_split_pct),
Constraint::Percentage(100 - inspect_vertical_split_pct),
])
.split(left_inner)
};
let split_row = left_inner.y + left_split[0].height;
areas.inspect_vertical_splitter = Some(Rect::new(
left_inner.x,
split_row.saturating_sub(1),
left_inner.width,
2,
));
areas.staged_sub = Some(left_split[0]);
areas.unstaged_sub = Some(left_split[1]);
if has_conflicts {
areas.conflicts_sub = Some(left_split[2]);
} else {
areas.conflicts_sub = None;
}
let staged_inner = draw_file_subpanel(
f,
"Staged",
SUCCESS(),
&changes.staged,
"Nothing staged",
Borders::BOTTOM,
focus == DetailSection::Staged,
if focus == DetailSection::Staged {
Some(staging_file_selection)
} else {
None
},
&app.staged_list_state,
left_split[0],
);
areas.staged_sub_inner = Some(staged_inner);
let unstaged_inner = draw_file_subpanel(
f,
"Unstaged",
WARNING(),
&changes.unstaged,
"No unstaged changes",
if has_conflicts {
Borders::BOTTOM
} else {
Borders::empty()
},
focus == DetailSection::Unstaged,
if focus == DetailSection::Unstaged {
Some(staging_file_selection)
} else {
None
},
&app.unstaged_list_state,
left_split[1],
);
areas.unstaged_sub_inner = Some(unstaged_inner);
if has_conflicts {
let conflicts_inner = draw_file_subpanel(
f,
"Conflicts",
DANGER(),
&changes.conflicted,
"No conflicts",
Borders::empty(),
focus == DetailSection::Conflicts,
if focus == DetailSection::Conflicts {
Some(app.conflict_file_selection)
} else {
None
},
&app.conflicts_list_state,
left_split[2],
);
areas.conflicts_sub_inner = Some(conflicts_inner);
} else {
areas.conflicts_sub_inner = None;
}
let right_border_style = if right_focused {
Style::default().fg(ACCENT())
} else {
muted_style()
};
let right_block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(right_border_style)
.title(Line::from(vec![
Span::raw(" "),
Span::styled(
if last_staging_focus == DetailSection::Conflicts {
"Conflict Markers"
} else {
"Staging Details"
},
primary_style(),
),
if let Some(ref name) = selected_file_name {
Span::styled(format!(" {}", name), muted_style())
} else {
Span::raw("")
},
Span::raw(" "),
]));
let inner = right_block.inner(panels[1]);
f.render_widget(right_block, panels[1]);
inner
};
if file_diff.is_empty() {
let v_center = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(45),
Constraint::Length(1),
Constraint::Min(0),
])
.split(right_inner);
f.render_widget(
Paragraph::new(Span::styled(
"Select a file to view its diff",
muted_style(),
))
.alignment(Alignment::Center),
v_center[1],
);
} else {
let hunk_ranges = app.get_diff_hunk_ranges();
let selected_hunk_range = hunk_ranges.get(app.diff_hunk_selection);
let diff_spans: Vec<Line> = file_diff
.iter()
.enumerate()
.map(|(i, line)| {
let is_selected_hunk = selected_hunk_range.map(|r| r.contains(&i)).unwrap_or(false);
let (prefix, bg_style) = if right_focused {
if app.diff_line_mode {
if i == app.diff_line_selection {
(
"▎",
Style::default().bg(ratatui::style::Color::Rgb(70, 70, 70)),
)
} else if is_selected_hunk {
(
" ",
Style::default().bg(ratatui::style::Color::Rgb(40, 40, 40)),
)
} else {
(" ", Style::default())
}
} else {
if is_selected_hunk {
(
"▎",
Style::default().bg(ratatui::style::Color::Rgb(50, 50, 50)),
)
} else {
(" ", Style::default())
}
}
} else {
(" ", Style::default())
};
let mut style = match line.kind {
DiffLineKind::Added => Style::default().fg(SUCCESS()),
DiffLineKind::Removed => Style::default().fg(DANGER()),
DiffLineKind::Header => Style::default().fg(ratatui::style::Color::Cyan),
DiffLineKind::Context => Style::default(),
DiffLineKind::ConflictOurs => {
Style::default().fg(ratatui::style::Color::LightRed)
}
DiffLineKind::ConflictTheirs => {
Style::default().fg(ratatui::style::Color::LightBlue)
}
DiffLineKind::ConflictSeparator => Style::default()
.fg(ratatui::style::Color::Yellow)
.add_modifier(ratatui::style::Modifier::BOLD),
};
style = style.patch(bg_style);
Line::from(vec![
Span::styled(prefix, style),
Span::styled(line.content.clone(), style),
])
})
.collect();
f.render_widget(
Paragraph::new(diff_spans)
.scroll((diff_scroll as u16, 0))
.wrap(Wrap { trim: false }),
right_inner,
);
}
}
#[allow(clippy::too_many_arguments)]
fn draw_file_subpanel(
f: &mut Frame,
title: &'static str,
title_color: ratatui::style::Color,
files: &[FileEntry],
empty_msg: &'static str,
borders: Borders,
focused: bool,
selection: Option<usize>,
list_state: &std::cell::RefCell<ListState>,
area: Rect,
) -> Rect {
let title_style = if focused {
Style::default()
.fg(ACCENT())
.add_modifier(ratatui::style::Modifier::BOLD)
} else {
Style::default().fg(title_color)
};
let border_style = if focused {
Style::default().fg(ACCENT())
} else {
muted_style()
};
let block = Block::default()
.borders(borders)
.border_style(border_style)
.title(Line::from(vec![
Span::raw(" "),
Span::styled(title, title_style),
Span::raw(" "),
Span::styled(format!("({})", files.len()), muted_style()),
Span::raw(" "),
]));
let inner = block.inner(area);
f.render_widget(block, area);
if files.is_empty() {
let v = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(40),
Constraint::Length(1),
Constraint::Min(0),
])
.split(inner);
f.render_widget(
Paragraph::new(Span::styled(empty_msg, muted_style())).alignment(Alignment::Center),
v[1],
);
return inner;
}
if let Some(sel_idx) = selection {
let items: Vec<ListItem> = files
.iter()
.map(|e| ListItem::new(file_entry_line(e)))
.collect();
let list =
List::new(items).highlight_style(Style::default().add_modifier(Modifier::REVERSED));
let mut state = list_state.borrow_mut();
state.select(Some(sel_idx));
f.render_stateful_widget(list, inner, &mut *state);
} else {
let file_lines: Vec<Line<'static>> = files.iter().map(file_entry_line).collect();
f.render_widget(Paragraph::new(file_lines).wrap(Wrap { trim: false }), inner);
}
inner
}
fn draw_overview_tab(
f: &mut Frame,
resolved: &std::path::Path,
info: &RepoInfo,
overview_horizontal_split_pct: u16,
areas: &mut DetailAreas,
area: Rect,
) {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(overview_horizontal_split_pct),
Constraint::Percentage(100 - overview_horizontal_split_pct),
])
.split(area);
let split_col = area.x + chunks[0].width;
areas.overview_horizontal_splitter = Some(Rect::new(
split_col.saturating_sub(1),
area.y,
2,
area.height,
));
let left_block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(muted_style())
.title(Line::from(vec![
Span::raw(" "),
Span::styled("Overview", primary_style()),
Span::raw(" "),
]))
.padding(Padding::horizontal(1));
let body_lines = build_repo_body(resolved, info);
let body = Paragraph::new(body_lines)
.block(left_block)
.wrap(Wrap { trim: false });
f.render_widget(body, chunks[0]);
let right_title = if info.committer_stats_limit_reached {
"Stats (last 10k commits)"
} else {
"Stats"
};
let right_block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(muted_style())
.title(Line::from(vec![
Span::raw(" "),
Span::styled(right_title, primary_style()),
Span::raw(" "),
]))
.padding(Padding::horizontal(1));
let stats_lines = build_committer_stats_lines(info);
let stats_body = Paragraph::new(stats_lines)
.block(right_block)
.wrap(Wrap { trim: false });
f.render_widget(stats_body, chunks[1]);
}
fn build_committer_stats_lines(info: &RepoInfo) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = vec![];
push_section_header(&mut lines, "Committer Statistics");
if info.committer_stats.is_empty() {
lines.push(Line::from(vec![
Span::raw(FIELD_INDENT),
Span::styled("(no commits / unborn branch)", muted_style()),
]));
} else {
for stat in &info.committer_stats {
let mut stat_spans = vec![
Span::raw(FIELD_INDENT),
Span::styled("● ", Style::default().fg(ACCENT())),
Span::styled(stat.name.clone(), primary_style()),
];
if stat.email != "?" && !stat.email.is_empty() {
stat_spans.push(Span::styled(format!(" <{}>", stat.email), muted_style()));
}
stat_spans.push(Span::styled(" ➔ ", muted_style()));
stat_spans.push(Span::styled(
format!(
"{} commit{}",
stat.count,
if stat.count == 1 { "" } else { "s" }
),
Style::default().fg(SUCCESS()),
));
lines.push(Line::from(stat_spans));
}
}
lines
}
fn centred_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(r);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(popup_layout[1])[1]
}
pub(crate) const DETAIL_HELP_LINES: &[(&str, &str)] = &[
(
"↑ [Up]",
"Select previous commit / file / branch / file tree item",
),
(
"↓ [Down]",
"Select next commit / file / branch / file tree item",
),
("⇞ [PgUp]", "Jump page size rows up"),
("⇟ [PgDn]", "Jump page size rows down"),
("Home", "Scroll to top / go to first item"),
("End", "Scroll to bottom / go to last item"),
("⇥ [Tab] / ⇧⇥", "Cycle detail view tabs"),
("w / W", "Cycle panel focus forward (w) / backward (W)"),
("← / →", "Focus Local/Remote branch (Branches tab)"),
("← / → or < / >", "Collapse/Expand folder (Files tab)"),
("f", "Fuzzy find files (Files tab)"),
(
"↵ [Enter]",
"Stage/Unstage file, Checkout branch, Checkout tag, or Inspect commit",
),
(
"c / C",
"Commit (c) / Amend last commit (C) (Workspace/Inspect) / Create branch (Branches)",
),
("t", "Create tag (Workspace tab commits list)"),
(
"i",
"Interactive rebase from selected commit (Workspace) / selected branch (Branches)",
),
(
"f",
"Open search column picker and go to logs (Workspace tab)",
),
(
"d",
"Delete selected branch (Branches) / tag (Tags) / stash (Stashes)",
),
(
"a",
"Stage/Unstage All (Workspace) / Apply selected stash (Stashes)",
),
(
"x",
"Discard changes in selected file (Workspace / Inspect)",
),
(
"X",
"Discard all changes in repository (Workspace / Inspect)",
),
("m", "Merge selected branch into current branch (Branches)"),
("r", "Rebase current branch onto selected branch (Branches)"),
("1", "Go to Workspace tab"),
("2", "Go to Files tab"),
("3", "Go to Graph View tab"),
("4", "Go to Branches tab"),
("5", "Go to Tags tab"),
("6", "Go to Remotes tab"),
("7", "Go to Stashes tab"),
("8", "Go to Overview tab"),
(
"f / F",
"Fetch remote tags (Tags tab) / Fetch selected remote (Remotes tab)",
),
("⇧F [Shift+F]", "Fetch selected local branch's upstream"),
("p", "Pull branch (Branches) / Push tag (Tags)"),
(
"⇧P [Shift+P]",
"Push branch (Branches) / Push all tags (Tags)",
),
("R", "Resync current tab state"),
("s", "Stash changes (Workspace changes or Stashes tab)"),
(
"o",
"Accept OURS version of conflict (Conflicts / ConflictDiff)",
),
(
"t",
"Accept THEIRS version of conflict (Conflicts / ConflictDiff)",
),
("r", "Mark conflict as resolved (Conflicts / ConflictDiff)"),
("A", "Abort the merge (Conflicts / ConflictDiff)"),
("C", "Continue the merge (Conflicts / ConflictDiff)"),
("? / ⎋ [Esc]", "Close this help"),
("q / ⎋ [Esc]", "Back to repository list"),
(
"→ [Right] / ↵ [Enter]",
"Inspect selected commit (Workspace commits list)",
),
("⎋ [Esc]", "Back to workspace commits list (Inspect mode)"),
(
"Left-Click",
"Focus clicked panel / change tab (mouse support)",
),
("Left-Click+Drag", "Drag boundaries to resize split panels"),
];
fn draw_detail_help_overlay(f: &mut Frame, area: Rect, scroll: usize) {
let popup_area = centred_rect(60, 55, area);
f.render_widget(Clear, popup_area);
let key_width = DETAIL_HELP_LINES
.iter()
.map(|(k, _)| k.chars().count())
.max()
.unwrap_or(0);
let mut lines: Vec<Line> = vec![Line::from("")];
for (key, desc) in DETAIL_HELP_LINES {
let padded = format!("{:>width$}", key, width = key_width);
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
padded,
Style::default().fg(ACCENT()).add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::raw((*desc).to_string()),
]));
}
lines.push(Line::from(""));
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(ACCENT()))
.title(Line::from(vec![
Span::raw(" "),
Span::styled("Detail Shortcuts", primary_style()),
Span::raw(" "),
Span::styled("? / Esc close", muted_style()),
Span::raw(" "),
]))
.padding(Padding::horizontal(1));
let inner_height = popup_area.height.saturating_sub(2) as usize;
let max_scroll = lines.len().saturating_sub(inner_height);
let scroll = scroll.min(max_scroll);
let lines_len = lines.len();
let para = Paragraph::new(lines)
.block(block)
.scroll((scroll as u16, 0));
f.render_widget(para, popup_area);
if max_scroll > 0 {
let mut scrollbar_state = ratatui::widgets::ScrollbarState::new(lines_len)
.position(scroll)
.viewport_content_length(inner_height);
let scrollbar =
ratatui::widgets::Scrollbar::new(ratatui::widgets::ScrollbarOrientation::VerticalRight)
.begin_symbol(Some("▲"))
.end_symbol(Some("▼"))
.thumb_style(Style::default().fg(ACCENT()));
f.render_stateful_widget(scrollbar, popup_area, &mut scrollbar_state);
}
}
#[allow(clippy::too_many_arguments)]
fn draw_detail_commits(
f: &mut Frame,
info: &RepoInfo,
focus: DetailSection,
commit_selection: usize,
commit_search_query: &Option<String>,
area: Rect,
commits_table_state: &std::cell::RefCell<TableState>,
areas: &mut DetailAreas,
) {
let focused = focus == DetailSection::Commits;
let border_style = if focused {
Style::default().fg(ACCENT())
} else {
muted_style()
};
let filtered_commits: Vec<&crate::repo::CommitEntry> = if let Some(query) = commit_search_query
{
let q = query.to_lowercase();
info.commits
.iter()
.filter(|c| {
c.id.to_lowercase().contains(&q)
|| c.author.to_lowercase().contains(&q)
|| c.when.to_lowercase().contains(&q)
|| c.summary.to_lowercase().contains(&q)
})
.collect()
} else {
info.commits.iter().collect()
};
let dirty = !info.changes.staged.is_empty()
|| !info.changes.unstaged.is_empty()
|| !info.changes.untracked.is_empty()
|| !info.changes.conflicted.is_empty();
let show_dirty = if dirty {
if let Some(query) = commit_search_query {
"<uncommitted>".contains(&query.to_lowercase())
} else {
true
}
} else {
false
};
let total_entries = filtered_commits.len() + if show_dirty { 1 } else { 0 };
let selected_num = if total_entries > 0 {
(commit_selection + 1).min(total_entries)
} else {
0
};
let count_text = if total_entries > 0 {
format!("({}/{})", selected_num, total_entries)
} else {
"(0/0)".to_string()
};
let title_spans = if let Some(q) = commit_search_query {
vec![
Span::raw(" "),
Span::styled("Commits (Filter: ", primary_style()),
Span::styled(q.clone(), accent_style().add_modifier(Modifier::BOLD)),
Span::styled(")", primary_style()),
Span::raw(" "),
Span::styled(count_text, muted_style()),
Span::raw(" "),
]
} else {
vec![
Span::raw(" "),
Span::styled("Commits", primary_style()),
Span::raw(" "),
Span::styled(count_text, muted_style()),
Span::raw(" "),
]
};
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(border_style)
.title(Line::from(title_spans));
if filtered_commits.is_empty() && !show_dirty {
let inner = block.inner(area);
areas.commits_inner = Some(inner);
f.render_widget(block, area);
let v = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(40),
Constraint::Length(1),
Constraint::Min(0),
])
.split(inner);
f.render_widget(
Paragraph::new(Span::styled(
"No commits yet / empty repository",
muted_style(),
))
.alignment(Alignment::Center),
v[1],
);
return;
}
let header = Row::new(vec![
Cell::from("ID"),
Cell::from("Author"),
Cell::from("Date"),
Cell::from("Summary"),
])
.style(Style::default().add_modifier(Modifier::BOLD).fg(ACCENT()));
let mut rows: Vec<Row> = Vec::new();
if show_dirty {
rows.push(Row::new(vec![
Cell::from(Span::styled("-", muted_style())),
Cell::from(Span::styled("-", muted_style())),
Cell::from(Span::styled("-", muted_style())),
Cell::from(Span::styled(
"<uncommitted>",
Style::default().fg(WARNING()),
)),
]));
}
rows.extend(filtered_commits.iter().map(|commit| {
let mut spans: Vec<Span<'static>> = Vec::new();
for r in &commit.refs {
let (label, style) = if let Some(tag) = r.strip_prefix("tag:") {
(
format!("[{}]", tag),
Style::default().fg(WARNING()).add_modifier(Modifier::BOLD),
)
} else if let Some(remote) = r.strip_prefix("remote:") {
(
format!("[{}]", remote),
Style::default().fg(SUCCESS()).add_modifier(Modifier::BOLD),
)
} else {
(
format!("[{}]", r),
Style::default().fg(ACCENT()).add_modifier(Modifier::BOLD),
)
};
spans.push(Span::styled(label, style));
spans.push(Span::raw(" "));
}
spans.push(Span::styled(commit.summary.clone(), Style::default()));
let id_span = if !commit.signature_status.is_empty() && commit.signature_status != "N" {
let (sig_char, sig_style) = match commit.signature_status.as_str() {
"G" => (
"✓",
Style::default().fg(SUCCESS()).add_modifier(Modifier::BOLD),
),
"B" => (
"✗",
Style::default().fg(DANGER()).add_modifier(Modifier::BOLD),
),
"U" | "X" | "Y" | "R" => ("✓", Style::default().fg(WARNING())),
_ => ("?", muted_style()),
};
Line::from(vec![
Span::styled(sig_char, sig_style),
Span::raw(" "),
Span::styled(commit.id.clone(), Style::default().fg(WARNING())),
])
} else {
Line::from(vec![Span::styled(
commit.id.clone(),
Style::default().fg(WARNING()),
)])
};
Row::new(vec![
Cell::from(id_span),
Cell::from(Span::styled(commit.author.clone(), Style::default())),
Cell::from(Span::styled(commit.when.clone(), muted_style())),
Cell::from(Line::from(spans)),
])
}));
let widths = [
Constraint::Length(11), Constraint::Length(18), Constraint::Length(16), Constraint::Min(20), ];
let inner = block.inner(area);
areas.commits_inner = Some(inner);
let table = Table::new(rows, widths)
.header(header)
.block(block)
.row_highlight_style(Style::default().add_modifier(Modifier::REVERSED))
.column_spacing(2);
let mut state = commits_table_state.borrow_mut();
if focused {
state.select(Some(commit_selection));
} else {
state.select(None);
}
f.render_stateful_widget(table, area, &mut *state);
}
fn build_body(app: &crate::app::App, detail: &ItemDetail) -> Vec<Line<'static>> {
match detail {
ItemDetail::Missing { resolved } => {
let mut lines = vec![];
push_section_header(&mut lines, "Overview");
lines.push(kind_line(
app.sym("close"),
DANGER(),
"Not a directory",
"(path does not exist or isn't accessible)",
));
lines.push(field_line(
"Path",
Span::raw(resolved.display().to_string()),
));
lines
}
ItemDetail::Directory { resolved } => {
let mut lines = vec![];
push_section_header(&mut lines, "Overview");
lines.push(kind_line(
app.sym("bullet_empty"),
WARNING(),
"Plain directory",
"(exists, but no .git entry was found)",
));
lines.push(field_line(
"Path",
Span::raw(resolved.display().to_string()),
));
lines
}
ItemDetail::Error { resolved, message } => {
let mut lines = vec![];
push_section_header(&mut lines, "Overview");
lines.push(kind_line(
app.sym("warning").trim(),
WARNING(),
"Could not read repository",
"(libgit2 reported an error — see below)",
));
lines.push(field_line(
"Path",
Span::raw(resolved.display().to_string()),
));
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::raw(FIELD_INDENT),
Span::styled(message.clone(), Style::default().fg(DANGER())),
]));
lines
}
ItemDetail::Repo { resolved, info } => build_repo_body(resolved, info),
}
}
fn build_repo_body(resolved: &std::path::Path, info: &RepoInfo) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = vec![];
push_section_header(&mut lines, "General");
lines.push(kind_line(
"●",
SUCCESS(),
"Git Repository",
"(inspectable libgit2)",
));
lines.push(field_line(
"Path",
Span::raw(resolved.display().to_string()),
));
let branch = info
.branch
.clone()
.unwrap_or_else(|| "(detached HEAD)".to_string());
lines.push(field_line("Branch", Span::styled(branch, accent_style())));
push_section_header(&mut lines, "HEAD Commit");
if let Some(head) = &info.head {
lines.push(field_line(
"Hash",
Span::styled(head.short_id.clone(), Style::default().fg(WARNING())),
));
lines.push(field_line(
"Message",
Span::styled(head.summary.clone(), primary_style()),
));
lines.push(field_line("Author", Span::raw(head.author.clone())));
lines.push(field_line("Date", Span::raw(head.when.clone())));
} else {
lines.push(field_line(
"HEAD",
Span::styled("(empty repository)", muted_style()),
));
}
push_section_header(&mut lines, "Sync");
append_sync(&mut lines, info);
lines
}
fn push_section_header(lines: &mut Vec<Line<'static>>, title: &'static str) {
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::raw(FIELD_INDENT),
Span::styled("▍ ", Style::default().fg(ACCENT())),
Span::styled(title, primary_style()),
]));
lines.push(Line::from(""));
}
fn append_sync(lines: &mut Vec<Line<'static>>, info: &RepoInfo) {
match &info.upstream {
None => {
lines.push(field_line(
"Upstream",
Span::styled("(not configured)", muted_style()),
));
lines.push(field_line("Sync", Span::styled("—", muted_style())));
}
Some(name) => {
lines.push(field_line(
"Upstream",
Span::styled(name.clone(), Style::default().fg(ACCENT())),
));
let s = &info.summary;
if s.is_synced() {
lines.push(field_line(
"Sync",
Span::styled("in sync", Style::default().fg(SUCCESS())),
));
} else {
let mut spans = vec![
Span::raw(FIELD_INDENT),
Span::styled(field_label("Sync"), muted_style()),
];
if s.ahead > 0 {
spans.push(Span::styled(format!("{} ahead", s.ahead), primary_style()));
}
if s.behind > 0 {
if s.ahead > 0 {
spans.push(Span::raw(", "));
}
spans.push(Span::styled(
format!("{} behind", s.behind),
Style::default().fg(WARNING()),
));
}
lines.push(Line::from(spans));
}
}
}
if info.remotes.is_empty() {
lines.push(field_line("Remotes", Span::styled("(none)", muted_style())));
} else {
lines.push(Line::from(""));
for r in &info.remotes {
lines.push(remote_line(r));
}
}
}
fn remote_line(remote: &RemoteInfo) -> Line<'static> {
Line::from(vec![
Span::raw(" "),
Span::styled(format!("{:<8}", remote.name), Style::default().fg(ACCENT())),
Span::raw(" "),
Span::raw(remote.url.clone()),
])
}
fn file_entry_line(entry: &FileEntry) -> Line<'static> {
let label_style = match entry.label {
"N" => Style::default().fg(SUCCESS()),
"D" => Style::default().fg(DANGER()),
"C" => Style::default().fg(DANGER()).add_modifier(Modifier::BOLD),
"R" | "T" => Style::default().fg(ACCENT()),
"?" => muted_style(),
_ => Style::default().fg(WARNING()), };
Line::from(vec![
Span::raw(FILE_INDENT),
Span::styled(format!("{:<FILE_LABEL_WIDTH$}", entry.label), label_style),
Span::styled(entry.path.clone(), muted_style()),
])
}
fn field_label(name: &str) -> String {
let mut s = format!("{}:", name);
while s.chars().count() < FIELD_LABEL_WIDTH {
s.push(' ');
}
s
}
fn field_line(name: &'static str, value: Span<'static>) -> Line<'static> {
Line::from(vec![
Span::raw(FIELD_INDENT),
Span::styled(field_label(name), muted_style()),
value,
])
}
fn kind_line(
symbol: &'static str,
color: ratatui::style::Color,
title: &'static str,
sub: &'static str,
) -> Line<'static> {
Line::from(vec![
Span::raw(FIELD_INDENT),
Span::styled(symbol, Style::default().fg(color)),
Span::raw(" "),
Span::styled(title, primary_style()),
Span::raw(" "),
Span::styled(sub, muted_style()),
])
}
fn draw_commit_popup(
f: &mut Frame,
input_buffer: &str,
editing: bool,
commit_amend: bool,
scroll: usize,
area: Rect,
maximized: bool,
) {
let popup_area = if maximized {
let width = area.width.saturating_sub(20).max(area.width.min(40));
let height = area.height.saturating_sub(20).max(area.height.min(15));
let x = area.x + (area.width.saturating_sub(width)) / 2;
let y = area.y + (area.height.saturating_sub(height)) / 2;
Rect::new(x, y, width, height)
} else {
centred_rect(80, 45, area)
};
f.render_widget(Clear, popup_area);
let border_color = if editing { ACCENT() } else { WARNING() };
let border_style = Style::default().fg(border_color);
let title_text = if editing {
if commit_amend {
" Amend Commit Message "
} else {
" Commit Message "
}
} else {
if commit_amend {
" Confirm Amend Commit "
} else {
" Confirm Commit "
}
};
let title = Line::from(vec![
Span::raw(" "),
Span::styled(title_text, primary_style()),
Span::raw(" "),
]);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(border_style)
.title(title)
.padding(Padding::horizontal(1));
let text = if input_buffer.is_empty() {
Paragraph::new(Span::styled("(type commit message here...)", muted_style()))
.wrap(Wrap { trim: true })
.scroll((scroll as u16, 0))
} else {
Paragraph::new(input_buffer)
.wrap(Wrap { trim: true })
.scroll((scroll as u16, 0))
};
let inner_area = block.inner(popup_area);
f.render_widget(block, popup_area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0), Constraint::Length(1)])
.split(inner_area);
f.render_widget(text, chunks[0]);
let checkbox = if commit_amend { "[X]" } else { "[ ]" };
let checkbox_style = if commit_amend {
Style::default().fg(SUCCESS()).add_modifier(Modifier::BOLD)
} else {
muted_style()
};
let checkbox_line = Line::from(vec![
Span::styled(format!("{} ", checkbox), checkbox_style),
Span::styled("Amend last commit", primary_style()),
if !editing {
Span::styled(" (toggle: [a/space])", muted_style())
} else {
Span::styled(" (toggle: [⌃A])", muted_style())
},
]);
f.render_widget(Paragraph::new(checkbox_line), chunks[1]);
if editing {
let lines: Vec<&str> = input_buffer.split('\n').collect();
let last_line = lines.last().copied().unwrap_or("");
let line_count = lines.len();
let cursor_y = chunks[0]
.y
.saturating_add(line_count.saturating_sub(1) as u16)
.min(
chunks[0]
.y
.saturating_add(chunks[0].height.saturating_sub(1)),
);
let cursor_offset = last_line.chars().count() as u16;
let cursor_x = chunks[0]
.x
.saturating_add(cursor_offset.min(chunks[0].width.saturating_sub(1)));
f.set_cursor_position(ratatui::layout::Position::new(cursor_x, cursor_y));
}
}
fn graph_line_spans(line: &crate::repo::GraphLine) -> Line<'static> {
let mut spans = Vec::new();
spans.push(Span::styled(line.graph.clone(), muted_style()));
if let Some(ref c) = line.commit {
let short_hash = if c.oid.len() >= 7 {
&c.oid[0..7]
} else {
&c.oid
};
spans.push(Span::styled(format!("{} ", short_hash), accent_style()));
if !c.signature_status.is_empty() && c.signature_status != "N" {
let (sig_char, sig_style) = match c.signature_status.as_str() {
"G" => (
"✓ ",
Style::default().fg(SUCCESS()).add_modifier(Modifier::BOLD),
),
"B" => (
"✗ ",
Style::default().fg(DANGER()).add_modifier(Modifier::BOLD),
),
"U" | "X" | "Y" | "R" => ("✓ ", Style::default().fg(WARNING())),
_ => ("? ", muted_style()),
};
spans.push(Span::styled(sig_char, sig_style));
}
if !c.decoration.is_empty() {
let dec = c.decoration.trim();
let dec_content = if dec.starts_with('(') && dec.ends_with(')') {
&dec[1..dec.len() - 1]
} else {
dec
};
spans.push(Span::styled("(", muted_style()));
let mut first = true;
for ref_item in dec_content.split(", ") {
if !first {
spans.push(Span::styled(", ", muted_style()));
}
first = false;
if let Some(stripped) = ref_item.strip_prefix("HEAD -> ") {
spans.push(Span::styled(
"HEAD -> ",
Style::default().fg(SUCCESS()).add_modifier(Modifier::BOLD),
));
spans.push(Span::styled(
stripped.to_string(),
Style::default().fg(ACCENT()).add_modifier(Modifier::BOLD),
));
} else if let Some(stripped) = ref_item.strip_prefix("tag: ") {
spans.push(Span::styled("tag: ", Style::default().fg(WARNING())));
spans.push(Span::styled(
stripped.to_string(),
Style::default().fg(WARNING()).add_modifier(Modifier::BOLD),
));
} else if ref_item.contains('/') {
spans.push(Span::styled(
ref_item.to_string(),
Style::default().fg(DANGER()),
));
} else {
spans.push(Span::styled(
ref_item.to_string(),
Style::default().fg(SUCCESS()),
));
}
}
spans.push(Span::styled(") ", muted_style()));
}
spans.push(Span::styled(c.summary.clone(), primary_style()));
spans.push(Span::styled(" - ", muted_style()));
spans.push(Span::styled(c.author.clone(), muted_style()));
spans.push(Span::styled(format!(" ({})", c.date), muted_style()));
}
Line::from(spans)
}
#[allow(clippy::too_many_arguments)]
fn draw_branches_view(
f: &mut Frame,
info: &RepoInfo,
focus: DetailSection,
local_branch_selection: usize,
remote_branch_selection: usize,
areas: &mut DetailAreas,
branches_horizontal_split_pct: u16,
app: &crate::app::App,
area: Rect,
) {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(branches_horizontal_split_pct),
Constraint::Percentage(100 - branches_horizontal_split_pct),
])
.split(area);
let left_area = chunks[0];
let right_area = chunks[1];
areas.local_branches = Some(left_area);
areas.remote_branches = Some(right_area);
let split_col = area.x + left_area.width;
areas.branches_horizontal_splitter = Some(Rect::new(
split_col.saturating_sub(1),
area.y,
2,
area.height,
));
let local_focused = focus == DetailSection::LocalBranches;
let local_border_style = if local_focused {
Style::default().fg(ACCENT())
} else {
muted_style()
};
let local_block = Block::default()
.borders(Borders::ALL)
.border_type(CARD_BORDER())
.border_style(local_border_style)
.title(Line::from(vec![
Span::raw(" "),
Span::styled("Local Branches", primary_style()),
Span::raw(" "),
]))
.padding(Padding::uniform(1));
let local_items: Vec<ListItem> = info
.local_branches
.iter()
.map(|b| {
let style = if b.is_head {
Style::default().fg(SUCCESS()).add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let prefix = if b.is_head { app.sym("branch") } else { " " };
let mut spans = vec![
Span::styled(prefix, Style::default().fg(SUCCESS())),
Span::styled(b.name.clone(), style),
];
if !b.short_sha.is_empty() {
spans.push(Span::raw(" "));
spans.push(Span::styled(format!("[{}]", b.short_sha), accent_style()));
if !b.short_message.is_empty() {
spans.push(Span::raw(" "));
spans.push(Span::styled("·", muted_style()));
spans.push(Span::raw(" "));
spans.push(Span::styled(b.short_message.clone(), muted_style()));
}
}
ListItem::new(Line::from(spans))
})
.collect();
let inner = local_block.inner(left_area);
areas.local_branches_inner = Some(inner);
let local_list = List::new(local_items)
.block(local_block)
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
let mut local_state = app.local_branch_list_state.borrow_mut();
if local_focused {
local_state.select(Some(local_branch_selection));
} else {
local_state.select(None);
}
f.render_stateful_widget(local_list, left_area, &mut *local_state);
let remote_focused = focus == DetailSection::RemoteBranches;
let remote_border_style = if remote_focused {
Style::default().fg(ACCENT())
} else {
muted_style()
};
let remote_block = Block::default()
.borders(Borders::ALL)
.border_type(CARD_BORDER())
.border_style(remote_border_style)
.title(Line::from(vec![
Span::raw(" "),
Span::styled("Remote Branches", primary_style()),
Span::raw(" "),
]))
.padding(Padding::uniform(1));
let remote_items: Vec<ListItem> = info
.remote_branches
.iter()
.map(|b| {
let mut spans = vec![
Span::raw(" "),
Span::styled(b.name.clone(), primary_style()),
];
if !b.short_sha.is_empty() {
spans.push(Span::raw(" "));
spans.push(Span::styled(format!("[{}]", b.short_sha), accent_style()));
if !b.short_message.is_empty() {
spans.push(Span::raw(" "));
spans.push(Span::styled("·", muted_style()));
spans.push(Span::raw(" "));
spans.push(Span::styled(b.short_message.clone(), muted_style()));
}
}
ListItem::new(Line::from(spans))
})
.collect();
let inner = remote_block.inner(right_area);
areas.remote_branches_inner = Some(inner);
let remote_list = List::new(remote_items)
.block(remote_block)
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
let mut remote_state = app.remote_branch_list_state.borrow_mut();
if remote_focused {
remote_state.select(Some(remote_branch_selection));
} else {
remote_state.select(None);
}
f.render_stateful_widget(remote_list, right_area, &mut *remote_state);
}
#[allow(clippy::too_many_arguments)]
fn draw_files_view(
f: &mut Frame,
resolved: &std::path::Path,
info: &RepoInfo,
visible_files: &[crate::app::FileTreeItem],
focus: DetailSection,
file_list_selection: usize,
file_content_scroll: usize,
areas: &mut DetailAreas,
files_horizontal_split_pct: u16,
app: &crate::app::App,
area: Rect,
) {
let files_full_screen = app.inspect_full_diff;
let chunks = if files_full_screen {
let left_rect = Rect::new(area.x, area.y, 0, area.height);
vec![left_rect, area]
} else {
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(files_horizontal_split_pct),
Constraint::Percentage(100 - files_horizontal_split_pct),
])
.split(area)
.to_vec()
};
areas.files = Some(chunks[0]);
areas.file_content = Some(chunks[1]);
if files_full_screen {
areas.files_horizontal_splitter = None;
} else {
let split_col = area.x + chunks[0].width;
areas.files_horizontal_splitter = Some(Rect::new(
split_col.saturating_sub(1),
area.y,
2,
area.height,
));
}
if !files_full_screen {
let focused = focus == DetailSection::Files;
let border_style = if focused {
Style::default().fg(ACCENT())
} else {
muted_style()
};
let left_block = Block::default()
.borders(Borders::ALL)
.border_type(CARD_BORDER())
.border_style(border_style)
.title(Line::from(vec![
Span::raw(" "),
Span::styled("Repository Files", primary_style()),
Span::raw(" "),
]))
.padding(Padding::uniform(1));
let items: Vec<ListItem> = visible_files
.iter()
.map(|item| {
let indent = " ".repeat(item.depth);
let (prefix, style) = if item.is_dir {
if item.is_expanded {
(app.sym("folder_tree_expanded"), primary_style())
} else {
(app.sym("folder_tree_collapsed"), primary_style())
}
} else {
(app.sym("file_tree"), muted_style())
};
ListItem::new(Line::from(vec![
Span::raw(indent),
Span::styled(prefix, style),
Span::styled(item.name.clone(), primary_style()),
]))
})
.collect();
let list = List::new(items)
.block(left_block)
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
let mut state = ListState::default();
state.select(Some(file_list_selection));
f.render_stateful_widget(list, chunks[0], &mut state);
}
if let Some(selected_item) = visible_files.get(file_list_selection) {
if selected_item.is_dir {
let folder_name = if selected_item.name.is_empty() {
"/"
} else {
&selected_item.name
};
let right_focused = focus == DetailSection::FileContent;
let right_border_style = if right_focused {
Style::default().fg(ACCENT())
} else {
muted_style()
};
let mut title_spans = vec![
Span::raw(" "),
Span::styled(format!("Contents of {}", folder_name), primary_style()),
];
if right_focused && file_content_scroll > 0 {
title_spans.push(Span::styled(
format!(" ↕ line {}", file_content_scroll + 1),
muted_style(),
));
}
title_spans.push(Span::raw(" "));
let right_block = Block::default()
.borders(Borders::ALL)
.border_type(CARD_BORDER())
.border_style(right_border_style)
.title(Line::from(title_spans))
.padding(Padding::uniform(1));
let prefix = if selected_item.full_path.is_empty() {
"".to_string()
} else {
format!("{}/", selected_item.full_path)
};
let mut direct_children = std::collections::BTreeSet::new();
for f_path in &info.files {
if f_path.starts_with(&prefix) {
let relative = &f_path[prefix.len()..];
if let Some(idx) = relative.find('/') {
let subdir = &relative[..idx];
direct_children.insert((subdir.to_string(), true));
} else {
direct_children.insert((relative.to_string(), false));
}
}
}
let mut children_vec: Vec<(String, bool)> = direct_children.into_iter().collect();
children_vec.sort_by(|a, b| match (a.1, b.1) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.0.cmp(&b.0),
});
let mut lines = Vec::new();
if children_vec.is_empty() {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled("(empty folder)", muted_style()),
]));
} else {
for (name, is_dir) in children_vec {
if is_dir {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(app.sym("folder"), Style::default().fg(ACCENT())),
Span::styled(name, primary_style()),
]));
} else {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(app.sym("file"), muted_style()),
Span::raw(name),
]));
}
}
}
let body = Paragraph::new(lines)
.block(right_block)
.wrap(Wrap { trim: false })
.scroll((file_content_scroll as u16, 0));
f.render_widget(body, chunks[1]);
} else {
let right_focused = focus == DetailSection::FileContent;
let right_border_style = if right_focused {
Style::default().fg(ACCENT())
} else {
muted_style()
};
let mut title_spans = vec![
Span::raw(" "),
Span::styled(
format!("Content of {}", selected_item.name),
primary_style(),
),
];
if right_focused && file_content_scroll > 0 {
title_spans.push(Span::styled(
format!(" ↕ line {}", file_content_scroll + 1),
muted_style(),
));
}
title_spans.push(Span::raw(" "));
let right_block = Block::default()
.borders(Borders::ALL)
.border_type(CARD_BORDER())
.border_style(right_border_style)
.title(Line::from(title_spans))
.padding(Padding::uniform(1));
let file_path = resolved.join(&selected_item.full_path);
let content_text = match read_file_content(&file_path) {
Ok(content) => content,
Err(e) => format!("Could not read file: {}", e),
};
let body = Paragraph::new(content_text)
.block(right_block)
.wrap(Wrap { trim: false })
.scroll((file_content_scroll as u16, 0));
f.render_widget(body, chunks[1]);
}
} else {
let right_block = Block::default()
.borders(Borders::ALL)
.border_type(CARD_BORDER())
.border_style(muted_style())
.title(Line::from(vec![
Span::raw(" "),
Span::styled("Content", primary_style()),
Span::raw(" "),
]));
f.render_widget(right_block, chunks[1]);
}
}
fn read_file_content(path: &std::path::Path) -> Result<String, std::io::Error> {
use std::io::Read;
let file = std::fs::File::open(path)?;
let mut buffer = Vec::new();
file.take(100_000).read_to_end(&mut buffer)?;
let content = String::from_utf8(buffer)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
Ok(content)
}
fn draw_branch_create_popup(
f: &mut Frame,
input_buffer: &str,
base_branch: Option<&str>,
area: Rect,
) {
let popup_area = centred_rect(50, 20, area);
f.render_widget(Clear, popup_area);
let border_style = Style::default().fg(ACCENT());
let title = Line::from(vec![
Span::raw(" "),
Span::styled("Create Branch", primary_style()),
Span::raw(" "),
]);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(border_style)
.title(title)
.padding(Padding::horizontal(1));
let base_name = base_branch.unwrap_or("HEAD");
let content = vec![
Line::from(vec![
Span::styled("Base: ", muted_style()),
Span::styled(base_name, primary_style()),
]),
Line::from(""),
Line::from(vec![
Span::styled("New Branch Name: ", muted_style()),
Span::styled(input_buffer, primary_style().add_modifier(Modifier::BOLD)),
]),
];
let inner_area = block.inner(popup_area);
f.render_widget(block, popup_area);
let paragraph = Paragraph::new(content);
f.render_widget(paragraph, inner_area);
let cursor_y = inner_area.y.saturating_add(2).min(
inner_area
.y
.saturating_add(inner_area.height.saturating_sub(1)),
);
let label_width = "New Branch Name: ".chars().count() as u16;
let cursor_offset = label_width.saturating_add(input_buffer.chars().count() as u16);
let cursor_x = inner_area.x.saturating_add(cursor_offset).min(
inner_area
.x
.saturating_add(inner_area.width.saturating_sub(1)),
);
f.set_cursor_position(ratatui::layout::Position::new(cursor_x, cursor_y));
}
fn draw_branch_delete_popup(f: &mut Frame, target: &Option<(String, bool)>, area: Rect) {
let popup_area = centred_rect(50, 20, area);
f.render_widget(Clear, popup_area);
let border_style = Style::default().fg(DANGER());
let title = Line::from(vec![
Span::raw(" "),
Span::styled("Delete Branch", primary_style()),
Span::raw(" "),
]);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(border_style)
.title(title)
.padding(Padding::horizontal(1));
let (branch_name, is_remote) = match target {
Some((name, remote)) => (name.as_str(), *remote),
None => ("", false),
};
let type_label = if is_remote {
"remote-tracking branch"
} else {
"branch"
};
let content = vec![
Line::from(vec![
Span::styled("Are you sure you want to delete the ", primary_style()),
Span::styled(type_label, accent_style()),
Span::raw(":"),
]),
Line::from(""),
Line::from(vec![
Span::raw(" "),
Span::styled(
branch_name,
Style::default().fg(DANGER()).add_modifier(Modifier::BOLD),
),
]),
Line::from(""),
Line::from(vec![
Span::styled("Confirm: ", muted_style()),
Span::styled("y", accent_style().add_modifier(Modifier::BOLD)),
Span::styled(" / Cancel: ", muted_style()),
Span::styled("n", accent_style().add_modifier(Modifier::BOLD)),
]),
];
let paragraph = Paragraph::new(content).block(block);
f.render_widget(paragraph, popup_area);
}
fn draw_branch_checkout_popup(f: &mut Frame, target: &Option<(String, bool)>, area: Rect) {
let popup_area = centred_rect(50, 20, area);
f.render_widget(Clear, popup_area);
let border_style = Style::default().fg(ACCENT());
let title = Line::from(vec![
Span::raw(" "),
Span::styled("Checkout Branch", primary_style()),
Span::raw(" "),
]);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(border_style)
.title(title)
.padding(Padding::horizontal(1));
let (branch_name, is_remote) = match target {
Some((name, remote)) => (name.as_str(), *remote),
None => ("", false),
};
let type_label = if is_remote {
"remote-tracking branch"
} else {
"branch"
};
let content = vec![
Line::from(vec![
Span::styled("Are you sure you want to checkout the ", primary_style()),
Span::styled(type_label, accent_style()),
Span::raw(":"),
]),
Line::from(""),
Line::from(vec![
Span::raw(" "),
Span::styled(
branch_name,
Style::default().fg(ACCENT()).add_modifier(Modifier::BOLD),
),
]),
Line::from(""),
Line::from(vec![
Span::styled("Confirm: ", muted_style()),
Span::styled("y", accent_style().add_modifier(Modifier::BOLD)),
Span::styled(" / Cancel: ", muted_style()),
Span::styled("n", accent_style().add_modifier(Modifier::BOLD)),
]),
];
let paragraph = Paragraph::new(content).block(block);
f.render_widget(paragraph, popup_area);
}
fn draw_tag_checkout_popup(f: &mut Frame, target: &Option<String>, area: Rect) {
let popup_area = centred_rect(50, 20, area);
f.render_widget(Clear, popup_area);
let border_style = Style::default().fg(ACCENT());
let title = Line::from(vec![
Span::raw(" "),
Span::styled("Checkout Tag", primary_style()),
Span::raw(" "),
]);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(border_style)
.title(title)
.padding(Padding::horizontal(1));
let tag_name = target.as_deref().unwrap_or("");
let content = vec![
Line::from(vec![Span::styled(
"Are you sure you want to checkout the tag (detached HEAD):",
primary_style(),
)]),
Line::from(""),
Line::from(vec![
Span::raw(" "),
Span::styled(
tag_name,
Style::default().fg(ACCENT()).add_modifier(Modifier::BOLD),
),
]),
Line::from(""),
Line::from(vec![
Span::styled("Confirm: ", muted_style()),
Span::styled("y", accent_style().add_modifier(Modifier::BOLD)),
Span::styled(" / Cancel: ", muted_style()),
Span::styled("n", accent_style().add_modifier(Modifier::BOLD)),
]),
];
let paragraph = Paragraph::new(content).block(block);
f.render_widget(paragraph, popup_area);
}
fn draw_discard_changes_popup(f: &mut Frame, target: &Option<(String, bool)>, area: Rect) {
let popup_area = centred_rect(60, 20, area);
f.render_widget(Clear, popup_area);
let border_style = Style::default().fg(DANGER());
let title = Line::from(vec![
Span::raw(" "),
Span::styled("Discard Changes", primary_style()),
Span::raw(" "),
]);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(border_style)
.title(title)
.padding(Padding::horizontal(1));
let (file_path, staged) = match target {
Some((path, staged)) => (path.as_str(), *staged),
None => ("", false),
};
let area_label = if staged { "staged" } else { "unstaged" };
let content = vec![
Line::from(vec![
Span::styled("Are you sure you want to discard ", primary_style()),
Span::styled(area_label, accent_style()),
Span::styled(" changes in:", primary_style()),
]),
Line::from(""),
Line::from(vec![
Span::raw(" "),
Span::styled(
file_path,
Style::default().fg(DANGER()).add_modifier(Modifier::BOLD),
),
]),
Line::from(""),
Line::from(vec![Span::styled(
"This operation is destructive and cannot be undone.",
Style::default().fg(DANGER()),
)]),
Line::from(""),
Line::from(vec![
Span::styled("Confirm: ", muted_style()),
Span::styled("y", accent_style().add_modifier(Modifier::BOLD)),
Span::styled(" / Cancel: ", muted_style()),
Span::styled("n", accent_style().add_modifier(Modifier::BOLD)),
]),
];
let paragraph = Paragraph::new(content).block(block);
f.render_widget(paragraph, popup_area);
}
fn draw_branch_merge_popup(
f: &mut Frame,
target: &Option<(String, bool)>,
current_branch: Option<&str>,
area: Rect,
) {
let popup_area = centred_rect(50, 20, area);
f.render_widget(Clear, popup_area);
let border_style = Style::default().fg(ACCENT());
let title = Line::from(vec![
Span::raw(" "),
Span::styled("Merge Branch", primary_style()),
Span::raw(" "),
]);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(border_style)
.title(title)
.padding(Padding::horizontal(1));
let (branch_name, is_remote) = match target {
Some((name, remote)) => (name.as_str(), *remote),
None => ("", false),
};
let type_label = if is_remote {
"remote-tracking branch"
} else {
"branch"
};
let current = current_branch.unwrap_or("HEAD");
let content = vec![
Line::from(vec![
Span::styled("Are you sure you want to merge the ", primary_style()),
Span::styled(type_label, accent_style()),
Span::raw(":"),
]),
Line::from(""),
Line::from(vec![
Span::raw(" "),
Span::styled(
branch_name,
Style::default().fg(ACCENT()).add_modifier(Modifier::BOLD),
),
]),
Line::from(""),
Line::from(vec![
Span::styled("into the current branch ", primary_style()),
Span::styled(
format!("'{}'", current),
accent_style().add_modifier(Modifier::BOLD),
),
Span::raw("?"),
]),
Line::from(""),
Line::from(vec![
Span::styled("Confirm: ", muted_style()),
Span::styled("y", accent_style().add_modifier(Modifier::BOLD)),
Span::styled(" / Cancel: ", muted_style()),
Span::styled("n", accent_style().add_modifier(Modifier::BOLD)),
]),
];
let paragraph = Paragraph::new(content).block(block);
f.render_widget(paragraph, popup_area);
}
fn draw_merge_abort_confirm_popup(f: &mut Frame, area: Rect) {
let popup_area = centred_rect(45, 12, area);
f.render_widget(Clear, popup_area);
let border_style = Style::default().fg(DANGER());
let title = Line::from(vec![
Span::raw(" "),
Span::styled(
"Abort Merge",
Style::default().fg(DANGER()).add_modifier(Modifier::BOLD),
),
Span::raw(" "),
]);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(border_style)
.title(title)
.padding(Padding::uniform(1));
let content = vec![
Line::from(Span::styled(
"Are you sure you want to abort the merge?",
primary_style(),
)),
Line::from(""),
Line::from(Span::styled(
"All unresolved conflict changes will be lost.",
muted_style(),
)),
Line::from(""),
Line::from(vec![
Span::styled("Confirm: ", muted_style()),
Span::styled(
"y",
Style::default().fg(DANGER()).add_modifier(Modifier::BOLD),
),
Span::styled(" / Cancel: ", muted_style()),
Span::styled("n", accent_style().add_modifier(Modifier::BOLD)),
]),
];
let paragraph = Paragraph::new(content).block(block);
f.render_widget(paragraph, popup_area);
}
fn draw_merge_continue_confirm_popup(f: &mut Frame, area: Rect) {
let popup_area = centred_rect(45, 12, area);
f.render_widget(Clear, popup_area);
let border_style = Style::default().fg(SUCCESS());
let title = Line::from(vec![
Span::raw(" "),
Span::styled(
"Continue Merge",
Style::default().fg(SUCCESS()).add_modifier(Modifier::BOLD),
),
Span::raw(" "),
]);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(border_style)
.title(title)
.padding(Padding::uniform(1));
let content = vec![
Line::from(Span::styled(
"Are you sure you want to commit the merge?",
primary_style(),
)),
Line::from(""),
Line::from(Span::styled(
"This will finalize the merge commit.",
muted_style(),
)),
Line::from(""),
Line::from(vec![
Span::styled("Confirm: ", muted_style()),
Span::styled(
"y",
Style::default().fg(SUCCESS()).add_modifier(Modifier::BOLD),
),
Span::styled(" / Cancel: ", muted_style()),
Span::styled("n", accent_style().add_modifier(Modifier::BOLD)),
]),
];
let paragraph = Paragraph::new(content).block(block);
f.render_widget(paragraph, popup_area);
}
fn draw_branch_rebase_popup(
f: &mut Frame,
target: &Option<(String, bool)>,
current_branch: Option<&str>,
area: Rect,
) {
let popup_area = centred_rect(50, 20, area);
f.render_widget(Clear, popup_area);
let border_style = Style::default().fg(ACCENT());
let title = Line::from(vec![
Span::raw(" "),
Span::styled("Rebase Branch", primary_style()),
Span::raw(" "),
]);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(border_style)
.title(title)
.padding(Padding::horizontal(1));
let (branch_name, is_remote) = match target {
Some((name, remote)) => (name.as_str(), *remote),
None => ("", false),
};
let type_label = if is_remote {
"remote-tracking branch"
} else {
"branch"
};
let current = current_branch.unwrap_or("HEAD");
let content = vec![
Line::from(vec![
Span::styled("Are you sure you want to rebase the ", primary_style()),
Span::styled(
format!("current branch '{}'", current),
accent_style().add_modifier(Modifier::BOLD),
),
]),
Line::from(vec![
Span::styled("onto the ", primary_style()),
Span::styled(type_label, accent_style()),
Span::raw(":"),
]),
Line::from(""),
Line::from(vec![
Span::raw(" "),
Span::styled(
branch_name,
Style::default().fg(ACCENT()).add_modifier(Modifier::BOLD),
),
]),
Line::from(""),
Line::from(vec![
Span::styled("Confirm: ", muted_style()),
Span::styled("y", accent_style().add_modifier(Modifier::BOLD)),
Span::styled(" / Cancel: ", muted_style()),
Span::styled("n", accent_style().add_modifier(Modifier::BOLD)),
]),
];
let paragraph = Paragraph::new(content).block(block);
f.render_widget(paragraph, popup_area);
}
fn draw_branch_interactive_rebase_popup(
f: &mut Frame,
target: &Option<(String, bool)>,
current_branch: Option<&str>,
area: Rect,
) {
let popup_area = centred_rect(50, 20, area);
f.render_widget(Clear, popup_area);
let border_style = Style::default().fg(ACCENT());
let title = Line::from(vec![
Span::raw(" "),
Span::styled("Interactive Rebase Branch", primary_style()),
Span::raw(" "),
]);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(border_style)
.title(title)
.padding(Padding::horizontal(1));
let (branch_name, is_remote) = match target {
Some((name, remote)) => (name.as_str(), *remote),
None => ("", false),
};
let type_label = if is_remote {
"remote-tracking branch"
} else {
"branch"
};
let current = current_branch.unwrap_or("HEAD");
let content = vec![
Line::from(vec![
Span::styled(
"Are you sure you want to interactively rebase the ",
primary_style(),
),
Span::styled(
format!("current branch '{}'", current),
accent_style().add_modifier(Modifier::BOLD),
),
]),
Line::from(vec![
Span::styled("onto the ", primary_style()),
Span::styled(type_label, accent_style()),
Span::raw(":"),
]),
Line::from(""),
Line::from(vec![
Span::raw(" "),
Span::styled(
branch_name,
Style::default().fg(ACCENT()).add_modifier(Modifier::BOLD),
),
]),
Line::from(""),
Line::from(vec![
Span::styled("Confirm: ", muted_style()),
Span::styled("y", accent_style().add_modifier(Modifier::BOLD)),
Span::styled(" / Cancel: ", muted_style()),
Span::styled("n", accent_style().add_modifier(Modifier::BOLD)),
]),
];
let paragraph = Paragraph::new(content).block(block);
f.render_widget(paragraph, popup_area);
}
fn draw_remote_picker_popup(f: &mut Frame, remotes: &[RemoteInfo], selection: usize, area: Rect) {
let popup_area = centred_rect(50, 60, area);
f.render_widget(Clear, popup_area);
let border_style = Style::default().fg(ACCENT());
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(border_style)
.title(Line::from(vec![
Span::raw(" "),
Span::styled("Select Remote", primary_style()),
Span::raw(" "),
]))
.padding(Padding::horizontal(1));
let inner = block.inner(popup_area);
f.render_widget(block, popup_area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(1), Constraint::Length(1)])
.split(inner);
let items: Vec<ListItem> = remotes
.iter()
.enumerate()
.map(|(i, r)| {
let style = if i == selection {
accent_style().add_modifier(Modifier::BOLD | Modifier::REVERSED)
} else {
primary_style()
};
ListItem::new(Line::from(vec![
Span::raw(" "),
Span::styled(r.name.clone(), style),
Span::styled(" ", muted_style()),
Span::styled(r.url.clone(), muted_style()),
]))
})
.collect();
let mut list_state = ListState::default();
list_state.select(Some(selection));
f.render_stateful_widget(List::new(items), chunks[0], &mut list_state);
let hint = Line::from(vec![
Span::styled("↑↓ navigate ", muted_style()),
Span::styled("Enter", accent_style().add_modifier(Modifier::BOLD)),
Span::styled(" confirm ", muted_style()),
Span::styled("Esc", accent_style().add_modifier(Modifier::BOLD)),
Span::styled(" cancel", muted_style()),
]);
f.render_widget(Paragraph::new(hint), chunks[1]);
}
fn draw_branch_push_popup(f: &mut Frame, target: &Option<(String, bool)>, area: Rect) {
let popup_area = centred_rect(50, 20, area);
f.render_widget(Clear, popup_area);
let border_style = Style::default().fg(ACCENT());
let title = Line::from(vec![
Span::raw(" "),
Span::styled("Push Branch", primary_style()),
Span::raw(" "),
]);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(border_style)
.title(title)
.padding(Padding::horizontal(1));
let branch_name = match target {
Some((name, _)) => name.as_str(),
None => "",
};
let content = vec![
Line::from(vec![
Span::styled("Are you sure you want to push branch ", primary_style()),
Span::styled(branch_name, accent_style().add_modifier(Modifier::BOLD)),
Span::raw("?"),
]),
Line::from(""),
Line::from(vec![
Span::styled("Confirm: ", muted_style()),
Span::styled("y", accent_style().add_modifier(Modifier::BOLD)),
Span::styled(" / Cancel: ", muted_style()),
Span::styled("n", accent_style().add_modifier(Modifier::BOLD)),
]),
];
let paragraph = Paragraph::new(content).block(block);
f.render_widget(paragraph, popup_area);
}
fn draw_tag_create_popup(
f: &mut Frame,
input_buffer: &str,
target_commit_oid: Option<&str>,
area: Rect,
) {
let popup_area = centred_rect(50, 20, area);
f.render_widget(Clear, popup_area);
let border_style = Style::default().fg(ACCENT());
let title = Line::from(vec![
Span::raw(" "),
Span::styled("Create Tag", primary_style()),
Span::raw(" "),
]);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(border_style)
.title(title)
.padding(Padding::horizontal(1));
let commit_hash = target_commit_oid
.map(|oid| if oid.len() >= 7 { &oid[..7] } else { oid })
.unwrap_or("unknown");
let content = vec![
Line::from(vec![
Span::styled("Target Commit: ", muted_style()),
Span::styled(commit_hash, primary_style()),
]),
Line::from(""),
Line::from(vec![
Span::styled("Tag Name: ", muted_style()),
Span::styled(input_buffer, primary_style().add_modifier(Modifier::BOLD)),
]),
];
let inner_area = block.inner(popup_area);
f.render_widget(block, popup_area);
let paragraph = Paragraph::new(content);
f.render_widget(paragraph, inner_area);
let cursor_y = inner_area.y.saturating_add(2).min(
inner_area
.y
.saturating_add(inner_area.height.saturating_sub(1)),
);
let label_width = "Tag Name: ".chars().count() as u16;
let cursor_offset = label_width.saturating_add(input_buffer.chars().count() as u16);
let cursor_x = inner_area.x.saturating_add(cursor_offset).min(
inner_area
.x
.saturating_add(inner_area.width.saturating_sub(1)),
);
f.set_cursor_position(ratatui::layout::Position::new(cursor_x, cursor_y));
}
fn draw_stash_create_popup(f: &mut Frame, input_buffer: &str, area: Rect) {
let popup_area = centred_rect(50, 20, area);
f.render_widget(Clear, popup_area);
let border_style = Style::default().fg(ACCENT());
let title = Line::from(vec![
Span::raw(" "),
Span::styled("Stash Changes", primary_style()),
Span::raw(" "),
]);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(border_style)
.title(title)
.padding(Padding::horizontal(1));
let content = vec![
Line::from(vec![Span::styled("Stash Name / Message: ", muted_style())]),
Line::from(""),
Line::from(vec![Span::styled(
input_buffer,
primary_style().add_modifier(Modifier::BOLD),
)]),
];
let inner_area = block.inner(popup_area);
f.render_widget(block, popup_area);
let paragraph = Paragraph::new(content);
f.render_widget(paragraph, inner_area);
let cursor_y = inner_area.y.saturating_add(2).min(
inner_area
.y
.saturating_add(inner_area.height.saturating_sub(1)),
);
let cursor_offset = input_buffer.chars().count() as u16;
let cursor_x = inner_area.x.saturating_add(cursor_offset).min(
inner_area
.x
.saturating_add(inner_area.width.saturating_sub(1)),
);
f.set_cursor_position(ratatui::layout::Position::new(cursor_x, cursor_y));
}
#[allow(clippy::too_many_arguments)]
fn draw_tags_view(
f: &mut Frame,
info: &RepoInfo,
focus: DetailSection,
local_tag_selection: usize,
remote_tags_loaded: bool,
areas: &mut DetailAreas,
app: &crate::app::App,
area: Rect,
) {
areas.local_tags = Some(area);
areas.remote_tags = None;
let local_focused = focus == DetailSection::LocalTags;
let local_border_style = if local_focused {
Style::default().fg(ACCENT())
} else {
muted_style()
};
let local_block = Block::default()
.borders(Borders::ALL)
.border_type(CARD_BORDER())
.border_style(local_border_style)
.title(Line::from(vec![
Span::raw(" "),
Span::styled("Local Tags", primary_style()),
Span::raw(" "),
]))
.padding(Padding::uniform(1));
let local_items: Vec<ListItem> = info
.local_tags
.iter()
.map(|t| {
let mut spans = vec![Span::styled(" ", Style::default())];
if !t.short_sha.is_empty() {
spans.push(Span::styled(format!("[{}]", t.short_sha), accent_style()));
spans.push(Span::raw(" "));
}
spans.push(Span::styled(t.name.clone(), primary_style()));
if !t.short_message.is_empty() {
spans.push(Span::raw(" "));
spans.push(Span::styled("·", muted_style()));
spans.push(Span::raw(" "));
spans.push(Span::styled(t.short_message.clone(), muted_style()));
}
let is_pushed = if info.remotes.is_empty() {
true
} else if remote_tags_loaded {
info.remote_tags.iter().any(|rt| rt.name == t.name)
} else {
true
};
if !is_pushed {
spans.push(Span::raw(" "));
spans.push(Span::styled("unpushed", Style::default().fg(WARNING())));
}
ListItem::new(Line::from(spans))
})
.collect();
let inner = local_block.inner(area);
areas.local_tags_inner = Some(inner);
let local_list = List::new(local_items)
.block(local_block)
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
let mut local_state = app.local_tag_list_state.borrow_mut();
if local_focused {
local_state.select(Some(local_tag_selection));
} else {
local_state.select(None);
}
f.render_stateful_widget(local_list, area, &mut *local_state);
}
fn draw_remotes_view(
f: &mut Frame,
info: &RepoInfo,
focus: DetailSection,
remote_selection: usize,
areas: &mut DetailAreas,
app: &crate::app::App,
area: Rect,
) {
areas.remotes = Some(area);
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(35), Constraint::Percentage(65)])
.split(area);
let left_area = chunks[0];
let right_area = chunks[1];
let focused = focus == DetailSection::Remotes;
let border_style = if focused {
Style::default().fg(ACCENT())
} else {
muted_style()
};
let list_block = Block::default()
.borders(Borders::ALL)
.border_type(CARD_BORDER())
.border_style(border_style)
.title(Line::from(vec![
Span::raw(" "),
Span::styled("Remotes", primary_style()),
Span::raw(" "),
]))
.padding(Padding::uniform(1));
let list_items: Vec<ListItem> = info
.remotes
.iter()
.map(|r| {
ListItem::new(Line::from(vec![
Span::raw(" "),
Span::styled(r.name.clone(), primary_style()),
]))
})
.collect();
let inner = list_block.inner(left_area);
areas.remotes_inner = Some(inner);
let list = List::new(list_items)
.block(list_block)
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
let mut list_state = app.remote_list_state.borrow_mut();
if focused {
list_state.select(Some(remote_selection));
} else {
list_state.select(None);
}
f.render_stateful_widget(list, left_area, &mut *list_state);
let details_block = Block::default()
.borders(Borders::ALL)
.border_type(CARD_BORDER())
.border_style(muted_style())
.title(Line::from(vec![
Span::raw(" "),
Span::styled("Remote Details", primary_style()),
Span::raw(" "),
]))
.padding(Padding::uniform(1));
let mut details_lines = Vec::new();
if let Some(selected_remote) = info.remotes.get(remote_selection) {
details_lines.push(Line::from(""));
details_lines.push(Line::from(vec![
Span::styled(" Name: ", accent_style()),
Span::styled(selected_remote.name.clone(), primary_style()),
]));
details_lines.push(Line::from(vec![
Span::styled(" Fetch URL: ", accent_style()),
Span::raw(selected_remote.url.clone()),
]));
if let Some(push_url) = &selected_remote.push_url {
details_lines.push(Line::from(vec![
Span::styled(" Push URL: ", accent_style()),
Span::raw(push_url.clone()),
]));
} else {
details_lines.push(Line::from(vec![
Span::styled(" Push URL: ", accent_style()),
Span::raw(selected_remote.url.clone()),
]));
}
details_lines.push(Line::from(""));
details_lines.push(Line::from(vec![
Span::raw(" "),
Span::styled("Refspecs:", primary_style()),
]));
for spec in &selected_remote.refspecs {
details_lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(spec.clone(), muted_style()),
]));
}
} else {
details_lines.push(Line::from(""));
details_lines.push(Line::from(Span::styled(
" No remotes configured",
muted_style(),
)));
}
let details_paragraph = Paragraph::new(details_lines).block(details_block);
f.render_widget(details_paragraph, right_area);
}
fn draw_tag_delete_popup(f: &mut Frame, target: &Option<(String, bool)>, area: Rect) {
let popup_area = centred_rect(55, 25, area);
f.render_widget(Clear, popup_area);
let border_style = Style::default().fg(DANGER());
let title = Line::from(vec![
Span::raw(" "),
Span::styled("Delete Tag", primary_style()),
Span::raw(" "),
]);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(border_style)
.title(title)
.padding(Padding::horizontal(1));
let (tag_name, is_on_remote) = match target {
Some((name, is_on_remote)) => (name.as_str(), *is_on_remote),
None => ("", false),
};
let mut content = vec![
Line::from(vec![
Span::styled("Are you sure you want to delete the tag ", primary_style()),
Span::styled(tag_name, accent_style()),
Span::raw("?"),
]),
Line::from(""),
];
if is_on_remote {
content.push(Line::from(vec![
Span::styled(
"Warning: ",
Style::default().fg(WARNING()).add_modifier(Modifier::BOLD),
),
Span::raw(
"This tag is also present on the remote and will be deleted from the remote.",
),
]));
content.push(Line::from(""));
}
content.push(Line::from(vec![
Span::styled("Confirm: ", muted_style()),
Span::styled("y", accent_style().add_modifier(Modifier::BOLD)),
Span::styled(" / Cancel: ", muted_style()),
Span::styled("n", accent_style().add_modifier(Modifier::BOLD)),
]));
let paragraph = Paragraph::new(content)
.block(block)
.wrap(Wrap { trim: false });
f.render_widget(paragraph, popup_area);
}
fn draw_stash_delete_popup(f: &mut Frame, target: &Option<String>, area: Rect) {
let popup_area = centred_rect(50, 20, area);
f.render_widget(Clear, popup_area);
let border_style = Style::default().fg(DANGER());
let title = Line::from(vec![
Span::raw(" "),
Span::styled("Delete Stash", primary_style()),
Span::raw(" "),
]);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(border_style)
.title(title)
.padding(Padding::horizontal(1));
let stash_name = match target {
Some(name) => name.as_str(),
None => "",
};
let content = vec![
Line::from(vec![Span::styled(
"Are you sure you want to delete the stash:",
primary_style(),
)]),
Line::from(""),
Line::from(vec![
Span::raw(" "),
Span::styled(
stash_name,
Style::default().fg(DANGER()).add_modifier(Modifier::BOLD),
),
]),
Line::from(""),
Line::from(vec![
Span::styled("Confirm: ", muted_style()),
Span::styled("y", accent_style().add_modifier(Modifier::BOLD)),
Span::styled(" / Cancel: ", muted_style()),
Span::styled("n", accent_style().add_modifier(Modifier::BOLD)),
]),
];
let paragraph = Paragraph::new(content)
.block(block)
.wrap(Wrap { trim: false });
f.render_widget(paragraph, popup_area);
}
fn draw_stash_apply_popup(f: &mut Frame, target: &Option<String>, delete_after: bool, area: Rect) {
let popup_area = centred_rect(55, 25, area);
f.render_widget(Clear, popup_area);
let border_style = Style::default().fg(ACCENT());
let title = Line::from(vec![
Span::raw(" "),
Span::styled("Apply Stash", primary_style()),
Span::raw(" "),
]);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(border_style)
.title(title)
.padding(Padding::horizontal(1));
let stash_name = match target {
Some(name) => name.as_str(),
None => "",
};
let mut content = vec![
Line::from(vec![Span::styled(
"Are you sure you want to apply the stash:",
primary_style(),
)]),
Line::from(""),
Line::from(vec![
Span::raw(" "),
Span::styled(
stash_name,
Style::default().fg(ACCENT()).add_modifier(Modifier::BOLD),
),
]),
Line::from(""),
];
let delete_after_style = if delete_after {
Style::default().fg(SUCCESS()).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(ratatui::style::Color::DarkGray)
};
let checkbox = if delete_after { "[X]" } else { "[ ]" };
content.push(Line::from(vec![
Span::styled(format!(" {} ", checkbox), delete_after_style),
Span::styled("Delete stash after applying", primary_style()),
Span::styled(" (toggle: [d/space/a])", muted_style()),
]));
content.push(Line::from(""));
content.push(Line::from(vec![
Span::styled("Confirm: ", muted_style()),
Span::styled("y", accent_style().add_modifier(Modifier::BOLD)),
Span::styled(" / Cancel: ", muted_style()),
Span::styled("n", accent_style().add_modifier(Modifier::BOLD)),
]));
let paragraph = Paragraph::new(content)
.block(block)
.wrap(Wrap { trim: false });
f.render_widget(paragraph, popup_area);
}
fn draw_cherry_pick_popup(
f: &mut Frame,
target: &Option<(String, String)>,
current_branch: Option<&str>,
area: Rect,
) {
let popup_area = centred_rect(55, 25, area);
f.render_widget(Clear, popup_area);
let border_style = Style::default().fg(ACCENT());
let title = Line::from(vec![
Span::raw(" "),
Span::styled("Cherry-pick Commit", primary_style()),
Span::raw(" "),
]);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(border_style)
.title(title)
.padding(Padding::horizontal(1));
let (commit_oid, summary) = match target {
Some((oid, sum)) => (oid.as_str(), sum.as_str()),
None => ("", ""),
};
let current = current_branch.unwrap_or("HEAD");
let content = vec![
Line::from(vec![
Span::styled(
"Are you sure you want to cherry-pick commit ",
primary_style(),
),
Span::styled(
format!("{:.7}", commit_oid),
accent_style().add_modifier(Modifier::BOLD),
),
]),
Line::from(vec![
Span::styled("onto branch ", primary_style()),
Span::styled(
format!("'{}'", current),
accent_style().add_modifier(Modifier::BOLD),
),
Span::raw("?"),
]),
Line::from(""),
Line::from(vec![
Span::raw(" "),
Span::styled(summary, primary_style()),
]),
Line::from(""),
Line::from(vec![
Span::styled("Confirm: ", muted_style()),
Span::styled(
"y",
Style::default().fg(SUCCESS()).add_modifier(Modifier::BOLD),
),
Span::styled(" / Cancel: ", muted_style()),
Span::styled("n", accent_style().add_modifier(Modifier::BOLD)),
]),
];
let paragraph = Paragraph::new(content)
.block(block)
.wrap(Wrap { trim: false });
f.render_widget(paragraph, popup_area);
}
fn draw_revert_popup(
f: &mut Frame,
target: &Option<(String, String)>,
current_branch: Option<&str>,
area: Rect,
) {
let popup_area = centred_rect(55, 25, area);
f.render_widget(Clear, popup_area);
let border_style = Style::default().fg(ACCENT());
let title = Line::from(vec![
Span::raw(" "),
Span::styled("Revert Commit", primary_style()),
Span::raw(" "),
]);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(border_style)
.title(title)
.padding(Padding::horizontal(1));
let (commit_oid, summary) = match target {
Some((oid, sum)) => (oid.as_str(), sum.as_str()),
None => ("", ""),
};
let current = current_branch.unwrap_or("HEAD");
let content = vec![
Line::from(vec![
Span::styled("Are you sure you want to revert commit ", primary_style()),
Span::styled(
format!("{:.7}", commit_oid),
accent_style().add_modifier(Modifier::BOLD),
),
]),
Line::from(vec![
Span::styled("on branch ", primary_style()),
Span::styled(
format!("'{}'", current),
accent_style().add_modifier(Modifier::BOLD),
),
Span::raw("?"),
]),
Line::from(""),
Line::from(vec![
Span::raw(" "),
Span::styled(summary, primary_style()),
]),
Line::from(""),
Line::from(vec![
Span::styled("Confirm: ", muted_style()),
Span::styled(
"y",
Style::default().fg(SUCCESS()).add_modifier(Modifier::BOLD),
),
Span::styled(" / Cancel: ", muted_style()),
Span::styled("n", accent_style().add_modifier(Modifier::BOLD)),
]),
];
let paragraph = Paragraph::new(content)
.block(block)
.wrap(Wrap { trim: false });
f.render_widget(paragraph, popup_area);
}
fn draw_tag_push_popup(f: &mut Frame, target: &Option<String>, area: Rect) {
let popup_area = centred_rect(50, 20, area);
f.render_widget(Clear, popup_area);
let border_style = Style::default().fg(SUCCESS());
let title = Line::from(vec![
Span::raw(" "),
Span::styled("Push Tag", primary_style()),
Span::raw(" "),
]);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(border_style)
.title(title)
.padding(Padding::horizontal(1));
let tag_name = target.as_deref().unwrap_or("");
let content = vec![
Line::from(vec![
Span::styled("Are you sure you want to push the tag ", primary_style()),
Span::styled(tag_name, accent_style()),
Span::raw(" to remote?"),
]),
Line::from(""),
Line::from(vec![
Span::styled("Confirm: ", muted_style()),
Span::styled("y", accent_style().add_modifier(Modifier::BOLD)),
Span::styled(" / Cancel: ", muted_style()),
Span::styled("n", accent_style().add_modifier(Modifier::BOLD)),
]),
];
let paragraph = Paragraph::new(content)
.block(block)
.wrap(Wrap { trim: false });
f.render_widget(paragraph, popup_area);
}
fn draw_tag_push_all_popup(f: &mut Frame, area: Rect) {
let popup_area = centred_rect(50, 20, area);
f.render_widget(Clear, popup_area);
let border_style = Style::default().fg(SUCCESS());
let title = Line::from(vec![
Span::raw(" "),
Span::styled("Push All Tags", primary_style()),
Span::raw(" "),
]);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(border_style)
.title(title)
.padding(Padding::horizontal(1));
let content = vec![
Line::from(vec![
Span::styled("Are you sure you want to push ", primary_style()),
Span::styled(
"ALL local tags",
Style::default().fg(WARNING()).add_modifier(Modifier::BOLD),
),
Span::styled(" to remote?", primary_style()),
]),
Line::from(""),
Line::from(vec![
Span::styled("Confirm: ", muted_style()),
Span::styled("y", accent_style().add_modifier(Modifier::BOLD)),
Span::styled(" / Cancel: ", muted_style()),
Span::styled("n", accent_style().add_modifier(Modifier::BOLD)),
]),
];
let paragraph = Paragraph::new(content)
.block(block)
.wrap(Wrap { trim: false });
f.render_widget(paragraph, popup_area);
}
#[allow(clippy::too_many_arguments)]
fn draw_stashes_view(
f: &mut Frame,
info: &RepoInfo,
focus: DetailSection,
stash_selection: usize,
stash_file_selection: usize,
file_diff: &[DiffLine],
diff_scroll: usize,
areas: &mut DetailAreas,
stashes_horizontal_split_pct: u16,
stashes_vertical_split_pct: u16,
app: &crate::app::App,
area: Rect,
) {
areas.bottom_left = None;
areas.bottom_right = None;
areas.commits = None;
areas.local_branches = None;
areas.remote_branches = None;
areas.local_tags = None;
areas.remote_tags = None;
areas.files = None;
areas.file_content = None;
areas.remotes = None;
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(stashes_horizontal_split_pct),
Constraint::Percentage(100 - stashes_horizontal_split_pct),
])
.split(area);
let left_area = chunks[0];
let right_area = chunks[1];
areas.bottom_right = Some(right_area);
let split_col = area.x + left_area.width;
areas.stashes_horizontal_splitter = Some(Rect::new(
split_col.saturating_sub(1),
area.y,
2,
area.height,
));
let left_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(stashes_vertical_split_pct),
Constraint::Percentage(100 - stashes_vertical_split_pct),
])
.split(left_area);
let split_row = left_area.y + left_chunks[0].height;
areas.stashes_vertical_splitter = Some(Rect::new(
left_area.x,
split_row.saturating_sub(1),
left_area.width,
2,
));
areas.stashes = Some(left_chunks[0]);
areas.stashed_files = Some(left_chunks[1]);
let stashes_focused = focus == DetailSection::Stashes;
let stashes_border_style = if stashes_focused {
Style::default().fg(ACCENT())
} else {
muted_style()
};
let list_block = Block::default()
.borders(Borders::ALL)
.border_type(CARD_BORDER())
.border_style(stashes_border_style)
.title(Line::from(vec![
Span::raw(" "),
Span::styled("Stashes", primary_style()),
Span::raw(" "),
]))
.padding(Padding::uniform(1));
let list_items: Vec<ListItem> = info
.stashes
.iter()
.map(|s| {
ListItem::new(Line::from(vec![Span::styled(
format!(" stash@{{{}}}: {}", s.index, s.message),
primary_style(),
)]))
})
.collect();
let inner = list_block.inner(left_chunks[0]);
areas.stashes_inner = Some(inner);
let list = List::new(list_items)
.block(list_block)
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
let mut list_state = app.stash_list_state.borrow_mut();
if stashes_focused || !info.stashes.is_empty() {
list_state.select(Some(stash_selection));
} else {
list_state.select(None);
}
f.render_stateful_widget(list, left_chunks[0], &mut *list_state);
let files_focused = focus == DetailSection::StashedFiles;
let selected_stash = info.stashes.get(stash_selection);
let stashed_files = selected_stash.map(|s| s.files.as_slice()).unwrap_or(&[]);
let stashed_files_inner = draw_file_subpanel(
f,
"Stashed Files",
WARNING(),
stashed_files,
"No files in this stash",
Borders::ALL,
files_focused,
if files_focused || !stashed_files.is_empty() {
Some(stash_file_selection)
} else {
None
},
&app.stash_file_list_state,
left_chunks[1],
);
areas.stashed_files_inner = Some(stashed_files_inner);
let diff_focused = focus == DetailSection::StagingDetails;
let right_border_style = if diff_focused {
Style::default().fg(ACCENT())
} else {
muted_style()
};
let selected_file_name: Option<String> = stashed_files
.get(stash_file_selection)
.map(|e| e.path.clone());
let right_block = Block::default()
.borders(Borders::ALL)
.border_type(CARD_BORDER())
.border_style(right_border_style)
.title(Line::from(vec![
Span::raw(" "),
Span::styled("Stash Diff", primary_style()),
if let Some(ref name) = selected_file_name {
Span::styled(format!(" {}", name), muted_style())
} else {
Span::raw("")
},
Span::raw(" "),
]))
.padding(Padding::uniform(1));
let right_inner = right_block.inner(right_area);
f.render_widget(right_block, right_area);
if file_diff.is_empty() {
let v_center = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(45),
Constraint::Length(1),
Constraint::Min(0),
])
.split(right_inner);
f.render_widget(
Paragraph::new(Span::styled(
"Select a file to view its diff",
muted_style(),
))
.alignment(Alignment::Center),
v_center[1],
);
} else {
let diff_spans: Vec<Line> = file_diff
.iter()
.map(|line| {
let style = match line.kind {
DiffLineKind::Added => Style::default().fg(SUCCESS()),
DiffLineKind::Removed => Style::default().fg(DANGER()),
DiffLineKind::Header => Style::default().fg(ratatui::style::Color::Cyan),
DiffLineKind::Context => Style::default(),
DiffLineKind::ConflictOurs => {
Style::default().fg(ratatui::style::Color::LightRed)
}
DiffLineKind::ConflictTheirs => {
Style::default().fg(ratatui::style::Color::LightBlue)
}
DiffLineKind::ConflictSeparator => Style::default()
.fg(ratatui::style::Color::Yellow)
.add_modifier(ratatui::style::Modifier::BOLD),
};
Line::from(Span::styled(line.content.clone(), style))
})
.collect();
f.render_widget(
Paragraph::new(diff_spans)
.scroll((diff_scroll as u16, 0))
.wrap(Wrap { trim: false }),
right_inner,
);
}
}
#[allow(clippy::too_many_arguments)]
fn draw_inspect_window(
f: &mut Frame,
commit: &CommitEntry,
focus: DetailSection,
file_selection: usize,
file_diff: &[DiffLine],
diff_scroll: usize,
commit_details_scroll: usize,
areas: &mut DetailAreas,
inspect_horizontal_split_pct: u16,
inspect_vertical_split_pct: u16,
app: &crate::app::App,
area: Rect,
) {
let right_focused = focus == DetailSection::StagingDetails;
let right_inner = if app.inspect_full_diff {
areas.bottom_left = None;
areas.bottom_right = Some(area);
areas.commit_details = None;
areas.inspect_horizontal_splitter = None;
areas.inspect_vertical_splitter = None;
let right_block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(ACCENT()))
.title(Line::from(vec![
Span::raw(" "),
Span::styled("Diff", primary_style()),
if right_focused && diff_scroll > 0 {
Span::styled(format!(" ↕ line {}", diff_scroll + 1), muted_style())
} else {
Span::raw("")
},
if right_focused {
Span::styled(
format!(" {} scroll (Full Screen)", app.sym("up_down")),
muted_style(),
)
} else {
Span::raw("")
},
Span::raw(" "),
]));
let right_inner = right_block.inner(area);
f.render_widget(right_block, area);
right_inner
} else {
let panels = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(inspect_horizontal_split_pct),
Constraint::Percentage(100 - inspect_horizontal_split_pct),
])
.split(area);
let split_col = area.x + panels[0].width;
areas.inspect_horizontal_splitter = Some(Rect::new(
split_col.saturating_sub(1),
area.y,
2,
area.height,
));
let left_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(inspect_vertical_split_pct),
Constraint::Percentage(100 - inspect_vertical_split_pct),
])
.split(panels[0]);
let split_row = panels[0].y + left_chunks[0].height;
areas.inspect_vertical_splitter = Some(Rect::new(
panels[0].x,
split_row.saturating_sub(1),
panels[0].width,
2,
));
areas.commit_details = Some(left_chunks[0]);
areas.bottom_left = Some(left_chunks[1]);
areas.bottom_right = Some(panels[1]);
let details_focused = focus == DetailSection::CommitDetails;
let left_focused = focus == DetailSection::Staged;
draw_commit_details_widget(
f,
commit,
details_focused,
commit_details_scroll,
left_chunks[0],
);
let left_border_style = if left_focused {
Style::default().fg(ACCENT())
} else {
muted_style()
};
let left_block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(left_border_style)
.title(Line::from(vec![
Span::raw(" "),
Span::styled("Changed Files", primary_style()),
Span::raw(" "),
Span::styled(format!("({})", commit.files.len()), muted_style()),
Span::raw(" "),
]));
let left_inner = left_block.inner(left_chunks[1]);
f.render_widget(left_block, left_chunks[1]);
if commit.files.is_empty() {
let v = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(40),
Constraint::Length(1),
Constraint::Min(0),
])
.split(left_inner);
f.render_widget(
Paragraph::new(Span::styled("No files changed", muted_style()))
.alignment(Alignment::Center),
v[1],
);
} else {
let items: Vec<ListItem> = commit
.files
.iter()
.map(|f| ListItem::new(file_entry_line(f)))
.collect();
let list =
List::new(items).highlight_style(Style::default().add_modifier(Modifier::REVERSED));
let mut state = ListState::default();
if left_focused {
state.select(Some(file_selection));
} else {
state.select(None);
}
f.render_stateful_widget(list, left_inner, &mut state);
}
let right_border_style = if right_focused {
Style::default().fg(ACCENT())
} else {
muted_style()
};
let right_block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(right_border_style)
.title(Line::from(vec![
Span::raw(" "),
Span::styled("Diff", primary_style()),
if right_focused && diff_scroll > 0 {
Span::styled(format!(" ↕ line {}", diff_scroll + 1), muted_style())
} else {
Span::raw("")
},
if right_focused {
Span::styled(format!(" {} scroll", app.sym("up_down")), muted_style())
} else {
Span::raw("")
},
Span::raw(" "),
]));
let right_inner = right_block.inner(panels[1]);
f.render_widget(right_block, panels[1]);
right_inner
};
if file_diff.is_empty() {
let v_center = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(45),
Constraint::Length(1),
Constraint::Min(0),
])
.split(right_inner);
f.render_widget(
Paragraph::new(Span::styled(
"Select a file to view its diff",
muted_style(),
))
.alignment(Alignment::Center),
v_center[1],
);
} else {
let diff_spans: Vec<Line> = file_diff
.iter()
.map(|line| {
let style = match line.kind {
DiffLineKind::Added => Style::default().fg(SUCCESS()),
DiffLineKind::Removed => Style::default().fg(DANGER()),
DiffLineKind::Header => Style::default().fg(ratatui::style::Color::Cyan),
DiffLineKind::Context => Style::default(),
DiffLineKind::ConflictOurs => {
Style::default().fg(ratatui::style::Color::LightRed)
}
DiffLineKind::ConflictTheirs => {
Style::default().fg(ratatui::style::Color::LightBlue)
}
DiffLineKind::ConflictSeparator => Style::default()
.fg(ratatui::style::Color::Yellow)
.add_modifier(ratatui::style::Modifier::BOLD),
};
Line::from(Span::styled(line.content.clone(), style))
})
.collect();
f.render_widget(
Paragraph::new(diff_spans)
.scroll((diff_scroll as u16, 0))
.wrap(Wrap { trim: false }),
right_inner,
);
}
}
fn draw_commit_details_widget(
f: &mut Frame,
commit: &CommitEntry,
focused: bool,
scroll: usize,
area: Rect,
) {
let border_style = if focused {
Style::default().fg(ACCENT())
} else {
muted_style()
};
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(border_style)
.title(Line::from(vec![
Span::raw(" "),
Span::styled("Commit Info", primary_style()),
if focused && scroll > 0 {
Span::styled(format!(" ↕ line {}", scroll + 1), muted_style())
} else {
Span::raw("")
},
if focused {
Span::styled(" ↑↓ scroll", muted_style())
} else {
Span::raw("")
},
Span::raw(" "),
]))
.padding(Padding::horizontal(1));
let inner = block.inner(area);
f.render_widget(block, area);
let mut lines = Vec::new();
lines.push(Line::from(vec![
Span::styled("Hash: ", primary_style()),
Span::raw(&commit.oid),
]));
lines.push(Line::from(vec![
Span::styled("Author: ", primary_style()),
Span::raw(&commit.author),
]));
lines.push(Line::from(vec![
Span::styled("Date: ", primary_style()),
Span::raw(format!("{} ({})", commit.date, commit.when)),
]));
if !commit.refs.is_empty() {
let mut ref_spans = vec![Span::styled("Refs: ", primary_style())];
for (idx, r) in commit.refs.iter().enumerate() {
if idx > 0 {
ref_spans.push(Span::raw(", "));
}
let style = if r.starts_with("tag:") {
Style::default()
.fg(ratatui::style::Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(ACCENT()).add_modifier(Modifier::BOLD)
};
ref_spans.push(Span::styled(r.clone(), style));
}
lines.push(Line::from(ref_spans));
}
lines.push(Line::from(""));
lines.push(Line::from(vec![Span::styled("Message:", primary_style())]));
for line in commit.message.lines() {
lines.push(Line::from(vec![Span::raw(line.to_string())]));
}
let paragraph = Paragraph::new(lines)
.scroll((scroll as u16, 0))
.wrap(Wrap { trim: true });
f.render_widget(paragraph, inner);
}
fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
let vertical = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(area);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(vertical[1])[1]
}
fn draw_search_column_picker(f: &mut Frame, app: &crate::app::App, area: Rect) {
let popup_area = centered_rect(50, 30, area);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(ACCENT()))
.title(Line::from(vec![
Span::raw(" "),
Span::styled("Search Columns Selector", primary_style()),
Span::raw(" "),
]));
let inner = block.inner(popup_area);
f.render_widget(Clear, popup_area);
f.render_widget(block, popup_area);
let vertical_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(4), Constraint::Min(0), ])
.split(inner);
let columns = [
("SHA", app.search_columns_sha),
("Message", app.search_columns_message),
("Author", app.search_columns_author),
("Date", app.search_columns_date),
];
let mut lines = Vec::new();
for (idx, (name, enabled)) in columns.iter().enumerate() {
let is_selected = idx == app.search_column_selection;
let checkbox = if *enabled { "[x]" } else { "[ ]" };
let checkbox_span = if *enabled {
Span::styled(
checkbox,
Style::default().fg(SUCCESS()).add_modifier(Modifier::BOLD),
)
} else {
Span::styled(checkbox, muted_style())
};
let style = if is_selected {
Style::default().fg(ACCENT()).add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let select_indicator = if is_selected { "▸ " } else { " " };
lines.push(Line::from(vec![
Span::styled(select_indicator, style),
checkbox_span,
Span::raw(" "),
Span::styled(name.to_string(), style),
]));
}
f.render_widget(Paragraph::new(lines), vertical_chunks[1]);
let instructions = Line::from(vec![
Span::styled(" [Space]", accent_style()),
Span::raw(" Toggle "),
Span::styled("[Enter]", accent_style()),
Span::raw(" Confirm "),
Span::styled("[Esc]", accent_style()),
Span::raw(" Cancel "),
]);
f.render_widget(
Paragraph::new(instructions).alignment(Alignment::Center),
vertical_chunks[2],
);
}
fn draw_logs_view(
f: &mut Frame,
info: &RepoInfo,
commit_selection: usize,
_commit_search_query: &Option<String>,
app: &crate::app::App,
area: Rect,
) {
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(ACCENT()))
.title(Line::from(vec![
Span::raw(" "),
Span::styled("Git Commits Logs", primary_style()),
Span::raw(" "),
]));
let inner = block.inner(area);
f.render_widget(block, area);
if info.commits.is_empty() {
f.render_widget(
Paragraph::new(Span::styled("No commits yet", muted_style()))
.alignment(Alignment::Center),
inner,
);
return;
}
let header = Row::new(vec![
Cell::from("ID"),
Cell::from("Author"),
Cell::from("Date"),
Cell::from("Summary"),
])
.style(Style::default().add_modifier(Modifier::BOLD).fg(ACCENT()));
let mut rows: Vec<Row> = Vec::new();
for (i, commit) in info.commits.iter().enumerate() {
let is_selected = i == commit_selection;
let is_match = app.commit_matches_query(commit);
let mut spans: Vec<Span<'static>> = Vec::new();
if is_match {
spans.push(Span::styled(
format!("{} ", app.sym("star")),
Style::default().fg(SUCCESS()).add_modifier(Modifier::BOLD),
));
}
for r in &commit.refs {
let (label, style) = if let Some(tag) = r.strip_prefix("tag:") {
(
format!("[{}]", tag),
Style::default().fg(WARNING()).add_modifier(Modifier::BOLD),
)
} else if let Some(remote) = r.strip_prefix("remote:") {
(
format!("[{}]", remote),
Style::default().fg(SUCCESS()).add_modifier(Modifier::BOLD),
)
} else {
(
format!("[{}]", r),
Style::default().fg(ACCENT()).add_modifier(Modifier::BOLD),
)
};
spans.push(Span::styled(label, style));
spans.push(Span::raw(" "));
}
let summary_style = if is_match {
Style::default().fg(SUCCESS()).add_modifier(Modifier::BOLD)
} else {
Style::default()
};
spans.push(Span::styled(commit.summary.clone(), summary_style));
let mut row_style = Style::default();
if is_selected {
row_style = row_style.bg(ratatui::style::Color::Rgb(60, 60, 60));
}
rows.push(
Row::new(vec![
Cell::from(Span::styled(
commit.id.clone(),
if is_match {
Style::default().fg(SUCCESS()).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(WARNING())
},
)),
Cell::from(Span::styled(
commit.author.clone(),
if is_match {
Style::default().fg(SUCCESS()).add_modifier(Modifier::BOLD)
} else {
Style::default()
},
)),
Cell::from(Span::styled(
commit.when.clone(),
if is_match {
Style::default().fg(SUCCESS()).add_modifier(Modifier::BOLD)
} else {
muted_style()
},
)),
Cell::from(Line::from(spans)),
])
.style(row_style),
);
}
let widths = [
Constraint::Length(9),
Constraint::Length(18),
Constraint::Length(16),
Constraint::Min(20),
];
let mut state = TableState::default();
state.select(Some(commit_selection));
let table = Table::new(rows, widths).header(header).column_spacing(2);
f.render_stateful_widget(table, inner, &mut state);
}
fn draw_remote_add_name_popup(f: &mut Frame, input_buffer: &str, area: Rect) {
let popup_area = centred_rect(50, 20, area);
f.render_widget(Clear, popup_area);
let border_style = Style::default().fg(ACCENT());
let title = Line::from(vec![
Span::raw(" "),
Span::styled("Add Remote (Step 1/2)", primary_style()),
Span::raw(" "),
]);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(border_style)
.title(title)
.padding(Padding::horizontal(1));
let content = vec![
Line::from(vec![Span::styled("Remote Name: ", muted_style())]),
Line::from(""),
Line::from(vec![Span::styled(
input_buffer,
primary_style().add_modifier(Modifier::BOLD),
)]),
];
let inner_area = block.inner(popup_area);
f.render_widget(block, popup_area);
let paragraph = Paragraph::new(content);
f.render_widget(paragraph, inner_area);
let cursor_y = inner_area.y.saturating_add(2).min(
inner_area
.y
.saturating_add(inner_area.height.saturating_sub(1)),
);
let cursor_offset = input_buffer.chars().count() as u16;
let cursor_x = inner_area.x.saturating_add(cursor_offset).min(
inner_area
.x
.saturating_add(inner_area.width.saturating_sub(1)),
);
f.set_cursor_position(ratatui::layout::Position::new(cursor_x, cursor_y));
}
fn draw_remote_add_url_popup(f: &mut Frame, remote_name: &str, input_buffer: &str, area: Rect) {
let popup_area = centred_rect(50, 20, area);
f.render_widget(Clear, popup_area);
let border_style = Style::default().fg(ACCENT());
let title = Line::from(vec![
Span::raw(" "),
Span::styled("Add Remote (Step 2/2)", primary_style()),
Span::raw(" "),
]);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(border_style)
.title(title)
.padding(Padding::horizontal(1));
let content = vec![
Line::from(vec![
Span::styled("Remote Name: ", muted_style()),
Span::styled(remote_name, primary_style().add_modifier(Modifier::BOLD)),
]),
Line::from(""),
Line::from(vec![Span::styled("Remote URL: ", muted_style())]),
Line::from(""),
Line::from(vec![Span::styled(
input_buffer,
primary_style().add_modifier(Modifier::BOLD),
)]),
];
let inner_area = block.inner(popup_area);
f.render_widget(block, popup_area);
let paragraph = Paragraph::new(content);
f.render_widget(paragraph, inner_area);
let cursor_y = inner_area.y.saturating_add(4).min(
inner_area
.y
.saturating_add(inner_area.height.saturating_sub(1)),
);
let cursor_offset = input_buffer.chars().count() as u16;
let cursor_x = inner_area.x.saturating_add(cursor_offset).min(
inner_area
.x
.saturating_add(inner_area.width.saturating_sub(1)),
);
f.set_cursor_position(ratatui::layout::Position::new(cursor_x, cursor_y));
}
fn draw_remote_delete_popup(f: &mut Frame, remote_name: &str, area: Rect) {
let popup_area = centred_rect(50, 15, area);
f.render_widget(Clear, popup_area);
let border_style = Style::default().fg(DANGER());
let title = Line::from(vec![
Span::raw(" "),
Span::styled("Remove Remote", primary_style()),
Span::raw(" "),
]);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(border_style)
.title(title)
.padding(Padding::horizontal(1));
let content = vec![
Line::from(vec![
Span::raw("Are you sure you want to remove remote "),
Span::styled(
remote_name,
Style::default().fg(DANGER()).add_modifier(Modifier::BOLD),
),
Span::raw("?"),
]),
Line::from(""),
Line::from(vec![
Span::styled("All remote-tracking branches for ", muted_style()),
Span::styled(remote_name, primary_style()),
Span::styled(" will be deleted.", muted_style()),
]),
Line::from(""),
Line::from(vec![Span::styled(
"Confirm: [y] Cancel: [n/Esc]",
muted_style(),
)]),
];
let inner_area = block.inner(popup_area);
f.render_widget(block, popup_area);
let paragraph = Paragraph::new(content);
f.render_widget(paragraph, inner_area);
}