From 2b438a09727499eecf8dc62a97f9bdcdf9c83694 Mon Sep 17 00:00:00 2001 From: Ellie Huxtable Date: Tue, 23 Apr 2024 14:02:30 +0100 Subject: [PATCH] wip --- Cargo.lock | 15 ++ Cargo.toml | 2 + crates/atuin-run/Cargo.toml | 5 + crates/atuin-run/src/markdown.rs | 2 +- crates/atuin-run/src/pty.rs | 226 +++++++++++++++++-------- crates/atuin/Cargo.toml | 5 +- crates/atuin/src/command/client.rs | 6 +- crates/atuin/src/command/client/run.rs | 49 +++--- 8 files changed, 211 insertions(+), 99 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 890ff37a..1c8c4b75 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -394,9 +394,14 @@ dependencies = [ name = "atuin-run" version = "0.1.0" dependencies = [ + "bytes", "comrak", + "crossterm", "eyre", "portable-pty", + "ratatui", + "tokio", + "tui-term", "vt100", ] @@ -4561,6 +4566,16 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tui-term" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c276fa6f3dbd87715b2124b55e3f7479e9b5f632704a37bf6660bdb1410420d2" +dependencies = [ + "ratatui", + "vt100", +] + [[package]] name = "typed-arena" version = "2.0.2" diff --git a/Cargo.toml b/Cargo.toml index 8c3df8ba..da4ec6b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,7 +40,9 @@ typed-builder = "0.18.2" pretty_assertions = "1.3.0" thiserror = "1.0" rustix = { version = "0.38.34", features = ["process", "fs"] } +crossterm = { version = "0.27", features = ["use-dev-tty"] } tower = "0.4" +ratatui = "0.26" tracing = "0.1" [workspace.dependencies.tracing-subscriber] diff --git a/crates/atuin-run/Cargo.toml b/crates/atuin-run/Cargo.toml index 41207780..07171656 100644 --- a/crates/atuin-run/Cargo.toml +++ b/crates/atuin-run/Cargo.toml @@ -13,6 +13,11 @@ readme.workspace = true [dependencies] eyre.workspace = true +ratatui.workspace = true +tokio.workspace = true +crossterm.workspace = true comrak = "0.22" portable-pty = "0.8.1" vt100 = "0.15.2" +tui-term = "0.1.10" +bytes = "1.6.0" diff --git a/crates/atuin-run/src/markdown.rs b/crates/atuin-run/src/markdown.rs index f3e3c0c2..4485e540 100644 --- a/crates/atuin-run/src/markdown.rs +++ b/crates/atuin-run/src/markdown.rs @@ -4,7 +4,7 @@ use comrak::{ parse_document, Arena, ComrakOptions, }; -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct Block { pub info: String, pub code: String, diff --git a/crates/atuin-run/src/pty.rs b/crates/atuin-run/src/pty.rs index 998dfa34..49476c06 100644 --- a/crates/atuin-run/src/pty.rs +++ b/crates/atuin-run/src/pty.rs @@ -1,96 +1,178 @@ /// Create and manage pseudoterminals -use eyre::Result; +use std::{ + io::{self, BufWriter, Read, Write}, + sync::{Arc, RwLock}, + time::Duration, +}; + +use bytes::Bytes; +use crossterm::{ + event::{self, Event, KeyCode, KeyEventKind}, + execute, + style::ResetColor, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem}; -use std::sync::mpsc::channel; +use ratatui::{ + backend::{Backend, CrosstermBackend}, + layout::Alignment, + style::{Modifier, Style}, + widgets::{Block, Borders, Paragraph}, + Frame, Terminal, +}; +use tokio::{ + sync::mpsc::{channel, Sender}, + task, +}; +use tui_term::widget::PseudoTerminal; use vt100::Screen; -/// Run a command in a pty, return output. Pty is closed once the command has completed. -/// If a child process would work, prefer that approach - this is a bit slower and heavier. -pub fn run_pty() -> Result { +#[derive(Debug)] +struct Size { + cols: u16, + rows: u16, +} + +pub async fn run_pty(blocks: Vec) -> io::Result<()> { + let mut stdout = io::stdout(); + execute!(stdout, ResetColor)?; + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + let pty_system = NativePtySystem::default(); + let cwd = std::env::current_dir().unwrap(); + let mut cmd = CommandBuilder::new_default_prog(); + cmd.cwd(cwd); + + let size = Size { + rows: terminal.size()?.height, + cols: terminal.size()?.width, + }; let pair = pty_system .openpty(PtySize { - rows: 24, - cols: 80, + rows: size.rows, + cols: size.cols, pixel_width: 0, pixel_height: 0, }) .unwrap(); - - let cmd = CommandBuilder::new("bash"); - let mut child = pair.slave.spawn_command(cmd).unwrap(); - - // Release any handles owned by the slave: we don't need it now - // that we've spawned the child. - drop(pair.slave); - - // Read the output in another thread. - // This is important because it is easy to encounter a situation - // where read/write buffers fill and block either your process - // or the spawned process. - let (tx, rx) = channel(); - let mut reader = pair.master.try_clone_reader().unwrap(); - - std::thread::spawn(move || { - // Consume the output from the child - let mut s = String::new(); - reader.read_to_string(&mut s).unwrap(); - tx.send(s).unwrap(); + // Wait for the child to complete + task::spawn_blocking(move || { + let mut child = pair.slave.spawn_command(cmd).unwrap(); + let _child_exit_status = child.wait().unwrap(); + drop(pair.slave); }); + let mut reader = pair.master.try_clone_reader().unwrap(); + let parser = Arc::new(RwLock::new(vt100::Parser::new(size.rows, size.cols, 0))); + { - // Obtain the writer. - // When the writer is dropped, EOF will be sent to - // the program that was spawned. - // It is important to take the writer even if you don't - // send anything to its stdin so that EOF can be - // generated, otherwise you risk deadlocking yourself. - let mut writer = pair.master.take_writer().unwrap(); + let parser = parser.clone(); + task::spawn_blocking(move || { + // Consume the output from the child + // Can't read the full buffer, since that would wait for EOF + let mut buf = [0u8; 8192]; + let mut processed_buf = Vec::new(); + loop { + let size = reader.read(&mut buf).unwrap(); + if size == 0 { + break; + } + if size > 0 { + processed_buf.extend_from_slice(&buf[..size]); + let mut parser = parser.write().unwrap(); + parser.process(&processed_buf); - if cfg!(target_os = "macos") { - // macOS quirk: the child and reader must be started and - // allowed a brief grace period to run before we allow - // the writer to drop. Otherwise, the data we send to - // the kernel to trigger EOF is interleaved with the - // data read by the reader! WTF!? - // This appears to be a race condition for very short - // lived processes on macOS. - // I'd love to find a more deterministic solution to - // this than sleeping. - std::thread::sleep(std::time::Duration::from_millis(20)); + // Clear the processed portion of the buffer + processed_buf.clear(); + } + } + }); + } + + let (tx, mut rx) = channel::(32); + + let mut writer = BufWriter::new(pair.master.take_writer().unwrap()); + + // Drop writer on purpose + tokio::spawn(async move { + while let Some(bytes) = rx.recv().await { + writer.write_all(&bytes).unwrap(); + writer.flush().unwrap(); } + drop(pair.master); + }); - // This example doesn't need to write anything, but if you - // want to send data to the child, you'd set `to_write` to - // that data and do it like this: - let to_write = "echo 'omg the pty DID SOMETHING'\r\nexit\r\n"; - if !to_write.is_empty() { - // To avoid deadlock, wrt. reading and waiting, we send - // data to the stdin of the child in a different thread. - std::thread::spawn(move || { - writer.write_all(to_write.as_bytes()).unwrap(); - }); + println!("{blocks:?}"); + run(&mut terminal, parser, tx, blocks).await?; + + tokio::time::sleep(Duration::from_secs(2)).await; + + // restore terminal + disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen,)?; + terminal.show_cursor()?; + + Ok(()) +} + +async fn run( + terminal: &mut Terminal, + parser: Arc>, + sender: Sender, + blocks: Vec, +) -> io::Result<()> { + tokio::time::sleep(Duration::from_millis(500)).await; + + for i in blocks { + terminal.draw(|f| ui(f, parser.read().unwrap().screen()))?; + + tokio::time::sleep(Duration::from_millis(500)).await; + + for line in i.code.lines() { + let b = Bytes::from(line.trim_end().to_string().into_bytes()); + + sender.send(b).await; + sender.send(Bytes::from(vec![b'\n'])).await; + + terminal.draw(|f| ui(f, parser.read().unwrap().screen()))?; + tokio::time::sleep(Duration::from_millis(500)).await; } } - // Wait for the child to complete - println!("child status: {:?}", child.wait().unwrap()); + terminal.draw(|f| ui(f, parser.read().unwrap().screen()))?; - // Take care to drop the master after our processes are - // done, as some platforms get unhappy if it is dropped - // sooner than that. - drop(pair.master); + tokio::time::sleep(Duration::from_millis(1500)).await; - // Now wait for the output to be read by our reader thread - let output = rx.recv().unwrap(); - - // We print with escapes escaped because the windows conpty - // implementation synthesizes title change escape sequences - // in the output stream and it can be confusing to see those - // printed out raw in another terminal. - let out = output.to_string(); - println!("{out}"); - - Ok("".to_string()) + Ok(()) +} + +fn ui(f: &mut Frame, screen: &Screen) { + let chunks = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Vertical) + .margin(1) + .constraints( + [ + ratatui::layout::Constraint::Percentage(100), + ratatui::layout::Constraint::Min(1), + ] + .as_ref(), + ) + .split(f.size()); + let block = Block::default() + .borders(Borders::ALL) + .style(Style::default().add_modifier(Modifier::BOLD)); + + let pseudo_term = PseudoTerminal::new(screen).block(block); + f.render_widget(pseudo_term, chunks[0]); + + let explanation = "Press q to exit".to_string(); + let explanation = Paragraph::new(explanation) + .style(Style::default().add_modifier(Modifier::BOLD | Modifier::REVERSED)) + .alignment(Alignment::Center); + f.render_widget(explanation, chunks[1]); } diff --git a/crates/atuin/Cargo.toml b/crates/atuin/Cargo.toml index 7329c18d..43e25430 100644 --- a/crates/atuin/Cargo.toml +++ b/crates/atuin/Cargo.toml @@ -51,6 +51,9 @@ atuin-history = { path = "../atuin-history", version = "0.1.0" } atuin-daemon = { path = "../atuin-daemon", version = "0.1.0" } atuin-run = { path = "../atuin-run", version = "0.1.0" } +ratatui.workspace = true +crossterm.workspace = true + log = { workspace = true } env_logger = "0.11.2" time = { workspace = true } @@ -59,7 +62,6 @@ directories = { workspace = true } indicatif = "0.17.5" serde = { workspace = true } serde_json = { workspace = true } -crossterm = { version = "0.27", features = ["use-dev-tty"] } unicode-width = "0.1" itertools = { workspace = true } tokio = { workspace = true } @@ -79,7 +81,6 @@ tiny-bip39 = "1" futures-util = "0.3" fuzzy-matcher = "0.3.7" colored = "2.0.4" -ratatui = "0.26" tracing = "0.1" tracing-subscriber = { workspace = true } uuid = { workspace = true } diff --git a/crates/atuin/src/command/client.rs b/crates/atuin/src/command/client.rs index 7867e16d..ae5c58d0 100644 --- a/crates/atuin/src/command/client.rs +++ b/crates/atuin/src/command/client.rs @@ -82,8 +82,8 @@ pub enum Cmd { Daemon, /// Execute a runbook or workflow - #[command()] - Run, + #[command(subcommand)] + Run(run::Cmd), /// Print example configuration #[command()] @@ -155,7 +155,7 @@ impl Cmd { Self::Doctor => doctor::run(&settings).await, - Self::Run => run::run(), + Self::Run(r) => r.run().await, Self::DefaultConfig => { default_config::run(); diff --git a/crates/atuin/src/command/client/run.rs b/crates/atuin/src/command/client/run.rs index 4dcdf18b..5d197fdb 100644 --- a/crates/atuin/src/command/client/run.rs +++ b/crates/atuin/src/command/client/run.rs @@ -1,25 +1,32 @@ -use eyre::Result; +use std::path::PathBuf; + +use clap::Subcommand; +use eyre::{eyre, Result}; use atuin_run::{markdown::parse, pty::run_pty}; +use rustix::path::Arg; -pub fn run() -> Result<()> { - let blocks = parse( - " -1. do a thing -```sh -echo 'foo' -``` - -2. do another thing -```sh -echo 'bar' -``` -", - ); - - println!("{:?}", blocks); - - run_pty(); - - Ok(()) +#[derive(Debug, Subcommand)] +pub enum Cmd { + Markdown { path: String }, +} + +impl Cmd { + pub async fn run(&self) -> Result<()> { + match self { + Cmd::Markdown { path } => { + let file = PathBuf::from(path); + + if !file.exists() { + return Err(eyre!("File does not exist at {path}")); + } + + let md = tokio::fs::read_to_string(file).await?; + let blocks = parse(md.as_str()); + run_pty(blocks).await?; + } + } + + Ok(()) + } }