pub mod api;
pub mod diff;
pub mod git;
pub mod host;
pub mod schema;
pub mod store;
pub mod theme;
pub mod ui;
mod act;
mod browse;
mod chrome;
mod clone;
mod code_index;
mod code_nav;
mod compose;
mod config;
#[cfg(test)]
mod crawl;
mod create;
mod data;
mod diff_job;
mod diff_screen;
mod discuss;
mod edit;
mod features;
#[cfg(test)]
mod fixtures;
mod follow;
mod forms;
mod gist;
mod home;
mod housekeeping;
mod join;
mod keymap;
mod live;
mod manage;
mod nav;
mod notice;
mod offline;
mod picker;
mod projects;
mod release;
mod review;
pub mod route;
mod runtime;
mod security;
#[cfg(test)]
mod sequence_tests;
mod settings;
mod site;
mod source;
mod state;
mod tabs;
mod talk;
mod typing;
mod view;
mod watch;
pub mod wiki;
mod work;
mod workflows;
mod yaml;
use crate::route::{Dest, Route, Target, issues, pulls};
use std::path::PathBuf;
use std::time::{Duration, Instant};
use crate::api::GitHub;
use crate::api::model::{PrRef, RepoId};
use crate::store::Store;
use crate::theme::{ColorDepth, Mode, Theme};
use crate::ui::Icons;
use anyhow::{Context, Result, bail};
use clap::{Parser, Subcommand};
use crate::config::{Config, DepthSetting, ModeSetting};
use crate::data::DataKey;
use crate::diff_job::GitContext;
use crate::diff_screen::DiffOf;
use crate::keymap::Keymap;
use crate::state::{Api, Remote, State};
const BACKGROUND_QUERY_TIMEOUT: Duration = Duration::from_millis(100);
#[derive(Parser)]
#[command(
version,
about = "A keyboard-driven terminal client for GitHub",
args_conflicts_with_subcommands = true
)]
struct Cli {
#[command(subcommand)]
command: Option<Command>,
target: Option<String>,
#[arg(long, global = true)]
config: Option<PathBuf>,
#[arg(long, global = true, value_enum)]
theme: Option<ModeSetting>,
}
#[derive(Subcommand)]
enum Command {
Pr {
target: String,
},
}
#[expect(
clippy::print_stderr,
reason = "errors that end ghtui, before or after the terminal is taken over"
)]
pub fn main() -> std::process::ExitCode {
if std::env::var_os(crate::git::credentials::ASKPASS_FLAG).is_some() {
return askpass();
}
let started = Instant::now();
let runtime = match tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
{
Ok(runtime) => runtime,
Err(err) => {
eprintln!("ghtui: cannot start the async runtime: {err}");
return std::process::ExitCode::FAILURE;
}
};
let result = runtime.block_on(run(started));
runtime.shutdown_timeout(Duration::from_millis(100));
match result {
Ok(()) => std::process::ExitCode::SUCCESS,
Err(err) => {
eprintln!("ghtui: {err:#}");
std::process::ExitCode::FAILURE
}
}
}
async fn run(started: Instant) -> Result<()> {
let cli = Cli::parse();
let config_path = cli.config.clone().or_else(config::default_config_path);
let config = match &config_path {
Some(path) => Config::load(path)?,
None => Config::default(),
};
let host = host::Host::resolve(|k| std::env::var(k).ok(), config.github.host.as_deref())
.map_err(anyhow::Error::msg)?;
host::set(host);
let token = std::thread::spawn(move || crate::api::auth::resolve_token(host));
let keymap = Keymap::with_overrides(&config.keys).map_err(anyhow::Error::msg)?;
let cache_dir = config::cache_dir().context("cannot determine a cache directory")?;
let _log_guard = init_logging(&cache_dir);
tracing::info!(version = env!("CARGO_PKG_VERSION"), %host, "starting");
let target = match (&cli.command, &cli.target) {
(Some(Command::Pr { target }), _) => Some(resolve_pr(target).await?),
(None, Some(target)) => Some(resolve_open(target).await?),
(None, None) => None,
};
let theme = build_theme(&config, cli.theme)?;
let icons = Icons {
nerd_font: config.ui.nerd_font,
};
let store = Store::open(&cache_dir.join("cache.redb"));
let (token, source) = token
.join()
.map_err(|_| anyhow::anyhow!("token resolution panicked"))??;
tracing::info!(%source, "authenticated");
let store = store.for_account(host.name(), &token.fingerprint());
let reviews = open_reviews().map(|r| r.for_account(store.account().clone()));
let git = GitContext {
cache_root: cache_dir.join(host.name()),
credentials: git_credentials(&token, source),
cwd: std::env::current_dir()?,
};
let gh = GitHub::new(token, store, host);
let size = crossterm::terminal::size().unwrap_or((80, 24));
let mut state = State::new(theme, icons, keymap, size);
state.density = config.ui.density;
state.offline.limits = config.offline;
state.viewer = gh.store().account().login();
if let Err(err) = &reviews {
state.problems.insert(
state::Problem::Drafts,
format!("Drafts are not being saved: {err}"),
);
}
state.home = home::sections(config.home.as_deref());
state.config_path = config_path.clone();
for key in state.home_needs().into_iter().filter(DataKey::cache_first) {
let cached = data::load::cached(&gh, &key);
state.data.insert(key, Remote::cached(cached));
}
let tabs = gh
.cached::<Vec<String>>(crate::store::TABS)
.map(|c| c.value)
.unwrap_or_default();
state.restore_tabs(&tabs);
let mut cmds = match target {
Some(at) => {
if let Target::Page(Route::Pulls(pulls::Pr { pr, .. }))
| Target::Files(DiffOf::Pr(pr)) = &at.target
{
let key = DataKey::Pr(pr.clone());
let cached = data::load::cached(&gh, &key);
state.data.insert(key, Remote::cached(cached));
}
let mut cmds = vec![state::Cmd::Api(Api::FetchViewer)];
cmds.extend(state.start_at(at));
cmds
}
None => state.load_visible(true),
};
state.visits = gh
.cached::<Vec<nav::Visit>>(crate::store::VISITS)
.map(|c| c.value)
.unwrap_or_default();
cmds.push(state::Cmd::Timer(state::Timer::Minute, 60_000));
cmds.extend(state.ensure(crate::data::DataKey::Unread, true));
state.sync_page();
install_panic_hook();
let mut terminal =
std::mem::ManuallyDrop::new(init_terminal().context("needs an interactive terminal")?);
capture_input(true);
let result = runtime::run(&mut terminal, state, gh, git, reviews, cmds, started).await;
restore_terminal();
if let Err(err) = &result {
tracing::error!("{err:#}");
}
result
}
fn open_reviews() -> Result<crate::store::Reviews, String> {
let dir = config::data_dir().ok_or("cannot determine a data directory")?;
crate::store::Reviews::open(&dir.join("reviews")).map_err(|e| e.to_string())
}
fn git_credentials(
token: &crate::api::auth::Token,
source: crate::api::auth::TokenSource,
) -> crate::git::credentials::Credentials {
use crate::git::credentials::Credentials;
match (source, std::env::current_exe()) {
(crate::api::auth::TokenSource::GhCli, _) => Credentials::GhHelper,
(_, Ok(program)) => Credentials::AskPass {
program,
token: token.expose().to_owned(),
},
(_, Err(err)) => {
tracing::warn!(%err, "cannot locate ghtui for GIT_ASKPASS; using git's own credentials");
Credentials::Ambient
}
}
}
#[expect(clippy::print_stdout, reason = "git reads the answer from stdout")]
fn askpass() -> std::process::ExitCode {
use crate::git::credentials::{ASKPASS_TOKEN, askpass_answer};
let prompt = std::env::args().nth(1).unwrap_or_default();
match std::env::var(ASKPASS_TOKEN) {
Ok(token) if !token.is_empty() => {
println!("{}", askpass_answer(&prompt, &token));
std::process::ExitCode::SUCCESS
}
_ => std::process::ExitCode::FAILURE,
}
}
async fn resolve_target(target: &str) -> Result<PrRef> {
if let Some(pr) = PrRef::parse(target) {
return Ok(pr);
}
let Ok(number) = target.trim_start_matches('#').parse::<u64>() else {
bail!(
"`{target}` is not a pull request; use owner/repo#123, a PR URL, or a number inside a clone"
);
};
let cwd = std::env::current_dir()?;
let remotes = crate::git::remotes(&cwd).await?;
let repo = crate::git::infer_github_repo(&remotes).with_context(|| {
format!(
"not inside a clone with a {} remote; use owner/repo#123",
host::current()
)
})?;
Ok(PrRef {
repo: RepoId::new(repo.owner, repo.name),
number,
})
}
async fn resolve_pr(target: &str) -> Result<Dest> {
let dest = Dest::from_url(target);
match dest.target {
Target::Page(Route::Pulls(pulls::Pr { .. })) | Target::Files(DiffOf::Pr(_)) => Ok(dest),
_ => Ok(Target::Page(Route::pr(resolve_target(target).await?)).into()),
}
}
async fn resolve_open(target: &str) -> Result<Dest> {
let number = target.trim_start_matches('#');
if !number.is_empty() && number.chars().all(|c| c.is_ascii_digit()) {
let pr = resolve_target(number).await?;
return Ok(Target::Page(Route::Issues(issues::Issue {
repo: pr.repo,
number: pr.number,
}))
.into());
}
let shown = route::parse_input(target, None)
.filter(|dest| !matches!(dest.target, route::Target::External(_)));
shown.with_context(|| {
format!(
"can't open `{target}`; use owner/repo, owner/repo#123, @user, or a {} URL",
host::current()
)
})
}
fn build_theme(config: &Config, cli_mode: Option<ModeSetting>) -> Result<Theme> {
let seed = config.seed()?;
let depth = match config.theme.color_depth {
DepthSetting::Auto => crate::theme::detect_color_depth(|k| std::env::var(k).ok()),
DepthSetting::TrueColor => ColorDepth::TrueColor,
DepthSetting::Ansi256 => ColorDepth::Ansi256,
};
let mode = match cli_mode.unwrap_or(config.theme.mode) {
ModeSetting::Light => Mode::Light,
ModeSetting::Dark => Mode::Dark,
ModeSetting::Auto => crate::theme::detect_background(BACKGROUND_QUERY_TIMEOUT)
.map_or(Mode::Dark, crate::theme::mode_for_background),
};
tracing::info!(?mode, ?depth, %seed, "theme");
Ok(Theme::new(seed, mode, depth))
}
fn init_logging(
cache_dir: &std::path::Path,
) -> Option<tracing_appender::non_blocking::WorkerGuard> {
use tracing_subscriber::EnvFilter;
std::fs::create_dir_all(cache_dir).ok()?;
let appender = tracing_appender::rolling::never(cache_dir, "ghtui.log");
let (writer, guard) = tracing_appender::non_blocking(appender);
let filter = EnvFilter::try_from_env("GHTUI_LOG")
.unwrap_or_else(|_| EnvFilter::new("info,hyper=warn,hyper_util=warn,reqwest=warn"));
tracing_subscriber::fmt()
.with_writer(writer)
.with_ansi(false)
.with_env_filter(filter)
.try_init()
.ok()?;
Some(guard)
}
pub fn capture_input(on: bool) {
use crossterm::ExecutableCommand;
use crossterm::event::{
DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste,
EnableFocusChange, EnableMouseCapture,
};
let mut out = std::io::stdout();
let result = if on {
(out.execute(EnableMouseCapture))
.and_then(|out| out.execute(EnableBracketedPaste))
.and_then(|out| out.execute(EnableFocusChange))
.map(drop)
} else {
(out.execute(DisableMouseCapture))
.and_then(|out| out.execute(DisableBracketedPaste))
.and_then(|out| out.execute(DisableFocusChange))
.map(drop)
};
if let Err(err) = result {
tracing::warn!(%err, "capturing input");
}
}
pub fn restore_terminal() {
use crossterm::ExecutableCommand;
capture_input(false);
let result = std::io::stdout()
.execute(crossterm::cursor::Show)
.map(drop)
.and_then(|()| ratatui::try_restore());
if let Err(err) = result {
tracing::warn!(%err, "restoring the terminal");
}
}
fn init_terminal() -> std::io::Result<ratatui::DefaultTerminal> {
use crossterm::ExecutableCommand;
crossterm::terminal::enable_raw_mode()?;
let result = std::io::stdout()
.execute(crossterm::terminal::EnterAlternateScreen)
.and_then(|_| {
ratatui::Terminal::new(ratatui::backend::CrosstermBackend::new(std::io::stdout()))
});
if result.is_err() {
restore_terminal();
}
result
}
fn install_panic_hook() {
let main = std::thread::current().id();
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
tracing::error!("panic: {info}");
if std::thread::current().id() == main {
restore_terminal();
previous(info);
}
}));
}