use super::DiffPage;
use crate::{
diff_review::{ReviewComment, ReviewLineKind, ReviewSide},
output::sanitize_display_text,
rendering::{DisplayLine, DisplayRole},
tui::{state::MissionControlState, theme::MissionControlTheme},
};
use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
style::Modifier,
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph, Wrap},
};
pub(super) fn columns(area: Rect) -> [Rect; 3] {
Layout::horizontal([
Constraint::Percentage(20),
Constraint::Percentage(40),
Constraint::Percentage(40),
])
.areas(area)
}
pub(crate) fn draw(frame: &mut Frame<'_>, area: Rect, state: &MissionControlState) {
let theme = state.theme;
let page = &state.diff;
let columns = columns(area);
for (index, column) in columns.iter().enumerate() {
let title = if index == 0 {
page.snapshot.as_ref().map_or_else(
|| "Files".to_owned(),
|s| {
format!(
"Files · {}",
sanitize_display_text(&s.root.display().to_string())
)
},
)
} else {
["Files", "Original", "Diff (old → new)"][index].to_owned()
};
let border_style = theme.border(!state.is_prompt_focused() && page.focus == index + 1);
frame.render_widget(
Block::default()
.borders(Borders::ALL)
.title(title)
.style(theme.pane())
.border_style(border_style),
*column,
);
}
let Some(snapshot) = &page.snapshot else {
frame.render_widget(
Paragraph::new(sanitize_display_text(
page.error.as_deref().unwrap_or("Loading changes…"),
))
.style(theme.pane()),
inner(columns[1]),
);
return;
};
let entries = page.tree_entries();
let visible = inner(columns[0]).height as usize;
let offsets = page.viewport_offsets(visible);
let lines: Vec<_> = entries
.iter()
.enumerate()
.skip(offsets[0])
.take(visible)
.map(|(index, entry)| {
let style = if index == page.tree_row {
theme.right_column_rail_active_tab()
} else {
theme.pane()
};
let mut line = Line::styled(entry.label.as_str(), style);
if let Some(comment) = entry.comment.and_then(|index| snapshot.comments.get(index)) {
let marker = comment_marker(comment.stale, comment.resolved);
if let Some(start) = entry.label.find(marker) {
line = Line::from(vec![
Span::styled(&entry.label[..start], style),
Span::styled(marker, comment_style(theme, comment)),
Span::styled(&entry.label[start + marker.len()..], style),
]);
}
}
line
})
.collect();
frame.render_widget(Paragraph::new(lines), content_area(columns[0]));
for (index, column) in columns.iter().enumerate() {
crate::tui::render::render_scrollbar_with_theme(
frame,
inner(*column),
page.row_count(index + 1).saturating_sub(visible) + 1,
offsets[index],
visible,
theme,
);
}
if let Some(file) = snapshot.files.get(page.selected) {
let display = &snapshot.files_display[page.selected];
let visible = inner(columns[1]).height as usize;
let original_digits = display.original.len().max(1).ilog10() as usize + 1;
let current_digits = display.current.len().max(1).ilog10() as usize + 1;
let original: Vec<_> = display
.original
.iter()
.enumerate()
.skip(offsets[1])
.take(visible)
.map(|(index, text)| {
let marker = line_marker(page, theme, ReviewSide::Original, index + 1, 2, index);
source_line(
theme,
Some(text),
(marker, format!("{:>original_digits$} ", index + 1)),
display.removed[index].then_some(DisplayRole::DiffRemoved),
page.focus == 2 && index == page.original_row,
)
})
.collect();
frame.render_widget(Paragraph::new(original), content_area(columns[1]));
let visible = inner(columns[2]).height as usize;
let changed: Vec<_> = file
.rows
.iter()
.enumerate()
.skip(offsets[2])
.take(visible)
.map(|(index, row)| {
let (prefix, role) = match row.kind {
ReviewLineKind::Added => ("+", Some(DisplayRole::DiffInserted)),
ReviewLineKind::Removed => ("-", Some(DisplayRole::DiffRemoved)),
ReviewLineKind::Context => (" ", None),
};
let (side, number) = row
.new_line
.map_or((ReviewSide::Original, row.old_line.unwrap_or(0)), |line| {
(ReviewSide::Changed, line)
});
let marker = line_marker(page, theme, side, number, 3, index);
source_line(
theme,
number.checked_sub(1).and_then(|index| {
if side == ReviewSide::Original {
display.original.get(index)
} else {
display.current.get(index)
}
}),
(
marker,
format!(
"{:>original_digits$} {:>current_digits$}{prefix}",
row.old_line
.map_or_else(String::new, |line| line.to_string()),
row.new_line
.map_or_else(String::new, |line| line.to_string()),
),
),
role,
page.focus == 3 && index == page.diff_row,
)
})
.collect();
frame.render_widget(Paragraph::new(changed), content_area(columns[2]));
if let Some(notice) = &file.notice {
footer(frame, columns[2], notice, theme);
}
} else {
frame.render_widget(
Paragraph::new("No working-tree changes").style(theme.pane()),
inner(columns[1]),
);
}
if let Some(error) = &page.error {
footer(frame, area, error, theme);
}
}
pub(super) fn content_area(area: Rect) -> Rect {
let mut area = inner(area);
area.width = area.width.saturating_sub(1);
area
}
fn inner(area: Rect) -> Rect {
area.inner(ratatui::layout::Margin::new(1, 1))
}
fn footer(frame: &mut Frame<'_>, area: Rect, text: &str, theme: MissionControlTheme) {
if area.height > 0 {
frame.render_widget(
Paragraph::new(sanitize_display_text(text)).style(theme.pane()),
Rect::new(area.x, area.bottom() - 1, area.width, 1),
);
}
}
pub(super) fn comment_marker(_stale: bool, resolved: bool) -> &'static str {
if resolved { "[✓]" } else { "[•]" }
}
fn comment_style(theme: MissionControlTheme, comment: &ReviewComment) -> ratatui::style::Style {
theme.display_role(if comment.stale {
DisplayRole::DiffChanged
} else if comment.resolved {
DisplayRole::DiffInserted
} else {
DisplayRole::Link
})
}
fn line_marker(
page: &DiffPage,
theme: MissionControlTheme,
side: ReviewSide,
line: usize,
pane: usize,
row: usize,
) -> Span<'static> {
let comments = page.snapshot.as_ref().and_then(|snapshot| {
snapshot
.files
.get(page.selected)
.map(|file| (snapshot, file))
});
if let Some((snapshot, file)) = comments
&& let Some(comment) = snapshot.comments.iter().find(|comment| {
comment.path == file.path && comment.side == side && comment.line == line
})
{
return Span::styled(
comment_marker(comment.stale, comment.resolved),
comment_style(theme, comment),
);
}
let selected_row = if pane == 2 {
page.original_row
} else {
page.diff_row
};
let reserve_marker = page.focus == pane
|| comments.is_some_and(|(snapshot, file)| {
snapshot.comments.iter().any(|comment| {
comment.path == file.path && (pane == 3 || comment.side == ReviewSide::Original)
})
});
Span::styled(
if page.focus == pane && row == selected_row {
"[+]"
} else if reserve_marker {
" "
} else {
""
},
theme.display_role(DisplayRole::CodeBlockGutter),
)
}
fn source_line<'a>(
theme: MissionControlTheme,
line: Option<&'a DisplayLine>,
gutter: (Span<'static>, String),
role: Option<DisplayRole>,
selected: bool,
) -> Line<'a> {
let mut style = theme.display_role(role.unwrap_or(DisplayRole::CodeBlockGutter));
if selected {
style = style.add_modifier(Modifier::UNDERLINED);
}
let mut marker = gutter.0;
if selected {
marker.style = marker.style.add_modifier(Modifier::UNDERLINED);
}
let mut spans = vec![marker, Span::styled(gutter.1, style)];
if let Some(line) = line {
spans.extend(line.spans.iter().map(|span| {
let token_role = if matches!(span.role, DisplayRole::FallbackCode | DisplayRole::Plain)
{
role.unwrap_or(span.role)
} else {
span.role
};
let mut style = theme.display_role(token_role);
if let Some(role) = role {
style = style.bg(theme.display_role(role).bg.unwrap_or(theme.surface_code()));
}
if selected {
style = style.add_modifier(Modifier::UNDERLINED);
}
Span::styled(span.text.as_str(), style)
}));
}
let line = Line::from(spans);
if let Some(role) = role {
line.style(theme.display_role(role))
} else {
line
}
}
fn dialog_area(area: Rect) -> Rect {
let width = area.width.min(96);
let height = area.height.min(9);
Rect::new(
area.x + (area.width - width) / 2,
area.y + (area.height - height) / 2,
width,
height,
)
}
pub(super) fn dialog_controls(area: Rect) -> (Rect, Rect) {
let inner = inner(dialog_area(area));
let action_height = inner.height.min(2);
(
Rect::new(
inner.x,
inner.y,
inner.width,
inner.height.saturating_sub(action_height + 1),
),
Rect::new(
inner.x,
inner.bottom().saturating_sub(action_height),
inner.width,
action_height,
),
)
}
pub(crate) fn draw_dialog(frame: &mut Frame<'_>, state: &MissionControlState) {
let Some(dialog) = &state.diff.dialog else {
return;
};
let area = dialog_area(frame.area());
let theme = state.theme;
frame.render_widget(Clear, area);
let title = format!(
"Comment {}:{} {}{}",
sanitize_display_text(&dialog.path),
dialog.line,
comment_marker(dialog.stale, dialog.resolved),
if dialog.stale {
" · stale"
} else if dialog.resolved {
" · resolved"
} else {
""
}
);
frame.render_widget(
Block::default()
.borders(Borders::ALL)
.title(title)
.style(theme.pane()),
area,
);
let (text, actions) = dialog_controls(frame.area());
dialog.editor.render_with_focus(frame, text, true, theme);
let mut hints = "[Alt-S] Save [Esc] Cancel [Alt-D] Discard".to_owned();
if dialog.id.is_some() {
hints.push_str(if dialog.resolved {
" [Alt-R] Reopen"
} else {
" [Alt-R] Resolve"
});
}
frame.render_widget(
Paragraph::new(hints)
.style(theme.pane().add_modifier(Modifier::DIM))
.alignment(ratatui::layout::Alignment::Center)
.wrap(Wrap { trim: true }),
actions,
);
if state.diff.saving {
footer(frame, area, "Saving…", theme);
} else if let Some(error) = &state.diff.error {
footer(frame, area, error, theme);
}
}