use super::Browser;
use crate::components::list_view::{
Boundary, FilterOutcome, ItemIndex, ListCursor, ListFilter, ListMovement, RowSpan, Viewport,
};
use crate::theme::Theme;
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
pub struct History {
path: String,
selection: ListCursor,
viewport: Viewport,
entries: Vec<rootle_provider::LogEntry>,
truncated: bool,
loading: bool,
pending_sha: Option<String>,
filter: ListFilter,
}
impl History {
fn at(path: &str, pending_sha: Option<String>) -> Self {
History {
path: path.to_string(),
selection: ListCursor::new(),
viewport: Viewport::default(),
entries: Vec::new(),
truncated: false,
loading: true,
pending_sha,
filter: ListFilter::default(),
}
}
fn loaded(&mut self, entries: Vec<rootle_provider::LogEntry>, truncated: bool) {
let previous = self.selected().map(|entry| entry.sha.clone());
self.loading = false;
self.truncated = truncated;
self.entries = entries;
if let Some(sha) = self.pending_sha.take().or(previous)
&& let Some(i) = self
.entries
.iter()
.position(|e| e.sha == sha || e.sha.starts_with(&sha))
{
self.selection.select(ItemIndex::new(
self.visible()
.iter()
.position(|&index| index == i)
.unwrap_or(0),
));
}
self.selection.clamp(self.visible().len());
}
fn visible(&self) -> Vec<usize> {
self.filter.visible(&self.entries, |entry, filter| {
filter.matches(&entry.subject)
|| filter.matches(&entry.sha)
|| filter.matches(&entry.author)
})
}
fn selected(&self) -> Option<&rootle_provider::LogEntry> {
let vis = self.visible();
vis.get(
self.selection
.selected()
.get()
.min(vis.len().saturating_sub(1)),
)
.map(|&i| &self.entries[i])
}
pub(crate) fn diagnostics(&self) -> HistoryDiagnostics {
HistoryDiagnostics {
entries: self.entries.len(),
visible: self.visible().len(),
selected: self.selection.selected().get(),
loading: self.loading,
truncated: self.truncated,
}
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct HistoryDiagnostics {
pub(crate) entries: usize,
pub(crate) visible: usize,
pub(crate) selected: usize,
pub(crate) loading: bool,
pub(crate) truncated: bool,
}
pub(crate) struct BlameState {
pub(crate) path: String,
pub(crate) ranges: Vec<rootle_provider::BlameRange>,
pub(crate) loading: bool,
}
impl Browser {
pub fn clear_blame(&mut self) {
self.blame = None;
self.preview.set_blame(None);
}
pub fn blame_toggle_on(&mut self) -> bool {
if self.preview.text_line_count() == 0 {
return false;
}
let apply = matches!(&self.blame, Some(b) if !b.loading);
if apply {
self.blame_apply();
}
true
}
pub fn blame_needed_for(&self) -> Option<String> {
if self.preview.text_line_count() == 0 {
return None;
}
match &self.blame {
Some(b) if b.loading => None, Some(_) => None, None => self.selected_file().map(|(p, _)| p),
}
}
pub fn blame_mark_loading(&mut self, path: String) {
self.blame = Some(BlameState {
path,
ranges: Vec::new(),
loading: true,
});
}
pub fn blame_store(&mut self, path: String, ranges: Vec<rootle_provider::BlameRange>) {
let active = self.blame_awaiting(&path);
self.blame = Some(BlameState {
path,
ranges,
loading: false,
});
if active {
self.blame_apply();
}
}
fn blame_apply(&mut self) {
let Some(b) = &self.blame else { return };
let lines = self.preview.text_line_count();
if lines == 0 {
return;
}
let mut marks: Vec<Option<crate::components::preview::BlameMark>> = vec![None; lines];
for r in &b.ranges {
let start = (r.start_line as usize).saturating_sub(1);
if start < lines {
marks[start] = Some(crate::components::preview::BlameMark {
sha: r.sha.chars().take(7).collect(),
author: r.author.clone(),
});
}
}
self.preview.set_blame(Some(marks));
}
pub fn blame_line_sha(&self) -> Option<String> {
if !self.preview.blaming() {
return None;
}
let b = self.blame.as_ref()?;
if b.loading {
return None;
}
let line = self.preview_line().unwrap_or(1) as usize;
b.ranges
.iter()
.find(|r| r.start_line as usize <= line && line <= r.end_line as usize)
.map(|r| r.sha.clone())
}
pub fn open_history(&mut self, at_sha: Option<String>) -> bool {
match self.selected_file() {
Some((path, _)) => {
self.history = Some(History::at(&path, at_sha));
true
}
None => false,
}
}
pub fn history_loaded(&mut self, entries: Vec<rootle_provider::LogEntry>, truncated: bool) {
if let Some(h) = &mut self.history {
h.loaded(entries, truncated);
}
}
pub fn history_path(&self) -> Option<&str> {
self.history.as_ref().map(|h| h.path.as_str())
}
pub fn history_active(&self) -> bool {
self.history.is_some()
}
pub fn history_filtering(&self) -> bool {
self.history
.as_ref()
.is_some_and(|history| history.filter.active())
}
pub fn history_begin_filter(&mut self) {
if let Some(h) = &mut self.history {
h.filter.begin();
}
}
pub fn history_filter_key(&mut self, key: ratatui::crossterm::event::KeyEvent) {
if let Some(history) = &mut self.history
&& history.filter.handle_key(key) != FilterOutcome::Unchanged
{
history.selection.reset();
history.viewport.reset();
}
}
pub fn history_esc(&mut self) -> bool {
if let Some(h) = &mut self.history
&& h.filter.clear()
{
h.selection.reset();
h.viewport.reset();
return false;
}
self.close_history();
true
}
pub fn history_move(&mut self, delta: i64) {
if let Some(h) = &mut self.history {
let movement = if delta < 0 {
ListMovement::Previous
} else {
ListMovement::Next
};
h.selection
.advance(movement, h.visible().len(), Boundary::Wrap);
}
}
pub fn at_commit_view(&self) -> bool {
self.at_commit.is_some()
}
pub fn history_pick_entry(&self) -> Option<rootle_provider::LogEntry> {
self.history.as_ref()?.selected().cloned()
}
pub fn history_pick(&self) -> Option<(String, String)> {
let h = self.history.as_ref()?;
let c = h.selected()?;
Some((h.path.clone(), c.sha.clone()))
}
pub(crate) fn render_history(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) {
let Some(h) = &mut self.history else {
return;
};
let sem = &theme.semantic;
let mut title = format!(" history — {} ", h.path);
if h.filter.active() || !h.filter.is_empty() {
title = format!(" history — {} /{} ", h.path, h.filter.text());
}
let block = Block::default()
.borders(Borders::ALL)
.border_type(theme.border_type())
.border_style(Style::default().fg(sem.border_focused))
.style(Style::default().bg(sem.base))
.title(Span::styled(title, Style::default().fg(sem.subtext0)));
let inner = block.inner(area);
frame.render_widget(block, area);
if h.loading {
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
" loading history…",
Style::default().fg(sem.subtext0),
))),
inner,
);
return;
}
let vis = h.visible();
let mut lines: Vec<Line> = Vec::new();
for (row, &i) in vis.iter().enumerate() {
let c = &h.entries[i];
let selected = row == h.selection.selected().get();
let style = if selected {
Style::default().fg(sem.selection_fg).bg(sem.selection_bg)
} else {
Style::default().fg(sem.text)
};
let dim = if selected {
style
} else {
Style::default().fg(sem.subtext0)
};
lines.push(Line::from(vec![
Span::styled(
if selected { "▌ " } else { " " },
Style::default().fg(sem.border_focused),
),
Span::styled(
format!("{} ", c.sha.chars().take(7).collect::<String>()),
Style::default().fg(sem.warning),
),
Span::styled(c.subject.clone(), style),
]));
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(format!("{} · {}", c.author, c.date), dim),
]));
}
if vis.is_empty() {
lines.push(Line::from(Span::styled(
" no matching commits",
Style::default().fg(sem.subtext0),
)));
}
if h.truncated {
lines.push(Line::from(Span::styled(
" ⋮ truncated — / filters",
Style::default().fg(sem.subtext0),
)));
}
let selected = (!vis.is_empty()).then(|| {
let row = h.selection.selected().get() * 2;
RowSpan::new(row, row + 2)
});
h.viewport
.render(frame, area, inner, lines, selected, theme);
}
}