use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::Style,
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, Paragraph, Wrap},
};
use unicode_width::UnicodeWidthStr;
use crate::app::{
App, DiffViewMode, ExpandDirection, FileTreeItem, FocusedPanel, GAP_EXPAND_BATCH, GapId,
InputMode,
};
use crate::model::{LineOrigin, LineRange, LineSide};
use crate::theme::Theme;
use crate::ui::{comment_panel, help_popup, status_bar, styles};
use crate::vcs::git::calculate_gap;
pub fn render(frame: &mut Frame, app: &mut App) {
frame.render_widget(
Block::default().style(styles::panel_style(&app.theme)),
frame.area(),
);
if app.input_mode == InputMode::CommitSelect {
render_commit_select(frame, app);
return;
}
app.comment_cursor_screen_pos = None;
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(vec![
Constraint::Length(1), Constraint::Min(0), Constraint::Length(1), ])
.split(frame.area());
status_bar::render_header(frame, app, chunks[0]);
render_main_content(frame, app, chunks[1]);
status_bar::render_status_bar(frame, app, chunks[2]);
if app.input_mode == InputMode::Help {
help_popup::render_help(frame, app);
}
if app.input_mode == InputMode::Confirm {
comment_panel::render_confirm_dialog(frame, app, "Copy review to clipboard?");
}
if app.input_mode == InputMode::Comment {
let (col, row) = app.comment_cursor_screen_pos.unwrap_or_else(|| {
if let Some(diff_area) = app.diff_area {
(diff_area.x + 1, diff_area.y + 1)
} else {
(chunks[1].x + 1, chunks[1].y + 1)
}
});
frame.set_cursor_position(ratatui::layout::Position { x: col, y: row });
}
}
fn render_commit_select(frame: &mut Frame, app: &mut App) {
let area = frame.area();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Min(0), Constraint::Length(1), ])
.split(area);
let header = Paragraph::new(" Select commits to review ")
.style(styles::header_style(&app.theme))
.block(Block::default().style(styles::panel_style(&app.theme)));
frame.render_widget(header, chunks[0]);
let block = Block::default()
.title(" Recent Commits ")
.borders(Borders::ALL)
.style(styles::panel_style(&app.theme))
.border_style(styles::border_style(&app.theme, true));
let inner = block.inner(chunks[1]);
frame.render_widget(block, chunks[1]);
app.commit_list_viewport_height = inner.height as usize;
let range = app.commit_selection_range;
let total_commits = app.commit_list.len();
let visible_count = app.visible_commit_count.min(total_commits);
let mut items: Vec<Line> = app
.commit_list
.iter()
.take(visible_count)
.enumerate()
.map(|(i, commit)| {
let is_selected = app.is_commit_selected(i);
let is_cursor = i == app.commit_list_cursor;
let range_marker = match range {
Some((start, end)) if i == start && i == end => "─",
Some((start, _)) if i == start => "┌",
Some((_, end)) if i == end => "└",
Some((start, end)) if i > start && i < end => "│",
_ => " ",
};
let checkbox = if is_selected { "[x]" } else { "[ ]" };
let pointer = if is_cursor { ">" } else { " " };
let style = if is_cursor {
styles::selected_style(&app.theme)
} else if is_selected {
Style::default().fg(app.theme.fg_secondary)
} else {
Style::default()
};
let checkbox_style = if is_selected {
styles::reviewed_style(&app.theme)
} else {
styles::pending_style(&app.theme)
};
let range_style = if is_selected {
styles::reviewed_style(&app.theme)
} else {
Style::default().fg(app.theme.fg_secondary)
};
let time_str = commit.time.format("%Y-%m-%d").to_string();
let mut spans = vec![
Span::styled(format!("{pointer} "), style),
Span::styled(format!("{range_marker} "), range_style),
Span::styled(format!("{checkbox} "), checkbox_style),
Span::styled(
format!("{} ", commit.short_id),
styles::hash_style(&app.theme),
),
];
if commit.id == crate::app::STAGED_SELECTION_ID
|| commit.id == crate::app::UNSTAGED_SELECTION_ID
{
spans.push(Span::styled(&commit.summary, style));
return Line::from(spans);
}
if let Some(branch_name) = &commit.branch_name {
spans.push(Span::styled(
format!("[{}] ", truncate_str(branch_name, 20)),
styles::branch_style(&app.theme),
));
}
spans.push(Span::styled(truncate_str(&commit.summary, 50), style));
spans.push(Span::styled(
format!(" ({}, {})", commit.author, time_str),
Style::default().fg(app.theme.fg_secondary),
));
Line::from(spans)
})
.collect();
if app.can_show_more_commits() {
let is_cursor = app.commit_list_cursor == visible_count;
let style = if is_cursor {
styles::selected_style(&app.theme)
} else {
Style::default().fg(app.theme.fg_secondary)
};
items.push(Line::from(vec![
Span::styled(if is_cursor { "> " } else { " " }, style),
Span::styled(" ... show more commits ...", style),
]));
}
let visible_items: Vec<Line> = items
.into_iter()
.skip(app.commit_list_scroll_offset)
.take(inner.height as usize)
.collect();
let list = Paragraph::new(visible_items).style(styles::panel_style(&app.theme));
frame.render_widget(list, inner);
let theme = &app.theme;
let mode_span = Span::styled(" SELECT ", styles::mode_style(theme));
let selected_count = match app.commit_selection_range {
Some((start, end)) => end - start + 1,
None => 0,
};
let selection_info = if selected_count > 0 {
format!(" ({selected_count} selected)")
} else {
String::new()
};
let hints = format!(" j/k:navigate Space:select range Enter:confirm q:quit{selection_info}");
let hints_span = Span::styled(hints, Style::default().fg(theme.fg_secondary));
let left_spans = vec![mode_span, hints_span];
let (message_span, message_width) = status_bar::build_message_span(app.message.as_ref(), theme);
let spans = status_bar::build_right_aligned_spans(
left_spans,
message_span,
message_width,
chunks[2].width as usize,
);
let footer = Paragraph::new(Line::from(spans))
.style(styles::status_bar_style(theme))
.block(Block::default());
frame.render_widget(footer, chunks[2]);
}
fn truncate_str(s: &str, max_len: usize) -> String {
if s.len() <= max_len {
s.to_string()
} else {
let truncate_at = max_len.saturating_sub(3);
let end = s
.char_indices()
.map(|(i, _)| i)
.take_while(|&i| i <= truncate_at)
.last()
.unwrap_or(0);
format!("{}...", &s[..end])
}
}
fn render_main_content(frame: &mut Frame, app: &mut App, area: Rect) {
let content_area = if app.has_inline_commit_selector() {
let selector_height = (app.review_commits.len() as u16 + 2).min(8); let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(selector_height), Constraint::Min(0)])
.split(area);
render_inline_commit_selector(frame, app, chunks[0]);
chunks[1]
} else {
area
};
if app.show_file_list {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(20), Constraint::Percentage(80), ])
.split(content_area);
app.file_list_area = Some(chunks[0]);
app.diff_area = Some(chunks[1]);
render_file_list(frame, app, chunks[0]);
render_diff_view(frame, app, chunks[1]);
} else {
app.file_list_area = None;
app.diff_area = Some(content_area);
render_diff_view(frame, app, content_area);
}
}
fn render_inline_commit_selector(frame: &mut Frame, app: &mut App, area: Rect) {
let focused = app.focused_panel == FocusedPanel::CommitSelector;
let block = Block::default()
.title(" Commits ")
.borders(Borders::ALL)
.border_style(styles::border_style(&app.theme, focused));
let inner = block.inner(area);
frame.render_widget(block, area);
app.commit_list_viewport_height = inner.height as usize;
{
let range = app.commit_selection_range;
let total_commits = app.review_commits.len();
let items: Vec<Line> = app
.review_commits
.iter()
.take(total_commits)
.enumerate()
.map(|(i, commit)| {
let is_selected = app.is_commit_selected(i);
let is_cursor = i == app.commit_list_cursor;
let range_marker = match range {
Some((start, end)) if i == start && i == end => "\u{2500}",
Some((start, _)) if i == start => "\u{250c}",
Some((_, end)) if i == end => "\u{2514}",
Some((start, end)) if i > start && i < end => "\u{2502}",
_ => " ",
};
let checkbox = if is_selected { "[x]" } else { "[ ]" };
let style = if is_cursor {
styles::selected_style(&app.theme)
} else if is_selected {
Style::default().fg(app.theme.fg_secondary)
} else {
Style::default()
};
let checkbox_style = if is_selected {
styles::reviewed_style(&app.theme)
} else {
styles::pending_style(&app.theme)
};
let range_style = if is_selected {
styles::reviewed_style(&app.theme)
} else {
Style::default().fg(app.theme.fg_secondary)
};
let pointer = if is_cursor { "> " } else { " " };
let time_str = commit.time.format("%Y-%m-%d").to_string();
let mut spans = vec![
Span::styled(pointer.to_string(), style),
Span::styled(format!("{} ", range_marker), range_style),
Span::styled(format!("{} ", checkbox), checkbox_style),
Span::styled(
format!("{} ", commit.short_id),
styles::hash_style(&app.theme),
),
];
if let Some(branch_name) = &commit.branch_name {
spans.push(Span::styled(
format!("[{}] ", truncate_str(branch_name, 20)),
styles::branch_style(&app.theme),
));
}
spans.push(Span::styled(truncate_str(&commit.summary, 50), style));
spans.push(Span::styled(
format!(" ({}, {})", commit.author, time_str),
Style::default().fg(app.theme.fg_secondary),
));
Line::from(spans)
})
.collect();
let visible_items: Vec<Line> = items
.into_iter()
.skip(app.commit_list_scroll_offset)
.take(inner.height as usize)
.collect();
let paragraph = Paragraph::new(visible_items);
frame.render_widget(paragraph, inner);
}
}
fn render_file_list(frame: &mut Frame, app: &mut App, area: Rect) {
use ratatui::style::Modifier;
use std::path::Path;
let focused = app.focused_panel == FocusedPanel::FileList;
let block = Block::default()
.title(" Files ")
.borders(Borders::ALL)
.style(styles::panel_style(&app.theme))
.border_style(styles::border_style(&app.theme, focused));
let inner = block.inner(area);
let visible_items = app.build_visible_items();
let max_content_width = visible_items
.iter()
.map(|item| match item {
FileTreeItem::Directory { path, depth, .. } => {
let dir_name = Path::new(path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(path);
depth * 2 + 2 + dir_name.width() + 1
}
FileTreeItem::File { file_idx, depth } => {
let file = &app.diff_files[*file_idx];
let filename = file
.display_path()
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("?");
depth * 2 + 3 + 3 + filename.width()
}
})
.max()
.unwrap_or(0);
app.file_list_state.viewport_width = inner.width as usize;
app.file_list_state.viewport_height = inner.height as usize;
app.file_list_state.max_content_width = max_content_width;
let max_scroll_x = max_content_width.saturating_sub(inner.width as usize);
if app.file_list_state.scroll_x > max_scroll_x {
app.file_list_state.scroll_x = max_scroll_x;
}
let scroll_x = app.file_list_state.scroll_x;
if app.focused_panel == FocusedPanel::Diff {
let current_file_idx = app.diff_state.current_file_idx;
for (tree_idx, item) in visible_items.iter().enumerate() {
if let FileTreeItem::File { file_idx, .. } = item
&& *file_idx == current_file_idx
{
if app.file_list_state.selected() != tree_idx {
let current_offset = app.file_list_state.list_state.offset();
app.file_list_state.select(tree_idx);
*app.file_list_state.list_state.offset_mut() = current_offset;
}
break;
}
}
}
let selected_idx = app.file_list_state.selected();
let items: Vec<ListItem> = visible_items
.iter()
.enumerate()
.map(|(i, item)| {
let is_selected = i == selected_idx;
match item {
FileTreeItem::Directory {
path,
depth,
expanded,
} => {
let indent = " ".repeat(*depth);
let icon = if *expanded { "▾" } else { "▸" };
let dir_name = Path::new(path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(path);
let style = if is_selected {
styles::selected_style(&app.theme).add_modifier(Modifier::UNDERLINED)
} else {
Style::default()
};
let line = Line::from(vec![
Span::styled(indent, Style::default()),
Span::styled(format!("{icon} "), styles::dir_icon_style(&app.theme)),
Span::styled(format!("{dir_name}/"), style),
]);
ListItem::new(apply_horizontal_scroll(line, scroll_x))
}
FileTreeItem::File { file_idx, depth } => {
let file = &app.diff_files[*file_idx];
let path = file.display_path();
let is_reviewed = app.session.is_file_reviewed(path);
let review_mark = if is_reviewed { "✓" } else { " " };
let style = if is_selected {
styles::selected_style(&app.theme).add_modifier(Modifier::UNDERLINED)
} else {
Style::default()
};
let line = if file.is_commit_message {
Line::from(vec![
Span::styled(
format!("[{review_mark}]"),
if is_reviewed {
styles::reviewed_style(&app.theme)
} else {
styles::pending_style(&app.theme)
},
),
Span::styled(" Commit Message".to_string(), style),
])
} else {
let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("?");
let status = file.status.as_char();
let indent = " ".repeat(*depth);
Line::from(vec![
Span::styled(indent, Style::default()),
Span::styled(
format!("[{review_mark}]"),
if is_reviewed {
styles::reviewed_style(&app.theme)
} else {
styles::pending_style(&app.theme)
},
),
Span::styled(
format!(" {status} "),
styles::file_status_style(&app.theme, status),
),
Span::styled(filename.to_string(), style),
])
};
ListItem::new(apply_horizontal_scroll(line, scroll_x))
}
}
})
.collect();
let list = List::new(items)
.style(styles::panel_style(&app.theme))
.block(block);
frame.render_stateful_widget(list, area, &mut app.file_list_state.list_state);
}
fn render_diff_view(frame: &mut Frame, app: &mut App, area: Rect) {
match app.diff_view_mode {
DiffViewMode::Unified => render_unified_diff(frame, app, area),
DiffViewMode::SideBySide => render_side_by_side_diff(frame, app, area),
}
}
fn render_unified_diff(frame: &mut Frame, app: &mut App, area: Rect) {
let focused = app.focused_panel == FocusedPanel::Diff;
let title = match app.current_file_path() {
Some(path) => format!(" Diff (Unified) \u{2014} {} ", path.display()),
None => " Diff (Unified) ".to_string(),
};
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.style(styles::panel_style(&app.theme))
.border_style(styles::border_style(&app.theme, focused));
let inner = block.inner(area);
frame.render_widget(block, area);
app.diff_state.viewport_height = inner.height as usize;
let mut lines: Vec<Line> = Vec::new();
let mut line_idx: usize = 0;
let current_line_idx = app.diff_state.cursor_line;
let mut comment_cursor_logical_line: Option<usize> = None;
let mut comment_cursor_column: u16 = 0;
let mut comment_input_box_range: Option<(usize, usize)> = None;
let is_review_comment_mode =
app.input_mode == InputMode::Comment && app.comment_is_review_level;
let general_indicator = cursor_indicator_spaced(line_idx, current_line_idx);
lines.push(Line::from(vec![
Span::styled(
general_indicator,
styles::current_line_indicator_style(&app.theme),
),
Span::styled(
"═══ Review Comments ",
styles::file_header_style(&app.theme),
),
Span::styled("═".repeat(40), styles::file_header_style(&app.theme)),
]));
line_idx += 1;
for comment in &app.session.review_comments {
let is_being_edited =
app.editing_comment_id.as_ref() == Some(&comment.id) && is_review_comment_mode;
if is_being_edited {
let (input_lines, cursor_info) = comment_panel::format_comment_input_lines(
&app.theme,
comment_type_presentation(app, &app.comment_type),
&app.comment_buffer,
app.comment_cursor,
None,
true,
app.supports_keyboard_enhancement,
);
comment_cursor_logical_line = Some(line_idx + cursor_info.line_offset);
comment_cursor_column = 1 + cursor_info.column;
comment_input_box_range =
Some((line_idx, line_idx + input_lines.len().saturating_sub(1)));
for mut input_line in input_lines {
let indicator = cursor_indicator(line_idx, current_line_idx);
input_line.spans.insert(
0,
Span::styled(indicator, styles::current_line_indicator_style(&app.theme)),
);
lines.push(input_line);
line_idx += 1;
}
} else {
let comment_lines = comment_panel::format_comment_lines(
&app.theme,
comment_type_presentation(app, &comment.comment_type),
&comment.content,
None,
);
for mut comment_line in comment_lines {
let indicator = cursor_indicator(line_idx, current_line_idx);
comment_line.spans.insert(
0,
Span::styled(indicator, styles::current_line_indicator_style(&app.theme)),
);
lines.push(comment_line);
line_idx += 1;
}
}
}
if is_review_comment_mode && app.editing_comment_id.is_none() {
let (input_lines, cursor_info) = comment_panel::format_comment_input_lines(
&app.theme,
comment_type_presentation(app, &app.comment_type),
&app.comment_buffer,
app.comment_cursor,
None,
false,
app.supports_keyboard_enhancement,
);
comment_cursor_logical_line = Some(line_idx + cursor_info.line_offset);
comment_cursor_column = 1 + cursor_info.column;
comment_input_box_range = Some((line_idx, line_idx + input_lines.len().saturating_sub(1)));
for mut input_line in input_lines {
let indicator = cursor_indicator(line_idx, current_line_idx);
input_line.spans.insert(
0,
Span::styled(indicator, styles::current_line_indicator_style(&app.theme)),
);
lines.push(input_line);
line_idx += 1;
}
}
for (file_idx, file) in app.diff_files.iter().enumerate() {
let path = file.display_path();
let status = file.status.as_char();
let is_reviewed = app.session.is_file_reviewed(path);
let indicator = cursor_indicator_spaced(line_idx, current_line_idx);
let review_mark = if is_reviewed { "✓ " } else { "" };
let header_text = if file.is_commit_message {
format!("═══ {}Commit Message ", review_mark)
} else {
format!("═══ {}{} [{}] ", review_mark, path.display(), status)
};
lines.push(Line::from(vec![
Span::styled(indicator, styles::current_line_indicator_style(&app.theme)),
Span::styled(header_text, styles::file_header_style(&app.theme)),
Span::styled("═".repeat(40), styles::file_header_style(&app.theme)),
]));
line_idx += 1;
if is_reviewed {
continue;
}
let is_file_comment_mode = app.input_mode == InputMode::Comment
&& app.comment_is_file_level
&& file_idx == app.diff_state.current_file_idx;
if let Some(review) = app.session.files.get(path) {
for comment in &review.file_comments {
let is_being_edited =
app.editing_comment_id.as_ref() == Some(&comment.id) && is_file_comment_mode;
if is_being_edited {
let (input_lines, cursor_info) = comment_panel::format_comment_input_lines(
&app.theme,
comment_type_presentation(app, &app.comment_type),
&app.comment_buffer,
app.comment_cursor,
None,
true,
app.supports_keyboard_enhancement,
);
comment_cursor_logical_line = Some(line_idx + cursor_info.line_offset);
comment_cursor_column = 1 + cursor_info.column;
comment_input_box_range =
Some((line_idx, line_idx + input_lines.len().saturating_sub(1)));
for mut input_line in input_lines {
let indicator = cursor_indicator(line_idx, current_line_idx);
input_line.spans.insert(
0,
Span::styled(
indicator,
styles::current_line_indicator_style(&app.theme),
),
);
lines.push(input_line);
line_idx += 1;
}
} else {
let comment_lines = comment_panel::format_comment_lines(
&app.theme,
comment_type_presentation(app, &comment.comment_type),
&comment.content,
None,
);
for mut comment_line in comment_lines {
let indicator = cursor_indicator(line_idx, current_line_idx);
comment_line.spans.insert(
0,
Span::styled(
indicator,
styles::current_line_indicator_style(&app.theme),
),
);
lines.push(comment_line);
line_idx += 1;
}
}
}
}
if is_file_comment_mode && app.editing_comment_id.is_none() {
let (input_lines, cursor_info) = comment_panel::format_comment_input_lines(
&app.theme,
comment_type_presentation(app, &app.comment_type),
&app.comment_buffer,
app.comment_cursor,
None,
false,
app.supports_keyboard_enhancement,
);
comment_cursor_logical_line = Some(line_idx + cursor_info.line_offset);
comment_cursor_column = 1 + cursor_info.column;
comment_input_box_range =
Some((line_idx, line_idx + input_lines.len().saturating_sub(1)));
for mut input_line in input_lines {
let indicator = cursor_indicator(line_idx, current_line_idx);
input_line.spans.insert(
0,
Span::styled(indicator, styles::current_line_indicator_style(&app.theme)),
);
lines.push(input_line);
line_idx += 1;
}
}
if file.is_too_large {
let indicator = cursor_indicator_spaced(line_idx, current_line_idx);
lines.push(Line::from(vec![
Span::styled(indicator, styles::current_line_indicator_style(&app.theme)),
Span::styled("(file too large to display)", styles::dim_style(&app.theme)),
]));
line_idx += 1;
} else if file.is_binary {
let indicator = cursor_indicator_spaced(line_idx, current_line_idx);
lines.push(Line::from(vec![
Span::styled(indicator, styles::current_line_indicator_style(&app.theme)),
Span::styled("(binary file)", styles::dim_style(&app.theme)),
]));
line_idx += 1;
} else if file.hunks.is_empty() {
let indicator = cursor_indicator_spaced(line_idx, current_line_idx);
lines.push(Line::from(vec![
Span::styled(indicator, styles::current_line_indicator_style(&app.theme)),
Span::styled("(no changes)", styles::dim_style(&app.theme)),
]));
line_idx += 1;
} else {
let line_comments = app
.session
.files
.get(path)
.map(|r| &r.line_comments)
.cloned()
.unwrap_or_default();
for (hunk_idx, hunk) in file.hunks.iter().enumerate() {
let prev_hunk = if hunk_idx > 0 {
file.hunks.get(hunk_idx - 1)
} else {
None
};
let gap = calculate_gap(
prev_hunk.map(|h| (&h.new_start, &h.new_count)),
hunk.new_start,
);
let gap_id = GapId { file_idx, hunk_idx };
if gap > 0 {
let top_lines = app.expanded_top.get(&gap_id);
let bot_lines = app.expanded_bottom.get(&gap_id);
let top_len = top_lines.map_or(0, |v| v.len());
let bot_len = bot_lines.map_or(0, |v| v.len());
let remaining = (gap as usize).saturating_sub(top_len + bot_len);
let is_top_of_file = hunk_idx == 0;
if let Some(top) = top_lines {
for expanded_line in top {
render_expanded_context_line(
&mut lines,
&mut line_idx,
current_line_idx,
expanded_line,
&app.theme,
);
}
}
if remaining > 0 {
if is_top_of_file {
if remaining > GAP_EXPAND_BATCH {
render_hidden_lines(
&mut lines,
&mut line_idx,
current_line_idx,
remaining,
&app.theme,
);
}
render_expander_line(
&mut lines,
&mut line_idx,
current_line_idx,
ExpandDirection::Up,
remaining,
&app.theme,
);
} else if remaining >= GAP_EXPAND_BATCH {
render_expander_line(
&mut lines,
&mut line_idx,
current_line_idx,
ExpandDirection::Down,
remaining,
&app.theme,
);
render_hidden_lines(
&mut lines,
&mut line_idx,
current_line_idx,
remaining,
&app.theme,
);
render_expander_line(
&mut lines,
&mut line_idx,
current_line_idx,
ExpandDirection::Up,
remaining,
&app.theme,
);
} else {
render_expander_line(
&mut lines,
&mut line_idx,
current_line_idx,
ExpandDirection::Both,
remaining,
&app.theme,
);
}
}
if let Some(bot) = bot_lines {
for expanded_line in bot {
render_expanded_context_line(
&mut lines,
&mut line_idx,
current_line_idx,
expanded_line,
&app.theme,
);
}
}
}
let indicator = cursor_indicator_spaced(line_idx, current_line_idx);
lines.push(Line::from(vec![
Span::styled(indicator, styles::current_line_indicator_style(&app.theme)),
Span::styled(
hunk.header.to_string(),
styles::diff_hunk_header_style(&app.theme),
),
]));
line_idx += 1;
for diff_line in &hunk.lines {
let (prefix, base_style) = match diff_line.origin {
LineOrigin::Addition => ("+", styles::diff_add_style(&app.theme)),
LineOrigin::Deletion => ("-", styles::diff_del_style(&app.theme)),
LineOrigin::Context => (" ", styles::diff_context_style(&app.theme)),
};
let is_in_visual_selection = {
let line_num = match diff_line.origin {
LineOrigin::Addition | LineOrigin::Context => diff_line.new_lineno,
LineOrigin::Deletion => diff_line.old_lineno,
};
let side = match diff_line.origin {
LineOrigin::Addition | LineOrigin::Context => LineSide::New,
LineOrigin::Deletion => LineSide::Old,
};
line_num
.map(|ln| app.is_line_in_visual_selection(ln, side))
.unwrap_or(false)
};
let style = if is_in_visual_selection {
base_style.patch(styles::visual_selection_style(&app.theme))
} else {
base_style
};
let line_num_str = match diff_line.origin {
LineOrigin::Addition => diff_line
.new_lineno
.map(|n| format!("{n:>4} "))
.unwrap_or_else(|| " ".to_string()),
LineOrigin::Deletion => diff_line
.old_lineno
.map(|n| format!("{n:>4} "))
.unwrap_or_else(|| " ".to_string()),
_ => diff_line
.new_lineno
.or(diff_line.old_lineno)
.map(|n| format!("{n:>4} "))
.unwrap_or_else(|| " ".to_string()),
};
let indicator = cursor_indicator(line_idx, current_line_idx);
let line_num_style = if is_in_visual_selection {
styles::dim_style(&app.theme)
.patch(styles::visual_selection_style(&app.theme))
} else {
styles::dim_style(&app.theme)
};
let mut line_spans = vec![
Span::styled(indicator, styles::current_line_indicator_style(&app.theme)),
Span::styled(line_num_str, line_num_style),
Span::styled(format!("{prefix} "), style),
];
if let Some(ref highlighted) = diff_line.highlighted_spans {
for (span_style, span_text) in highlighted {
let final_style = if is_in_visual_selection {
span_style.patch(styles::visual_selection_style(&app.theme))
} else {
*span_style
};
line_spans.push(Span::styled(span_text.clone(), final_style));
}
} else {
line_spans.push(Span::styled(diff_line.content.clone(), style));
}
if matches!(
diff_line.origin,
LineOrigin::Addition | LineOrigin::Deletion
) {
let eol_style = match diff_line.highlighted_spans.as_ref() {
Some(_) => {
let syntax_bg = match diff_line.origin {
LineOrigin::Addition => app.theme.syntax_add_bg,
LineOrigin::Deletion => app.theme.syntax_del_bg,
LineOrigin::Context => app.theme.panel_bg,
};
let base = line_spans.last().map(|s| s.style).unwrap_or(style);
base.bg(syntax_bg)
}
None => line_spans.last().map(|s| s.style).unwrap_or(style),
};
line_spans.push(Span::styled(String::new(), eol_style));
}
lines.push(Line::from(line_spans));
line_idx += 1;
if let Some(old_ln) = diff_line.old_lineno {
let is_line_comment_mode = app.input_mode == InputMode::Comment
&& !app.comment_is_file_level
&& file_idx == app.diff_state.current_file_idx
&& app.comment_line == Some((old_ln, LineSide::Old));
if let Some(comments) = line_comments.get(&old_ln) {
for comment in comments {
if comment.side == Some(LineSide::Old) {
let is_being_edited = is_line_comment_mode
&& app.editing_comment_id.as_ref() == Some(&comment.id);
if is_being_edited {
let line_range = app
.comment_line_range
.map(|(r, _)| r)
.or_else(|| Some(LineRange::single(old_ln)));
let (input_lines, cursor_info) =
comment_panel::format_comment_input_lines(
&app.theme,
comment_type_presentation(app, &app.comment_type),
&app.comment_buffer,
app.comment_cursor,
line_range,
true,
app.supports_keyboard_enhancement,
);
comment_cursor_logical_line =
Some(line_idx + cursor_info.line_offset);
comment_cursor_column = 1 + cursor_info.column;
comment_input_box_range = Some((
line_idx,
line_idx + input_lines.len().saturating_sub(1),
));
for mut input_line in input_lines {
let indicator =
cursor_indicator(line_idx, current_line_idx);
input_line.spans.insert(
0,
Span::styled(
indicator,
styles::current_line_indicator_style(
&app.theme,
),
),
);
lines.push(input_line);
line_idx += 1;
}
} else {
let line_range = comment
.line_range
.or_else(|| Some(LineRange::single(old_ln)));
let comment_lines = comment_panel::format_comment_lines(
&app.theme,
comment_type_presentation(app, &comment.comment_type),
&comment.content,
line_range,
);
for mut comment_line in comment_lines {
let is_current = line_idx == current_line_idx;
let indicator = if is_current { "▶" } else { " " };
comment_line.spans.insert(
0,
Span::styled(
indicator,
styles::current_line_indicator_style(
&app.theme,
),
),
);
lines.push(comment_line);
line_idx += 1;
}
}
}
}
}
if is_line_comment_mode && app.editing_comment_id.is_none() {
let line_range = app
.comment_line_range
.map(|(r, _)| r)
.or_else(|| Some(LineRange::single(old_ln)));
let (input_lines, cursor_info) =
comment_panel::format_comment_input_lines(
&app.theme,
comment_type_presentation(app, &app.comment_type),
&app.comment_buffer,
app.comment_cursor,
line_range,
false,
app.supports_keyboard_enhancement,
);
comment_cursor_logical_line = Some(line_idx + cursor_info.line_offset);
comment_cursor_column = 1 + cursor_info.column;
comment_input_box_range =
Some((line_idx, line_idx + input_lines.len().saturating_sub(1)));
for mut input_line in input_lines {
let indicator = cursor_indicator(line_idx, current_line_idx);
input_line.spans.insert(
0,
Span::styled(
indicator,
styles::current_line_indicator_style(&app.theme),
),
);
lines.push(input_line);
line_idx += 1;
}
}
}
if let Some(new_ln) = diff_line.new_lineno {
let is_line_comment_mode = app.input_mode == InputMode::Comment
&& !app.comment_is_file_level
&& file_idx == app.diff_state.current_file_idx
&& app.comment_line == Some((new_ln, LineSide::New));
if let Some(comments) = line_comments.get(&new_ln) {
for comment in comments {
if comment.side != Some(LineSide::Old) {
let is_being_edited = is_line_comment_mode
&& app.editing_comment_id.as_ref() == Some(&comment.id);
if is_being_edited {
let line_range = app
.comment_line_range
.map(|(r, _)| r)
.or_else(|| Some(LineRange::single(new_ln)));
let (input_lines, cursor_info) =
comment_panel::format_comment_input_lines(
&app.theme,
comment_type_presentation(app, &app.comment_type),
&app.comment_buffer,
app.comment_cursor,
line_range,
true,
app.supports_keyboard_enhancement,
);
comment_cursor_logical_line =
Some(line_idx + cursor_info.line_offset);
comment_cursor_column = 1 + cursor_info.column;
comment_input_box_range = Some((
line_idx,
line_idx + input_lines.len().saturating_sub(1),
));
for mut input_line in input_lines {
let indicator =
cursor_indicator(line_idx, current_line_idx);
input_line.spans.insert(
0,
Span::styled(
indicator,
styles::current_line_indicator_style(
&app.theme,
),
),
);
lines.push(input_line);
line_idx += 1;
}
} else {
let line_range = comment
.line_range
.or_else(|| Some(LineRange::single(new_ln)));
let comment_lines = comment_panel::format_comment_lines(
&app.theme,
comment_type_presentation(app, &comment.comment_type),
&comment.content,
line_range,
);
for mut comment_line in comment_lines {
let indicator =
cursor_indicator(line_idx, current_line_idx);
comment_line.spans.insert(
0,
Span::styled(
indicator,
styles::current_line_indicator_style(
&app.theme,
),
),
);
lines.push(comment_line);
line_idx += 1;
}
}
}
}
}
if is_line_comment_mode && app.editing_comment_id.is_none() {
let line_range = app
.comment_line_range
.map(|(r, _)| r)
.or_else(|| Some(LineRange::single(new_ln)));
let (input_lines, cursor_info) =
comment_panel::format_comment_input_lines(
&app.theme,
comment_type_presentation(app, &app.comment_type),
&app.comment_buffer,
app.comment_cursor,
line_range,
false,
app.supports_keyboard_enhancement,
);
comment_cursor_logical_line = Some(line_idx + cursor_info.line_offset);
comment_cursor_column = 1 + cursor_info.column;
comment_input_box_range =
Some((line_idx, line_idx + input_lines.len().saturating_sub(1)));
for mut input_line in input_lines {
let indicator = cursor_indicator(line_idx, current_line_idx);
input_line.spans.insert(
0,
Span::styled(
indicator,
styles::current_line_indicator_style(&app.theme),
),
);
lines.push(input_line);
line_idx += 1;
}
}
}
}
}
}
let indicator = cursor_indicator(line_idx, current_line_idx);
lines.push(Line::from(Span::styled(
indicator,
styles::current_line_indicator_style(&app.theme),
)));
line_idx += 1;
}
scroll_comment_input_into_view(
&mut app.diff_state.scroll_offset,
comment_input_box_range,
comment_cursor_logical_line,
inner.height as usize,
lines.len(),
);
let visible_lines_unscrolled: Vec<Line> = lines
.into_iter()
.skip(app.diff_state.scroll_offset)
.take(inner.height as usize)
.collect();
let line_widths: Vec<usize> = visible_lines_unscrolled
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.width())
.sum::<usize>()
})
.collect();
let max_content_width = line_widths.iter().copied().max().unwrap_or(0);
app.diff_state.viewport_width = inner.width as usize;
app.diff_state.max_content_width = max_content_width;
let viewport_width = inner.width as usize;
let viewport_height = inner.height as usize;
app.diff_state.visible_line_count = if app.diff_state.wrap_lines && viewport_width > 0 {
let mut visual_rows_used = 0;
let mut logical_lines_visible = 0;
for &width in &line_widths {
let rows_for_line = if width == 0 {
1
} else {
width.div_ceil(viewport_width)
};
if visual_rows_used + rows_for_line > viewport_height {
break;
}
visual_rows_used += rows_for_line;
logical_lines_visible += 1;
}
logical_lines_visible.max(1)
} else {
viewport_height
};
let max_scroll_x = max_content_width.saturating_sub(inner.width as usize);
if app.diff_state.scroll_x > max_scroll_x {
app.diff_state.scroll_x = max_scroll_x;
}
if app.diff_state.wrap_lines {
app.diff_state.scroll_x = 0;
}
let scroll_x = app.diff_state.scroll_x;
let visible_lines_unscrolled_for_bg = visible_lines_unscrolled.clone();
let visible_lines: Vec<Line> = if app.diff_state.wrap_lines {
visible_lines_unscrolled
} else {
visible_lines_unscrolled
.into_iter()
.map(|line| apply_horizontal_scroll(line, scroll_x))
.collect()
};
paint_unified_diff_row_backgrounds(
frame,
inner,
&visible_lines_unscrolled_for_bg,
&line_widths,
app.diff_state.wrap_lines,
inner.width as usize,
&app.theme,
);
let mut diff = Paragraph::new(visible_lines).style(Style::default().fg(app.theme.fg_primary));
if app.diff_state.wrap_lines {
diff = diff.wrap(Wrap { trim: false });
}
frame.render_widget(diff, inner);
if let Some(cursor_logical_line) = comment_cursor_logical_line {
let scroll_offset = app.diff_state.scroll_offset;
let visible_lines_count = app.diff_state.visible_line_count.max(1);
if cursor_logical_line >= scroll_offset
&& cursor_logical_line < scroll_offset + visible_lines_count
{
let logical_offset = cursor_logical_line - scroll_offset;
let mut visual_row: u16 = 0;
let viewport_width = inner.width as usize;
if app.diff_state.wrap_lines && viewport_width > 0 {
for i in 0..logical_offset {
if i < line_widths.len() {
let width = line_widths[i];
let rows = if width == 0 {
1
} else {
width.div_ceil(viewport_width)
};
visual_row += rows as u16;
} else {
visual_row += 1;
}
}
} else {
visual_row = logical_offset as u16;
}
let screen_col = inner.x + comment_cursor_column;
let screen_row_abs = inner.y + visual_row;
app.comment_cursor_screen_pos = Some((screen_col, screen_row_abs));
}
}
}
type SideBySideCursorInfo = (usize, u16, usize, usize);
struct SideBySideContext<'a> {
app: &'a App,
theme: &'a Theme,
content_width: usize,
current_line_idx: usize,
comment_input_mode: bool,
comment_line: Option<(u32, LineSide)>,
comment_type: crate::model::CommentType,
comment_buffer: &'a str,
comment_cursor: usize,
comment_line_range: Option<LineRange>,
editing_comment_id: Option<&'a str>,
supports_keyboard_enhancement: bool,
}
fn cursor_indicator(line_idx: usize, current_line_idx: usize) -> &'static str {
if line_idx == current_line_idx {
"▶"
} else {
" "
}
}
fn cursor_indicator_spaced(line_idx: usize, current_line_idx: usize) -> &'static str {
if line_idx == current_line_idx {
"▶ "
} else {
" "
}
}
fn render_expanded_context_line(
lines: &mut Vec<Line<'_>>,
line_idx: &mut usize,
current_line_idx: usize,
expanded_line: &crate::model::DiffLine,
theme: &Theme,
) {
let indicator = cursor_indicator(*line_idx, current_line_idx);
let line_num = expanded_line
.new_lineno
.map(|n| format!("{n:>4} "))
.unwrap_or_else(|| " ".to_string());
let line_spans = vec![
Span::styled(indicator, styles::current_line_indicator_style(theme)),
Span::styled(line_num, styles::expanded_context_style(theme)),
Span::styled(" ", styles::expanded_context_style(theme)),
Span::styled(
expanded_line.content.clone(),
styles::expanded_context_style(theme),
),
];
lines.push(Line::from(line_spans));
*line_idx += 1;
}
fn render_expander_line(
lines: &mut Vec<Line<'_>>,
line_idx: &mut usize,
current_line_idx: usize,
direction: ExpandDirection,
remaining: usize,
theme: &Theme,
) {
let arrow = match direction {
ExpandDirection::Down => "↓",
ExpandDirection::Up => "↑",
ExpandDirection::Both => "↕",
};
let count = remaining.min(GAP_EXPAND_BATCH);
let indicator = cursor_indicator_spaced(*line_idx, current_line_idx);
lines.push(Line::from(vec![
Span::styled(indicator, styles::current_line_indicator_style(theme)),
Span::styled(
format!(" ... {arrow} expand ({count} lines) ..."),
styles::dim_style(theme),
),
]));
*line_idx += 1;
}
fn render_sbs_expanded_context_line(
lines: &mut Vec<Line<'_>>,
line_idx: &mut usize,
current_line_idx: usize,
expanded_line: &crate::model::DiffLine,
content_width: usize,
theme: &Theme,
) {
let indicator = cursor_indicator(*line_idx, current_line_idx);
let line_num = expanded_line
.new_lineno
.map(|n| format!("{n:>4} "))
.unwrap_or_else(|| " ".to_string());
let line_spans = vec![
Span::styled(indicator, styles::current_line_indicator_style(theme)),
Span::styled(line_num.clone(), styles::expanded_context_style(theme)),
Span::styled(" ", styles::expanded_context_style(theme)),
Span::styled(
truncate_or_pad(&expanded_line.content, content_width),
styles::expanded_context_style(theme),
),
Span::styled(" │ ", styles::dim_style(theme)),
Span::styled(line_num, styles::expanded_context_style(theme)),
Span::styled(" ", styles::expanded_context_style(theme)),
Span::styled(
truncate_or_pad(&expanded_line.content, content_width),
styles::expanded_context_style(theme),
),
];
lines.push(Line::from(line_spans));
*line_idx += 1;
}
fn render_hidden_lines(
lines: &mut Vec<Line<'_>>,
line_idx: &mut usize,
current_line_idx: usize,
count: usize,
theme: &Theme,
) {
let indicator = cursor_indicator_spaced(*line_idx, current_line_idx);
lines.push(Line::from(vec![
Span::styled(indicator, styles::current_line_indicator_style(theme)),
Span::styled(
format!(" ... {count} lines hidden ..."),
styles::dim_style(theme),
),
]));
*line_idx += 1;
}
fn comment_type_presentation(
app: &App,
comment_type: &crate::model::CommentType,
) -> comment_panel::CommentTypePresentation {
comment_panel::CommentTypePresentation {
label: app.comment_type_label(comment_type),
color: app.comment_type_color(comment_type),
}
}
fn scroll_comment_input_into_view(
scroll_offset: &mut usize,
box_range: Option<(usize, usize)>,
cursor_line: Option<usize>,
viewport_height: usize,
total_lines: usize,
) {
let Some((box_start, box_end)) = box_range else {
return;
};
if viewport_height == 0 {
return;
}
let box_height = box_end.saturating_sub(box_start) + 1;
if box_height <= viewport_height {
if box_start < *scroll_offset {
*scroll_offset = box_start;
} else if box_end >= *scroll_offset + viewport_height {
*scroll_offset = box_end + 1 - viewport_height;
}
} else if let Some(cursor) = cursor_line {
if cursor < *scroll_offset {
*scroll_offset = cursor;
} else if cursor >= *scroll_offset + viewport_height {
*scroll_offset = cursor + 1 - viewport_height;
}
}
let max_scroll = total_lines.saturating_sub(viewport_height);
if *scroll_offset > max_scroll {
*scroll_offset = max_scroll;
}
}
fn render_side_by_side_diff(frame: &mut Frame, app: &mut App, area: Rect) {
let focused = app.focused_panel == FocusedPanel::Diff;
let title = match app.current_file_path() {
Some(path) => format!(" Diff (Side-by-Side) \u{2014} {} ", path.display()),
None => " Diff (Side-by-Side) ".to_string(),
};
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.style(styles::panel_style(&app.theme))
.border_style(styles::border_style(&app.theme, focused));
let inner = block.inner(area);
frame.render_widget(block, area);
app.diff_state.viewport_height = inner.height as usize;
let available_width = inner.width.saturating_sub(16) as usize;
let content_width = available_width / 2;
let comment_input_mode = app.input_mode == InputMode::Comment
&& !app.comment_is_file_level
&& !app.comment_is_review_level;
let ctx = SideBySideContext {
app,
theme: &app.theme,
content_width,
current_line_idx: app.diff_state.cursor_line,
comment_input_mode,
comment_line: app.comment_line,
comment_type: app.comment_type.clone(),
comment_buffer: &app.comment_buffer,
comment_cursor: app.comment_cursor,
comment_line_range: app.comment_line_range.map(|(r, _)| r),
editing_comment_id: app.editing_comment_id.as_deref(),
supports_keyboard_enhancement: app.supports_keyboard_enhancement,
};
let mut lines: Vec<Line> = Vec::new();
let mut line_idx: usize = 0;
let mut comment_cursor_logical_line: Option<usize> = None;
let mut comment_cursor_column: u16 = 0;
let mut comment_input_box_range: Option<(usize, usize)> = None;
let is_review_comment_mode =
app.input_mode == InputMode::Comment && app.comment_is_review_level;
let general_indicator = cursor_indicator_spaced(line_idx, ctx.current_line_idx);
lines.push(Line::from(vec![
Span::styled(
general_indicator,
styles::current_line_indicator_style(&app.theme),
),
Span::styled(
"═══ Review Comments ",
styles::file_header_style(&app.theme),
),
Span::styled("═".repeat(40), styles::file_header_style(&app.theme)),
]));
line_idx += 1;
for comment in &app.session.review_comments {
let is_being_edited =
app.editing_comment_id.as_ref() == Some(&comment.id) && is_review_comment_mode;
if is_being_edited {
let (input_lines, cursor_info) = comment_panel::format_comment_input_lines(
&app.theme,
comment_type_presentation(app, &app.comment_type),
&app.comment_buffer,
app.comment_cursor,
None,
true,
app.supports_keyboard_enhancement,
);
comment_cursor_logical_line = Some(line_idx + cursor_info.line_offset);
comment_cursor_column = 1 + cursor_info.column;
comment_input_box_range =
Some((line_idx, line_idx + input_lines.len().saturating_sub(1)));
for mut input_line in input_lines {
let indicator = cursor_indicator(line_idx, ctx.current_line_idx);
input_line.spans.insert(
0,
Span::styled(indicator, styles::current_line_indicator_style(&app.theme)),
);
lines.push(input_line);
line_idx += 1;
}
} else {
let comment_lines = comment_panel::format_comment_lines(
&app.theme,
comment_type_presentation(app, &comment.comment_type),
&comment.content,
None,
);
for mut comment_line in comment_lines {
let indicator = cursor_indicator(line_idx, ctx.current_line_idx);
comment_line.spans.insert(
0,
Span::styled(indicator, styles::current_line_indicator_style(&app.theme)),
);
lines.push(comment_line);
line_idx += 1;
}
}
}
if is_review_comment_mode && app.editing_comment_id.is_none() {
let (input_lines, cursor_info) = comment_panel::format_comment_input_lines(
&app.theme,
comment_type_presentation(app, &app.comment_type),
&app.comment_buffer,
app.comment_cursor,
None,
false,
app.supports_keyboard_enhancement,
);
comment_cursor_logical_line = Some(line_idx + cursor_info.line_offset);
comment_cursor_column = 1 + cursor_info.column;
comment_input_box_range = Some((line_idx, line_idx + input_lines.len().saturating_sub(1)));
for mut input_line in input_lines {
let indicator = cursor_indicator(line_idx, ctx.current_line_idx);
input_line.spans.insert(
0,
Span::styled(indicator, styles::current_line_indicator_style(&app.theme)),
);
lines.push(input_line);
line_idx += 1;
}
}
for (file_idx, file) in app.diff_files.iter().enumerate() {
let path = file.display_path();
let status = file.status.as_char();
let is_reviewed = app.session.is_file_reviewed(path);
let indicator = cursor_indicator_spaced(line_idx, ctx.current_line_idx);
let review_mark = if is_reviewed { "✓ " } else { "" };
let header_text = if file.is_commit_message {
format!("═══ {}Commit Message ", review_mark)
} else {
format!("═══ {}{} [{}] ", review_mark, path.display(), status)
};
lines.push(Line::from(vec![
Span::styled(indicator, styles::current_line_indicator_style(&app.theme)),
Span::styled(header_text, styles::file_header_style(&app.theme)),
Span::styled("═".repeat(40), styles::file_header_style(&app.theme)),
]));
line_idx += 1;
if is_reviewed {
continue;
}
let is_file_comment_mode = app.input_mode == InputMode::Comment
&& app.comment_is_file_level
&& file_idx == app.diff_state.current_file_idx;
if let Some(review) = app.session.files.get(path) {
for comment in &review.file_comments {
let is_being_edited =
app.editing_comment_id.as_ref() == Some(&comment.id) && is_file_comment_mode;
if is_being_edited {
let (input_lines, cursor_info) = comment_panel::format_comment_input_lines(
&app.theme,
comment_type_presentation(app, &app.comment_type),
&app.comment_buffer,
app.comment_cursor,
None,
true,
app.supports_keyboard_enhancement,
);
comment_cursor_logical_line = Some(line_idx + cursor_info.line_offset);
comment_cursor_column = 1 + cursor_info.column;
comment_input_box_range =
Some((line_idx, line_idx + input_lines.len().saturating_sub(1)));
for mut input_line in input_lines {
let indicator = cursor_indicator(line_idx, ctx.current_line_idx);
input_line.spans.insert(
0,
Span::styled(
indicator,
styles::current_line_indicator_style(&app.theme),
),
);
lines.push(input_line);
line_idx += 1;
}
} else {
let comment_lines = comment_panel::format_comment_lines(
&app.theme,
comment_type_presentation(app, &comment.comment_type),
&comment.content,
None,
);
for mut comment_line in comment_lines {
let indicator = cursor_indicator(line_idx, ctx.current_line_idx);
comment_line.spans.insert(
0,
Span::styled(
indicator,
styles::current_line_indicator_style(&app.theme),
),
);
lines.push(comment_line);
line_idx += 1;
}
}
}
}
if is_file_comment_mode && app.editing_comment_id.is_none() {
let (input_lines, cursor_info) = comment_panel::format_comment_input_lines(
&app.theme,
comment_type_presentation(app, &app.comment_type),
&app.comment_buffer,
app.comment_cursor,
None,
false,
app.supports_keyboard_enhancement,
);
comment_cursor_logical_line = Some(line_idx + cursor_info.line_offset);
comment_cursor_column = 1 + cursor_info.column;
comment_input_box_range =
Some((line_idx, line_idx + input_lines.len().saturating_sub(1)));
for mut input_line in input_lines {
let indicator = cursor_indicator(line_idx, ctx.current_line_idx);
input_line.spans.insert(
0,
Span::styled(indicator, styles::current_line_indicator_style(&app.theme)),
);
lines.push(input_line);
line_idx += 1;
}
}
if file.is_too_large {
let indicator = cursor_indicator_spaced(line_idx, ctx.current_line_idx);
lines.push(Line::from(vec![
Span::styled(indicator, styles::current_line_indicator_style(&app.theme)),
Span::styled("(file too large to display)", styles::dim_style(&app.theme)),
]));
line_idx += 1;
} else if file.is_binary {
let indicator = cursor_indicator_spaced(line_idx, ctx.current_line_idx);
lines.push(Line::from(vec![
Span::styled(indicator, styles::current_line_indicator_style(&app.theme)),
Span::styled("(binary file)", styles::dim_style(&app.theme)),
]));
line_idx += 1;
} else if file.hunks.is_empty() {
let indicator = cursor_indicator_spaced(line_idx, ctx.current_line_idx);
lines.push(Line::from(vec![
Span::styled(indicator, styles::current_line_indicator_style(&app.theme)),
Span::styled("(no changes)", styles::dim_style(&app.theme)),
]));
line_idx += 1;
} else {
let line_comments = app
.session
.files
.get(path)
.map(|r| &r.line_comments)
.cloned()
.unwrap_or_default();
for (hunk_idx, hunk) in file.hunks.iter().enumerate() {
let prev_hunk = if hunk_idx > 0 {
file.hunks.get(hunk_idx - 1)
} else {
None
};
let gap = calculate_gap(
prev_hunk.map(|h| (&h.new_start, &h.new_count)),
hunk.new_start,
);
let gap_id = GapId { file_idx, hunk_idx };
if gap > 0 {
let top_lines = app.expanded_top.get(&gap_id);
let bot_lines = app.expanded_bottom.get(&gap_id);
let top_len = top_lines.map_or(0, |v| v.len());
let bot_len = bot_lines.map_or(0, |v| v.len());
let remaining = (gap as usize).saturating_sub(top_len + bot_len);
let is_top_of_file = hunk_idx == 0;
if let Some(top) = top_lines {
for expanded_line in top {
render_sbs_expanded_context_line(
&mut lines,
&mut line_idx,
ctx.current_line_idx,
expanded_line,
ctx.content_width,
&app.theme,
);
}
}
if remaining > 0 {
if is_top_of_file {
if remaining > GAP_EXPAND_BATCH {
render_hidden_lines(
&mut lines,
&mut line_idx,
ctx.current_line_idx,
remaining,
&app.theme,
);
}
render_expander_line(
&mut lines,
&mut line_idx,
ctx.current_line_idx,
ExpandDirection::Up,
remaining,
&app.theme,
);
} else if remaining >= GAP_EXPAND_BATCH {
render_expander_line(
&mut lines,
&mut line_idx,
ctx.current_line_idx,
ExpandDirection::Down,
remaining,
&app.theme,
);
render_hidden_lines(
&mut lines,
&mut line_idx,
ctx.current_line_idx,
remaining,
&app.theme,
);
render_expander_line(
&mut lines,
&mut line_idx,
ctx.current_line_idx,
ExpandDirection::Up,
remaining,
&app.theme,
);
} else {
render_expander_line(
&mut lines,
&mut line_idx,
ctx.current_line_idx,
ExpandDirection::Both,
remaining,
&app.theme,
);
}
}
if let Some(bot) = bot_lines {
for expanded_line in bot {
render_sbs_expanded_context_line(
&mut lines,
&mut line_idx,
ctx.current_line_idx,
expanded_line,
ctx.content_width,
&app.theme,
);
}
}
}
let indicator = cursor_indicator_spaced(line_idx, ctx.current_line_idx);
lines.push(Line::from(vec![
Span::styled(indicator, styles::current_line_indicator_style(&app.theme)),
Span::styled(
hunk.header.to_string(),
styles::diff_hunk_header_style(&app.theme),
),
]));
line_idx += 1;
let (new_line_idx, cursor_info) = render_hunk_lines_side_by_side(
&hunk.lines,
&line_comments,
&ctx,
line_idx,
&mut lines,
);
line_idx = new_line_idx;
if let Some((line, col, box_start, box_end)) = cursor_info {
comment_cursor_logical_line = Some(line);
comment_cursor_column = col;
comment_input_box_range = Some((box_start, box_end));
}
}
}
let indicator = cursor_indicator(line_idx, ctx.current_line_idx);
lines.push(Line::from(Span::styled(
indicator,
styles::current_line_indicator_style(&app.theme),
)));
line_idx += 1;
}
scroll_comment_input_into_view(
&mut app.diff_state.scroll_offset,
comment_input_box_range,
comment_cursor_logical_line,
inner.height as usize,
lines.len(),
);
let visible_lines_unscrolled: Vec<Line> = lines
.into_iter()
.skip(app.diff_state.scroll_offset)
.take(inner.height as usize)
.collect();
let line_widths: Vec<usize> = visible_lines_unscrolled
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.width())
.sum::<usize>()
})
.collect();
let max_content_width = line_widths.iter().copied().max().unwrap_or(0);
app.diff_state.viewport_width = inner.width as usize;
app.diff_state.max_content_width = max_content_width;
let viewport_width = inner.width as usize;
let viewport_height = inner.height as usize;
app.diff_state.visible_line_count = if app.diff_state.wrap_lines && viewport_width > 0 {
let mut visual_rows_used = 0;
let mut logical_lines_visible = 0;
for &width in &line_widths {
let rows_for_line = if width == 0 {
1
} else {
width.div_ceil(viewport_width)
};
if visual_rows_used + rows_for_line > viewport_height {
break;
}
visual_rows_used += rows_for_line;
logical_lines_visible += 1;
}
logical_lines_visible.max(1)
} else {
viewport_height
};
let max_scroll_x = max_content_width.saturating_sub(inner.width as usize);
if app.diff_state.scroll_x > max_scroll_x {
app.diff_state.scroll_x = max_scroll_x;
}
if app.diff_state.wrap_lines {
app.diff_state.scroll_x = 0;
}
let scroll_x = app.diff_state.scroll_x;
let visible_lines: Vec<Line> = if app.diff_state.wrap_lines {
visible_lines_unscrolled
} else {
visible_lines_unscrolled
.into_iter()
.map(|line| apply_horizontal_scroll(line, scroll_x))
.collect()
};
let mut diff = Paragraph::new(visible_lines).style(styles::panel_style(&app.theme));
if app.diff_state.wrap_lines {
diff = diff.wrap(Wrap { trim: false });
}
frame.render_widget(diff, inner);
if let Some(cursor_logical_line) = comment_cursor_logical_line {
let scroll_offset = app.diff_state.scroll_offset;
let visible_lines_count = app.diff_state.visible_line_count.max(1);
if cursor_logical_line >= scroll_offset
&& cursor_logical_line < scroll_offset + visible_lines_count
{
let logical_offset = cursor_logical_line - scroll_offset;
let mut visual_row: u16 = 0;
let viewport_width = inner.width as usize;
if app.diff_state.wrap_lines && viewport_width > 0 {
for i in 0..logical_offset {
if i < line_widths.len() {
let width = line_widths[i];
let rows = if width == 0 {
1
} else {
width.div_ceil(viewport_width)
};
visual_row += rows as u16;
} else {
visual_row += 1;
}
}
} else {
visual_row = logical_offset as u16;
}
let screen_col = inner.x + comment_cursor_column;
let screen_row_abs = inner.y + visual_row;
app.comment_cursor_screen_pos = Some((screen_col, screen_row_abs));
}
}
}
fn render_hunk_lines_side_by_side(
hunk_lines: &[crate::model::DiffLine],
line_comments: &std::collections::HashMap<u32, Vec<crate::model::Comment>>,
ctx: &SideBySideContext,
mut line_idx: usize,
lines: &mut Vec<Line>,
) -> (usize, Option<SideBySideCursorInfo>) {
let mut i = 0;
let mut cursor_info_out: Option<SideBySideCursorInfo> = None;
while i < hunk_lines.len() {
let diff_line = &hunk_lines[i];
match diff_line.origin {
LineOrigin::Context => {
let (new_line_idx, cursor_info) = render_context_line_side_by_side(
diff_line,
line_comments,
ctx,
line_idx,
lines,
);
line_idx = new_line_idx;
if cursor_info.is_some() {
cursor_info_out = cursor_info;
}
i += 1;
}
LineOrigin::Deletion => {
let (new_line_idx, lines_processed, cursor_info) =
render_deletion_addition_pair_side_by_side(
hunk_lines,
i,
line_comments,
ctx,
line_idx,
lines,
);
line_idx = new_line_idx;
if cursor_info.is_some() {
cursor_info_out = cursor_info;
}
i = lines_processed;
}
LineOrigin::Addition => {
let (new_line_idx, cursor_info) = render_standalone_addition_side_by_side(
diff_line,
line_comments,
ctx,
line_idx,
lines,
);
line_idx = new_line_idx;
if cursor_info.is_some() {
cursor_info_out = cursor_info;
}
i += 1;
}
}
}
(line_idx, cursor_info_out)
}
fn render_context_line_side_by_side(
diff_line: &crate::model::DiffLine,
line_comments: &std::collections::HashMap<u32, Vec<crate::model::Comment>>,
ctx: &SideBySideContext,
mut line_idx: usize,
lines: &mut Vec<Line>,
) -> (usize, Option<SideBySideCursorInfo>) {
let line_num = diff_line
.old_lineno
.or(diff_line.new_lineno)
.map(|n| format!("{n:>4}"))
.unwrap_or_else(|| " ".to_string());
let indicator = cursor_indicator(line_idx, ctx.current_line_idx);
let mut spans = vec![
Span::styled(indicator, styles::current_line_indicator_style(ctx.theme)),
Span::styled(format!("{line_num} "), styles::dim_style(ctx.theme)),
Span::styled(" ".to_string(), styles::diff_context_style(ctx.theme)),
];
if let Some(ref highlighted) = diff_line.highlighted_spans {
let content_spans = truncate_or_pad_spans(
highlighted,
ctx.content_width,
styles::diff_context_style(ctx.theme),
);
spans.extend(content_spans);
} else {
let content = truncate_or_pad(&diff_line.content, ctx.content_width);
spans.push(Span::styled(content, styles::diff_context_style(ctx.theme)));
}
spans.push(Span::styled(" │ ", styles::dim_style(ctx.theme)));
spans.push(Span::styled(
format!("{line_num} "),
styles::dim_style(ctx.theme),
));
spans.push(Span::styled(
" ".to_string(),
styles::diff_context_style(ctx.theme),
));
if let Some(ref highlighted) = diff_line.highlighted_spans {
let content_spans = truncate_or_pad_spans(
highlighted,
ctx.content_width,
styles::diff_context_style(ctx.theme),
);
spans.extend(content_spans);
} else {
let content = truncate_or_pad(&diff_line.content, ctx.content_width);
spans.push(Span::styled(content, styles::diff_context_style(ctx.theme)));
}
lines.push(Line::from(spans));
line_idx += 1;
let mut cursor_info_out: Option<SideBySideCursorInfo> = None;
if let Some(new_ln) = diff_line.new_lineno {
let (new_line_idx, cursor_info) =
add_comments_to_line(new_ln, line_comments, LineSide::New, ctx, line_idx, lines);
line_idx = new_line_idx;
cursor_info_out = cursor_info;
}
(line_idx, cursor_info_out)
}
fn render_deletion_addition_pair_side_by_side(
hunk_lines: &[crate::model::DiffLine],
start_idx: usize,
line_comments: &std::collections::HashMap<u32, Vec<crate::model::Comment>>,
ctx: &SideBySideContext,
mut line_idx: usize,
lines: &mut Vec<Line>,
) -> (usize, usize, Option<SideBySideCursorInfo>) {
let mut del_end = start_idx + 1;
while del_end < hunk_lines.len() && hunk_lines[del_end].origin == LineOrigin::Deletion {
del_end += 1;
}
let add_start = del_end;
let mut add_end = add_start;
while add_end < hunk_lines.len() && hunk_lines[add_end].origin == LineOrigin::Addition {
add_end += 1;
}
let del_count = del_end - start_idx;
let add_count = add_end - add_start;
let max_lines = del_count.max(add_count);
let mut cursor_info_out: Option<SideBySideCursorInfo> = None;
for offset in 0..max_lines {
let indicator = cursor_indicator(line_idx, ctx.current_line_idx);
let mut spans = vec![Span::styled(
indicator,
styles::current_line_indicator_style(ctx.theme),
)];
if offset < del_count {
let del_line = &hunk_lines[start_idx + offset];
add_deletion_spans(ctx.theme, &mut spans, del_line, ctx.content_width);
} else {
add_empty_column_spans(&mut spans, ctx.content_width);
}
spans.push(Span::styled(" │ ", styles::dim_style(ctx.theme)));
if offset < add_count {
let add_line = &hunk_lines[add_start + offset];
add_addition_spans(ctx.theme, &mut spans, add_line, ctx.content_width);
} else {
add_empty_column_spans(&mut spans, ctx.content_width);
}
lines.push(Line::from(spans));
line_idx += 1;
if offset < del_count {
let del_line = &hunk_lines[start_idx + offset];
if let Some(old_ln) = del_line.old_lineno {
let (new_line_idx, cursor_info) = add_comments_to_line(
old_ln,
line_comments,
LineSide::Old,
ctx,
line_idx,
lines,
);
line_idx = new_line_idx;
if cursor_info.is_some() {
cursor_info_out = cursor_info;
}
}
}
if offset < add_count {
let add_line = &hunk_lines[add_start + offset];
if let Some(new_ln) = add_line.new_lineno {
let (new_line_idx, cursor_info) = add_comments_to_line(
new_ln,
line_comments,
LineSide::New,
ctx,
line_idx,
lines,
);
line_idx = new_line_idx;
if cursor_info.is_some() {
cursor_info_out = cursor_info;
}
}
}
}
(line_idx, add_end, cursor_info_out)
}
fn render_standalone_addition_side_by_side(
diff_line: &crate::model::DiffLine,
line_comments: &std::collections::HashMap<u32, Vec<crate::model::Comment>>,
ctx: &SideBySideContext,
mut line_idx: usize,
lines: &mut Vec<Line>,
) -> (usize, Option<SideBySideCursorInfo>) {
let indicator = cursor_indicator(line_idx, ctx.current_line_idx);
let mut spans = vec![Span::styled(
indicator,
styles::current_line_indicator_style(ctx.theme),
)];
add_empty_column_spans(&mut spans, ctx.content_width);
spans.push(Span::styled(" │ ", styles::dim_style(ctx.theme)));
add_addition_spans(ctx.theme, &mut spans, diff_line, ctx.content_width);
lines.push(Line::from(spans));
line_idx += 1;
let mut cursor_info_out: Option<SideBySideCursorInfo> = None;
if let Some(new_ln) = diff_line.new_lineno {
let (new_line_idx, cursor_info) =
add_comments_to_line(new_ln, line_comments, LineSide::New, ctx, line_idx, lines);
line_idx = new_line_idx;
cursor_info_out = cursor_info;
}
(line_idx, cursor_info_out)
}
fn add_deletion_spans(
theme: &Theme,
spans: &mut Vec<Span>,
diff_line: &crate::model::DiffLine,
content_width: usize,
) {
let line_num = diff_line
.old_lineno
.map(|n| format!("{n:>4}"))
.unwrap_or_else(|| " ".to_string());
spans.push(Span::styled(
format!("{line_num} "),
styles::dim_style(theme),
));
spans.push(Span::styled("-".to_string(), styles::diff_del_style(theme)));
if let Some(ref highlighted) = diff_line.highlighted_spans {
let syntax_pad_style = Style::default().fg(theme.diff_del).bg(theme.syntax_del_bg);
let content_spans = truncate_or_pad_spans(highlighted, content_width, syntax_pad_style);
spans.extend(content_spans);
} else {
let content = truncate_or_pad(&diff_line.content, content_width);
spans.push(Span::styled(content, styles::diff_del_style(theme)));
}
}
fn add_addition_spans(
theme: &Theme,
spans: &mut Vec<Span>,
diff_line: &crate::model::DiffLine,
content_width: usize,
) {
let line_num = diff_line
.new_lineno
.map(|n| format!("{n:>4}"))
.unwrap_or_else(|| " ".to_string());
spans.push(Span::styled(
format!("{line_num} "),
styles::dim_style(theme),
));
spans.push(Span::styled("+".to_string(), styles::diff_add_style(theme)));
if let Some(ref highlighted) = diff_line.highlighted_spans {
let syntax_pad_style = Style::default().fg(theme.diff_add).bg(theme.syntax_add_bg);
let content_spans = truncate_or_pad_spans(highlighted, content_width, syntax_pad_style);
spans.extend(content_spans);
} else {
let content = truncate_or_pad(&diff_line.content, content_width);
spans.push(Span::styled(content, styles::diff_add_style(theme)));
}
}
fn add_empty_column_spans(spans: &mut Vec<Span>, content_width: usize) {
spans.push(Span::styled(
" ".repeat(5 + 1 + content_width),
Style::default(),
));
}
fn add_comments_to_line(
line_num: u32,
line_comments: &std::collections::HashMap<u32, Vec<crate::model::Comment>>,
side: LineSide,
ctx: &SideBySideContext,
mut line_idx: usize,
lines: &mut Vec<Line>,
) -> (usize, Option<SideBySideCursorInfo>) {
let is_line_comment_mode = ctx.comment_input_mode && ctx.comment_line == Some((line_num, side));
let mut cursor_info_out: Option<SideBySideCursorInfo> = None;
if let Some(comments) = line_comments.get(&line_num) {
for comment in comments {
let comment_side = comment.side.unwrap_or(LineSide::New);
if (side == LineSide::Old && comment_side == LineSide::Old)
|| (side == LineSide::New && comment_side != LineSide::Old)
{
let is_being_edited =
is_line_comment_mode && ctx.editing_comment_id == Some(comment.id.as_str());
if is_being_edited {
let line_range = ctx
.comment_line_range
.or_else(|| Some(LineRange::single(line_num)));
let (input_lines, cursor_info) = comment_panel::format_comment_input_lines(
ctx.theme,
comment_type_presentation(ctx.app, &ctx.comment_type),
ctx.comment_buffer,
ctx.comment_cursor,
line_range,
true,
ctx.supports_keyboard_enhancement,
);
let box_end = line_idx + input_lines.len().saturating_sub(1);
cursor_info_out = Some((
line_idx + cursor_info.line_offset,
1 + cursor_info.column,
line_idx,
box_end,
));
for mut input_line in input_lines {
let indicator = cursor_indicator(line_idx, ctx.current_line_idx);
input_line.spans.insert(
0,
Span::styled(
indicator,
styles::current_line_indicator_style(ctx.theme),
),
);
lines.push(input_line);
line_idx += 1;
}
} else {
let line_range = comment
.line_range
.or_else(|| Some(LineRange::single(line_num)));
let comment_lines = comment_panel::format_comment_lines(
ctx.theme,
comment_type_presentation(ctx.app, &comment.comment_type),
&comment.content,
line_range,
);
for mut comment_line in comment_lines {
let indicator = cursor_indicator(line_idx, ctx.current_line_idx);
comment_line.spans.insert(
0,
Span::styled(
indicator,
styles::current_line_indicator_style(ctx.theme),
),
);
lines.push(comment_line);
line_idx += 1;
}
}
}
}
}
if is_line_comment_mode && ctx.editing_comment_id.is_none() {
let line_range = ctx
.comment_line_range
.or_else(|| Some(LineRange::single(line_num)));
let (input_lines, cursor_info) = comment_panel::format_comment_input_lines(
ctx.theme,
comment_type_presentation(ctx.app, &ctx.comment_type),
ctx.comment_buffer,
ctx.comment_cursor,
line_range,
false,
ctx.supports_keyboard_enhancement,
);
let box_end = line_idx + input_lines.len().saturating_sub(1);
cursor_info_out = Some((
line_idx + cursor_info.line_offset,
1 + cursor_info.column,
line_idx,
box_end,
));
for mut input_line in input_lines {
let indicator = cursor_indicator(line_idx, ctx.current_line_idx);
input_line.spans.insert(
0,
Span::styled(indicator, styles::current_line_indicator_style(ctx.theme)),
);
lines.push(input_line);
line_idx += 1;
}
}
(line_idx, cursor_info_out)
}
fn truncate_or_pad(s: &str, width: usize) -> String {
let char_count = s.chars().count();
if char_count > width {
s.chars().take(width.saturating_sub(3)).collect::<String>() + "..."
} else {
format!("{s:width$}")
}
}
fn truncate_or_pad_spans(
spans: &[(Style, String)],
width: usize,
base_style: Style,
) -> Vec<Span<'static>> {
let total_width: usize = spans.iter().map(|(_, text)| text.width()).sum();
if total_width > width {
let mut result = Vec::new();
let mut remaining = width.saturating_sub(3);
for (style, text) in spans {
if remaining == 0 {
break;
}
let text_width = text.width();
if text_width <= remaining {
result.push(Span::styled(text.clone(), *style));
remaining -= text_width;
} else {
let mut truncated = String::new();
let mut current_width = 0;
for c in text.chars() {
let char_width = unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
if current_width + char_width > remaining {
break;
}
truncated.push(c);
current_width += char_width;
}
if !truncated.is_empty() {
result.push(Span::styled(truncated, *style));
}
remaining = 0;
}
}
result.push(Span::styled("...".to_string(), base_style));
result
} else if total_width < width {
let mut result: Vec<Span> = spans
.iter()
.map(|(style, text)| Span::styled(text.clone(), *style))
.collect();
let padding = " ".repeat(width - total_width);
result.push(Span::styled(padding, base_style));
result
} else {
spans
.iter()
.map(|(style, text)| Span::styled(text.clone(), *style))
.collect()
}
}
fn unified_line_bg_style(line: &Line, theme: &Theme) -> Option<Style> {
let prefix = line.spans.get(2)?.content.as_ref();
let default_bg = match prefix {
"+ " => theme.diff_add_bg,
"- " => theme.diff_del_bg,
_ => return None,
};
let bg = line
.spans
.last()
.and_then(|span| span.style.bg)
.unwrap_or(default_bg);
Some(Style::default().bg(bg))
}
fn paint_unified_diff_row_backgrounds(
frame: &mut Frame,
inner: Rect,
visible_lines_unscrolled: &[Line],
line_widths: &[usize],
wrap_lines: bool,
viewport_width: usize,
theme: &Theme,
) {
let mut visual_row: usize = 0;
for (idx, line) in visible_lines_unscrolled.iter().enumerate() {
if visual_row >= inner.height as usize {
break;
}
let rows_for_line = if wrap_lines && viewport_width > 0 {
let width = line_widths.get(idx).copied().unwrap_or(0);
if width == 0 {
1
} else {
width.div_ceil(viewport_width)
}
} else {
1
};
if let Some(row_style) = unified_line_bg_style(line, theme) {
for _ in 0..rows_for_line {
if visual_row >= inner.height as usize {
break;
}
let row_rect = Rect {
x: inner.x,
y: inner.y + visual_row as u16,
width: inner.width,
height: 1,
};
frame.buffer_mut().set_style(row_rect, row_style);
visual_row += 1;
}
} else {
visual_row += rows_for_line;
}
}
}
fn apply_horizontal_scroll(line: Line, scroll_x: usize) -> Line {
if scroll_x == 0 || line.spans.is_empty() {
return line;
}
let mut spans: Vec<Span> = line.spans.into_iter().collect();
let indicator = spans.remove(0);
let mut chars_to_skip = scroll_x;
let mut new_spans = vec![indicator];
for span in spans {
let content = span.content.to_string();
let char_count = content.chars().count();
if chars_to_skip >= char_count {
chars_to_skip -= char_count;
} else if chars_to_skip > 0 {
let new_content: String = content.chars().skip(chars_to_skip).collect();
chars_to_skip = 0;
new_spans.push(Span::styled(new_content, span.style));
} else {
new_spans.push(Span::styled(content, span.style));
}
}
Line::from(new_spans)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn should_return_string_unchanged_when_within_max_len() {
let s = "hello";
let result = truncate_str(s, 10);
assert_eq!(result, "hello");
}
#[test]
fn should_truncate_ascii_string_with_ellipsis() {
let s = "hello world this is long";
let result = truncate_str(s, 10);
assert_eq!(result, "hello w...");
}
#[test]
fn should_truncate_without_panicking_on_multibyte_chars() {
let s = "Resolve \"SD : Envoi en validation manuelle après 3 rejet de la fiche employé\"";
let result = truncate_str(s, 47);
assert!(result.ends_with("..."));
assert!(result.len() <= 47);
}
#[test]
fn should_handle_string_of_only_multibyte_chars() {
let s = "ééééééééé";
let result = truncate_str(s, 5);
assert!(result.ends_with("..."));
assert!(result.is_char_boundary(result.len()));
}
#[test]
fn should_not_scroll_when_comment_box_already_visible() {
let mut scroll = 0;
scroll_comment_input_into_view(&mut scroll, Some((5, 7)), Some(6), 10, 100);
assert_eq!(scroll, 0);
}
#[test]
fn should_scroll_down_when_comment_box_below_viewport() {
let mut scroll = 0;
scroll_comment_input_into_view(&mut scroll, Some((20, 22)), Some(21), 10, 100);
assert_eq!(scroll, 13);
}
#[test]
fn should_scroll_up_when_comment_box_above_viewport() {
let mut scroll = 20;
scroll_comment_input_into_view(&mut scroll, Some((5, 7)), Some(6), 10, 100);
assert_eq!(scroll, 5);
}
#[test]
fn should_scroll_to_cursor_when_box_taller_than_viewport() {
let mut scroll = 0;
scroll_comment_input_into_view(&mut scroll, Some((30, 49)), Some(45), 10, 100);
assert_eq!(scroll, 36);
}
#[test]
fn should_not_scroll_past_end_of_content() {
let mut scroll = 200;
scroll_comment_input_into_view(&mut scroll, Some((95, 97)), Some(96), 10, 100);
assert_eq!(scroll, 90);
}
#[test]
fn should_not_scroll_when_no_comment_box() {
let mut scroll = 42;
scroll_comment_input_into_view(&mut scroll, None, None, 10, 100);
assert_eq!(scroll, 42);
}
#[test]
fn should_handle_box_partially_below_viewport() {
let mut scroll = 0;
scroll_comment_input_into_view(&mut scroll, Some((8, 10)), Some(9), 10, 100);
assert_eq!(scroll, 1);
}
#[test]
fn should_pad_highlighted_spans_to_exact_width() {
let highlighter = crate::syntax::SyntaxHighlighter::default();
let lines = vec!["let x = 1;".to_string()];
let highlighted = highlighter
.highlight_file_lines(std::path::Path::new("test.rs"), &lines)
.unwrap();
let spans = highlighted[0].as_ref().unwrap();
let width = 80;
let result = truncate_or_pad_spans(spans, width, Style::default());
let total_chars: usize = result.iter().map(|s| s.content.chars().count()).sum();
assert_eq!(
total_chars, width,
"padded spans should have exactly {width} chars, got {total_chars}"
);
}
}