diff options
| author | Ellie Huxtable <ellie@elliehuxtable.com> | 2024-04-11 16:59:01 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2024-04-11 16:59:01 +0100 |
| commit | 6cd4319fcf540ef70f74cc2f10d0d4297ee7b788 (patch) | |
| tree | 3d24dbf70493c377e162d9941faac65c829623f9 /ui/backend/src | |
| parent | feat(bash/blesh): use _ble_exec_time_ata for duration even in bash < 5 (#1940) (diff) | |
| download | atuin-6cd4319fcf540ef70f74cc2f10d0d4297ee7b788.zip | |
feat(gui): add base structure (#1935)
* initial
* ui things
* cargo
* update, add history refresh button
* history page a bit better, add initial dotfiles page
* re-org layout
* bye squigglies
* add dotfiles ui, show aliases
* add default shell detection
* put stats in a little drawer, alias import changes
* use new table for aliases, add alias deleting
* support adding aliases
* close drawer when added, no alias autocomplete
* clippy, format
* attempt to ensure gdk is installed ok
* sudo
* no linux things on mac ffs
* I forgot we build for windows too... end of day
* remove tauri backend from workspace
Diffstat (limited to 'ui/backend/src')
| -rw-r--r-- | ui/backend/src/db.rs | 245 | ||||
| -rw-r--r-- | ui/backend/src/dotfiles/aliases.rs | 91 | ||||
| -rw-r--r-- | ui/backend/src/dotfiles/mod.rs | 1 | ||||
| -rw-r--r-- | ui/backend/src/main.rs | 63 | ||||
| -rw-r--r-- | ui/backend/src/store.rs | 1 |
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 @@ + |
