ghtui 0.2.0

A keyboard-driven terminal client for GitHub, with a pull request diff viewer that goes further than GitHub's
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
//! `ghtui`: a keyboard-driven terminal client for GitHub. The binary runs
//! [`main`]; the library is there so fuzz targets can reach its parts.

// The parts that know nothing of the app: GitHub, diffs, git, storage,
// the theme and drawing.
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};

/// How long to wait for the terminal to report its background color.
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>,
    /// What to open: owner/repo, owner/repo#123, @user, a GitHub URL, or
    /// #123 inside a clone. Without it, ghtui opens your home page.
    target: Option<String>,
    /// Config file (default: ~/.config/ghtui/config.toml).
    #[arg(long, global = true)]
    config: Option<PathBuf>,
    /// Color scheme; overrides the config file.
    #[arg(long, global = true, value_enum)]
    theme: Option<ModeSetting>,
}

#[derive(Subcommand)]
enum Command {
    /// Open a pull request.
    Pr {
        /// `owner/repo#123`, a pull request URL, or `123` inside a clone.
        target: String,
    },
}

#[expect(
    clippy::print_stderr,
    reason = "errors that end ghtui, before or after the terminal is taken over"
)]
/// Runs ghtui (or, when git asks, its credential helper).
pub fn main() -> std::process::ExitCode {
    // git runs this binary as its GIT_ASKPASS helper for the cache clone.
    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));
    // Don't wait on background work (a big diff, a stalled fetch) to exit.
    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);
    // Token resolution may run `gh` (~40ms); overlap it with the rest of
    // startup.
    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 {
        // Clones are per host: `o/r` here isn't `o/r` on another GitHub.
        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;
    // Whose pages these are is known before GitHub answers (if it can).
    state.viewer = gh.store().account().login();
    if let Err(err) = &reviews {
        state.problems.insert(
            state::Problem::Drafts,
            format!("Drafts are not being saved: {err}"),
        );
    }
    // Home's sections, shown from the cache at once.
    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));
            }
            // The home page stays underneath (Esc goes there) and loads
            // when you get there. With tabs reopened, it's a new tab after
            // them.
            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();
    // Never dropped: `Terminal`'s drop prints to stderr when it can't show
    // the cursor, which panics when the terminal is gone.
    // `restore_terminal` shows it instead.
    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
}

/// Review drafts' home.
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())
}

/// How git authenticates for the cache clone: through `gh` when that's
/// where the token came from, otherwise through this binary as askpass.
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
        }
    }
}

/// `GIT_ASKPASS` mode: answer git's prompt from the environment ghtui set
/// on the git process, then exit.
#[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,
    }
}

/// `owner/repo#N`, a URL, or a bare number resolved against this clone's
/// GitHub remote (`upstream` first, for forks).
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,
    })
}

/// The pull request `ghtui pr` opens: at the tab and spot its URL names
/// (Files changed for `/files`), else its conversation.
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()),
    }
}

/// The page to open from the command line.
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,
        // Must happen before the alternate screen and raw mode.
        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))
}

/// Logs to `<cache>/ghtui.log`, never to the terminal. Level from
/// `GHTUI_LOG` (default `info`).
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)
}

/// Clicks, the wheel, pastes (as one piece of text, not keys) and focus
/// go to ghtui while it runs.
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");
    }
}

/// Leaves raw mode, the alternate screen and input capture, and shows
/// the cursor. Failures are logged, never printed: when the terminal is gone
/// (a closed pane sends SIGHUP), printing to it panics.
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");
    }
}

/// Raw mode and the alternate screen, like `ratatui::init` but without its
/// panic hook (see [`install_panic_hook`]).
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
}

/// Logs every panic. Only a panic on the main thread (the UI loop) ends
/// ghtui, so only that one restores the terminal and prints. Background
/// tasks catch their own panics (`runtime::spawn_guarded`) and keep the
/// session running; printing those would scribble over the screen.
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);
        }
    }));
}