Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const WORKDIR_FLAG_ID: &str = "workdir";
const FILE_FLAG_ID: &str = "file";
const GIT_DIR_FLAG_ID: &str = "directory";
const WATCHER_FLAG_ID: &str = "watcher";
const TTY_FLAG_ID: &str = "tty";
const KEY_BINDINGS_FLAG_ID: &str = "key_bindings";
const KEY_SYMBOLS_FLAG_ID: &str = "key_symbols";
const DEFAULT_THEME: &str = "theme.ron";
Expand All @@ -33,6 +34,8 @@ pub struct CliArgs {
pub notify_watcher: bool,
pub key_bindings_path: Option<PathBuf>,
pub key_symbols_path: Option<PathBuf>,
/// Render the TUI on `/dev/tty` instead of stdout (Unix only).
pub use_tty: bool,
}

pub fn process_cmdline() -> Result<CliArgs> {
Expand Down Expand Up @@ -92,13 +95,16 @@ pub fn process_cmdline() -> Result<CliArgs> {
.get_one::<String>(KEY_SYMBOLS_FLAG_ID)
.map(PathBuf::from);

let use_tty = arg_matches.get_flag(TTY_FLAG_ID);

Ok(CliArgs {
theme,
select_file,
repo_path,
notify_watcher,
key_bindings_path,
key_symbols_path,
use_tty,
})
}

Expand Down Expand Up @@ -161,6 +167,12 @@ fn app() -> ClapApp {
.long("watcher")
.action(clap::ArgAction::SetTrue),
)
.arg(
Arg::new(TTY_FLAG_ID)
.help("Render on /dev/tty instead of stdout (Unix). When stdout is not a terminal, /dev/tty is used automatically for editor embedding (e.g. Helix :insert-output).")
.long("tty")
.action(clap::ArgAction::SetTrue),
)
.arg(
Arg::new(BUG_REPORT_FLAG_ID)
.help("Generate a bug report")
Expand Down
1 change: 1 addition & 0 deletions src/gitui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ mod tests {
notify_watcher: false,
key_bindings_path: None,
key_symbols_path: None,
use_tty: false,
};

let theme = Theme::init(&PathBuf::new());
Expand Down
40 changes: 22 additions & 18 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,23 +77,22 @@ mod spinner;
mod string_utils;
mod strings;
mod tabs;
mod terminal_io;
mod ui;
mod watcher;

use crate::{
app::App,
args::{process_cmdline, CliArgs},
terminal_io::{SharedTerminalWriter, TerminalWriter},
};
use anyhow::{anyhow, bail, Result};
use app::QuitState;
use asyncgit::{sync::RepoPath, AsyncGitNotification};
use backtrace::Backtrace;
use crossbeam_channel::{Receiver, Select};
use crossterm::{
terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen,
LeaveAlternateScreen,
},
terminal::{disable_raw_mode, enable_raw_mode},
ExecutableCommand,
};
use gitui::Gitui;
Expand All @@ -102,14 +101,14 @@ use keys::KeyConfig;
use ratatui::backend::CrosstermBackend;
use scopeguard::defer;
use std::{
io::{self, Stdout},
panic,
path::Path,
sync::{Arc, Mutex},
time::{Duration, Instant},
};
use ui::style::Theme;

type Terminal = ratatui::Terminal<CrosstermBackend<io::Stdout>>;
type Terminal = ratatui::Terminal<CrosstermBackend<SharedTerminalWriter>>;

static TICK_INTERVAL: Duration = Duration::from_secs(5);
static SPINNER_INTERVAL: Duration = Duration::from_millis(80);
Expand Down Expand Up @@ -174,15 +173,23 @@ fn main() -> Result<()> {
.unwrap_or_default();
let theme = Theme::init(&cliargs.theme);

let terminal_writer = Arc::new(Mutex::new(TerminalWriter::open(
cliargs.use_tty,
)?));
terminal_io::init(Arc::clone(&terminal_writer))
.map_err(|_| anyhow!("terminal writer already initialized"))?;

setup_terminal()?;
defer! {
shutdown_terminal();
}

set_panic_handler()?;

let mut terminal =
start_terminal(io::stdout(), &cliargs.repo_path)?;
let mut terminal = start_terminal(
SharedTerminalWriter(Arc::clone(&terminal_writer)),
&cliargs.repo_path,
)?;

let updater = if cliargs.notify_watcher {
Updater::NotifyWatcher
Expand Down Expand Up @@ -211,6 +218,7 @@ fn main() -> Result<()> {
notify_watcher: args.notify_watcher,
key_bindings_path: args.key_bindings_path,
key_symbols_path: args.key_symbols_path,
use_tty: args.use_tty,
}
}
_ => break,
Expand All @@ -237,21 +245,17 @@ fn run_app(

fn setup_terminal() -> Result<()> {
enable_raw_mode()?;
io::stdout().execute(EnterAlternateScreen)?;
terminal_io::execute(crossterm::terminal::EnterAlternateScreen)?;
Ok(())
}

fn shutdown_terminal() {
let leave_screen =
io::stdout().execute(LeaveAlternateScreen).map(|_f| ());

if let Err(e) = leave_screen {
if let Err(e) = terminal_io::execute(crossterm::terminal::LeaveAlternateScreen)
{
log::error!("leave_screen failed:\n{e}");
}

let leave_raw_mode = disable_raw_mode();

if let Err(e) = leave_raw_mode {
if let Err(e) = disable_raw_mode() {
log::error!("leave_raw_mode failed:\n{e}");
}
}
Expand Down Expand Up @@ -321,7 +325,7 @@ fn select_event(
}

fn start_terminal(
buf: Stdout,
writer: SharedTerminalWriter,
repo_path: &RepoPath,
) -> Result<Terminal> {
let mut path = repo_path.gitpath().canonicalize()?;
Expand All @@ -335,7 +339,7 @@ fn start_terminal(
path = Path::new("~").join(relative_part);
}

let mut backend = CrosstermBackend::new(buf);
let mut backend = CrosstermBackend::new(writer);
backend.execute(crossterm::terminal::SetTitle(format!(
"gitui ({})",
path.display()
Expand Down
13 changes: 5 additions & 8 deletions src/popups/externaleditor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,7 @@ use anyhow::{anyhow, bail, Result};
use asyncgit::sync::{
get_config_string, utils::repo_work_dir, RepoPath,
};
use crossterm::{
event::Event,
terminal::{EnterAlternateScreen, LeaveAlternateScreen},
ExecutableCommand,
};
use crossterm::event::Event;
use ratatui::{
layout::Rect,
text::{Line, Span},
Expand All @@ -25,7 +21,7 @@ use ratatui::{
};
use scopeguard::defer;
use std::ffi::OsStr;
use std::{env, io, path::Path, process::Command};
use std::{env, path::Path, process::Command};

///
pub struct ExternalEditorPopup {
Expand Down Expand Up @@ -61,9 +57,10 @@ impl ExternalEditorPopup {
bail!("file not found: {path:?}");
}

io::stdout().execute(LeaveAlternateScreen)?;
crate::terminal_io::execute(crossterm::terminal::LeaveAlternateScreen)?;
defer! {
io::stdout().execute(EnterAlternateScreen).expect("reset terminal");
crate::terminal_io::execute(crossterm::terminal::EnterAlternateScreen)
.expect("reset terminal");
}

let environment_options = ["GIT_EDITOR", "VISUAL", "EDITOR"];
Expand Down
124 changes: 124 additions & 0 deletions src/terminal_io.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
//! Terminal output selection for embedding gitui in editors (e.g. Helix).

use crossterm::{Command, ExecutableCommand};
use std::{
io::{self, IsTerminal, Stdout, Write},
sync::{Arc, Mutex, OnceLock},
};

/// The output stream used for the TUI (stdout or `/dev/tty` on Unix).
pub enum TerminalWriter {
Stdout(Stdout),
#[cfg(unix)]
Tty(std::fs::File),
}

impl TerminalWriter {
/// Opens the terminal output stream.
///
/// On Unix, when `force_tty` is set or stdout is not a terminal, `/dev/tty`
/// is used so interactive programs work when stdout is captured (e.g. Helix
/// `:insert-output`).
pub fn open(force_tty: bool) -> io::Result<Self> {
#[cfg(unix)]
{
let use_tty = force_tty || !io::stdout().is_terminal();
if use_tty {
match std::fs::File::open("/dev/tty") {
Ok(file) => return Ok(Self::Tty(file)),
Err(err) if force_tty => return Err(err),
Err(_) => {}
}
}
}

#[cfg(not(unix))]
if force_tty {
return Err(io::Error::new(
io::ErrorKind::Unsupported,
"--tty is only supported on Unix",
));
}

Ok(Self::Stdout(io::stdout()))
}
}

impl Write for TerminalWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
match self {
Self::Stdout(writer) => writer.write(buf),
#[cfg(unix)]
Self::Tty(writer) => writer.write(buf),
}
}

fn flush(&mut self) -> io::Result<()> {
match self {
Self::Stdout(writer) => writer.flush(),
#[cfg(unix)]
Self::Tty(writer) => writer.flush(),
}
}
}

/// Shared handle to the terminal writer for crossterm/ratatui and shutdown hooks.
#[derive(Clone)]
pub struct SharedTerminalWriter(pub Arc<Mutex<TerminalWriter>>);

impl Write for SharedTerminalWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.lock().expect("terminal writer poisoned").write(buf)
}

fn flush(&mut self) -> io::Result<()> {
self.0
.lock()
.expect("terminal writer poisoned")
.flush()
}
}

static TERMINAL_WRITER: OnceLock<Arc<Mutex<TerminalWriter>>> = OnceLock::new();

/// Registers the process-wide terminal writer (call once at startup).
pub fn init(writer: Arc<Mutex<TerminalWriter>>) -> Result<(), Arc<Mutex<TerminalWriter>>> {
TERMINAL_WRITER.set(writer)
}

/// Runs a closure against the terminal writer.
pub fn with_writer<F, R>(f: F) -> io::Result<R>
where
F: FnOnce(&mut TerminalWriter) -> io::Result<R>,
{
let writer = TERMINAL_WRITER
.get()
.ok_or_else(|| io::Error::other("terminal writer not initialized"))?;
f(&mut writer.lock().expect("terminal writer poisoned"))
}

/// Executes a crossterm command on the active terminal writer.
pub fn execute<C: Command>(cmd: C) -> io::Result<()> {
with_writer(|writer| {
writer.execute(cmd)?;
Ok(())
})
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn open_stdout_when_not_forcing_tty() {
let writer = TerminalWriter::open(false).unwrap();
assert!(matches!(writer, TerminalWriter::Stdout(_)));
}

#[test]
#[cfg(not(unix))]
fn open_tty_errors_on_non_unix() {
let err = TerminalWriter::open(true).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::Unsupported);
}
}