use ratatui::{
Frame,
prelude::*,
widgets::{Block, Borders, Paragraph},
};
use super::state::{App, View};
use crate::app::helpers::revision::short_id;
use crate::keys::{self, BookmarkKind, DialogHintKind, HintContext};
use crate::model::{DiffContent, DiffLineKind, FileOperation};
use crate::ui::components::dialog::DialogKind;
use crate::ui::widgets::{
render_blame_status_bar, render_diff_status_bar, render_error_banner, render_help_panel,
render_placeholder, render_status_hints, status_hints_height,
};
impl App {
pub fn render(&mut self, frame: &mut Frame) {
let notification = self
.notification
.as_ref()
.filter(|n| !n.is_expired())
.cloned();
match self.current_view {
View::Log => self.render_log_view(frame, notification.as_ref()),
View::Diff => self.render_diff_view(frame, notification.as_ref()),
View::Status => self.render_status_view(frame, notification.as_ref()),
View::Operation => self.render_operation_view(frame, notification.as_ref()),
View::Blame => self.render_blame_view(frame, notification.as_ref()),
View::Resolve => self.render_resolve_view(frame, notification.as_ref()),
View::Bookmark => self.render_bookmark_view(frame, notification.as_ref()),
View::Tag => self.render_tag_view(frame, notification.as_ref()),
View::Workspace => self.render_workspace_view(frame, notification.as_ref()),
View::Evolog => self.render_evolog_view(frame, notification.as_ref()),
View::CommandHistory => self.render_command_history_view(frame, notification.as_ref()),
View::Help => self.render_help_view(frame),
}
if let Some(ref error) = self.error_message {
let status_bar_height = self.get_current_status_bar_height(frame.area().width);
render_error_banner(frame, error, status_bar_height);
}
if let Some(ref dialog) = self.active_dialog {
dialog.render(frame, frame.area());
}
}
fn get_current_status_bar_height(&self, width: u16) -> u16 {
match self.current_view {
View::Log | View::Status | View::Operation => {
let ctx = self.build_hint_context();
let hints = keys::current_hints(self.current_view, self.log_view.input_mode, &ctx);
status_hints_height(&hints, width)
}
View::Bookmark => {
let ctx = self.build_bookmark_hint_context();
let hints = keys::current_hints(View::Bookmark, self.log_view.input_mode, &ctx);
status_hints_height(&hints, width)
}
View::Tag | View::Workspace => {
let ctx = keys::HintContext::default();
let hints = keys::current_hints(self.current_view, self.log_view.input_mode, &ctx);
status_hints_height(&hints, width)
}
View::Resolve => {
let ctx = self.build_resolve_hint_context();
let hints = keys::current_hints(View::Resolve, self.log_view.input_mode, &ctx);
status_hints_height(&hints, width)
}
View::CommandHistory => {
let ctx = keys::HintContext::default();
let hints =
keys::current_hints(View::CommandHistory, self.log_view.input_mode, &ctx);
status_hints_height(&hints, width)
}
View::Evolog | View::Diff => 1,
View::Blame => status_hints_height(keys::BLAME_VIEW_HINTS, width),
View::Help => 0,
}
}
fn build_hint_context(&self) -> HintContext {
let change = self.log_view.selected_change();
HintContext {
has_bookmarks: change.is_some_and(|c| !c.bookmarks.is_empty()),
has_conflicts: change.is_some_and(|c| c.has_conflict),
is_working_copy: change.is_some_and(|c| c.is_working_copy),
skip_emptied: self.log_view.skip_emptied,
simplify_parents: self.log_view.simplify_parents,
rebase_mode: self.log_view.rebase_mode,
dialog: self.dialog_hint_kind(),
..HintContext::default()
}
}
fn build_resolve_hint_context(&self) -> HintContext {
HintContext {
is_working_copy: self
.resolve_view
.as_ref()
.is_some_and(|rv| rv.is_working_copy),
dialog: self.dialog_hint_kind(),
..HintContext::default()
}
}
fn dialog_hint_kind(&self) -> Option<DialogHintKind> {
self.active_dialog.as_ref().map(|d| match &d.kind {
DialogKind::Confirm { .. } => DialogHintKind::Confirm,
DialogKind::Select {
single_select: true,
..
} => DialogHintKind::SingleSelect,
DialogKind::Select { .. } => DialogHintKind::Select,
DialogKind::Input { .. } => DialogHintKind::Confirm,
})
}
fn render_log_view(
&mut self,
frame: &mut Frame,
notification: Option<&crate::model::Notification>,
) {
let area = frame.area();
let ctx = self.build_hint_context();
let hints = keys::current_hints(View::Log, self.log_view.input_mode, &ctx);
let sb_height = status_hints_height(&hints, area.width);
let main_area = Rect {
x: area.x,
y: area.y,
width: area.width,
height: area.height.saturating_sub(sb_height),
};
self.preview_auto_disabled = main_area.height < 20;
let preview_active = self.preview_enabled && !self.preview_auto_disabled;
if preview_active {
let chunks = Layout::vertical([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(main_area);
self.log_view.render(frame, chunks[0], notification);
self.render_preview_pane(frame, chunks[1]);
} else {
self.log_view.render(frame, main_area, notification);
}
render_status_hints(frame, &hints);
}
fn render_preview_pane(&self, frame: &mut Frame, area: Rect) {
let selected_change_id = self
.log_view
.selected_change()
.map(|c| c.change_id.as_str());
let cached = selected_change_id.and_then(|id| self.preview_cache.peek(id));
let title = match cached {
Some(entry) => {
let commit_short = short_id(entry.content.commit_id.as_str());
format!(" Preview: {} ({}) ", &entry.change_id, commit_short)
}
None => " Preview ".to_string(),
};
let block = Block::default()
.borders(Borders::ALL)
.title(Line::from(title).bold().cyan());
match cached {
Some(entry) => {
let inner = block.inner(area);
let lines = build_preview_lines(
&entry.content,
&entry.bookmarks,
inner.height as usize,
inner.width as usize,
);
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, area);
}
None => {
let paragraph = Paragraph::new(" No preview available").block(block);
frame.render_widget(paragraph, area);
}
}
}
fn render_diff_view(
&self,
frame: &mut Frame,
notification: Option<&crate::model::Notification>,
) {
if let Some(ref diff_view) = self.diff_view {
let area = frame.area();
let main_area = Rect {
x: area.x,
y: area.y,
width: area.width,
height: area.height.saturating_sub(1),
};
let diff_content_height = main_area.height.saturating_sub(5);
self.last_frame_height.set(diff_content_height);
diff_view.render(frame, main_area, notification);
render_diff_status_bar(frame, diff_view);
} else {
render_placeholder(
frame,
" Tij - Diff View ",
Color::Yellow,
"No diff loaded - Press q to go back",
);
}
}
fn render_status_view(
&self,
frame: &mut Frame,
notification: Option<&crate::model::Notification>,
) {
let area = frame.area();
let ctx = self.build_hint_context();
let hints = keys::current_hints(View::Status, self.log_view.input_mode, &ctx);
let sb_height = status_hints_height(&hints, area.width);
let main_area = Rect {
x: area.x,
y: area.y,
width: area.width,
height: area.height.saturating_sub(sb_height),
};
let file_list_height = main_area.height.saturating_sub(5);
self.last_frame_height.set(file_list_height);
self.status_view.render(frame, main_area, notification);
render_status_hints(frame, &hints);
}
fn render_operation_view(
&self,
frame: &mut Frame,
notification: Option<&crate::model::Notification>,
) {
let area = frame.area();
let ctx = self.build_hint_context();
let hints = keys::current_hints(View::Operation, self.log_view.input_mode, &ctx);
let sb_height = status_hints_height(&hints, area.width);
let main_area = Rect {
x: area.x,
y: area.y,
width: area.width,
height: area.height.saturating_sub(sb_height),
};
self.operation_view.render(frame, main_area, notification);
render_status_hints(frame, &hints);
}
fn build_bookmark_hint_context(&self) -> HintContext {
let kind = self.bookmark_view.selected_bookmark().map(|info| {
if info.bookmark.remote.is_none() {
if info.change_id.is_some() {
BookmarkKind::LocalJumpable
} else {
BookmarkKind::LocalNoChange
}
} else if info.bookmark.is_untracked_remote() {
BookmarkKind::UntrackedRemote
} else {
BookmarkKind::TrackedRemote
}
});
HintContext {
selected_bookmark_kind: kind,
dialog: self.dialog_hint_kind(),
..HintContext::default()
}
}
fn render_bookmark_view(
&self,
frame: &mut Frame,
notification: Option<&crate::model::Notification>,
) {
let area = frame.area();
let ctx = self.build_bookmark_hint_context();
let hints = keys::current_hints(View::Bookmark, self.log_view.input_mode, &ctx);
let sb_height = status_hints_height(&hints, area.width);
let main_area = Rect {
x: area.x,
y: area.y,
width: area.width,
height: area.height.saturating_sub(sb_height),
};
self.bookmark_view.render(frame, main_area, notification);
render_status_hints(frame, &hints);
}
fn render_tag_view(
&self,
frame: &mut Frame,
notification: Option<&crate::model::Notification>,
) {
let area = frame.area();
let ctx = keys::HintContext::default();
let hints = keys::current_hints(View::Tag, self.log_view.input_mode, &ctx);
let sb_height = status_hints_height(&hints, area.width);
let main_area = Rect {
x: area.x,
y: area.y,
width: area.width,
height: area.height.saturating_sub(sb_height),
};
self.tag_view.render(frame, main_area, notification);
render_status_hints(frame, &hints);
}
fn render_workspace_view(
&self,
frame: &mut Frame,
notification: Option<&crate::model::Notification>,
) {
let area = frame.area();
let ctx = keys::HintContext::default();
let hints = keys::current_hints(View::Workspace, self.log_view.input_mode, &ctx);
let sb_height = status_hints_height(&hints, area.width);
let main_area = Rect {
x: area.x,
y: area.y,
width: area.width,
height: area.height.saturating_sub(sb_height),
};
self.workspace_view.render(frame, main_area, notification);
render_status_hints(frame, &hints);
}
fn render_evolog_view(
&self,
frame: &mut Frame,
notification: Option<&crate::model::Notification>,
) {
if let Some(ref evolog_view) = self.evolog_view {
evolog_view.render(frame, frame.area(), notification);
} else {
render_placeholder(
frame,
" Tij - Evolution Log ",
Color::Cyan,
"No evolution log loaded - Press q to go back",
);
}
}
fn render_command_history_view(
&self,
frame: &mut Frame,
notification: Option<&crate::model::Notification>,
) {
let area = frame.area();
let ctx = keys::HintContext::default();
let hints = keys::current_hints(View::CommandHistory, self.log_view.input_mode, &ctx);
let sb_height = status_hints_height(&hints, area.width);
let main_area = Rect {
x: area.x,
y: area.y,
width: area.width,
height: area.height.saturating_sub(sb_height),
};
self.command_history_view
.render(frame, main_area, &self.command_history, notification);
render_status_hints(frame, &hints);
}
fn render_help_view(&self, frame: &mut Frame) {
let search_query = self.help_search_query.as_deref();
let search_input = if self.help_search_input {
Some(self.help_input_buffer.as_str())
} else {
None
};
render_help_panel(
frame,
frame.area(),
self.help_scroll,
search_query,
search_input,
);
}
fn render_resolve_view(
&self,
frame: &mut Frame,
notification: Option<&crate::model::Notification>,
) {
if let Some(ref resolve_view) = self.resolve_view {
let area = frame.area();
let ctx = self.build_resolve_hint_context();
let hints = keys::current_hints(View::Resolve, self.log_view.input_mode, &ctx);
let sb_height = status_hints_height(&hints, area.width);
let main_area = Rect {
x: area.x,
y: area.y,
width: area.width,
height: area.height.saturating_sub(sb_height),
};
resolve_view.render(frame, main_area, notification);
render_status_hints(frame, &hints);
} else {
render_placeholder(
frame,
" Tij - Resolve View ",
Color::Red,
"No conflicts loaded - Press q to go back",
);
}
}
fn render_blame_view(
&self,
frame: &mut Frame,
notification: Option<&crate::model::Notification>,
) {
if let Some(ref blame_view) = self.blame_view {
let area = frame.area();
let sb_height = status_hints_height(keys::BLAME_VIEW_HINTS, area.width);
let main_area = Rect {
x: area.x,
y: area.y,
width: area.width,
height: area.height.saturating_sub(sb_height),
};
let blame_content_height = main_area.height.saturating_sub(2);
self.last_frame_height.set(blame_content_height);
blame_view.render(frame, main_area, notification);
render_blame_status_bar(frame, blame_view);
} else {
render_placeholder(
frame,
" Tij - Blame View ",
Color::Yellow,
"No file loaded - Press q to go back",
);
}
}
}
struct FileSummaryEntry {
path: String,
op: FileOperation,
insertions: usize,
deletions: usize,
}
fn extract_file_summaries(lines: &[crate::model::DiffLine]) -> Vec<FileSummaryEntry> {
let mut summaries = Vec::new();
let mut current_path: Option<String> = None;
let mut current_file_op: Option<FileOperation> = None;
let mut insertions = 0usize;
let mut deletions = 0usize;
for line in lines {
match line.kind {
DiffLineKind::FileHeader => {
if let Some(path) = current_path.take() {
let op =
current_file_op.unwrap_or_else(|| infer_file_op(insertions, deletions));
summaries.push(FileSummaryEntry {
path,
op,
insertions,
deletions,
});
}
current_path = Some(line.content.clone());
current_file_op = line.file_op;
insertions = 0;
deletions = 0;
}
DiffLineKind::Added => insertions += 1,
DiffLineKind::Deleted => deletions += 1,
_ => {}
}
}
if let Some(path) = current_path {
let op = current_file_op.unwrap_or_else(|| infer_file_op(insertions, deletions));
summaries.push(FileSummaryEntry {
path,
op,
insertions,
deletions,
});
}
summaries
}
fn infer_file_op(insertions: usize, deletions: usize) -> FileOperation {
if deletions == 0 && insertions > 0 {
FileOperation::Added
} else if insertions == 0 && deletions > 0 {
FileOperation::Deleted
} else {
FileOperation::Modified
}
}
fn build_preview_lines(
content: &DiffContent,
bookmarks: &[String],
max_lines: usize,
max_width: usize,
) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
if !content.author.is_empty() {
lines.push(Line::from(vec![
Span::styled("Author: ", Style::default().fg(Color::DarkGray)),
Span::raw(format!("{} {}", content.author, content.timestamp)),
]));
}
if !bookmarks.is_empty() {
lines.push(Line::from(vec![
Span::styled("Bookmarks: ", Style::default().fg(Color::DarkGray)),
Span::styled(bookmarks.join(", "), Style::default().fg(Color::Magenta)),
]));
}
if !content.description.is_empty() {
lines.push(Line::from(Span::styled(
content.description.clone(),
Style::default().bold(),
)));
}
let summaries = extract_file_summaries(&content.lines);
let total_files = summaries.len();
let total_insertions: usize = summaries.iter().map(|s| s.insertions).sum();
let total_deletions: usize = summaries.iter().map(|s| s.deletions).sum();
if total_files > 0 {
let stats_text = format!(
"{} file{} changed, +{}, -{}",
total_files,
if total_files == 1 { "" } else { "s" },
total_insertions,
total_deletions,
);
lines.push(Line::from(Span::styled(
stats_text,
Style::default().fg(Color::DarkGray),
)));
}
if !lines.is_empty() {
lines.push(Line::default());
}
if summaries.is_empty() && content.description.is_empty() && content.author.is_empty() {
return lines;
}
if summaries.is_empty() {
lines.push(Line::from(Span::styled(
"(no changes)",
Style::default().fg(Color::DarkGray),
)));
} else {
let mut remaining = max_lines.saturating_sub(lines.len());
if remaining == 0 && !lines.is_empty() {
lines.pop(); remaining = 1;
}
let need_overflow = summaries.len() > remaining && remaining > 0;
let display_count = if need_overflow {
remaining.saturating_sub(1) } else {
summaries.len().min(remaining)
};
for entry in summaries.iter().take(display_count) {
lines.push(format_file_summary_line(entry, max_width));
}
if need_overflow {
let more = summaries.len() - display_count;
lines.push(Line::from(Span::styled(
format!(
"… and {} more file{}",
more,
if more == 1 { "" } else { "s" }
),
Style::default().fg(Color::DarkGray),
)));
}
}
lines.truncate(max_lines);
lines
}
fn format_file_summary_line(entry: &FileSummaryEntry, max_width: usize) -> Line<'static> {
let (op_color, op_char) = match entry.op {
FileOperation::Added => (Color::Green, 'A'),
FileOperation::Deleted => (Color::Red, 'D'),
FileOperation::Modified => (Color::Yellow, 'M'),
};
let stats = match (entry.insertions, entry.deletions) {
(0, 0) => String::new(),
(ins, 0) => format!("+{}", ins),
(0, del) => format!("-{}", del),
(ins, del) => format!("+{} -{}", ins, del),
};
let op_prefix = format!(" {} ", op_char);
let op_width = 3;
let stats_width = if !stats.is_empty() && max_width >= 20 {
stats.chars().count() + 1 } else {
0
};
let path_budget = max_width
.saturating_sub(op_width)
.saturating_sub(stats_width);
let display_path = truncate_path(&entry.path, path_budget);
let display_path_width = display_path.chars().count();
let mut spans = vec![
Span::styled(op_prefix, Style::default().fg(op_color)),
Span::styled(display_path, Style::default().fg(op_color)),
];
if stats_width > 0 {
let used = op_width + display_path_width + stats_width;
let pad = max_width.saturating_sub(used);
let padded_stats = format!("{:>width$}", stats, width = pad + stats.chars().count());
spans.push(Span::styled(
padded_stats,
Style::default().fg(Color::DarkGray),
));
}
Line::from(spans)
}
fn truncate_path(path: &str, budget: usize) -> String {
if budget == 0 {
return String::new();
}
let char_count = path.chars().count();
if char_count <= budget {
return path.to_string();
}
if budget <= 2 {
return "..".chars().take(budget).collect();
}
let keep = budget - 2;
let truncated: String = path.chars().take(keep).collect();
format!("{}..", truncated)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{DiffContent, DiffLine};
const TEST_WIDTH: usize = 40;
#[test]
fn test_build_preview_lines_empty_content() {
let content = DiffContent::default();
let lines = build_preview_lines(&content, &[], 10, TEST_WIDTH);
assert!(lines.is_empty());
}
#[test]
fn test_build_preview_lines_header_only() {
let content = DiffContent {
author: "alice@example.com".to_string(),
timestamp: "2025-01-15 10:30".to_string(),
description: "Fix login bug".to_string(),
..DiffContent::default()
};
let lines = build_preview_lines(&content, &[], 10, TEST_WIDTH);
assert_eq!(lines.len(), 4);
}
#[test]
fn test_build_preview_lines_with_bookmarks() {
let content = DiffContent {
author: "alice@example.com".to_string(),
timestamp: "2025-01-15 10:30".to_string(),
description: "Fix login bug".to_string(),
..DiffContent::default()
};
let bookmarks = vec!["main".to_string(), "feature/login".to_string()];
let lines = build_preview_lines(&content, &bookmarks, 10, TEST_WIDTH);
assert_eq!(lines.len(), 5);
}
#[test]
fn test_build_preview_lines_file_summary() {
let content = DiffContent {
author: "alice@example.com".to_string(),
timestamp: "2025-01-15".to_string(),
description: "Add feature".to_string(),
lines: vec![
DiffLine::file_header("src/main.rs"),
DiffLine {
kind: DiffLineKind::Added,
line_numbers: Some((None, Some(1))),
content: "fn main() {}".to_string(),
file_op: None,
},
],
..DiffContent::default()
};
let lines = build_preview_lines(&content, &[], 20, TEST_WIDTH);
assert_eq!(lines.len(), 5);
}
#[test]
fn test_build_preview_lines_overflow() {
let mut diff_lines = Vec::new();
for i in 0..10 {
if i > 0 {
diff_lines.push(DiffLine::separator());
}
diff_lines.push(DiffLine::file_header(format!("file{}.rs", i)));
diff_lines.push(DiffLine {
kind: DiffLineKind::Added,
line_numbers: Some((None, Some(1))),
content: "content".to_string(),
file_op: None,
});
}
let content = DiffContent {
author: "alice".to_string(),
timestamp: "2025-01-15".to_string(),
description: "Many files".to_string(),
lines: diff_lines,
..DiffContent::default()
};
let lines = build_preview_lines(&content, &[], 8, TEST_WIDTH);
assert_eq!(lines.len(), 8);
let last_line_text: String = lines
.last()
.unwrap()
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert!(
last_line_text.contains("7 more file"),
"Expected overflow indicator, got: {}",
last_line_text
);
}
#[test]
fn test_build_preview_lines_zero_remaining_sacrifices_blank() {
let content = DiffContent {
author: "alice".to_string(),
timestamp: "2025-01-15".to_string(),
description: "Tight".to_string(),
lines: vec![
DiffLine::file_header("src/main.rs"),
DiffLine {
kind: DiffLineKind::Added,
line_numbers: Some((None, Some(1))),
content: "new".to_string(),
file_op: None,
},
],
..DiffContent::default()
};
let lines = build_preview_lines(&content, &[], 4, TEST_WIDTH);
assert_eq!(lines.len(), 4);
let last_line_text: String = lines
.last()
.unwrap()
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert!(
last_line_text.contains("src/main.rs"),
"Expected file summary, got: {}",
last_line_text
);
}
#[test]
fn test_build_preview_lines_zero_remaining_overflow() {
let content = DiffContent {
author: "alice".to_string(),
timestamp: "2025-01-15".to_string(),
description: "Tight".to_string(),
lines: vec![
DiffLine::file_header("src/a.rs"),
DiffLine {
kind: DiffLineKind::Added,
line_numbers: Some((None, Some(1))),
content: "new".to_string(),
file_op: None,
},
DiffLine::separator(),
DiffLine::file_header("src/b.rs"),
DiffLine {
kind: DiffLineKind::Added,
line_numbers: Some((None, Some(1))),
content: "new".to_string(),
file_op: None,
},
],
..DiffContent::default()
};
let lines = build_preview_lines(&content, &[], 4, TEST_WIDTH);
assert_eq!(lines.len(), 4);
let last_line_text: String = lines
.last()
.unwrap()
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert!(
last_line_text.contains("2 more file"),
"Expected overflow indicator, got: {}",
last_line_text
);
}
#[test]
fn test_build_preview_lines_no_changes() {
let content = DiffContent {
author: "alice".to_string(),
timestamp: "2025-01-15".to_string(),
description: "Empty commit".to_string(),
..DiffContent::default()
};
let lines = build_preview_lines(&content, &[], 10, TEST_WIDTH);
assert_eq!(lines.len(), 4);
let last_line_text: String = lines
.last()
.unwrap()
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert!(
last_line_text.contains("no changes"),
"Expected '(no changes)', got: {}",
last_line_text
);
}
#[test]
fn test_build_preview_lines_truncated() {
let mut diff_lines = Vec::new();
for i in 0..20 {
if i > 0 {
diff_lines.push(DiffLine::separator());
}
diff_lines.push(DiffLine::file_header(format!("file{}.rs", i)));
diff_lines.push(DiffLine {
kind: DiffLineKind::Added,
line_numbers: Some((None, Some(1))),
content: "line".to_string(),
file_op: None,
});
}
let content = DiffContent {
author: "alice@example.com".to_string(),
timestamp: "2025-01-15".to_string(),
description: "Long diff".to_string(),
lines: diff_lines,
..DiffContent::default()
};
let lines = build_preview_lines(&content, &[], 5, TEST_WIDTH);
assert_eq!(lines.len(), 5);
}
#[test]
fn test_extract_file_summaries_basic() {
let lines = vec![
DiffLine::file_header("src/main.rs"),
DiffLine {
kind: DiffLineKind::Added,
line_numbers: Some((None, Some(1))),
content: "new".to_string(),
file_op: None,
},
DiffLine {
kind: DiffLineKind::Deleted,
line_numbers: Some((Some(1), None)),
content: "old".to_string(),
file_op: None,
},
DiffLine::separator(),
DiffLine::file_header("src/new.rs"),
DiffLine {
kind: DiffLineKind::Added,
line_numbers: Some((None, Some(1))),
content: "fn new()".to_string(),
file_op: None,
},
DiffLine::separator(),
DiffLine::file_header("src/old.rs"),
DiffLine {
kind: DiffLineKind::Deleted,
line_numbers: Some((Some(1), None)),
content: "fn old()".to_string(),
file_op: None,
},
];
let summaries = extract_file_summaries(&lines);
assert_eq!(summaries.len(), 3);
assert_eq!(summaries[0].path, "src/main.rs");
assert_eq!(summaries[0].op, FileOperation::Modified);
assert_eq!(summaries[0].insertions, 1);
assert_eq!(summaries[0].deletions, 1);
assert_eq!(summaries[1].path, "src/new.rs");
assert_eq!(summaries[1].op, FileOperation::Added);
assert_eq!(summaries[1].insertions, 1);
assert_eq!(summaries[1].deletions, 0);
assert_eq!(summaries[2].path, "src/old.rs");
assert_eq!(summaries[2].op, FileOperation::Deleted);
assert_eq!(summaries[2].insertions, 0);
assert_eq!(summaries[2].deletions, 1);
}
#[test]
fn test_extract_file_summaries_empty() {
let summaries = extract_file_summaries(&[]);
assert!(summaries.is_empty());
}
#[test]
fn test_truncate_path_fits() {
assert_eq!(truncate_path("src/main.rs", 20), "src/main.rs");
}
#[test]
fn test_truncate_path_truncated() {
assert_eq!(
truncate_path("src/very/long/path/to/file.rs", 15),
"src/very/long.."
);
}
#[test]
fn test_truncate_path_budget_zero() {
assert_eq!(truncate_path("src/main.rs", 0), "");
}
#[test]
fn test_truncate_path_budget_two() {
assert_eq!(truncate_path("src/main.rs", 2), "..");
}
#[test]
fn test_infer_file_op() {
assert_eq!(infer_file_op(5, 0), FileOperation::Added);
assert_eq!(infer_file_op(0, 3), FileOperation::Deleted);
assert_eq!(infer_file_op(3, 2), FileOperation::Modified);
assert_eq!(infer_file_op(0, 0), FileOperation::Modified); }
#[test]
fn test_extract_file_summaries_totals() {
let lines = vec![
DiffLine::file_header("src/main.rs"),
DiffLine {
kind: DiffLineKind::Added,
line_numbers: Some((None, Some(1))),
content: "new line".to_string(),
file_op: None,
},
DiffLine {
kind: DiffLineKind::Added,
line_numbers: Some((None, Some(2))),
content: "another new".to_string(),
file_op: None,
},
DiffLine {
kind: DiffLineKind::Deleted,
line_numbers: Some((Some(1), None)),
content: "old line".to_string(),
file_op: None,
},
DiffLine::separator(),
DiffLine::file_header("src/lib.rs"),
DiffLine {
kind: DiffLineKind::Added,
line_numbers: Some((None, Some(1))),
content: "pub fn hello()".to_string(),
file_op: None,
},
];
let summaries = extract_file_summaries(&lines);
assert_eq!(summaries.len(), 2);
let total_ins: usize = summaries.iter().map(|s| s.insertions).sum();
let total_del: usize = summaries.iter().map(|s| s.deletions).sum();
assert_eq!(total_ins, 3);
assert_eq!(total_del, 1);
}
#[test]
fn test_preview_cache_validated_on_refresh_log() {
use crate::app::state::{PreviewCache, PreviewCacheEntry};
use crate::model::Change;
let mut cache = PreviewCache::new();
cache.insert(PreviewCacheEntry {
change_id: "abc12345".to_string(),
commit_id: "commit_aaa".to_string(),
content: DiffContent {
author: "alice@example.com".to_string(),
description: "Old description".to_string(),
..DiffContent::default()
},
bookmarks: vec!["main".to_string()],
});
let changes = vec![Change {
change_id: crate::model::ChangeId::new("abc12345".to_string()),
commit_id: crate::model::CommitId::new("commit_aaa".to_string()),
bookmarks: vec!["main".to_string(), "dev".to_string()],
..Change::default()
}];
cache.validate(&changes);
assert_eq!(cache.len(), 1);
let entry = cache.peek("abc12345").unwrap();
assert_eq!(entry.bookmarks, vec!["main".to_string(), "dev".to_string()]);
let changes_stale = vec![Change {
change_id: crate::model::ChangeId::new("abc12345".to_string()),
commit_id: crate::model::CommitId::new("commit_bbb".to_string()),
..Change::default()
}];
cache.validate(&changes_stale);
assert_eq!(cache.len(), 0);
}
#[test]
fn test_extract_file_summaries_modified_with_only_adds() {
let lines = vec![
DiffLine::file_header_with_op("src/main.rs", FileOperation::Modified),
DiffLine {
kind: DiffLineKind::Added,
line_numbers: Some((None, Some(5))),
content: "new line".to_string(),
file_op: None,
},
];
let summaries = extract_file_summaries(&lines);
assert_eq!(summaries.len(), 1);
assert_eq!(summaries[0].op, FileOperation::Modified);
}
#[test]
fn test_extract_file_summaries_modified_with_only_deletes() {
let lines = vec![
DiffLine::file_header_with_op("src/main.rs", FileOperation::Modified),
DiffLine {
kind: DiffLineKind::Deleted,
line_numbers: Some((Some(5), None)),
content: "old line".to_string(),
file_op: None,
},
];
let summaries = extract_file_summaries(&lines);
assert_eq!(summaries.len(), 1);
assert_eq!(summaries[0].op, FileOperation::Modified);
}
#[test]
fn test_extract_file_summaries_added_file() {
let lines = vec![
DiffLine::file_header_with_op("src/new.rs", FileOperation::Added),
DiffLine {
kind: DiffLineKind::Added,
line_numbers: Some((None, Some(1))),
content: "fn new() {}".to_string(),
file_op: None,
},
];
let summaries = extract_file_summaries(&lines);
assert_eq!(summaries.len(), 1);
assert_eq!(summaries[0].op, FileOperation::Added);
}
#[test]
fn test_extract_file_summaries_deleted_file() {
let lines = vec![
DiffLine::file_header_with_op("src/old.rs", FileOperation::Deleted),
DiffLine {
kind: DiffLineKind::Deleted,
line_numbers: Some((Some(1), None)),
content: "fn old() {}".to_string(),
file_op: None,
},
];
let summaries = extract_file_summaries(&lines);
assert_eq!(summaries.len(), 1);
assert_eq!(summaries[0].op, FileOperation::Deleted);
}
#[test]
fn test_extract_file_summaries_fallback_without_file_op() {
let lines = vec![
DiffLine::file_header("src/main.rs"), DiffLine {
kind: DiffLineKind::Added,
line_numbers: None,
content: "added".to_string(),
file_op: None,
},
DiffLine {
kind: DiffLineKind::Deleted,
line_numbers: None,
content: "deleted".to_string(),
file_op: None,
},
];
let summaries = extract_file_summaries(&lines);
assert_eq!(summaries.len(), 1);
assert_eq!(summaries[0].op, FileOperation::Modified);
}
#[test]
fn test_extract_file_summaries_rename_is_modified() {
let lines = vec![
DiffLine::file_header_with_op("src/renamed.rs", FileOperation::Modified),
DiffLine {
kind: DiffLineKind::Added,
line_numbers: Some((None, Some(1))),
content: "content".to_string(),
file_op: None,
},
];
let summaries = extract_file_summaries(&lines);
assert_eq!(summaries.len(), 1);
assert_eq!(summaries[0].op, FileOperation::Modified);
}
#[test]
fn test_parse_show_to_file_summaries_preserves_file_op() {
use crate::jj::parser::Parser;
let output = "\
Commit ID: abc123
Change ID: xyz789
Author : Test <test@example.com> (2024-01-30 12:00:00)
Committer: Test <test@example.com> (2024-01-30 12:00:00)
Append only
Modified regular file src/main.rs:
10 10: fn main() {
11: + println!(\"new line\");
11 12: }
";
let content = Parser::parse_show(output).unwrap();
let summaries = extract_file_summaries(&content.lines);
assert_eq!(summaries.len(), 1);
assert_eq!(summaries[0].path, "src/main.rs");
assert_eq!(summaries[0].op, FileOperation::Modified);
assert_eq!(summaries[0].insertions, 1);
assert_eq!(summaries[0].deletions, 0);
}
#[test]
fn test_extract_file_summaries_mixed_operations() {
let lines = vec![
DiffLine::file_header_with_op("src/brand_new.rs", FileOperation::Added),
DiffLine {
kind: DiffLineKind::Added,
line_numbers: Some((None, Some(1))),
content: "pub fn new() {}".to_string(),
file_op: None,
},
DiffLine::separator(),
DiffLine::file_header_with_op("src/main.rs", FileOperation::Modified),
DiffLine {
kind: DiffLineKind::Added,
line_numbers: Some((None, Some(5))),
content: "appended line".to_string(),
file_op: None,
},
DiffLine::separator(),
DiffLine::file_header_with_op("src/lib.rs", FileOperation::Modified),
DiffLine {
kind: DiffLineKind::Deleted,
line_numbers: Some((Some(3), None)),
content: "removed line".to_string(),
file_op: None,
},
DiffLine::separator(),
DiffLine::file_header_with_op("src/old.rs", FileOperation::Deleted),
DiffLine {
kind: DiffLineKind::Deleted,
line_numbers: Some((Some(1), None)),
content: "fn old() {}".to_string(),
file_op: None,
},
];
let summaries = extract_file_summaries(&lines);
assert_eq!(summaries.len(), 4);
assert_eq!(summaries[0].path, "src/brand_new.rs");
assert_eq!(summaries[0].op, FileOperation::Added);
assert_eq!(summaries[1].path, "src/main.rs");
assert_eq!(summaries[1].op, FileOperation::Modified);
assert_eq!(summaries[2].path, "src/lib.rs");
assert_eq!(summaries[2].op, FileOperation::Modified);
assert_eq!(summaries[3].path, "src/old.rs");
assert_eq!(summaries[3].op, FileOperation::Deleted);
}
#[test]
fn test_parse_diff_body_to_file_summaries_preserves_file_op() {
use crate::jj::parser::Parser;
let output = "\
Modified regular file src/main.rs:
10 10: fn main() {
11: + println!(\"appended\");
11 12: }
Added regular file src/new.rs:
1: pub fn new() {}
Removed regular file src/old.rs:
1 : fn old() {}
";
let content = Parser::parse_diff_body(output);
let summaries = extract_file_summaries(&content.lines);
assert_eq!(summaries.len(), 3);
assert_eq!(summaries[0].path, "src/main.rs");
assert_eq!(summaries[0].op, FileOperation::Modified);
assert_eq!(summaries[0].insertions, 1);
assert_eq!(summaries[0].deletions, 0);
assert_eq!(summaries[1].path, "src/new.rs");
assert_eq!(summaries[1].op, FileOperation::Added);
assert_eq!(summaries[2].path, "src/old.rs");
assert_eq!(summaries[2].op, FileOperation::Deleted);
}
#[test]
fn test_git_format_falls_back_to_infer() {
use crate::jj::parser::Parser;
let output = "\
diff --git a/src/main.rs b/src/main.rs
@@ -1,3 +1,4 @@
fn main() {
+ println!(\"new\");
- println!(\"old\");
}";
let content = Parser::parse_diff_body_git(output);
let summaries = extract_file_summaries(&content.lines);
assert_eq!(summaries.len(), 1);
assert_eq!(summaries[0].path, "src/main.rs");
assert_eq!(summaries[0].op, FileOperation::Modified);
}
}