chore: remove ui directory (#2329)

This is still in development, but rather than clutter the commit history
and issues with an unreleased project I've split the UI into its own
repo.

Once ready for release, I'll either merge the ui code back in, or just
make the repo public.
This commit is contained in:
Ellie Huxtable 2024-07-30 16:54:10 +01:00 committed by GitHub
parent dee8ece263
commit 808138de63
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
103 changed files with 0 additions and 23226 deletions

View File

@ -9,14 +9,6 @@ updates:
directory: "/" # Location of package manifests directory: "/" # Location of package manifests
schedule: schedule:
interval: "weekly" interval: "weekly"
- package-ecosystem: "cargo" # See documentation for possible values
directory: "/ui/backend" # Location of package manifests
schedule:
interval: "weekly"
- package-ecosystem: "npm" # See documentation for possible values
directory: "/ui" # Location of package manifests
schedule:
interval: "weekly"
- package-ecosystem: "docker" # See documentation for possible values - package-ecosystem: "docker" # See documentation for possible values
directory: "/" # Location of package manifests directory: "/" # Location of package manifests
schedule: schedule:

28
ui/.gitignore vendored
View File

@ -1,28 +0,0 @@
# Logs
logs
bundle
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.vite
gen

View File

@ -1 +0,0 @@
public-hoist-pattern[]=*@nextui-org/*

View File

@ -1,3 +0,0 @@
# Atuin Desktop
Currently WIP and not yet ready for use

View File

@ -1,4 +0,0 @@
# Generated by Cargo
# will have compiled files and executables
/target/

7643
ui/backend/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,59 +0,0 @@
[package]
name = "ui"
version = "0.0.0"
description = "A Tauri App"
publish = false
authors = ["you"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[build-dependencies]
tauri-build = { version = "2.0.0-beta", features = [] }
[dependencies]
atuin-client = { path = "../../crates/atuin-client", version = "18.4.0-beta.3" }
atuin-common = { path = "../../crates/atuin-common", version = "18.4.0-beta.3" }
atuin-dotfiles = { path = "../../crates/atuin-dotfiles", version = "18.4.0-beta.3" }
atuin-history = { path = "../../crates/atuin-history", version = "18.4.0-beta.3" }
eyre = "0.6"
tauri = { version = "2.0.0-beta", features = ["tray-icon"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
time = "0.3.36"
uuid = "1.7.0"
syntect = "5.2.0"
tokio = "1.38.0"
comrak = "0.22"
portable-pty = "0.8.1"
vt100 = "0.15.2"
bytes = "1.6.0"
nix = "0.29.0"
lazy_static = "1.5.0"
shellexpand = "3.1.0"
tauri-plugin-http = "2.0.0-beta"
tauri-plugin-single-instance = "2.0.0-beta"
tauri-plugin-os = "2.0.0-beta.8"
tauri-plugin-shell = "2.0.0-beta.7"
tauri-plugin-dialog = "2.0.0-beta.11"
[target."cfg(target_os = \"macos\")".dependencies]
cocoa = "0.25"
[dependencies.sqlx]
version = "0.7"
features = ["runtime-tokio-rustls", "time", "postgres", "uuid"]
[features]
# this feature is used for production builds or when `devPath` points to the filesystem
# DO NOT REMOVE!!
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"

View File

@ -1,3 +0,0 @@
fn main() {
tauri_build::build()
}

View File

@ -1,36 +0,0 @@
{
"identifier": "migrated",
"description": "permissions that were migrated from v1",
"context": "local",
"windows": [
"main"
],
"permissions": [
"path:default",
"event:default",
"window:default",
"app:default",
"resources:default",
"menu:default",
"tray:default",
"shell:allow-open",
"sql:allow-load",
"sql:allow-execute",
"sql:allow-select",
"os:allow-platform",
"window:allow-start-dragging",
{
"identifier": "http:default",
"allow": [
"https://api.atuin.sh/*"
]
},
"os:default",
"dialog:default"
],
"platforms": [
"linux",
"macOS",
"windows"
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

View File

@ -1,2 +0,0 @@
[toolchain]
channel = "1.79"

View File

@ -1,316 +0,0 @@
// Some wrappers around the Atuin history DB
// I'll probably use this to inform changes to the "upstream" client crate
// We also use Strings a bunch for errors. They're passed to the Tauri frontend,
// which requires that they be serializable.
// Can rework that in the future too, but my main concern is avoiding tauri limitations/reqs
// ending up in the main crate.
use serde::Serialize;
use sqlx::{sqlite::SqliteRow, Row};
use std::collections::HashMap;
use std::path::PathBuf;
use atuin_client::settings::{FilterMode, SearchMode};
use atuin_client::{
database::{Context, Database, OptFilters, Sqlite},
history::History,
};
use atuin_history::stats;
// useful for preprocessing data for the frontend
#[derive(Serialize, Debug)]
pub struct NameValue<T> {
pub name: String,
pub value: T,
}
#[derive(Serialize, Debug)]
pub struct GlobalStats {
pub total_history: u64,
pub daily: Vec<NameValue<u64>>,
pub stats: Option<stats::Stats>,
pub last_1d: u64,
pub last_7d: u64,
pub last_30d: u64,
}
#[derive(Serialize, Debug)]
pub struct UIHistory {
pub id: String,
/// When the command was run.
pub timestamp: i128,
/// How long the command took to run.
pub duration: i64,
/// The exit code of the command.
pub exit: i64,
/// The command that was run.
pub command: String,
/// The current working directory when the command was run.
pub cwd: String,
/// The session ID, associated with a terminal session.
pub session: String,
/// The hostname of the machine the command was run on.
pub user: String,
pub host: String,
}
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 mac = format!("/Users/{}", user);
let linux = format!("/home/{}", user);
let cwd = history.cwd.replace(mac.as_str(), "~");
let cwd = cwd.replace(linux.as_str(), "~");
UIHistory {
id: history.id.0,
timestamp: history.timestamp.unix_timestamp_nanos(),
duration: history.duration,
exit: history.exit,
command: history.command,
session: history.session,
host,
user,
cwd,
}
}
}
pub struct HistoryDB(Sqlite);
impl HistoryDB {
pub async fn new(path: PathBuf, timeout: f64) -> Result<Self, String> {
let sqlite = Sqlite::new(path, timeout)
.await
.map_err(|e| e.to_string())?;
Ok(Self(sqlite))
}
pub async fn list(
&self,
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: 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())?;
Ok(history)
}
pub async fn search(&self, offset: Option<u64>, query: &str) -> Result<Vec<UIHistory>, String> {
let context = Context {
session: "".to_string(),
cwd: "".to_string(),
host_id: "".to_string(),
hostname: "".to_string(),
git_root: None,
};
let filters = OptFilters {
limit: Some(200),
offset: offset.map(|offset| offset as i64),
..OptFilters::default()
};
let history = self
.0
.search(
SearchMode::Fuzzy,
FilterMode::Global,
&context,
query,
filters,
)
.await
.map_err(|e| e.to_string())?;
let history = history
.into_iter()
.filter(|h| h.duration > 0)
.map(|h| h.into())
.collect();
Ok(history)
}
pub async fn prefix_search(&self, query: &str) -> Result<Vec<UIHistory>, String> {
let context = Context {
session: "".to_string(),
cwd: "".to_string(),
host_id: "".to_string(),
hostname: "".to_string(),
git_root: None,
};
let filters = OptFilters {
limit: Some(5),
..OptFilters::default()
};
let history = self
.0
.search(
SearchMode::Prefix,
FilterMode::Global,
&context,
query,
filters,
)
.await
.map_err(|e| e.to_string())?;
let history = history
.into_iter()
.filter(|h| h.duration > 0)
.map(|h| h.into())
.collect();
Ok(history)
}
pub async fn calendar(&self) -> Result<Vec<(String, u64)>, String> {
let query = "select count(1) as count, strftime('%F', datetime(timestamp / 1000000000, 'unixepoch')) as day from history where timestamp > ((unixepoch() - 31536000) * 1000000000) group by day;";
let calendar: Vec<(String, u64)> = sqlx::query(query)
// safe to cast, count(x) is never < 0
.map(|row: SqliteRow| {
(
row.get::<String, _>("day"),
row.get::<i64, _>("count") as u64,
)
})
.fetch_all(&self.0.pool)
.await
.map_err(|e| e.to_string())?;
Ok(calendar)
}
pub async fn global_stats(&self) -> Result<GlobalStats, String> {
let day_ago = time::OffsetDateTime::now_utc() - time::Duration::days(1);
let day_ago = day_ago.unix_timestamp_nanos();
let week_ago = time::OffsetDateTime::now_utc() - time::Duration::days(7);
let week_ago = week_ago.unix_timestamp_nanos();
let month_ago = time::OffsetDateTime::now_utc() - time::Duration::days(30);
let month_ago = month_ago.unix_timestamp_nanos();
// get the last 30 days of shell history
let history: Vec<UIHistory> = sqlx::query("SELECT * FROM history WHERE timestamp > ?")
.bind(month_ago as i64)
.map(|row: SqliteRow| {
History::from_db()
.id(row.get("id"))
.timestamp(
time::OffsetDateTime::from_unix_timestamp_nanos(
row.get::<i64, _>("timestamp") as i128,
)
.unwrap(),
)
.duration(row.get("duration"))
.exit(row.get("exit"))
.command(row.get("command"))
.cwd(row.get("cwd"))
.session(row.get("session"))
.hostname(row.get("hostname"))
.deleted_at(None)
.build()
.into()
})
.map(|h: History| h.into())
.fetch_all(&self.0.pool)
.await
.map_err(|e| e.to_string())?;
let total: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM history")
.fetch_one(&self.0.pool)
.await
.map_err(|e| e.to_string())?;
let mut day = 0;
let mut week = 0;
let mut month = 0;
let mut daily = HashMap::new();
let ymd = time::format_description::parse("[year]-[month]-[day]").unwrap();
for i in history {
if i.timestamp > day_ago {
day += 1;
}
if i.timestamp > week_ago {
week += 1;
}
if i.timestamp > month_ago {
month += 1;
// get the start of the day, as a unix timestamp
let date = time::OffsetDateTime::from_unix_timestamp_nanos(i.timestamp)
.unwrap()
.format(&ymd)
.unwrap();
daily.entry(date).and_modify(|v| *v += 1).or_insert(1);
}
}
let mut daily: Vec<NameValue<u64>> = daily
.into_iter()
.map(|(k, v)| NameValue { name: k, value: v })
.collect();
daily.sort_by(|a, b| a.name.cmp(&b.name));
Ok(GlobalStats {
total_history: total.0 as u64,
last_30d: month,
last_7d: week,
last_1d: day,
daily,
stats: None,
})
}
}

View File

@ -1,91 +0,0 @@
use std::path::PathBuf;
use atuin_client::{encryption, record::sqlite_store::SqliteStore, settings::Settings};
use atuin_common::shell::Shell;
use atuin_dotfiles::{
shell::{existing_aliases, Alias},
store::AliasStore,
};
async fn alias_store() -> eyre::Result<AliasStore> {
let settings = Settings::new()?;
let record_store_path = PathBuf::from(settings.record_store_path.as_str());
let sqlite_store = SqliteStore::new(record_store_path, settings.local_timeout).await?;
let encryption_key: [u8; 32] = encryption::load_key(&settings)?.into();
let host_id = Settings::host_id().expect("failed to get host_id");
Ok(AliasStore::new(sqlite_store, host_id, encryption_key))
}
#[tauri::command]
pub async fn aliases() -> Result<Vec<Alias>, String> {
let alias_store = alias_store().await.map_err(|e| e.to_string())?;
let aliases = alias_store
.aliases()
.await
.map_err(|e| format!("failed to load aliases: {}", e))?;
Ok(aliases)
}
#[tauri::command]
pub async fn delete_alias(name: String) -> Result<(), String> {
let alias_store = alias_store().await.map_err(|e| e.to_string())?;
alias_store
.delete(name.as_str())
.await
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn set_alias(name: String, value: String) -> Result<(), String> {
let alias_store = alias_store().await.map_err(|e| e.to_string())?;
alias_store
.set(name.as_str(), value.as_str())
.await
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn import_aliases() -> Result<Vec<Alias>, String> {
let store = alias_store().await.map_err(|e| e.to_string())?;
let shell = Shell::default_shell().map_err(|e| e.to_string())?;
let shell_name = shell.to_string();
if !shell.is_posixish() {
return Err(format!(
"Default shell {shell_name} not supported for import"
));
}
let existing_aliases = existing_aliases(Some(shell)).map_err(|e| e.to_string())?;
let store_aliases = store.aliases().await.map_err(|e| e.to_string())?;
let mut res = Vec::new();
for alias in existing_aliases {
// O(n), but n is small, and imports infrequent
// can always make a map
if store_aliases.contains(&alias) {
continue;
}
res.push(alias.clone());
store
.set(&alias.name, &alias.value)
.await
.map_err(|e| e.to_string())?;
}
Ok(res)
}

View File

@ -1,2 +0,0 @@
pub mod aliases;
pub mod vars;

View File

@ -1,57 +0,0 @@
use std::path::PathBuf;
use atuin_client::{encryption, record::sqlite_store::SqliteStore, settings::Settings};
use atuin_common::shell::Shell;
use atuin_dotfiles::{
shell::{existing_aliases, Alias, Var},
store::var::VarStore,
};
async fn var_store() -> eyre::Result<VarStore> {
let settings = Settings::new()?;
let record_store_path = PathBuf::from(settings.record_store_path.as_str());
let sqlite_store = SqliteStore::new(record_store_path, settings.local_timeout).await?;
let encryption_key: [u8; 32] = encryption::load_key(&settings)?.into();
let host_id = Settings::host_id().expect("failed to get host_id");
Ok(VarStore::new(sqlite_store, host_id, encryption_key))
}
#[tauri::command]
pub async fn vars() -> Result<Vec<Var>, String> {
let var_store = var_store().await.map_err(|e| e.to_string())?;
let vars = var_store
.vars()
.await
.map_err(|e| format!("failed to load aliases: {}", e))?;
Ok(vars)
}
#[tauri::command]
pub async fn delete_var(name: String) -> Result<(), String> {
let var_store = var_store().await.map_err(|e| e.to_string())?;
var_store
.delete(name.as_str())
.await
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn set_var(name: String, value: String, export: bool) -> Result<(), String> {
let var_store = var_store().await.map_err(|e| e.to_string())?;
var_store
.set(name.as_str(), value.as_str(), export)
.await
.map_err(|e| e.to_string())?;
Ok(())
}

View File

@ -1,73 +0,0 @@
// Handle installing the Atuin CLI
// We can use the standard install script for this
use std::process::Command;
use tokio::{
fs::{read_to_string, OpenOptions},
io::AsyncWriteExt,
};
use atuin_common::shell::Shell;
#[tauri::command]
pub(crate) async fn install_cli() -> Result<(), String> {
let output = Command::new("sh")
.arg("-c")
.arg("curl --proto '=https' --tlsv1.2 -LsSf https://github.com/atuinsh/atuin/releases/latest/download/atuin-installer.sh | sh")
.output().map_err(|e|format!("Failed to execute Atuin installer: {e}"));
Ok(())
}
#[tauri::command]
pub(crate) async fn is_cli_installed() -> Result<bool, String> {
let shell = Shell::default_shell().map_err(|e| format!("Failed to get default shell: {e}"))?;
let output = if shell == Shell::Powershell {
shell
.run_interactive(&["atuin --version; if ($?) {echo 'ATUIN FOUND'}"])
.map_err(|e| format!("Failed to run interactive command"))?
} else {
shell
.run_interactive(&["atuin --version && echo 'ATUIN FOUND'"])
.map_err(|e| format!("Failed to run interactive command"))?
};
Ok(output.contains("ATUIN FOUND"))
}
#[tauri::command]
pub(crate) async fn setup_cli() -> Result<(), String> {
let shell = Shell::default_shell().map_err(|e| format!("Failed to get default shell: {e}"))?;
let config_file_path = shell.config_file();
if config_file_path.is_none() {
return Err("Failed to fetch default config file".to_string());
}
let config_file_path = config_file_path.unwrap();
let config_file = read_to_string(config_file_path.clone())
.await
.map_err(|e| format!("Failed to read config file: {e}"))?;
if config_file.contains("atuin init") {
return Ok(());
}
let mut file = OpenOptions::new()
.write(true)
.append(true)
.open(config_file_path)
.await
.unwrap();
let config = format!(
"if [ -x \"$(command -v atuin)\" ]; then eval \"$(atuin init {})\"; fi",
shell.to_string()
);
file.write_all(config.as_bytes())
.await
.map_err(|e| format!("Failed to write Atuin shell init: {e}"));
Ok(())
}

View File

@ -1,329 +0,0 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use tauri::State;
use std::path::PathBuf;
use tauri::{AppHandle, Manager};
use time::format_description::well_known::Rfc3339;
mod db;
mod dotfiles;
mod install;
mod pty;
mod run;
mod state;
mod store;
use atuin_client::settings::Settings;
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;
#[derive(Debug, serde::Serialize)]
struct HomeInfo {
pub record_count: u64,
pub history_count: u64,
pub username: Option<String>,
pub last_sync: Option<String>,
pub top_commands: Vec<(String, u64)>,
pub recent_commands: Vec<UIHistory>,
}
#[tauri::command]
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(offset.unwrap_or(0)), Some(100))
.await?
.into_iter()
.map(|h| h.into())
.collect();
Ok(history)
}
#[tauri::command]
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(offset, query.as_str()).await?;
Ok(history)
}
#[tauri::command]
async fn global_stats() -> Result<GlobalStats, String> {
let settings = Settings::new().map_err(|e| e.to_string())?;
let db_path = PathBuf::from(settings.db_path.as_str());
let db = HistoryDB::new(db_path, settings.local_timeout).await?;
let 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 session() -> Result<String, String> {
Settings::new()
.map_err(|e| e.to_string())?
.session_token()
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn login(username: String, password: String, key: String) -> Result<String, String> {
let settings = Settings::new().map_err(|e| e.to_string())?;
let record_store_path = PathBuf::from(settings.record_store_path.as_str());
let store = SqliteStore::new(record_store_path, settings.local_timeout)
.await
.map_err(|e| e.to_string())?;
if settings.logged_in() {
return Err(String::from("Already logged in"));
}
let session = atuin_client::login::login(&settings, &store, username, password, key)
.await
.map_err(|e| e.to_string())?;
Ok(session)
}
#[tauri::command]
async fn logout() -> Result<(), String> {
let settings = Settings::new().map_err(|e| e.to_string())?;
atuin_client::logout::logout(&settings).map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
async fn register(username: String, email: String, password: String) -> Result<String, String> {
let settings = Settings::new().map_err(|e| e.to_string())?;
let session = atuin_client::register::register(&settings, username, email, password)
.await
.map_err(|e| e.to_string())?;
Ok(session)
}
#[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());
let sqlite_store = SqliteStore::new(record_store_path, settings.local_timeout)
.await
.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 last_sync = Settings::last_sync()
.map_err(|e| e.to_string())?
.format(&Rfc3339)
.map_err(|e| e.to_string())?;
let record_count = sqlite_store.len_all().await.map_err(|e| e.to_string())?;
let history_count = sqlite_store
.len_tag(HISTORY_TAG)
.await
.map_err(|e| e.to_string())?;
let history = db.list(None, None).await?;
let stats = stats::compute(&settings, &history, 10, 1)
.map_or(vec![], |stats| stats.top[0..5].to_vec())
.iter()
.map(|(commands, count)| (commands.join(" "), *count as u64))
.collect();
let recent = if history.len() > 5 {
history[0..5].to_vec()
} else {
vec![]
};
let recent = recent.into_iter().map(|h| h.into()).collect();
let info = if !settings.logged_in() {
HomeInfo {
username: None,
last_sync: None,
record_count,
history_count,
top_commands: stats,
recent_commands: recent,
}
} else {
let client = atuin_client::api_client::Client::new(
&settings.sync_address,
settings
.session_token()
.map_err(|e| e.to_string())?
.as_str(),
settings.network_connect_timeout,
settings.network_timeout,
)
.map_err(|e| e.to_string())?;
let me = client.me().await.map_err(|e| e.to_string())?;
HomeInfo {
username: Some(me.username),
last_sync: Some(last_sync.to_string()),
record_count,
history_count,
top_commands: stats,
recent_commands: recent,
}
};
Ok(info)
}
// Match the format that the frontend library we use expects
// All the processing in Rust, not JSunwrap.
// Faaaassssssst af ⚡️🦀
#[derive(Debug, serde::Serialize)]
pub struct HistoryCalendarDay {
pub date: String,
pub count: u64,
pub level: u8,
}
#[tauri::command]
async fn history_calendar() -> Result<Vec<HistoryCalendarDay>, 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 calendar = db.calendar().await?;
// probs don't want to iterate _this_ many times, but it's only the last year. so 365
// iterations at max. should be quick.
let max = calendar
.iter()
.max_by_key(|d| d.1)
.expect("Can't find max count");
let ret = calendar
.iter()
.map(|d| {
// calculate the "level". we have 5, so figure out which 5th it fits into
let percent: f64 = d.1 as f64 / max.1 as f64;
let level = if d.1 == 0 {
0.0
} else {
(percent / 0.2).round() + 1.0
};
HistoryCalendarDay {
date: d.0.clone(),
count: d.1,
level: std::cmp::min(4, level as u8),
}
})
.collect();
Ok(ret)
}
#[tauri::command]
async fn prefix_search(query: &str) -> Result<Vec<String>, 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.prefix_search(query).await?;
let commands = history.into_iter().map(|h| h.command).collect();
Ok(commands)
}
#[tauri::command]
async fn cli_settings() -> Result<Settings, String> {
let settings = Settings::new().map_err(|e| e.to_string())?;
Ok(settings)
}
fn show_window(app: &AppHandle) {
let windows = app.webview_windows();
windows
.values()
.next()
.expect("Sorry, no window found")
.set_focus()
.expect("Can't Bring Window to Focus");
}
fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_shell::init())
.invoke_handler(tauri::generate_handler![
list,
search,
prefix_search,
global_stats,
aliases,
home_info,
config,
session,
login,
logout,
register,
history_calendar,
cli_settings,
run::pty::pty_open,
run::pty::pty_write,
run::pty::pty_resize,
run::pty::pty_kill,
install::install_cli,
install::is_cli_installed,
install::setup_cli,
dotfiles::aliases::import_aliases,
dotfiles::aliases::delete_alias,
dotfiles::aliases::set_alias,
dotfiles::vars::vars,
dotfiles::vars::delete_var,
dotfiles::vars::set_var,
])
.plugin(
tauri_plugin_sql::Builder::default()
.add_migrations("sqlite:runbooks.db", run::migrations::migrations())
.build(),
)
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_single_instance::init(|app, args, cwd| {
let _ = show_window(app);
}))
.manage(state::AtuinState::default())
.setup(|app| Ok(()))
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View File

@ -1,124 +0,0 @@
use std::{
io::Write,
sync::{Arc, Mutex},
};
use bytes::Bytes;
use eyre::{eyre, Result};
use portable_pty::{CommandBuilder, MasterPty, PtySize};
pub struct Pty {
tx: tokio::sync::mpsc::Sender<Bytes>,
pub master: Arc<Mutex<Box<dyn MasterPty + Send>>>,
pub reader: Arc<Mutex<Box<dyn std::io::Read + Send>>>,
pub child: Arc<Mutex<Box<dyn portable_pty::Child + Send>>>,
}
impl Pty {
pub async fn open<'a>(rows: u16, cols: u16, cwd: Option<String>) -> Result<Self> {
let sys = portable_pty::native_pty_system();
let pair = sys
.openpty(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})
.map_err(|e| eyre!("Failed to open pty: {}", e))?;
let mut cmd = CommandBuilder::new_default_prog();
if let Some(cwd) = cwd {
cmd.cwd(cwd);
}
let child = pair.slave.spawn_command(cmd).unwrap();
drop(pair.slave);
// Handle input -> write to master writer
let (master_tx, mut master_rx) = tokio::sync::mpsc::channel::<Bytes>(32);
let mut writer = pair.master.take_writer().unwrap();
let reader = pair
.master
.try_clone_reader()
.map_err(|e| e.to_string())
.expect("Failed to clone reader");
tokio::spawn(async move {
while let Some(bytes) = master_rx.recv().await {
writer.write_all(&bytes).unwrap();
writer.flush().unwrap();
}
// When the channel has been closed, we won't be getting any more input. Close the
// writer and the master.
// This will also close the writer, which sends EOF to the underlying shell. Ensuring
// that is also closed.
drop(writer);
});
Ok(Pty {
tx: master_tx,
master: Arc::new(Mutex::new(pair.master)),
reader: Arc::new(Mutex::new(reader)),
child: Arc::new(Mutex::new(child)),
})
}
pub async fn resize(&self, rows: u16, cols: u16) -> Result<()> {
let master = self
.master
.lock()
.map_err(|e| eyre!("Failed to lock pty master: {e}"))?;
master
.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})
.map_err(|e| eyre!("Failed to resize terminal: {e}"))?;
Ok(())
}
pub async fn send_bytes(&self, bytes: Bytes) -> Result<()> {
self.tx
.send(bytes)
.await
.map_err(|e| eyre!("Failed to write to master tx: {}", e))
}
pub async fn send_string(&self, cmd: &str) -> Result<()> {
let bytes: Vec<u8> = cmd.bytes().collect();
let bytes = Bytes::from(bytes);
self.send_bytes(bytes).await
}
pub async fn send_single_string(&self, cmd: &str) -> Result<()> {
let mut bytes: Vec<u8> = cmd.bytes().collect();
bytes.push(0x04);
let bytes = Bytes::from(bytes);
self.send_bytes(bytes).await
}
pub async fn kill_child(&self) -> Result<()> {
let mut child = self
.child
.lock()
.map_err(|e| eyre!("Failed to lock pty child: {e}"))?;
child
.kill()
.map_err(|e| eyre!("Failed to kill child: {e}"))?;
Ok(())
}
}

View File

@ -1,13 +0,0 @@
use lazy_static::lazy_static;
use tauri_plugin_sql::{Builder, Migration, MigrationKind};
pub fn migrations() -> Vec<Migration> {
vec![
Migration {
version: 1,
description: "create_initial_tables",
sql: "CREATE TABLE runbooks(id string PRIMARY KEY, name TEXT, content TEXT, created bigint, updated bigint);",
kind: MigrationKind::Up,
}
]
}

View File

@ -1,2 +0,0 @@
pub mod migrations;
pub mod pty;

View File

@ -1,103 +0,0 @@
use eyre::{Result, WrapErr};
use std::io::BufRead;
use std::path::PathBuf;
use crate::state::AtuinState;
use tauri::{Emitter, Manager, State};
use atuin_client::{database::Sqlite, record::sqlite_store::SqliteStore, settings::Settings};
#[tauri::command]
pub async fn pty_open<'a>(
app: tauri::AppHandle,
state: State<'a, AtuinState>,
cwd: Option<String>,
) -> Result<uuid::Uuid, String> {
let id = uuid::Uuid::new_v4();
let cwd = cwd.map(|c| shellexpand::tilde(c.as_str()).to_string());
let pty = crate::pty::Pty::open(24, 80, cwd).await.unwrap();
let reader = pty.reader.clone();
tauri::async_runtime::spawn_blocking(move || loop {
let mut buf = [0u8; 512];
match reader.lock().unwrap().read(&mut buf) {
// EOF
Ok(0) => {
println!("reader loop hit eof");
break;
}
Ok(n) => {
println!("read {n} bytes");
// TODO: sort inevitable encoding issues
let out = String::from_utf8_lossy(&buf).to_string();
let out = out.trim_matches(char::from(0));
let channel = format!("pty-{id}");
app.emit(channel.as_str(), out).unwrap();
}
Err(e) => {
println!("failed to read: {e}");
break;
}
}
});
state.pty_sessions.write().await.insert(id, pty);
Ok(id)
}
#[tauri::command]
pub(crate) async fn pty_write(
pid: uuid::Uuid,
data: String,
state: tauri::State<'_, AtuinState>,
) -> Result<(), String> {
let sessions = state.pty_sessions.read().await;
let pty = sessions.get(&pid).ok_or("Pty not found")?;
let bytes = data.as_bytes().to_vec();
pty.send_bytes(bytes.into())
.await
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub(crate) async fn pty_resize(
pid: uuid::Uuid,
rows: u16,
cols: u16,
state: tauri::State<'_, AtuinState>,
) -> Result<(), String> {
let sessions = state.pty_sessions.read().await;
let pty = sessions.get(&pid).ok_or("Pty not found")?;
pty.resize(rows, cols).await.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub(crate) async fn pty_kill(
pid: uuid::Uuid,
state: tauri::State<'_, AtuinState>,
) -> Result<(), String> {
let pty = state.pty_sessions.write().await.remove(&pid);
match pty {
Some(pty) => {
pty.kill_child().await.map_err(|e| e.to_string())?;
println!("RIP {pid:?}");
}
None => {}
}
Ok(())
}

View File

@ -1,10 +0,0 @@
use std::collections::HashMap;
use std::sync::Mutex;
use tauri::async_runtime::RwLock;
use crate::pty::Pty;
#[derive(Default)]
pub(crate) struct AtuinState {
pub pty_sessions: RwLock<HashMap<uuid::Uuid, Pty>>,
}

View File

@ -1 +0,0 @@

View File

@ -1,47 +0,0 @@
{
"app": {
"security": {
"csp": null
},
"trayIcon": {
"iconAsTemplate": false,
"iconPath": "icons/icon.png"
},
"windows": [
{
"fullscreen": false,
"resizable": true,
"title": "Atuin",
"width": 1200,
"height": 800,
"titleBarStyle": "Overlay",
"hiddenTitle": true
}
]
},
"build": {
"beforeBuildCommand": "pnpm build",
"beforeDevCommand": "pnpm dev",
"devUrl": "http://localhost:1420",
"frontendDist": "../dist"
},
"bundle": {
"active": true,
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"targets": "all"
},
"identifier": "sh.atuin.app",
"plugins": {
"shell": {
"open": true
}
},
"productName": "Atuin",
"version": "0.0.0"
}

View File

@ -1,17 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/styles.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

View File

@ -1,14 +0,0 @@
<!doctype html>
<html lang="en" class="h-full bg-white">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tauri + React + TS</title>
</head>
<body class="h-full">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@ -1,84 +0,0 @@
{
"name": "ui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri"
},
"dependencies": {
"@blocknote/core": "^0.15.3",
"@blocknote/mantine": "^0.15.3",
"@blocknote/react": "^0.15.3",
"@codemirror/autocomplete": "^6.17.0",
"@codemirror/commands": "^6.6.0",
"@codemirror/language": "^6.10.2",
"@codemirror/lint": "^6.8.1",
"@codemirror/search": "^6.5.6",
"@codemirror/state": "^6.4.1",
"@codemirror/view": "^6.28.6",
"@headlessui/react": "^2.1.2",
"@heroicons/react": "^2.1.5",
"@nextui-org/react": "^2.4.6",
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-toast": "^1.2.1",
"@tailwindcss/forms": "^0.5.7",
"@tanstack/react-table": "^8.19.3",
"@tanstack/react-virtual": "^3.8.3",
"@tauri-apps/api": "2.0.0-beta.15",
"@tauri-apps/plugin-dialog": "2.0.0-beta.7",
"@tauri-apps/plugin-http": "2.0.0-beta.8",
"@tauri-apps/plugin-os": "2.0.0-beta.7",
"@tauri-apps/plugin-shell": "2.0.0-beta.8",
"@tauri-apps/plugin-sql": "2.0.0-beta.5",
"@types/luxon": "^3.4.2",
"@uiw/codemirror-extensions-langs": "^4.23.0",
"@uiw/react-codemirror": "^4.23.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"core": "link:@tauri-apps/api/core",
"date-fns": "^3.6.0",
"framer-motion": "^11.3.8",
"highlight.js": "^11.10.0",
"lucide-react": "^0.402.0",
"luxon": "^3.4.4",
"prism-react-renderer": "^2.3.1",
"prismjs": "^1.29.0",
"react": "^18.3.1",
"react-activity-calendar": "^2.2.11",
"react-dom": "^18.3.1",
"react-router-dom": "^6.25.1",
"react-spinners": "^0.14.1",
"react-tooltip": "^5.27.1",
"react-window": "^1.8.10",
"react-window-infinite-loader": "^1.0.9",
"recharts": "^2.12.7",
"tailwind-merge": "^2.4.0",
"tailwindcss-animate": "^1.0.7",
"usehooks-ts": "^3.1.0",
"uuidv7": "^1.0.1",
"vaul": "^0.9.1",
"zustand": "^4.5.4"
},
"devDependencies": {
"@iconify/react": "^5.0.1",
"@tauri-apps/cli": "2.0.0-beta.22",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.39",
"tailwindcss": "^3.4.6",
"typescript": "^5.5.3",
"vite": "^5.3.4",
"vite-tsconfig-paths": "^4.3.2"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@ -1,6 +0,0 @@
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -1,27 +0,0 @@
html {
overscroll-behavior: none;
}
.logo.vite:hover {
filter: drop-shadow(0 0 2em #747bff);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafb);
}
.history-header {
height: 150px;
}
.history-search {
height: 64px;
}
.history-list {
height: calc(100dvh - 4rem - 2rem);
}
.history-item {
height: 90px;
}

View File

@ -1,237 +0,0 @@
import "./App.css";
import { open } from "@tauri-apps/plugin-shell";
import { useState, ReactElement } from "react";
import { useStore } from "@/state/store";
import { Toaster } from "@/components/ui/toaster";
import { KeyRoundIcon } from "lucide-react";
import { Icon } from "@iconify/react";
import Home from "./pages/Home.tsx";
import History from "./pages/History.tsx";
import Dotfiles from "./pages/Dotfiles.tsx";
import LoginOrRegister from "./components/LoginOrRegister.tsx";
import Runbooks from "./pages/Runbooks.tsx";
import {
Avatar,
User,
Button,
ScrollShadow,
Spacer,
Dropdown,
DropdownItem,
DropdownMenu,
DropdownSection,
DropdownTrigger,
Modal,
ModalContent,
useDisclosure,
} from "@nextui-org/react";
import Sidebar, { SidebarItem } from "@/components/Sidebar";
import icon from "@/assets/icon.svg";
import { logout } from "./state/client.ts";
enum Section {
Home,
History,
Dotfiles,
Runbooks,
}
function renderMain(section: Section): ReactElement {
switch (section) {
case Section.Home:
return <Home />;
case Section.History:
return <History />;
case Section.Dotfiles:
return <Dotfiles />;
case Section.Runbooks:
return <Runbooks />;
}
}
function App() {
// routers don't really work in Tauri. It's not a browser!
// I think hashrouter may work, but I'd rather avoiding thinking of them as
// pages
const [section, setSection] = useState(Section.Home);
const user = useStore((state: any) => state.user);
const refreshUser = useStore((state: any) => state.refreshUser);
const { isOpen, onOpen, onOpenChange } = useDisclosure();
const navigation: SidebarItem[] = [
{
key: "personal",
title: "Personal",
items: [
{
key: "home",
icon: "solar:home-2-linear",
title: "Home",
onPress: () => setSection(Section.Home),
},
{
key: "runbooks",
icon: "solar:notebook-linear",
title: "Runbooks",
onPress: () => {
console.log("runbooks");
setSection(Section.Runbooks);
},
},
{
key: "history",
icon: "solar:history-outline",
title: "History",
onPress: () => setSection(Section.History),
},
{
key: "dotfiles",
icon: "solar:file-smile-linear",
title: "Dotfiles",
onPress: () => setSection(Section.Dotfiles),
},
],
},
];
return (
<div
className="flex w-screen select-none"
style={{ maxWidth: "100vw", height: "calc(100dvh - 2rem)" }}
>
<div className="flex w-full">
<div className="relative flex flex-col !border-r-small border-divider transition-width pb-6 pt-4 items-center">
<div className="flex items-center gap-0 px-3 justify-center">
<div className="flex h-8 w-8">
<img src={icon} alt="icon" className="h-8 w-8" />
</div>
</div>
<ScrollShadow className="-mr-6 h-full max-h-full py-6 pr-6">
<Sidebar
defaultSelectedKey="home"
isCompact={true}
items={navigation}
className="z-50"
/>
</ScrollShadow>
<Spacer y={2} />
<div className="flex items-center gap-3 px-3">
<Dropdown showArrow placement="right-start">
<DropdownTrigger>
<Button disableRipple isIconOnly radius="full" variant="light">
<Avatar
isBordered
className="flex-none"
size="sm"
name={user.username || ""}
/>
</Button>
</DropdownTrigger>
<DropdownMenu aria-label="Custom item styles">
<DropdownItem
key="profile"
isReadOnly
className="h-14 opacity-100"
textValue="Signed in as"
>
<User
avatarProps={{
size: "sm",
name: user.username || "Anonymous User",
showFallback: true,
imgProps: {
className: "transition-none",
},
}}
classNames={{
name: "text-default-600",
description: "text-default-500",
}}
description={
user.bio || (user.username && "No bio") || "Sign up now"
}
name={user.username || "Anonymous User"}
/>
</DropdownItem>
<DropdownItem
key="settings"
description="Configure Atuin"
startContent={
<Icon icon="solar:settings-linear" width={24} />
}
>
Settings
</DropdownItem>
<DropdownSection aria-label="Help & Feedback">
<DropdownItem
key="help_and_feedback"
description="Get in touch"
onPress={() => open("https://forum.atuin.sh")}
startContent={
<Icon width={24} icon="solar:question-circle-linear" />
}
>
Help & Feedback
</DropdownItem>
{(user.username && (
<DropdownItem
key="logout"
startContent={
<Icon width={24} icon="solar:logout-broken" />
}
onClick={() => {
logout();
refreshUser();
}}
>
Log Out
</DropdownItem>
)) || (
<DropdownItem
key="signup"
description="Sync, backup and share your data"
className="bg-emerald-100"
startContent={<KeyRoundIcon size="18px" />}
onPress={onOpen}
>
Log in or Register
</DropdownItem>
)}
</DropdownSection>
</DropdownMenu>
</Dropdown>
</div>
</div>
{renderMain(section)}
<Toaster />
<Modal
isOpen={isOpen}
onOpenChange={onOpenChange}
placement="top-center"
>
<ModalContent className="p-8">
{(onClose) => (
<>
<LoginOrRegister onClose={onClose} />
</>
)}
</ModalContent>
</Modal>
</div>
</div>
);
}
export default App;

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 12 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 14 KiB

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -1,20 +0,0 @@
export enum ButtonStyle {
PrimarySm = "bg-emerald-500 hover:bg-emerald-600",
PrimarySmFill = "bg-emerald-500 hover:bg-emerald-600 w-full text-sm",
}
interface ButtonProps {
text: string;
style: ButtonStyle;
}
export default function Button(props: ButtonProps) {
return (
<button
type="button"
className={`rounded ${props.style} px-2 py-1 font-semibold text-white shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500`}
>
{props.text}
</button>
);
}

View File

@ -1,39 +0,0 @@
import { Highlight, themes } from "prism-react-renderer";
// @ts-ignore
import Prism from "prismjs";
// @ts-ignore
import "prismjs/components/prism-bash";
export default function CodeBlock({ code, language }: any) {
return (
<div className="overflow-auto">
<Highlight
theme={themes.github}
code={code}
prism={Prism}
language={language}
>
{({ style, tokens, getLineProps, getTokenProps }) => (
<pre style={style} className="p-4 break-words whitespace-pre-wrap">
{tokens.map((line, i) => (
<div key={i} {...getLineProps({ line })} data-vaul-no-drag>
{i == 0 && (
<span className="text-gray-500 select-none">$ </span>
)}
{line.map((token, key) => (
<span
key={key}
{...getTokenProps({ token })}
data-vaul-no-drag
/>
))}
</div>
))}
</pre>
)}
</Highlight>
</div>
);
}

View File

@ -1,24 +0,0 @@
import { Drawer as VDrawer } from "vaul";
export default function Drawer({
trigger,
children,
width,
open,
onOpenChange,
}: any) {
return (
<VDrawer.Root direction="right" open={open} onOpenChange={onOpenChange}>
<VDrawer.Trigger asChild>{trigger}</VDrawer.Trigger>
<VDrawer.Portal>
<VDrawer.Overlay className="fixed inset-0 bg-black/40 z-50" />
<VDrawer.Content
style={{ width: width || "400px" }}
className={`bg-white flex flex-col z-50 h-full mt-24 fixed bottom-0 right-0`}
>
{children}
</VDrawer.Content>
</VDrawer.Portal>
</VDrawer.Root>
);
}

View File

@ -1,33 +0,0 @@
import HistoryRow from "./history/HistoryRow";
export default function HistoryList(props: any) {
return (
<div
role="list"
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.items.map((i: any) => {
let h = props.history[i.index];
return (
<div
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: `${i.size}px`,
transform: `translateY(${i.start}px)`,
}}
>
<HistoryRow h={h} />
</div>
);
})}
</div>
);
}

View File

@ -1,54 +0,0 @@
import { ArrowPathIcon } from "@heroicons/react/24/outline";
import { MagnifyingGlassIcon } from "@heroicons/react/20/solid";
interface HistorySearchProps {
query: string;
refresh: () => void;
setQuery: (query: string) => void;
}
export default function HistorySearch(props: HistorySearchProps) {
return (
<div className="flex flex-1 gap-x-4 self-stretch lg:gap-x-6">
<form
className="relative flex flex-1"
onSubmit={(e) => {
e.preventDefault();
}}
>
<label htmlFor="search-field" className="sr-only">
Search
</label>
<MagnifyingGlassIcon
className="pointer-events-none absolute inset-y-0 left-0 h-full w-5 text-gray-400"
aria-hidden="true"
/>
<input
id="search-field"
className="block h-full w-full border-0 py-0 pl-8 pr-0 text-gray-900 placeholder:text-gray-400 focus:ring-0 sm:text-sm outline-none"
placeholder="Search..."
autoComplete="off"
autoCapitalize="off"
autoCorrect="off"
spellCheck="false"
type="search"
name="search"
onChange={(query) => {
props.setQuery(query.target.value);
}}
/>
</form>
<div className="flex items-center gap-x-4 lg:gap-x-6">
<button
type="button"
className="-m-2.5 p-2.5 text-gray-400 hover:text-gray-500"
onClick={() => {
props.refresh();
}}
>
<ArrowPathIcon className="h-6 w-6" aria-hidden="true" />
</button>
</div>
</div>
);
}

View File

@ -1,341 +0,0 @@
import Logo from "@/assets/logo-light.svg";
import { useState } from "react";
import { login, register } from "@/state/client";
import { useStore } from "@/state/store";
interface LoginProps {
toggleRegister: () => void;
onClose: () => void;
}
function Login(props: LoginProps) {
const refreshUser = useStore((state) => state.refreshUser);
const [errors, setErrors] = useState<string | null>(null);
const doLogin = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
const username = form.username.value;
const password = form.password.value;
const key = form.key.value;
console.log("Logging in...");
try {
await login(username, password, key);
refreshUser();
props.onClose();
} catch (e: any) {
console.error(e);
setErrors(e);
}
};
return (
<>
<div className="flex min-h-full flex-1 flex-col justify-center px-6 ">
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
<img className="mx-auto h-10 w-auto" src={Logo} alt="Atuin" />
<h2 className="mt-5 text-center text-2xl font-bold leading-9 tracking-tight text-gray-900">
Sign in to your account
</h2>
<p className="text-sm text-center text-gray-600 mt-4 text-wrap">
Backup and sync your data across devices. All data is end-to-end
encrypted and stored securely in the cloud.
</p>
</div>
<div className="mt-10 sm:mx-auto sm:w-full sm:max-w-sm">
<form
className="space-y-6"
action="#"
method="POST"
onSubmit={doLogin}
>
<div>
<label
htmlFor="username"
className="block text-sm font-medium leading-6 text-gray-900"
>
Username
</label>
<div className="mt-2">
<input
id="username"
name="username"
type="username"
autoComplete="off"
autoCapitalize="off"
autoCorrect="off"
spellCheck="false"
required
className="block w-full rounded-md border-0 px-1.5 py-1.5 outline-none text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-emerald-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
<div>
<div className="flex items-center justify-between">
<label
htmlFor="password"
className="block text-sm font-medium leading-6 text-gray-900"
>
Password
</label>
<div className="text-sm">
{/* You can't right now. Sorry. Validate emails first.
<a
href="#"
className="font-semibold text-emerald-600 hover:text-emerald-500"
>
Forgot password?
</a>
*/}
</div>
</div>
<div className="mt-2">
<input
id="password"
name="password"
type="password"
autoCapitalize="off"
autoCorrect="off"
spellCheck="false"
autoComplete="current-password"
required
className="block w-full rounded-md border-0 px-1.5 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset outline-none focus:ring-emerald-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
<div>
<div className="flex items-center justify-between">
<label
htmlFor="key"
className="block text-sm font-medium leading-6 text-gray-900"
>
<p>Key</p>
<p className="text-xs text-gray-500 font-normal">
Paste the output of "atuin key" from another machine
</p>
</label>
</div>
<div className="mt-2">
<input
id="key"
name="key"
autoCapitalize="off"
autoCorrect="off"
spellCheck="false"
autoComplete="off"
required
className="block w-full rounded-md border-0 px-1.5 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset outline-none focus:ring-emerald-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
<div>
<button
type="submit"
className="flex w-full justify-center rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-emerald-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-600"
>
Sign in
</button>
</div>
</form>
{errors && (
<p className="mt-4 text-center text-sm text-red-500">{errors}</p>
)}
<p className="mt-10 text-center text-sm text-gray-500">
Not a member?{" "}
<a
href="#"
className="font-semibold leading-6 text-emerald-600 hover:text-emerald-500"
onClick={(e) => {
e.preventDefault();
props.toggleRegister();
}}
>
Register
</a>
</p>
</div>
</div>
</>
);
}
interface RegisterProps {
toggleLogin: () => void;
onClose: () => void;
}
function Register(props: RegisterProps) {
const refreshUser = useStore((state) => state.refreshUser);
const [errors, setErrors] = useState<string | null>(null);
const doRegister = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
const username = form.username.value;
const email = form.email.value;
const password = form.password.value;
try {
await register(username, email, password);
refreshUser();
props.onClose();
} catch (e: any) {
setErrors(e);
}
};
return (
<>
<div className="flex min-h-full flex-1 flex-col justify-center px-6 ">
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
<img className="mx-auto h-10 w-auto" src={Logo} alt="Atuin" />
<h2 className="mt-5 text-center text-2xl font-bold leading-9 tracking-tight text-gray-900">
Register for an account
</h2>
<p className="text-sm text-center text-gray-600 mt-4 text-wrap">
Backup and sync your data across devices. All data is end-to-end
encrypted and stored securely in the cloud.
</p>
</div>
<div className="mt-10 sm:mx-auto sm:w-full sm:max-w-sm">
<form
className="space-y-6"
action="#"
method="POST"
onSubmit={doRegister}
>
<div>
<label
htmlFor="username"
className="block text-sm font-medium leading-6 text-gray-900"
>
Username
</label>
<div className="mt-2">
<input
id="username"
name="username"
type="username"
autoComplete="off"
autoCapitalize="off"
autoCorrect="off"
spellCheck="false"
required
className="block w-full rounded-md border-0 px-1.5 py-1.5 outline-none text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-emerald-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
<div>
<label
htmlFor="email"
className="block text-sm font-medium leading-6 text-gray-900"
>
Email
</label>
<div className="mt-2">
<input
id="email"
name="email"
type="email"
autoComplete="email"
autoCapitalize="off"
autoCorrect="off"
spellCheck="false"
required
className="block w-full rounded-md border-0 px-1.5 py-1.5 outline-none text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-emerald-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
<div>
<div className="flex items-center justify-between">
<label
htmlFor="password"
className="block text-sm font-medium leading-6 text-gray-900"
>
Password
</label>
<div className="text-sm">
{/* You can't right now. Sorry. Validate emails first.
<a
href="#"
className="font-semibold text-emerald-600 hover:text-emerald-500"
>
Forgot password?
</a>
*/}
</div>
</div>
<div className="mt-2">
<input
id="password"
name="password"
type="password"
autoCapitalize="off"
autoCorrect="off"
spellCheck="false"
autoComplete="current-password"
required
className="block w-full rounded-md border-0 px-1.5 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset outline-none focus:ring-emerald-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
<div>
<button
type="submit"
className="flex w-full justify-center rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-emerald-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-600"
>
Register
</button>
</div>
</form>
{errors && (
<p className="mt-4 text-center text-sm text-red-500">{errors}</p>
)}
<p className="mt-10 text-center text-sm text-gray-500">
Already have an account?{" "}
<a
href="#"
className="font-semibold leading-6 text-emerald-600 hover:text-emerald-500"
onClick={(e) => {
e.preventDefault();
props.toggleLogin();
}}
>
Login
</a>
</p>
</div>
</div>
</>
);
}
export default function LoginOrRegister({ onClose }: { onClose: () => void }) {
let [login, setLogin] = useState<boolean>(false);
if (login) {
return <Login onClose={onClose} toggleRegister={() => setLogin(false)} />;
}
return <Register onClose={onClose} toggleLogin={() => setLogin(true)} />;
}

