diff options
| author | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2026-08-22 21:11:34 +0200 |
|---|---|---|
| committer | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2026-08-22 21:11:34 +0200 |
| commit | 83e9324dc7325506ac0a7d3c28d3342c68ad1165 (patch) | |
| tree | bdf985e3e9a1743202f3ef20813dcf78be0710b2 /crates | |
| parent | fix(nix): Update all references and switch build to crane (diff) | |
| download | atuin-83e9324dc7325506ac0a7d3c28d3342c68ad1165.zip | |
crates/turtle/list: Restore the history listing code from atuin
_That_ was actually useful to have.
Diffstat (limited to 'crates')
| -rw-r--r-- | crates/client/Cargo.toml | 1 | ||||
| -rw-r--r-- | crates/client/src/command/client/history/list.rs | 291 | ||||
| -rw-r--r-- | crates/client/src/command/client/history/mod.rs | 63 |
3 files changed, 327 insertions, 28 deletions
diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index 10ed1001..b8038714 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -23,6 +23,7 @@ fs-err = { workspace = true } interim = { workspace = true } log = { workspace = true } regex = { workspace = true } +runtime-format = { workspace = true } rustix = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/client/src/command/client/history/list.rs b/crates/client/src/command/client/history/list.rs index 0fc59f6a..47ce3b99 100644 --- a/crates/client/src/command/client/history/list.rs +++ b/crates/client/src/command/client/history/list.rs @@ -1,39 +1,282 @@ -use std::{fmt::Display, time::Duration}; +use std::{ + fmt::{self, Display}, + io::{self, IsTerminal, Write}, + time::Duration, +}; -use crate::{atuin_client::settings::Settings, command::client::history::format_duration_into}; +use crate::{ + atuin_client::settings::{Settings, Timezone}, + command::client::history::format_duration_into, +}; use eyre::Result; -use turtle_api::client::HistoryClient; +use runtime_format::{FormatKey, FormatKeyError, ParseSegment, ParsedFmt}; +use time::{OffsetDateTime, macros::format_description}; +use turtle_api::{client::HistoryClient, history::History}; -pub(super) async fn handle(settings: &Settings) -> Result<()> { - const CSI: &str = "\x1b["; - const CSE: &str = "m"; - fn col(v: impl Display, num: u32) -> String { - format!("{CSI}{num}{CSE}{v}{CSI}0{CSE}") +#[derive(Clone, Copy, Debug)] +pub(super) enum ListMode { + Human, + CmdOnly, + Regular, +} + +impl ListMode { + pub(super) const fn from_flags(human: bool, cmd_only: bool) -> Self { + if human { + Self::Human + } else if cmd_only { + Self::CmdOnly + } else { + Self::Regular + } } +} + +/// Type wrapper around `History` with formatting settings. +#[derive(Clone, Copy, Debug)] +struct FmtHistory<'a> { + history: &'a History, + cmd_format: CmdFormat, + tz: &'a Timezone, +} - struct F(Duration); - impl Display for F { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - format_duration_into(self.0, f) +#[derive(Clone, Copy, Debug)] +enum CmdFormat { + Literal, + Escaped, +} +impl CmdFormat { + fn for_output<O: IsTerminal>(out: &O) -> Self { + if out.is_terminal() { + Self::Escaped + } else { + Self::Literal } } +} - let mut client = HistoryClient::new(settings.daemon.socket_path.clone()).await?; +static TIME_FMT: &[time::format_description::FormatItem<'static>] = + format_description!("[year]-[month]-[day] [hour repr:24]:[minute]:[second]"); + +/// defines how to format the history +impl FormatKey for FmtHistory<'_> { + fn fmt(&self, key: &str, f: &mut fmt::Formatter<'_>) -> Result<(), FormatKeyError> { + match key { + "command" => match self.cmd_format { + CmdFormat::Literal | CmdFormat::Escaped => f.write_str(self.history.command.trim()), + }?, + "directory" => f.write_str(self.history.cwd.trim())?, + "exit" => f.write_str(&self.history.exit.to_string())?, + "duration" => { + let dur = self.history.duration; + format_duration_into(dur, f)?; + } + "time" => { + self.history + .timestamp + .to_offset(self.tz.0) + .format(TIME_FMT) + .map_err(|_| fmt::Error)? + .fmt(f)?; + } + "relativetime" => { + let since = OffsetDateTime::now_utc() - self.history.timestamp; + let d = Duration::try_from(since).unwrap_or_default(); + format_duration_into(d, f)?; + } + "host" => f.write_str( + self.history + .hostname + .split_once(':') + .map_or(&self.history.hostname, |(host, _)| host), + )?, + "author" => f.write_str(&self.history.author)?, + "intent" => f.write_str(self.history.intent.as_deref().unwrap_or_default())?, + "user" => f.write_str( + self.history + .hostname + .split_once(':') + .map_or("", |(_, user)| user), + )?, + "session" => f.write_str(&self.history.session)?, + "uuid" => f.write_str(&self.history.id.to_string())?, + _ => return Err(FormatKeyError::UnknownKey), + } + Ok(()) + } +} + +fn parse_fmt(format: &str) -> ParsedFmt<'_> { + match ParsedFmt::new(format) { + Ok(fmt) => fmt, + Err(err) => { + eprintln!("ERROR: History formatting failed with the following error: {err}"); + + if format.contains('"') && (format.contains(":{") || format.contains(",{")) { + eprintln!("It looks like you're trying to create JSON output."); + eprintln!("For JSON, you need to escape literal braces by doubling them:"); + eprintln!("Example: '{{\"command\":\"{{command}}\",\"time\":\"{{time}}\"}}'"); + } else { + eprintln!( + "If your formatting string contains literal curly braces, you need to escape them by doubling:" + ); + eprintln!("Use {{{{ for literal {{ and }}}} for literal }}"); + } + std::process::exit(1) + } + } +} + +fn print_list( + h: &[History], + list_mode: ListMode, + format: Option<&str>, + print0: bool, + reverse: bool, + tz: Timezone, +) { + let w = io::stdout(); + let mut w = w.lock(); - let hists = client.history(None).await?; + let fmt_str = match list_mode { + ListMode::Human => format + .unwrap_or("{time} ยท {duration}\t{command}") + .replace("\\t", "\t"), + ListMode::Regular => format + .unwrap_or("{time}\t{command}\t{duration}") + .replace("\\t", "\t"), + // not used + ListMode::CmdOnly => String::new(), + }; - for hist in hists { - println!( - "{}@{}: {} at {} for {} [{}]", - hist.author, - hist.hostname, - col(hist.command, 36), - col(hist.cwd, 32), - F(hist.duration), - col(hist.exit, 31) - ); + let parsed_fmt = match list_mode { + ListMode::Human | ListMode::Regular => parse_fmt(&fmt_str), + ListMode::CmdOnly => std::iter::once(ParseSegment::Key("command")).collect(), + }; + + let iterator = if reverse { + Box::new(h.iter().rev()) as Box<dyn Iterator<Item = &History>> + } else { + Box::new(h.iter()) as Box<dyn Iterator<Item = &History>> + }; + + let entry_terminator = if print0 { "\0" } else { "\n" }; + let flush_each_line = print0; + + for history in iterator { + let fh = FmtHistory { + history, + cmd_format: CmdFormat::for_output(&w), + tz: &tz, + }; + let args = parsed_fmt.with_args(&fh); + + // Check for formatting errors before attempting to write + if let Err(err) = args.status() { + eprintln!("ERROR: history output failed with: {err}"); + std::process::exit(1); + } + + let write_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + write!(w, "{args}{entry_terminator}") + })); + + match write_result { + Ok(Ok(())) => { + // Write succeeded + } + Ok(Err(err)) => { + if err.kind() != io::ErrorKind::BrokenPipe { + eprintln!("ERROR: Failed to write history output: {err}"); + std::process::exit(1); + } + } + Err(_) => { + eprintln!("ERROR: Format string caused a formatting error."); + eprintln!( + "This may be due to an unsupported format string containing special characters." + ); + eprintln!( + "Please check your format string syntax and ensure literal braces are properly escaped." + ); + std::process::exit(1); + } + } + if flush_each_line { + check_for_write_errors(w.flush()); + } } + if !flush_each_line { + check_for_write_errors(w.flush()); + } +} +fn check_for_write_errors(write: Result<(), io::Error>) { + if let Err(err) = write { + // Ignore broken pipe (issue #626) + if err.kind() != io::ErrorKind::BrokenPipe { + eprintln!("ERROR: History output failed with the following error: {err}"); + std::process::exit(1); + } + } +} + +pub(super) async fn handle( + settings: &Settings, + mode: ListMode, + format: Option<String>, + print0: bool, + reverse: bool, + tz: Timezone, +) -> Result<()> { + let mut client = HistoryClient::new(settings.daemon.socket_path.clone()).await?; + let history = client.history(None).await?; + + print_list( + &history, + mode, + match format { + None => Some("{time}\t{command}\t{duration}"), + _ => format.as_deref(), + }, + print0, + reverse, + tz, + ); + Ok(()) } + +// pub(super) async fn handle(settings: &Settings) -> Result<()> { +// const CSI: &str = "\x1b["; +// const CSE: &str = "m"; +// fn col(v: impl Display, num: u32) -> String { +// format!("{CSI}{num}{CSE}{v}{CSI}0{CSE}") +// } +// +// struct F(Duration); +// impl Display for F { +// fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { +// format_duration_into(self.0, f) +// } +// } +// +// let mut client = HistoryClient::new(settings.daemon.socket_path.clone()).await?; +// +// let hists = client.history(None).await?; +// +// for hist in hists { +// println!( +// "{}@{}: {} at {} for {} [{}]", +// hist.author, +// hist.hostname, +// col(hist.command, 36), +// col(hist.cwd, 32), +// F(hist.duration), +// col(hist.exit, 31) +// ); +// } +// +// Ok(()) +// } diff --git a/crates/client/src/command/client/history/mod.rs b/crates/client/src/command/client/history/mod.rs index f32ad83d..55647e15 100644 --- a/crates/client/src/command/client/history/mod.rs +++ b/crates/client/src/command/client/history/mod.rs @@ -9,7 +9,10 @@ use eyre::Result; use time::macros::format_description; use turtle_api::history::History; -use crate::atuin_client::settings::Settings; +use crate::{ + atuin_client::settings::{Settings, Timezone}, + command::client::history::list::ListMode, +}; mod end; mod list; @@ -52,8 +55,47 @@ pub(crate) enum Cmd { /// Stream history events from the daemon as they are received Tail, - /// Very basic listing of tracked history. - List, + /// List all items in history + List { + #[arg(long, short)] + cwd: bool, + + #[arg(long, short)] + session: bool, + + #[arg(long)] + human: bool, + + /// Show only the text of the command + #[arg(long)] + cmd_only: bool, + + /// Terminate the output with a null, for better multiline support + #[arg(long)] + print0: bool, + + #[arg(long, short, default_value = "true")] + // accept no value + #[arg(num_args(0..=1), default_missing_value("true"))] + // accept a value + #[arg(action = clap::ArgAction::Set)] + reverse: bool, + + /// Display the command time in another timezone other than the configured default. + /// + /// This option takes one of the following kinds of values: + /// + /// - the special value "local" (or "l") which refers to the system time zone + /// - an offset from UTC (e.g. "+9", "-2:30") + #[arg(long, visible_alias = "tz", verbatim_doc_comment)] + timezone: Option<Timezone>, + + /// Available variables: {command}, {directory}, {duration}, {user}, {host}, {author}, {intent}, {exit}, {time}, {session}, and {uuid} + /// + /// Example: --format "{time} - [{duration}] - {directory}$\t{command}" + #[arg(long, short)] + format: Option<String>, + }, } impl Cmd { @@ -83,7 +125,20 @@ impl Cmd { end::handle(settings, &id, exit, duration.map(Duration::from_nanos)).await } Self::Tail => tail::handle(settings).await, - Self::List => list::handle(settings).await, + Self::List { + cwd: _, + session: _, + human, + cmd_only, + print0, + reverse, + timezone, + format, + } => { + let mode = ListMode::from_flags(human, cmd_only); + let tz = timezone.unwrap_or(settings.timezone); + list::handle(settings, mode, format, print0, reverse, tz).await + } } } } |
