use std::time::{SystemTime, UNIX_EPOCH};
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Clear, Paragraph};
use crate::app::App;
use crate::git::log::{Commit, LANE_COLORS, RefKind};
use crate::layout::PaneId;
use crate::pane::Pane;
use crate::ui::theme::{self, Theme};
const SHA_RIGHT_PAD: usize = 2;
struct ColWidths {
branch: usize,
author: usize,
age: usize,
sha: usize,
}
pub fn draw(
frame: &mut Frame,
app: &mut App,
pane_id: PaneId,
area: Rect,
_focused: bool,
) -> Option<(u16, u16)> {
if area.width == 0 || area.height == 0 {
return None;
}
let t = theme::cur();
frame.render_widget(
Paragraph::new("").style(Style::default().bg(t.bg_dark)),
area,
);
app.rects.editor_panes.push((area, pane_id));
let wip_snapshot = app.git.snapshot().clone();
let branch_col_override = app.config.ui.git_graph_branch_col;
let author_col_override = app.config.ui.git_graph_author_col;
let detail_w_cfg = app
.git_graph_detail_col_override
.map(|n| n as usize)
.or(app.config.ui.git_graph_detail_col);
let Some(Pane::GitGraph(g)) = app.panes.get_mut(pane_id) else {
return None;
};
if g.total_rows() == 0 {
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
" (no commits — not a git repo, or empty history)",
Style::default().fg(t.comment).bg(t.bg_dark),
))),
area,
);
return None;
}
g.selected = g.selected.min(g.total_rows() - 1);
let toolbar_h: u16 = if area.width >= 40 && area.height >= 6 {
1
} else {
0
};
let nerd_icons = !app.config.ui.ascii_icons;
if toolbar_h > 0 {
let toolbar_area = Rect::new(area.x, area.y, area.width, toolbar_h);
draw_git_toolbar(
frame,
toolbar_area,
&t,
pane_id,
nerd_icons,
&mut app.rects.git_toolbar_buttons,
);
}
let body_area_full = Rect::new(
area.x,
area.y + toolbar_h,
area.width,
area.height.saturating_sub(toolbar_h),
);
let detail_w: u16 = if body_area_full.width >= 80 {
if let Some(w) = detail_w_cfg {
(w as u16).clamp(20, body_area_full.width.saturating_sub(40))
} else {
(body_area_full.width / 3).clamp(28, 60)
}
} else {
0
};
let (list_area, detail_area) = if detail_w > 0 {
(
Rect::new(
body_area_full.x,
body_area_full.y,
body_area_full.width - detail_w - 1,
body_area_full.height,
),
Some(Rect::new(
body_area_full.x + body_area_full.width - detail_w,
body_area_full.y,
detail_w,
body_area_full.height,
)),
)
} else {
(body_area_full, None)
};
if detail_w > 0 {
let divider_x = body_area_full.x + body_area_full.width - detail_w - 1;
for row in 0..body_area_full.height {
let abs_y = body_area_full.y + row;
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
"│",
Style::default().fg(t.grey).bg(t.bg_dark),
))),
Rect::new(divider_x, abs_y, 1, 1),
);
}
app.rects.git_graph_detail_dividers.push((
Rect::new(divider_x, body_area_full.y, 1, body_area_full.height),
pane_id,
));
}
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let header_area = Rect::new(list_area.x, list_area.y, list_area.width, 1);
let body_area = Rect::new(
list_area.x,
list_area.y + 1,
list_area.width,
list_area.height.saturating_sub(1),
);
let lane_pad: usize = app.config.git_graph.lane_spacing as usize;
let h = body_area.height as usize;
if g.selected < g.scroll {
g.scroll = g.selected;
} else if g.selected >= g.scroll + h {
g.scroll = g.selected + 1 - h;
}
let total = g.total_rows();
let max_scroll = total.saturating_sub(h.min(total));
g.scroll = g.scroll.min(max_scroll);
let has_wip_offset = usize::from(g.has_wip);
let mut visible: Vec<(usize, Option<usize>)> = Vec::with_capacity(h);
for v_idx in g.scroll..g.scroll + h.min(total - g.scroll) {
let commit_idx = if g.has_wip && v_idx == 0 {
None
} else {
Some(v_idx - has_wip_offset)
};
visible.push((v_idx, commit_idx));
}
let graph_w = visible
.iter()
.filter_map(|(_, c_idx)| c_idx.and_then(|i| g.commits.get(i)))
.map(|c| c.graph.len())
.max()
.unwrap_or(0)
.min(24);
let auto_branch_w = visible
.iter()
.filter_map(|(_, c_idx)| c_idx.and_then(|i| g.commits.get(i)))
.map(|c| chip_width_for_refs(&c.refs))
.max()
.unwrap_or(0);
let auto_author_w = visible
.iter()
.filter_map(|(_, c_idx)| c_idx.and_then(|i| g.commits.get(i)))
.map(|c| c.author.chars().count())
.max()
.unwrap_or(0);
let cols = compute_column_widths(
(body_area.width as usize).saturating_sub(SHA_RIGHT_PAD),
graph_w,
ColAutoSize {
branch_chars: auto_branch_w,
author_chars: auto_author_w,
branch_override: branch_col_override,
author_override: author_col_override,
},
);
let mut chips: Vec<String> = Vec::new();
if let Some(b) = &g.filter.branch {
chips.push(format!("⎇ {b}"));
}
if let Some(a) = &g.filter.author {
chips.push(format!("@{a}"));
}
if let Some(grep) = &g.filter.grep {
chips.push(format!("~{grep}"));
}
match (&g.filter.since, &g.filter.until) {
(Some(s), Some(u)) => chips.push(format!("{s}..{u}")),
(Some(s), None) => chips.push(format!("since {s}")),
(None, Some(u)) => chips.push(format!("until {u}")),
_ => {}
}
let filter_label = if chips.is_empty() {
None
} else {
Some(format!("{} · F clears", chips.join(" · ")))
};
let sort = g.sort;
let mut header_clickables: Vec<(Rect, crate::git::graph::SortColumn)> = Vec::new();
draw_header(
frame,
header_area,
&t,
&cols,
graph_w,
&g.hash_filter,
filter_label.as_deref(),
sort,
&mut header_clickables,
);
app.rects.git_graph_column_headers = header_clickables;
let mut rows: Vec<Line> = Vec::with_capacity(h);
let mut row_recordings: Vec<(u16, usize)> = Vec::with_capacity(h);
let wip_lane_clr = t.yellow;
for (v_idx, c_idx) in &visible {
row_recordings.push(((v_idx - g.scroll) as u16, *v_idx));
let selected = *v_idx == g.selected;
let row_bg = if selected { t.bg2 } else { t.bg_dark };
if c_idx.is_none() {
let mut spans: Vec<Span> = Vec::new();
spans.push(Span::styled(
"▌",
Style::default().fg(wip_lane_clr).bg(row_bg),
));
spans.push(Span::styled(
if selected { "▶ " } else { " " },
Style::default().fg(t.yellow).bg(row_bg),
));
let sep_style = Style::default().fg(t.line).bg(row_bg);
let body_sep = |spans: &mut Vec<Span>| {
spans.push(Span::styled(" │ ", sep_style));
};
if cols.branch > 0 {
let label = format!("WIP @ {}", wip_snapshot.branch.as_deref().unwrap_or("…"));
spans.push(Span::styled(
pad_or_truncate(&label, cols.branch),
Style::default()
.fg(wip_lane_clr)
.bg(row_bg)
.add_modifier(Modifier::BOLD),
));
body_sep(&mut spans);
} else {
spans.push(Span::styled(" ", Style::default().bg(row_bg)));
}
let wip_graph_col_w = graph_w + lane_pad * graph_w.saturating_sub(1);
spans.push(Span::styled(
" ".repeat(wip_graph_col_w),
Style::default().bg(row_bg),
));
body_sep(&mut spans);
let branch_section = if cols.branch > 0 { cols.branch + 3 } else { 2 };
let fixed_used = 1
+ 2
+ branch_section
+ wip_graph_col_w
+ 3
+ (if cols.author > 0 { cols.author + 3 } else { 0 })
+ (if cols.age > 0 { cols.age + 3 } else { 0 })
+ (if cols.sha > 0 { cols.sha + 3 } else { 0 })
+ SHA_RIGHT_PAD;
let subject_w = (body_area.width as usize).saturating_sub(fixed_used);
let summary = format_wip_summary(&wip_snapshot);
let subject = pad_or_truncate(&summary, subject_w);
spans.push(Span::styled(
subject,
Style::default()
.fg(wip_lane_clr)
.bg(row_bg)
.add_modifier(Modifier::ITALIC),
));
if cols.author > 0 {
body_sep(&mut spans);
spans.push(Span::styled(
" ".repeat(cols.author),
Style::default().bg(row_bg),
));
}
if cols.age > 0 {
body_sep(&mut spans);
spans.push(Span::styled(
" ".repeat(cols.age),
Style::default().bg(row_bg),
));
}
if cols.sha > 0 {
body_sep(&mut spans);
spans.push(Span::styled(
" ".repeat(cols.sha),
Style::default().bg(row_bg),
));
}
if SHA_RIGHT_PAD > 0 {
spans.push(Span::styled(
" ".repeat(SHA_RIGHT_PAD),
Style::default().bg(row_bg),
));
}
rows.push(Line::from(spans));
continue;
}
let commit_idx = c_idx.unwrap();
let Some(c) = g.commits.get(commit_idx) else {
continue;
};
let lane_clr = lane_color(&t, (c.lane % LANE_COLORS) as u8);
let mut spans: Vec<Span> = Vec::new();
spans.push(Span::styled("▌", Style::default().fg(lane_clr).bg(row_bg)));
spans.push(Span::styled(
if selected { "▶ " } else { " " },
Style::default().fg(t.yellow).bg(row_bg),
));
let sep_style = Style::default().fg(t.line).bg(row_bg);
let body_sep = |spans: &mut Vec<Span>| {
spans.push(Span::styled(" │ ", sep_style));
};
if cols.branch > 0 {
render_branch_chips(&mut spans, &c.refs, cols.branch, row_bg, &t);
body_sep(&mut spans);
} else {
spans.push(Span::styled(" ", Style::default().bg(row_bg)));
}
let opens_right = |ch: char| matches!(ch, '╭' | '╰' | '─' | '┼');
let opens_left = |ch: char| matches!(ch, '╮' | '╯' | '─' | '┼');
let mut graph_col_x = body_area.x
+ spans
.iter()
.map(|s| s.content.chars().count() as u16)
.sum::<u16>();
for k in 0..graph_w {
let cell = c.graph.get(k);
if let Some(cell) = cell {
spans.push(Span::styled(
cell.ch.to_string(),
Style::default().fg(lane_color(&t, cell.color)).bg(row_bg),
));
} else {
spans.push(Span::styled(" ", Style::default().bg(row_bg)));
}
if let Some(commit_idx) = c_idx
&& cell.is_some_and(|c| c.ch != ' ')
{
let screen_y = body_area.y + (v_idx - g.scroll) as u16;
app.rects.git_graph_lane_cells.push((
ratatui::layout::Rect {
x: graph_col_x,
y: screen_y,
width: 1,
height: 1,
},
pane_id,
*commit_idx,
k,
));
}
graph_col_x = graph_col_x.saturating_add(1);
if lane_pad > 0 && k + 1 < graph_w {
let cur_opens_r = cell.is_some_and(|c| opens_right(c.ch));
let next = c.graph.get(k + 1);
let next_opens_l = next.is_some_and(|c| opens_left(c.ch));
let (pad_ch, pad_color) = if cur_opens_r || next_opens_l {
let color = if cur_opens_r {
cell.map(|c| c.color).unwrap_or(0)
} else {
next.map(|c| c.color).unwrap_or(0)
};
('─', Some(color))
} else {
(' ', None)
};
let mut style = Style::default().bg(row_bg);
if let Some(cl) = pad_color {
style = style.fg(lane_color(&t, cl));
}
spans.push(Span::styled(pad_ch.to_string().repeat(lane_pad), style));
graph_col_x = graph_col_x.saturating_add(lane_pad as u16);
}
}
body_sep(&mut spans);
let branch_section = if cols.branch > 0 { cols.branch + 3 } else { 2 };
let graph_col_w = graph_w + lane_pad * graph_w.saturating_sub(1);
let prefix_used = 1 + 2 + branch_section + graph_col_w + 3;
let suffix_used = (if cols.author > 0 { cols.author + 3 } else { 0 })
+ (if cols.age > 0 { cols.age + 3 } else { 0 })
+ (if cols.sha > 0 { cols.sha + 3 } else { 0 })
+ SHA_RIGHT_PAD;
let fixed_used = prefix_used + suffix_used;
let subject_w = (body_area.width as usize).saturating_sub(fixed_used);
let subject = pad_or_truncate(&c.subject, subject_w);
if let Some(commit_idx) = c_idx {
let subject_x = body_area.x + prefix_used as u16;
let screen_y = body_area.y + (v_idx - g.scroll) as u16;
app.rects.git_graph_subject_cells.push((
ratatui::layout::Rect {
x: subject_x,
y: screen_y,
width: subject_w as u16,
height: 1,
},
pane_id,
*commit_idx,
));
}
spans.push(Span::styled(subject, Style::default().fg(t.fg).bg(row_bg)));
if cols.author > 0 {
body_sep(&mut spans);
let author = right_align(&c.author, cols.author);
spans.push(Span::styled(
author,
Style::default().fg(t.comment).bg(row_bg),
));
}
if cols.age > 0 {
body_sep(&mut spans);
let age = right_align(&format_commit_datetime(c.time), cols.age);
spans.push(Span::styled(age, Style::default().fg(t.comment).bg(row_bg)));
}
if cols.sha > 0 {
body_sep(&mut spans);
let sha = right_align(&c.short, cols.sha);
spans.push(Span::styled(sha, Style::default().fg(t.orange).bg(row_bg)));
}
if SHA_RIGHT_PAD > 0 {
spans.push(Span::styled(
" ".repeat(SHA_RIGHT_PAD),
Style::default().bg(row_bg),
));
}
rows.push(Line::from(spans));
}
frame.render_widget(
Paragraph::new(rows).style(Style::default().bg(t.bg_dark)),
body_area,
);
let suppress_commit_clicks = g.embedded_diff.is_some();
for (visible_y, v_idx) in row_recordings {
if suppress_commit_clicks {
continue;
}
let screen_y = body_area.y.saturating_add(visible_y);
if screen_y < body_area.y.saturating_add(body_area.height) {
app.rects.list_rows.push((
ratatui::layout::Rect {
x: body_area.x,
y: screen_y,
width: body_area.width,
height: 1,
},
pane_id,
v_idx,
));
}
}
let mut textarea_cursor: Option<(u16, u16)> = None;
if let Some(da) = detail_area {
if g.is_wip_selected() {
let workspace = g.workspace.clone();
let commit = g.wip_commit.clone();
textarea_cursor = draw_wip_detail(
frame,
da,
&t,
&wip_snapshot,
&workspace,
pane_id,
&commit,
&mut app.rects.wip_buttons,
&mut app.rects.wip_file_rows,
&mut app.rects.wip_commit_textarea,
);
} else if let (Some(c), Some(detail)) = (g.selected_commit(), g.detail.as_ref()) {
draw_detail(
frame,
da,
&t,
c,
detail,
now,
pane_id,
&mut app.rects.commit_file_rows,
);
}
}
if g.embedded_diff.is_none() && body_area.width >= 8 && body_area.height > 0 {
let bar_x = body_area.x + body_area.width - 1;
let bar_area = Rect::new(bar_x, body_area.y, 1, body_area.height);
let cells = bar_area.height as usize;
let total = g.total_rows();
for cy in 0..cells {
let cell = Rect::new(bar_area.x, bar_area.y + cy as u16, 1, 1);
frame.render_widget(Paragraph::new(" ").style(Style::default().bg(t.bg2)), cell);
}
if total > cells && cells > 0 {
let thumb_h = ((cells * cells) / total).max(1);
let max_scroll = total - cells;
let max_thumb_top = cells.saturating_sub(thumb_h);
let thumb_top = (g.scroll * max_thumb_top)
.checked_div(max_scroll)
.unwrap_or(0);
for cy in thumb_top..(thumb_top + thumb_h).min(cells) {
let cell = Rect::new(bar_area.x, bar_area.y + cy as u16, 1, 1);
frame.render_widget(
Paragraph::new(" ").style(Style::default().bg(t.comment)),
cell,
);
}
}
app.rects.scrollbars.push(crate::app::ScrollbarHit {
area: bar_area,
pane_id,
total,
viewport: cells,
kind: crate::app::ScrollbarKind::GitGraphCommits,
});
}
let has_embedded =
matches!(app.panes.get(pane_id), Some(Pane::GitGraph(g)) if g.embedded_diff.is_some());
if has_embedded {
let needs_full = matches!(
app.panes.get(pane_id),
Some(Pane::GitGraph(g)) if g.embedded_diff.as_ref().map(|d| {
matches!(
d.view_mode,
crate::pane::DiffViewMode::Split | crate::pane::DiffViewMode::Inline
) && d.full_hunks.is_none()
}).unwrap_or(false)
);
if needs_full {
let scope = match app.panes.get(pane_id) {
Some(Pane::GitGraph(g)) => g.embedded_diff.as_ref().map(|d| d.scope.clone()),
_ => None,
};
if let Some(scope) = scope {
let full = app.fetch_diff_full(&scope);
if let Some(Pane::GitGraph(g)) = app.panes.get_mut(pane_id)
&& let Some(d) = g.embedded_diff.as_mut()
{
d.full_hunks = Some(full);
}
}
}
frame.render_widget(Clear, list_area);
frame.render_widget(
Paragraph::new("").style(Style::default().bg(t.bg_dark)),
list_area,
);
let diff_tb_h: u16 = if list_area.height >= 4 { 1 } else { 0 };
if diff_tb_h > 0
&& let Some(Pane::GitGraph(g)) = app.panes.get(pane_id)
&& let Some(d) = g.embedded_diff.as_ref()
{
let (view_mode, wrap_on) = (d.view_mode, d.wrap);
crate::ui::diff_view::draw_diff_toolbar(
frame,
Rect::new(list_area.x, list_area.y, list_area.width, diff_tb_h),
&t,
pane_id,
view_mode,
wrap_on,
&mut app.rects.diff_toolbar_buttons,
);
}
let diff_body = Rect::new(
list_area.x,
list_area.y + diff_tb_h,
list_area.width,
list_area.height.saturating_sub(diff_tb_h),
);
let expand_indicator = app.config.ui.expand_indicator.clone();
let rects = &mut app.rects;
if let Some(Pane::GitGraph(g)) = app.panes.get_mut(pane_id)
&& let Some(d) = g.embedded_diff.as_mut()
{
d.cursor = d.cursor.min(d.hunks.len().saturating_sub(1));
let kind = crate::app::ScrollbarKind::EmbeddedDiff;
match d.view_mode {
crate::pane::DiffViewMode::Inline => crate::ui::diff_view::render_inline(
frame,
d,
&t,
diff_body,
&mut rects.list_rows,
&mut rects.scrollbars,
&mut rects.diff_hunk_buttons,
kind,
pane_id,
),
crate::pane::DiffViewMode::Hunk => crate::ui::diff_view::render_hunk(
frame,
d,
&t,
diff_body,
&mut rects.list_rows,
&mut rects.scrollbars,
&mut rects.diff_hunk_buttons,
kind,
pane_id,
&expand_indicator,
),
crate::pane::DiffViewMode::Split => crate::ui::diff_view::render_split(
frame,
d,
&t,
diff_body,
&mut rects.list_rows,
&mut rects.scrollbars,
&mut rects.diff_hunk_buttons,
kind,
pane_id,
),
}
}
}
textarea_cursor
}
#[derive(Debug, Clone, Copy)]
struct ColAutoSize {
branch_chars: usize,
author_chars: usize,
branch_override: Option<usize>,
author_override: Option<usize>,
}
#[allow(clippy::too_many_arguments)]
fn draw_detail(
frame: &mut Frame,
area: Rect,
t: &Theme,
c: &Commit,
detail: &crate::git::graph::CommitDetail,
now: i64,
pane_id: PaneId,
file_rows_out: &mut Vec<(Rect, PaneId, usize)>,
) {
frame.render_widget(Paragraph::new("").style(Style::default().bg(t.bg)), area);
let w = area.width as usize;
let mut lines: Vec<Line> = Vec::new();
let mut pending_file_rows: Vec<(usize, usize, usize, usize)> = Vec::new();
let head = format!(
" {} · {} · {} ",
c.short,
c.author,
humanize_age(now - c.time)
);
let dashes = w.saturating_sub(head.chars().count() + 1);
lines.push(Line::from(vec![
Span::styled("─", Style::default().fg(t.line).bg(t.bg)),
Span::styled(head, Style::default().fg(t.orange).bg(t.bg)),
Span::styled("─".repeat(dashes), Style::default().fg(t.line).bg(t.bg)),
]));
for raw in detail.message.lines() {
lines.push(Line::from(Span::styled(
format!(" {raw}"),
Style::default().fg(t.fg).bg(t.bg),
)));
}
if !c.parents.is_empty() {
lines.push(Line::from(Span::styled(
format!(
" parents: {}",
c.parents
.iter()
.map(|p| p.chars().take(9).collect::<String>())
.collect::<Vec<_>>()
.join(" ")
),
Style::default().fg(t.comment).bg(t.bg),
)));
}
lines.push(Line::from(Span::styled(" ", Style::default().bg(t.bg))));
let total = detail.files.len();
lines.push(Line::from(Span::styled(
format!(" changed files ({total}):"),
Style::default()
.fg(t.comment)
.bg(t.bg)
.add_modifier(Modifier::BOLD),
)));
let avail = (area.height as usize).saturating_sub(lines.len() + 1);
let shown = total.min(avail.saturating_sub(1));
for (idx, (status, path)) in detail.files.iter().take(shown).enumerate() {
let letter = status.chars().next().unwrap_or('?');
let color = match letter {
'A' => t.green,
'M' => t.yellow,
'D' => t.red,
'R' => t.blue,
'C' => t.cyan,
_ => t.comment,
};
let prefix = format!(" {letter} ");
let prefix_chars = prefix.chars().count();
let row_chars = prefix_chars + path.chars().count();
lines.push(Line::from(vec![
Span::styled(prefix, Style::default().fg(color).bg(t.bg)),
Span::styled(path.clone(), Style::default().fg(t.fg).bg(t.bg)),
Span::styled(
" ".repeat(w.saturating_sub(row_chars)),
Style::default().bg(t.bg),
),
]));
pending_file_rows.push((lines.len() - 1, 0, w, idx));
}
if shown < total {
lines.push(Line::from(Span::styled(
format!(" … and {} more", total - shown),
Style::default().fg(t.comment).bg(t.bg),
)));
}
frame.render_widget(Paragraph::new(lines).style(Style::default().bg(t.bg)), area);
for (line_idx, x_start, x_end, file_idx) in pending_file_rows {
if (line_idx as u16) >= area.height {
continue;
}
let y = area.y + line_idx as u16;
let x = area.x + x_start as u16;
let width = x_end.saturating_sub(x_start) as u16;
let max_w = (area.x + area.width).saturating_sub(x);
let clamped_w = width.min(max_w);
if clamped_w == 0 {
continue;
}
file_rows_out.push((
Rect {
x,
y,
width: clamped_w,
height: 1,
},
pane_id,
file_idx,
));
}
}
fn lane_color(t: &Theme, idx: u8) -> Color {
match idx as usize % LANE_COLORS {
0 => t.blue,
1 => t.green,
2 => t.yellow,
3 => t.purple,
4 => t.cyan,
_ => t.orange,
}
}
fn compute_column_widths(total: usize, graph_w: usize, auto: ColAutoSize) -> ColWidths {
let min_fixed = 1 + 2 + graph_w + 3 + 20;
let mut remaining = total.saturating_sub(min_fixed);
let mut w = ColWidths {
branch: 0,
author: 0,
age: 0,
sha: 0,
};
if remaining >= 9 + 3 {
w.sha = 9;
remaining -= 9 + 3;
}
if remaining >= 13 + 3 {
w.age = 13;
remaining -= 13 + 3;
} else if remaining >= 11 + 3 {
w.age = 11;
remaining -= 11 + 3;
} else if remaining >= 6 + 3 {
w.age = 6;
remaining -= 6 + 3;
}
let author_target = match auto.author_override {
Some(n) => n,
None => auto.author_chars.clamp(8, 22),
};
if author_target > 0 && remaining >= author_target + 3 {
w.author = author_target;
remaining -= author_target + 3;
}
let branch_target = match auto.branch_override {
Some(n) => n,
None => {
if auto.branch_chars == 0 {
0
} else {
auto.branch_chars.clamp(8, 24)
}
}
};
if branch_target > 0 && remaining >= branch_target + 3 {
w.branch = branch_target.min(remaining.saturating_sub(3));
}
w
}
fn chip_width_for_refs(refs: &[crate::git::log::RefLabel]) -> usize {
let mut sum = 0usize;
for (i, r) in refs.iter().enumerate() {
let label_chars = match r.kind {
RefKind::Tag => r.name.chars().count() + 1, _ => r.name.chars().count(),
};
sum += label_chars;
if i + 1 < refs.len() {
sum += 1; }
}
sum
}
fn format_wip_summary(snap: &crate::git::status::Snapshot) -> String {
let total = snap.modified + snap.staged + snap.untracked + snap.conflicts;
let mut parts: Vec<String> = Vec::new();
if total == 0 {
parts.push("working tree clean".to_string());
} else {
parts.push(format!("{total} change(s)"));
if snap.staged > 0 {
parts.push(format!("{} staged", snap.staged));
}
if snap.untracked > 0 {
parts.push(format!("{} new", snap.untracked));
}
if snap.conflicts > 0 {
parts.push(format!("⚠ {} conflict(s)", snap.conflicts));
}
}
if snap.ahead > 0 || snap.behind > 0 {
parts.push(format!("↑{} ↓{}", snap.ahead, snap.behind));
}
parts.join(" · ")
}
#[allow(clippy::too_many_arguments)]
fn draw_wip_detail(
frame: &mut Frame,
area: Rect,
t: &Theme,
snap: &crate::git::status::Snapshot,
workspace: &std::path::Path,
pane_id: PaneId,
commit: &crate::git::graph::WipCommitInput,
buttons_out: &mut Vec<(Rect, PaneId, crate::WipAction)>,
file_rows_out: &mut Vec<(Rect, PaneId, std::path::PathBuf, bool)>,
textarea_out: &mut Option<(Rect, PaneId)>,
) -> Option<(u16, u16)> {
frame.render_widget(Paragraph::new("").style(Style::default().bg(t.bg)), area);
let commit_h: u16 = {
let h = area.height;
if h >= 14 {
10
} else if h >= 10 {
8
} else if h >= 6 {
4
} else {
0
}
};
let files_area = Rect::new(
area.x,
area.y,
area.width,
area.height.saturating_sub(commit_h),
);
let commit_area = if commit_h > 0 {
Rect::new(
area.x,
area.y + area.height.saturating_sub(commit_h),
area.width,
commit_h,
)
} else {
Rect::new(area.x, area.y + area.height, area.width, 0)
};
let w = files_area.width as usize;
let mut lines: Vec<Line> = Vec::new();
let head = format!(
" WIP @ {} · {}",
snap.branch.as_deref().unwrap_or("(detached)"),
format_wip_summary(snap),
);
let head = head.chars().take(w.saturating_sub(1)).collect::<String>();
lines.push(Line::from(vec![
Span::styled("─", Style::default().fg(t.line).bg(t.bg)),
Span::styled(
head,
Style::default()
.fg(t.yellow)
.bg(t.bg)
.add_modifier(Modifier::BOLD),
),
]));
lines.push(Line::from(Span::styled("", Style::default().bg(t.bg))));
let mut unstaged: Vec<(
std::path::PathBuf,
String,
&'static str,
ratatui::style::Color,
)> = Vec::new();
let mut staged: Vec<(
std::path::PathBuf,
String,
&'static str,
ratatui::style::Color,
)> = Vec::new();
for (path, state) in &snap.files {
let rel = path
.strip_prefix(workspace)
.unwrap_or(path)
.display()
.to_string();
match state {
crate::git::status::FileState::Modified => {
unstaged.push((path.clone(), rel, "M", t.yellow));
}
crate::git::status::FileState::Untracked => {
unstaged.push((path.clone(), rel, "?", t.comment));
}
crate::git::status::FileState::Conflicted => {
unstaged.push((path.clone(), rel, "!", t.red));
}
crate::git::status::FileState::Staged => {
staged.push((path.clone(), rel, "A", t.green));
}
}
}
unstaged.sort_by(|a, b| a.1.cmp(&b.1));
staged.sort_by(|a, b| a.1.cmp(&b.1));
let mut pending_buttons: Vec<(usize, u16, u16, crate::WipAction)> = Vec::new();
let mut pending_file_rows: Vec<(usize, u16, u16, std::path::PathBuf, bool)> = Vec::new();
let unstaged_label = if unstaged.is_empty() {
"Unstaged Files (0)".to_string()
} else {
format!("Unstaged Files ({})", unstaged.len())
};
let stage_all_label = " Stage All ";
let stage_all_chars = stage_all_label.chars().count();
let label_text = format!(" ▾ {unstaged_label}");
let label_chars = label_text.chars().count();
let padding = w.saturating_sub(label_chars + stage_all_chars).max(1);
let mut header_spans: Vec<Span> = vec![
Span::styled(
label_text,
Style::default()
.fg(t.fg)
.bg(t.bg)
.add_modifier(Modifier::BOLD),
),
Span::styled(" ".repeat(padding), Style::default().bg(t.bg)),
];
let stage_all_active = !unstaged.is_empty();
let stage_all_style = if stage_all_active {
Style::default()
.fg(t.bg_dark)
.bg(t.green)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(t.comment).bg(t.bg)
};
header_spans.push(Span::styled(stage_all_label.to_string(), stage_all_style));
let line_idx_unstaged_header = lines.len();
if stage_all_active {
let btn_x_start = (label_chars + padding) as u16;
let btn_x_end = btn_x_start + stage_all_chars as u16;
pending_buttons.push((
line_idx_unstaged_header,
btn_x_start,
btn_x_end,
crate::WipAction::StageAll,
));
}
lines.push(Line::from(header_spans));
let plus_label = " [+] ";
let plus_chars = plus_label.chars().count();
for (abs_path, rel, letter, color) in &unstaged {
let prefix = format!(" {letter} ");
let prefix_chars = prefix.chars().count();
let path_avail = w.saturating_sub(prefix_chars + plus_chars + 1).max(8);
let path_display = pad_or_truncate(rel, path_avail);
let row_spans: Vec<Span> = vec![
Span::styled(prefix, Style::default().fg(*color).bg(t.bg)),
Span::styled(path_display, Style::default().fg(t.fg).bg(t.bg)),
Span::styled(" ", Style::default().bg(t.bg)),
Span::styled(
plus_label.to_string(),
Style::default()
.fg(t.bg_dark)
.bg(t.green)
.add_modifier(Modifier::BOLD),
),
];
let btn_x_start = (prefix_chars + path_avail + 1) as u16;
let btn_x_end = btn_x_start + plus_chars as u16;
pending_buttons.push((
lines.len(),
btn_x_start,
btn_x_end,
crate::WipAction::StageFile(abs_path.clone()),
));
pending_file_rows.push((
lines.len(),
0,
(prefix_chars + path_avail) as u16,
abs_path.clone(),
false,
));
lines.push(Line::from(row_spans));
}
lines.push(Line::from(Span::styled("", Style::default().bg(t.bg))));
let staged_label = if staged.is_empty() {
"Staged Files (0)".to_string()
} else {
format!("Staged Files ({})", staged.len())
};
let unstage_all_label = " Unstage All ";
let unstage_all_chars = unstage_all_label.chars().count();
let staged_label_text = format!(" ▾ {staged_label}");
let staged_label_chars = staged_label_text.chars().count();
let staged_padding = w
.saturating_sub(staged_label_chars + unstage_all_chars)
.max(1);
let mut staged_header_spans: Vec<Span> = vec![
Span::styled(
staged_label_text,
Style::default()
.fg(t.fg)
.bg(t.bg)
.add_modifier(Modifier::BOLD),
),
Span::styled(" ".repeat(staged_padding), Style::default().bg(t.bg)),
];
let unstage_all_active = !staged.is_empty();
let unstage_all_style = if unstage_all_active {
Style::default()
.fg(t.bg_dark)
.bg(t.orange)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(t.comment).bg(t.bg)
};
staged_header_spans.push(Span::styled(
unstage_all_label.to_string(),
unstage_all_style,
));
let line_idx_staged_header = lines.len();
if unstage_all_active {
let btn_x_start = (staged_label_chars + staged_padding) as u16;
let btn_x_end = btn_x_start + unstage_all_chars as u16;
pending_buttons.push((
line_idx_staged_header,
btn_x_start,
btn_x_end,
crate::WipAction::UnstageAll,
));
}
lines.push(Line::from(staged_header_spans));
let minus_label = " [−] ";
let minus_chars = minus_label.chars().count();
for (abs_path, rel, letter, color) in &staged {
let prefix = format!(" {letter} ");
let prefix_chars = prefix.chars().count();
let path_avail = w.saturating_sub(prefix_chars + minus_chars + 1).max(8);
let path_display = pad_or_truncate(rel, path_avail);
let row_spans: Vec<Span> = vec![
Span::styled(prefix, Style::default().fg(*color).bg(t.bg)),
Span::styled(path_display, Style::default().fg(t.fg).bg(t.bg)),
Span::styled(" ", Style::default().bg(t.bg)),
Span::styled(
minus_label.to_string(),
Style::default()
.fg(t.bg_dark)
.bg(t.orange)
.add_modifier(Modifier::BOLD),
),
];
let btn_x_start = (prefix_chars + path_avail + 1) as u16;
let btn_x_end = btn_x_start + minus_chars as u16;
pending_buttons.push((
lines.len(),
btn_x_start,
btn_x_end,
crate::WipAction::UnstageFile(abs_path.clone()),
));
pending_file_rows.push((
lines.len(),
0,
(prefix_chars + path_avail) as u16,
abs_path.clone(),
true,
));
lines.push(Line::from(row_spans));
}
lines.push(Line::from(Span::styled("", Style::default().bg(t.bg))));
let files_max = files_area.height as usize;
if lines.len() > files_max && files_max > 0 {
let dropped = lines.len() - files_max + 1;
lines.truncate(files_max - 1);
lines.push(Line::from(Span::styled(
format!(" … and {dropped} more"),
Style::default().fg(t.comment).bg(t.bg),
)));
}
frame.render_widget(
Paragraph::new(lines).style(Style::default().bg(t.bg)),
files_area,
);
for (line_idx, x_start, x_end, action) in pending_buttons {
if (line_idx as u16) >= files_area.height {
continue;
}
let y = files_area.y + line_idx as u16;
let x = files_area.x + x_start;
let width = x_end.saturating_sub(x_start);
if x + width > files_area.x + files_area.width {
continue;
}
buttons_out.push((
Rect {
x,
y,
width,
height: 1,
},
pane_id,
action,
));
}
for (line_idx, x_start, x_end, abs_path, staged) in pending_file_rows {
if (line_idx as u16) >= files_area.height {
continue;
}
let y = files_area.y + line_idx as u16;
let x = files_area.x + x_start;
let width = x_end.saturating_sub(x_start);
if x + width > files_area.x + files_area.width {
continue;
}
file_rows_out.push((
Rect {
x,
y,
width,
height: 1,
},
pane_id,
abs_path,
staged,
));
}
if commit_h == 0 {
return None;
}
draw_commit_section(
frame,
commit_area,
t,
snap,
pane_id,
commit,
buttons_out,
textarea_out,
)
}
#[allow(clippy::too_many_arguments)]
fn draw_commit_section(
frame: &mut Frame,
area: Rect,
t: &Theme,
snap: &crate::git::status::Snapshot,
pane_id: PaneId,
commit: &crate::git::graph::WipCommitInput,
buttons_out: &mut Vec<(Rect, PaneId, crate::WipAction)>,
textarea_out: &mut Option<(Rect, PaneId)>,
) -> Option<(u16, u16)> {
frame.render_widget(Paragraph::new("").style(Style::default().bg(t.bg)), area);
let staged_count = snap.staged;
let h = area.height;
let header_row = area.y;
let textarea_rows: u16 = h.saturating_sub(if h >= 5 { 3 } else { 2 }).max(1);
let textarea_y0 = header_row + 1;
let buttons_y = textarea_y0 + textarea_rows;
let hint_y = buttons_y + 1;
let header_text = if staged_count == 0 {
" ▾ Commit · (nothing staged)".to_string()
} else {
format!(" ▾ Commit · {staged_count} file(s) staged")
};
let header_truncated = pad_or_truncate(&header_text, area.width as usize);
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
header_truncated,
Style::default()
.fg(t.fg)
.bg(t.bg)
.add_modifier(Modifier::BOLD),
)))
.style(Style::default().bg(t.bg)),
Rect::new(area.x, header_row, area.width, 1),
);
let pad_x: u16 = 2;
let content_w = area.width.saturating_sub(pad_x * 2);
if content_w >= 4 && textarea_rows >= 1 {
let ta_rect = Rect::new(area.x + pad_x, textarea_y0, content_w, textarea_rows);
let cursor = draw_textarea(frame, ta_rect, t, commit);
*textarea_out = Some((ta_rect, pane_id));
draw_commit_buttons(
frame,
Rect::new(area.x, buttons_y, area.width, 1),
t,
pane_id,
staged_count,
commit,
buttons_out,
);
if h >= 5 {
let hint = if commit.focused {
" Enter newline · Esc unfocus · Ctrl+Enter commit"
} else {
" Click textarea to type · c commit · C AI message"
};
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
pad_or_truncate(hint, area.width as usize),
Style::default().fg(t.comment).bg(t.bg),
)))
.style(Style::default().bg(t.bg)),
Rect::new(area.x, hint_y, area.width, 1),
);
}
return cursor;
}
draw_commit_buttons(
frame,
Rect::new(area.x, area.y + 1, area.width, 1),
t,
pane_id,
staged_count,
commit,
buttons_out,
);
None
}
fn draw_textarea(
frame: &mut Frame,
area: Rect,
t: &Theme,
commit: &crate::git::graph::WipCommitInput,
) -> Option<(u16, u16)> {
let bg = if commit.focused { t.bg_dark } else { t.bg2 };
frame.render_widget(Paragraph::new("").style(Style::default().bg(bg)), area);
let content_w = area.width as usize;
if content_w == 0 || area.height == 0 {
return None;
}
let rows = layout_textarea_rows(&commit.text, content_w);
let (cur_row, cur_col) = locate_cursor(&rows, &commit.text, commit.cursor);
let visible_h = area.height as usize;
let scroll = if cur_row >= visible_h {
cur_row + 1 - visible_h
} else {
0
};
let mut out: Vec<Line> = Vec::new();
for vrow in 0..visible_h {
let actual = scroll + vrow;
let line: String = if let Some(&(s, e)) = rows.get(actual) {
commit.text[s..e].to_string()
} else {
String::new()
};
let padded = pad_or_truncate(&line, content_w);
out.push(Line::from(Span::styled(
padded,
Style::default().fg(t.fg).bg(bg),
)));
}
if commit.text.is_empty() && !commit.focused {
let placeholder = if commit.ai_streaming {
" (asking Claude for a commit message…) "
} else {
" click here · then type a commit message "
};
let row0 = pad_or_truncate(placeholder, content_w);
out[0] = Line::from(Span::styled(
row0,
Style::default()
.fg(t.comment)
.bg(bg)
.add_modifier(Modifier::ITALIC),
));
}
frame.render_widget(Paragraph::new(out).style(Style::default().bg(bg)), area);
if commit.focused {
let visual_row = cur_row.saturating_sub(scroll);
if visual_row < visible_h && cur_col <= content_w {
let x = area.x + cur_col as u16;
let y = area.y + visual_row as u16;
return Some((x, y));
}
}
None
}
fn layout_textarea_rows(text: &str, width: usize) -> Vec<(usize, usize)> {
if width == 0 {
return vec![(0, text.len())];
}
let mut rows: Vec<(usize, usize)> = Vec::new();
let mut row_start = 0usize;
let mut cols = 0usize;
for (idx, ch) in text.char_indices() {
if ch == '\n' {
rows.push((row_start, idx));
row_start = idx + 1;
cols = 0;
continue;
}
if cols >= width {
rows.push((row_start, idx));
row_start = idx;
cols = 0;
}
cols += 1;
}
rows.push((row_start, text.len()));
rows
}
fn locate_cursor(rows: &[(usize, usize)], text: &str, cursor: usize) -> (usize, usize) {
let mut idx = 0;
for (i, &(s, e)) in rows.iter().enumerate() {
if cursor >= s && cursor <= e {
idx = i;
break;
}
idx = i;
}
let (s, _e) = rows.get(idx).copied().unwrap_or((0, 0));
let col = text[s..cursor.min(text.len())].chars().count();
(idx, col)
}
fn draw_commit_buttons(
frame: &mut Frame,
area: Rect,
t: &Theme,
pane_id: PaneId,
staged_count: usize,
commit: &crate::git::graph::WipCommitInput,
buttons_out: &mut Vec<(Rect, PaneId, crate::WipAction)>,
) {
frame.render_widget(Paragraph::new("").style(Style::default().bg(t.bg)), area);
let commit_btn = " Commit ";
let ai_btn = " AI Message ";
let clear_btn = " Clear ";
let gap = " ";
let leading = " ";
let commit_active = staged_count > 0 && !commit.is_blank() && !commit.ai_streaming;
let ai_active = staged_count > 0 && !commit.ai_streaming;
let clear_active = !commit.text.is_empty() && !commit.ai_streaming;
let style_active = |fg, bg, on: bool| {
if on {
Style::default().fg(fg).bg(bg).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(t.comment).bg(t.bg2)
}
};
let commit_style = style_active(t.bg_dark, t.green, commit_active);
let ai_style = if commit.ai_streaming {
Style::default()
.fg(t.bg_dark)
.bg(t.yellow)
.add_modifier(Modifier::BOLD)
} else {
style_active(t.bg_dark, t.blue, ai_active)
};
let clear_style = style_active(t.bg_dark, t.red, clear_active);
let mut spans: Vec<Span> = Vec::new();
spans.push(Span::styled(leading.to_string(), Style::default().bg(t.bg)));
let mut x = area.x + leading.chars().count() as u16;
let commit_w = commit_btn.chars().count() as u16;
spans.push(Span::styled(commit_btn.to_string(), commit_style));
buttons_out.push((
Rect {
x,
y: area.y,
width: commit_w,
height: 1,
},
pane_id,
crate::WipAction::OpenCommitPrompt,
));
x += commit_w;
spans.push(Span::styled(gap.to_string(), Style::default().bg(t.bg)));
x += gap.chars().count() as u16;
let ai_w = ai_btn.chars().count() as u16;
let ai_label = if commit.ai_streaming {
" AI writing… "
} else {
ai_btn
};
spans.push(Span::styled(ai_label.to_string(), ai_style));
buttons_out.push((
Rect {
x,
y: area.y,
width: ai_label.chars().count() as u16,
height: 1,
},
pane_id,
crate::WipAction::RequestAiCommitMessage,
));
x += ai_w;
spans.push(Span::styled(gap.to_string(), Style::default().bg(t.bg)));
x += gap.chars().count() as u16;
let clear_w = clear_btn.chars().count() as u16;
spans.push(Span::styled(clear_btn.to_string(), clear_style));
buttons_out.push((
Rect {
x,
y: area.y,
width: clear_w,
height: 1,
},
pane_id,
crate::WipAction::ClearCommitDraft,
));
frame.render_widget(
Paragraph::new(Line::from(spans)).style(Style::default().bg(t.bg)),
area,
);
}
#[allow(clippy::too_many_arguments)]
fn draw_header(
frame: &mut Frame,
area: Rect,
t: &Theme,
cols: &ColWidths,
graph_w: usize,
hash_filter: &str,
filter_label: Option<&str>,
sort: Option<(crate::git::graph::SortColumn, bool)>,
column_clickables: &mut Vec<(Rect, crate::git::graph::SortColumn)>,
) {
use crate::git::graph::SortColumn;
let sort_glyph = |c: SortColumn| -> &'static str {
match sort {
Some((sc, true)) if sc == c => " ▲",
Some((sc, false)) if sc == c => " ▼",
_ => " ",
}
};
let header_style = |c: SortColumn| -> Style {
let mut st = Style::default().fg(t.comment).bg(t.bg_darker);
if matches!(sort, Some((sc, _)) if sc == c) {
st = st.fg(t.yellow).add_modifier(Modifier::BOLD);
} else {
st = st.add_modifier(Modifier::BOLD);
}
st
};
let mut cell_x: u16 = 0;
if area.width == 0 || area.height == 0 {
return;
}
let bg = t.bg_darker;
let mut spans: Vec<Span> = Vec::new();
spans.push(Span::styled(" ", Style::default().bg(bg)));
cell_x += 3;
let sep_style = Style::default().fg(t.grey).bg(bg);
let sep_span = || Span::styled(" │ ", sep_style);
if cols.branch > 0 {
spans.push(Span::styled(
pad_or_truncate("BRANCH / TAG", cols.branch),
Style::default()
.fg(t.comment)
.bg(bg)
.add_modifier(Modifier::BOLD),
));
cell_x += cols.branch as u16;
spans.push(sep_span());
cell_x += 3;
} else {
spans.push(Span::styled(" ", Style::default().bg(bg)));
cell_x += 2;
}
spans.push(Span::styled(
pad_or_truncate("GRAPH", graph_w),
Style::default()
.fg(t.comment)
.bg(bg)
.add_modifier(Modifier::BOLD),
));
cell_x += graph_w as u16;
spans.push(sep_span());
cell_x += 3;
let branch_section = if cols.branch > 0 { cols.branch + 3 } else { 2 };
let fixed_used = 1
+ 2
+ branch_section
+ graph_w
+ 3
+ (if cols.author > 0 { cols.author + 3 } else { 0 })
+ (if cols.age > 0 { cols.age + 3 } else { 0 })
+ (if cols.sha > 0 { cols.sha + 3 } else { 0 })
+ SHA_RIGHT_PAD;
let subject_w = (area.width as usize).saturating_sub(fixed_used);
let (subject_label, label_style) = if !hash_filter.is_empty() {
(
pad_or_truncate(&format!("/{hash_filter}_"), subject_w),
Style::default()
.fg(t.yellow)
.bg(bg)
.add_modifier(Modifier::BOLD),
)
} else if let Some(chip) = filter_label {
(
pad_or_truncate(chip, subject_w),
Style::default()
.fg(t.bg_darker)
.bg(t.yellow)
.add_modifier(Modifier::BOLD),
)
} else {
(
pad_or_truncate("COMMIT MESSAGE", subject_w),
Style::default()
.fg(t.comment)
.bg(bg)
.add_modifier(Modifier::BOLD),
)
};
spans.push(Span::styled(subject_label, label_style));
cell_x += subject_w as u16;
if cols.author > 0 {
spans.push(sep_span());
cell_x += 3;
let label = format!("AUTHOR{}", sort_glyph(SortColumn::Author));
spans.push(Span::styled(
right_align(&label, cols.author),
header_style(SortColumn::Author),
));
column_clickables.push((
Rect {
x: area.x + cell_x,
y: area.y,
width: cols.author as u16,
height: 1,
},
SortColumn::Author,
));
cell_x += cols.author as u16;
}
if cols.age > 0 {
spans.push(sep_span());
cell_x += 3;
let label = format!("DATE / TIME{}", sort_glyph(SortColumn::Date));
spans.push(Span::styled(
right_align(&label, cols.age),
header_style(SortColumn::Date),
));
column_clickables.push((
Rect {
x: area.x + cell_x,
y: area.y,
width: cols.age as u16,
height: 1,
},
SortColumn::Date,
));
cell_x += cols.age as u16;
}
if cols.sha > 0 {
spans.push(sep_span());
cell_x += 3;
let label = format!("SHA{}", sort_glyph(SortColumn::Sha));
spans.push(Span::styled(
right_align(&label, cols.sha),
header_style(SortColumn::Sha),
));
column_clickables.push((
Rect {
x: area.x + cell_x,
y: area.y,
width: cols.sha as u16,
height: 1,
},
SortColumn::Sha,
));
cell_x += cols.sha as u16;
}
let _ = cell_x;
if SHA_RIGHT_PAD > 0 {
spans.push(Span::styled(
" ".repeat(SHA_RIGHT_PAD),
Style::default().bg(bg),
));
}
frame.render_widget(
Paragraph::new(Line::from(spans)).style(Style::default().bg(bg)),
area,
);
}
fn render_branch_chips(
spans: &mut Vec<Span<'static>>,
refs: &[crate::git::log::RefLabel],
width: usize,
row_bg: Color,
t: &Theme,
) {
if width == 0 {
return;
}
let mut sorted: Vec<&crate::git::log::RefLabel> = refs.iter().collect();
sorted.sort_by_key(|r| match r.kind {
RefKind::Head => 0,
RefKind::LocalBranch => 1,
RefKind::RemoteBranch => 2,
RefKind::Tag => 3,
});
let mut chips: Vec<(String, Color, bool)> = Vec::with_capacity(sorted.len());
for r in &sorted {
let entry = match r.kind {
RefKind::Head => (r.name.clone(), t.cyan, true),
RefKind::LocalBranch => (r.name.clone(), t.green, false),
RefKind::RemoteBranch => (r.name.clone(), t.purple, false),
RefKind::Tag => (format!("⊙{}", r.name), t.yellow, false),
};
chips.push(entry);
}
let mut used = 0usize;
let mut emitted = 0usize;
for (i, (label, color, bold)) in chips.iter().enumerate() {
let needed = label.chars().count() + if i + 1 < chips.len() { 1 } else { 0 };
if used + needed > width {
let remaining = chips.len() - i;
let tail = format!("+{remaining}");
let tail_w = tail.chars().count();
if used + tail_w <= width {
spans.push(Span::styled(
tail.clone(),
Style::default().fg(t.comment).bg(row_bg),
));
used += tail_w;
}
break;
}
let mut st = Style::default().fg(*color).bg(row_bg);
if *bold {
st = st.add_modifier(Modifier::BOLD);
}
spans.push(Span::styled(label.clone(), st));
used += label.chars().count();
if i + 1 < chips.len() {
spans.push(Span::styled(" ", Style::default().bg(row_bg)));
used += 1;
}
emitted += 1;
}
let _ = emitted;
if used < width {
spans.push(Span::styled(
" ".repeat(width - used),
Style::default().bg(row_bg),
));
}
}
fn pad_or_truncate(s: &str, width: usize) -> String {
if width == 0 {
return String::new();
}
let n = s.chars().count();
if n == width {
s.to_string()
} else if n < width {
format!("{}{}", s, " ".repeat(width - n))
} else if width == 1 {
"…".to_string()
} else {
let mut out: String = s.chars().take(width - 1).collect();
out.push('…');
out
}
}
fn right_align(s: &str, width: usize) -> String {
if width == 0 {
return String::new();
}
let n = s.chars().count();
if n == width {
s.to_string()
} else if n < width {
format!("{}{}", " ".repeat(width - n), s)
} else if width == 1 {
"…".to_string()
} else {
let take = width - 1;
let skip = n - take;
let out: String = s.chars().skip(skip).collect();
format!("…{out}")
}
}
pub fn draw_git_toolbar(
frame: &mut Frame,
area: Rect,
t: &Theme,
pane_id: PaneId,
nerd: bool,
buttons_out: &mut Vec<(Rect, PaneId, crate::GitToolbarAction)>,
) {
if area.width < 20 || area.height < 1 {
return;
}
let bg = t.bg_darker;
frame.render_widget(Paragraph::new("").style(Style::default().bg(bg)), area);
let buttons: [(
&str,
&str,
&str,
crate::GitToolbarAction,
ratatui::style::Color,
); 13] = [
(
"Undo",
"\u{F054C}",
"↶",
crate::GitToolbarAction::Undo,
t.comment,
),
(
"Redo",
"\u{F044E}",
"↷",
crate::GitToolbarAction::Redo,
t.comment,
),
(
"Pull",
"\u{F0162}",
"↓",
crate::GitToolbarAction::Pull,
t.green,
),
(
"Push",
"\u{F0166}",
"↑",
crate::GitToolbarAction::Push,
t.blue,
),
(
"Fetch",
"\u{F0450}",
"↺",
crate::GitToolbarAction::Fetch,
t.cyan,
),
(
"Branch",
"\u{F062C}",
"⎇",
crate::GitToolbarAction::BranchPicker,
t.yellow,
),
(
"Commit",
"\u{F012C}",
"✓",
crate::GitToolbarAction::Commit,
t.green,
),
(
"Stash",
"\u{F01DA}",
"↧",
crate::GitToolbarAction::Stash,
t.purple,
),
(
"Pop",
"\u{F01DB}",
"↥",
crate::GitToolbarAction::StashPop,
t.purple,
),
(
"Reflog",
"\u{F02DA}",
"↺",
crate::GitToolbarAction::Reflog,
t.orange,
),
(
"Refresh",
"\u{EB37}",
"↻",
crate::GitToolbarAction::RefreshRepos,
t.cyan,
),
(
"Switch",
"\u{F062C}",
"⇄",
crate::GitToolbarAction::SwitchRepo,
t.blue,
),
(
"Blame",
"\u{F02A0}",
"B",
crate::GitToolbarAction::BlameToggle,
t.yellow,
),
];
let button_widths: Vec<u16> = buttons
.iter()
.map(|(label, _, _, _, _)| 3u16 + label.chars().count() as u16 + 1)
.collect();
let normal_div_w: u16 = 3;
let compact_div_w: u16 = 1;
let total_at = |div: u16| -> u16 {
let btns: u16 = button_widths.iter().sum();
btns + (button_widths.len() as u16).saturating_sub(1) * div
};
let use_compact = total_at(normal_div_w) > area.width;
let div_w = if use_compact {
compact_div_w
} else {
normal_div_w
};
let n = {
let mut used = 0u16;
let mut n = 0usize;
for (i, w) in button_widths.iter().enumerate() {
let extra = if i > 0 { div_w } else { 0 };
if used + extra + w > area.width {
break;
}
used += extra + w;
n += 1;
}
n.max(1)
};
let mut spans: Vec<Span> = Vec::new();
let mut x = area.x;
for (i, (label, nerd_icon, ascii_icon, action, color)) in buttons.iter().take(n).enumerate() {
let icon = if nerd { *nerd_icon } else { *ascii_icon };
spans.push(Span::styled(
format!(" {icon} "),
Style::default()
.fg(*color)
.bg(bg)
.add_modifier(Modifier::BOLD),
));
spans.push(Span::styled(
format!("{label} "),
Style::default()
.fg(t.fg)
.bg(bg)
.add_modifier(Modifier::BOLD),
));
buttons_out.push((Rect::new(x, area.y, button_widths[i], 1), pane_id, *action));
x += button_widths[i];
if i + 1 < n {
let div_str = if use_compact { "│" } else { " │ " };
spans.push(Span::styled(div_str, Style::default().fg(t.grey).bg(bg)));
x += div_w;
}
}
frame.render_widget(
Paragraph::new(Line::from(spans)).style(Style::default().bg(bg)),
Rect::new(area.x, area.y, area.width, 1),
);
}
pub fn format_commit_datetime(secs: i64) -> String {
let off_secs = std::env::var("TZ_OFFSET_HOURS")
.ok()
.and_then(|s| s.parse::<i64>().ok())
.map(|h| h * 3600)
.unwrap_or(0);
let local = secs.saturating_add(off_secs);
let days = local.div_euclid(86_400);
let day_secs = local.rem_euclid(86_400);
let hh = day_secs / 3600;
let mm = (day_secs / 60) % 60;
let (_y, month, day) = days_to_ymd(days);
format!("{month:02}/{day:02} {hh:02}:{mm:02}")
}
fn days_to_ymd(days_since_epoch: i64) -> (i64, u32, u32) {
let z = days_since_epoch + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u64; let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1; let m = if mp < 10 { mp + 3 } else { mp - 9 }; let y = if m <= 2 { y + 1 } else { y };
(y, m as u32, d as u32)
}
pub fn humanize_age(secs: i64) -> String {
let s = secs.max(0);
if s < 60 {
return "now".to_string();
}
let m = s / 60;
if m < 60 {
return format!("{m}m");
}
let h = m / 60;
if h < 24 {
return format!("{h}h");
}
let d = h / 24;
if d < 14 {
return format!("{d}d");
}
let w = d / 7;
if w < 9 {
return format!("{w}w");
}
let mo = d / 30;
if mo < 24 {
return format!("{mo}mo");
}
format!("{}y", d / 365)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn days_to_ymd_known_dates() {
assert_eq!(days_to_ymd(0), (1970, 1, 1));
assert_eq!(days_to_ymd(30), (1970, 1, 31));
assert_eq!(days_to_ymd(10957), (2000, 1, 1));
assert_eq!(days_to_ymd(19723 + 59), (2024, 2, 29));
}
#[test]
fn format_commit_datetime_pads_to_11_chars() {
let prior = std::env::var("TZ_OFFSET_HOURS").ok();
unsafe {
std::env::remove_var("TZ_OFFSET_HOURS");
}
assert_eq!(format_commit_datetime(0), "01/01 00:00");
let s = format_commit_datetime(1_735_134_300);
assert_eq!(s.chars().count(), 11);
if let Some(p) = prior {
unsafe {
std::env::set_var("TZ_OFFSET_HOURS", p);
}
}
}
#[test]
fn ages_humanize() {
assert_eq!(humanize_age(10), "now");
assert_eq!(humanize_age(120), "2m");
assert_eq!(humanize_age(3 * 3600), "3h");
assert_eq!(humanize_age(2 * 86400), "2d");
assert_eq!(humanize_age(21 * 86400), "3w");
assert_eq!(humanize_age(90 * 86400), "3mo");
assert_eq!(humanize_age(800 * 86400), "2y");
}
#[test]
fn pad_or_truncate_pads_short_strings() {
assert_eq!(pad_or_truncate("ab", 5), "ab ");
}
#[test]
fn pad_or_truncate_truncates_with_ellipsis() {
assert_eq!(pad_or_truncate("hello world", 8), "hello w…");
}
#[test]
fn pad_or_truncate_exact_width_passthrough() {
assert_eq!(pad_or_truncate("hello", 5), "hello");
}
#[test]
fn right_align_pads_left() {
assert_eq!(right_align("ab", 5), " ab");
}
#[test]
fn right_align_truncates_from_left() {
assert_eq!(right_align("Christopher McLennan", 12), "…er McLennan");
}
#[test]
fn compute_column_widths_wide_pane_includes_everything() {
let c = compute_column_widths(
200,
10,
ColAutoSize {
branch_chars: 30,
author_chars: 18,
branch_override: None,
author_override: None,
},
);
assert!(c.sha >= 9);
assert!(c.age >= 6);
assert!(c.author >= 10);
assert!(c.branch >= 18);
}
#[test]
fn compute_column_widths_narrow_collapses_right_to_left() {
let c = compute_column_widths(
45,
6,
ColAutoSize {
branch_chars: 30,
author_chars: 18,
branch_override: None,
author_override: None,
},
);
assert!(c.sha > 0, "sha should be the last to collapse");
}
#[test]
fn compute_column_widths_very_narrow_keeps_subject_only() {
let c = compute_column_widths(
28,
4,
ColAutoSize {
branch_chars: 30,
author_chars: 18,
branch_override: None,
author_override: None,
},
);
assert_eq!(c.branch, 0);
assert_eq!(c.author, 0);
}
#[test]
fn compute_column_widths_auto_sizes_to_content() {
let c = compute_column_widths(
200,
10,
ColAutoSize {
branch_chars: 5,
author_chars: 4,
branch_override: None,
author_override: None,
},
);
assert_eq!(c.author, 8, "author auto-size clamps to 8 min");
assert_eq!(c.branch, 8, "branch auto-size clamps to 8 min (was 10)");
}
#[test]
fn compute_column_widths_respects_explicit_overrides() {
let c = compute_column_widths(
200,
10,
ColAutoSize {
branch_chars: 30,
author_chars: 30,
branch_override: Some(0), author_override: Some(12),
},
);
assert_eq!(c.branch, 0);
assert_eq!(c.author, 12);
}
#[test]
fn format_wip_summary_clean_and_dirty_trees() {
use crate::git::status::Snapshot;
let clean = Snapshot::default();
assert_eq!(format_wip_summary(&clean), "working tree clean");
let dirty = Snapshot {
modified: 3,
staged: 1,
untracked: 2,
..Snapshot::default()
};
assert_eq!(format_wip_summary(&dirty), "6 change(s) · 1 staged · 2 new");
let ahead = Snapshot {
ahead: 2,
behind: 1,
..Snapshot::default()
};
assert_eq!(format_wip_summary(&ahead), "working tree clean · ↑2 ↓1");
let conflict = Snapshot {
conflicts: 1,
..Snapshot::default()
};
assert_eq!(
format_wip_summary(&conflict),
"1 change(s) · ⚠ 1 conflict(s)"
);
}
#[test]
fn layout_textarea_rows_wraps_and_splits_on_newline() {
assert_eq!(layout_textarea_rows("hello", 0), vec![(0, 5)]);
assert_eq!(layout_textarea_rows("ab\ncd", 10), vec![(0, 2), (3, 5)]);
assert_eq!(layout_textarea_rows("abcdef", 3), vec![(0, 3), (3, 6)]);
assert_eq!(layout_textarea_rows("", 5), vec![(0, 0)]);
}
#[test]
fn locate_cursor_maps_byte_offset_to_row_col() {
let text = "abcdef";
let rows = layout_textarea_rows(text, 3); assert_eq!(locate_cursor(&rows, text, 0), (0, 0));
assert_eq!(locate_cursor(&rows, text, 4), (1, 1));
assert_eq!(locate_cursor(&rows, text, 6), (1, 3));
}
#[test]
fn chip_width_for_refs_sums_names_seps_and_tag_glyph() {
use crate::git::log::{RefKind, RefLabel};
assert_eq!(chip_width_for_refs(&[]), 0);
let refs = vec![
RefLabel {
kind: RefKind::LocalBranch,
name: "main".into(),
},
RefLabel {
kind: RefKind::Tag,
name: "v1.0".into(),
},
];
assert_eq!(chip_width_for_refs(&refs), 10);
}
#[test]
fn lane_color_cycles_through_the_palette() {
let t = theme::onedark();
assert_eq!(lane_color(&t, 0), lane_color(&t, LANE_COLORS as u8));
assert_ne!(lane_color(&t, 0), lane_color(&t, 1));
assert_eq!(lane_color(&t, 5), t.orange);
}
}