feat(ui): scroll history infinitely (#1999)

* wip, history scrolls right!

* wip

* virtual scroll fucking worksssss

* paging works :)

* scroll search results now too
This commit is contained in:
Ellie Huxtable 2024-05-06 08:11:47 +01:00 committed by GitHub
parent 851e581e16
commit 754ddeaa8d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 607 additions and 206 deletions

View File

@ -1,14 +1,16 @@
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use crossterm::style::{Color, ResetColor, SetAttribute, SetForegroundColor}; use crossterm::style::{Color, ResetColor, SetAttribute, SetForegroundColor};
use serde::{Deserialize, Serialize};
use atuin_client::{history::History, settings::Settings};
use unicode_segmentation::UnicodeSegmentation; use unicode_segmentation::UnicodeSegmentation;
pub struct Stats<'a> { use atuin_client::{history::History, settings::Settings};
#[derive(Debug, Serialize, Deserialize)]
pub struct Stats {
pub total_commands: usize, pub total_commands: usize,
pub unique_commands: usize, pub unique_commands: usize,
pub top: Vec<(Vec<&'a str>, usize)>, pub top: Vec<(Vec<String>, usize)>,
} }
fn first_non_whitespace(s: &str) -> Option<usize> { fn first_non_whitespace(s: &str) -> Option<usize> {
@ -161,12 +163,12 @@ pub fn pretty_print(stats: Stats, ngram_size: usize) {
println!("Unique commands: {}", stats.unique_commands); println!("Unique commands: {}", stats.unique_commands);
} }
pub fn compute<'a>( pub fn compute(
settings: &Settings, settings: &Settings,
history: &'a [History], history: &[History],
count: usize, count: usize,
ngram_size: usize, ngram_size: usize,
) -> Option<Stats<'a>> { ) -> Option<Stats> {
let mut commands = HashSet::<&str>::with_capacity(history.len()); let mut commands = HashSet::<&str>::with_capacity(history.len());
let mut total_unignored = 0; let mut total_unignored = 0;
let mut prefixes = HashMap::<Vec<&str>, usize>::with_capacity(history.len()); let mut prefixes = HashMap::<Vec<&str>, usize>::with_capacity(history.len());
@ -212,7 +214,10 @@ pub fn compute<'a>(
Some(Stats { Some(Stats {
unique_commands: unique, unique_commands: unique,
total_commands: total_unignored, total_commands: total_unignored,
top, top: top
.into_iter()
.map(|t| (t.0.into_iter().map(|s| s.to_string()).collect(), t.1))
.collect(),
}) })
} }

132
ui/backend/Cargo.lock generated
View File