View File

@ -1,328 +0,0 @@
"use client";
import {
Accordion,
AccordionItem,
type ListboxProps,
type ListboxSectionProps,
type Selection,
} from "@nextui-org/react";
import React from "react";
import {
Listbox,
Tooltip,
ListboxItem,
ListboxSection,
} from "@nextui-org/react";
import { Icon } from "@iconify/react";
import { cn } from "@/lib/utils";
export enum SidebarItemType {
Nest = "nest",
}
export type SidebarItem = {
key: string;
title: string;
icon?: string;
href?: string;
onPress?: () => void;
type?: SidebarItemType.Nest;
startContent?: React.ReactNode;
endContent?: React.ReactNode;
items?: SidebarItem[];
className?: string;
};
export type SidebarProps = Omit<ListboxProps<SidebarItem>, "children"> & {
items: SidebarItem[];
isCompact?: boolean;
hideEndContent?: boolean;
iconClassName?: string;
sectionClasses?: ListboxSectionProps["classNames"];
classNames?: ListboxProps["classNames"];
defaultSelectedKey: string;
onSelect?: (key: string) => void;
};
const Sidebar = React.forwardRef<HTMLElement, SidebarProps>(
(
{
items,
isCompact,
defaultSelectedKey,
onSelect,
hideEndContent,
sectionClasses: sectionClassesProp = {},
itemClasses: itemClassesProp = {},
iconClassName,
classNames,
className,
...props
},
ref,
) => {
const [selected, setSelected] =
React.useState<React.Key>(defaultSelectedKey);
const sectionClasses = {
...sectionClassesProp,
base: cn(sectionClassesProp?.base, "w-full", {
"p-0 max-w-[44px]": isCompact,
}),
group: cn(sectionClassesProp?.group, {
"flex flex-col gap-1": isCompact,
}),
heading: cn(sectionClassesProp?.heading, {
hidden: isCompact,
}),
};
const itemClasses = {
...itemClassesProp,
base: cn(itemClassesProp?.base, {
"w-11 h-11 gap-0 p-0": isCompact,
}),
};
const renderNestItem = React.useCallback(
(item: SidebarItem) => {
const isNestType =
item.items &&
item.items?.length > 0 &&
item?.type === SidebarItemType.Nest;
if (isNestType) {
// Is a nest type item , so we need to remove the href
delete item.href;
}
return (
<ListboxItem
{...item}
key={item.key}
classNames={{
base: cn(
{
"h-auto p-0": !isCompact && isNestType,
},
{
"inline-block w-11": isCompact && isNestType,
},
),
}}
endContent={
isCompact || isNestType || hideEndContent
? null
: item.endContent ?? null
}
startContent={
isCompact || isNestType ? null : item.icon ? (
<Icon
className={cn(
"text-default-500 group-data-[selected=true]:text-foreground",
iconClassName,
)}
icon={item.icon}
width={24}
/>
) : (
item.startContent ?? null
)
}
title={isCompact || isNestType ? null : item.title}
>
{isCompact ? (
<Tooltip content={item.title} placement="right">
<div className="flex w-full items-center justify-center">
{item.icon ? (
<Icon
className={cn(
"text-default-500 group-data-[selected=true]:text-foreground",
iconClassName,
)}
icon={item.icon}
width={24}
/>
) : (
item.startContent ?? null
)}
</div>
</Tooltip>
) : null}
{!isCompact && isNestType ? (
<Accordion className={"p-0"}>
<AccordionItem
key={item.key}
aria-label={item.title}
classNames={{
heading: "pr-3",
trigger: "p-0",
content: "py-0 pl-4",
}}
title={
item.icon ? (
<div
className={"flex h-11 items-center gap-2 px-2 py-1.5"}
>
<Icon
className={cn(
"text-default-500 group-data-[selected=true]:text-foreground",
iconClassName,
)}
icon={item.icon}
width={24}
/>
<span className="text-small font-medium text-default-500 group-data-[selected=true]:text-foreground">
{item.title}
</span>
</div>
) : (
item.startContent ?? null
)
}
>
{item.items && item.items?.length > 0 ? (
<Listbox
className={"mt-0.5"}
classNames={{
list: cn("border-l border-default-200 pl-4"),
}}
items={item.items}
variant="flat"
>
{item.items.map(renderItem)}
</Listbox>
) : (
renderItem(item)
)}
</AccordionItem>
</Accordion>
) : null}
</ListboxItem>
);
},
[isCompact, hideEndContent, iconClassName, items],
);
const renderItem = React.useCallback(
(item: SidebarItem) => {
const isNestType =
item.items &&
item.items?.length > 0 &&
item?.type === SidebarItemType.Nest;
if (isNestType) {
return renderNestItem(item);
}
return (
<ListboxItem
{...item}
key={item.key}
endContent={
isCompact || hideEndContent ? null : item.endContent ?? null
}
startContent={
isCompact ? null : item.icon ? (
<Icon
className={cn(
"text-default-500 group-data-[selected=true]:text-foreground",
iconClassName,
)}
icon={item.icon}
width={24}
/>
) : (
item.startContent ?? null
)
}
textValue={item.title}
title={isCompact ? null : item.title}
>
{isCompact ? (
<Tooltip content={item.title} placement="right">
<div className="flex w-full items-center justify-center">
{item.icon ? (
<Icon
className={cn(
"text-default-500 group-data-[selected=true]:text-foreground",
iconClassName,
)}
icon={item.icon}
width={24}
/>
) : (
item.startContent ?? null
)}
</div>
</Tooltip>
) : null}
</ListboxItem>
);
},
[isCompact, hideEndContent, iconClassName, itemClasses?.base],
);
return (
<Listbox
key={isCompact ? "compact" : "default"}
ref={ref}
hideSelectedIcon
as="nav"
className={cn("list-none", className)}
classNames={{
...classNames,
list: cn("items-center", classNames?.list),
}}
color="default"
itemClasses={{
...itemClasses,
base: cn(
"px-3 min-h-11 rounded-large h-[44px] data-[selected=true]:bg-default-100",
itemClasses?.base,
),
title: cn(
"text-small font-medium text-default-500 group-data-[selected=true]:text-foreground",
itemClasses?.title,
),
}}
items={items}
selectedKeys={[selected] as unknown as Selection}
selectionMode="single"
variant="flat"
onSelectionChange={(keys) => {
const key = Array.from(keys)[0];
setSelected(key as React.Key);
onSelect?.(key as string);
}}
{...props}
>
{(item) => {
return item.items &&
item.items?.length > 0 &&
item?.type === SidebarItemType.Nest ? (
renderNestItem(item)
) : item.items && item.items?.length > 0 ? (
<ListboxSection
key={item.key}
classNames={sectionClasses}
showDivider={isCompact}
title={item.title}
>
{item.items.map(renderItem)}
</ListboxSection>
) : (
renderItem(item)
);
}}
</Listbox>
);
},
);
Sidebar.displayName = "Sidebar";
export default Sidebar;

