This commit is contained in:
吴杨帆 2026-05-17 03:00:56 +00:00 committed by GitHub
commit e597386c0c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 164 additions and 26 deletions

View file

@ -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";
@ -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> {
@ -92,6 +95,8 @@ 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,
@ -99,6 +104,7 @@ pub fn process_cmdline() -> Result<CliArgs> {
notify_watcher,
key_bindings_path,
key_symbols_path,
use_tty,
})
}
@ -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")

View file

@ -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());

View file

@ -77,12 +77,14 @@ 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;
@ -90,10 +92,7 @@ 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;
@ -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);
@ -174,6 +173,12 @@ 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();
@ -181,8 +186,10 @@ fn main() -> Result<()> {
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
@ -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,
@ -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}");
}
}
@ -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()?;
@ -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()

View file

@ -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},
@ -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 {
@ -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"];

124
src/terminal_io.rs Normal file
View file

@ -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);
}
}