@ -288,6 +288,36 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "atuin-history"
version = "0.1.0"
dependencies = [
"async-trait",
"atuin-client",
"atuin-common",
"base64 0.21.7",
"crossterm",
"directories",
"eyre",
"fs-err",
"futures-util",
"indicatif",
"interim",
"itertools",
"log",
"semver",
"serde",
"serde_json",
"sysinfo",
"time",
"tokio",
"tracing",
"unicode-segmentation",
"unicode-width",
"uuid",
"whoami",
]
[[package]] [[package]]
name = "autocfg" name = "autocfg"
version = "1.2.0" version = "1.2.0"
@ -860,6 +890,32 @@ version = "0.8.19"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345" checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345"
[[package]]
name = "crossterm"
version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df"
dependencies = [
"bitflags 2.5.0",
"crossterm_winapi",
"filedescriptor",
"libc",
"mio",
"parking_lot",
"signal-hook",
"signal-hook-mio",
"winapi",
]
[[package]]
name = "crossterm_winapi"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
dependencies = [
"winapi",
]
[[package]] [[package]]
name = "crypto-common" name = "crypto-common"
version = "0.1.6" version = "0.1.6"
@ -1324,6 +1380,17 @@ dependencies = [
"rustc_version", "rustc_version",
] ]
[[package]]
name = "filedescriptor"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7199d965852c3bac31f779ef99cbb4537f80e952e2d6aa0ffeb30cce00f4f46e"
dependencies = [
"libc",
"thiserror",
"winapi",
]
[[package]] [[package]]
name = "finl_unicode" name = "finl_unicode"
version = "1.2.0" version = "1.2.0"
@ -2602,6 +2669,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
dependencies = [ dependencies = [
"libc", "libc",
"log",
"wasi 0.11.0+wasi-snapshot-preview1", "wasi 0.11.0+wasi-snapshot-preview1",
"windows-sys 0.48.0", "windows-sys 0.48.0",
] ]
@ -4114,6 +4182,27 @@ dependencies = [
"dirs", "dirs",
] ]
[[package]]
name = "signal-hook"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801"
dependencies = [
"libc",
"signal-hook-registry",
]
[[package]]
name = "signal-hook-mio"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29ad2e15f37ec9a6cc544097b78a1ec90001e9f71b81338ca39f430adaca99af"
dependencies = [
"libc",
"mio",
"signal-hook",
]
[[package]] [[package]]
name = "signal-hook-registry" name = "signal-hook-registry"
version = "1.4.1" version = "1.4.1"
@ -4819,6 +4908,41 @@ dependencies = [
"tauri-utils", "tauri-utils",
] ]
[[package]]
name = "tauri-plugin"
version = "2.0.0-beta.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6baaee0a083db1e04a1b7a3b0670d86a4d95dd2a54e7cbfb5547762b8ed098d9"
dependencies = [
"anyhow",
"glob",
"plist",
"schemars",
"serde",
"serde_json",
"tauri-utils",
"toml 0.8.12",
"walkdir",
]
[[package]]
name = "tauri-plugin-sql"
version = "2.0.0-beta.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90c17360ef831e2789aab5439c241c8d7787ebd7c4fc40540181539b151f93bb"
dependencies = [
"futures-core",
"log",
"serde",
"serde_json",
"sqlx",
"tauri",
"tauri-plugin",
"thiserror",
"time",
"tokio",
]
[[package]] [[package]]
name = "tauri-runtime" name = "tauri-runtime"
version = "2.0.0-beta.11" version = "2.0.0-beta.11"
@ -4864,16 +4988,16 @@ dependencies = [
[[package]] [[package]]
name = "tauri-utils" name = "tauri-utils"
version = "2.0.0-beta.11" version = "2.0.0-beta.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a148adf8077e1891c8b7d1c2be90c1c8eb8c7a071c35bb8edbdfe7cd9d8e23c" checksum = "d4709765385f035338ecc330f3fba753b8ee283c659c235da9768949cdb25469"
dependencies = [ dependencies = [
"brotli", "brotli",
"cargo_metadata", "cargo_metadata",
"ctor", "ctor",
"dunce", "dunce",
"glob", "glob",
"heck 0.4.1", "heck 0.5.0",
"html5ever", "html5ever",
"infer", "infer",
"json-patch", "json-patch",
@ -5316,6 +5440,7 @@ dependencies = [
"atuin-client", "atuin-client",
"atuin-common", "atuin-common",
"atuin-dotfiles", "atuin-dotfiles",
"atuin-history",
"eyre", "eyre",
"serde", "serde",
"serde_json", "serde_json",
@ -5323,6 +5448,7 @@ dependencies = [
"syntect", "syntect",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-sql",
"time", "time",
"uuid", "uuid",
] ]

View File

@ -14,8 +14,8 @@ tauri-build = { version = "2.0.0-beta", features = [] }
[dependencies] [dependencies]
atuin-client = { path = "../../crates/atuin-client", version = "18.2.0" } atuin-client = { path = "../../crates/atuin-client", version = "18.2.0" }
atuin-common = { path = "../../crates/atuin-common", version = "18.2.0" } atuin-common = { path = "../../crates/atuin-common", version = "18.2.0" }
atuin-dotfiles = { path = "../../crates/atuin-dotfiles", version = "0.2.0" } atuin-dotfiles = { path = "../../crates/atuin-dotfiles", version = "0.2.0" }
atuin-history = { path = "../../crates/atuin-history", version = "0.1.0" }
eyre = "0.6" eyre = "0.6"
tauri = { version = "2.0.0-beta", features = ["tray-icon"] } tauri = { version = "2.0.0-beta", features = ["tray-icon"] }
@ -36,3 +36,7 @@ custom-protocol = ["tauri/custom-protocol"]
#[lib] #[lib]
#crate-type = ["staticlib", "cdylib", "rlib"] #crate-type = ["staticlib", "cdylib", "rlib"]
[dependencies.tauri-plugin-sql]
features = ["sqlite"] # or "postgres", or "mysql"
version = "2.0.0-beta"

View File

@ -2,9 +2,7 @@
"identifier": "migrated", "identifier": "migrated",
"description": "permissions that were migrated from v1", "description": "permissions that were migrated from v1",
"context": "local", "context": "local",
"windows": [ "windows": ["main"],
"main"
],
"permissions": [ "permissions": [
"path:default", "path:default",
"event:default", "event:default",
@ -12,13 +10,10 @@
"app:default", "app:default",
"resources:default", "resources:default",
"menu:default", "menu:default",
"tray:default" "tray:default",
"sql:allow-load",
"sql:allow-execute",
"sql:allow-select"
], ],
"platforms": [ "platforms": ["linux", "macOS", "windows", "android", "iOS"]
"linux",
"macOS",
"windows",
"android",
"iOS"
]
} }

View File

@ -15,6 +15,7 @@ use atuin_client::{
database::{Context, Database, OptFilters, Sqlite}, database::{Context, Database, OptFilters, Sqlite},
history::History, history::History,
}; };
use atuin_history::stats;
// useful for preprocessing data for the frontend // useful for preprocessing data for the frontend
#[derive(Serialize, Debug)] #[derive(Serialize, Debug)]
@ -28,6 +29,7 @@ pub struct GlobalStats {
pub total_history: u64, pub total_history: u64,
pub daily: Vec<NameValue<u64>>, pub daily: Vec<NameValue<u64>>,
pub stats: Option<stats::Stats>,
pub last_1d: u64, pub last_1d: u64,
pub last_7d: u64, pub last_7d: u64,
@ -55,7 +57,8 @@ pub struct UIHistory {
pub host: String, pub host: String,
} }
pub fn to_ui_history(history: History) -> UIHistory { impl From<History> for UIHistory {
fn from(history: History) -> Self {
let parts: Vec<String> = history.hostname.split(':').map(str::to_string).collect(); let parts: Vec<String> = history.hostname.split(':').map(str::to_string).collect();
let (host, user) = if parts.len() == 2 { let (host, user) = if parts.len() == 2 {
@ -82,6 +85,7 @@ pub fn to_ui_history(history: History) -> UIHistory {
cwd, cwd,
} }
} }
}
pub struct HistoryDB(Sqlite); pub struct HistoryDB(Sqlite);
@ -94,35 +98,47 @@ impl HistoryDB {
Ok(Self(sqlite)) Ok(Self(sqlite))
} }
pub async fn list(&self, limit: Option<usize>, unique: bool) -> Result<Vec<UIHistory>, String> { pub async fn list(
let filters = vec![]; &self,
offset: Option<u64>,
// bit of a hack but provide an empty context limit: Option<usize>,
// shell context makes _no sense_ in a GUI ) -> Result<Vec<History>, String> {
let context = Context { let query = if let Some(limit) = limit {
session: "".to_string(), sqlx::query("select * from history order by timestamp desc limit ?1 offset ?2")
cwd: "".to_string(), .bind(limit as i64)
host_id: "".to_string(), .bind(offset.unwrap_or(0) as i64)
hostname: "".to_string(), } else {
git_root: None, sqlx::query("select * from history order by timestamp desc")
}; };
let history = self let history: Vec<History> = query
.0 .map(|row: SqliteRow| {
.list(&filters, &context, limit, unique, false) 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()
})
.fetch_all(&self.0.pool)
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let history = history
.into_iter()
.filter(|h| h.duration > 0)
.map(to_ui_history)
.collect();
Ok(history) Ok(history)
} }
pub async fn search(&self, query: &str) -> Result<Vec<UIHistory>, String> { pub async fn search(&self, offset: Option<u64>, query: &str) -> Result<Vec<UIHistory>, String> {
let context = Context { let context = Context {
session: "".to_string(), session: "".to_string(),
cwd: "".to_string(), cwd: "".to_string(),
@ -133,6 +149,7 @@ impl HistoryDB {
let filters = OptFilters { let filters = OptFilters {
limit: Some(200), limit: Some(200),
offset: offset.map(|offset| offset as i64),
..OptFilters::default() ..OptFilters::default()
}; };
@ -151,7 +168,7 @@ impl HistoryDB {
let history = history let history = history
.into_iter() .into_iter()
.filter(|h| h.duration > 0) .filter(|h| h.duration > 0)
.map(to_ui_history) .map(|h| h.into())
.collect(); .collect();
Ok(history) Ok(history)
@ -189,7 +206,7 @@ impl HistoryDB {
.build() .build()
.into() .into()
}) })
.map(to_ui_history) .map(|h: History| h.into())
.fetch_all(&self.0.pool) .fetch_all(&self.0.pool)
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
@ -240,6 +257,7 @@ impl HistoryDB {
last_7d: week, last_7d: week,
last_1d: day, last_1d: day,
daily, daily,
stats: None,
}) })
} }
} }