View File

@ -1,4 +0,0 @@
import Sidebar, { SidebarItem } from "./Sidebar";
export type { SidebarItem };
export default Sidebar;

View File

@ -1,180 +0,0 @@
import { useEffect, useState } from "react";
import DataTable from "@/components/ui/data-table";
import { Button } from "@/components/ui/button";
import { MoreHorizontal } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ColumnDef } from "@tanstack/react-table";
import { invoke } from "@tauri-apps/api/core";
import Drawer from "@/components/Drawer";
import { Alias } from "@/state/models";
import { useStore } from "@/state/store";
function deleteAlias(name: string, refreshAliases: () => void) {
invoke("delete_alias", { name: name })
.then(() => {
refreshAliases();
})
.catch(() => {
console.error("Failed to delete alias");
});
}
function AddAlias({ onAdd: onAdd }: { onAdd?: () => void }) {
let [name, setName] = useState("");
let [value, setValue] = useState("");
// simple form to add aliases
return (
<div className="p-4">
<h2 className="text-xl font-semibold leading-6 text-gray-900">
Add alias
</h2>
<p className="mt-2">Add a new alias to your shell</p>
<form
className="mt-4"
onSubmit={(e) => {
e.preventDefault();
invoke("set_alias", { name: name, value: value })
.then(() => {
console.log("Added alias");
if (onAdd) onAdd();
})
.catch(() => {
console.error("Failed to add alias");
});
}}
>
<input
className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-md focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Alias name"
/>
<input
className="mt-4 bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-md focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
autoComplete="off"
autoCapitalize="off"
autoCorrect="off"
spellCheck="false"
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Alias value"
/>
<input
type="submit"
className="block mt-4 rounded-md bg-green-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm hover:bg-green-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-green-600"
value="Add alias"
/>
</form>
</div>
);
}
export default function Aliases() {
const aliases = useStore((state) => state.aliases);
const refreshAliases = useStore((state) => state.refreshAliases);
let [aliasDrawerOpen, setAliasDrawerOpen] = useState(false);
const columns: ColumnDef<Alias>[] = [
{
accessorKey: "name",
header: "Name",
},
{
accessorKey: "value",
header: "Value",
},
{
id: "actions",
cell: ({ row }: any) => {
const alias = row.original;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0 float-right">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4 text-right" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => deleteAlias(alias.name, refreshAliases)}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
},
},
];
useEffect(() => {
refreshAliases();
}, []);
return (
<div className="pt-10">
<div className="sm:flex sm:items-center">
<div className="sm:flex-auto">
<h1 className="text-base font-semibold leading-6 text-gray-900">
Aliases
</h1>
<p className="mt-2 text-sm text-gray-700">
Aliases allow you to condense long commands into short,
easy-to-remember commands.
</p>
</div>
<div className="mt-4 sm:ml-16 sm:mt-0 flex-row">
<Drawer
open={aliasDrawerOpen}
onOpenChange={setAliasDrawerOpen}
width="30%"
trigger={
<button
type="button"
className="block rounded-md bg-green-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm hover:bg-green-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-green-600"
>
Add
</button>
}
>
<AddAlias
onAdd={() => {
refreshAliases();
setAliasDrawerOpen(false);
}}
/>
</Drawer>
</div>
</div>
<div className="mt-8 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 sm:px-6 lg:px-8">
<DataTable columns={columns} data={aliases} />
</div>
</div>
</div>
</div>
);
}

