#![forbid(unsafe_code)]
#![deny(
mismatched_lifetime_syntaxes,
unused_imports,
unused_must_use,
dead_code,
unstable_name_collisions,
unused_assignments
)]
#![deny(clippy::all, clippy::perf, clippy::nursery, clippy::pedantic)]
#![deny(
clippy::unwrap_used,
clippy::filetype_is_file,
clippy::cargo,
clippy::panic,
clippy::match_like_matches_macro
)]
#![allow(
clippy::multiple_crate_versions,
clippy::bool_to_int_with_if,
clippy::module_name_repetitions,
clippy::empty_docs,
clippy::unnecessary_debug_formatting
)]
mod app;
mod args;
mod bug_report;
mod clipboard;
mod cmdbar;
mod components;
mod input;
mod keys;
mod notify_mutex;
mod options;
mod popup_stack;
mod popups;
mod queue;
mod spinner;
mod string_utils;
mod strings;
mod tabs;
mod ui;
mod watcher;
use crate::{
app::App,
args::{process_cmdline, CliArgs},
};
use anyhow::{anyhow, bail, Result};
use app::QuitState;
use asyncgit::{
sync::{utils::repo_work_dir, RepoPath},
AsyncGitNotification,
};
use backtrace::Backtrace;
use crossbeam_channel::{never, tick, unbounded, Receiver, Select};
use crossterm::{
terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen,
LeaveAlternateScreen,
},
ExecutableCommand,
};
use input::{Input, InputEvent, InputState};
use keys::KeyConfig;
use ratatui::backend::CrosstermBackend;
use scopeguard::defer;
use scopetime::scope_time;
use spinner::Spinner;
use std::{
io::{self, Stdout},
panic,
path::Path,
time::{Duration, Instant},
};
use ui::style::Theme;
use watcher::RepoWatcher;
type Terminal = ratatui::Terminal<CrosstermBackend<io::Stdout>>;
static TICK_INTERVAL: Duration = Duration::from_secs(5);
static SPINNER_INTERVAL: Duration = Duration::from_millis(80);
#[derive(Clone)]
pub enum QueueEvent {
Tick,
Notify,
SpinnerUpdate,
AsyncEvent(AsyncNotification),
InputEvent(InputEvent),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SyntaxHighlightProgress {
Progress,
Done,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AsyncAppNotification {
SyntaxHighlighting(SyntaxHighlightProgress),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AsyncNotification {
App(AsyncAppNotification),
Git(AsyncGitNotification),
}
#[derive(Clone, Copy, PartialEq)]
enum Updater {
Ticker,
NotifyWatcher,
}
macro_rules! log_eprintln {
( $($arg:tt)* ) => {{
log::error!($($arg)*);
eprintln!($($arg)*);
}};
}
fn main() -> Result<()> {
let app_start = Instant::now();
let cliargs = process_cmdline()?;
asyncgit::register_tracing_logging();
ensure_valid_path(&cliargs.repo_path)?;
let key_config = KeyConfig::init(
cliargs.key_bindings_path.as_ref(),
cliargs.key_symbols_path.as_ref(),
)
.map_err(|e| log_eprintln!("KeyConfig loading error: {e}"))
.unwrap_or_default();
let theme = Theme::init(&cliargs.theme);
setup_terminal()?;
defer! {
shutdown_terminal();
}
set_panic_handler()?;
let mut terminal =
start_terminal(io::stdout(), &cliargs.repo_path)?;
let input = Input::new();
let updater = if cliargs.notify_watcher {
Updater::NotifyWatcher
} else {
Updater::Ticker
};
let mut args = cliargs;
loop {
let quit_state = run_app(
app_start,
args.clone(),
theme.clone(),
key_config.clone(),
&input,
updater,
&mut terminal,
)?;
match quit_state {
QuitState::OpenSubmodule(p) => {
args = CliArgs {
repo_path: p,
select_file: None,
theme: args.theme,
notify_watcher: args.notify_watcher,
key_bindings_path: args.key_bindings_path,
key_symbols_path: args.key_symbols_path,
}
}
_ => break,
}
}
Ok(())
}
fn run_app(
app_start: Instant,
cliargs: CliArgs,
theme: Theme,
key_config: KeyConfig,
input: &Input,
updater: Updater,
terminal: &mut Terminal,
) -> Result<QuitState, anyhow::Error> {
let (tx_git, rx_git) = unbounded();
let (tx_app, rx_app) = unbounded();
let rx_input = input.receiver();
let (rx_ticker, rx_watcher) = match updater {
Updater::NotifyWatcher => {
let repo_watcher = RepoWatcher::new(
repo_work_dir(&cliargs.repo_path)?.as_str(),
);
(never(), repo_watcher.receiver())
}
Updater::Ticker => (tick(TICK_INTERVAL), never()),
};
let spinner_ticker = tick(SPINNER_INTERVAL);
let mut app = App::new(
cliargs,
tx_git,
tx_app,
input.clone(),
theme,
key_config,
)?;
let mut spinner = Spinner::default();
let mut first_update = true;
log::trace!("app start: {} ms", app_start.elapsed().as_millis());
loop {
let event = if first_update {
first_update = false;
QueueEvent::Notify
} else {
select_event(
&rx_input,
&rx_git,
&rx_app,
&rx_ticker,
&rx_watcher,
&spinner_ticker,
)?
};
{
if matches!(event, QueueEvent::SpinnerUpdate) {
spinner.update();
spinner.draw(terminal)?;
continue;
}
scope_time!("loop");
match event {
QueueEvent::InputEvent(ev) => {
if matches!(
ev,
InputEvent::State(InputState::Polling)
) {
terminal.hide_cursor()?;
}
app.event(ev)?;
}
QueueEvent::Tick | QueueEvent::Notify => {
app.update()?;
}
QueueEvent::AsyncEvent(ev) => {
if !matches!(
ev,
AsyncNotification::Git(
AsyncGitNotification::FinishUnchanged
)
) {
app.update_async(ev)?;
}
}
QueueEvent::SpinnerUpdate => unreachable!(),
}
draw(terminal, &app)?;
spinner.set_state(app.any_work_pending());
spinner.draw(terminal)?;
if app.is_quit() {
break;
}
}
}
Ok(app.quit_state())
}
fn setup_terminal() -> Result<()> {
enable_raw_mode()?;
io::stdout().execute(EnterAlternateScreen)?;
Ok(())
}
fn shutdown_terminal() {
let leave_screen =
io::stdout().execute(LeaveAlternateScreen).map(|_f| ());
if let Err(e) = leave_screen {
log::error!("leave_screen failed:\n{e}");
}
let leave_raw_mode = disable_raw_mode();
if let Err(e) = leave_raw_mode {
log::error!("leave_raw_mode failed:\n{e}");
}
}
fn draw(terminal: &mut Terminal, app: &App) -> io::Result<()> {
if app.requires_redraw() {
terminal.clear()?;
}
terminal.draw(|f| {
if let Err(e) = app.draw(f) {
log::error!("failed to draw: {e:?}");
}
})?;
Ok(())
}
fn ensure_valid_path(repo_path: &RepoPath) -> Result<()> {
match asyncgit::sync::repo_open_error(repo_path) {
Some(e) => {
log::error!("invalid repo path: {e}");
bail!("invalid repo path: {e}")
}
None => Ok(()),
}
}
fn select_event(
rx_input: &Receiver<InputEvent>,
rx_git: &Receiver<AsyncGitNotification>,
rx_app: &Receiver<AsyncAppNotification>,
rx_ticker: &Receiver<Instant>,
rx_notify: &Receiver<()>,
rx_spinner: &Receiver<Instant>,
) -> Result<QueueEvent> {
let mut sel = Select::new();
sel.recv(rx_input);
sel.recv(rx_git);
sel.recv(rx_app);
sel.recv(rx_ticker);
sel.recv(rx_notify);
sel.recv(rx_spinner);
let oper = sel.select();
let index = oper.index();
let ev = match index {
0 => oper.recv(rx_input).map(QueueEvent::InputEvent),
1 => oper.recv(rx_git).map(|e| {
QueueEvent::AsyncEvent(AsyncNotification::Git(e))
}),
2 => oper.recv(rx_app).map(|e| {
QueueEvent::AsyncEvent(AsyncNotification::App(e))
}),
3 => oper.recv(rx_ticker).map(|_| QueueEvent::Notify),
4 => oper.recv(rx_notify).map(|()| QueueEvent::Notify),
5 => oper.recv(rx_spinner).map(|_| QueueEvent::SpinnerUpdate),
_ => bail!("unknown select source"),
}?;
Ok(ev)
}
fn start_terminal(
buf: Stdout,
repo_path: &RepoPath,
) -> Result<Terminal> {
let mut path = repo_path.gitpath().canonicalize()?;
let home = dirs::home_dir().ok_or_else(|| {
anyhow!("failed to find the home directory")
})?;
if path.starts_with(&home) {
let relative_part = path
.strip_prefix(&home)
.expect("can't fail because of the if statement");
path = Path::new("~").join(relative_part);
}
let mut backend = CrosstermBackend::new(buf);
backend.execute(crossterm::terminal::SetTitle(format!(
"gitui ({})",
path.display()
)))?;
let mut terminal = Terminal::new(backend)?;
terminal.hide_cursor()?;
terminal.clear()?;
Ok(terminal)
}
fn set_panic_handler() -> Result<()> {
panic::set_hook(Box::new(|e| {
let backtrace = Backtrace::new();
shutdown_terminal();
log_eprintln!("\nGitUI was closed due to an unexpected panic.\nPlease file an issue on https://github.com/gitui-org/gitui/issues with the following info:\n\n{e}\n\ntrace:\n{backtrace:?}");
}));
rayon_core::ThreadPoolBuilder::new()
.num_threads(4)
.build_global()?;
Ok(())
}