View File

@ -13,6 +13,7 @@ mod store;
use atuin_client::{ use atuin_client::{
encryption, history::HISTORY_TAG, record::sqlite_store::SqliteStore, record::store::Store, encryption, history::HISTORY_TAG, record::sqlite_store::SqliteStore, record::store::Store,
}; };
use atuin_history::stats;
use db::{GlobalStats, HistoryDB, UIHistory}; use db::{GlobalStats, HistoryDB, UIHistory};
use dotfiles::aliases::aliases; use dotfiles::aliases::aliases;
@ -25,25 +26,30 @@ struct HomeInfo {
} }
#[tauri::command] #[tauri::command]
async fn list() -> Result<Vec<UIHistory>, String> { async fn list(offset: Option<u64>) -> Result<Vec<UIHistory>, String> {
let settings = Settings::new().map_err(|e| e.to_string())?; let settings = Settings::new().map_err(|e| e.to_string())?;
let db_path = PathBuf::from(settings.db_path.as_str()); let db_path = PathBuf::from(settings.db_path.as_str());
let db = HistoryDB::new(db_path, settings.local_timeout).await?; let db = HistoryDB::new(db_path, settings.local_timeout).await?;
let history = db.list(Some(100), false).await?; let history = db
.list(Some(offset.unwrap_or(0)), Some(100))
.await?
.into_iter()
.map(|h| h.into())
.collect();
Ok(history) Ok(history)
} }
#[tauri::command] #[tauri::command]
async fn search(query: String) -> Result<Vec<UIHistory>, String> { async fn search(query: String, offset: Option<u64>) -> Result<Vec<UIHistory>, String> {
let settings = Settings::new().map_err(|e| e.to_string())?; let settings = Settings::new().map_err(|e| e.to_string())?;
let db_path = PathBuf::from(settings.db_path.as_str()); let db_path = PathBuf::from(settings.db_path.as_str());
let db = HistoryDB::new(db_path, settings.local_timeout).await?; let db = HistoryDB::new(db_path, settings.local_timeout).await?;
let history = db.search(query.as_str()).await?; let history = db.search(offset, query.as_str()).await?;
Ok(history) Ok(history)
} }
@ -54,11 +60,21 @@ async fn global_stats() -> Result<GlobalStats, String> {
let db_path = PathBuf::from(settings.db_path.as_str()); let db_path = PathBuf::from(settings.db_path.as_str());
let db = HistoryDB::new(db_path, settings.local_timeout).await?; let db = HistoryDB::new(db_path, settings.local_timeout).await?;
let stats = db.global_stats().await?; let mut stats = db.global_stats().await?;
let history = db.list(None, None).await?;
let history_stats = stats::compute(&settings, &history, 10, 1);
stats.stats = history_stats;
Ok(stats) Ok(stats)
} }
#[tauri::command]
async fn config() -> Result<Settings, String> {
Settings::new().map_err(|e| e.to_string())
}
#[tauri::command] #[tauri::command]
async fn home_info() -> Result<HomeInfo, String> { async fn home_info() -> Result<HomeInfo, String> {
let settings = Settings::new().map_err(|e| e.to_string())?; let settings = Settings::new().map_err(|e| e.to_string())?;
@ -115,6 +131,7 @@ fn main() {
global_stats, global_stats,
aliases, aliases,
home_info, home_info,
config,
dotfiles::aliases::import_aliases, dotfiles::aliases::import_aliases,
dotfiles::aliases::delete_alias, dotfiles::aliases::delete_alias,
dotfiles::aliases::set_alias, dotfiles::aliases::set_alias,
@ -122,6 +139,7 @@ fn main() {
dotfiles::vars::delete_var, dotfiles::vars::delete_var,
dotfiles::vars::set_var, dotfiles::vars::set_var,
]) ])
.plugin(tauri_plugin_sql::Builder::default().build())
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
} }

