use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
style::{Color, Modifier, Style, Stylize},
text::{Line, Span},
widgets::Paragraph,
};
use crate::jj::constants;
use crate::model::{Change, Notification};
use crate::ui::{components, symbols, theme};
use super::{InputMode, LogView, RebaseMode, RebaseSource, empty_text};
impl LogView {
pub fn render(&mut self, frame: &mut Frame, area: Rect, notification: Option<&Notification>) {
let (log_area, input_area) = match self.input_mode {
InputMode::Normal
| InputMode::RebaseModeSelect
| InputMode::RebaseSelect
| InputMode::SquashSelect
| InputMode::CompareSelect
| InputMode::InterdiffSelect
| InputMode::BisectSelect
| InputMode::ParallelizeSelect => (area, None),
InputMode::SearchInput
| InputMode::RevsetInput
| InputMode::DescribeInput
| InputMode::BookmarkInput
| InputMode::RebaseRevsetInput => {
let chunks =
Layout::vertical([Constraint::Min(1), Constraint::Length(3)]).split(area);
(chunks[0], Some(chunks[1]))
}
};
self.render_log_list(frame, log_area, notification);
if let Some(input_area) = input_area {
self.render_input_bar(frame, input_area);
}
}
fn render_log_list(&self, frame: &mut Frame, area: Rect, notification: Option<&Notification>) {
let title = self.build_title();
let title_width = title.width();
let available_for_notif = area.width.saturating_sub(title_width as u16 + 4) as usize; let notif_line = notification
.filter(|n| !n.is_expired())
.map(|n| components::build_notification_title(n, Some(available_for_notif)))
.filter(|line| !line.spans.is_empty());
let block = components::bordered_block_with_notification(title, notif_line);
if self.changes.is_empty() {
self.render_empty_state(frame, area, block);
return;
}
let inner_height = area.height.saturating_sub(2) as usize; if inner_height == 0 {
return;
}
let scroll_offset = self.calculate_scroll_offset(inner_height);
let mut lines: Vec<Line> = Vec::new();
for (idx, change) in self.changes.iter().enumerate().skip(scroll_offset) {
if lines.len() >= inner_height {
break;
}
let is_selected = idx == self.selected_index && !change.is_graph_only;
let line = self.build_change_line(change, is_selected);
lines.push(line);
}
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, area);
}
fn build_title(&self) -> Line<'static> {
if self.input_mode == InputMode::RebaseModeSelect {
return Line::from(" Tij - Log View [Rebase: Select mode (r/s/b/A/B)] ")
.bold()
.yellow()
.centered();
}
if self.input_mode == InputMode::RebaseRevsetInput {
let mode_label = match self.rebase_mode {
RebaseMode::Revision => "-r",
RebaseMode::Source => "-s",
RebaseMode::Branch => "-b",
_ => "",
};
return Line::from(format!(
" Tij - Log View [Rebase {}: Enter revset] ",
mode_label
))
.bold()
.yellow()
.centered();
}
if self.input_mode == InputMode::RebaseSelect {
if matches!(self.rebase_source, Some(RebaseSource::Revset(_))) {
let revset_src = match &self.rebase_source {
Some(RebaseSource::Revset(s)) => s.as_str(),
_ => "?",
};
let mode_label = match self.rebase_mode {
RebaseMode::Revision => "-r",
RebaseMode::Source => "-s",
RebaseMode::Branch => "-b",
_ => "",
};
return Line::from(format!(
" Tij - Log View [Rebase {} \"{}\": Select destination] ",
mode_label, revset_src
))
.bold()
.yellow()
.centered();
}
let title = match self.rebase_mode {
RebaseMode::Revision => " Tij - Log View [Rebase: Select destination] ".to_string(),
RebaseMode::Source => {
" Tij - Log View [Rebase -s: Select destination (with descendants)] "
.to_string()
}
RebaseMode::Branch => {
" Tij - Log View [Rebase -b: Select destination (branch)] ".to_string()
}
RebaseMode::InsertAfter => {
" Tij - Log View [Rebase: Select insert-after target] ".to_string()
}
RebaseMode::InsertBefore => {
" Tij - Log View [Rebase: Select insert-before target] ".to_string()
}
};
return Line::from(title).bold().yellow().centered();
}
if self.input_mode == InputMode::SquashSelect {
return Line::from(" Tij - Log View [Squash: Select destination] ")
.bold()
.yellow()
.centered();
}
if self.input_mode == InputMode::CompareSelect {
let from_id = self
.compare_from
.as_ref()
.map(|(cid, _)| cid.as_str())
.unwrap_or("?");
return Line::from(format!(
" Tij - Log View [Compare: From={}, Select To] ",
from_id
))
.bold()
.yellow()
.centered();
}
if self.input_mode == InputMode::InterdiffSelect {
let from_id = self
.interdiff_from
.as_ref()
.map(|(cid, _)| cid.as_str())
.unwrap_or("?");
return Line::from(format!(
" Tij - Log View [Interdiff: From={}, Select To] ",
from_id
))
.bold()
.yellow()
.centered();
}
if self.input_mode == InputMode::BisectSelect {
let bad_id = self
.bisect_bad
.as_ref()
.map(|(_, short)| short.as_str())
.unwrap_or("?");
return Line::from(format!(
" Tij - Log View [Bisect: Bad={}, Select Good] ",
bad_id
))
.bold()
.yellow()
.centered();
}
if self.input_mode == InputMode::ParallelizeSelect {
let from_id = self
.parallelize_from
.as_ref()
.map(|(cid, _)| cid.as_str())
.unwrap_or("?");
return Line::from(format!(
" Tij - Log View [Parallelize: From={}, Select end] ",
from_id
))
.bold()
.yellow()
.centered();
}
let count_suffix = if self.current_revset.is_some() {
let count = self.changes.iter().filter(|c| !c.is_graph_only).count();
if self.truncated {
format!(" ({}+)", count)
} else {
format!(" ({})", count)
}
} else if self.truncated {
let count = self.changes.iter().filter(|c| !c.is_graph_only).count();
format!(" ({}+)", count)
} else {
String::new()
};
let title_text = match (&self.current_revset, &self.last_search_query) {
(Some(revset), Some(query)) => {
format!(
" Tij - Log View [{}{}] [Search: {}] ",
revset, count_suffix, query
)
}
(Some(revset), None) => {
format!(" Tij - Log View [{}{}] ", revset, count_suffix)
}
(None, Some(query)) => {
format!(" Tij - Log View{} [Search: {}] ", count_suffix, query)
}
(None, None) => {
if count_suffix.is_empty() {
" Tij - Log View ".to_string()
} else {
format!(" Tij - Log View{} ", count_suffix)
}
}
};
Line::from(title_text).bold().cyan().centered()
}
fn render_empty_state(
&self,
frame: &mut Frame,
area: Rect,
block: ratatui::widgets::Block<'static>,
) {
let paragraph =
components::empty_state(empty_text::TITLE, Some(empty_text::HINT)).block(block);
frame.render_widget(paragraph, area);
}
fn calculate_scroll_offset(&self, visible_changes: usize) -> usize {
if visible_changes == 0 {
return 0;
}
let mut offset = self.scroll_offset;
if self.selected_index < offset {
offset = self.selected_index;
} else if self.selected_index >= offset + visible_changes {
offset = self.selected_index - visible_changes + 1;
}
offset
}
fn build_change_line(&self, change: &Change, is_selected: bool) -> Line<'static> {
let mut spans = Vec::new();
if !change.graph_prefix.is_empty() {
spans.push(Span::styled(
change.graph_prefix.clone(),
Style::default().fg(theme::log_view::GRAPH_LINE),
));
}
if change.is_graph_only {
return Line::from(spans);
}
spans.push(Span::styled(
format!("{} ", change.short_id()),
Style::default().fg(theme::log_view::CHANGE_ID),
));
if change.change_id != constants::ROOT_CHANGE_ID {
spans.push(Span::raw(format!("{} ", change.author)));
spans.push(Span::styled(
format!("{} ", change.timestamp),
Style::default().fg(theme::log_view::TIMESTAMP),
));
}
if !change.bookmarks.is_empty() {
spans.push(Span::styled(
format!("{} ", change.bookmarks.join(", ")),
Style::default().fg(theme::log_view::BOOKMARK),
));
}
let show_ws_markers = !(change.working_copy_names.is_empty()
|| change.is_working_copy && change.working_copy_names.len() == 1);
if show_ws_markers {
let marker = change
.working_copy_names
.iter()
.map(|name| format!("{}@", name))
.collect::<Vec<_>>()
.join(" ");
spans.push(Span::styled(
format!("{} ", marker),
Style::default().fg(Color::Magenta),
));
}
if change.has_conflict {
spans.push(Span::styled(
"[CONFLICT] ",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
));
}
let description = change.display_description();
if change.is_empty && description == symbols::empty::NO_DESCRIPTION {
spans.push(Span::styled(
format!("{} ", symbols::empty::CHANGE_LABEL),
Style::default().fg(theme::log_view::EMPTY_LABEL),
));
}
spans.push(Span::raw(description.to_string()));
let mut line = Line::from(spans);
let is_rebase_source = matches!(
self.input_mode,
InputMode::RebaseModeSelect | InputMode::RebaseSelect | InputMode::RebaseRevsetInput
) && matches!(
&self.rebase_source,
Some(RebaseSource::Selected { change_id, .. }) if *change_id == change.change_id
);
let is_squash_source = self.input_mode == InputMode::SquashSelect
&& self
.squash_source
.as_ref()
.is_some_and(|(cid, _)| *cid == change.change_id);
let is_compare_from = self.input_mode == InputMode::CompareSelect
&& self
.compare_from
.as_ref()
.is_some_and(|(cid, _)| *cid == change.change_id);
let is_interdiff_from = self.input_mode == InputMode::InterdiffSelect
&& self
.interdiff_from
.as_ref()
.is_some_and(|(cid, _)| *cid == change.change_id);
let is_bisect_bad = self.input_mode == InputMode::BisectSelect
&& self
.bisect_bad
.as_ref()
.is_some_and(|(cid, _)| *cid == change.change_id);
let is_parallelize_from = self.input_mode == InputMode::ParallelizeSelect
&& self
.parallelize_from
.as_ref()
.is_some_and(|(cid, _)| *cid == change.change_id);
if is_rebase_source
|| is_squash_source
|| is_compare_from
|| is_interdiff_from
|| is_bisect_bad
|| is_parallelize_from
{
line = line.style(
Style::default()
.bg(Color::DarkGray)
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
} else if is_selected {
line = line.style(
Style::default()
.fg(theme::selection::FG)
.bg(theme::selection::BG)
.add_modifier(Modifier::BOLD),
);
}
line
}
fn render_input_bar(&self, frame: &mut Frame, area: Rect) {
let Some((prompt, title)) = self.input_mode.input_bar_meta() else {
return;
};
let input_text = format!("{}{}", prompt, self.input_buffer);
let available_width = area.width.saturating_sub(2) as usize;
if available_width == 0 {
return;
}
let char_count = input_text.chars().count();
let display_text = if char_count > available_width {
let skip = char_count.saturating_sub(available_width.saturating_sub(1)); format!("…{}", input_text.chars().skip(skip).collect::<String>())
} else {
input_text.clone()
};
let paragraph =
Paragraph::new(display_text).block(components::bordered_block(Line::from(title)));
frame.render_widget(paragraph, area);
let cursor_pos = char_count.min(available_width);
frame.set_cursor_position((area.x + cursor_pos as u16 + 1, area.y + 1));
}
}
#[cfg(test)]
mod tests {
use super::LogView;
use crate::jj::constants;
use crate::model::{Change, ChangeId, CommitId};
fn create_selectable_changes(count: usize) -> Vec<Change> {
(0..count)
.map(|i| Change {
change_id: ChangeId::new(format!("chg{i:05}")),
commit_id: CommitId::new(format!("commit{i:05}")),
author: "user@example.com".to_string(),
timestamp: "2024-01-29".to_string(),
description: format!("Commit {i}"),
is_working_copy: i == 0,
is_empty: false,
bookmarks: vec![],
graph_prefix: if i == 0 {
"@ ".to_string()
} else {
"○ ".to_string()
},
is_graph_only: false,
has_conflict: false,
working_copy_names: Vec::new(),
})
.collect()
}
fn title_text(view: &LogView) -> String {
let mut text = String::new();
for span in view.build_title().spans {
text.push_str(span.content.as_ref());
}
text
}
#[test]
fn test_build_title_includes_revset_count() {
let mut view = LogView::new();
view.current_revset = Some("ancestors(@, 5)".to_string());
view.set_changes(create_selectable_changes(5));
assert_eq!(title_text(&view), " Tij - Log View [ancestors(@, 5) (5)] ");
}
#[test]
fn test_build_title_includes_truncated_indicator_for_revset() {
let mut view = LogView::new();
view.current_revset = Some("all()".to_string());
let limit = constants::DEFAULT_LOG_LIMIT.parse::<usize>().unwrap();
view.set_changes(create_selectable_changes(limit));
view.truncated = true;
assert_eq!(
title_text(&view),
format!(" Tij - Log View [all() ({}+)] ", limit)
);
}
#[test]
fn test_build_title_includes_truncated_indicator_without_revset() {
let mut view = LogView::new();
let limit = constants::DEFAULT_LOG_LIMIT.parse::<usize>().unwrap();
view.set_changes(create_selectable_changes(limit));
view.truncated = true;
assert_eq!(title_text(&view), format!(" Tij - Log View ({}+) ", limit));
}
}