View File

@ -1,194 +0,0 @@
import { useEffect, useState } from "react";
import DataTable from "@/components/ui/data-table";
import { Button } from "@/components/ui/button";
import { MoreHorizontal } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ColumnDef } from "@tanstack/react-table";
import { invoke } from "@tauri-apps/api/core";
import Drawer from "@/components/Drawer";
import { Var } from "@/state/models";
import { useStore } from "@/state/store";
function deleteVar(name: string, refreshVars: () => void) {
invoke("delete_var", { name: name })
.then(() => {
refreshVars();
})
.catch(() => {
console.error("Failed to delete var");
});
}
function AddVar({ onAdd: onAdd }: { onAdd?: () => void }) {
let [name, setName] = useState("");
let [value, setValue] = useState("");
let [exp, setExport] = useState<boolean>(false);
// simple form to add vars
return (
<div className="p-4">
<h2 className="text-xl font-semibold leading-6 text-gray-900">Add var</h2>
<p className="mt-2">Add a new var to your shell</p>
<form
className="mt-4"
onSubmit={(e) => {
e.preventDefault();
invoke("set_var", { name: name, value: value, export: exp })
.then(() => {
console.log("Added var");
if (onAdd) onAdd();
})
.catch(() => {
console.error("Failed to add var");
});
}}
>
<input
className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-md focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Var name"
/>
<input
className="mt-4 bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-md focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
autoComplete="off"
autoCapitalize="off"
autoCorrect="off"
spellCheck="false"
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Var value"
/>
<div>
<label>
<input
className="mt-4 bg-gray-50 mr-2 inline"
autoComplete="off"
autoCapitalize="off"
autoCorrect="off"
spellCheck="false"
type="checkbox"
value={exp.toString()}
onChange={(e) => setExport(e.target.checked)}
/>
Export the var and make it visible to subprocesses
</label>
</div>
<input
type="submit"
className="block mt-4 rounded-md bg-green-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm hover:bg-green-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-green-600"
value="Add var"
/>
</form>
</div>
);
}
export default function Vars() {
const vars = useStore((state) => state.vars);
const refreshVars = useStore((state) => state.refreshVars);
let [varDrawerOpen, setVarDrawerOpen] = useState(false);
const columns: ColumnDef<Var>[] = [
{
accessorKey: "name",
header: "Name",
},
{
accessorKey: "value",
header: "Value",
},
{
id: "actions",
cell: ({ row }: any) => {
const shell_var = row.original;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0 float-right">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4 text-right" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => deleteVar(shell_var.name, refreshVars)}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
},
},
];
useEffect(() => {
refreshVars();
}, []);
return (
<div className="pt-10">
<div className="sm:flex sm:items-center">
<div className="sm:flex-auto">
<h1 className="text-base font-semibold leading-6 text-gray-900">
Vars
</h1>
<p className="mt-2 text-sm text-gray-700">
Configure environment variables here
</p>
</div>
<div className="mt-4 sm:ml-16 sm:mt-0 flex-row">
<Drawer
open={varDrawerOpen}
onOpenChange={setVarDrawerOpen}
width="30%"
trigger={
<button
type="button"
className="block rounded-md bg-green-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm hover:bg-green-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-green-600"
>
Add
</button>
}
>
<AddVar
onAdd={() => {
refreshVars();
setVarDrawerOpen(false);
}}
/>
</Drawer>
</div>
</div>
<div className="mt-8 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 sm:px-6 lg:px-8">
<DataTable columns={columns} data={vars} />
</div>
</div>
</div>
</div>
);
}