View File

@ -16,7 +16,9 @@
"@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-slot": "^1.0.2",
"@tailwindcss/forms": "^0.5.7", "@tailwindcss/forms": "^0.5.7",
"@tanstack/react-table": "^8.15.3", "@tanstack/react-table": "^8.15.3",
"@tanstack/react-virtual": "^3.5.0",
"@tauri-apps/api": "2.0.0-beta.7", "@tauri-apps/api": "2.0.0-beta.7",
"@tauri-apps/plugin-sql": "2.0.0-beta.2",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.0", "clsx": "^2.1.0",
"core": "link:@tauri-apps/api/core", "core": "link:@tauri-apps/api/core",
@ -27,6 +29,8 @@
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-spinners": "^0.13.8", "react-spinners": "^0.13.8",
"react-window": "^1.8.10",
"react-window-infinite-loader": "^1.0.9",
"recharts": "^2.12.4", "recharts": "^2.12.4",
"tailwind-merge": "^2.2.2", "tailwind-merge": "^2.2.2",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",

View File

@ -23,9 +23,15 @@ dependencies:
'@tanstack/react-table': '@tanstack/react-table':
specifier: ^8.15.3 specifier: ^8.15.3
version: 8.15.3(react-dom@18.2.0)(react@18.2.0) version: 8.15.3(react-dom@18.2.0)(react@18.2.0)
'@tanstack/react-virtual':
specifier: ^3.5.0
version: 3.5.0(react-dom@18.2.0)(react@18.2.0)
'@tauri-apps/api': '@tauri-apps/api':
specifier: 2.0.0-beta.7 specifier: 2.0.0-beta.7
version: 2.0.0-beta.7 version: 2.0.0-beta.7
'@tauri-apps/plugin-sql':
specifier: 2.0.0-beta.2
version: 2.0.0-beta.2
class-variance-authority: class-variance-authority:
specifier: ^0.7.0 specifier: ^0.7.0
version: 0.7.0 version: 0.7.0
@ -56,6 +62,12 @@ dependencies:
react-spinners: react-spinners:
specifier: ^0.13.8 specifier: ^0.13.8
version: 0.13.8(react-dom@18.2.0)(react@18.2.0) version: 0.13.8(react-dom@18.2.0)(react@18.2.0)
react-window:
specifier: ^1.8.10
version: 1.8.10(react-dom@18.2.0)(react@18.2.0)
react-window-infinite-loader:
specifier: ^1.0.9
version: 1.0.9(react-dom@18.2.0)(react@18.2.0)
recharts: recharts:
specifier: ^2.12.4 specifier: ^2.12.4
version: 2.12.4(react-dom@18.2.0)(react@18.2.0) version: 2.12.4(react-dom@18.2.0)(react@18.2.0)
@ -584,7 +596,7 @@ packages:
react: ^16 || ^17 || ^18 react: ^16 || ^17 || ^18
react-dom: ^16 || ^17 || ^18 react-dom: ^16 || ^17 || ^18
dependencies: dependencies:
'@tanstack/react-virtual': 3.2.0(react-dom@18.2.0)(react@18.2.0) '@tanstack/react-virtual': 3.5.0(react-dom@18.2.0)(react@18.2.0)
client-only: 0.0.1 client-only: 0.0.1
react: 18.2.0 react: 18.2.0
react-dom: 18.2.0(react@18.2.0) react-dom: 18.2.0(react@18.2.0)
@ -1300,13 +1312,13 @@ packages:
react-dom: 18.2.0(react@18.2.0) react-dom: 18.2.0(react@18.2.0)
dev: false dev: false
/@tanstack/react-virtual@3.2.0(react-dom@18.2.0)(react@18.2.0): /@tanstack/react-virtual@3.5.0(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-OEdMByf2hEfDa6XDbGlZN8qO6bTjlNKqjM3im9JG+u3mCL8jALy0T/67oDI001raUUPh1Bdmfn4ZvPOV5knpcg==} resolution: {integrity: sha512-rtvo7KwuIvqK9zb0VZ5IL7fiJAEnG+0EiFZz8FUOs+2mhGqdGmjKIaT1XU7Zq0eFqL0jonLlhbayJI/J2SA/Bw==}
peerDependencies: peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 react: ^16.8.0 || ^17.0.0 || ^18.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
dependencies: dependencies:
'@tanstack/virtual-core': 3.2.0 '@tanstack/virtual-core': 3.5.0
react: 18.2.0 react: 18.2.0
react-dom: 18.2.0(react@18.2.0) react-dom: 18.2.0(react@18.2.0)
dev: false dev: false
@ -1316,8 +1328,13 @@ packages:
engines: {node: '>=12'} engines: {node: '>=12'}
dev: false dev: false
/@tanstack/virtual-core@3.2.0: /@tanstack/virtual-core@3.5.0:
resolution: {integrity: sha512-P5XgYoAw/vfW65byBbJQCw+cagdXDT/qH6wmABiLt4v4YBT2q2vqCOhihe+D1Nt325F/S/0Tkv6C5z0Lv+VBQQ==} resolution: {integrity: sha512-KnPRCkQTyqhanNC0K63GBG3wA8I+D1fQuVnAvcBF8f13akOKeQp1gSbu6f77zCxhEk727iV5oQnbHLYzHrECLg==}
dev: false
/@tauri-apps/api@2.0.0-beta.4:
resolution: {integrity: sha512-Nxtj28NYUo5iwYkpYslxmOPkdI2WkELU2e3UH9nbJm9Ydki2CQwJVGQxx4EANtdZcMNsEsUzRqaDTvEUYH1l6w==}
engines: {node: '>= 18', npm: '>= 6.6.0', yarn: '>= 1.19.1'}
dev: false dev: false
/@tauri-apps/api@2.0.0-beta.7: /@tauri-apps/api@2.0.0-beta.7:
@ -1432,6 +1449,12 @@ packages:
'@tauri-apps/cli-win32-x64-msvc': 2.0.0-beta.2 '@tauri-apps/cli-win32-x64-msvc': 2.0.0-beta.2
dev: true dev: true
/@tauri-apps/plugin-sql@2.0.0-beta.2:
resolution: {integrity: sha512-gNX/4VjGl0TD4Ct58ar4bLF82iRp2L5sS79FmtzKlXYj7tVbkxenIi+mGIBz3Ut1JQP5WNL4/5wq74bkDlBggA==}
dependencies:
'@tauri-apps/api': 2.0.0-beta.4
dev: false
/@types/babel__core@7.20.5: /@types/babel__core@7.20.5:
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
dependencies: dependencies:
@ -2107,6 +2130,10 @@ packages:
engines: {node: '>=12'} engines: {node: '>=12'}
dev: false dev: false
/memoize-one@5.2.1:
resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==}
dev: false
/merge2@1.4.1: /merge2@1.4.1:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@ -2381,6 +2408,30 @@ packages:
react-dom: 18.2.0(react@18.2.0) react-dom: 18.2.0(react@18.2.0)
dev: false dev: false
/react-window-infinite-loader@1.0.9(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-5Hg89IdU4Vrp0RT8kZYKeTIxWZYhNkVXeI1HbKo01Vm/Z7qztDvXljwx16sMzsa9yapRJQW3ODZfMUw38SOWHw==}
engines: {node: '>8.0.0'}
peerDependencies:
react: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0
react-dom: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0
dependencies:
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/react-window@1.8.10(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-Y0Cx+dnU6NLa5/EvoHukUD0BklJ8qITCtVEPY1C/nL8wwoZ0b5aEw8Ff1dOVHw7fCzMt55XfJDd8S8W8LCaUCg==}
engines: {node: '>8.0.0'}
peerDependencies:
react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0
react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0
dependencies:
'@babel/runtime': 7.24.4
memoize-one: 5.2.1
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/react@18.2.0: /react@18.2.0:
resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==} resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}

