use super::{Browser, scrollbar};
use crate::components::vim_input::VimInput;
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,
cursor: usize,
scroll: u16,
entries: Vec<crate::provider::LogEntry>,
truncated: bool,
loading: bool,
pending_sha: Option<String>,
filter: VimInput,
filtering: bool,
filter_value: String,
pre_filter: String,
}
impl History {
fn at(path: &str, pending_sha: Option<String>) -> Self {
History {
path: path.to_string(),
cursor: 0,
scroll: 0,
entries: Vec::new(),
truncated: false,
loading: true,
pending_sha,
filter: VimInput::transient(),
filtering: false,
filter_value: String::new(),
pre_filter: String::new(),
}
}
fn loaded(&mut self, entries: Vec<crate::provider::LogEntry>, truncated: bool) {
self.loading = false;
self.truncated = truncated;
self.entries = entries;
if let Some(sha) = self.pending_sha.take()
&& let Some(i) = self
.entries
.iter()
.position(|e| e.sha == sha || e.sha.starts_with(&sha))
{
self.cursor = self.visible().iter().position(|&vi| vi == i).unwrap_or(0);
}
}
fn visible(&self) -> Vec<usize> {
let needle = self.filter_value.to_lowercase();
self.entries
.iter()
.enumerate()
.filter(|(_, c)| {
needle.is_empty()
|| c.subject.to_lowercase().contains(&needle)
|| c.sha.contains(&needle)
|| c.author.to_lowercase().contains(&needle)
})
.map(|(i, _)| i)
.collect()
}
fn selected(&self) -> Option<&crate::provider::LogEntry> {
let vis = self.visible();
vis.get(self.cursor.min(vis.len().saturating_sub(1)))
.map(|&i| &self.entries[i])
}
}
pub(crate) struct BlameState {
pub(crate) path: String,
pub(crate) ranges: Vec<crate::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<crate::provider::BlameRange>) {
let active = self
.blame
.as_ref()
.map(|b| b.loading && b.path == path)
.unwrap_or(false);
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<crate::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().map(|h| h.filtering).unwrap_or(false)
}
pub fn history_begin_filter(&mut self) {
if let Some(h) = &mut self.history {
h.pre_filter = h.filter_value.clone();
h.filtering = true;
}
}
pub fn history_filter_key(&mut self, key: ratatui::crossterm::event::KeyEvent) {
if let Some(h) = &mut self.history {
match h.filter.handle_key(key) {
crate::components::vim_input::Outcome::Changed => {
h.filter_value = h.filter.value();
h.cursor = 0;
h.scroll = 0;
}
crate::components::vim_input::Outcome::Submitted => {
h.filtering = false;
h.filter_value = h.filter.value();
h.cursor = 0;
h.scroll = 0;
}
crate::components::vim_input::Outcome::Cancelled => {
h.filtering = false;
h.filter_value = h.pre_filter.clone();
}
_ => {}
}
}
}
pub fn history_esc(&mut self) -> bool {
if let Some(h) = &mut self.history
&& !h.filter_value.is_empty()
{
h.filter_value.clear();
h.cursor = 0;
return false;
}
self.close_history();
true
}
pub fn history_move(&mut self, delta: i64) {
if let Some(h) = &mut self.history {
let len = h.visible().len() as i64;
if len > 0 {
h.cursor = ((h.cursor as i64 + delta).rem_euclid(len)) as usize;
}
}
}
pub fn at_commit_view(&self) -> bool {
self.at_commit.is_some()
}
pub fn history_pick_entry(&self) -> Option<crate::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.filtering || !h.filter_value.is_empty() {
title = format!(" history — {} /{} ", h.path, h.filter.value());
}
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)))
.title_bottom(Span::styled(
" j/k commit · enter file at commit · / filter · esc back ",
Style::default().fg(sem.hint),
));
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.cursor;
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 height = inner.height as usize;
let total = lines.len();
let max_scroll = total.saturating_sub(height) as u16;
let want = (h.cursor * 2) as u16;
if want < h.scroll {
h.scroll = want;
} else if want + 1 >= h.scroll + height as u16 {
h.scroll = (want + 2).saturating_sub(height as u16);
}
h.scroll = h.scroll.min(max_scroll);
let scroll = h.scroll;
frame.render_widget(Paragraph::new(lines).scroll((scroll, 0)), inner);
scrollbar(frame, area, height, total, scroll as usize, theme);
}
}