mod theme;
use std::collections::HashMap;
use std::ops::Range;
use ratatui::Frame;
use ratatui::layout::{Alignment, Constraint, Layout, Margin, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph, Widget, Wrap};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use crate::app::{
App, ContentFileHit, DiffLayout, Focus, Modal, PaletteCommand, SidebarHit, ToastLevel,
UiGeometry, View,
};
use crate::git::Branch;
use crate::git::diff::{CommitDetails, DiffDocument, DiffLine, DiffLineKind, HighlightSpan};
use crate::git::status::{Change, ChangeArea, ChangeStatus};
use self::theme::Theme;
const HELP_LINES: &[(&str, &str)] = &[
("Navigation", ""),
("j / k, ↑ / ↓", "Move selection or scroll preview"),
("PgUp / PgDn", "Move by a page"),
("gg / G", "Jump to first / last item"),
("Tab", "Switch focus between sidebar and preview"),
("Enter", "Toggle sidebar / preview focus"),
("h / l, ← / →", "Scroll preview horizontally"),
("[ / ]", "Previous / next diff hunk"),
("e", "Collapse / expand all file diffs"),
("z", "Hide / show sidebar"),
("1 / 2", "Changes / commit history"),
("/", "Filter the active list"),
("Esc", "Clear filter, close modal, or return focus"),
("", ""),
("Changes", ""),
("s / u", "Stage / unstage selected file"),
("a / U", "Stage all / unstage all"),
("x", "Discard selected change (asks first)"),
("c", "Commit staged changes"),
("b", "Switch, create, or delete branches"),
("", ""),
("History", ""),
("C / R", "Cherry-pick / revert selected commit"),
("n", "Create branch at selected commit"),
("", ""),
("Repository", ""),
("r / Ctrl+R", "Refresh"),
("f / l / p / y", "Fetch / pull / push / sync"),
("v", "Toggle unified / side-by-side diff"),
(": / Ctrl+P", "Open command palette"),
("?", "Show this help"),
("q", "Quit"),
];
pub fn draw(frame: &mut Frame<'_>, app: &mut App) {
let theme = Theme::default();
frame.render_widget(
Block::default().style(Style::default().bg(theme.background)),
frame.area(),
);
if frame.area().width < 72 || frame.area().height < 18 {
draw_too_small(frame, &theme);
return;
}
let vertical = Layout::vertical([
Constraint::Length(3),
Constraint::Min(8),
Constraint::Length(2),
])
.split(frame.area());
let tabs = vertical[0];
let main = vertical[1];
let footer = vertical[2];
let maximum_sidebar = main.width.saturating_sub(32).max(22);
app.sidebar_width = app.sidebar_width.clamp(22, maximum_sidebar);
let (sidebar_area, sidebar_divider, content_area) = if app.sidebar_hidden {
(Rect::default(), Rect::default(), main)
} else {
let columns = Layout::horizontal([
Constraint::Length(app.sidebar_width),
Constraint::Length(1),
Constraint::Min(31),
])
.split(main);
(columns[0], columns[1], columns[2])
};
let (changes_tab, history_tab) = draw_tabs(frame, tabs, app, &theme);
let sidebar_hits = if app.sidebar_hidden {
Vec::new()
} else {
draw_sidebar(frame, sidebar_area, app, &theme)
};
if !app.sidebar_hidden {
draw_main_divider(frame, sidebar_divider, app.resize_target.is_some(), &theme);
}
let (diff_divider, content_file_hits) = draw_content(frame, content_area, app, &theme);
draw_footer(frame, footer, app, &theme);
app.geometry = UiGeometry {
changes_tab,
history_tab,
main,
sidebar: sidebar_area,
sidebar_divider,
content: content_area,
diff_divider,
sidebar_hits,
content_file_hits,
};
if let Some(modal) = app.modal.as_ref() {
draw_modal(frame, modal, app, &theme);
}
if let Some(toast) = app.toast.as_ref() {
draw_toast(frame, toast.message.as_str(), toast.level, &theme);
}
}
fn draw_main_divider(frame: &mut Frame<'_>, area: Rect, dragging: bool, theme: &Theme) {
let color = if dragging {
theme.border_focus
} else {
theme.border
};
for row in area.y..area.bottom() {
frame.render_widget(
Paragraph::new("│").style(Style::default().fg(color).bg(theme.background)),
Rect::new(area.x, row, 1, 1),
);
}
}
fn draw_too_small(frame: &mut Frame<'_>, theme: &Theme) {
let text = Text::from(vec![
Line::from(Span::styled(
"Quinjet",
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(Span::styled(
"Terminal too small",
Style::default().fg(theme.text),
)),
Line::from(Span::styled(
"Resize to at least 72 × 18",
Style::default().fg(theme.muted),
)),
]);
frame.render_widget(
Paragraph::new(text).alignment(Alignment::Center).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.border)),
),
centered_rect(50, 8, frame.area()),
);
}
fn draw_tabs(frame: &mut Frame<'_>, area: Rect, app: &App, theme: &Theme) -> (Rect, Rect) {
let repository = if app.repository_root.to_string_lossy() == app.repository_name {
app.repository_name.clone()
} else {
format!("{} {}", app.repository_name, app.repository_root.display())
};
let branch = if app.status.branch.head.is_empty() {
"detecting branch…".to_owned()
} else {
let mut text = format!(" {}", app.status.branch.head);
if app.status.branch.ahead > 0 {
text.push_str(&format!(" ↑{}", app.status.branch.ahead));
}
if app.status.branch.behind > 0 {
text.push_str(&format!(" ↓{}", app.status.branch.behind));
}
text
};
let header = Layout::horizontal([
Constraint::Length(16),
Constraint::Length(16),
Constraint::Min(8),
Constraint::Length((branch.width() + 3).min(area.width as usize) as u16),
])
.split(area);
draw_tab(
frame,
header[0],
" Changes ",
app.view == View::Changes,
theme,
);
draw_tab(
frame,
header[1],
" History ",
app.view == View::History,
theme,
);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(
" QUINJET ",
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
),
Span::styled(repository, Style::default().fg(theme.text)),
]))
.block(
Block::default()
.borders(Borders::TOP | Borders::BOTTOM)
.border_style(Style::default().fg(theme.border))
.style(Style::default().bg(theme.panel)),
),
header[2],
);
frame.render_widget(
Paragraph::new(branch)
.alignment(Alignment::Right)
.style(Style::default().fg(theme.accent).bg(theme.panel))
.block(
Block::default()
.borders(Borders::TOP | Borders::RIGHT | Borders::BOTTOM)
.border_style(Style::default().fg(theme.border)),
),
header[3],
);
(header[0], header[1])
}
fn draw_tab(frame: &mut Frame<'_>, area: Rect, label: &str, active: bool, theme: &Theme) {
let style = if active {
Style::default()
.fg(theme.text)
.bg(theme.accent_soft)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.muted).bg(theme.panel)
};
frame.render_widget(
Paragraph::new(label)
.alignment(Alignment::Center)
.style(style)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(if active {
theme.border_focus
} else {
theme.border
})),
),
area,
);
}
fn draw_sidebar(
frame: &mut Frame<'_>,
area: Rect,
app: &mut App,
theme: &Theme,
) -> Vec<(u16, SidebarHit)> {
match app.view {
View::Changes => draw_changes_sidebar(frame, area, app, theme),
View::History => draw_history_sidebar(frame, area, app, theme),
}
}
fn draw_changes_sidebar(
frame: &mut Frame<'_>,
area: Rect,
app: &mut App,
theme: &Theme,
) -> Vec<(u16, SidebarHit)> {
let block = panel_block(
if app.filter.is_empty() {
format!(" Changes {} ", app.status.changes.len())
} else {
format!(" Changes /{} ", app.filter)
},
app.focus == Focus::Sidebar && app.modal.is_none(),
theme,
);
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.height == 0 {
return Vec::new();
}
let list_area = inner;
let visible = app.visible_change_indices();
let row_count = change_row_count(app, &visible);
let height = list_area.height as usize;
let selected_row = selected_change_row(app, &visible);
ensure_offset(&mut app.sidebar_offset, selected_row, height, row_count);
let rows = build_change_rows(app, &visible);
let mut hits = Vec::new();
let end = (app.sidebar_offset + height).min(rows.len());
for (y, row) in
(list_area.y..list_area.bottom()).zip(rows.iter().take(end).skip(app.sidebar_offset))
{
match row {
ChangeRow::Header { area: group, count } => {
let selected = app.selected_change_group == Some(*group);
let background = if selected {
theme.selected
} else {
theme.panel_alt
};
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(" ▾ ", Style::default().fg(theme.muted)),
Span::styled(
group.label().to_uppercase(),
Style::default().fg(theme.text).add_modifier(Modifier::BOLD),
),
Span::styled(format!(" {count}"), Style::default().fg(theme.muted)),
]))
.style(Style::default().bg(background)),
Rect::new(list_area.x, y, list_area.width, 1),
);
hits.push((y, SidebarHit::ChangeGroup(*group)));
}
ChangeRow::Change {
index,
cursor,
change,
} => {
let selected = app.selected_change_group.is_none() && *cursor == app.change_cursor;
let row_style = if selected {
Style::default().bg(theme.selected)
} else {
Style::default().bg(theme.panel)
};
let path = change.parent_path();
let available = list_area.width.saturating_sub(8) as usize;
let name = truncate_middle(
&change.file_name(),
available.saturating_sub(path.width() + 1),
);
let line = Line::from(vec![
Span::styled(
if selected { " › " } else { " " },
Style::default().fg(theme.accent),
),
Span::styled(
name,
Style::default().fg(theme.text).add_modifier(if selected {
Modifier::BOLD
} else {
Modifier::empty()
}),
),
Span::styled(
if path.is_empty() {
String::new()
} else {
format!(" {path}")
},
Style::default().fg(theme.muted),
),
]);
frame.render_widget(
Paragraph::new(line).style(row_style),
Rect::new(list_area.x, y, list_area.width.saturating_sub(4), 1),
);
let action = match change.area {
ChangeArea::Staged => "−",
ChangeArea::Conflict => "!",
ChangeArea::Unstaged => "+",
};
let badge = format!("{action} {} ", change.status.code());
frame.render_widget(
Paragraph::new(badge).alignment(Alignment::Right).style(
Style::default()
.fg(status_color(change.status, theme))
.bg(if selected {
theme.selected
} else {
theme.panel
})
.add_modifier(Modifier::BOLD),
),
Rect::new(list_area.right().saturating_sub(5), y, 5, 1),
);
hits.push((y, SidebarHit::Change(*index)));
}
}
}
draw_scrollbar(frame, list_area, app.sidebar_offset, rows.len(), theme);
if visible.is_empty() {
let message = if app.status.changes.is_empty() {
"\n ✓ Working tree clean\n\n No pending changes"
} else {
"\n No changes match this filter"
};
frame.render_widget(
Paragraph::new(message)
.style(Style::default().fg(if app.status.changes.is_empty() {
theme.success
} else {
theme.muted
}))
.wrap(Wrap { trim: false }),
list_area,
);
}
hits
}
#[derive(Clone)]
enum ChangeRow<'a> {
Header {
area: ChangeArea,
count: usize,
},
Change {
index: usize,
cursor: usize,
change: &'a Change,
},
}
fn selected_change_row(app: &App, visible: &[usize]) -> usize {
let selected_index = visible.get(app.change_cursor).copied();
let selected_area = selected_index.map(|index| app.status.changes[index].area);
let mut row = 0;
for area in [
ChangeArea::Conflict,
ChangeArea::Staged,
ChangeArea::Unstaged,
] {
let group: Vec<_> = visible
.iter()
.filter(|index| app.status.changes[**index].area == area)
.copied()
.collect();
if group.is_empty() {
continue;
}
if app.selected_change_group == Some(area) {
return row;
}
row += 1;
if selected_area == Some(area) {
return row
+ group
.iter()
.position(|index| Some(*index) == selected_index)
.unwrap_or_default();
}
row += group.len();
}
0
}
fn change_row_count(app: &App, visible: &[usize]) -> usize {
let group_count = [
ChangeArea::Conflict,
ChangeArea::Staged,
ChangeArea::Unstaged,
]
.into_iter()
.filter(|area| {
visible
.iter()
.any(|index| app.status.changes[*index].area == *area)
})
.count();
visible.len() + group_count
}
fn build_change_rows<'a>(app: &'a App, visible: &[usize]) -> Vec<ChangeRow<'a>> {
let mut rows = Vec::new();
let mut cursor_map = HashMap::new();
for (cursor, index) in visible.iter().enumerate() {
cursor_map.insert(*index, cursor);
}
for area in [
ChangeArea::Conflict,
ChangeArea::Staged,
ChangeArea::Unstaged,
] {
let group: Vec<_> = visible
.iter()
.filter(|index| app.status.changes[**index].area == area)
.copied()
.collect();
if group.is_empty() {
continue;
}
rows.push(ChangeRow::Header {
area,
count: group.len(),
});
for index in group {
rows.push(ChangeRow::Change {
index,
cursor: cursor_map[&index],
change: &app.status.changes[index],
});
}
}
rows
}
fn draw_history_sidebar(
frame: &mut Frame<'_>,
area: Rect,
app: &mut App,
theme: &Theme,
) -> Vec<(u16, SidebarHit)> {
let title = if app.filter.is_empty() {
format!(
" Commit History {}{} ",
app.history.len(),
if app.history_complete { "" } else { "+" }
)
} else {
format!(" Commit History /{} ", app.filter)
};
let block = panel_block(
title,
app.focus == Focus::Sidebar && app.modal.is_none(),
theme,
);
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.height == 0 {
return Vec::new();
}
let visible = app.visible_commit_indices();
let height = inner.height as usize;
ensure_offset(
&mut app.sidebar_offset,
app.history_cursor,
height,
visible.len(),
);
let mut hits = Vec::new();
let end = (app.sidebar_offset + height).min(visible.len());
for (row_offset, index) in visible
.iter()
.take(end)
.skip(app.sidebar_offset)
.enumerate()
{
let cursor = app.sidebar_offset + row_offset;
let commit = &app.history[*index];
let selected = cursor == app.history_cursor;
let y = inner.y + row_offset as u16;
let row_style = Style::default().bg(if selected {
theme.selected
} else {
theme.panel
});
let graph = history_glyph(commit, cursor);
let badge = commit
.decorations
.first()
.map(|decoration| format!(" {}", clean_decoration(decoration)))
.unwrap_or_default();
let reserved = commit.short_id.width() + 8;
let subject = truncate_middle(
&commit.subject,
(inner.width as usize).saturating_sub(reserved + badge.width()),
);
let line = Line::from(vec![
Span::styled(
if selected { " › " } else { " " },
Style::default().fg(theme.accent),
),
Span::styled(graph, Style::default().fg(graph_color(cursor, theme))),
Span::styled(
subject,
Style::default().fg(theme.text).add_modifier(if selected {
Modifier::BOLD
} else {
Modifier::empty()
}),
),
Span::styled(badge, Style::default().fg(theme.modified)),
]);
frame.render_widget(
Paragraph::new(line).style(row_style),
Rect::new(inner.x, y, inner.width.saturating_sub(10), 1),
);
frame.render_widget(
Paragraph::new(commit.short_id.as_str())
.alignment(Alignment::Right)
.style(Style::default().fg(theme.muted).bg(if selected {
theme.selected
} else {
theme.panel
})),
Rect::new(inner.right().saturating_sub(10), y, 9, 1),
);
hits.push((y, SidebarHit::Commit(*index)));
}
draw_scrollbar(frame, inner, app.sidebar_offset, visible.len(), theme);
if visible.is_empty() {
frame.render_widget(
Paragraph::new(if app.history_loading {
"\n Loading commit history…"
} else if app.history.is_empty() {
"\n No commits yet"
} else {
"\n No commits match this filter"
})
.style(Style::default().fg(theme.muted)),
inner,
);
} else if app.history_loading && inner.height > 1 {
let area = Rect::new(inner.x, inner.bottom().saturating_sub(1), inner.width, 1);
frame.render_widget(
Paragraph::new(" Loading more commits…")
.style(Style::default().fg(theme.accent).bg(theme.panel_alt)),
area,
);
}
hits
}
fn draw_content(
frame: &mut Frame<'_>,
area: Rect,
app: &mut App,
theme: &Theme,
) -> (Option<Rect>, Vec<ContentFileHit>) {
let file_action = if app.document.file_count() > 0 {
if app.files_collapsed {
" [e Expand all]"
} else {
" [e Collapse all]"
}
} else {
""
};
let title = format!(
" {}{}{} ",
truncate_middle(&app.document.title, area.width.saturating_sub(34) as usize),
if app.document_loading { " ⟳" } else { "" },
file_action,
);
let block = panel_block(
title,
app.focus == Focus::Content && app.modal.is_none(),
theme,
);
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.width == 0 || inner.height == 0 {
return (None, Vec::new());
}
let details_rows = app
.document
.commit_details
.as_ref()
.map_or(0, |_| commit_details_row_count(inner.height));
let side_by_side = app.diff_layout == DiffLayout::SideBySide && inner.width >= 72;
let diff_rows = if side_by_side {
side_by_side_rows(&app.document, app).len()
} else {
unified_row_indices(&app.document, app).len()
};
let visual_length = details_rows + diff_rows;
let max_scroll = visual_length.saturating_sub(inner.height as usize);
app.content_scroll = app.content_scroll.min(max_scroll);
let mut diff_area = inner;
let mut diff_scroll = app.content_scroll;
match app.document.commit_details.as_ref() {
Some(details) if diff_scroll < details_rows => {
let visible_details = details_rows - diff_scroll;
let details_height = visible_details.min(inner.height as usize) as u16;
let details_area = Rect::new(inner.x, inner.y, inner.width, details_height);
draw_commit_details_scrolled(
frame,
details_area,
details,
&app.document,
diff_scroll,
details_rows,
theme,
);
diff_area = Rect::new(
inner.x,
inner.y.saturating_add(details_height),
inner.width,
inner.height.saturating_sub(details_height),
);
diff_scroll = 0;
}
_ => diff_scroll = diff_scroll.saturating_sub(details_rows),
}
let render_area = Rect::new(
diff_area.x,
diff_area.y,
diff_area.width.saturating_sub(1),
diff_area.height,
);
let (divider, content_file_hits) = if render_area.width < 2 || render_area.height == 0 {
(None, Vec::new())
} else if side_by_side {
let (divider, hits) = draw_side_by_side_diff(frame, render_area, app, diff_scroll, theme);
(Some(divider), hits)
} else {
let hits = draw_unified_diff(frame, render_area, app, diff_scroll, theme);
(None, hits)
};
draw_scrollbar(frame, inner, app.content_scroll, visual_length, theme);
(divider, content_file_hits)
}
fn commit_details_row_count(available_height: u16) -> usize {
7.min(available_height.saturating_sub(3)) as usize
}
fn draw_commit_details_scrolled(
frame: &mut Frame<'_>,
area: Rect,
details: &CommitDetails,
document: &DiffDocument,
scroll: usize,
total_rows: usize,
theme: &Theme,
) {
let block = Block::default()
.title(" Commit details ")
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.border))
.style(Style::default().bg(theme.panel_alt).fg(theme.text));
let full_area = Rect::new(0, 0, area.width, total_rows as u16);
let mut buffer = ratatui::buffer::Buffer::empty(full_area);
let inner = block.inner(full_area);
block.render(full_area, &mut buffer);
let file_count = document.file_count();
let lines = vec![
Line::from(Span::styled(
details.subject.as_str(),
Style::default().fg(theme.text).add_modifier(Modifier::BOLD),
)),
detail_line(
"Author",
format!(
"{} <{}> · {}",
details.author, details.author_email, details.authored_at
),
theme,
),
detail_line(
"Committer",
format!(
"{} <{}> · {}",
details.committer, details.committer_email, details.committed_at
),
theme,
),
detail_line("Commit", details.id.clone(), theme),
Line::from(vec![
Span::styled("Changes ", Style::default().fg(theme.muted)),
Span::styled(
format!(
"{} file{} changed ",
file_count,
if file_count == 1 { "" } else { "s" }
),
Style::default().fg(theme.text),
),
Span::styled(
format!("+{}", document.addition_count()),
Style::default()
.fg(theme.added)
.add_modifier(Modifier::BOLD),
),
Span::styled(" ", Style::default()),
Span::styled(
format!("-{}", document.deletion_count()),
Style::default()
.fg(theme.removed)
.add_modifier(Modifier::BOLD),
),
]),
];
Paragraph::new(lines).render(inner, &mut buffer);
for destination_row in 0..area.height {
let source_row = scroll as u16 + destination_row;
if source_row >= full_area.height {
break;
}
for column in 0..area.width {
let source = buffer[(column, source_row)].clone();
if let Some(destination) = frame
.buffer_mut()
.cell_mut((area.x + column, area.y + destination_row))
{
*destination = source;
}
}
}
}
fn detail_line<'a>(label: &'a str, value: String, theme: &Theme) -> Line<'a> {
Line::from(vec![
Span::styled(format!("{label:<10}"), Style::default().fg(theme.muted)),
Span::styled(value, Style::default().fg(theme.text)),
])
}
fn unified_row_indices(document: &DiffDocument, app: &App) -> Vec<usize> {
let mut rows = Vec::new();
let mut index = 0;
while index < document.lines.len() {
if document.lines[index].kind == DiffLineKind::HunkHeader {
index += 1;
continue;
}
rows.push(index);
let collapsed = document.lines[index].kind == DiffLineKind::FileHeader
&& file_header_path(&document.lines[index])
.is_some_and(|path| app.preview_file_collapsed(path));
if collapsed {
index += 1;
while index < document.lines.len()
&& document.lines[index].kind != DiffLineKind::FileFooter
{
index += 1;
}
if index < document.lines.len() {
index += 1;
}
} else {
index += 1;
}
}
rows
}
fn draw_unified_diff(
frame: &mut Frame<'_>,
area: Rect,
app: &App,
diff_scroll: usize,
theme: &Theme,
) -> Vec<ContentFileHit> {
let rows = unified_row_indices(&app.document, app);
let first_index = rows.get(diff_scroll).copied().unwrap_or_default();
let mut in_file = inside_file_before(&app.document, first_index);
let emphasis = intraline_emphasis(&app.document.lines);
let sticky = (first_index < app.document.lines.len()
&& app.document.lines[first_index].kind != DiffLineKind::FileHeader)
.then(|| sticky_file_header(&app.document, first_index))
.flatten();
let content_y = area.y + u16::from(sticky.is_some());
let content_height = area.height.saturating_sub(u16::from(sticky.is_some()));
let mut hits = Vec::new();
if let Some(header) = sticky {
let sticky_area = Rect::new(area.x, area.y, area.width, 1);
draw_file_header(frame, sticky_area, header, app, theme);
if let Some(path) = file_header_path(header) {
hits.push(ContentFileHit {
area: sticky_area,
path: path.into(),
});
}
}
for (offset, line_index) in rows
.iter()
.copied()
.skip(diff_scroll)
.take(content_height as usize)
.enumerate()
{
let line = &app.document.lines[line_index];
let row_area = Rect::new(area.x, content_y + offset as u16, area.width, 1);
match line.kind {
DiffLineKind::FileHeader => {
draw_file_header(frame, row_area, line, app, theme);
if let Some(path) = file_header_path(line) {
hits.push(ContentFileHit {
area: row_area,
path: path.into(),
});
}
in_file = true;
}
DiffLineKind::FileFooter => {
draw_file_footer(frame, row_area, theme);
in_file = false;
}
_ => draw_unified_line(
frame,
row_area,
line,
in_file,
app.horizontal_scroll,
emphasis[line_index].as_ref(),
theme,
),
}
}
hits
}
fn draw_unified_line(
frame: &mut Frame<'_>,
area: Rect,
line: &DiffLine,
boxed: bool,
horizontal_scroll: usize,
emphasis: Option<&Range<usize>>,
theme: &Theme,
) {
let content_area = if boxed {
draw_file_edges(frame, area, line.kind, theme);
Rect::new(area.x + 1, area.y, area.width.saturating_sub(2), 1)
} else {
area
};
let old = line
.old_line
.map_or(String::new(), |number| number.to_string());
let new = line
.new_line
.map_or(String::new(), |number| number.to_string());
let (marker, marker_style) = marker_for(line.kind, theme);
let mut spans = vec![
Span::styled(format!("{old:>4} "), Style::default().fg(theme.muted)),
Span::styled(format!("{new:>4} "), Style::default().fg(theme.muted)),
Span::styled(marker, marker_style),
];
spans.extend(highlight_spans(
&line.spans,
horizontal_scroll,
content_area.width.saturating_sub(12) as usize,
line.kind,
emphasis,
theme,
));
frame.render_widget(
Paragraph::new(Line::from(spans)).style(line_background(line.kind, theme)),
content_area,
);
}
fn inside_file_before(document: &DiffDocument, offset: usize) -> bool {
let mut in_file = false;
for line in document.lines.iter().take(offset) {
match line.kind {
DiffLineKind::FileHeader => in_file = true,
DiffLineKind::FileFooter => in_file = false,
_ => {}
}
}
in_file
}
fn sticky_file_header(document: &DiffDocument, line_index: usize) -> Option<&DiffLine> {
let mut header = None;
for line in document.lines.iter().take(line_index.saturating_add(1)) {
match line.kind {
DiffLineKind::FileHeader => header = Some(line),
DiffLineKind::FileFooter => header = None,
_ => {}
}
}
header
}
fn file_header_path(line: &DiffLine) -> Option<&str> {
line.spans
.first()
.map(|span| span.text.split(" · ").next().unwrap_or(span.text.as_str()))
}
fn draw_file_header(frame: &mut Frame<'_>, area: Rect, line: &DiffLine, app: &App, theme: &Theme) {
if area.width == 0 {
return;
}
let label = line
.spans
.first()
.map(|span| span.text.as_str())
.unwrap_or_default();
let disclosure = if file_header_path(line).is_some_and(|path| app.preview_file_collapsed(path))
{
"›"
} else {
"⌄"
};
let additions = line
.spans
.get(1)
.map(|span| span.text.as_str())
.unwrap_or("+0");
let deletions = line
.spans
.get(2)
.map(|span| span.text.as_str())
.unwrap_or("-0");
let reserved = 9usize + additions.width() + deletions.width();
let label = truncate_middle(label, (area.width as usize).saturating_sub(reserved));
let fill = (area.width as usize)
.saturating_sub(reserved)
.saturating_sub(label.width());
let selected = file_header_path(line).is_some_and(|path| app.preview_file_selected(path));
let border = if selected {
theme.border_focus
} else {
theme.border
};
let background = if selected {
theme.selected
} else {
theme.panel_alt
};
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled("┌─", Style::default().fg(border)),
Span::styled(format!(" {disclosure} "), Style::default().fg(theme.muted)),
Span::styled(
label,
Style::default().fg(theme.text).add_modifier(Modifier::BOLD),
),
Span::styled("─".repeat(fill), Style::default().fg(border)),
Span::styled(" ", Style::default()),
Span::styled(
additions.to_owned(),
Style::default()
.fg(theme.added)
.add_modifier(Modifier::BOLD),
),
Span::styled(" ", Style::default()),
Span::styled(
deletions.to_owned(),
Style::default()
.fg(theme.removed)
.add_modifier(Modifier::BOLD),
),
Span::styled(" ┐", Style::default().fg(border)),
]))
.style(Style::default().bg(background)),
area,
);
}
fn draw_file_footer(frame: &mut Frame<'_>, area: Rect, theme: &Theme) {
if area.width == 0 {
return;
}
let text = if area.width == 1 {
"└".to_owned()
} else {
format!("└{}┘", "─".repeat(area.width.saturating_sub(2) as usize))
};
frame.render_widget(
Paragraph::new(text).style(Style::default().fg(theme.border).bg(theme.panel)),
area,
);
}
fn draw_file_edges(frame: &mut Frame<'_>, area: Rect, kind: DiffLineKind, theme: &Theme) {
if area.width < 2 {
return;
}
let background = match kind {
DiffLineKind::Added => theme.added_background,
DiffLineKind::Removed => theme.removed_background,
DiffLineKind::HunkHeader => theme.panel_alt,
_ => theme.panel,
};
let style = Style::default().fg(theme.border).bg(background);
frame.render_widget(
Paragraph::new("│").style(style),
Rect::new(area.x, area.y, 1, 1),
);
frame.render_widget(
Paragraph::new("│").style(style),
Rect::new(area.right().saturating_sub(1), area.y, 1, 1),
);
}
fn draw_side_by_side_diff(
frame: &mut Frame<'_>,
area: Rect,
app: &App,
diff_scroll: usize,
theme: &Theme,
) -> (Rect, Vec<ContentFileHit>) {
let content = Rect::new(
area.x + 1,
area.y,
area.width.saturating_sub(2),
area.height,
);
let usable_width = content.width.saturating_sub(1);
let left_width = usable_width.saturating_mul(app.diff_split_percent) / 100;
let left = Rect::new(content.x, content.y, left_width, content.height);
let divider = Rect::new(left.right(), area.y, 1, area.height);
let right = Rect::new(
divider.right(),
area.y,
content.right().saturating_sub(divider.right()),
area.height,
);
let divider_color = if app.resize_target == Some(crate::app::ResizeTarget::Diff) {
theme.border_focus
} else {
theme.border
};
let rows = side_by_side_rows(&app.document, app);
let sticky = rows.get(diff_scroll).and_then(|first| match first {
SideBySideRow::FileHeader(_) | SideBySideRow::FileFooter => None,
_ => rows[..diff_scroll].iter().rev().find_map(|row| match row {
SideBySideRow::FileHeader(header) => Some(*header),
SideBySideRow::FileFooter => None,
_ => None,
}),
});
let content_y = area.y + u16::from(sticky.is_some());
let content_height = area.height.saturating_sub(u16::from(sticky.is_some()));
let mut hits = Vec::new();
if let Some(header) = sticky {
let sticky_area = Rect::new(area.x, area.y, area.width, 1);
draw_file_header(frame, sticky_area, header, app, theme);
if let Some(path) = file_header_path(header) {
hits.push(ContentFileHit {
area: sticky_area,
path: path.into(),
});
}
}
for (offset, row) in rows
.iter()
.skip(diff_scroll)
.take(content_height as usize)
.enumerate()
{
let y = content_y + offset as u16;
let row_area = Rect::new(area.x, y, area.width, 1);
match row {
SideBySideRow::FileHeader(line) => {
draw_file_header(frame, row_area, line, app, theme);
if let Some(path) = file_header_path(line) {
hits.push(ContentFileHit {
area: row_area,
path: path.into(),
});
}
}
SideBySideRow::FileFooter => draw_file_footer(frame, row_area, theme),
SideBySideRow::Full { line, boxed } => draw_full_width_diff_line(
frame,
row_area,
line,
*boxed,
app.horizontal_scroll,
theme,
),
SideBySideRow::Split(old_line, new_line) => {
let (old_emphasis, new_emphasis) = paired_intraline_emphasis(*old_line, *new_line);
draw_diff_side(
frame,
Rect::new(left.x, y, left.width, 1),
*old_line,
true,
app.horizontal_scroll,
old_emphasis.as_ref(),
theme,
);
frame.render_widget(
Paragraph::new("│").style(Style::default().fg(divider_color).bg(theme.panel)),
Rect::new(divider.x, y, 1, 1),
);
draw_diff_side(
frame,
Rect::new(right.x, y, right.width, 1),
*new_line,
false,
app.horizontal_scroll,
new_emphasis.as_ref(),
theme,
);
let edge_kind = old_line
.or(*new_line)
.map_or(DiffLineKind::Context, |line| line.kind);
draw_file_edges(frame, row_area, edge_kind, theme);
}
}
}
(divider, hits)
}
enum SideBySideRow<'a> {
FileHeader(&'a DiffLine),
FileFooter,
Full { line: &'a DiffLine, boxed: bool },
Split(Option<&'a DiffLine>, Option<&'a DiffLine>),
}
fn side_by_side_rows<'a>(document: &'a DiffDocument, app: &App) -> Vec<SideBySideRow<'a>> {
let mut rows = Vec::new();
let mut index = 0;
let mut in_file = false;
while index < document.lines.len() {
let line = &document.lines[index];
match line.kind {
DiffLineKind::FileHeader => {
rows.push(SideBySideRow::FileHeader(line));
in_file = true;
index += 1;
if file_header_path(line).is_some_and(|path| app.preview_file_collapsed(path)) {
while index < document.lines.len()
&& document.lines[index].kind != DiffLineKind::FileFooter
{
index += 1;
}
if index < document.lines.len() {
index += 1;
}
in_file = false;
}
}
DiffLineKind::FileFooter => {
rows.push(SideBySideRow::FileFooter);
in_file = false;
index += 1;
}
DiffLineKind::HunkHeader => {
index += 1;
}
DiffLineKind::Meta => {
rows.push(SideBySideRow::Full {
line,
boxed: in_file,
});
index += 1;
}
DiffLineKind::Added => {
rows.push(SideBySideRow::Split(None, Some(line)));
index += 1;
}
DiffLineKind::Context => {
rows.push(SideBySideRow::Split(Some(line), Some(line)));
index += 1;
}
DiffLineKind::Removed => {
let removed_start = index;
while index < document.lines.len()
&& document.lines[index].kind == DiffLineKind::Removed
{
index += 1;
}
let added_start = index;
while index < document.lines.len()
&& document.lines[index].kind == DiffLineKind::Added
{
index += 1;
}
let removed = &document.lines[removed_start..added_start];
let added = &document.lines[added_start..index];
for pair_index in 0..removed.len().max(added.len()) {
rows.push(SideBySideRow::Split(
removed.get(pair_index),
added.get(pair_index),
));
}
}
}
}
rows
}
fn intraline_emphasis(lines: &[DiffLine]) -> Vec<Option<Range<usize>>> {
let mut emphasis = vec![None; lines.len()];
let mut index = 0;
while index < lines.len() {
if lines[index].kind != DiffLineKind::Removed {
index += 1;
continue;
}
let removed_start = index;
while index < lines.len() && lines[index].kind == DiffLineKind::Removed {
index += 1;
}
let added_start = index;
while index < lines.len() && lines[index].kind == DiffLineKind::Added {
index += 1;
}
let pair_count = (added_start - removed_start).min(index - added_start);
for pair_index in 0..pair_count {
let old_index = removed_start + pair_index;
let new_index = added_start + pair_index;
let (old_range, new_range) =
paired_intraline_emphasis(Some(&lines[old_index]), Some(&lines[new_index]));
emphasis[old_index] = old_range;
emphasis[new_index] = new_range;
}
}
emphasis
}
fn paired_intraline_emphasis(
old_line: Option<&DiffLine>,
new_line: Option<&DiffLine>,
) -> (Option<Range<usize>>, Option<Range<usize>>) {
let (Some(old_line), Some(new_line)) = (old_line, new_line) else {
return (None, None);
};
if old_line.kind != DiffLineKind::Removed || new_line.kind != DiffLineKind::Added {
return (None, None);
}
changed_ranges(&old_line.text(), &new_line.text())
}
fn changed_ranges(old: &str, new: &str) -> (Option<Range<usize>>, Option<Range<usize>>) {
let mut prefix = 0;
for ((old_index, old_character), (new_index, new_character)) in
old.char_indices().zip(new.char_indices())
{
if old_character != new_character || old_index != new_index {
break;
}
prefix = old_index + old_character.len_utf8();
}
let mut old_end = old.len();
let mut new_end = new.len();
for ((old_index, old_character), (new_index, new_character)) in
old.char_indices().rev().zip(new.char_indices().rev())
{
if old_character != new_character || old_index < prefix || new_index < prefix {
break;
}
old_end = old_index;
new_end = new_index;
}
(
(prefix < old_end).then_some(prefix..old_end),
(prefix < new_end).then_some(prefix..new_end),
)
}
fn draw_full_width_diff_line(
frame: &mut Frame<'_>,
area: Rect,
line: &DiffLine,
boxed: bool,
horizontal_scroll: usize,
theme: &Theme,
) {
let content_area = if boxed {
draw_file_edges(frame, area, line.kind, theme);
Rect::new(area.x + 1, area.y, area.width.saturating_sub(2), 1)
} else {
area
};
let (marker, marker_style) = marker_for(line.kind, theme);
let mut spans = vec![Span::styled(marker, marker_style)];
spans.extend(highlight_spans(
&line.spans,
horizontal_scroll,
content_area.width.saturating_sub(2) as usize,
line.kind,
None,
theme,
));
frame.render_widget(
Paragraph::new(Line::from(spans)).style(line_background(line.kind, theme)),
content_area,
);
}
fn draw_diff_side(
frame: &mut Frame<'_>,
area: Rect,
line: Option<&DiffLine>,
old_side: bool,
horizontal_scroll: usize,
emphasis: Option<&Range<usize>>,
theme: &Theme,
) {
let Some(line) = line else {
frame.render_widget(
Paragraph::new(" ").style(Style::default().bg(theme.panel_alt)),
area,
);
return;
};
let number = if old_side {
line.old_line
} else {
line.new_line
};
let number = number.map_or(String::new(), |number| number.to_string());
let (marker, marker_style) = marker_for(line.kind, theme);
let mut spans = vec![
Span::styled(format!("{number:>4} "), Style::default().fg(theme.muted)),
Span::styled(marker, marker_style),
];
spans.extend(highlight_spans(
&line.spans,
horizontal_scroll,
area.width.saturating_sub(7) as usize,
line.kind,
emphasis,
theme,
));
frame.render_widget(
Paragraph::new(Line::from(spans)).style(line_background(line.kind, theme)),
area,
);
}
fn highlight_spans<'a>(
spans: &'a [HighlightSpan],
horizontal_scroll: usize,
width: usize,
kind: DiffLineKind,
emphasis: Option<&Range<usize>>,
theme: &Theme,
) -> Vec<Span<'a>> {
let mut skip = horizontal_scroll;
let mut remaining = width;
let mut source_offset = 0;
let mut output = Vec::new();
for span in spans {
if remaining == 0 {
break;
}
let foreground = span
.foreground
.map(|(r, g, b)| Color::Rgb(r, g, b))
.unwrap_or_else(|| line_foreground(kind, theme));
let mut style = Style::default().fg(foreground);
if span.bold {
style = style.add_modifier(Modifier::BOLD);
}
if span.italic {
style = style.add_modifier(Modifier::ITALIC);
}
let span_start = source_offset;
let span_end = span_start + span.text.len();
let intersection = emphasis.and_then(|range| {
let start = range.start.max(span_start);
let end = range.end.min(span_end);
(start < end).then_some(start..end)
});
if let Some(changed) = intersection {
push_highlight_piece(
&mut output,
&span.text[..changed.start - span_start],
style,
false,
kind,
theme,
&mut skip,
&mut remaining,
);
push_highlight_piece(
&mut output,
&span.text[changed.start - span_start..changed.end - span_start],
style,
true,
kind,
theme,
&mut skip,
&mut remaining,
);
push_highlight_piece(
&mut output,
&span.text[changed.end - span_start..],
style,
false,
kind,
theme,
&mut skip,
&mut remaining,
);
} else {
push_highlight_piece(
&mut output,
&span.text,
style,
false,
kind,
theme,
&mut skip,
&mut remaining,
);
}
source_offset = span_end;
}
output
}
#[allow(clippy::too_many_arguments)]
fn push_highlight_piece<'a>(
output: &mut Vec<Span<'a>>,
text: &str,
mut style: Style,
emphasized: bool,
kind: DiffLineKind,
theme: &Theme,
skip: &mut usize,
remaining: &mut usize,
) {
if text.is_empty() || *remaining == 0 {
return;
}
let text_width = text.width();
if *skip >= text_width {
*skip -= text_width;
return;
}
if emphasized {
style = match kind {
DiffLineKind::Added => style.bg(theme.added_emphasis_background),
DiffLineKind::Removed => style.bg(theme.removed_emphasis_background),
_ => style,
};
}
let sliced = slice_width(text, *skip, *remaining);
*skip = 0;
*remaining = remaining.saturating_sub(sliced.width());
output.push(Span::styled(sliced, style));
}
fn marker_for(kind: DiffLineKind, theme: &Theme) -> (&'static str, Style) {
match kind {
DiffLineKind::Added => (" ", Style::default().fg(theme.added)),
DiffLineKind::Removed => (" ", Style::default().fg(theme.removed)),
DiffLineKind::HunkHeader => (
" ",
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
),
DiffLineKind::Context => (" ", Style::default().fg(theme.muted)),
DiffLineKind::Meta => (" ", Style::default().fg(theme.muted)),
DiffLineKind::FileHeader | DiffLineKind::FileFooter => {
("", Style::default().fg(theme.muted))
}
}
}
fn line_background(kind: DiffLineKind, theme: &Theme) -> Style {
match kind {
DiffLineKind::Added => Style::default().bg(theme.added_background),
DiffLineKind::Removed => Style::default().bg(theme.removed_background),
DiffLineKind::HunkHeader => Style::default().bg(theme.panel_alt).fg(theme.accent),
_ => Style::default().bg(theme.panel),
}
}
fn line_foreground(kind: DiffLineKind, theme: &Theme) -> Color {
match kind {
DiffLineKind::Added => theme.added,
DiffLineKind::Removed => theme.removed,
DiffLineKind::HunkHeader => theme.accent,
_ => theme.text,
}
}
fn draw_scrollbar(frame: &mut Frame<'_>, area: Rect, offset: usize, length: usize, theme: &Theme) {
if length <= area.height as usize || area.width == 0 {
return;
}
let height = area.height as usize;
let thumb_height = (height * height / length).max(1).min(height);
let max_offset = length.saturating_sub(height).max(1);
let thumb_start = offset.min(max_offset) * (height - thumb_height) / max_offset;
for row in 0..height {
let color = if (thumb_start..thumb_start + thumb_height).contains(&row) {
theme.accent_soft
} else {
theme.border
};
frame.render_widget(
Paragraph::new("▐").style(Style::default().fg(color)),
Rect::new(area.right().saturating_sub(1), area.y + row as u16, 1, 1),
);
}
}
fn draw_footer(frame: &mut Frame<'_>, area: Rect, app: &App, theme: &Theme) {
let left = if let Some(busy) = app.busy.as_deref() {
Line::from(vec![
Span::styled(
" ⟳ ",
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
),
Span::styled(busy, Style::default().fg(theme.text)),
])
} else if app.refreshing {
Line::from(vec![
Span::styled(" ⟳ ", Style::default().fg(theme.accent)),
Span::styled("Refreshing repository…", Style::default().fg(theme.muted)),
])
} else {
Line::from(vec![
Span::styled(" ", Style::default().fg(theme.accent)),
Span::styled(
if app.status.branch.head.is_empty() {
"—"
} else {
&app.status.branch.head
},
Style::default().fg(theme.text).add_modifier(Modifier::BOLD),
),
Span::styled(
format!(
" {} changes {} staged",
app.status.changes.len(),
app.status.staged_count()
),
Style::default().fg(theme.muted),
),
])
};
frame.render_widget(
Paragraph::new(left)
.style(Style::default().bg(theme.panel_alt))
.block(
Block::default()
.borders(Borders::TOP)
.border_style(Style::default().fg(theme.border)),
),
area,
);
}
fn draw_modal(frame: &mut Frame<'_>, modal: &Modal, app: &App, theme: &Theme) {
match modal {
Modal::Help { scroll } => draw_help(frame, *scroll, theme),
Modal::Commit { input, amend } => draw_commit(frame, input, *amend, theme),
Modal::Prompt { title, input, .. } => draw_prompt(frame, title, input, theme),
Modal::Confirm { title, message, .. } => draw_confirm(frame, title, message, theme),
Modal::Branches {
items,
selected,
query,
loading,
..
} => draw_branches(frame, items, *selected, query, *loading, theme),
Modal::CommandPalette { query, selected } => {
draw_palette(frame, app, query, *selected, theme);
}
Modal::Conflict { change } => draw_conflict(frame, change, theme),
}
}
fn draw_help(frame: &mut Frame<'_>, scroll: usize, theme: &Theme) {
let area = centered_rect(
68,
31.min(frame.area().height.saturating_sub(4)),
frame.area(),
);
frame.render_widget(Clear, area);
let block = modal_block(" Keyboard Shortcuts ", theme);
let inner = block.inner(area);
frame.render_widget(block, area);
let lines = HELP_LINES
.iter()
.skip(scroll)
.take(inner.height.saturating_sub(1) as usize)
.map(|(key, description)| {
if description.is_empty() && !key.is_empty() {
Line::from(Span::styled(
*key,
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
))
} else {
Line::from(vec![
Span::styled(format!("{key:<20}"), Style::default().fg(theme.modified)),
Span::styled(*description, Style::default().fg(theme.text)),
])
}
})
.collect::<Vec<_>>();
frame.render_widget(Paragraph::new(lines), inner);
draw_modal_hint(frame, area, "Esc close", theme);
}
fn draw_commit(frame: &mut Frame<'_>, input: &crate::app::TextBuffer, amend: bool, theme: &Theme) {
let width = frame.area().width.saturating_sub(12).min(76);
let area = centered_rect(width, 12, frame.area());
frame.render_widget(Clear, area);
let block = modal_block(
if amend {
" Amend Commit "
} else {
" Commit Staged Changes "
},
theme,
);
let inner = block.inner(area);
frame.render_widget(block, area);
let input_area = Rect::new(
inner.x,
inner.y + 1,
inner.width,
inner.height.saturating_sub(3),
);
frame.render_widget(
Paragraph::new(input.value.as_str())
.style(Style::default().fg(theme.text).bg(theme.panel_alt))
.wrap(Wrap { trim: false })
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.border_focus)),
),
input_area,
);
set_text_cursor(frame, input_area.inner(Margin::new(1, 1)), input, true);
frame.render_widget(
Paragraph::new("Ctrl+Enter commit Enter newline Esc cancel")
.style(Style::default().fg(theme.muted)),
Rect::new(inner.x, inner.bottom().saturating_sub(1), inner.width, 1),
);
}
fn draw_prompt(frame: &mut Frame<'_>, title: &str, input: &crate::app::TextBuffer, theme: &Theme) {
let area = centered_rect(
frame.area().width.saturating_sub(14).min(68),
7,
frame.area(),
);
frame.render_widget(Clear, area);
let block = modal_block(&format!(" {title} "), theme);
let inner = block.inner(area);
frame.render_widget(block, area);
let input_area = Rect::new(inner.x, inner.y + 1, inner.width, 1);
frame.render_widget(
Paragraph::new(input.value.as_str())
.style(Style::default().fg(theme.text).bg(theme.panel_alt)),
input_area,
);
set_text_cursor(frame, input_area, input, false);
draw_modal_hint(frame, area, "Enter accept Esc cancel", theme);
}
fn draw_confirm(frame: &mut Frame<'_>, title: &str, message: &str, theme: &Theme) {
let area = centered_rect(
frame.area().width.saturating_sub(14).min(72),
9,
frame.area(),
);
frame.render_widget(Clear, area);
let block = modal_block(&format!(" {title} "), theme);
let inner = block.inner(area);
frame.render_widget(block, area);
frame.render_widget(
Paragraph::new(message)
.style(Style::default().fg(theme.text))
.wrap(Wrap { trim: true }),
Rect::new(
inner.x,
inner.y + 1,
inner.width,
inner.height.saturating_sub(3),
),
);
draw_modal_hint(frame, area, "y / Enter confirm n / Esc cancel", theme);
}
fn draw_conflict(frame: &mut Frame<'_>, change: &Change, theme: &Theme) {
let area = centered_rect(
frame.area().width.saturating_sub(14).min(72),
10,
frame.area(),
);
frame.render_widget(Clear, area);
let block = modal_block(" Resolve Merge Conflict ", theme);
let inner = block.inner(area);
frame.render_widget(block, area);
frame.render_widget(
Paragraph::new(vec![
Line::from(Span::styled(
change.display_path(),
Style::default().fg(theme.text).add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(vec![
Span::styled(
"o",
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
),
Span::raw(" accept ours "),
Span::styled(
"t",
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
),
Span::raw(" accept theirs "),
Span::styled(
"s",
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
),
Span::raw(" mark resolved"),
]),
]),
Rect::new(
inner.x,
inner.y + 1,
inner.width,
inner.height.saturating_sub(2),
),
);
draw_modal_hint(frame, area, "Esc cancel", theme);
}
fn draw_branches(
frame: &mut Frame<'_>,
items: &[Branch],
selected: usize,
query: &crate::app::TextBuffer,
loading: bool,
theme: &Theme,
) {
let height = frame.area().height.saturating_sub(8).min(25);
let area = centered_rect(
frame.area().width.saturating_sub(12).min(76),
height,
frame.area(),
);
frame.render_widget(Clear, area);
let block = modal_block(" Branches ", theme);
let inner = block.inner(area);
frame.render_widget(block, area);
let query_area = Rect::new(inner.x, inner.y, inner.width, 1);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(" / ", Style::default().fg(theme.accent)),
Span::styled(query.value.as_str(), Style::default().fg(theme.text)),
]))
.style(Style::default().bg(theme.panel_alt)),
query_area,
);
set_text_cursor(
frame,
Rect::new(
query_area.x + 3,
query_area.y,
query_area.width.saturating_sub(3),
1,
),
query,
false,
);
let visible = App::filtered_branches(items, &query.value);
let list_area = Rect::new(
inner.x,
inner.y + 2,
inner.width,
inner.height.saturating_sub(4),
);
if loading {
frame.render_widget(
Paragraph::new("Loading branches…").style(Style::default().fg(theme.muted)),
list_area,
);
} else {
let offset = selected.saturating_sub(list_area.height.saturating_sub(1) as usize);
let lines = visible
.iter()
.skip(offset)
.take(list_area.height as usize)
.enumerate()
.filter_map(|(visible_offset, index)| {
let branch = items.get(*index)?;
let cursor = offset + visible_offset;
let style = if cursor == selected {
Style::default()
.bg(theme.selected)
.fg(theme.text)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.text)
};
Some(Line::from(vec![
Span::styled(
if branch.current { " ● " } else { " " },
Style::default()
.fg(if branch.current {
theme.success
} else {
theme.muted
})
.bg(style.bg.unwrap_or(theme.panel)),
),
Span::styled(
truncate_middle(&branch.name, list_area.width.saturating_sub(30) as usize),
style,
),
Span::styled(
format!(" {} {}", branch.short_id, branch.relative_date),
Style::default()
.fg(theme.muted)
.bg(style.bg.unwrap_or(theme.panel)),
),
]))
})
.collect::<Vec<_>>();
frame.render_widget(Paragraph::new(lines), list_area);
}
draw_modal_hint(
frame,
area,
"Enter switch Ctrl+n new Delete delete Esc close",
theme,
);
}
fn draw_palette(
frame: &mut Frame<'_>,
app: &App,
query: &crate::app::TextBuffer,
selected: usize,
theme: &Theme,
) {
let commands = app.palette_commands(&query.value);
let height = (commands.len() as u16 + 6)
.min(frame.area().height.saturating_sub(6))
.max(8);
let area = Rect::new(
frame.area().x
+ (frame
.area()
.width
.saturating_sub(76.min(frame.area().width.saturating_sub(8))))
/ 2,
frame.area().y + 3,
76.min(frame.area().width.saturating_sub(8)),
height,
);
frame.render_widget(Clear, area);
let block = modal_block(" Command Palette ", theme);
let inner = block.inner(area);
frame.render_widget(block, area);
let query_area = Rect::new(inner.x, inner.y, inner.width, 1);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(" > ", Style::default().fg(theme.accent)),
Span::styled(query.value.as_str(), Style::default().fg(theme.text)),
]))
.style(Style::default().bg(theme.panel_alt)),
query_area,
);
set_text_cursor(
frame,
Rect::new(
query_area.x + 3,
query_area.y,
query_area.width.saturating_sub(3),
1,
),
query,
false,
);
let list_area = Rect::new(
inner.x,
inner.y + 2,
inner.width,
inner.height.saturating_sub(2),
);
let offset = selected.saturating_sub(list_area.height.saturating_sub(1) as usize);
let lines = commands
.iter()
.skip(offset)
.take(list_area.height as usize)
.enumerate()
.map(|(index, command)| palette_line(*command, offset + index == selected, theme))
.collect::<Vec<_>>();
frame.render_widget(Paragraph::new(lines), list_area);
}
fn palette_line(command: PaletteCommand, selected: bool, theme: &Theme) -> Line<'static> {
let background = if selected {
theme.selected
} else {
theme.panel
};
Line::from(vec![
Span::styled(
if selected { " › " } else { " " },
Style::default().fg(theme.accent).bg(background),
),
Span::styled(
command.label(),
Style::default()
.fg(theme.text)
.bg(background)
.add_modifier(if selected {
Modifier::BOLD
} else {
Modifier::empty()
}),
),
])
}
fn draw_toast(frame: &mut Frame<'_>, message: &str, level: ToastLevel, theme: &Theme) {
let width = (message.width() as u16 + 6)
.min(frame.area().width.saturating_sub(4))
.max(24);
let height = ((message.width() as u16 / width.max(1)) + 3).min(7);
let area = Rect::new(
frame.area().right().saturating_sub(width + 2),
frame.area().bottom().saturating_sub(height + 3),
width,
height,
);
let color = match level {
ToastLevel::Info => theme.accent,
ToastLevel::Success => theme.success,
ToastLevel::Error => theme.error,
};
frame.render_widget(Clear, area);
frame.render_widget(
Paragraph::new(message)
.style(Style::default().fg(theme.text).bg(theme.panel_alt))
.wrap(Wrap { trim: true })
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(color)),
),
area,
);
}
fn draw_modal_hint(frame: &mut Frame<'_>, area: Rect, hint: &str, theme: &Theme) {
frame.render_widget(
Paragraph::new(hint)
.alignment(Alignment::Right)
.style(Style::default().fg(theme.muted).bg(theme.panel)),
Rect::new(
area.x + 2,
area.bottom().saturating_sub(2),
area.width.saturating_sub(4),
1,
),
);
}
fn set_text_cursor(
frame: &mut Frame<'_>,
area: Rect,
input: &crate::app::TextBuffer,
multiline: bool,
) {
let before = &input.value[..input.cursor.min(input.value.len())];
let (row, column) = if multiline {
let row = before
.chars()
.filter(|character| *character == '\n')
.count();
let column = before.rsplit('\n').next().unwrap_or_default().width();
(row, column)
} else {
(0, before.replace('\n', " ").width())
};
let x = area
.x
.saturating_add(column.min(area.width.saturating_sub(1) as usize) as u16);
let y = area
.y
.saturating_add(row.min(area.height.saturating_sub(1) as usize) as u16);
frame.set_cursor_position((x, y));
}
fn panel_block(title: String, focused: bool, theme: &Theme) -> Block<'static> {
Block::default()
.title(title)
.borders(Borders::ALL)
.border_type(BorderType::Plain)
.border_style(Style::default().fg(if focused {
theme.border_focus
} else {
theme.border
}))
.style(Style::default().bg(theme.panel).fg(theme.text))
}
fn modal_block(title: &str, theme: &Theme) -> Block<'static> {
Block::default()
.title(title.to_owned())
.title_alignment(Alignment::Left)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.border_focus))
.style(Style::default().bg(theme.panel).fg(theme.text))
}
fn status_color(status: ChangeStatus, theme: &Theme) -> Color {
match status {
ChangeStatus::Added | ChangeStatus::Untracked => theme.added,
ChangeStatus::Deleted => theme.removed,
ChangeStatus::Conflicted => theme.conflict,
ChangeStatus::Modified
| ChangeStatus::Renamed
| ChangeStatus::Copied
| ChangeStatus::TypeChanged => theme.modified,
}
}
fn graph_color(index: usize, theme: &Theme) -> Color {
match index % 4 {
0 => theme.accent,
1 => theme.modified,
2 => theme.added,
_ => theme.conflict,
}
}
fn history_glyph(commit: &crate::git::history::Commit, index: usize) -> &'static str {
if commit.parent_ids.len() > 1 {
"●╮ "
} else if index > 0 {
"●│ "
} else {
"● "
}
}
fn clean_decoration(decoration: &str) -> &str {
decoration.strip_prefix("HEAD -> ").unwrap_or(decoration)
}
fn ensure_offset(offset: &mut usize, cursor: usize, height: usize, length: usize) {
if height == 0 || length == 0 {
*offset = 0;
return;
}
if cursor < *offset {
*offset = cursor;
} else if cursor >= *offset + height {
*offset = cursor + 1 - height;
}
*offset = (*offset).min(length.saturating_sub(height));
}
fn centered_rect(width: u16, height: u16, outer: Rect) -> Rect {
let width = width.min(outer.width);
let height = height.min(outer.height);
Rect::new(
outer.x + (outer.width - width) / 2,
outer.y + (outer.height - height) / 2,
width,
height,
)
}
fn truncate_middle(value: &str, width: usize) -> String {
if width == 0 {
return String::new();
}
if value.width() <= width {
return value.to_owned();
}
if width <= 1 {
return "…".to_owned();
}
let left_width = (width - 1) * 2 / 3;
let right_width = width - 1 - left_width;
format!(
"{}…{}",
slice_width(value, 0, left_width),
suffix_width(value, right_width)
)
}
fn slice_width(value: &str, skip: usize, width: usize) -> String {
let mut skipped = 0;
let mut used = 0;
let mut output = String::new();
for character in value.chars() {
let character_width = character.width().unwrap_or_default();
if skipped + character_width <= skip {
skipped += character_width;
continue;
}
if used + character_width > width {
break;
}
output.push(character);
used += character_width;
}
output
}
fn suffix_width(value: &str, width: usize) -> String {
let mut characters = Vec::new();
let mut used = 0;
for character in value.chars().rev() {
let character_width = character.width().unwrap_or_default();
if used + character_width > width {
break;
}
characters.push(character);
used += character_width;
}
characters.into_iter().rev().collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn middle_truncation_respects_display_width() {
let result = truncate_middle("src/a-very-long-file-name.rs", 14);
assert!(result.width() <= 14);
assert!(result.contains('…'));
assert!(result.ends_with("me.rs"));
}
#[test]
fn side_by_side_pairs_replacements() {
let document = DiffDocument {
title: String::new(),
truncated: false,
commit_details: None,
lines: vec![
test_line(DiffLineKind::Removed, "old one"),
test_line(DiffLineKind::Removed, "old two"),
test_line(DiffLineKind::Added, "new one"),
test_line(DiffLineKind::Context, "same"),
],
};
let app = App::new("/tmp/repo", "repo");
let rows = side_by_side_rows(&document, &app);
assert_eq!(rows.len(), 3);
let SideBySideRow::Split(old, new) = &rows[0] else {
panic!("expected a split diff row");
};
assert_eq!(old.unwrap().text(), "old one");
assert_eq!(new.unwrap().text(), "new one");
let SideBySideRow::Split(_, new) = &rows[1] else {
panic!("expected a split diff row");
};
assert!(new.is_none());
let SideBySideRow::Split(old, _) = &rows[2] else {
panic!("expected a split diff row");
};
assert_eq!(old.unwrap().text(), "same");
}
#[test]
fn hides_raw_hunk_coordinates_in_both_diff_layouts() {
let document = DiffDocument {
title: String::new(),
truncated: false,
commit_details: None,
lines: vec![
test_file_header("src/main.rs", 1, 0),
test_line(DiffLineKind::HunkHeader, "@@ -10,2 +10,3 @@ fn main()"),
test_line(DiffLineKind::Context, "same"),
test_line(DiffLineKind::FileFooter, ""),
],
};
let app = App::new("/tmp/repo", "repo");
assert_eq!(unified_row_indices(&document, &app), vec![0, 2, 3]);
assert!(
side_by_side_rows(&document, &app)
.iter()
.all(|row| !matches!(row, SideBySideRow::Full { line, .. } if line.kind == DiffLineKind::HunkHeader))
);
}
#[test]
fn commit_preview_renders_details_once_and_names_each_file_pane() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let mut app = App::new("/tmp/repo", "repo");
app.view = View::History;
app.focus = Focus::Content;
app.document = DiffDocument {
title: "abc1234 — Improve history".to_owned(),
truncated: false,
commit_details: Some(CommitDetails {
id: "abc123456789".to_owned(),
subject: "Improve history".to_owned(),
author: "Ada".to_owned(),
author_email: "ada@example.com".to_owned(),
authored_at: "2026-01-02T03:04:05Z".to_owned(),
committer: "Grace".to_owned(),
committer_email: "grace@example.com".to_owned(),
committed_at: "2026-01-02T04:05:06Z".to_owned(),
}),
lines: vec![
test_file_header("src/main.rs", 1, 0),
test_line(DiffLineKind::HunkHeader, "@@ -1,0 +1 @@"),
test_line(DiffLineKind::Added, "fn main() {}"),
test_line(DiffLineKind::FileFooter, ""),
test_file_header("README.md", 1, 0),
test_line(DiffLineKind::HunkHeader, "@@ -1,0 +1 @@"),
test_line(DiffLineKind::Added, "# Quinjet"),
test_line(DiffLineKind::FileFooter, ""),
],
};
let backend = TestBackend::new(140, 32);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|frame| draw(frame, &mut app)).unwrap();
let rendered: String = terminal
.backend()
.buffer()
.content()
.iter()
.map(|cell| cell.symbol())
.collect();
assert_eq!(rendered.matches("Commit details").count(), 1);
assert!(rendered.contains("src/main.rs"));
assert!(rendered.contains("README.md"));
assert!(!rendered.contains("@@"));
assert!(!rendered.contains('◆'));
assert!(!rendered.contains('░'));
}
#[test]
fn file_header_right_aligns_colored_line_counts() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let header = test_file_header("src/main.rs", 12, 3);
let theme = Theme::default();
let backend = TestBackend::new(40, 1);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
let app = App::new("/tmp/repo", "repo");
draw_file_header(frame, frame.area(), &header, &app, &theme);
})
.unwrap();
let buffer = terminal.backend().buffer();
let rendered: String = buffer.content().iter().map(|cell| cell.symbol()).collect();
let addition_column = rendered
.chars()
.position(|character| character == '+')
.unwrap();
let deletion_column = rendered
.chars()
.position(|character| character == '-')
.unwrap();
assert!(rendered.ends_with("+12 -3 ┐"));
assert_eq!(buffer[(addition_column as u16, 0)].fg, theme.added);
assert_eq!(buffer[(deletion_column as u16, 0)].fg, theme.removed);
}
#[test]
fn commit_details_participate_in_vertical_scroll() {
assert_eq!(commit_details_row_count(30), 7);
assert_eq!(commit_details_row_count(8), 5);
assert_eq!(commit_details_row_count(3), 0);
}
#[test]
fn collapse_all_keeps_only_selectable_file_headers() {
let document = DiffDocument {
title: String::new(),
truncated: false,
commit_details: None,
lines: vec![
test_file_header("one.rs", 1, 0),
test_line(DiffLineKind::HunkHeader, "@@ -0,0 +1 @@"),
test_line(DiffLineKind::Added, "one"),
test_line(DiffLineKind::FileFooter, ""),
test_file_header("two.rs", 1, 0),
test_line(DiffLineKind::Added, "two"),
test_line(DiffLineKind::FileFooter, ""),
],
};
let mut app = App::new("/tmp/repo", "repo");
app.files_collapsed = true;
assert_eq!(unified_row_indices(&document, &app), vec![0, 4]);
assert_eq!(side_by_side_rows(&document, &app).len(), 2);
}
#[test]
fn computes_vscode_style_intraline_changed_ranges() {
assert_eq!(
changed_ranges("const oldValue = 1;", "const newValue = 2;"),
(Some(6..18), Some(6..18))
);
assert_eq!(changed_ranges("same", "same"), (None, None));
assert_eq!(
changed_ranges("prefix old suffix", "prefix new suffix"),
(Some(7..10), Some(7..10))
);
}
fn test_file_header(path: &str, additions: usize, deletions: usize) -> DiffLine {
DiffLine {
kind: DiffLineKind::FileHeader,
old_line: None,
new_line: None,
spans: vec![
HighlightSpan {
text: path.to_owned(),
foreground: None,
bold: false,
italic: false,
},
HighlightSpan {
text: format!("+{additions}"),
foreground: None,
bold: false,
italic: false,
},
HighlightSpan {
text: format!("-{deletions}"),
foreground: None,
bold: false,
italic: false,
},
],
}
}
fn test_line(kind: DiffLineKind, text: &str) -> DiffLine {
DiffLine {
kind,
old_line: None,
new_line: None,
spans: vec![HighlightSpan {
text: text.to_owned(),
foreground: None,
bold: false,
italic: false,
}],
}
}
}