View File

@ -1,40 +0,0 @@
import { useState, useEffect } from "react";
import PacmanLoader from "react-spinners/PacmanLoader";
import CodeBlock from "@/components/CodeBlock";
import HistoryRow from "@/components/history/HistoryRow";
import { ShellHistory, inspectCommandHistory } from "@/state/models";
function renderLoading() {
return (
<div className="flex items-center justify-center h-full">
<PacmanLoader color="#26bd65" />
</div>
);
}
export default function HistoryInspect({ history }: any) {
let [other, setOther] = useState<ShellHistory[]>([]);
useEffect(() => {
(async () => {
let inspect = await inspectCommandHistory(history);
setOther(inspect.other);
})();
}, []);
if (other.length == 0) return renderLoading();
return (
<div className="overflow-y-auto">
<CodeBlock code={history.command} language="bash" />
<div>
{other.map((i: any) => {
return <HistoryRow h={i} />;
})}
</div>
</div>
);
}

View File

@ -1,120 +0,0 @@
// @ts-ignore
import { DateTime } from "luxon";
import { ChevronRightIcon } from "@heroicons/react/20/solid";
import { Highlight, themes } from "prism-react-renderer";
// @ts-ignore
import Prism from "prismjs";
// @ts-ignore
import "prismjs/components/prism-bash";
import Drawer from "../Drawer";
import HistoryInspect from "./HistoryInspect";
import { cn } from "@/lib/utils";
function msToTime(ms: number) {
let milliseconds = parseInt(ms.toFixed(1));
let seconds = parseInt((ms / 1000).toFixed(1));
let minutes = parseInt((ms / (1000 * 60)).toFixed(1));
let hours = parseInt((ms / (1000 * 60 * 60)).toFixed(1));
let days = parseInt((ms / (1000 * 60 * 60 * 24)).toFixed(1));
if (milliseconds < 1000) return milliseconds + "ms";
else if (seconds < 60) return seconds + "s";
else if (minutes < 60) return minutes + "m";
else if (hours < 24) return hours + "hr";
else return days + " Days";
}
export default function HistoryRow({ h, compact }: any) {
return (
<li
key={h.id}
className={cn(
"relative flex justify-between gap-x-6 px-4 py-5 hover:bg-gray-50 sm:px-6",
{ "py-5": !compact },
{ "py-1": compact },
)}
>
<div className="flex min-w-0 gap-x-4">
{!compact && (
<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 truncate">
<Highlight
theme={themes.github}
code={h.command}
language="bash"
prism={Prism}
>
{({ style, tokens, getLineProps, getTokenProps }) => (
<pre style={style} className="!bg-inherit text-sm">
{tokens &&
tokens.map((line, i) => {
if (i != 0) return;
return (
<div key={i} {...getLineProps({ line })}>
{line.map((token, key) => (
<span key={key} {...getTokenProps({ token })} />
))}
</div>
);
})}
</pre>
)}
</Highlight>
<p className="mt-1 flex text-xs leading-5 text-gray-500">
<span className="relative truncate ">{h.user}</span>
<span>&nbsp;on&nbsp;</span>
<span className="relative truncate ">{h.host}</span>
<span>&nbsp;in&nbsp;</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>
</p>
) : (
<div />
)}
</div>
<Drawer
width="60%"
trigger={
<button type="button">
<ChevronRightIcon
className="h-5 w-5 flex-none text-gray-400"
aria-hidden="true"
/>
</button>
}
>
<HistoryInspect history={h} />
</Drawer>
</div>
</li>
);
}

View File

@ -1,161 +0,0 @@
import { useState, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
import PacmanLoader from "react-spinners/PacmanLoader";
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
} from "recharts";
function renderLoading() {
return (
<div className="flex flex-col items-center justify-center h-full ">
<div>
<PacmanLoader color="#26bd65" />
</div>
<div className="block mt-4">
<p>Crunching the latest numbers...</p>
</div>
</div>
);
}
function TopTable({ stats }: any) {
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: any) => (
<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([]);
useEffect(() => {
if (stats.length != 0) return;
invoke("global_stats")
.then((s: any) => {
console.log(s.daily);
setStats([
{
name: "Total history",
stat: s.total_history.toLocaleString(),
},
{
name: "Unique history",
stat: s.stats.unique_commands.toLocaleString(),
},
{
name: "Last 1d",
stat: s.last_1d.toLocaleString(),
},
{
name: "Last 7d",
stat: s.last_7d.toLocaleString(),
},
{
name: "Last 30d",
stat: s.last_30d.toLocaleString(),
},
]);
setChart(s.daily);
setTop(s.stats);
})
.catch((e) => {
console.log(e);
});
}, []);
if (stats.length == 0) {
return renderLoading();
}
return (
<div className="flex flex-col overflow-y-scroll">
<div className="flexfull">
<dl className="grid grid-cols-1 sm:grid-cols-5 w-full">
{stats.map((item: any) => (
<div
key={item.name}
className="overflow-hidden bg-white px-4 py-5 shadow sm:p-6"
>
<dt className="truncate text-sm font-medium text-gray-500">
{item.name}
</dt>
<dd className="mt-1 text-3xl font-semibold tracking-tight text-gray-900">
{item.stat}
</dd>
</div>
))}
</dl>
</div>
<div className="flex flex-col h-54 py-4 pl-5">
<div className="flex flex-col h-48 pt-5 pr-5">
<ResponsiveContainer width="100%" height="100%">
<BarChart width={500} height={300} data={chart}>
<XAxis dataKey="name" hide={true} />
<YAxis />
<Tooltip />
<Bar dataKey="value" fill="#26bd65" />
</BarChart>
</ResponsiveContainer>
</div>
</div>
<div>
<TopTable stats={top.top} />
</div>
</div>
);
}

View File

@ -1 +0,0 @@
export default function QuickActions() {}

View File

@ -1,141 +0,0 @@
import { useEffect } from "react";
import {
Button,
ButtonGroup,
Tooltip,
Listbox,
ListboxItem,
Dropdown,
DropdownTrigger,
DropdownMenu,
DropdownItem,
Badge,
} from "@nextui-org/react";
import { EllipsisVerticalIcon } from "lucide-react";
import { DateTime } from "luxon";
import { NotebookPenIcon } from "lucide-react";
import Runbook from "@/state/runbooks/runbook";
import { AtuinState, useStore } from "@/state/store";
const NoteSidebar = () => {
const runbooks = useStore((state: AtuinState) => state.runbooks);
const refreshRunbooks = useStore(
(state: AtuinState) => state.refreshRunbooks,
);
const currentRunbook = useStore((state: AtuinState) => state.currentRunbook);
const setCurrentRunbook = useStore(
(state: AtuinState) => state.setCurrentRunbook,
);
const runbookInfo = useStore((state: AtuinState) => state.runbookInfo);
useEffect(() => {
refreshRunbooks();
}, []);
return (
<div className="w-48 flex flex-col border-r-1">
<div className="overflow-y-auto flex-grow">
<Listbox
hideSelectedIcon
items={runbooks.map((runbook: any): any => {
return [runbook, runbookInfo[runbook.id]];
})}
variant="flat"
aria-label="Runbook list"
selectionMode="single"
selectedKeys={currentRunbook ? [currentRunbook] : []}
itemClasses={{ base: "data-[selected=true]:bg-gray-200" }}
topContent={
<ButtonGroup className="z-20">
<Tooltip showArrow content="New Runbook" closeDelay={50}>
<Button
isIconOnly
aria-label="New note"
variant="light"
size="sm"
onPress={async () => {
// otherwise the cursor is weirdly positioned in the new document
window.getSelection()?.removeAllRanges();
let runbook = await Runbook.create();
setCurrentRunbook(runbook.id);
refreshRunbooks();
}}
>
<NotebookPenIcon className="p-[0.15rem]" />
</Button>
</Tooltip>
</ButtonGroup>
}
>
{([runbook, info]: [Runbook, { ptys: number }]) => (
<ListboxItem
key={runbook.id}
onPress={() => {
setCurrentRunbook(runbook.id);
}}
textValue={runbook.name || "Untitled"}
endContent={
<Dropdown>
<Badge
content={info?.ptys}
color="primary"
style={
info && info?.ptys > 0
? {}
: {
display: "none",
}
}
>
<DropdownTrigger className="bg-transparent">
<Button isIconOnly>
<EllipsisVerticalIcon
size="16px"
className="bg-transparent"
/>
</Button>
</DropdownTrigger>
</Badge>
<DropdownMenu aria-label="Dynamic Actions">
<DropdownItem
key={"delete"}
color="danger"
className="text-danger"
onPress={async () => {
await Runbook.delete(runbook.id);
if (runbook.id == currentRunbook) setCurrentRunbook("");
refreshRunbooks();
}}
>
Delete
</DropdownItem>
</DropdownMenu>
</Dropdown>
}
>
<div className="flex flex-col">
<div className="text-md">{runbook.name || "Untitled"}</div>
<div className="text-xs text-gray-500">
<em>
{DateTime.fromJSDate(runbook.updated).toLocaleString(
DateTime.DATETIME_SHORT,
)}
</em>
</div>
</div>
</ListboxItem>
)}
</Listbox>
</div>
</div>
);
};
export default NoteSidebar;

View File

@ -1,200 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import "./index.css";
import { Spinner } from "@nextui-org/react";
// Errors, but it all works fine and is there. Maybe missing ts defs?
// I'll figure it out later
import {
// @ts-ignore
BlockNoteSchema,
// @ts-ignore
BlockNoteEditor,
// @ts-ignore
defaultBlockSpecs,
// @ts-ignore
filterSuggestionItems,
// @ts-ignore
insertOrUpdateBlock,
} from "@blocknote/core";
import {
//@ts-ignore
SuggestionMenuController,
// @ts-ignore
AddBlockButton,
// @ts-ignore
getDefaultReactSlashMenuItems,
// @ts-ignore
SideMenu,
// @ts-ignore
SideMenuController,
} from "@blocknote/react";
import { BlockNoteView } from "@blocknote/mantine";
import "@blocknote/core/fonts/inter.css";
import "@blocknote/mantine/style.css";
import { CodeIcon, FolderOpenIcon } from "lucide-react";
import { useDebounceCallback } from "usehooks-ts";
import Run from "@/components/runbooks/editor/blocks/Run";
import Directory from "@/components/runbooks/editor/blocks/Directory";
import { DeleteBlock } from "@/components/runbooks/editor/ui/DeleteBlockButton";
import { AtuinState, useStore } from "@/state/store";
import Runbook from "@/state/runbooks/runbook";
// Our schema with block specs, which contain the configs and implementations for blocks
// that we want our editor to use.
const schema = BlockNoteSchema.create({
blockSpecs: {
// Adds all default blocks.
...defaultBlockSpecs,
// Adds the code block.
run: Run,
directory: Directory,
},
});
// Slash menu item to insert an Alert block
const insertRun = (editor: typeof schema.BlockNoteEditor) => ({
title: "Code",
onItemClick: () => {
insertOrUpdateBlock(editor, {
type: "run",
});
},
icon: <CodeIcon size={18} />,
aliases: ["code", "run"],
group: "Execute",
});
const insertDirectory = (editor: typeof schema.BlockNoteEditor) => ({
title: "Directory",
onItemClick: () => {
insertOrUpdateBlock(editor, {
type: "directory",
});
},
icon: <FolderOpenIcon size={18} />,
aliases: ["directory", "dir", "folder"],
group: "Execute",
});
export default function Editor() {
const runbookId = useStore((store: AtuinState) => store.currentRunbook);
const refreshRunbooks = useStore(
(store: AtuinState) => store.refreshRunbooks,
);
let [runbook, setRunbook] = useState<Runbook | null>(null);
useEffect(() => {
if (!runbookId) return;
const fetchRunbook = async () => {
let rb = await Runbook.load(runbookId);
setRunbook(rb);
};
fetchRunbook();
}, [runbookId]);
const onChange = async () => {
if (!runbook) return;
console.log("saved!");
runbook.name = fetchName();
if (editor) runbook.content = JSON.stringify(editor.document);
await runbook.save();
refreshRunbooks();
};
const debouncedOnChange = useDebounceCallback(onChange, 1000);
const editor = useMemo(() => {
if (!runbook) return undefined;
if (runbook.content) {
return BlockNoteEditor.create({
initialContent: JSON.parse(runbook.content),
schema,
});
}
return BlockNoteEditor.create({ schema });
}, [runbook]);
const fetchName = (): string => {
// Infer the title from the first text block
if (!editor) return "Untitled";
let blocks = editor.document;
for (const block of blocks) {
if (block.type == "heading" || block.type == "paragraph") {
if (block.content.length == 0) continue;
// @ts-ignore
if (block.content[0].text.length == 0) continue;
// @ts-ignore
return block.content[0].text;
}
}
return "Untitled";
};
if (!runbook) {
return (
<div className="flex w-full h-full flex-col justify-center items-center">
<Spinner />
</div>
);
}
if (editor === undefined) {
return (
<div className="flex w-full h-full flex-col justify-center items-center">
<Spinner />
</div>
);
}
// Renders the editor instance.
return (
<div className="overflow-y-scroll w-full">
<BlockNoteView
editor={editor}
slashMenu={false}
sideMenu={false}
onChange={debouncedOnChange}
>
<SuggestionMenuController
triggerCharacter={"/"}
getItems={async (query: any) =>
filterSuggestionItems(
[
...getDefaultReactSlashMenuItems(editor),
insertRun(editor),
insertDirectory(editor),
],
query,
)
}
/>
<SideMenuController
sideMenu={(props: any) => (
<SideMenu {...props}>
<AddBlockButton {...props} />
<DeleteBlock {...props} />
</SideMenu>
)}
/>
</BlockNoteView>
</div>
);
}

