use std::rc::Rc;
use ratatui::{
crossterm::event::{KeyCode, KeyEvent},
layout::{Constraint, Layout, Rect},
style::{Modifier, Style, Stylize},
text::Line,
widgets::{Block, Borders, Padding, Paragraph},
DefaultTerminal, Frame,
};
use rustc_hash::FxHashMap;
use crate::{
color::{ColorTheme, GraphColorSet},
config::{CoreConfig, CursorType, UiConfig, UserCommand, UserCommandType},
event::{AppEvent, EventController, UserEvent, UserEventWithCount},
external::{
copy_to_clipboard, exec_user_command, exec_user_command_suspend, ExternalCommandParameters,
},
git::{Commit, FileChange, Head, Ref, Repository},
graph::{CellWidthType, Graph, GraphImageManager},
keybind::KeyBind,
protocol::ImageProtocol,
view::{RefreshViewContext, View},
widget::commit_list::{CommitInfo, CommitListState},
};
#[derive(Debug, Default)]
enum StatusLine {
#[default]
None,
Input(String, Option<u16>, Option<String>),
NotificationInfo(String),
NotificationSuccess(String),
NotificationWarn(String),
NotificationError(String),
}
#[derive(Clone, Copy)]
pub enum InitialSelection {
Latest,
Head,
}
pub enum Ret {
Quit,
Refresh(RefreshRequest),
}
pub struct RefreshRequest {
pub context: RefreshViewContext,
}
#[derive(Debug)]
pub struct AppContext {
pub keybind: KeyBind,
pub core_config: CoreConfig,
pub ui_config: UiConfig,
pub color_theme: ColorTheme,
pub image_protocol: ImageProtocol,
}
#[derive(Debug, Default)]
struct AppStatus {
status_line: StatusLine,
numeric_prefix: String,
view_area: Rect,
}
#[derive(Debug)]
pub struct App<'a> {
repository: &'a Repository,
view: View<'a>,
app_status: AppStatus,
ctx: Rc<AppContext>,
ec: &'a EventController,
}
impl<'a> App<'a> {
pub fn new(
repository: &'a Repository,
graph_image_manager: GraphImageManager<'a>,
graph: &'a Graph,
graph_color_set: &'a GraphColorSet,
cell_width_type: CellWidthType,
initial_selection: InitialSelection,
ctx: Rc<AppContext>,
ec: &'a EventController,
refresh_view_context: Option<RefreshViewContext>,
) -> Self {
let mut ref_name_to_commit_index_map = FxHashMap::default();
let commits = graph
.commits
.iter()
.enumerate()
.map(|(i, commit)| {
let refs = repository.refs(&commit.commit_hash);
for r in &refs {
ref_name_to_commit_index_map.insert(r.name(), i);
}
let (pos_x, _) = graph.commit_pos_map[&commit.commit_hash];
let graph_color = graph_color_set.get(pos_x).to_ratatui_color();
CommitInfo::new(commit, refs, graph_color)
})
.collect();
let graph_cell_width = match cell_width_type {
CellWidthType::Double => (graph.max_pos_x + 1) as u16 * 2,
CellWidthType::Single => (graph.max_pos_x + 1) as u16,
};
let head = repository.head();
let mut commit_list_state = CommitListState::new(
commits,
graph_image_manager,
graph_cell_width,
head,
ref_name_to_commit_index_map,
ctx.core_config.search.ignore_case,
ctx.core_config.search.fuzzy,
);
if let InitialSelection::Head = initial_selection {
match repository.head() {
Head::Branch { name } => commit_list_state.select_ref(name),
Head::Detached { target } => commit_list_state.select_commit_hash(target),
Head::None => {}
}
}
let view = View::of_list(commit_list_state, ctx.clone(), ec.sender());
let mut app = Self {
repository,
view,
app_status: AppStatus::default(),
ctx,
ec,
};
if let Some(context) = refresh_view_context {
app.init_with_context(context);
}
app
}
}
impl App<'_> {
pub fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<Ret, std::io::Error> {
self.clear_image(None)?;
terminal.clear()?;
loop {
terminal.draw(|f| self.render(f))?;
match self.ec.recv() {
AppEvent::Key(key) => {
match self.app_status.status_line {
StatusLine::None | StatusLine::Input(_, _, _) => {
}
StatusLine::NotificationInfo(_)
| StatusLine::NotificationSuccess(_)
| StatusLine::NotificationWarn(_) => {
self.clear_status_line();
}
StatusLine::NotificationError(_) => {
self.clear_status_line();
continue;
}
}
let user_event = self.ctx.keybind.get(&key);
if let Some(UserEvent::Cancel) = user_event {
if !self.app_status.numeric_prefix.is_empty() {
self.app_status.numeric_prefix.clear();
continue;
}
}
match user_event {
Some(UserEvent::ForceQuit) => {
self.ec.send(AppEvent::Quit);
}
Some(ue) => {
let event_with_count =
process_numeric_prefix(&self.app_status.numeric_prefix, *ue, key);
self.view.handle_event(event_with_count, key);
self.app_status.numeric_prefix.clear();
}
None => {
if let StatusLine::Input(_, _, _) = self.app_status.status_line {
self.app_status.numeric_prefix.clear();
self.view.handle_event(
UserEventWithCount::from_event(UserEvent::Unknown),
key,
);
} else if let KeyCode::Char(c) = key.code {
if c.is_ascii_digit()
&& (c != '0' || !self.app_status.numeric_prefix.is_empty())
{
self.app_status.numeric_prefix.push(c);
}
}
}
}
}
AppEvent::Resize(w, h) => {
let _ = (w, h);
}
AppEvent::Quit => {
return Ok(Ret::Quit);
}
AppEvent::OpenDetail => {
self.clear_image(Some(terminal))?;
self.open_detail();
}
AppEvent::CloseDetail => {
terminal.clear()?;
self.close_detail();
}
AppEvent::OpenUserCommand(n) => {
self.clear_image(Some(terminal))?;
self.open_user_command(n, Some(terminal));
}
AppEvent::CloseUserCommand => {
terminal.clear()?;
self.close_user_command();
}
AppEvent::OpenRefs => {
self.open_refs();
}
AppEvent::CloseRefs => {
self.close_refs();
}
AppEvent::OpenHelp => {
self.clear_image(None)?;
self.open_help();
}
AppEvent::CloseHelp => {
terminal.clear()?;
self.close_help();
}
AppEvent::SelectOlderCommit => {
self.select_older_commit();
}
AppEvent::SelectNewerCommit => {
self.select_newer_commit();
}
AppEvent::SelectParentCommit => {
self.select_parent_commit();
}
AppEvent::CopyToClipboard { name, value } => {
self.copy_to_clipboard(name, value);
}
AppEvent::Refresh(context) => {
let request = RefreshRequest { context };
return Ok(Ret::Refresh(request));
}
AppEvent::ClearStatusLine => {
self.clear_status_line();
}
AppEvent::UpdateStatusInput(msg, cursor_pos, msg_r) => {
self.update_status_input(msg, cursor_pos, msg_r);
}
AppEvent::NotifyInfo(msg) => {
self.info_notification(msg);
}
AppEvent::NotifySuccess(msg) => {
self.success_notification(msg);
}
AppEvent::NotifyWarn(msg) => {
self.warn_notification(msg);
}
AppEvent::NotifyError(msg) => {
self.error_notification(msg);
}
}
}
}
fn render(&mut self, f: &mut Frame) {
let base = Block::default()
.fg(self.ctx.color_theme.fg)
.bg(self.ctx.color_theme.bg);
f.render_widget(base, f.area());
let [view_area, status_line_area] =
Layout::vertical([Constraint::Min(0), Constraint::Length(2)]).areas(f.area());
self.update_state(view_area);
self.view.render(f, view_area);
self.render_status_line(f, status_line_area);
}
}
impl App<'_> {
fn render_status_line(&self, f: &mut Frame, area: Rect) {
let text: Line = match &self.app_status.status_line {
StatusLine::None => {
if self.app_status.numeric_prefix.is_empty() {
Line::raw("")
} else {
Line::raw(self.app_status.numeric_prefix.as_str())
.fg(self.ctx.color_theme.status_input_transient_fg)
}
}
StatusLine::Input(msg, _, transient_msg) => {
let msg_w = console::measure_text_width(msg.as_str());
if let Some(t_msg) = transient_msg {
let t_msg_w = console::measure_text_width(t_msg.as_str());
let pad_w = area.width as usize - msg_w - t_msg_w - 2 ;
Line::from(vec![
msg.as_str().fg(self.ctx.color_theme.status_input_fg),
" ".repeat(pad_w).into(),
t_msg
.as_str()
.fg(self.ctx.color_theme.status_input_transient_fg),
])
} else {
Line::raw(msg).fg(self.ctx.color_theme.status_input_fg)
}
}
StatusLine::NotificationInfo(msg) => {
Line::raw(msg).fg(self.ctx.color_theme.status_info_fg)
}
StatusLine::NotificationSuccess(msg) => Line::raw(msg)
.add_modifier(Modifier::BOLD)
.fg(self.ctx.color_theme.status_success_fg),
StatusLine::NotificationWarn(msg) => Line::raw(msg)
.add_modifier(Modifier::BOLD)
.fg(self.ctx.color_theme.status_warn_fg),
StatusLine::NotificationError(msg) => Line::raw(format!("ERROR: {msg}"))
.add_modifier(Modifier::BOLD)
.fg(self.ctx.color_theme.status_error_fg),
};
let paragraph = Paragraph::new(text).block(
Block::default()
.borders(Borders::TOP)
.style(Style::default().fg(self.ctx.color_theme.divider_fg))
.padding(Padding::horizontal(1)),
);
f.render_widget(paragraph, area);
if let StatusLine::Input(_, Some(cursor_pos), _) = &self.app_status.status_line {
let (x, y) = (area.x + cursor_pos + 1, area.y + 1);
match &self.ctx.ui_config.common.cursor_type {
CursorType::Native => {
f.set_cursor_position((x, y));
}
CursorType::Virtual(cursor) => {
let style = Style::default().fg(self.ctx.color_theme.virtual_cursor_fg);
f.buffer_mut().set_string(x, y, cursor, style);
}
}
}
}
}
impl App<'_> {
fn update_state(&mut self, view_area: Rect) {
self.app_status.view_area = view_area;
}
fn clear_image(&self, terminal: Option<&mut DefaultTerminal>) -> Result<(), std::io::Error> {
if let Some(t) = terminal {
for y in 1..t.size()?.height {
self.ctx.image_protocol.clear_line(y);
}
} else {
self.ctx.image_protocol.clear();
}
Ok(())
}
fn open_detail(&mut self) {
let commit_list_state = match self.view {
View::List(ref mut view) => view.take_list_state(),
View::UserCommand(ref mut view) => view.take_list_state(),
_ => return,
};
let (commit, changes, refs) = selected_commit_details(self.repository, &commit_list_state);
self.view = View::of_detail(
commit_list_state,
commit,
changes,
refs,
self.ctx.clone(),
self.ec.sender(),
);
}
fn close_detail(&mut self) {
if let View::Detail(ref mut view) = self.view {
let commit_list_state = view.take_list_state();
self.view = View::of_list(commit_list_state, self.ctx.clone(), self.ec.sender());
}
}
fn open_user_command(
&mut self,
user_command_number: usize,
terminal: Option<&mut DefaultTerminal>,
) {
let clear = match extract_user_command_by_number(user_command_number, &self.ctx)
.map(|c| &c.r#type)
{
Ok(UserCommandType::Inline) => {
self.open_user_command_inline(user_command_number);
false
}
Ok(UserCommandType::Silent) => {
self.open_user_command_silent(user_command_number);
true
}
Ok(UserCommandType::Suspend) => {
self.open_user_command_suspend(user_command_number);
true
}
Err(err) => {
self.ec.send(AppEvent::NotifyError(err));
false
}
};
if clear {
if let Some(t) = terminal {
if let Err(err) = t.clear() {
let msg = format!("Failed to clear terminal: {err:?}");
self.ec.send(AppEvent::NotifyError(msg));
}
}
}
}
fn open_user_command_inline(&mut self, user_command_number: usize) {
let commit_list_state = match self.view {
View::List(ref mut view) => view.as_list_state(),
View::Detail(ref mut view) => view.as_list_state(),
View::UserCommand(ref mut view) => view.as_list_state(),
_ => return,
};
let (commit, _, refs) = selected_commit_details(self.repository, commit_list_state);
let result = build_external_command_parameters_and_exec_command(
&commit,
&refs,
user_command_number,
self.app_status.view_area,
&self.ctx,
);
match result {
Ok(output) => {
let commit_list_state = match self.view {
View::List(ref mut view) => view.take_list_state(),
View::Detail(ref mut view) => view.take_list_state(),
View::UserCommand(ref mut view) => view.take_list_state(),
_ => return,
};
self.view = View::of_user_command(
commit_list_state,
output,
user_command_number,
self.ctx.clone(),
self.ec.sender(),
);
}
Err(err) => {
self.ec.send(AppEvent::NotifyError(err));
}
};
}
fn open_user_command_silent(&mut self, user_command_number: usize) {
let commit_list_state = match self.view {
View::List(ref mut view) => view.as_list_state(),
View::Detail(ref mut view) => view.as_list_state(),
View::UserCommand(ref mut view) => view.as_list_state(),
_ => return,
};
let (commit, _, refs) = selected_commit_details(self.repository, commit_list_state);
let result = build_external_command_parameters_and_exec_command(
&commit,
&refs,
user_command_number,
self.app_status.view_area,
&self.ctx,
);
match result {
Ok(_) => {
if extract_user_command_refresh_by_number(user_command_number, &self.ctx) {
self.view.refresh();
}
}
Err(err) => {
self.ec.send(AppEvent::NotifyError(err));
}
}
}
fn open_user_command_suspend(&mut self, user_command_number: usize) {
let commit_list_state = match self.view {
View::List(ref mut view) => view.as_list_state(),
View::Detail(ref mut view) => view.as_list_state(),
View::UserCommand(ref mut view) => view.as_list_state(),
_ => return,
};
let (commit, _, refs) = selected_commit_details(self.repository, commit_list_state);
match build_external_command_parameters(
&commit,
&refs,
user_command_number,
self.app_status.view_area,
&self.ctx,
) {
Ok(params) => {
self.ec.suspend();
let exec_result = exec_user_command_suspend(params);
self.ec.resume();
if extract_user_command_refresh_by_number(user_command_number, &self.ctx) {
self.view.refresh();
}
if let Err(err) = exec_result {
self.ec.send(AppEvent::NotifyError(err));
}
}
Err(err) => {
self.ec.send(AppEvent::NotifyError(err));
}
}
}
fn close_user_command(&mut self) {
if let View::UserCommand(ref mut view) = self.view {
let commit_list_state = view.take_list_state();
self.view = View::of_list(commit_list_state, self.ctx.clone(), self.ec.sender());
}
}
fn open_refs(&mut self) {
if let View::List(ref mut view) = self.view {
let commit_list_state = view.take_list_state();
let refs = self.repository.all_refs().into_iter().cloned().collect();
self.view = View::of_refs(commit_list_state, refs, self.ctx.clone(), self.ec.sender());
}
}
fn close_refs(&mut self) {
if let View::Refs(ref mut view) = self.view {
let commit_list_state = view.take_list_state();
self.view = View::of_list(commit_list_state, self.ctx.clone(), self.ec.sender());
}
}
fn open_help(&mut self) {
let before_view = std::mem::take(&mut self.view);
self.view = View::of_help(before_view, self.ctx.clone(), self.ec.sender());
}
fn close_help(&mut self) {
if let View::Help(ref mut view) = self.view {
self.view = view.take_before_view();
}
}
fn select_older_commit(&mut self) {
if let View::Detail(ref mut view) = self.view {
view.select_older_commit(self.repository);
} else if let View::UserCommand(ref mut view) = self.view {
view.select_older_commit(
self.repository,
self.app_status.view_area,
build_external_command_parameters_and_exec_command,
);
}
}
fn select_newer_commit(&mut self) {
if let View::Detail(ref mut view) = self.view {
view.select_newer_commit(self.repository);
} else if let View::UserCommand(ref mut view) = self.view {
view.select_newer_commit(
self.repository,
self.app_status.view_area,
build_external_command_parameters_and_exec_command,
);
}
}
fn select_parent_commit(&mut self) {
if let View::Detail(ref mut view) = self.view {
view.select_parent_commit(self.repository);
} else if let View::UserCommand(ref mut view) = self.view {
view.select_parent_commit(
self.repository,
self.app_status.view_area,
build_external_command_parameters_and_exec_command,
);
}
}
fn init_with_context(&mut self, context: RefreshViewContext) {
if let View::List(ref mut view) = self.view {
view.reset_commit_list_with(context.list_context());
}
match context {
RefreshViewContext::List { .. } => {}
RefreshViewContext::Detail { .. } => {
self.open_detail();
}
RefreshViewContext::UserCommand {
user_command_context,
..
} => {
self.open_user_command(user_command_context.n, None);
}
RefreshViewContext::Refs { refs_context, .. } => {
self.open_refs();
if let View::Refs(ref mut view) = self.view {
view.reset_refs_with(refs_context);
}
}
}
}
fn clear_status_line(&mut self) {
self.app_status.status_line = StatusLine::None;
}
fn update_status_input(
&mut self,
msg: String,
cursor_pos: Option<u16>,
transient_msg: Option<String>,
) {
self.app_status.status_line = StatusLine::Input(msg, cursor_pos, transient_msg);
}
fn info_notification(&mut self, msg: String) {
self.app_status.status_line = StatusLine::NotificationInfo(msg);
}
fn success_notification(&mut self, msg: String) {
self.app_status.status_line = StatusLine::NotificationSuccess(msg);
}
fn warn_notification(&mut self, msg: String) {
self.app_status.status_line = StatusLine::NotificationWarn(msg);
}
fn error_notification(&mut self, msg: String) {
self.app_status.status_line = StatusLine::NotificationError(msg);
}
fn copy_to_clipboard(&self, name: String, value: String) {
match copy_to_clipboard(value, &self.ctx.core_config.external.clipboard) {
Ok(_) => {
let msg = format!("Copied {name} to clipboard successfully");
self.ec.send(AppEvent::NotifySuccess(msg));
}
Err(msg) => {
self.ec.send(AppEvent::NotifyError(msg));
}
}
}
}
fn selected_commit_details(
repository: &Repository,
commit_list_state: &CommitListState,
) -> (Commit, Vec<FileChange>, Vec<Ref>) {
let selected = commit_list_state.selected_commit_hash().clone();
let (commit, changes) = repository.commit_detail(&selected);
let refs: Vec<Ref> = repository.refs(&selected).into_iter().cloned().collect();
(commit, changes, refs)
}
fn process_numeric_prefix(
numeric_prefix: &str,
user_event: UserEvent,
_key_event: KeyEvent,
) -> UserEventWithCount {
if user_event.is_countable() {
let count = if numeric_prefix.is_empty() {
1
} else {
numeric_prefix.parse::<usize>().unwrap_or(1)
};
UserEventWithCount::new(user_event, count)
} else {
UserEventWithCount::from_event(user_event)
}
}
fn extract_user_command_by_number(
user_command_number: usize,
ctx: &AppContext,
) -> Result<&UserCommand, String> {
ctx.core_config
.user_command
.commands
.get(&user_command_number.to_string())
.ok_or_else(|| format!("No user command configured for number {user_command_number}",))
}
fn extract_user_command_refresh_by_number(user_command_number: usize, ctx: &AppContext) -> bool {
extract_user_command_by_number(user_command_number, ctx)
.map(|c| c.refresh)
.unwrap_or_default()
}
fn build_external_command_parameters_and_exec_command(
commit: &Commit,
refs: &[Ref],
user_command_number: usize,
view_area: Rect,
ctx: &AppContext,
) -> Result<String, String> {
build_external_command_parameters(commit, refs, user_command_number, view_area, ctx)
.and_then(exec_user_command)
}
fn build_external_command_parameters<'a>(
commit: &'a Commit,
refs: &'a [Ref],
user_command_number: usize,
view_area: Rect,
ctx: &'a AppContext,
) -> Result<ExternalCommandParameters<'a>, String> {
let command = &extract_user_command_by_number(user_command_number, ctx)?.commands;
let target_hash = commit.commit_hash.as_str();
let parent_hashes = commit
.parent_commit_hashes
.iter()
.map(|c| c.as_str())
.collect();
let mut all_refs = vec![];
let mut branches = vec![];
let mut remote_branches = vec![];
let mut tags = vec![];
for r in refs {
match r {
Ref::Tag { .. } => tags.push(r.name()),
Ref::Branch { .. } => branches.push(r.name()),
Ref::RemoteBranch { .. } => remote_branches.push(r.name()),
Ref::Stash { .. } => continue, }
all_refs.push(r.name());
}
let area_width = view_area.width.saturating_sub(4); let area_height = (view_area.height.saturating_sub(1))
.min(ctx.ui_config.user_command.height)
.saturating_sub(1); Ok(ExternalCommandParameters {
command,
target_hash,
parent_hashes,
all_refs,
branches,
remote_branches,
tags,
area_width,
area_height,
})
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use super::*;
#[rustfmt::skip]
#[rstest]
#[case("", UserEvent::NavigateDown, UserEventWithCount::new(UserEvent::NavigateDown, 1))] #[case("5", UserEvent::NavigateUp, UserEventWithCount::new(UserEvent::NavigateUp, 5))] #[case("0", UserEvent::PageDown, UserEventWithCount::new(UserEvent::PageDown, 1))] #[case("42", UserEvent::ScrollDown, UserEventWithCount::new(UserEvent::ScrollDown, 42))] #[case("999", UserEvent::PageDown, UserEventWithCount::new(UserEvent::PageDown, 999))] #[case("abc", UserEvent::ScrollUp, UserEventWithCount::new(UserEvent::ScrollUp, 1))] #[case("5", UserEvent::Quit, UserEventWithCount::new(UserEvent::Quit, 1))] #[case("", UserEvent::Confirm, UserEventWithCount::new(UserEvent::Confirm, 1))] fn test_process_numeric_prefix(
#[case] numeric_prefix: &str,
#[case] user_event: UserEvent,
#[case] expected: UserEventWithCount,
) {
let dummy_key_event = KeyEvent::from(KeyCode::Enter); let actual = process_numeric_prefix(numeric_prefix, user_event, dummy_key_event);
assert_eq!(actual, expected);
}
}