use super::Browser;
use crate::components::list_view::{
Boundary, FilterOutcome, ItemIndex, ListCursor, ListFilter, ListMovement, RowSpan, Viewport,
};
use crate::request::{HistoryRequest, HistoryScope};
use crate::theme::Theme;
use ratatui::{
Frame,
layout::Rect,
style::Style,
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
};
use rootle_provider::{GitRef, RepoId, Sha};
pub struct History {
request: HistoryRequest,
selection: ListCursor,
viewport: Viewport,
entries: Vec<HistoryEntry>,
truncated: bool,
loading: bool,
pending_sha: Option<Sha>,
failure: Option<String>,
filter: ListFilter,
}
#[derive(Clone)]
pub struct HistoryEntry {
pub revision: Sha,
pub subject: String,
pub author: String,
pub date: String,
}
impl History {
fn at(request: HistoryRequest, pending_sha: Option<Sha>) -> Self {
History {
request,
selection: ListCursor::new(),
viewport: Viewport::default(),
entries: Vec::new(),
truncated: false,
loading: true,
pending_sha,
failure: None,
filter: ListFilter::default(),
}
}
fn loaded(&mut self, entries: Vec<rootle_provider::LogEntry>, truncated: bool) {
let previous = self.selected().map(|entry| entry.revision.clone());
self.loading = false;
self.truncated = truncated;
self.failure = None;
self.entries = entries
.into_iter()
.map(|entry| HistoryEntry {
revision: entry.sha.into(),
subject: crate::sanitize::sanitize_inline(&entry.subject),
author: crate::sanitize::sanitize_inline(&entry.author),
date: crate::sanitize::sanitize_inline(&entry.date),
})
.collect();
if let Some(sha) = self.pending_sha.take().or(previous)
&& let Some(i) = self.entries.iter().position(|entry| entry.revision == 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.revision.as_str())
|| filter.matches(&entry.author)
})
}
fn selected(&self) -> Option<&HistoryEntry> {
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,
}
impl Browser {
pub fn open_history(&mut self, at_sha: Option<String>) -> bool {
let Some((path, _)) = self.selected_file() else {
return false;
};
self.begin_history(HistoryScope::File(path.into()), at_sha.map(Sha::from))
}
pub fn open_repository_history(&mut self) -> bool {
self.begin_history(HistoryScope::Repository, None)
}
fn begin_history(&mut self, scope: HistoryScope, pending_sha: Option<Sha>) -> bool {
let Some(repository) = self.history_repository() else {
return false;
};
let request = HistoryRequest {
repository,
revision: self.current_ref().map(GitRef::from),
scope,
generation: self.history_generation.tick(),
};
self.history = Some(History::at(request, pending_sha));
true
}
fn history_repository(&self) -> Option<RepoId> {
let repositories = self.levels.get(1)?;
let selected = repositories.selected_entry()?;
if selected.kind != super::EntryKind::Repo || self.focus == 0 {
return None;
}
let organization = self.selected_owner()?;
Some(RepoId::from(format!("{organization}/{}", selected.name)))
}
pub fn history_request(&self) -> Option<&HistoryRequest> {
self.history.as_ref().map(|history| &history.request)
}
pub fn history_accepts(&self, request: &HistoryRequest) -> bool {
self.history_request() == Some(request)
&& self.history_repository().as_ref() == Some(&request.repository)
&& self.current_ref() == request.revision.as_ref().map(GitRef::as_str)
}
pub fn repository_history_active(&self) -> bool {
self.history_request()
.is_some_and(|request| request.scope == HistoryScope::Repository)
}
pub fn history_failed(&mut self, message: &str) {
if let Some(history) = &mut self.history {
history.loading = false;
history.failure = Some(crate::sanitize::sanitize_inline(message));
}
}
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_request()
.and_then(|request| request.scope.path())
}
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<HistoryEntry> {
self.history.as_ref()?.selected().cloned()
}
pub fn history_pick(&self) -> Option<(HistoryRequest, Sha)> {
let history = self.history.as_ref()?;
Some((
history.request.clone(),
history.selected()?.revision.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 label = match &h.request.scope {
HistoryScope::Repository => format!("repo history — {}", h.request.repository),
HistoryScope::File(path) => format!("history — {path}"),
};
let mut title = format!(" {} ", crate::sanitize::sanitize_inline(&label));
if h.filter.active() || !h.filter.is_empty() {
title = format!("{title}/{} ", 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;
}
if let Some(message) = &h.failure {
frame.render_widget(
Paragraph::new(message.as_str()).style(Style::default().fg(sem.error)),
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!("{} ", crate::sanitize::sanitize_inline(&c.revision.short())),
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);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn repository_history_identity_does_not_depend_on_pane_captions() {
let mut browser = Browser::new(&["owner".into()]);
let request = browser.begin_owner_load("owner");
browser.org_repos_loaded(&request, vec![rootle_provider::RepoInfo::bare("project")]);
browser.focus = 1;
browser.levels[1].title = "display caption, not a repository owner".into();
assert!(browser.open_repository_history());
assert_eq!(
browser.history_request().unwrap().repository,
RepoId::from("owner/project")
);
}
}