From 5b384487331eaf08031dfe438bb2affa31aafcbb Mon Sep 17 00:00:00 2001 From: Ellie Huxtable Date: Mon, 8 Jul 2024 11:17:47 +0100 Subject: feat(gui): runbooks that run (#2233) * add initial runbooks frontend * fix buttons, scroll, add shell support to editor * work * some tweaks * wip - run crate * functioning executable blocks * handle resizing, killing ptys * clear properly on stop * move terminal to its own component, handle lifecycle better * fix all build issues * ffs codespelll * update lockfile * clippy is needy once more * only build pty stuff on mac/linux * vendor pty handling into desktop * update lockfile --- ui/backend/src/db.rs | 35 +++++++++++++++ ui/backend/src/install.rs | 3 +- ui/backend/src/main.rs | 27 ++++++++++- ui/backend/src/pty.rs | 112 ++++++++++++++++++++++++++++++++++++++++++++++ ui/backend/src/run/mod.rs | 1 + ui/backend/src/run/pty.rs | 93 ++++++++++++++++++++++++++++++++++++++ ui/backend/src/state.rs | 10 +++++ 7 files changed, 279 insertions(+), 2 deletions(-) create mode 100644 ui/backend/src/pty.rs create mode 100644 ui/backend/src/run/mod.rs create mode 100644 ui/backend/src/run/pty.rs create mode 100644 ui/backend/src/state.rs (limited to 'ui/backend/src') diff --git a/ui/backend/src/db.rs b/ui/backend/src/db.rs index 1015ebf1..56d422ab 100644 --- a/ui/backend/src/db.rs +++ b/ui/backend/src/db.rs @@ -174,6 +174,41 @@ impl HistoryDB { Ok(history) } + pub async fn prefix_search(&self, query: &str) -> Result, String> { + let context = Context { + session: "".to_string(), + cwd: "".to_string(), + host_id: "".to_string(), + hostname: "".to_string(), + git_root: None, + }; + + let filters = OptFilters { + limit: Some(5), + ..OptFilters::default() + }; + + let history = self + .0 + .search( + SearchMode::Prefix, + FilterMode::Global, + &context, + query, + filters, + ) + .await + .map_err(|e| e.to_string())?; + + let history = history + .into_iter() + .filter(|h| h.duration > 0) + .map(|h| h.into()) + .collect(); + + Ok(history) + } + pub async fn calendar(&self) -> Result, String> { let query = "select count(1) as count, strftime('%F', datetime(timestamp / 1000000000, 'unixepoch')) as day from history where timestamp > ((unixepoch() - 31536000) * 1000000000) group by day;"; diff --git a/ui/backend/src/install.rs b/ui/backend/src/install.rs index 43ad0c54..17896e3a 100644 --- a/ui/backend/src/install.rs +++ b/ui/backend/src/install.rs @@ -24,7 +24,8 @@ pub(crate) async fn install_cli() -> Result<(), String> { pub(crate) async fn is_cli_installed() -> Result { let shell = Shell::default_shell().map_err(|e| format!("Failed to get default shell: {e}"))?; let output = if shell == Shell::Powershell { - shell.run_interactive(&["atuin --version; if ($?) {echo 'ATUIN FOUND'}"]) + shell + .run_interactive(&["atuin --version; if ($?) {echo 'ATUIN FOUND'}"]) .map_err(|e| format!("Failed to run interactive command"))? } else { shell diff --git a/ui/backend/src/main.rs b/ui/backend/src/main.rs index 2ba67e50..7adbbbe5 100644 --- a/ui/backend/src/main.rs +++ b/ui/backend/src/main.rs @@ -1,6 +1,8 @@ // Prevents additional console window on Windows in release, DO NOT REMOVE!! #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +use tauri::State; + use std::path::PathBuf; use tauri::{AppHandle, Manager}; @@ -9,6 +11,9 @@ use time::format_description::well_known::Rfc3339; mod db; mod dotfiles; mod install; +mod pty; +mod run; +mod state; mod store; use atuin_client::settings::Settings; @@ -168,7 +173,7 @@ async fn home_info() -> Result { } // Match the format that the frontend library we use expects -// All the processing in Rust, not JS. +// All the processing in Rust, not JSunwrap. // Faaaassssssst af ⚡️🦀 #[derive(Debug, serde::Serialize)] pub struct HistoryCalendarDay { @@ -215,6 +220,19 @@ async fn history_calendar() -> Result, String> { Ok(ret) } +#[tauri::command] +async fn prefix_search(query: &str) -> Result, String> { + let settings = Settings::new().map_err(|e| e.to_string())?; + + let db_path = PathBuf::from(settings.db_path.as_str()); + let db = HistoryDB::new(db_path, settings.local_timeout).await?; + + let history = db.prefix_search(query).await?; + let commands = history.into_iter().map(|h| h.command).collect(); + + Ok(commands) +} + fn show_window(app: &AppHandle) { let windows = app.webview_windows(); @@ -228,9 +246,11 @@ fn show_window(app: &AppHandle) { fn main() { tauri::Builder::default() + .plugin(tauri_plugin_shell::init()) .invoke_handler(tauri::generate_handler![ list, search, + prefix_search, global_stats, aliases, home_info, @@ -239,6 +259,10 @@ fn main() { login, register, history_calendar, + run::pty::pty_open, + run::pty::pty_write, + run::pty::pty_resize, + run::pty::pty_kill, install::install_cli, install::is_cli_installed, install::setup_cli, @@ -254,6 +278,7 @@ fn main() { .plugin(tauri_plugin_single_instance::init(|app, args, cwd| { let _ = show_window(app); })) + .manage(state::AtuinState::default()) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/ui/backend/src/pty.rs b/ui/backend/src/pty.rs new file mode 100644 index 00000000..07857824 --- /dev/null +++ b/ui/backend/src/pty.rs @@ -0,0 +1,112 @@ +use std::{ + io::Write, + sync::{Arc, Mutex}, +}; + +use bytes::Bytes; +use eyre::{eyre, Result}; +use portable_pty::{CommandBuilder, MasterPty, PtySize}; + +pub struct Pty { + tx: tokio::sync::mpsc::Sender, + + pub master: Arc>>, + pub reader: Arc>>, +} + +impl Pty { + pub async fn open<'a>(rows: u16, cols: u16) -> Result { + let sys = portable_pty::native_pty_system(); + + let pair = sys + .openpty(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }) + .map_err(|e| eyre!("Failed to open pty: {}", e))?; + + let cmd = CommandBuilder::new_default_prog(); + + tokio::task::spawn_blocking(move || { + let mut child = pair.slave.spawn_command(cmd).unwrap(); + // Wait for the child to exit + let _ = child.wait().unwrap(); + + // Ensure slave is dropped + // This closes file handles, we can deadlock if this is not done correctly. + drop(pair.slave); + }); + + // Handle input -> write to master writer + let (master_tx, mut master_rx) = tokio::sync::mpsc::channel::(32); + + let mut writer = pair.master.take_writer().unwrap(); + let reader = pair + .master + .try_clone_reader() + .map_err(|e| e.to_string()) + .expect("Failed to clone reader"); + + tokio::spawn(async move { + while let Some(bytes) = master_rx.recv().await { + writer.write_all(&bytes).unwrap(); + writer.flush().unwrap(); + } + + // When the channel has been closed, we won't be getting any more input. Close the + // writer and the master. + // This will also close the writer, which sends EOF to the underlying shell. Ensuring + // that is also closed. + drop(writer); + }); + + Ok(Pty { + tx: master_tx, + master: Arc::new(Mutex::new(pair.master)), + reader: Arc::new(Mutex::new(reader)), + }) + } + + pub async fn resize(&self, rows: u16, cols: u16) -> Result<()> { + let master = self + .master + .lock() + .map_err(|e| eyre!("Failed to lock pty master: {e}"))?; + + master + .resize(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }) + .map_err(|e| eyre!("Failed to resize terminal: {e}"))?; + + Ok(()) + } + + pub async fn send_bytes(&self, bytes: Bytes) -> Result<()> { + self.tx + .send(bytes) + .await + .map_err(|e| eyre!("Failed to write to master tx: {}", e)) + } + + pub async fn send_string(&self, cmd: &str) -> Result<()> { + let bytes: Vec = cmd.bytes().collect(); + let bytes = Bytes::from(bytes); + + self.send_bytes(bytes).await + } + + pub async fn send_single_string(&self, cmd: &str) -> Result<()> { + let mut bytes: Vec = cmd.bytes().collect(); + bytes.push(0x04); + + let bytes = Bytes::from(bytes); + + self.send_bytes(bytes).await + } +} diff --git a/ui/backend/src/run/mod.rs b/ui/backend/src/run/mod.rs new file mode 100644 index 00000000..5ece0912 --- /dev/null +++ b/ui/backend/src/run/mod.rs @@ -0,0 +1 @@ +pub mod pty; diff --git a/ui/backend/src/run/pty.rs b/ui/backend/src/run/pty.rs new file mode 100644 index 00000000..382b45dd --- /dev/null +++ b/ui/backend/src/run/pty.rs @@ -0,0 +1,93 @@ +use eyre::{Result, WrapErr}; +use std::io::BufRead; +use std::path::PathBuf; + +use crate::state::AtuinState; +use tauri::{Manager, State}; + +use atuin_client::{database::Sqlite, record::sqlite_store::SqliteStore, settings::Settings}; + +#[tauri::command] +pub async fn pty_open<'a>( + app: tauri::AppHandle, + state: State<'a, AtuinState>, +) -> Result { + let id = uuid::Uuid::new_v4(); + let pty = crate::pty::Pty::open(24, 80).await.unwrap(); + + let reader = pty.reader.clone(); + + tauri::async_runtime::spawn_blocking(move || loop { + let mut buf = [0u8; 512]; + + match reader.lock().unwrap().read(&mut buf) { + // EOF + Ok(0) => { + println!("reader loop hit eof"); + break; + } + + Ok(n) => { + println!("read {n} bytes"); + + // TODO: sort inevitable encoding issues + let out = String::from_utf8_lossy(&buf).to_string(); + let out = out.trim_matches(char::from(0)); + let channel = format!("pty-{id}"); + + app.emit(channel.as_str(), out).unwrap(); + } + + Err(e) => { + println!("failed to read: {e}"); + break; + } + } + }); + + state.pty_sessions.write().await.insert(id, pty); + + Ok(id) +} + +#[tauri::command] +pub(crate) async fn pty_write( + pid: uuid::Uuid, + data: String, + state: tauri::State<'_, AtuinState>, +) -> Result<(), String> { + let sessions = state.pty_sessions.read().await; + let pty = sessions.get(&pid).ok_or("Pty not found")?; + + let bytes = data.as_bytes().to_vec(); + pty.send_bytes(bytes.into()) + .await + .map_err(|e| e.to_string())?; + Ok(()) +} + +#[tauri::command] +pub(crate) async fn pty_resize( + pid: uuid::Uuid, + rows: u16, + cols: u16, + state: tauri::State<'_, AtuinState>, +) -> Result<(), String> { + let sessions = state.pty_sessions.read().await; + let pty = sessions.get(&pid).ok_or("Pty not found")?; + + pty.resize(rows, cols).await.map_err(|e| e.to_string())?; + + Ok(()) +} + +#[tauri::command] +pub(crate) async fn pty_kill( + pid: uuid::Uuid, + state: tauri::State<'_, AtuinState>, +) -> Result<(), String> { + let pty = state.pty_sessions.write().await.remove(&pid).unwrap(); + println!("RIP {pid:?}"); + + Ok(()) +} diff --git a/ui/backend/src/state.rs b/ui/backend/src/state.rs new file mode 100644 index 00000000..de53b4c5 --- /dev/null +++ b/ui/backend/src/state.rs @@ -0,0 +1,10 @@ +use std::collections::HashMap; +use std::sync::Mutex; +use tauri::async_runtime::RwLock; + +use crate::pty::Pty; + +#[derive(Default)] +pub(crate) struct AtuinState { + pub pty_sessions: RwLock>, +} -- cgit v1.3.1