From a7cb21a51b393b436c0ca7e09c892ebb3c597ad0 Mon Sep 17 00:00:00 2001 From: Steven Xu Date: Mon, 27 Mar 2023 01:44:06 +1100 Subject: [PATCH] feat: add *Nushell* support (#788) * feat: add *Nushell* support * refactor: use `sh` to swap `STDOUT` and `STDERR` instead of using a temporary file * feat: include both keybindings, with the current REPL buffer passed to *Atuin*'s * feat: don't record commands run by keybindings --- README.md | 15 ++++ atuin-client/src/import/mod.rs | 2 + atuin-client/src/import/nu.rs | 76 ++++++++++++++++++ atuin-client/src/import/nu_histdb.rs | 110 +++++++++++++++++++++++++++ src/command/client/import.rs | 20 ++++- src/command/init.rs | 44 +++++++++++ src/shell/atuin.nu | 41 ++++++++++ 7 files changed, 307 insertions(+), 1 deletion(-) create mode 100644 atuin-client/src/import/nu.rs create mode 100644 atuin-client/src/import/nu_histdb.rs create mode 100644 src/shell/atuin.nu diff --git a/README.md b/README.md index 5f4f1ee..8510746 100644 --- a/README.md +++ b/README.md @@ -274,6 +274,21 @@ Install `atuin` shell plugin in zsh, bash, or fish with [Fig](https://fig.io) in +### Nushell + +Run in *Nushell*: + +``` +mkdir ~/.local/share/atuin/ +atuin init nu | save ~/.local/share/atuin/init.nu +``` + +Add to `config.nu`: + +``` +source ~/.local/share/atuin/init.nu +``` + ## ...what's with the name? Atuin is named after "The Great A'Tuin", a giant turtle from Terry Pratchett's diff --git a/atuin-client/src/import/mod.rs b/atuin-client/src/import/mod.rs index 65c4f41..3d38cd2 100644 --- a/atuin-client/src/import/mod.rs +++ b/atuin-client/src/import/mod.rs @@ -8,6 +8,8 @@ use crate::history::History; pub mod bash; pub mod fish; +pub mod nu; +pub mod nu_histdb; pub mod resh; pub mod zsh; pub mod zsh_histdb; diff --git a/atuin-client/src/import/nu.rs b/atuin-client/src/import/nu.rs new file mode 100644 index 0000000..0f10760 --- /dev/null +++ b/atuin-client/src/import/nu.rs @@ -0,0 +1,76 @@ +// import old shell history! +// automatically hoover up all that we can find + +use std::{fs::File, io::Read, path::PathBuf}; + +use async_trait::async_trait; +use directories::BaseDirs; +use eyre::{eyre, Result}; + +use super::{unix_byte_lines, Importer, Loader}; +use crate::history::History; + +#[derive(Debug)] +pub struct Nu { + bytes: Vec, +} + +fn get_histpath() -> Result { + let base = BaseDirs::new().ok_or_else(|| eyre!("could not determine data directory"))?; + let config_dir = base.config_dir().join("nushell"); + + let histpath = config_dir.join("history.txt"); + if histpath.exists() { + Ok(histpath) + } else { + Err(eyre!("Could not find history file.")) + } +} + +#[async_trait] +impl Importer for Nu { + const NAME: &'static str = "nu"; + + async fn new() -> Result { + let mut bytes = Vec::new(); + let path = get_histpath()?; + let mut f = File::open(path)?; + f.read_to_end(&mut bytes)?; + Ok(Self { bytes }) + } + + async fn entries(&mut self) -> Result { + Ok(super::count_lines(&self.bytes)) + } + + async fn load(self, h: &mut impl Loader) -> Result<()> { + let now = chrono::Utc::now(); + + let mut counter = 0; + for b in unix_byte_lines(&self.bytes) { + let s = match std::str::from_utf8(b) { + Ok(s) => s, + Err(_) => continue, // we can skip past things like invalid utf8 + }; + + let cmd: String = s.replace("<\\n>", "\n"); + + let offset = chrono::Duration::nanoseconds(counter); + counter += 1; + + h.push(History::new( + now - offset, // preserve ordering + cmd, + String::from("unknown"), + -1, + -1, + None, + None, + None, + )) + .await?; + } + + Ok(()) + } +} diff --git a/atuin-client/src/import/nu_histdb.rs b/atuin-client/src/import/nu_histdb.rs new file mode 100644 index 0000000..0fb5192 --- /dev/null +++ b/atuin-client/src/import/nu_histdb.rs @@ -0,0 +1,110 @@ +// import old shell history! +// automatically hoover up all that we can find + +use std::path::PathBuf; + +use async_trait::async_trait; +use chrono::{prelude::*, Utc}; +use directories::BaseDirs; +use eyre::{eyre, Result}; +use sqlx::{sqlite::SqlitePool, Pool}; + +use super::Importer; +use crate::history::History; +use crate::import::Loader; + +#[derive(sqlx::FromRow, Debug)] +pub struct HistDbEntry { + pub id: i64, + pub command_line: Vec, + pub start_timestamp: i64, + pub session_id: i64, + pub hostname: Vec, + pub cwd: Vec, + pub duration_ms: i64, + pub exit_status: i64, + pub more_info: Vec, +} + +impl From for History { + fn from(histdb_item: HistDbEntry) -> Self { + let ts_secs = histdb_item.start_timestamp / 1000; + let ts_ns = (histdb_item.start_timestamp % 1000) * 1_000_000; + History::new( + DateTime::from_utc(NaiveDateTime::from_timestamp(ts_secs, ts_ns as u32), Utc), + String::from_utf8(histdb_item.command_line).unwrap(), + String::from_utf8(histdb_item.cwd).unwrap(), + histdb_item.exit_status, + histdb_item.duration_ms, + Some(format!("{:x}", histdb_item.session_id)), + Some(String::from_utf8(histdb_item.hostname).unwrap()), + None, + ) + } +} + +#[derive(Debug)] +pub struct NuHistDb { + histdb: Vec, +} + +/// Read db at given file, return vector of entries. +async fn hist_from_db(dbpath: PathBuf) -> Result> { + let pool = SqlitePool::connect(dbpath.to_str().unwrap()).await?; + hist_from_db_conn(pool).await +} + +async fn hist_from_db_conn(pool: Pool) -> Result> { + let query = r#" + SELECT + id, command_line, start_timestamp, session_id, hostname, cwd, duration_ms, exit_status, + more_info + FROM history + ORDER BY start_timestamp + "#; + let histdb_vec: Vec = sqlx::query_as::<_, HistDbEntry>(query) + .fetch_all(&pool) + .await?; + Ok(histdb_vec) +} + +impl NuHistDb { + pub fn histpath() -> Result { + let base = BaseDirs::new().ok_or_else(|| eyre!("could not determine data directory"))?; + let config_dir = base.config_dir().join("nushell"); + + let histdb_path = config_dir.join("history.sqlite3"); + if histdb_path.exists() { + Ok(histdb_path) + } else { + Err(eyre!("Could not find history file.")) + } + } +} + +#[async_trait] +impl Importer for NuHistDb { + // Not sure how this is used + const NAME: &'static str = "nu_histdb"; + + /// Creates a new NuHistDb and populates the history based on the pre-populated data + /// structure. + async fn new() -> Result { + let dbpath = NuHistDb::histpath()?; + let histdb_entry_vec = hist_from_db(dbpath).await?; + Ok(Self { + histdb: histdb_entry_vec, + }) + } + + async fn entries(&mut self) -> Result { + Ok(self.histdb.len()) + } + + async fn load(self, h: &mut impl Loader) -> Result<()> { + for i in self.histdb { + h.push(i.into()).await?; + } + Ok(()) + } +} diff --git a/src/command/client/import.rs b/src/command/client/import.rs index 7d7c2ca..7abc3d4 100644 --- a/src/command/client/import.rs +++ b/src/command/client/import.rs @@ -9,7 +9,8 @@ use atuin_client::{ database::Database, history::History, import::{ - bash::Bash, fish::Fish, resh::Resh, zsh::Zsh, zsh_histdb::ZshHistDb, Importer, Loader, + bash::Bash, fish::Fish, nu::Nu, nu_histdb::NuHistDb, resh::Resh, zsh::Zsh, + zsh_histdb::ZshHistDb, Importer, Loader, }, }; @@ -29,6 +30,10 @@ pub enum Cmd { Resh, /// Import history from the fish history file Fish, + /// Import history from the nu history file + Nu, + /// Import history from the nu history file + NuHistDb, } const BATCH_SIZE: usize = 100; @@ -68,6 +73,17 @@ impl Cmd { } else if shell.ends_with("/bash") { println!("Detected Bash"); import::(db).await + } else if shell.ends_with("/nu") { + if NuHistDb::histpath().is_ok() { + println!( + "Detected Nu-HistDb, using :{}", + NuHistDb::histpath().unwrap().to_str().unwrap() + ); + import::(db).await + } else { + println!("Detected Nushell"); + import::(db).await + } } else { println!("cannot import {shell} history"); Ok(()) @@ -79,6 +95,8 @@ impl Cmd { Self::Bash => import::(db).await, Self::Resh => import::(db).await, Self::Fish => import::(db).await, + Self::Nu => import::(db).await, + Self::NuHistDb => import::(db).await, } } } diff --git a/src/command/init.rs b/src/command/init.rs index 585a828..7cb4b35 100644 --- a/src/command/init.rs +++ b/src/command/init.rs @@ -21,6 +21,8 @@ pub enum Shell { Bash, /// Fish setup Fish, + /// Nu setup + Nu, } impl Cmd { @@ -90,11 +92,53 @@ bind -M insert \e\[A _atuin_bind_up"; println!("end"); } } + + fn init_nu(&self) { + let full = include_str!("../shell/atuin.nu"); + println!("{full}"); + + if std::env::var("ATUIN_NOBIND").is_err() { + const BIND_CTRL_R: &str = r#"let-env config = ( + $env.config | upsert keybindings ( + $env.config.keybindings + | append { + name: atuin + modifier: control + keycode: char_r + mode: emacs + event: { send: executehostcommand cmd: (_atuin_search_cmd) } + } + ) +) +"#; + const BIND_UP_ARROW: &str = r#"let-env config = ( + $env.config | upsert keybindings ( + $env.config.keybindings + | append { + name: atuin + modifier: none + keycode: up + mode: emacs + event: { send: executehostcommand cmd: (_atuin_search_cmd '--shell-up-key-binding') } + } + ) +) +"#; + if !self.disable_ctrl_r { + println!("{BIND_CTRL_R}"); + } + if !self.disable_up_arrow { + println!("{BIND_UP_ARROW}"); + } + } + } + pub fn run(self) { match self.shell { Shell::Zsh => self.init_zsh(), Shell::Bash => self.init_bash(), Shell::Fish => self.init_fish(), + Shell::Nu => self.init_nu(), } } } diff --git a/src/shell/atuin.nu b/src/shell/atuin.nu new file mode 100644 index 0000000..f8886fc --- /dev/null +++ b/src/shell/atuin.nu @@ -0,0 +1,41 @@ +# Source this in your ~/.config/nushell/config.nu +let-env ATUIN_SESSION = (atuin uuid) + +# Magic token to make sure we don't record commands run by keybindings +let ATUIN_KEYBINDING_TOKEN = $"# (random uuid)" + +let _atuin_pre_execution = {|| + let cmd = (commandline) + if not ($cmd | str starts-with $ATUIN_KEYBINDING_TOKEN) { + let-env ATUIN_HISTORY_ID = (atuin history start -- $cmd) + } +} + +let _atuin_pre_prompt = {|| + let last_exit = $env.LAST_EXIT_CODE + if 'ATUIN_HISTORY_ID' not-in $env { + return + } + with-env { RUST_LOG: error } { + atuin history end --exit $last_exit -- $env.ATUIN_HISTORY_ID | null + } +} + +def _atuin_search_cmd [...flags: string] { + [ + $ATUIN_KEYBINDING_TOKEN, + ([ + `commandline (sh -c 'RUST_LOG=error atuin search `, + $flags, + ` -i -- "$0" 3>&1 1>&2 2>&3' (commandline))`, + ] | flatten | str join ''), + ] | str join "\n" +} + +let-env config = ( + $env.config | upsert hooks ( + $env.config.hooks + | upsert pre_execution ($env.config.hooks.pre_execution | append $_atuin_pre_execution) + | upsert pre_prompt ($env.config.hooks.pre_prompt | append $_atuin_pre_prompt) + ) +)