aboutsummaryrefslogtreecommitdiffstats
path: root/ui
diff options
context:
space:
mode:
Diffstat (limited to 'ui')
-rw-r--r--ui/backend/Cargo.lock132
-rw-r--r--ui/backend/Cargo.toml6
-rw-r--r--ui/backend/capabilities/migrated.json19
-rw-r--r--ui/backend/src/db.rs106
-rw-r--r--ui/backend/src/main.rs28
-rw-r--r--ui/package.json4
-rw-r--r--ui/pnpm-lock.yaml63
-rw-r--r--ui/src/App.css16
-rw-r--r--ui/src/components/HistoryList.tsx126
-rw-r--r--ui/src/components/HistorySearch.tsx12
-rw-r--r--ui/src/components/dotfiles/Aliases.tsx2
-rw-r--r--ui/src/components/history/Stats.tsx67
-rw-r--r--ui/src/global.d.ts1
-rw-r--r--ui/src/pages/History.tsx53
-rw-r--r--ui/src/state/models.ts34
-rw-r--r--ui/src/state/store.ts23
-rw-r--r--ui/src/styles.css94
17 files changed, 591 insertions, 195 deletions
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>&nbsp;on&nbsp;</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>&nbsp;on&nbsp;</span>
- <span>&nbsp;in&nbsp;</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>&nbsp;in&nbsp;</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
+}