mirror of
https://github.com/gitui-org/gitui
synced 2026-05-23 17:08:21 +00:00
feat: render TUI on /dev/tty for editor embedding (Helix)
When stdout is not a terminal (e.g. Helix :insert-output), use /dev/tty on Unix so gitui can run interactively. Add --tty to force this behavior. Fixes #2556 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
8619c07f3f
commit
961ac03ad2
5 changed files with 144 additions and 26 deletions
12
src/args.rs
12
src/args.rs
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
40
src/main.rs
40
src/main.rs
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"];
|
||||
|
|
|
|||
104
src/terminal_io.rs
Normal file
104
src/terminal_io.rs
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
//! 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 {
|
||||
if let Ok(file) = std::fs::File::open("/dev/tty") {
|
||||
return Ok(Self::Tty(file));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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(())
|
||||
})
|
||||
}
|
||||
Loading…
Reference in a new issue