aboutsummaryrefslogtreecommitdiffstats
path: root/crates/client/src/command
diff options
context:
space:
mode:
authorBenedikt Peetz <benedikt.peetz@b-peetz.de>2026-08-22 21:11:34 +0200
committerBenedikt Peetz <benedikt.peetz@b-peetz.de>2026-08-22 21:11:34 +0200
commit83e9324dc7325506ac0a7d3c28d3342c68ad1165 (patch)
treebdf985e3e9a1743202f3ef20813dcf78be0710b2 /crates/client/src/command
parentfix(nix): Update all references and switch build to crane (diff)
downloadatuin-83e9324dc7325506ac0a7d3c28d3342c68ad1165.zip
crates/turtle/list: Restore the history listing code from atuin
_That_ was actually useful to have.
Diffstat (limited to '')
-rw-r--r--crates/client/src/command/client/history/list.rs291
-rw-r--r--crates/client/src/command/client/history/mod.rs63
2 files changed, 326 insertions, 28 deletions
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
+ }
}
}
}