diff options
| author | Ellie Huxtable <ellie@elliehuxtable.com> | 2024-05-06 08:11:47 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2024-05-06 08:11:47 +0100 |
| commit | 754ddeaa8d3e3e4f3efc93d5bb22c68c31bb5c36 (patch) | |
| tree | f48fb912c2be2d08855e97ff24b6919a115c3c4f | |
| parent | chore(deps): bump serde_with from 3.7.0 to 3.8.1 (#2002) (diff) | |
| download | atuin-754ddeaa8d3e3e4f3efc93d5bb22c68c31bb5c36.zip | |
feat(ui): scroll history infinitely (#1999)
* wip, history scrolls right!
* wip
* virtual scroll fucking worksssss
* paging works :)
* scroll search results now too
| -rw-r--r-- | crates/atuin-history/src/stats.rs | 19 | ||||
| -rw-r--r-- | ui/backend/Cargo.lock | 132 | ||||
| -rw-r--r-- | ui/backend/Cargo.toml | 6 | ||||
| -rw-r--r-- | ui/backend/capabilities/migrated.json | 19 | ||||
| -rw-r--r-- | ui/backend/src/db.rs | 106 | ||||
| -rw-r--r-- | ui/backend/src/main.rs | 28 | ||||
| -rw-r--r-- | ui/package.json | 4 | ||||
| -rw-r--r-- | ui/pnpm-lock.yaml | 63 | ||||
| -rw-r--r-- | ui/src/App.css | 16 | ||||
| -rw-r--r-- | ui/src/components/HistoryList.tsx | 126 | ||||
| -rw-r--r-- | ui/src/components/HistorySearch.tsx | 12 | ||||
| -rw-r--r-- | ui/src/components/dotfiles/Aliases.tsx | 2 | ||||
| -rw-r--r-- | ui/src/components/history/Stats.tsx | 67 | ||||
| -rw-r--r-- | ui/src/global.d.ts | 1 | ||||
| -rw-r--r-- | ui/src/pages/History.tsx | 53 | ||||
| -rw-r--r-- | ui/src/state/models.ts | 34 | ||||
| -rw-r--r-- | ui/src/state/store.ts | 23 | ||||
| -rw-r--r-- | ui/src/styles.css | 94 |
18 files changed, 603 insertions, 202 deletions
diff --git a/crates/atuin-history/src/stats.rs b/crates/atuin-history/src/stats.rs index fb6781fe..ab4127df 100644 --- a/crates/atuin-history/src/stats.rs +++ b/crates/atuin-history/src/stats.rs @@ -1,14 +1,16 @@ use std::collections::{HashMap, HashSet}; use crossterm::style::{Color, ResetColor, SetAttribute, SetForegroundColor}; +use serde::{Deserialize, Serialize}; +use unicode_segmentation::UnicodeSegmentation; use atuin_client::{history::History, settings::Settings}; -use unicode_segmentation::UnicodeSegmentation; -pub struct Stats<'a> { +#[derive(Debug, Serialize, Deserialize)] +pub struct Stats { pub total_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> { @@ -161,12 +163,12 @@ pub fn pretty_print(stats: Stats, ngram_size: usize) { println!("Unique commands: {}", stats.unique_commands); } -pub fn compute<'a>( +pub fn compute( settings: &Settings, - history: &'a [History], + history: &[History], count: usize, ngram_size: usize, -) -> Option<Stats<'a>> { +) -> Option<Stats> { let mut commands = HashSet::<&str>::with_capacity(history.len()); let mut total_unignored = 0; let mut prefixes = HashMap::<Vec<&str>, usize>::with_capacity(history.len()); @@ -212,7 +214,10 @@ pub fn compute<'a>( Some(Stats { unique_commands: unique, total_commands: total_unignored, - top, + top: top + .into_iter() + .map(|t| (t.0.into_iter().map(|s| s.to_string()).collect(), t.1)) + .collect(), }) } diff --git a/ui/backend/Cargo.lock b/ui/backend/Cargo.lock index 85736e2a..c8eace7c 100644 --- a/ui/backend/Cargo.lock +++ b/ui/backend/Cargo.lock @@ -289,6 +289,36 @@ dependencies = [ ] [[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]] name = "autocfg" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -861,6 +891,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "crypto-common" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1325,6 +1381,17 @@ dependencies = [ ] [[package]] +name = "filedescriptor" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7199d965852c3bac31f779ef99cbb4537f80e952e2d6aa0ffeb30cce00f4f46e" +dependencies = [ + "libc", + "thiserror", + "winapi", +] + +[[package]] name = "finl_unicode" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2602,6 +2669,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" dependencies = [ "libc", + "log", "wasi 0.11.0+wasi-snapshot-preview1", "windows-sys 0.48.0", ] @@ -4115,6 +4183,27 @@ dependencies = [ ] [[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]] name = "signal-hook-registry" version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4820,6 +4909,41 @@ dependencies = [ ] [[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]] name = "tauri-runtime" version = "2.0.0-beta.11" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4864,16 +4988,16 @@ dependencies = [ [[package]] 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" -checksum = "0a148adf8077e1891c8b7d1c2be90c1c8eb8c7a071c35bb8edbdfe7cd9d8e23c" +checksum = "d4709765385f035338ecc330f3fba753b8ee283c659c235da9768949cdb25469" dependencies = [ "brotli", "cargo_metadata", "ctor", "dunce", "glob", - "heck 0.4.1", + "heck 0.5.0", "html5ever", "infer", "json-patch", @@ -5316,6 +5440,7 @@ dependencies = [ "atuin-client", "atuin-common", "atuin-dotfiles", + "atuin-history", "eyre", "serde", "serde_json", @@ -5323,6 +5448,7 @@ dependencies = [ "syntect", "tauri", "tauri-build", + "tauri-plugin-sql", "time", "uuid", ] diff --git a/ui/backend/Cargo.toml b/ui/backend/Cargo.toml index 9cf47436..1bc40b02 100644 --- a/ui/backend/Cargo.toml +++ b/ui/backend/Cargo.toml @@ -14,8 +14,8 @@ tauri-build = { version = "2.0.0-beta", features = [] } [dependencies] atuin-client = { path = "../../crates/atuin-client", 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-history = { path = "../../crates/atuin-history", version = "0.1.0" } eyre = "0.6" tauri = { version = "2.0.0-beta", features = ["tray-icon"] } @@ -36,3 +36,7 @@ custom-protocol = ["tauri/custom-protocol"] #[lib] #crate-type = ["staticlib", "cdylib", "rlib"] + +[dependencies.tauri-plugin-sql] +features = ["sqlite"] # or "postgres", or "mysql" +version = "2.0.0-beta" diff --git a/ui/backend/capabilities/migrated.json b/ui/backend/capabilities/migrated.json index a47f2d7b..70b8b077 100644 --- a/ui/backend/capabilities/migrated.json +++ b/ui/backend/capabilities/migrated.json @@ -2,9 +2,7 @@ "identifier": "migrated", "description": "permissions that were migrated from v1", "context": "local", - "windows": [ - "main" - ], + "windows": ["main"], "permissions": [ "path:default", "event:default", @@ -12,13 +10,10 @@ "app:default", "resources:default", "menu:default", - "tray:default" + "tray:default", + "sql:allow-load", + "sql:allow-execute", + "sql:allow-select" ], - "platforms": [ - "linux", - "macOS", - "windows", - "android", - "iOS" - ] -}
\ No newline at end of file + "platforms": ["linux", "macOS", "windows", "android", "iOS"] +} diff --git a/ui/backend/src/db.rs b/ui/backend/src/db.rs index c1aa4de9..7e29302a 100644 --- a/ui/backend/src/db.rs +++ b/ui/backend/src/db.rs @@ -15,6 +15,7 @@ use atuin_client::{ database::{Context, Database, OptFilters, Sqlite}, history::History, }; +use atuin_history::stats; // useful for preprocessing data for the frontend #[derive(Serialize, Debug)] @@ -28,6 +29,7 @@ pub struct GlobalStats { pub total_history: u64, pub daily: Vec<NameValue<u64>>, + pub stats: Option<stats::Stats>, pub last_1d: u64, pub last_7d: u64, @@ -55,31 +57,33 @@ pub struct UIHistory { pub host: String, } -pub fn to_ui_history(history: History) -> UIHistory { - let parts: Vec<String> = history.hostname.split(':').map(str::to_string).collect(); +impl From<History> for UIHistory { + fn from(history: History) -> Self { + 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 (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 mac = format!("/Users/{}", user); + let linux = format!("/home/{}", user); - let cwd = history.cwd.replace(mac.as_str(), "~"); - let cwd = cwd.replace(linux.as_str(), "~"); + 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, + 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, + } } } @@ -94,35 +98,47 @@ impl HistoryDB { 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, + pub async fn list( + &self, + offset: Option<u64>, + limit: Option<usize>, + ) -> Result<Vec<History>, String> { + let query = if let Some(limit) = limit { + sqlx::query("select * from history order by timestamp desc limit ?1 offset ?2") + .bind(limit as i64) + .bind(offset.unwrap_or(0) as i64) + } else { + sqlx::query("select * from history order by timestamp desc") }; - let history = self - .0 - .list(&filters, &context, limit, unique, false) + let history: Vec<History> = query + .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() + }) + .fetch_all(&self.0.pool) .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> { + pub async fn search(&self, offset: Option<u64>, query: &str) -> Result<Vec<UIHistory>, String> { let context = Context { session: "".to_string(), cwd: "".to_string(), @@ -133,6 +149,7 @@ impl HistoryDB { let filters = OptFilters { limit: Some(200), + offset: offset.map(|offset| offset as i64), ..OptFilters::default() }; @@ -151,7 +168,7 @@ impl HistoryDB { let history = history .into_iter() .filter(|h| h.duration > 0) - .map(to_ui_history) + .map(|h| h.into()) .collect(); Ok(history) @@ -189,7 +206,7 @@ impl HistoryDB { .build() .into() }) - .map(to_ui_history) + .map(|h: History| h.into()) .fetch_all(&self.0.pool) .await .map_err(|e| e.to_string())?; @@ -240,6 +257,7 @@ impl HistoryDB { last_7d: week, last_1d: day, daily, + stats: None, }) } } diff --git a/ui/backend/src/main.rs b/ui/backend/src/main.rs index fe6271b8..ce248d61 100644 --- a/ui/backend/src/main.rs +++ b/ui/backend/src/main.rs @@ -13,6 +13,7 @@ mod store; use atuin_client::{ encryption, history::HISTORY_TAG, record::sqlite_store::SqliteStore, record::store::Store, }; +use atuin_history::stats; use db::{GlobalStats, HistoryDB, UIHistory}; use dotfiles::aliases::aliases; @@ -25,25 +26,30 @@ struct HomeInfo { } #[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 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?; + let history = db + .list(Some(offset.unwrap_or(0)), Some(100)) + .await? + .into_iter() + .map(|h| h.into()) + .collect(); Ok(history) } #[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 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?; + let history = db.search(offset, query.as_str()).await?; Ok(history) } @@ -54,12 +60,22 @@ async fn global_stats() -> Result<GlobalStats, 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?; + 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) } #[tauri::command] +async fn config() -> Result<Settings, String> { + Settings::new().map_err(|e| e.to_string()) +} + +#[tauri::command] async fn home_info() -> Result<HomeInfo, String> { let settings = Settings::new().map_err(|e| e.to_string())?; let record_store_path = PathBuf::from(settings.record_store_path.as_str()); @@ -115,6 +131,7 @@ fn main() { global_stats, aliases, home_info, + config, dotfiles::aliases::import_aliases, dotfiles::aliases::delete_alias, dotfiles::aliases::set_alias, @@ -122,6 +139,7 @@ fn main() { dotfiles::vars::delete_var, dotfiles::vars::set_var, ]) + .plugin(tauri_plugin_sql::Builder::default().build()) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/ui/package.json b/ui/package.json index e3025717..98163f01 100644 --- a/ui/package.json +++ b/ui/package.json @@ -16,7 +16,9 @@ "@radix-ui/react-slot": "^1.0.2", "@tailwindcss/forms": "^0.5.7", "@tanstack/react-table": "^8.15.3", + "@tanstack/react-virtual": "^3.5.0", "@tauri-apps/api": "2.0.0-beta.7", + "@tauri-apps/plugin-sql": "2.0.0-beta.2", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", "core": "link:@tauri-apps/api/core", @@ -27,6 +29,8 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-spinners": "^0.13.8", + "react-window": "^1.8.10", + "react-window-infinite-loader": "^1.0.9", "recharts": "^2.12.4", "tailwind-merge": "^2.2.2", "tailwindcss-animate": "^1.0.7", diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 1b4214fe..b777dd5f 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -23,9 +23,15 @@ dependencies: '@tanstack/react-table': specifier: ^8.15.3 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': specifier: 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: specifier: ^0.7.0 version: 0.7.0 @@ -56,6 +62,12 @@ dependencies: react-spinners: specifier: ^0.13.8 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: specifier: ^2.12.4 version: 2.12.4(react-dom@18.2.0)(react@18.2.0) @@ -584,7 +596,7 @@ packages: react: ^16 || ^17 || ^18 react-dom: ^16 || ^17 || ^18 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 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) dev: false - /@tanstack/react-virtual@3.2.0(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-OEdMByf2hEfDa6XDbGlZN8qO6bTjlNKqjM3im9JG+u3mCL8jALy0T/67oDI001raUUPh1Bdmfn4ZvPOV5knpcg==} + /@tanstack/react-virtual@3.5.0(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-rtvo7KwuIvqK9zb0VZ5IL7fiJAEnG+0EiFZz8FUOs+2mhGqdGmjKIaT1XU7Zq0eFqL0jonLlhbayJI/J2SA/Bw==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 dependencies: - '@tanstack/virtual-core': 3.2.0 + '@tanstack/virtual-core': 3.5.0 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) dev: false @@ -1316,8 +1328,13 @@ packages: engines: {node: '>=12'} dev: false - /@tanstack/virtual-core@3.2.0: - resolution: {integrity: sha512-P5XgYoAw/vfW65byBbJQCw+cagdXDT/qH6wmABiLt4v4YBT2q2vqCOhihe+D1Nt325F/S/0Tkv6C5z0Lv+VBQQ==} + /@tanstack/virtual-core@3.5.0: + 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 /@tauri-apps/api@2.0.0-beta.7: @@ -1432,6 +1449,12 @@ packages: '@tauri-apps/cli-win32-x64-msvc': 2.0.0-beta.2 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: resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} dependencies: @@ -2107,6 +2130,10 @@ packages: engines: {node: '>=12'} dev: false + /memoize-one@5.2.1: + resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} + dev: false + /merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -2381,6 +2408,30 @@ packages: react-dom: 18.2.0(react@18.2.0) 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: resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==} engines: {node: '>=0.10.0'} diff --git a/ui/src/App.css b/ui/src/App.css index 5a32a1a5..cf6d3123 100644 --- a/ui/src/App.css +++ b/ui/src/App.css @@ -9,3 +9,19 @@ html { .logo.react:hover { 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; +} diff --git a/ui/src/components/HistoryList.tsx b/ui/src/components/HistoryList.tsx index 9616ecf0..7cdeacd8 100644 --- a/ui/src/components/HistoryList.tsx +++ b/ui/src/components/HistoryList.tsx @@ -1,3 +1,4 @@ +import { useRef } from "react"; import { ChevronRightIcon } from "@heroicons/react/20/solid"; // @ts-ignore @@ -19,70 +20,81 @@ function msToTime(ms: number) { export default function HistoryList(props: any) { return ( - <ul + <div 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) => ( - <li - key={h.id} - className="relative flex justify-between gap-x-6 px-4 py-5 hover:bg-gray-50 sm:px-6" - > - <div className="flex min-w-0 gap-x-4"> - <div className="flex flex-col justify-center"> - <p className="flex text-xs text-gray-500 justify-center"> - {DateTime.fromMillis(h.timestamp / 1000000).toLocaleString( - DateTime.TIME_WITH_SECONDS, - )} - </p> - <p className="flex text-xs mt-1 text-gray-400 justify-center"> - {DateTime.fromMillis(h.timestamp / 1000000).toLocaleString( - DateTime.DATE_SHORT, - )} - </p> - </div> - <div className="min-w-0 flex-col justify-center"> - <pre className="whitespace-pre-wrap"> - <code className="text-sm">{h.command}</code> - </pre> - <p className="mt-1 flex text-xs leading-5 text-gray-500"> - <span className="relative truncate ">{h.user}</span> + {props.items.map((i: any) => { + let h = props.history[i.index]; - <span> on </span> + return ( + <li + key={h.id} + 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 flex-col justify-center"> + <p className="flex text-xs text-gray-500 justify-center"> + {DateTime.fromMillis(h.timestamp / 1000000).toLocaleString( + DateTime.TIME_WITH_SECONDS, + )} + </p> + <p className="flex text-xs mt-1 text-gray-400 justify-center"> + {DateTime.fromMillis(h.timestamp / 1000000).toLocaleString( + DateTime.DATE_SHORT, + )} + </p> + </div> + <div className="min-w-0 flex-col justify-center"> + <pre className="whitespace-pre-wrap"> + <code className="text-sm">{h.command}</code> + </pre> + <p className="mt-1 flex text-xs leading-5 text-gray-500"> + <span className="relative truncate ">{h.user}</span> - <span className="relative truncate ">{h.host}</span> + <span> on </span> - <span> in </span> + <span className="relative truncate ">{h.host}</span> - <span className="relative truncate ">{h.cwd}</span> - </p> - </div> - </div> - <div className="flex shrink-0 items-center gap-x-4"> - <div className="hidden sm:flex sm:flex-col sm:items-end"> - <p className="text-sm leading-6 text-gray-900">{h.exit}</p> - {h.duration ? ( - <p className="mt-1 text-xs leading-5 text-gray-500"> - <time dateTime={h.duration}> - {msToTime(h.duration / 1000000)} - </time> + <span> in </span> + + <span className="relative truncate ">{h.cwd}</span> </p> - ) : ( - <div className="mt-1 flex items-center gap-x-1.5"> - <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> + <div className="flex shrink-0 items-center gap-x-4"> + <div className="hidden sm:flex sm:flex-col sm:items-end"> + <p className="text-sm leading-6 text-gray-900">{h.exit}</p> + {h.duration ? ( + <p className="mt-1 text-xs leading-5 text-gray-500"> + <time dateTime={h.duration}> + {msToTime(h.duration / 1000000)} + </time> + </p> + ) : ( + <div /> + )} + </div> + <ChevronRightIcon + className="h-5 w-5 flex-none text-gray-400" + aria-hidden="true" + /> </div> - <ChevronRightIcon - className="h-5 w-5 flex-none text-gray-400" - aria-hidden="true" - /> - </div> - </li> - ))} - </ul> + </li> + ); + })} + </div> ); } diff --git a/ui/src/components/HistorySearch.tsx b/ui/src/components/HistorySearch.tsx index 08bed2a8..b3c8492a 100644 --- a/ui/src/components/HistorySearch.tsx +++ b/ui/src/components/HistorySearch.tsx @@ -3,12 +3,12 @@ import { ArrowPathIcon } from "@heroicons/react/24/outline"; import { MagnifyingGlassIcon } from "@heroicons/react/20/solid"; interface HistorySearchProps { - refresh: (query: string) => void; + query: string; + refresh: () => void; + setQuery: (query: string) => void; } export default function HistorySearch(props: HistorySearchProps) { - let [searchQuery, setSearchQuery] = useState(""); - return ( <div className="flex flex-1 gap-x-4 self-stretch lg:gap-x-6"> <form @@ -35,8 +35,8 @@ export default function HistorySearch(props: HistorySearchProps) { type="search" name="search" onChange={(query) => { - setSearchQuery(query.target.value); - props.refresh(query.target.value); + props.setQuery(query.target.value); + props.refresh(); }} /> </form> @@ -45,7 +45,7 @@ export default function HistorySearch(props: HistorySearchProps) { type="button" className="-m-2.5 p-2.5 text-gray-400 hover:text-gray-500" onClick={() => { - props.refresh(searchQuery); + props.refresh(); }} > <ArrowPathIcon className="h-6 w-6" aria-hidden="true" /> diff --git a/ui/src/components/dotfiles/Aliases.tsx b/ui/src/components/dotfiles/Aliases.tsx index 61fd001c..9af3e994 100644 --- a/ui/src/components/dotfiles/Aliases.tsx +++ b/ui/src/components/dotfiles/Aliases.tsx @@ -16,7 +16,7 @@ import { ColumnDef } from "@tanstack/react-table"; import { invoke } from "@tauri-apps/api/core"; import Drawer from "@/components/Drawer"; -import { Alias } from "@/state/models"; +import { Alias, inspectHistory } from "@/state/models"; import { useStore } from "@/state/store"; function deleteAlias(name: string, refreshAliases: () => void) { diff --git a/ui/src/components/history/Stats.tsx b/ui/src/components/history/Stats.tsx index ce92ac04..bc4e5c33 100644 --- a/ui/src/components/history/Stats.tsx +++ b/ui/src/components/history/Stats.tsx @@ -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() { const [stats, setStats]: any = useState([]); + const [top, setTop]: any = useState([]); const [chart, setChart]: any = useState([]); - console.log("Stats mounted"); - useEffect(() => { if (stats.length != 0) return; @@ -38,6 +86,10 @@ export default function Stats() { stat: s.total_history.toLocaleString(), }, { + name: "Unique history", + stat: s.stats.unique_commands.toLocaleString(), + }, + { name: "Last 1d", stat: s.last_1d.toLocaleString(), }, @@ -52,20 +104,23 @@ export default function Stats() { ]); setChart(s.daily); + + setTop(s.stats); }) .catch((e) => { console.log(e); }); }, []); + console.log(top); if (stats.length == 0) { return renderLoading(); } return ( - <div className="flex flex-col"> + <div className="flex flex-col overflow-y-scroll"> <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) => ( <div key={item.name} @@ -94,6 +149,10 @@ export default function Stats() { </ResponsiveContainer> </div> </div> + + <div> + <TopTable stats={top.top} /> + </div> </div> ); } diff --git a/ui/src/global.d.ts b/ui/src/global.d.ts new file mode 100644 index 00000000..d97caa3a --- /dev/null +++ b/ui/src/global.d.ts @@ -0,0 +1 @@ +type Option<T> = T | null; diff --git a/ui/src/pages/History.tsx b/ui/src/pages/History.tsx index 91ed9824..6eaa6f67 100644 --- a/ui/src/pages/History.tsx +++ b/ui/src/pages/History.tsx @@ -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 HistorySearch from "@/components/HistorySearch.tsx"; import Stats from "@/components/history/Stats.tsx"; import Drawer from "@/components/Drawer.tsx"; +import InfiniteHistory from "@/components/InfiniteHistory.tsx"; + import { useStore } from "@/state/store"; +import { inspectHistory, listHistory } from "@/state/models"; +import { invoke } from "@tauri-apps/api/core"; + function Header() { return ( <div className="md:flex md:items-center md:justify-between"> @@ -49,29 +55,64 @@ function Header() { export default function Search() { const history = useStore((state) => state.shellHistory); const refreshHistory = useStore((state) => state.refreshShellHistory); + const historyNextPage = useStore((state) => state.historyNextPage); + + let [query, setQuery] = useState(""); useEffect(() => { + (async () => { + // nothing rn + })(); + 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 ( <> <div className="pl-60"> - <div className="p-10"> + <div className="p-10 history-header"> <Header /> <p>A history of all the commands you run in your shell.</p> </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 - refresh={(query?: string) => { + query={query} + setQuery={(q) => { + setQuery(q); + refreshHistory(q); + }} + refresh={() => { refreshHistory(query); }} /> </div> - <main> - <HistoryList history={history} /> + <main className="overflow-y-scroll history-list" ref={parentRef}> + <HistoryList + history={history} + items={rowVirtualizer.getVirtualItems()} + height={rowVirtualizer.getTotalSize()} + /> </main> </div> </> diff --git a/ui/src/state/models.ts b/ui/src/state/models.ts index 5afcb804..5aca83a0 100644 --- a/ui/src/state/models.ts +++ b/ui/src/state/models.ts @@ -1,3 +1,5 @@ +import Database from "@tauri-apps/plugin-sql"; + export interface User { username: string; } @@ -18,7 +20,7 @@ export const DefaultHomeInfo: HomeInfo = { lastSyncTime: new Date(), }; -export interface ShellHistory { +export class ShellHistory { id: string; timestamp: number; command: string; @@ -26,6 +28,24 @@ export interface ShellHistory { host: string; cwd: string; 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 { @@ -36,5 +56,15 @@ export interface Alias { export interface Var { name: 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; } diff --git a/ui/src/state/store.ts b/ui/src/state/store.ts index 7e237d70..fef1b632 100644 --- a/ui/src/state/store.ts +++ b/ui/src/state/store.ts @@ -8,6 +8,7 @@ import { DefaultHomeInfo, Alias, ShellHistory, + Var, } from "./models"; import { invoke } from "@tauri-apps/api/core"; @@ -26,9 +27,10 @@ interface AtuinState { refreshAliases: () => void; refreshVars: () => void; refreshShellHistory: (query?: string) => void; + historyNextPage: (query?: string) => void; } -export const useStore = create<AtuinState>()((set) => ({ +export const useStore = create<AtuinState>()((set, get) => ({ user: DefaultUser, homeInfo: DefaultHomeInfo, aliases: [], @@ -78,4 +80,23 @@ export const useStore = create<AtuinState>()((set) => ({ 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] }); + }); + } + }, })); diff --git a/ui/src/styles.css b/ui/src/styles.css index b0e6fff5..27e12ec6 100644 --- a/ui/src/styles.css +++ b/ui/src/styles.css @@ -1,76 +1,76 @@ @tailwind base; - @tailwind components; - @tailwind utilities; +@tailwind components; +@tailwind utilities; - @layer base { +@layer base { :root { - --background: 0 0% 100%; - --foreground: 222.2 84% 4.9%; + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; - --card: 0 0% 100%; - --card-foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; - --popover: 0 0% 100%; - --popover-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; - --primary: 222.2 47.4% 11.2%; - --primary-foreground: 210 40% 98%; + --primary: 222.2 47.4% 11.2%; + --primary-foreground: 210 40% 98%; - --secondary: 210 40% 96.1%; - --secondary-foreground: 222.2 47.4% 11.2%; + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; - --muted: 210 40% 96.1%; - --muted-foreground: 215.4 16.3% 46.9%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; - --accent: 210 40% 96.1%; - --accent-foreground: 222.2 47.4% 11.2%; + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 210 40% 98%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; - --border: 214.3 31.8% 91.4%; - --input: 214.3 31.8% 91.4%; - --ring: 222.2 84% 4.9%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 222.2 84% 4.9%; - --radius: 0.5rem; + --radius: 0.5rem; } .dark { - --background: 222.2 84% 4.9%; - --foreground: 210 40% 98%; + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; - --card: 222.2 84% 4.9%; - --card-foreground: 210 40% 98%; + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; - --popover: 222.2 84% 4.9%; - --popover-foreground: 210 40% 98%; + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; - --primary: 210 40% 98%; - --primary-foreground: 222.2 47.4% 11.2%; + --primary: 210 40% 98%; + --primary-foreground: 222.2 47.4% 11.2%; - --secondary: 217.2 32.6% 17.5%; - --secondary-foreground: 210 40% 98%; + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; - --muted: 217.2 32.6% 17.5%; - --muted-foreground: 215 20.2% 65.1%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; - --accent: 217.2 32.6% 17.5%; - --accent-foreground: 210 40% 98%; + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 210 40% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; - --border: 217.2 32.6% 17.5%; - --input: 217.2 32.6% 17.5%; - --ring: 212.7 26.8% 83.9%; + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 212.7 26.8% 83.9%; } - } +} - @layer base { +@layer base { * { - @apply border-border; + @apply border-border; } body { - @apply bg-background text-foreground; + @apply bg-background text-foreground; } - }
\ No newline at end of file +} |
