aboutsummaryrefslogtreecommitdiffstats
path: root/ui/backend/src
diff options
context:
space:
mode:
Diffstat (limited to 'ui/backend/src')
-rw-r--r--ui/backend/src/db.rs245
-rw-r--r--ui/backend/src/dotfiles/aliases.rs91
-rw-r--r--ui/backend/src/dotfiles/mod.rs1
-rw-r--r--ui/backend/src/main.rs63
-rw-r--r--ui/backend/src/store.rs1
5 files changed, 401 insertions, 0 deletions
diff --git a/ui/backend/src/db.rs b/ui/backend/src/db.rs
new file mode 100644
index 00000000..c1aa4de9
--- /dev/null
+++ b/ui/backend/src/db.rs
@@ -0,0 +1,245 @@
+// Some wrappers around the Atuin history DB
+// I'll probably use this to inform changes to the "upstream" client crate
+// We also use Strings a bunch for errors. They're passed to the Tauri frontend,
+// which requires that they be serializable.
+// Can rework that in the future too, but my main concern is avoiding tauri limitations/reqs
+// ending up in the main crate.
+
+use serde::Serialize;
+use sqlx::{sqlite::SqliteRow, Row};
+use std::collections::HashMap;
+use std::path::PathBuf;
+
+use atuin_client::settings::{FilterMode, SearchMode};
+use atuin_client::{
+ database::{Context, Database, OptFilters, Sqlite},
+ history::History,
+};
+
+// useful for preprocessing data for the frontend
+#[derive(Serialize, Debug)]
+pub struct NameValue<T> {
+ pub name: String,
+ pub value: T,
+}
+
+#[derive(Serialize, Debug)]
+pub struct GlobalStats {
+ pub total_history: u64,
+
+ pub daily: Vec<NameValue<u64>>,
+
+ pub last_1d: u64,
+ pub last_7d: u64,
+ pub last_30d: u64,
+}
+
+#[derive(Serialize)]
+pub struct UIHistory {
+ pub id: String,
+ /// When the command was run.
+ pub timestamp: i128,
+ /// How long the command took to run.
+ pub duration: i64,
+ /// The exit code of the command.
+ pub exit: i64,
+ /// The command that was run.
+ pub command: String,
+ /// The current working directory when the command was run.
+ pub cwd: String,
+ /// The session ID, associated with a terminal session.
+ pub session: String,
+ /// The hostname of the machine the command was run on.
+ pub user: String,
+
+ pub host: String,
+}
+
+pub fn to_ui_history(history: History) -> UIHistory {
+ let parts: Vec<String> = history.hostname.split(':').map(str::to_string).collect();
+
+ let (host, user) = if parts.len() == 2 {
+ (parts[0].clone(), parts[1].clone())
+ } else {
+ ("no-host".to_string(), "no-user".to_string())
+ };
+
+ let mac = format!("/Users/{}", user);
+ let linux = format!("/home/{}", user);
+
+ let cwd = history.cwd.replace(mac.as_str(), "~");
+ let cwd = cwd.replace(linux.as_str(), "~");
+
+ UIHistory {
+ id: history.id.0,
+ timestamp: history.timestamp.unix_timestamp_nanos(),
+ duration: history.duration,
+ exit: history.exit,
+ command: history.command,
+ session: history.session,
+ host,
+ user,
+ cwd,
+ }
+}
+
+pub struct HistoryDB(Sqlite);
+
+impl HistoryDB {
+ pub async fn new(path: PathBuf, timeout: f64) -> Result<Self, String> {
+ let sqlite = Sqlite::new(path, timeout)
+ .await
+ .map_err(|e| e.to_string())?;
+
+ Ok(Self(sqlite))
+ }
+
+ pub async fn list(&self, limit: Option<usize>, unique: bool) -> Result<Vec<UIHistory>, String> {
+ let filters = vec![];
+
+ // bit of a hack but provide an empty context
+ // shell context makes _no sense_ in a GUI
+ let context = Context {
+ session: "".to_string(),
+ cwd: "".to_string(),
+ host_id: "".to_string(),
+ hostname: "".to_string(),
+ git_root: None,
+ };
+
+ let history = self
+ .0
+ .list(&filters, &context, limit, unique, false)
+ .await
+ .map_err(|e| e.to_string())?;
+
+ let history = history
+ .into_iter()
+ .filter(|h| h.duration > 0)
+ .map(to_ui_history)
+ .collect();
+
+ Ok(history)
+ }
+
+ pub async fn search(&self, query: &str) -> Result<Vec<UIHistory>, 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(200),
+ ..OptFilters::default()
+ };
+
+ let history = self
+ .0
+ .search(
+ SearchMode::Fuzzy,
+ FilterMode::Global,
+ &context,
+ query,
+ filters,
+ )
+ .await
+ .map_err(|e| e.to_string())?;
+
+ let history = history
+ .into_iter()
+ .filter(|h| h.duration > 0)
+ .map(to_ui_history)
+ .collect();
+
+ Ok(history)
+ }
+
+ pub async fn global_stats(&self) -> Result<GlobalStats, String> {
+ let day_ago = time::OffsetDateTime::now_utc() - time::Duration::days(1);
+ let day_ago = day_ago.unix_timestamp_nanos();
+
+ let week_ago = time::OffsetDateTime::now_utc() - time::Duration::days(7);
+ let week_ago = week_ago.unix_timestamp_nanos();
+
+ let month_ago = time::OffsetDateTime::now_utc() - time::Duration::days(30);
+ let month_ago = month_ago.unix_timestamp_nanos();
+
+ // get the last 30 days of shell history
+ let history: Vec<UIHistory> = sqlx::query("SELECT * FROM history WHERE timestamp > ?")
+ .bind(month_ago as i64)
+ .map(|row: SqliteRow| {
+ History::from_db()
+ .id(row.get("id"))
+ .timestamp(
+ time::OffsetDateTime::from_unix_timestamp_nanos(
+ row.get::<i64, _>("timestamp") as i128,
+ )
+ .unwrap(),
+ )
+ .duration(row.get("duration"))
+ .exit(row.get("exit"))
+ .command(row.get("command"))
+ .cwd(row.get("cwd"))
+ .session(row.get("session"))
+ .hostname(row.get("hostname"))
+ .deleted_at(None)
+ .build()
+ .into()
+ })
+ .map(to_ui_history)
+ .fetch_all(&self.0.pool)
+ .await
+ .map_err(|e| e.to_string())?;
+
+ let total: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM history")
+ .fetch_one(&self.0.pool)
+ .await
+ .map_err(|e| e.to_string())?;
+
+ let mut day = 0;
+ let mut week = 0;
+ let mut month = 0;
+
+ let mut daily = HashMap::new();
+ let ymd = time::format_description::parse("[year]-[month]-[day]").unwrap();
+
+ for i in history {
+ if i.timestamp > day_ago {
+ day += 1;
+ }
+
+ if i.timestamp > week_ago {
+ week += 1;
+ }
+
+ if i.timestamp > month_ago {
+ month += 1;
+
+ // get the start of the day, as a unix timestamp
+ let date = time::OffsetDateTime::from_unix_timestamp_nanos(i.timestamp)
+ .unwrap()
+ .format(&ymd)
+ .unwrap();
+
+ daily.entry(date).and_modify(|v| *v += 1).or_insert(1);
+ }
+ }
+
+ let mut daily: Vec<NameValue<u64>> = daily
+ .into_iter()
+ .map(|(k, v)| NameValue { name: k, value: v })
+ .collect();
+ daily.sort_by(|a, b| a.name.cmp(&b.name));
+
+ Ok(GlobalStats {
+ total_history: total.0 as u64,
+ last_30d: month,
+ last_7d: week,
+ last_1d: day,
+ daily,
+ })
+ }
+}
diff --git a/ui/backend/src/dotfiles/aliases.rs b/ui/backend/src/dotfiles/aliases.rs
new file mode 100644
index 00000000..972466fe
--- /dev/null
+++ b/ui/backend/src/dotfiles/aliases.rs
@@ -0,0 +1,91 @@
+use std::path::PathBuf;
+
+use atuin_client::{encryption, record::sqlite_store::SqliteStore, settings::Settings};
+use atuin_common::shell::Shell;
+use atuin_dotfiles::{
+ shell::{existing_aliases, Alias},
+ store::AliasStore,
+};
+
+async fn alias_store() -> eyre::Result<AliasStore> {
+ let settings = Settings::new()?;
+
+ let record_store_path = PathBuf::from(settings.record_store_path.as_str());
+ let sqlite_store = SqliteStore::new(record_store_path, settings.local_timeout).await?;
+
+ let encryption_key: [u8; 32] = encryption::load_key(&settings)?.into();
+
+ let host_id = Settings::host_id().expect("failed to get host_id");
+
+ Ok(AliasStore::new(sqlite_store, host_id, encryption_key))
+}
+
+#[tauri::command]
+pub async fn aliases() -> Result<Vec<Alias>, String> {
+ let alias_store = alias_store().await.map_err(|e| e.to_string())?;
+
+ let aliases = alias_store
+ .aliases()
+ .await
+ .map_err(|e| format!("failed to load aliases: {}", e))?;
+
+ Ok(aliases)
+}
+
+#[tauri::command]
+pub async fn delete_alias(name: String) -> Result<(), String> {
+ let alias_store = alias_store().await.map_err(|e| e.to_string())?;
+
+ alias_store
+ .delete(name.as_str())
+ .await
+ .map_err(|e| e.to_string())?;
+
+ Ok(())
+}
+
+#[tauri::command]
+pub async fn set_alias(name: String, value: String) -> Result<(), String> {
+ let alias_store = alias_store().await.map_err(|e| e.to_string())?;
+
+ alias_store
+ .set(name.as_str(), value.as_str())
+ .await
+ .map_err(|e| e.to_string())?;
+
+ Ok(())
+}
+
+#[tauri::command]
+pub async fn import_aliases() -> Result<Vec<Alias>, String> {
+ let store = alias_store().await.map_err(|e| e.to_string())?;
+ let shell = Shell::default_shell().map_err(|e| e.to_string())?;
+ let shell_name = shell.to_string();
+
+ if !shell.is_posixish() {
+ return Err(format!(
+ "Default shell {shell_name} not supported for import"
+ ));
+ }
+
+ let existing_aliases = existing_aliases(Some(shell)).map_err(|e| e.to_string())?;
+ let store_aliases = store.aliases().await.map_err(|e| e.to_string())?;
+
+ let mut res = Vec::new();
+
+ for alias in existing_aliases {
+ // O(n), but n is small, and imports infrequent
+ // can always make a map
+ if store_aliases.contains(&alias) {
+ continue;
+ }
+
+ res.push(alias.clone());
+ store
+ .set(&alias.name, &alias.value)
+ .await
+ .map_err(|e| e.to_string())?;
+ }
+
+ Ok(res)
+}
diff --git a/ui/backend/src/dotfiles/mod.rs b/ui/backend/src/dotfiles/mod.rs
new file mode 100644
index 00000000..d293a01b
--- /dev/null
+++ b/ui/backend/src/dotfiles/mod.rs
@@ -0,0 +1 @@
+pub mod aliases;
diff --git a/ui/backend/src/main.rs b/ui/backend/src/main.rs
new file mode 100644
index 00000000..98967562
--- /dev/null
+++ b/ui/backend/src/main.rs
@@ -0,0 +1,63 @@
+// Prevents additional console window on Windows in release, DO NOT REMOVE!!
+#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
+
+use std::path::PathBuf;
+
+use atuin_client::settings::Settings;
+
+mod db;
+mod dotfiles;
+mod store;
+
+use db::{GlobalStats, HistoryDB, UIHistory};
+use dotfiles::aliases::aliases;
+
+#[tauri::command]
+async fn list() -> Result<Vec<UIHistory>, 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.list(Some(100), false).await?;
+
+ Ok(history)
+}
+
+#[tauri::command]
+async fn search(query: String) -> Result<Vec<UIHistory>, 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.search(query.as_str()).await?;
+
+ Ok(history)
+}
+
+#[tauri::command]
+async fn global_stats() -> Result<GlobalStats, 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 stats = db.global_stats().await?;
+
+ Ok(stats)
+}
+
+fn main() {
+ tauri::Builder::default()
+ .invoke_handler(tauri::generate_handler![
+ list,
+ search,
+ global_stats,
+ aliases,
+ dotfiles::aliases::import_aliases,
+ dotfiles::aliases::delete_alias,
+ dotfiles::aliases::set_alias,
+ ])
+ .run(tauri::generate_context!())
+ .expect("error while running tauri application");
+}
diff --git a/ui/backend/src/store.rs b/ui/backend/src/store.rs
new file mode 100644
index 00000000..8b137891
--- /dev/null
+++ b/ui/backend/src/store.rs
@@ -0,0 +1 @@
+