View File

@ -9,3 +9,19 @@ html {
.logo.react:hover { .logo.react:hover {
filter: drop-shadow(0 0 2em #61dafb); filter: drop-shadow(0 0 2em #61dafb);
} }
.history-header {
height: 150px;
}
.history-search {
height: 64px;
}
.history-list {
height: calc(100vh - 150px - 64px);
}
.history-item {
height: 90px;
}

View File

@ -1,3 +1,4 @@
import { useRef } from "react";
import { ChevronRightIcon } from "@heroicons/react/20/solid"; import { ChevronRightIcon } from "@heroicons/react/20/solid";
// @ts-ignore // @ts-ignore
@ -19,14 +20,29 @@ function msToTime(ms: number) {
export default function HistoryList(props: any) { export default function HistoryList(props: any) {
return ( return (
<ul <div
role="list" role="list"
className="divide-y divide-gray-100 overflow-hidden bg-white shadow-sm ring-1 ring-gray-900/5" className="divide-y divide-gray-100 bg-white shadow-sm ring-1 ring-gray-900/5 overflow-auto"
style={{
height: `${props.height}px`,
position: "relative",
}}
> >
{props.history.map((h: any) => ( {props.items.map((i: any) => {
let h = props.history[i.index];
return (
<li <li
key={h.id} key={h.id}
className="relative flex justify-between gap-x-6 px-4 py-5 hover:bg-gray-50 sm:px-6" className="relative flex justify-between gap-x-6 px-4 py-5 hover:bg-gray-50 sm:px-6"
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: `${i.size}px`,
transform: `translateY(${i.start}px)`,
}}
> >
<div className="flex min-w-0 gap-x-4"> <div className="flex min-w-0 gap-x-4">
<div className="flex flex-col justify-center"> <div className="flex flex-col justify-center">
@ -68,12 +84,7 @@ export default function HistoryList(props: any) {
</time> </time>
</p> </p>
) : ( ) : (
<div className="mt-1 flex items-center gap-x-1.5"> <div />
<div className="flex-none rounded-full bg-emerald-500/20 p-1">
<div className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
</div>
<p className="text-xs leading-5 text-gray-500">Online</p>
</div>
)} )}
</div> </div>
<ChevronRightIcon <ChevronRightIcon
@ -82,7 +93,8 @@ export default function HistoryList(props: any) {
/> />
</div> </div>
</li> </li>
))} );
</ul> })}
</div>
); );
} }

