use super::DiffPage;
use crate::{
diff_review::{ReviewComment, ReviewFileChange, 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, Paragraph},
};
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
pub(super) fn columns(area: Rect) -> [Rect; 3] {
Layout::horizontal([
Constraint::Percentage(20),
Constraint::Percentage(40),
Constraint::Percentage(40),
])
.areas(area)
}
pub(super) fn refresh_button_area(column: Rect) -> Rect {
Rect::new(
column.x.saturating_add(1),
column.bottom().saturating_sub(1),
column.width.saturating_sub(2).min(14),
u16::from(column.height > 0),
)
}
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,
);
}
frame.render_widget(
Paragraph::new("[Refresh · F5]").style(theme.right_column_rail_active_tab()),
refresh_button_area(columns[0]),
);
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 = content_area(columns[0]).height as usize;
let offsets = page.viewport_offsets(visible);
let horizontal = std::array::from_fn::<_, 3, _>(|index| {
page.horizontal_offset(index + 1, content_area(columns[index]).width as usize)
});
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),
]);
}
} else if let Some(change) = entry
.file
.and_then(|index| snapshot.files.get(index))
.and_then(|file| file.change)
{
let (marker, color) = match change {
ReviewFileChange::Written => ("W ", theme.diff_inserted_color()),
ReviewFileChange::Edited => ("E ", theme.diff_changed_color()),
ReviewFileChange::Deleted => ("D ", theme.diff_removed_color()),
};
let file_style = if index == page.tree_row {
style
} else {
style.fg(color)
};
line = Line::from(vec![
Span::styled(marker, theme.pane().fg(color).add_modifier(Modifier::BOLD)),
Span::styled(entry.label.as_str(), file_style),
]);
}
line
})
.collect();
frame.render_widget(
scrolled_paragraph(lines, horizontal[0]),
content_area(columns[0]),
);
for (index, column) in columns.iter().enumerate() {
crate::tui::render::render_scrollbar_with_theme(
frame,
vertical_scrollbar_area(*column),
page.row_count(index + 1).saturating_sub(visible) + 1,
offsets[index],
visible,
theme,
);
let width = content_area(*column).width as usize;
crate::tui::render::render_horizontal_scrollbar_with_theme(
frame,
horizontal_scrollbar_area(*column),
page.content_width(index + 1).saturating_sub(width) + 1,
horizontal[index],
width,
theme,
);
}
if let Some(file) = snapshot.files.get(page.selected) {
let display = &snapshot.files_display[page.selected];
let visible = content_area(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(
scrolled_paragraph(original, horizontal[1]),
content_area(columns[1]),
);
let visible = content_area(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(
scrolled_paragraph(changed, horizontal[2]),
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, columns[2], error, theme);
}
}
pub(super) fn content_area(area: Rect) -> Rect {
let mut area = inner(area);
area.width = area.width.saturating_sub(1);
area.height = area.height.saturating_sub(1);
area
}
pub(super) fn vertical_scrollbar_area(area: Rect) -> Rect {
let mut area = inner(area);
area.height = area.height.saturating_sub(1);
area
}
fn horizontal_scrollbar_area(area: Rect) -> Rect {
let mut area = inner(area);
area.width = area.width.saturating_sub(1);
area
}
fn scrolled_paragraph(lines: Vec<Line<'_>>, offset: usize) -> Paragraph<'_> {
let lines: Vec<_> = lines
.into_iter()
.map(|mut line| {
let mut remaining = offset;
line.spans = line
.spans
.into_iter()
.filter_map(|mut span| {
if remaining == 0 {
return Some(span);
}
let width = span.width();
if remaining >= width {
remaining -= width;
return None;
}
for (index, grapheme) in span.content.grapheme_indices(true) {
let width = grapheme.width();
if width > remaining {
let text = if remaining == 0 {
span.content[index..].to_owned()
} else {
format!(
"{}{}",
" ".repeat(width - remaining),
&span.content[index + grapheme.len()..]
)
};
span.content = text.into();
remaining = 0;
return Some(span);
}
remaining -= width;
}
None
})
.collect();
line
})
.collect();
Paragraph::new(lines)
}
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
})
}
pub(super) fn marker_width(page: &DiffPage, pane: usize) -> usize {
let has_marker = page.focus == pane
|| page.snapshot.as_ref().is_some_and(|snapshot| {
snapshot.files.get(page.selected).is_some_and(|file| {
snapshot.comments.iter().any(|comment| {
comment.path == file.path && (pane == 3 || comment.side == ReviewSide::Original)
})
})
});
if has_marker { 3 } else { 0 }
}
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 = marker_width(page, pane) > 0;
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 {
crate::tui::modal_container::compact_modal_area(area, 40, 20)
}
pub(super) fn dialog_text_area(area: Rect) -> Rect {
inner(dialog_area(area))
}
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;
let busy = state.diff.saving;
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 {
""
}
);
let mut hints = "[Alt-S] Save [Alt-D] Discard".to_owned();
if dialog.id.is_some() {
hints.push_str(if dialog.resolved {
" [Alt-R] Reopen"
} else {
" [Alt-R] Resolve"
});
}
let hotkeys = if busy {
"Saving…".to_owned()
} else if let Some(error) = &state.diff.error {
sanitize_display_text(error)
} else {
hints
};
crate::tui::modal_container::draw_modal_container(
frame,
area,
title,
hotkeys,
theme,
(!busy).then_some("[Esc] close"),
);
dialog
.editor
.render_with_focus(frame, dialog_text_area(frame.area()), true, theme);
}