View File

@ -1,89 +0,0 @@
import { useState } from "react";
import { Input, Tooltip, Button } from "@nextui-org/react";
import { FolderInputIcon } from "lucide-react";
// @ts-ignore
import { createReactBlockSpec } from "@blocknote/react";
import { open } from "@tauri-apps/plugin-dialog";
interface DirectoryProps {
path: string;
onInputChange: (val: string) => void;
}
const Directory = ({ path, onInputChange }: DirectoryProps) => {
const [value, setValue] = useState(path);
const selectFolder = async () => {
const path = await open({
multiple: false,
directory: true,
});
setValue(path || "");
onInputChange(path || "");
};
return (
<div className="w-full !max-w-full !outline-none overflow-none">
<Tooltip
content="Change working directory for all subsequent code blocks"
delay={1000}
>
<div className="flex flex-row">
<div className="mr-2">
<Button
isIconOnly
variant="flat"
aria-label="Select folder"
onPress={selectFolder}
>
<FolderInputIcon />
</Button>
</div>
<div className="w-full">
<Input
placeholder="~"
value={value}
autoComplete="off"
autoCapitalize="off"
autoCorrect="off"
spellCheck="false"
onValueChange={(val) => {
setValue(val);
onInputChange(val);
}}
/>
</div>
</div>
</Tooltip>
</div>
);
};
export default createReactBlockSpec(
{
type: "directory",
propSchema: {
path: { default: "" },
},
content: "none",
},
{
// @ts-ignore
render: ({ block, editor, code, type }) => {
const onInputChange = (val: string) => {
editor.updateBlock(block, {
// @ts-ignore
props: { ...block.props, path: val },
});
};
return (
<Directory path={block.props.path} onInputChange={onInputChange} />
);
},
},
);

View File

@ -1,158 +0,0 @@
// Based on the basicSetup extension, as suggested by the source. Customized for Atuin.
import {
KeyBinding,
lineNumbers,
highlightActiveLineGutter,
highlightSpecialChars,
drawSelection,
dropCursor,
rectangularSelection,
crosshairCursor,
highlightActiveLine,
keymap,
} from "@codemirror/view";
import { EditorState, Extension } from "@codemirror/state";
import { history, defaultKeymap, historyKeymap } from "@codemirror/commands";
import { highlightSelectionMatches, searchKeymap } from "@codemirror/search";
import {
closeBrackets,
autocompletion,
closeBracketsKeymap,
completionKeymap,
CompletionContext,
} from "@codemirror/autocomplete";
import {
foldGutter,
indentOnInput,
syntaxHighlighting,
defaultHighlightStyle,
bracketMatching,
indentUnit,
foldKeymap,
} from "@codemirror/language";
import { lintKeymap } from "@codemirror/lint";
import { invoke } from "@tauri-apps/api/core";
export interface MinimalSetupOptions {
highlightSpecialChars?: boolean;
history?: boolean;
drawSelection?: boolean;
syntaxHighlighting?: boolean;
defaultKeymap?: boolean;
historyKeymap?: boolean;
}
export interface BasicSetupOptions extends MinimalSetupOptions {
lineNumbers?: boolean;
highlightActiveLineGutter?: boolean;
foldGutter?: boolean;
dropCursor?: boolean;
allowMultipleSelections?: boolean;
indentOnInput?: boolean;
bracketMatching?: boolean;
closeBrackets?: boolean;
autocompletion?: boolean;
rectangularSelection?: boolean;
crosshairCursor?: boolean;
highlightActiveLine?: boolean;
highlightSelectionMatches?: boolean;
closeBracketsKeymap?: boolean;
searchKeymap?: boolean;
foldKeymap?: boolean;
completionKeymap?: boolean;
lintKeymap?: boolean;
tabSize?: number;
}
function myCompletions(context: CompletionContext) {
let word = context.matchBefore(/^.*/);
if (!word) return null;
if (word.from == word.to && !context.explicit) return null;
return invoke("prefix_search", { query: word.text }).then(
// @ts-ignore
(results: string[]) => {
let options = results.map((i) => {
return { label: i, type: "text" };
});
return {
from: word.from,
options,
};
},
);
}
const buildAutocomplete = (): Extension => {
let ac = autocompletion({ override: [myCompletions] });
return ac;
};
export const extensions = (options: BasicSetupOptions = {}): Extension[] => {
const { crosshairCursor: initCrosshairCursor = false } = options;
let keymaps: KeyBinding[] = [];
if (options.closeBracketsKeymap !== false) {
keymaps = keymaps.concat(closeBracketsKeymap);
}
if (options.defaultKeymap !== false) {
keymaps = keymaps.concat(defaultKeymap);
}
if (options.searchKeymap !== false) {
keymaps = keymaps.concat(searchKeymap);
}
if (options.historyKeymap !== false) {
keymaps = keymaps.concat(historyKeymap);
}
if (options.foldKeymap !== false) {
keymaps = keymaps.concat(foldKeymap);
}
if (options.completionKeymap !== false) {
keymaps = keymaps.concat(completionKeymap);
}
if (options.lintKeymap !== false) {
keymaps = keymaps.concat(lintKeymap);
}
const extensions: Extension[] = [];
if (options.lineNumbers !== false) extensions.push(lineNumbers());
if (options.highlightActiveLineGutter !== false)
extensions.push(highlightActiveLineGutter());
if (options.highlightSpecialChars !== false)
extensions.push(highlightSpecialChars());
if (options.history !== false) extensions.push(history());
if (options.foldGutter !== false) extensions.push(foldGutter());
if (options.drawSelection !== false) extensions.push(drawSelection());
if (options.dropCursor !== false) extensions.push(dropCursor());
if (options.allowMultipleSelections !== false)
extensions.push(EditorState.allowMultipleSelections.of(true));
if (options.indentOnInput !== false) extensions.push(indentOnInput());
if (options.syntaxHighlighting !== false)
extensions.push(
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
);
if (options.bracketMatching !== false) extensions.push(bracketMatching());
if (options.closeBrackets !== false) extensions.push(closeBrackets());
if (options.autocompletion !== false) extensions.push(buildAutocomplete());
if (options.rectangularSelection !== false)
extensions.push(rectangularSelection());
if (initCrosshairCursor !== false) extensions.push(crosshairCursor());
if (options.highlightActiveLine !== false)
extensions.push(highlightActiveLine());
if (options.highlightSelectionMatches !== false)
extensions.push(highlightSelectionMatches());
if (options.tabSize && typeof options.tabSize === "number")
extensions.push(indentUnit.of(" ".repeat(options.tabSize)));
return extensions.concat([keymap.of(keymaps.flat())]).filter(Boolean);
};

View File

@ -1,9 +0,0 @@
ProseMirror-focused {
outline: none !important;
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1) !important;
}
.cm-editor.cm-focused {
outline: none !important;
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1) !important;
}

View File

@ -1,229 +0,0 @@
// @ts-ignore
import { createReactBlockSpec } from "@blocknote/react";
import "./index.css";
import CodeMirror from "@uiw/react-codemirror";
import { keymap } from "@codemirror/view";
import { langs } from "@uiw/codemirror-extensions-langs";
import { Play, Square } from "lucide-react";
import { useState } from "react";
import { extensions } from "./extensions";
import { platform } from "@tauri-apps/plugin-os";
import { invoke } from "@tauri-apps/api/core";
import Terminal from "./terminal.tsx";
import "@xterm/xterm/css/xterm.css";
import { AtuinState, useStore } from "@/state/store.ts";
interface RunBlockProps {
onChange: (val: string) => void;
onRun?: (pty: string) => void;
onStop?: (pty: string) => void;
id: string;
code: string;
type: string;
pty: string;
isEditable: boolean;
editor: any;
}
const findFirstParentOfType = (editor: any, id: string, type: string): any => {
// TODO: the types for blocknote aren't working. Now I'm doing this sort of shit,
// really need to fix that.
const document = editor.document;
var lastOfType = null;
// Iterate through ALL of the blocks.
for (let i = 0; i < document.length; i++) {
if (document[i].id == id) return lastOfType;
if (document[i].type == type) lastOfType = document[i];
}
return lastOfType;
};
const RunBlock = ({
onChange,
id,
code,
isEditable,
onRun,
onStop,
pty,
editor,
}: RunBlockProps) => {
const [value, setValue] = useState<String>(code);
const cleanupPtyTerm = useStore((store: AtuinState) => store.cleanupPtyTerm);
const terminals = useStore((store: AtuinState) => store.terminals);
const [currentRunbook, incRunbookPty, decRunbookPty] = useStore(
(store: AtuinState) => [
store.currentRunbook,
store.incRunbookPty,
store.decRunbookPty,
],
);
const isRunning = pty !== null && pty !== "";
const handleToggle = async (event: any | null) => {
if (event) event.stopPropagation();
// If there's no code, don't do anything
if (!value) return;
if (isRunning) {
await invoke("pty_kill", { pid: pty });
terminals[pty].terminal.dispose();
cleanupPtyTerm(pty);
if (onStop) onStop(pty);
if (currentRunbook) decRunbookPty(currentRunbook);
}
if (!isRunning) {
let cwd = findFirstParentOfType(editor, id, "directory");
if (cwd) {
cwd = cwd.props.path;
} else {
cwd = "~";
}
let pty = await invoke<string>("pty_open", { cwd });
if (onRun) onRun(pty);
if (currentRunbook) incRunbookPty(currentRunbook);
let isWindows = platform() == "windows";
let cmdEnd = isWindows ? "\r\n" : "\n";
let val = !value.endsWith("\n") ? value + cmdEnd : value;
await invoke("pty_write", { pid: pty, data: val });
}
};
const handleCmdEnter = () => {
handleToggle(null);
return true;
};
const customKeymap = keymap.of([
{
key: "Mod-Enter",
run: handleCmdEnter,
},
]);
return (
<div className="w-full !max-w-full !outline-none overflow-none">
<div className="flex flex-row items-start">
<div className="flex">
<button
onClick={handleToggle}
className={`flex items-center justify-center flex-shrink-0 w-8 h-8 mr-2 rounded border focus:outline-none focus:ring-2 transition-all duration-300 ease-in-out ${
isRunning
? "border-red-200 bg-red-50 text-red-600 hover:bg-red-100 hover:border-red-300 focus:ring-red-300"
: "border-green-200 bg-green-50 text-green-600 hover:bg-green-100 hover:border-green-300 focus:ring-green-300"
}`}
aria-label={isRunning ? "Stop code" : "Run code"}
>
<span
className={`inline-block transition-transform duration-300 ease-in-out ${isRunning ? "rotate-180" : ""}`}
>
{isRunning ? <Square size={16} /> : <Play size={16} />}
</span>
</button>
</div>
<div className="flex-1 min-w-0 w-40">
<CodeMirror
id={id}
placeholder={"Write your code here..."}
className="!pt-0 max-w-full border border-gray-300 rounded"
value={code}
editable={isEditable}
autoFocus
onChange={(val) => {
setValue(val);
onChange(val);
}}
extensions={[customKeymap, ...extensions(), langs.shell()]}
basicSetup={false}
/>
<div
className={`overflow-hidden transition-all duration-300 ease-in-out min-w-0 ${
isRunning ? "block" : "hidden"
}`}
>
{pty && <Terminal pty={pty} />}
</div>
</div>
</div>
</div>
);
};
export default createReactBlockSpec(
{
type: "run",
propSchema: {
type: {
default: "bash",
},
code: { default: "" },
pty: { default: "" },
global: { default: false },
},
content: "none",
},
{
// @ts-ignore
render: ({ block, editor, code, type }) => {
const onInputChange = (val: string) => {
editor.updateBlock(block, {
// @ts-ignore
props: { ...block.props, code: val },
});
};
const onRun = (pty: string) => {
editor.updateBlock(block, {
// @ts-ignore
props: { ...block.props, pty: pty },
});
};
const onStop = (_pty: string) => {
editor?.updateBlock(block, {
props: { ...block.props, pty: "" },
});
};
return (
<RunBlock
onChange={onInputChange}
id={block?.id}
code={block.props.code}
type={block.props.type}
pty={block.props.pty}
isEditable={editor.isEditable}
onRun={onRun}
onStop={onStop}
editor={editor}
/>
);
},
toExternalHTML: ({ block }) => {
return (
<pre lang="beep boop">
<code lang="bash">{block?.props?.code}</code>
</pre>
);
},
},
);

View File

