use std::path::PathBuf;
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, List, ListItem, Paragraph},
};
use crate::prelude::*;
use input::{Key, KeyEvent};
use operation::{Event, GitHistoryOp, Operation};
use settings::Settings;
use views::View;
use widgets::DiffWidget;
use widgets::focus::FocusRing;
use widgets::focusable::FocusOp;
use widgets::input_field::{self, InputField};
use widgets::list::SelectableList;
use crate::vcs::{CommitInfo, FileChange};
const FOCUS_FILTER: &str = "filter";
const FOCUS_REVISIONS: &str = "revisions";
const FOCUS_FILES: &str = "files";
const FOCUS_DIFF: &str = "diff";
#[derive(Debug)]
pub struct GitHistoryView {
pub repo_path: PathBuf,
filter: InputField,
pub revisions: SelectableList<CommitInfo>,
pub files: SelectableList<FileChange>,
pub(crate) diff: Option<DiffWidget>,
focus: FocusRing,
pub search_generation: u64,
pub loading: bool,
pub loaded_oid: Option<String>,
status_msg: Option<String>,
}
impl GitHistoryView {
pub fn new(repo_path: PathBuf) -> Self {
Self {
repo_path,
filter: InputField::new("Filter commits"),
revisions: SelectableList::new(vec![]),
files: SelectableList::new(vec![]),
diff: None,
focus: FocusRing::new(vec![FOCUS_FILTER, FOCUS_REVISIONS, FOCUS_FILES, FOCUS_DIFF]),
search_generation: 0,
loading: true,
loaded_oid: None,
status_msg: None,
}
}
pub fn selected_revision_oid(&self) -> Option<&str> {
self.revisions.selected().map(|c| c.oid.as_str())
}
pub fn selected_file_path(&self) -> Option<&std::path::Path> {
self.files.selected().map(|f| f.path.as_path())
}
pub fn filter_text(&self) -> &str {
self.filter.text()
}
fn focused_pane(&self) -> &str {
self.focus.current()
}
}
impl View for GitHistoryView {
const KIND: crate::views::ViewKind = crate::views::ViewKind::Primary;
fn save_state(&mut self, _app: &mut crate::app_state::AppState) {}
fn handle_key(&self, key: KeyEvent) -> Vec<Operation> {
if key.key == Key::Tab {
return vec![Operation::Focus(FocusOp::Next)];
}
if key.key == Key::BackTab {
return vec![Operation::Focus(FocusOp::Prev)];
}
match self.focused_pane() {
FOCUS_FILTER => {
if let Some(field_op) = input_field::key_to_op(key) {
return vec![Operation::GitHistoryLocal(GitHistoryOp::FilterInput(
field_op,
))];
}
match key.key {
Key::ArrowUp => vec![Operation::NavigateUp],
Key::ArrowDown => vec![Operation::NavigateDown],
Key::PageUp => vec![Operation::NavigatePageUp],
Key::PageDown => vec![Operation::NavigatePageDown],
_ => vec![],
}
}
FOCUS_REVISIONS => match key.key {
Key::ArrowUp => vec![Operation::NavigateUp],
Key::ArrowDown => vec![Operation::NavigateDown],
Key::PageUp => vec![Operation::NavigatePageUp],
Key::PageDown => vec![Operation::NavigatePageDown],
Key::Enter => vec![Operation::GitHistoryLocal(GitHistoryOp::SelectRevision)],
_ => {
if let Some(field_op) = input_field::key_to_op(key) {
vec![Operation::GitHistoryLocal(GitHistoryOp::FilterInput(
field_op,
))]
} else {
vec![]
}
}
},
FOCUS_FILES => match key.key {
Key::ArrowUp => vec![
Operation::NavigateUp,
Operation::GitHistoryLocal(GitHistoryOp::SelectFile),
],
Key::ArrowDown => vec![
Operation::NavigateDown,
Operation::GitHistoryLocal(GitHistoryOp::SelectFile),
],
Key::PageUp => vec![Operation::NavigatePageUp],
Key::PageDown => vec![Operation::NavigatePageDown],
Key::Enter => vec![Operation::GitHistoryLocal(GitHistoryOp::SelectFile)],
_ => vec![],
},
FOCUS_DIFF => match key.key {
Key::ArrowUp => vec![Operation::NavigateUp],
Key::ArrowDown => vec![Operation::NavigateDown],
Key::PageUp => vec![Operation::NavigatePageUp],
Key::PageDown => vec![Operation::NavigatePageDown],
_ => vec![],
},
_ => vec![],
}
}
fn handle_operation(&mut self, op: &Operation, _settings: &Settings) -> Option<Event> {
match op {
Operation::Focus(focus_op) => {
match focus_op {
FocusOp::Next => self.focus.focus_next(),
FocusOp::Prev => self.focus.focus_prev(),
_ => {}
}
Some(Event::applied("git_history", op.clone()))
}
Operation::GitHistoryLocal(gh_op) => {
match gh_op {
GitHistoryOp::FilterInput(field_op) => {
self.filter.apply(field_op);
self.search_generation += 1;
self.loading = true;
self.revisions.set_items(vec![]);
self.files.set_items(vec![]);
self.diff = None;
self.loaded_oid = None;
}
GitHistoryOp::CommitsLoaded { generation, commits } => {
if *generation == self.search_generation {
self.revisions.set_items(commits.clone());
self.loading = false;
}
}
GitHistoryOp::SelectRevision | GitHistoryOp::SelectFile => {}
GitHistoryOp::FilesLoaded { oid, files } => {
self.files.set_items(files.clone());
self.loaded_oid = Some(oid.clone());
self.diff = None;
self.loading = false;
}
GitHistoryOp::DiffLoaded { lines } => {
self.diff = Some(DiffWidget::new(lines));
self.loading = false;
}
GitHistoryOp::LoadError(msg) => {
self.status_msg = Some(format!("Error: {msg}"));
self.loading = false;
}
}
Some(Event::applied("git_history", op.clone()))
}
Operation::NavigateUp => {
match self.focused_pane() {
FOCUS_REVISIONS => self.revisions.move_up(),
FOCUS_FILES => self.files.move_up(),
FOCUS_DIFF => {
if let Some(d) = &mut self.diff {
d.move_up();
}
}
_ => {}
}
Some(Event::applied("git_history", op.clone()))
}
Operation::NavigateDown => {
match self.focused_pane() {
FOCUS_REVISIONS => self.revisions.move_down(),
FOCUS_FILES => self.files.move_down(),
FOCUS_DIFF => {
if let Some(d) = &mut self.diff {
d.move_down();
}
}
_ => {}
}
Some(Event::applied("git_history", op.clone()))
}
Operation::NavigatePageUp => {
match self.focused_pane() {
FOCUS_REVISIONS => {
for _ in 0..10 {
self.revisions.move_up();
}
}
FOCUS_FILES => {
for _ in 0..10 {
self.files.move_up();
}
}
FOCUS_DIFF => {
if let Some(d) = &mut self.diff {
d.page_up(20);
}
}
_ => {}
}
Some(Event::applied("git_history", op.clone()))
}
Operation::NavigatePageDown => {
match self.focused_pane() {
FOCUS_REVISIONS => {
for _ in 0..10 {
self.revisions.move_down();
}
}
FOCUS_FILES => {
for _ in 0..10 {
self.files.move_down();
}
}
FOCUS_DIFF => {
if let Some(d) = &mut self.diff {
d.page_down(20);
}
}
_ => {}
}
Some(Event::applied("git_history", op.clone()))
}
_ => None,
}
}
fn render(&self, frame: &mut Frame, area: Rect, theme: &crate::theme::Theme) {
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(38), Constraint::Min(1)])
.split(area);
let left_rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Min(4), Constraint::Length(8), ])
.split(cols[0]);
render_filter(self, frame, left_rows[0], theme);
render_revisions(self, frame, left_rows[1], theme);
render_files(self, frame, left_rows[2], theme);
render_diff(self, frame, cols[1], theme);
}
}
fn render_filter(view: &GitHistoryView, frame: &mut Frame, area: Rect, theme: &crate::theme::Theme) {
let focused = view.focus.is_focused(FOCUS_FILTER);
view.filter.render(frame, area, focused, theme);
if view.loading {
let indicator_area = Rect {
x: area.x + area.width.saturating_sub(10),
y: area.y,
width: 10.min(area.width),
height: 1,
};
frame.render_widget(
Paragraph::new(Span::styled(
" loading…",
Style::default().fg(theme.fg_dim()),
)),
indicator_area,
);
}
}
fn render_revisions(
view: &GitHistoryView,
frame: &mut Frame,
area: Rect,
theme: &crate::theme::Theme,
) {
let focused = view.focus.is_focused(FOCUS_REVISIONS);
let bg = if focused {
theme.bg_active()
} else {
theme.bg_inactive()
};
let block = Block::default()
.title(Span::styled(
" Revisions ",
Style::default()
.fg(theme.accent())
.add_modifier(Modifier::BOLD),
))
.borders(Borders::TOP)
.border_type(BorderType::Plain)
.style(Style::default().bg(bg));
let inner = block.inner(area);
frame.render_widget(block, area);
frame.render_widget(
view.revisions.widget(|commit, selected| {
let short_oid: String = commit.oid.chars().take(8).collect();
let sel_bg = if selected { theme.selection_bg() } else { bg };
let sel_fg = if selected {
theme.selection_fg()
} else {
theme.fg()
};
let spans = vec![
Span::styled(
format!("{short_oid} "),
Style::default().fg(Color::DarkGray).bg(sel_bg),
),
Span::styled(
truncate(&commit.summary, 32),
Style::default()
.fg(sel_fg)
.bg(sel_bg)
.add_modifier(if selected { Modifier::BOLD } else { Modifier::empty() }),
),
Span::styled(
format!(" {}", commit.date_relative),
Style::default().fg(theme.fg_dim()).bg(sel_bg),
),
];
ListItem::new(Line::from(spans))
}),
inner,
);
}
fn render_files(
view: &GitHistoryView,
frame: &mut Frame,
area: Rect,
theme: &crate::theme::Theme,
) {
let focused = view.focus.is_focused(FOCUS_FILES);
let bg = if focused {
theme.bg_active()
} else {
theme.bg_inactive()
};
let block = Block::default()
.title(Span::styled(
" Changed Files ",
Style::default()
.fg(theme.accent())
.add_modifier(Modifier::BOLD),
))
.borders(Borders::TOP)
.border_type(BorderType::Plain)
.style(Style::default().bg(bg));
let inner = block.inner(area);
frame.render_widget(block, area);
let file_items = crate::widgets::file_tree_list::build_tree_list_items_from_filechanges(
view.files.items(),
view.files.cursor(),
view.focus.is_focused(FOCUS_FILES),
);
frame.render_widget(List::new(file_items), inner);
}
fn render_diff(
view: &GitHistoryView,
frame: &mut Frame,
area: Rect,
theme: &crate::theme::Theme,
) {
let focused = view.focus.is_focused(FOCUS_DIFF);
let bg = if focused {
theme.bg_active()
} else {
theme.bg_inactive()
};
let block = Block::default()
.title(Span::styled(
" Diff ",
Style::default()
.fg(theme.accent())
.add_modifier(Modifier::BOLD),
))
.borders(Borders::LEFT)
.border_type(BorderType::Plain)
.style(Style::default().bg(bg));
let inner = block.inner(area);
frame.render_widget(block, area);
if let Some(ref msg) = view.status_msg {
frame.render_widget(
Paragraph::new(Span::styled(msg.as_str(), Style::default().fg(theme.level_warn()))),
inner,
);
return;
}
match &view.diff {
None => {
let hint = if view.loaded_oid.is_none() {
"Select a revision and press Enter to view diff"
} else if view.files.is_empty() {
"(no files changed)"
} else {
"Navigate to a file and press Enter to view diff"
};
frame.render_widget(
Paragraph::new(Span::styled(hint, Style::default().fg(theme.fg_dim()))),
inner,
);
}
Some(w) => {
w.render_into(inner, frame.buffer_mut(), focused);
}
}
}
fn truncate(s: &str, max_chars: usize) -> String {
let chars: Vec<char> = s.chars().collect();
if chars.len() <= max_chars {
s.to_owned()
} else {
let mut t: String = chars[..max_chars - 1].iter().collect();
t.push('…');
t
}
}