View File

@ -3,12 +3,12 @@ import { ArrowPathIcon } from "@heroicons/react/24/outline";
import { MagnifyingGlassIcon } from "@heroicons/react/20/solid"; import { MagnifyingGlassIcon } from "@heroicons/react/20/solid";
interface HistorySearchProps { interface HistorySearchProps {
refresh: (query: string) => void; query: string;
refresh: () => void;
setQuery: (query: string) => void;
} }
export default function HistorySearch(props: HistorySearchProps) { export default function HistorySearch(props: HistorySearchProps) {
let [searchQuery, setSearchQuery] = useState("");
return ( return (
<div className="flex flex-1 gap-x-4 self-stretch lg:gap-x-6"> <div className="flex flex-1 gap-x-4 self-stretch lg:gap-x-6">
<form <form
@ -35,8 +35,8 @@ export default function HistorySearch(props: HistorySearchProps) {
type="search" type="search"
name="search" name="search"
onChange={(query) => { onChange={(query) => {
setSearchQuery(query.target.value); props.setQuery(query.target.value);
props.refresh(query.target.value); props.refresh();
}} }}
/> />
</form> </form>
@ -45,7 +45,7 @@ export default function HistorySearch(props: HistorySearchProps) {
type="button" type="button"
className="-m-2.5 p-2.5 text-gray-400 hover:text-gray-500" className="-m-2.5 p-2.5 text-gray-400 hover:text-gray-500"
onClick={() => { onClick={() => {
props.refresh(searchQuery); props.refresh();
}} }}
> >
<ArrowPathIcon className="h-6 w-6" aria-hidden="true" /> <ArrowPathIcon className="h-6 w-6" aria-hidden="true" />

View File

@ -16,7 +16,7 @@ import { ColumnDef } from "@tanstack/react-table";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import Drawer from "@/components/Drawer"; import Drawer from "@/components/Drawer";
import { Alias } from "@/state/models"; import { Alias, inspectHistory } from "@/state/models";
import { useStore } from "@/state/store"; import { useStore } from "@/state/store";
function deleteAlias(name: string, refreshAliases: () => void) { function deleteAlias(name: string, refreshAliases: () => void) {

View File

@ -19,12 +19,60 @@ function renderLoading() {
); );
} }
function TopTable({ stats }: any) {
console.log(stats);
return (
<div className="px-4 sm:px-6 lg:px-8">
<div className="flex items-center">
<div className="flex-auto">
<h1 className="text-base font-semibold">Top commands</h1>
</div>
</div>
<div className="mt-4 flow-root">
<div className="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div className="inline-block min-w-full py-2 align-middle">
<table className="min-w-full divide-y divide-gray-300">
<thead>
<tr>
<th
scope="col"
className="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6 lg:pl-8"
>
Command
</th>
<th
scope="col"
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
>
Count
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 bg-white">
{stats.map((stat) => (
<tr>
<td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6 lg:pl-8">
{stat[0][0]}
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{stat[1]}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
}
export default function Stats() { export default function Stats() {
const [stats, setStats]: any = useState([]); const [stats, setStats]: any = useState([]);
const [top, setTop]: any = useState([]);
const [chart, setChart]: any = useState([]); const [chart, setChart]: any = useState([]);
console.log("Stats mounted");
useEffect(() => { useEffect(() => {
if (stats.length != 0) return; if (stats.length != 0) return;
@ -37,6 +85,10 @@ export default function Stats() {
name: "Total history", name: "Total history",
stat: s.total_history.toLocaleString(), stat: s.total_history.toLocaleString(),
}, },
{
name: "Unique history",
stat: s.stats.unique_commands.toLocaleString(),
},
{ {
name: "Last 1d", name: "Last 1d",
stat: s.last_1d.toLocaleString(), stat: s.last_1d.toLocaleString(),
@ -52,20 +104,23 @@ export default function Stats() {
]); ]);
setChart(s.daily); setChart(s.daily);
setTop(s.stats);
}) })
.catch((e) => { .catch((e) => {
console.log(e); console.log(e);
}); });
}, []); }, []);
console.log(top);
if (stats.length == 0) { if (stats.length == 0) {
return renderLoading(); return renderLoading();
} }
return ( return (
<div className="flex flex-col"> <div className="flex flex-col overflow-y-scroll">
<div className="flexfull"> <div className="flexfull">
<dl className="grid grid-cols-1 sm:grid-cols-4 w-full"> <dl className="grid grid-cols-1 sm:grid-cols-5 w-full">
{stats.map((item: any) => ( {stats.map((item: any) => (
<div <div
key={item.name} key={item.name}
@ -94,6 +149,10 @@ export default function Stats() {
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
</div> </div>
<div>
<TopTable stats={top.top} />
</div>
</div> </div>
); );
} }

1
ui/src/global.d.ts vendored Normal file
View File

@ -0,0 +1 @@
type Option<T> = T | null;

View File

@ -1,11 +1,17 @@
import { useEffect } from "react"; import { useEffect, useState, useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import HistoryList from "@/components/HistoryList.tsx"; import HistoryList from "@/components/HistoryList.tsx";
import HistorySearch from "@/components/HistorySearch.tsx"; import HistorySearch from "@/components/HistorySearch.tsx";
import Stats from "@/components/history/Stats.tsx"; import Stats from "@/components/history/Stats.tsx";
import Drawer from "@/components/Drawer.tsx"; import Drawer from "@/components/Drawer.tsx";
import InfiniteHistory from "@/components/InfiniteHistory.tsx";
import { useStore } from "@/state/store"; import { useStore } from "@/state/store";
import { inspectHistory, listHistory } from "@/state/models";
import { invoke } from "@tauri-apps/api/core";
function Header() { function Header() {
return ( return (
<div className="md:flex md:items-center md:justify-between"> <div className="md:flex md:items-center md:justify-between">
@ -49,29 +55,64 @@ function Header() {
export default function Search() { export default function Search() {
const history = useStore((state) => state.shellHistory); const history = useStore((state) => state.shellHistory);
const refreshHistory = useStore((state) => state.refreshShellHistory); const refreshHistory = useStore((state) => state.refreshShellHistory);
const historyNextPage = useStore((state) => state.historyNextPage);
let [query, setQuery] = useState("");
useEffect(() => { useEffect(() => {
(async () => {
// nothing rn
})();
refreshHistory(); refreshHistory();
}, []); }, []);
const parentRef = useRef();
const rowVirtualizer = useVirtualizer({
count: history.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 90,
overscan: 5,
});
useEffect(() => {
const [lastItem] = rowVirtualizer.getVirtualItems().slice(-1);
if (!lastItem) return; // no undefined plz
if (lastItem.index < history.length - 1) return; // if we're not at the end yet, bail
// we're at the end! more rows plz!
historyNextPage(query);
}, [rowVirtualizer.getVirtualItems()]);
return ( return (
<> <>
<div className="pl-60"> <div className="pl-60">
<div className="p-10"> <div className="p-10 history-header">
<Header /> <Header />
<p>A history of all the commands you run in your shell.</p> <p>A history of all the commands you run in your shell.</p>
</div> </div>
<div className="flex h-16 shrink-0 items-center gap-x-4 border-b border-t border-gray-200 bg-white px-4 shadow-sm sm:gap-x-6 sm:px-6 lg:px-8"> <div className="flex h-16 shrink-0 items-center gap-x-4 border-b border-t border-gray-200 bg-white px-4 shadow-sm sm:gap-x-6 sm:px-6 lg:px-8 history-search">
<HistorySearch <HistorySearch
refresh={(query?: string) => { query={query}
setQuery={(q) => {
setQuery(q);
refreshHistory(q);
}}
refresh={() => {
refreshHistory(query); refreshHistory(query);
}} }}
/> />
</div> </div>
<main> <main className="overflow-y-scroll history-list" ref={parentRef}>
<HistoryList history={history} /> <HistoryList
history={history}
items={rowVirtualizer.getVirtualItems()}
height={rowVirtualizer.getTotalSize()}
/>
</main> </main>
</div> </div>
</> </>

View File

@ -1,3 +1,5 @@
import Database from "@tauri-apps/plugin-sql";
export interface User { export interface User {
username: string; username: string;
} }
@ -18,7 +20,7 @@ export const DefaultHomeInfo: HomeInfo = {
lastSyncTime: new Date(), lastSyncTime: new Date(),
}; };
export interface ShellHistory { export class ShellHistory {
id: string; id: string;
timestamp: number; timestamp: number;
command: string; command: string;
@ -26,6 +28,24 @@ export interface ShellHistory {
host: string; host: string;
cwd: string; cwd: string;
duration: number; duration: number;
constructor(
id: string,
timestamp: number,
command: string,
user: string,
host: string,
cwd: string,
duration: number,
) {
this.id = id;
this.timestamp = timestamp;
this.command = command;
this.user = user;
this.host = host;
this.cwd = cwd;
this.duration = duration;
}
} }
export interface Alias { export interface Alias {
@ -36,5 +56,15 @@ export interface Alias {
export interface Var { export interface Var {
name: string; name: string;
value: string; value: string;
export: bool; export: boolean;
}
export async function inspectHistory(id: string): Promise<any> {
const db = await Database.load(
"sqlite:/Users/ellie/.local/share/atuin/history.db",
);
let res = await db.select("select * from history where id=$1", [id]);
return res;
} }

View File

@ -8,6 +8,7 @@ import {
DefaultHomeInfo, DefaultHomeInfo,
Alias, Alias,
ShellHistory, ShellHistory,
Var,
} from "./models"; } from "./models";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
@ -26,9 +27,10 @@ interface AtuinState {
refreshAliases: () => void; refreshAliases: () => void;
refreshVars: () => void; refreshVars: () => void;
refreshShellHistory: (query?: string) => void; refreshShellHistory: (query?: string) => void;
historyNextPage: (query?: string) => void;
} }
export const useStore = create<AtuinState>()((set) => ({ export const useStore = create<AtuinState>()((set, get) => ({
user: DefaultUser, user: DefaultUser,
homeInfo: DefaultHomeInfo, homeInfo: DefaultHomeInfo,
aliases: [], aliases: [],
@ -78,4 +80,23 @@ export const useStore = create<AtuinState>()((set) => ({
console.log(e); console.log(e);
}); });
}, },
historyNextPage: (query?: string) => {
let history = get().shellHistory;
let offset = history.length - 1;
if (query) {
invoke("search", { query: query, offset: offset })
.then((res: any) => {
set({ shellHistory: [...history, ...res] });
})
.catch((e) => {
console.log(e);
});
} else {
invoke("list", { offset: offset }).then((res: any) => {
set({ shellHistory: [...history, ...res] });
});
}
},
})); }));