@ -1,113 +0,0 @@
import { useState, useEffect, useRef } from "react";
import { listen } from "@tauri-apps/api/event";
import "@xterm/xterm/css/xterm.css";
import { useStore } from "@/state/store";
import { invoke } from "@tauri-apps/api/core";
import { IDisposable } from "@xterm/xterm";
const usePersistentTerminal = (pty: string) => {
const newPtyTerm = useStore((store) => store.newPtyTerm);
const terminals = useStore((store) => store.terminals);
const [isReady, setIsReady] = useState(false);
useEffect(() => {
if (!terminals.hasOwnProperty(pty)) {
// create a new terminal and store it in the store.
// this means we can resume the same instance even across mount/dismount
newPtyTerm(pty);
}
setIsReady(true);
return () => {
// We don't dispose of the terminal when the component unmounts
};
}, [pty, terminals, newPtyTerm]);
return { terminalData: terminals[pty], isReady };
};
const TerminalComponent = ({ pty }: any) => {
const terminalRef = useRef(null);
const { terminalData, isReady } = usePersistentTerminal(pty);
const [isAttached, setIsAttached] = useState(false);
const cleanupListenerRef = useRef<(() => void) | null>(null);
const keyDispose = useRef<IDisposable | null>(null);
useEffect(() => {
// no pty? no terminal
if (pty == null) return;
// the terminal may still be being created so hold off
if (!isReady) return;
const windowResize = () => {
if (!terminalData || !terminalData.fitAddon) return;
terminalData.fitAddon.fit();
};
// terminal object needs attaching to a ref to a div
if (!isAttached && terminalData && terminalData.terminal) {
// If it's never been attached, attach it
if (!terminalData.terminal.element && terminalRef.current) {
terminalData.terminal.open(terminalRef.current);
// it might have been previously attached, but need moving elsewhere
} else if (terminalData && terminalRef.current) {
// @ts-ignore
terminalRef.current.appendChild(terminalData.terminal.element);
}
terminalData.fitAddon.fit();
setIsAttached(true);
window.addEventListener("resize", windowResize);
const disposeOnKey = terminalData.terminal.onKey(async (event) => {
await invoke("pty_write", { pid: pty, data: event.key });
});
keyDispose.current = disposeOnKey;
}
listen(`pty-${pty}`, (event: any) => {
terminalData.terminal.write(event.payload);
}).then((ul) => {
cleanupListenerRef.current = ul;
});
// Customize further as needed
return () => {
if (
terminalData &&
terminalData.terminal &&
terminalData.terminal.element
) {
// Instead of removing, we just detach
if (terminalData.terminal.element.parentElement) {
terminalData.terminal.element.parentElement.removeChild(
terminalData.terminal.element,
);
}
setIsAttached(false);
}
if (cleanupListenerRef.current) {
cleanupListenerRef.current();
}
if (keyDispose.current) keyDispose.current.dispose();
window.removeEventListener("resize", windowResize);
};
}, [terminalData, isReady]);
if (!isReady) return null;
return (
<div className="!max-w-full min-w-0 overflow-hidden" ref={terminalRef} />
);
};
export default TerminalComponent;

View File

@ -1,7 +0,0 @@
.editor a {
color: #0000ee;
}
.editor a:hover {
cursor: pointer;
}

View File

@ -1,28 +0,0 @@
import {
SideMenuProps,
useBlockNoteEditor,
useComponentsContext,
} from "@blocknote/react";
import { TrashIcon } from "lucide-react";
// Custom Side Menu button to remove the hovered block.
export function DeleteBlock(props: SideMenuProps) {
const editor = useBlockNoteEditor();
const Components = useComponentsContext()!;
return (
<Components.SideMenu.Button
label="Remove block"
className="mx-1"
icon={
<TrashIcon
size={24}
onClick={() => {
editor.removeBlocks([props.block]);
}}
/>
}
/>
);
}

View File

@ -1,59 +0,0 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@ -1,56 +0,0 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@ -1,79 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@ -1,363 +0,0 @@
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
})
ChartContainer.displayName = "Chart"
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([_, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref
) => {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item.dataKey || item.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
ref={ref}
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
)
ChartTooltipContent.displayName = "ChartTooltip"
const ChartLegend = RechartsPrimitive.Legend
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}
>(
(
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
ref
) => {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
ref={ref}
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
)
ChartLegendContent.displayName = "ChartLegend"
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}

View File

@ -1,80 +0,0 @@
"use client";
import {
ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
}
export default function DataTable<TData, TValue>({
columns,
data,
}: DataTableProps<TData, TValue>) {
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
return (
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
);
}

View File

@ -1,120 +0,0 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@ -1,198 +0,0 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@ -1,117 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@ -1,127 +0,0 @@
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}

View File

@ -1,33 +0,0 @@
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast"
import { useToast } from "@/components/ui/use-toast"
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}

View File

@ -1,192 +0,0 @@
// Inspired by react-hot-toast library
import * as React from "react"
import type {
ToastActionElement,
ToastProps,
} from "@/components/ui/toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { useToast, toast }

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

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

View File

@ -1,48 +0,0 @@
import type { ClassValue } from "clsx";
import clsx from "clsx";
import { extendTailwindMerge } from "tailwind-merge";
const COMMON_UNITS = ["small", "medium", "large"];
/**
* We need to extend the tailwind merge to include NextUI's custom classes.
*
* So we can use classes like `text-small` or `text-default-500` and override them.
*/
const twMerge = extendTailwindMerge({
extend: {
theme: {
opacity: ["disabled"],
spacing: ["divider"],
borderWidth: COMMON_UNITS,
borderRadius: COMMON_UNITS,
},
classGroups: {
shadow: [{ shadow: COMMON_UNITS }],
"font-size": [{ text: ["tiny", ...COMMON_UNITS] }],
"bg-image": ["bg-stripe-gradient"],
},
},
});
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// edge still uses the old one
export function getWeekInfo() {
let locale = new Intl.Locale(navigator.language);
// @ts-ignore
if (locale.getWeekInfo) {
// @ts-ignore
return locale.getWeekInfo();
// @ts-ignore
} else if (locale.weekInfo) {
// @ts-ignore
return locale.weekInfo;
}
throw new Error("Could not fetch week info via new or old api");
}

View File

@ -1,22 +0,0 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { NextUIProvider } from "@nextui-org/react";
import App from "./App";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<NextUIProvider>
<main className="text-foreground bg-background">
<div
data-tauri-drag-region
className="w-full min-h-8 z-10 border-b-1"
/>
<div className="z-20 ">
<App />
</div>
</main>
</NextUIProvider>
</React.StrictMode>,
);

View File

@ -1,109 +0,0 @@
import { useState } from "react";
import Aliases from "@/components/dotfiles/Aliases";
import Vars from "@/components/dotfiles/Vars";
enum Section {
Aliases,
Vars,
Snippets,
}
function renderDotfiles(current: Section) {
switch (current) {
case Section.Aliases:
return <Aliases />;
case Section.Vars:
return <Vars />;
case Section.Snippets:
return <div />;
}
}
interface HeaderProps {
current: Section;
setCurrent: (section: Section) => void;
}
interface TabsProps {
current: Section;
setCurrent: (section: Section) => void;
}
function Header({ current, setCurrent }: HeaderProps) {
return (
<div className="md:flex md:items-center md:justify-between">
<div className="min-w-0 flex-1">
<h2 className="text-2xl font-bold leading-7 text-gray-900 sm:truncate sm:text-3xl sm:tracking-tight">
Dotfiles
</h2>
</div>
<Tabs current={current} setCurrent={setCurrent} />
</div>
);
}
function classNames(...classes: any[]) {
return classes.filter(Boolean).join(" ");
}
function Tabs({ current, setCurrent }: TabsProps) {
let tabs = [
{
name: "Aliases",
isCurrent: () => current === Section.Aliases,
section: Section.Aliases,
},
{
name: "Vars",
isCurrent: () => current === Section.Vars,
section: Section.Vars,
},
{
name: "Snippets",
isCurrent: () => current === Section.Snippets,
section: Section.Snippets,
},
];
return (
<div>
<div>
<nav className="flex space-x-4" aria-label="Tabs">
{tabs.map((tab) => (
<button
onClick={() => {
setCurrent(tab.section);
}}
key={tab.name}
className={classNames(
tab.isCurrent()
? "bg-gray-100 text-gray-700"
: "text-gray-500 hover:text-gray-700",
"rounded-md px-3 py-2 text-sm font-medium",
)}
aria-current={tab.isCurrent() ? "page" : undefined}
>
{tab.name}
</button>
))}
</nav>
</div>
</div>
);
}
export default function Dotfiles() {
let [current, setCurrent] = useState(Section.Aliases);
console.log(current);
return (
<div className="w-full flex-1 flex-col p-4 overflow-y-auto">
<div className="p-10">
<Header current={current} setCurrent={setCurrent} />
Manage your shell aliases, variables and paths
{renderDotfiles(current)}
</div>
</div>
);
}

View File

@ -1,73 +0,0 @@
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 { AtuinState, useStore } from "@/state/store";
export default function Search() {
const history = useStore((state: AtuinState) => state.shellHistory);
const refreshHistory = useStore(
(state: AtuinState) => state.refreshShellHistory,
);
const historyNextPage = useStore(
(state: AtuinState) => state.historyNextPage,
);
let [query, setQuery] = useState("");
useEffect(() => {
(async () => {
// nothing rn
})();
refreshHistory();
}, []);
const parentRef = useRef<HTMLElement | null>(null);
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="w-full flex-1 flex-col">
<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
query={query}
setQuery={(q) => {
setQuery(q);
refreshHistory(q);
}}
refresh={() => {
refreshHistory(query);
}}
/>
</div>
<main className="overflow-y-scroll history-list" ref={parentRef}>
<HistoryList
history={history}
items={rowVirtualizer.getVirtualItems()}
height={rowVirtualizer.getTotalSize()}
/>
</main>
</div>
</>
);
}

View File

@ -1,295 +0,0 @@
import React, { useEffect } from "react";
import { formatRelative } from "date-fns";
import { Tooltip as ReactTooltip } from "react-tooltip";
import { AtuinState, useStore } from "@/state/store";
import { useToast } from "@/components/ui/use-toast";
import { ToastAction } from "@/components/ui/toast";
import { invoke } from "@tauri-apps/api/core";
import {
Card,
CardHeader,
CardBody,
Listbox,
ListboxItem,
} from "@nextui-org/react";
import {
Bar,
BarChart,
CartesianGrid,
LabelList,
XAxis,
YAxis,
} from "recharts";
import { ChartConfig, ChartContainer } from "@/components/ui/chart";
import { Clock, Terminal } from "lucide-react";
import ActivityCalendar from "react-activity-calendar";
import HistoryRow from "@/components/history/HistoryRow";
import { ShellHistory } from "@/state/models";
function StatCard({ name, stat }: any) {
return (
<Card shadow="sm">
<CardHeader>
<h3 className="uppercase text-gray-500">{name}</h3>
</CardHeader>
<CardBody>
<h2 className="font-bold text-xl">{stat}</h2>
</CardBody>
</Card>
);
}
function TopChart({ chartData }: any) {
const chartConfig = {
command: {
label: "Command",
color: "#c4edde",
},
} satisfies ChartConfig;
return (
<ChartContainer config={chartConfig} className="max-h-72">
<BarChart
accessibilityLayer
data={chartData}
layout="vertical"
margin={{
right: 16,
}}
>
<CartesianGrid horizontal={false} />
<YAxis
dataKey="command"
type="category"
tickLine={false}
tickMargin={10}
axisLine={false}
tickFormatter={(value) => value.slice(0, 3)}
hide
/>
<XAxis dataKey="count" type="number" hide />
<Bar dataKey="count" layout="vertical" fill="#c4edde" radius={4}>
<LabelList
dataKey="command"
position="insideLeft"
offset={8}
className="fill-[--color-label]"
fontSize={12}
/>
<LabelList
dataKey="count"
position="right"
offset={8}
className="fill-foreground"
fontSize={12}
/>
</Bar>
</BarChart>
</ChartContainer>
);
}
function Header({ name }: any) {
let greeting = name && name.length > 0 ? "Hey, " + name + "!" : "Hey!";
return (
<div className="md:flex md:items-center md:justify-between">
<div className="flex-1">
<h2 className="text-2xl font-bold leading-7 text-gray-900 sm:truncate sm:text-3xl sm:tracking-tight">
{greeting}
</h2>
<h3 className="text-xl leading-7 text-gray-900 pt-4">
Welcome to{" "}
<a
href="https://atuin.sh"
target="_blank"
rel="noopener noreferrer nofollow"
>
Atuin
</a>
.
</h3>
</div>
</div>
);
}
const explicitTheme = {
light: ["#f0f0f0", "#c4edde", "#7ac7c4", "#f73859", "#384259"],
dark: ["#f0f0f0", "#c4edde", "#7ac7c4", "#f73859", "#384259"],
};
export default function Home() {
const homeInfo = useStore((state: AtuinState) => state.homeInfo);
const user = useStore((state: AtuinState) => state.user);
const calendar = useStore((state: AtuinState) => state.calendar);
const runbooks = useStore((state: AtuinState) => state.runbooks);
const weekStart = useStore((state: AtuinState) => state.weekStart);
const refreshHomeInfo = useStore(
(state: AtuinState) => state.refreshHomeInfo,
);
const refreshUser = useStore((state: AtuinState) => state.refreshUser);
const refreshCalendar = useStore(
(state: AtuinState) => state.refreshCalendar,
);
const refreshRunbooks = useStore(
(state: AtuinState) => state.refreshRunbooks,
);
const { toast } = useToast();
useEffect(() => {
refreshHomeInfo();
refreshUser();
refreshCalendar();
refreshRunbooks();
console.log(homeInfo);
let setup = async () => {
let installed = await invoke("is_cli_installed");
console.log("CLI installation status:", installed);
if (!installed) {
toast({
title: "Atuin CLI",
description: "CLI not detected - install?",
action: (
<ToastAction
altText="Install"
onClick={() => {
let install = async () => {
toast({
title: "Atuin CLI",
description: "Install in progress...",
});
console.log("Installing CLI...");
await invoke("install_cli");
console.log("Setting up plugin...");
await invoke("setup_cli");
toast({
title: "Atuin CLI",
description: "Installation complete",
});
};
install();
}}
>
Install
</ToastAction>
),
});
}
};
setup();
}, []);
if (!homeInfo) {
return <div>Loading...</div>;
}
return (
<div className="w-full flex-1 flex-col p-4 overflow-y-auto">
<div className="pl-10">
<Header name={user.username} />
</div>
<div className="p-10 grid grid-cols-4 gap-4">
<StatCard
name="Last Sync"
stat={
(homeInfo.lastSyncTime &&
formatRelative(homeInfo.lastSyncTime, new Date())) ||
"Never"
}
/>
<StatCard
name="Total Commands"
stat={homeInfo.historyCount.toLocaleString()}
/>
<StatCard
name="Total Runbooks"
stat={runbooks.length.toLocaleString()}
/>
<StatCard
name="Other Records"
stat={homeInfo.recordCount - homeInfo.historyCount}
/>
<Card shadow="sm" className="col-span-3">
<CardHeader>
<h2 className="uppercase text-gray-500">Activity graph</h2>
</CardHeader>
<CardBody>
<ActivityCalendar
hideTotalCount
theme={explicitTheme}
data={calendar}
weekStart={weekStart as any}
renderBlock={(block, activity) =>
React.cloneElement(block, {
"data-tooltip-id": "react-tooltip",
"data-tooltip-html": `${activity.count} commands on ${activity.date}`,
})
}
/>
<ReactTooltip id="react-tooltip" />
</CardBody>
</Card>
<Card shadow="sm">
<CardHeader>
<h2 className="uppercase text-gray-500">Quick actions </h2>
</CardHeader>
<CardBody>
<Listbox variant="flat" aria-label="Quick actions">
<ListboxItem
key="new-runbook"
description="Create an executable runbook"
startContent={<Terminal />}
>
New runbook
</ListboxItem>
<ListboxItem
key="shell-history"
description="Search and explore shell history"
startContent={<Clock />}
>
Shell History
</ListboxItem>
</Listbox>
</CardBody>
</Card>
<Card shadow="sm" className="col-span-2">
<CardHeader>
<h2 className="uppercase text-gray-500">Recent commands</h2>
</CardHeader>
<CardBody>
{homeInfo.recentCommands?.map((i: ShellHistory) => {
return <HistoryRow compact h={i} />;
})}
</CardBody>
</Card>
<Card shadow="sm" className="col-span-2">
<CardHeader>
<h2 className="uppercase text-gray-500">Top commands</h2>
</CardHeader>
<CardBody>
<TopChart chartData={homeInfo.topCommands} />
</CardBody>
</Card>
</div>
</div>
);
}

