From f3dc8d7c38264b97928799c235d7f159cf43a1c2 Mon Sep 17 00:00:00 2001 From: Ellie Huxtable Date: Tue, 23 Apr 2024 14:02:30 +0100 Subject: wip --- Cargo.lock | 62 +++++- Cargo.toml | 2 + crates/atuin-dotfiles/src/shell.rs | 100 ++++++++-- crates/atuin-run/Cargo.toml | 5 + crates/atuin-run/src/markdown.rs | 2 +- crates/atuin-run/src/pty.rs | 220 ++++++++++++++------- crates/atuin/Cargo.toml | 4 +- crates/atuin/src/command/client.rs | 6 +- crates/atuin/src/command/client/run.rs | 41 ++-- .../atuin/src/command/client/search/interactive.rs | 2 +- 10 files changed, 331 insertions(+), 113 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cef6f4f7..269376d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -317,9 +317,14 @@ dependencies = [ name = "atuin-run" version = "0.1.0" dependencies = [ + "bytes", "comrak", + "crossterm", "eyre", "portable-pty", + "ratatui", + "tokio", + "tui-term", "vt100", ] @@ -610,6 +615,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" +[[package]] +name = "castaway" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a17ed5635fc8536268e5d4de1e22e81ac34419e5f052d4d51f4e01dcc263fcc" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.0.92" @@ -784,6 +798,19 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "compact_str" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "ryu", + "static_assertions", +] + [[package]] name = "comrak" version = "0.22.0" @@ -2897,12 +2924,13 @@ dependencies = [ [[package]] name = "ratatui" -version = "0.25.0" +version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5659e52e4ba6e07b2dad9f1158f578ef84a73762625ddb51536019f34d180eb" +checksum = "a564a852040e82671dc50a37d88f3aa83bbc690dfc6844cfe7a2591620206a80" dependencies = [ "bitflags 2.5.0", "cassowary", + "compact_str", "crossterm", "indoc", "itertools", @@ -3884,14 +3912,20 @@ dependencies = [ [[package]] name = "stability" -version = "0.1.1" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebd1b177894da2a2d9120208c3386066af06a488255caabc5de8ddca22dbc3ce" +checksum = "2ff9eaf853dec4c8802325d8b6d3dffa86cc707fd7a1a4cdbf416e13b061787a" dependencies = [ "quote", - "syn 1.0.109", + "syn 2.0.58", ] +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "str-buf" version = "1.0.6" @@ -3923,18 +3957,18 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "strum" -version = "0.25.0" +version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125" +checksum = "5d8cec3501a5194c432b2b7976db6b7d10ec95c253208b45f83f7136aa985e29" dependencies = [ "strum_macros", ] [[package]] name = "strum_macros" -version = "0.25.3" +version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23dc1fa9ac9c169a78ba62f0b841814b7abae11bdd047b9c58f893439e309ea0" +checksum = "c6cf59daf282c0a494ba14fd21610a0325f9f90ec9d1231dea26bcb1d696c946" dependencies = [ "heck 0.4.1", "proc-macro2", @@ -4381,6 +4415,16 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tui-term" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2ba3e0ff5cf7ff043b20f3e390c6efd16b65a7bef0840a093821b8c71c4c72" +dependencies = [ + "ratatui", + "vt100", +] + [[package]] name = "typed-arena" version = "2.0.2" diff --git a/Cargo.toml b/Cargo.toml index 38991ee0..e0ae07f5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,8 @@ typed-builder = "0.18.0" pretty_assertions = "1.3.0" thiserror = "1.0" rustix = { version = "0.38.30", features = ["process", "fs"] } +ratatui = "0.26" +crossterm = { version = "0.27", features = ["use-dev-tty"] } [workspace.dependencies.reqwest] version = "0.11" diff --git a/crates/atuin-dotfiles/src/shell.rs b/crates/atuin-dotfiles/src/shell.rs index 7912bc34..a5cb0b7a 100644 --- a/crates/atuin-dotfiles/src/shell.rs +++ b/crates/atuin-dotfiles/src/shell.rs @@ -16,16 +16,41 @@ pub struct Alias { pub value: String, } -pub fn parse_alias(line: &str) -> Alias { - let mut parts = line.split('='); +pub fn parse_alias(line: &str) -> Option { + // consider the fact we might be importing a fish alias + // 'alias' output + // fish: alias foo bar + // posix: foo=bar + + let is_fish = line.split(' ').next().unwrap_or("") == "alias"; + + let parts: Vec<&str> = if is_fish { + line.split(' ') + .enumerate() + .filter_map(|(n, i)| if n == 0 { None } else { Some(i) }) + .collect() + } else { + line.split('=').collect() + }; + + if parts.len() <= 1 { + return None; + } + + let mut parts = parts.iter().map(|s| s.to_string()); let name = parts.next().unwrap().to_string(); - let remaining = parts.collect::>().join("=").to_string(); - Alias { + let remaining = if is_fish { + parts.collect::>().join(" ").to_string() + } else { + parts.collect::>().join("=").to_string() + }; + + Some(Alias { name, - value: remaining, - } + value: remaining.trim().to_string(), + }) } pub fn existing_aliases(shell: Option) -> Result, ShellError> { @@ -43,7 +68,8 @@ pub fn existing_aliases(shell: Option) -> Result, ShellError> // This will return a list of aliases, each on its own line // They will be in the form foo=bar let aliases = shell.run_interactive(["alias"])?; - let aliases: Vec = aliases.lines().map(parse_alias).collect(); + + let aliases: Vec = aliases.lines().filter_map(parse_alias).collect(); Ok(aliases) } @@ -73,28 +99,80 @@ pub async fn import_aliases(store: AliasStore) -> Result> { #[cfg(test)] mod tests { + use crate::shell::{parse_alias, Alias}; + #[test] fn test_parse_simple_alias() { - let alias = super::parse_alias("foo=bar"); + let alias = super::parse_alias("foo=bar").expect("failed to parse alias"); assert_eq!(alias.name, "foo"); assert_eq!(alias.value, "bar"); } #[test] fn test_parse_quoted_alias() { - let alias = super::parse_alias("emacs='TERM=xterm-24bits emacs -nw'"); + let alias = super::parse_alias("emacs='TERM=xterm-24bits emacs -nw'") + .expect("failed to parse alias"); + assert_eq!(alias.name, "emacs"); assert_eq!(alias.value, "'TERM=xterm-24bits emacs -nw'"); - let git_alias = super::parse_alias("gwip='git add -A; git rm $(git ls-files --deleted) 2> /dev/null; git commit --no-verify --no-gpg-sign --message \"--wip-- [skip ci]\"'"); + let git_alias = super::parse_alias("gwip='git add -A; git rm $(git ls-files --deleted) 2> /dev/null; git commit --no-verify --no-gpg-sign --message \"--wip-- [skip ci]\"'").expect("failed to parse alias"); assert_eq!(git_alias.name, "gwip"); assert_eq!(git_alias.value, "'git add -A; git rm $(git ls-files --deleted) 2> /dev/null; git commit --no-verify --no-gpg-sign --message \"--wip-- [skip ci]\"'"); } #[test] fn test_parse_quoted_alias_equals() { - let alias = super::parse_alias("emacs='TERM=xterm-24bits emacs -nw --foo=bar'"); + let alias = super::parse_alias("emacs='TERM=xterm-24bits emacs -nw --foo=bar'") + .expect("failed to parse alias"); assert_eq!(alias.name, "emacs"); assert_eq!(alias.value, "'TERM=xterm-24bits emacs -nw --foo=bar'"); } + + #[test] + fn test_parse_fish() { + let alias = super::parse_alias("alias foo bar").expect("failed to parse alias"); + assert_eq!(alias.name, "foo"); + assert_eq!(alias.value, "bar"); + + let alias = + super::parse_alias("alias x 'exa --icons --git --classify --group-directories-first'") + .expect("failed to parse alias"); + + assert_eq!(alias.name, "x"); + assert_eq!( + alias.value, + "'exa --icons --git --classify --group-directories-first'" + ); + } + + #[test] + fn test_parse_with_fortune() { + // Because we run the alias command in an interactive subshell + // there may be other output. + // Ensure that the parser can handle it + // Annoyingly not all aliases are picked up all the time if we use + // a non-interactive subshell. Boo. + let shell = " +/ In a consumer society there are \\ +| inevitably two kinds of slaves: the | +| prisoners of addiction and the | +\\ prisoners of envy. / + ------------------------------------- + \\ ^__^ + \\ (oo)\\_______ + (__)\\ )\\/\\ + ||----w | + || || +emacs='TERM=xterm-24bits emacs -nw --foo=bar' +k=kubectl +"; + + let aliases: Vec = shell.lines().filter_map(parse_alias).collect(); + assert_eq!(aliases[0].name, "emacs"); + assert_eq!(aliases[0].value, "'TERM=xterm-24bits emacs -nw --foo=bar'"); + + assert_eq!(aliases[1].name, "k"); + assert_eq!(aliases[1].value, "kubectl"); + } } 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(); + // 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 cmd = CommandBuilder::new("bash"); - let mut child = pair.slave.spawn_command(cmd).unwrap(); + let mut reader = pair.master.try_clone_reader().unwrap(); + let parser = Arc::new(RwLock::new(vt100::Parser::new(size.rows, size.cols, 0))); - // Release any handles owned by the slave: we don't need it now - // that we've spawned the child. - drop(pair.slave); + { + 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); - // 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(); + // Clear the processed portion of the buffer + processed_buf.clear(); + } + } + }); + } - 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(); - }); + let (tx, mut rx) = channel::(32); - { - // 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(); - - 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)); + 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); + }); + + 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; - // 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(); - }); + 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()))?; + + tokio::time::sleep(Duration::from_millis(1500)).await; - // 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); + Ok(()) +} - // Now wait for the output to be read by our reader thread - let output = rx.recv().unwrap(); +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)); - // 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}"); + let pseudo_term = PseudoTerminal::new(screen).block(block); + f.render_widget(pseudo_term, chunks[0]); - Ok("".to_string()) + 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 2c7d09d2..14e48eff 100644 --- a/crates/atuin/Cargo.toml +++ b/crates/atuin/Cargo.toml @@ -56,7 +56,7 @@ directories = { workspace = true } indicatif = "0.17.5" serde = { workspace = true } serde_json = { workspace = true } -crossterm = { version = "0.27", features = ["use-dev-tty"] } +crossterm.workspace = true unicode-width = "0.1" itertools = { workspace = true } tokio = { workspace = true } @@ -76,7 +76,7 @@ tiny-bip39 = "1" futures-util = "0.3" fuzzy-matcher = "0.3.7" colored = "2.0.4" -ratatui = "0.25" +ratatui.workspace = true tracing = "0.1" uuid = { workspace = true } unicode-segmentation = "1.11.0" diff --git a/crates/atuin/src/command/client.rs b/crates/atuin/src/command/client.rs index 45e039c6..afceea34 100644 --- a/crates/atuin/src/command/client.rs +++ b/crates/atuin/src/command/client.rs @@ -75,8 +75,8 @@ pub enum Cmd { Doctor, /// Execute a runbook or workflow - #[command()] - Run, + #[command(subcommand)] + Run(run::Cmd), /// Print example configuration #[command()] @@ -140,7 +140,7 @@ impl Cmd { Self::Doctor => doctor::run(&settings), - 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' -``` +#[derive(Debug, Subcommand)] +pub enum Cmd { + Markdown { path: String }, +} -2. do another thing -```sh -echo 'bar' -``` -", - ); +impl Cmd { + pub async fn run(&self) -> Result<()> { + match self { + Cmd::Markdown { path } => { + let file = PathBuf::from(path); - println!("{:?}", blocks); + if !file.exists() { + return Err(eyre!("File does not exist at {path}")); + } - run_pty(); + let md = tokio::fs::read_to_string(file).await?; + let blocks = parse(md.as_str()); + run_pty(blocks).await?; + } + } - Ok(()) + Ok(()) + } } diff --git a/crates/atuin/src/command/client/search/interactive.rs b/crates/atuin/src/command/client/search/interactive.rs index 7a3a834b..87506c5e 100644 --- a/crates/atuin/src/command/client/search/interactive.rs +++ b/crates/atuin/src/command/client/search/interactive.rs @@ -643,7 +643,7 @@ impl State { // TODO: this should be split so that we have one interactive search container that is // EITHER a search box or an inspector. But I'm not doing that now, way too much atm. // also allocate less 🙈 - let titles = TAB_TITLES.iter().copied().map(Line::from).collect(); + let titles: Vec = TAB_TITLES.iter().copied().map(Line::from).collect(); let tabs = Tabs::new(titles) .block(Block::default().borders(Borders::NONE)) -- cgit v1.2.3