View File

@ -1,25 +0,0 @@
import Editor from "@/components/runbooks/editor/Editor";
import List from "@/components/runbooks/List";
import { useStore } from "@/state/store";
export default function Runbooks() {
const currentRunbook = useStore((store) => store.currentRunbook);
return (
<div className="flex w-full !max-w-full flex-row ">
<List />
{currentRunbook && (
<div className="flex w-full">
<Editor />
</div>
)}
{!currentRunbook && (
<div className="flex align-middle justify-center flex-col h-screen w-full">
<h1 className="text-center">Select or create a runbook</h1>
</div>
)}
</div>
);
}

View File

@ -1,33 +0,0 @@
// At some point, I'd like to replace some of the Atuin calls
// with separate state handling here
import { invoke } from "@tauri-apps/api/core";
import { Settings } from "@/state/models";
export async function sessionToken(): Promise<String> {
return await invoke("session");
}
export async function settings(): Promise<Settings> {
return await invoke("config");
}
export async function login(
username: string,
password: string,
key: string,
): Promise<string> {
return await invoke("login", { username, password, key });
}
export async function logout(): Promise<string> {
return await invoke("logout");
}
export async function register(
username: string,
email: string,
password: string,
): Promise<string> {
return await invoke("register", { username, email, password });
}

View File

@ -1,177 +0,0 @@
import { invoke } from "@tauri-apps/api/core";
import Database from "@tauri-apps/plugin-sql";
export class User {
username: string | null;
constructor(username: string) {
this.username = username;
}
isLoggedIn(): boolean {
return this.username !== "" && this.username !== null;
}
}
export const DefaultUser: User = new User("");
export interface HomeInfo {
historyCount: number;
recordCount: number;
lastSyncTime: Date | null;
recentCommands: ShellHistory[];
topCommands: ShellHistory[];
}
export const DefaultHomeInfo: HomeInfo = {
historyCount: 0,
recordCount: 0,
lastSyncTime: new Date(),
recentCommands: [],
topCommands: [],
};
export class ShellHistory {
id: string;
timestamp: number;
command: string;
user: string;
host: string;
cwd: string;
duration: number;
exit: number;
/// Pass a row straight from the database to this
constructor(
id: string,
timestamp: number,
command: string,
hostuser: string,
cwd: string,
duration: number,
exit: number,
) {
this.id = id;
this.timestamp = timestamp;
this.command = command;
let [host, user] = hostuser.split(":");
this.user = user;
this.host = host;
this.cwd = cwd;
this.duration = duration;
this.exit = exit;
}
}
export interface Alias {
name: string;
value: string;
}
export interface Var {
name: string;
value: string;
export: boolean;
}
export interface InspectHistory {
other: ShellHistory[];
}
// Not yet complete. Not all types are defined here.
// Gonna hold off until the settings refactoring.
export interface Settings {
auto_sync: boolean;
update_check: boolean;
sync_address: string;
sync_frequency: string;
db_path: string;
record_store_path: string;
key_path: string;
session_path: string;
shell_up_key_binding: boolean;
inline_height: number;
invert: boolean;
show_preview: boolean;
max_preview_height: number;
show_help: boolean;
show_tabs: boolean;
word_chars: string;
scroll_context_lines: number;
history_format: string;
prefers_reduced_motion: boolean;
store_failed: boolean;
secrets_filter: boolean;
workspaces: boolean;
ctrl_n_shortcuts: boolean;
network_connect_timeout: number;
network_timeout: number;
local_timeout: number;
enter_accept: boolean;
smart_sort: boolean;
sync: Sync;
}
interface Sync {
records: boolean;
}
// Define other interfaces (Dialect, Timezone, Style, SearchMode, FilterMode, ExitMode, KeymapMode, CursorStyle, WordJumpMode, RegexSet, Stats) accordingly.
export async function inspectCommandHistory(
h: ShellHistory,
): Promise<InspectHistory> {
const settings: Settings = await invoke("cli_settings");
const db = await Database.load("sqlite:" + settings.db_path);
let other: any[] = await db.select(
"select * from history where command=?1 order by timestamp desc",
[h.command],
);
console.log(other);
return {
other: other.map(
(i) =>
new ShellHistory(
i.id,
i.timestamp,
i.command,
i.hostname,
i.cwd,
i.duration,
i.exit,
),
),
};
}
export async function inspectDirectoryHistory(
h: ShellHistory,
): Promise<InspectHistory> {
const settings: Settings = await invoke("cli_settings");
const db = await Database.load("sqlite:" + settings.db_path);
let other: any[] = await db.select(
"select * from history where cwd=?1 order by timestamp desc",
[h.cwd],
);
console.log(other);
return {
other: other.map(
(i) =>
new ShellHistory(
i.id,
i.timestamp,
i.command,
i.hostname,
i.cwd,
i.duration,
i.exit,
),
),
};
}

View File

@ -1,124 +0,0 @@
import Database from "@tauri-apps/plugin-sql";
import { uuidv7 } from "uuidv7";
export default class Runbook {
id: string;
created: Date;
updated: Date;
private _name: string;
private _content: string;
set name(value: string) {
this.updated = new Date();
this._name = value;
}
set content(value: string) {
this.updated = new Date();
this._content = value;
}
get content() {
return this._content;
}
get name() {
return this._name;
}
constructor(
id: string,
name: string,
content: string,
created: Date,
updated: Date,
) {
this.id = id;
this._name = name;
this._content = content;
this.created = created;
this.updated = updated;
}
/// Create a new Runbook, and automatically generate an ID.
public static async create(): Promise<Runbook> {
let now = new Date();
// Initialize with the same value for created/updated, to avoid needing null.
let runbook = new Runbook(uuidv7(), "", "", now, now);
await runbook.save();
return runbook;
}
public static async load(id: String): Promise<Runbook | null> {
const db = await Database.load("sqlite:runbooks.db");
let res = await db.select<any[]>("select * from runbooks where id = $1", [
id,
]);
if (res.length == 0) return null;
let rb = res[0];
return new Runbook(
rb.id,
rb.name,
rb.content,
new Date(rb.created / 1000000),
new Date(rb.updated / 1000000),
);
}
static async all(): Promise<Runbook[]> {
const db = await Database.load("sqlite:runbooks.db");
let res = await db.select<any[]>(
"select * from runbooks order by updated desc",
);
return res.map((i) => {
return new Runbook(
i.id,
i.name,
i.content,
new Date(i.created / 1000000),
new Date(i.updated / 1000000),
);
});
}
public async save() {
const db = await Database.load("sqlite:runbooks.db");
await db.execute(
`insert into runbooks(id, name, content, created, updated)
values ($1, $2, $3, $4, $5)
on conflict(id) do update
set
name=$2,
content=$3,
updated=$5`,
// getTime returns a timestamp as unix milliseconds
// we won't need or use the resolution here, but elsewhere Atuin stores timestamps in sqlite as nanoseconds since epoch
// let's do that across the board to avoid mistakes
[
this.id,
this._name,
this._content,
this.created.getTime() * 1000000,
this.updated.getTime() * 1000000,
],
);
}
public static async delete(id: string) {
const db = await Database.load("sqlite:runbooks.db");
await db.execute("delete from runbooks where id=$1", [id]);
}
}

View File

@ -1,289 +0,0 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { parseISO } from "date-fns";
import { fetch } from "@tauri-apps/plugin-http";
import {
User,
DefaultUser,
HomeInfo,
DefaultHomeInfo,
Alias,
ShellHistory,
Var,
} from "./models";
import { invoke } from "@tauri-apps/api/core";
import { sessionToken, settings } from "./client";
import { getWeekInfo } from "@/lib/utils";
import Runbook from "./runbooks/runbook";
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { WebglAddon } from "@xterm/addon-webgl";
export class TerminalData {
terminal: Terminal;
fitAddon: FitAddon;
constructor(terminal: Terminal, fit: FitAddon) {
this.terminal = terminal;
this.fitAddon = fit;
}
}
// I'll probs want to slice this up at some point, but for now a
// big blobby lump of state is fine.
// Totally just hoping that structure will be emergent in the future.
export interface AtuinState {
user: User;
homeInfo: HomeInfo;
aliases: Alias[];
vars: Var[];
shellHistory: ShellHistory[];
calendar: any[];
weekStart: number;
runbooks: Runbook[];
currentRunbook: string | null;
refreshHomeInfo: () => void;
refreshCalendar: () => void;
refreshAliases: () => void;
refreshVars: () => void;
refreshUser: () => void;
refreshRunbooks: () => void;
refreshShellHistory: (query?: string) => void;
historyNextPage: (query?: string) => void;
setCurrentRunbook: (id: String) => void;
setPtyTerm: (pty: string, terminal: any) => void;
newPtyTerm: (pty: string) => TerminalData;
cleanupPtyTerm: (pty: string) => void;
terminals: { [pty: string]: TerminalData };
// Store ephemeral state for runbooks, that is not persisted to the database
runbookInfo: { [runbook: string]: { ptys: number } };
incRunbookPty: (runbook: string) => void;
decRunbookPty: (runbook: string) => void;
}
let state = (set: any, get: any): AtuinState => ({
user: DefaultUser,
homeInfo: DefaultHomeInfo,
aliases: [],
vars: [],
shellHistory: [],
calendar: [],
runbooks: [],
currentRunbook: "",
terminals: {},
runbookInfo: {},
weekStart: getWeekInfo().firstDay,
refreshAliases: () => {
invoke("aliases").then((aliases: any) => {
set({ aliases: aliases });
});
},
refreshCalendar: () => {
invoke("history_calendar").then((calendar: any) => {
set({ calendar: calendar });
});
},
refreshVars: () => {
invoke("vars").then((vars: any) => {
set({ vars: vars });
});
},
refreshRunbooks: async () => {
let runbooks = await Runbook.all();
set({ runbooks });
},
refreshShellHistory: (query?: string) => {
if (query) {
invoke("search", { query: query })
.then((res: any) => {
set({ shellHistory: res });
})
.catch((e) => {
console.log(e);
});
} else {
invoke("list").then((res: any) => {
set({ shellHistory: res });
});
}
},
refreshHomeInfo: () => {
invoke("home_info")
.then((res: any) => {
console.log(res);
set({
homeInfo: {
historyCount: res.history_count,
recordCount: res.record_count,
lastSyncTime: (res.last_sync && parseISO(res.last_sync)) || null,
recentCommands: res.recent_commands,
topCommands: res.top_commands.map((top: any) => ({
command: top[0],
count: top[1],
})),
},
});
})
.catch((e) => {
console.log(e);
});
},
refreshUser: async () => {
let config = await settings();
let session;
try {
session = await sessionToken();
} catch (e) {
console.log("Not logged in, so not refreshing user");
set({ user: DefaultUser });
return;
}
let url = config.sync_address + "/api/v0/me";
let res = await fetch(url, {
headers: {
Authorization: `Token ${session}`,
},
});
let me = await res.json();
set({ user: new User(me.username) });
},
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] });
});
}
},
setCurrentRunbook: (id: String) => {
set({ currentRunbook: id });
},
setPtyTerm: (pty: string, terminal: TerminalData) => {
set({
terminals: { ...get().terminals, [pty]: terminal },
});
},
cleanupPtyTerm: (pty: string) => {
set((state: AtuinState) => {
const terminals = Object.keys(state.terminals).reduce(
(terms: { [pty: string]: TerminalData }, key) => {
if (key !== pty) {
terms[key] = state.terminals[key];
}
return terms;
},
{},
);
return { terminals };
});
},
newPtyTerm: (pty: string) => {
let terminal = new Terminal();
// TODO: fallback to canvas, also some sort of setting to allow disabling webgl usage
// probs fine for now though, it's widely supported. maybe issues on linux.
terminal.loadAddon(new WebglAddon());
let fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
const onResize = (size: { cols: number; rows: number }) => {
invoke("pty_resize", {
pid: pty,
cols: size.cols,
rows: size.rows,
});
};
terminal.onResize(onResize);
let td = new TerminalData(terminal, fitAddon);
set({
terminals: { ...get().terminals, [pty]: td },
});
return td;
},
incRunbookPty: (runbook: string) => {
set((state: AtuinState) => {
let oldVal = state.runbookInfo[runbook] || { ptys: 0 };
let newVal = { ptys: oldVal.ptys + 1 };
console.log(newVal);
return {
runbookInfo: {
...state.runbookInfo,
[runbook]: newVal,
},
};
});
},
decRunbookPty: (runbook: string) => {
set((state: AtuinState) => {
let newVal = state.runbookInfo[runbook];
if (!newVal) {
return;
}
newVal.ptys--;
return {
runbookInfo: {
...state.runbookInfo,
[runbook]: newVal,
},
};
});
},
});
export const useStore = create<AtuinState>()(
persist(state, {
name: "atuin-storage",
// don't serialize the terminals map
// it won't work as JSON. too cyclical
partialize: (state) =>
Object.fromEntries(
Object.entries(state).filter(([key]) => !["terminals"].includes(key)),
),
}),
);

View File

@ -1,76 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--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%;
--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%;
--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%;
--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%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--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%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--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%;
--accent: 217.2 32.6% 17.5%;
--accent-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%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@ -1 +0,0 @@
/// <reference types="vite/client" />

View File

@ -1,80 +0,0 @@
const { nextui } = require("@nextui-org/react");
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: "class",
content: [
"./pages/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
"./app/**/*.{ts,tsx}",
"./src/**/*.{ts,tsx}",
"./node_modules/@nextui-org/theme/dist/**/*.{js,ts,jsx,tsx}",
],
prefix: "",
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [require("tailwindcss-animate"), nextui()],
};

Some files were not shown because too many files have changed in this diff Show More