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.
8
.github/dependabot.yml
vendored
@ -9,14 +9,6 @@ updates:
|
||||
directory: "/" # Location of package manifests
|
||||
schedule:
|
||||
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
|
||||
directory: "/" # Location of package manifests
|
||||
schedule:
|
||||
|
28
ui/.gitignore
vendored
@ -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
|
@ -1,3 +0,0 @@
|
||||
# Atuin Desktop
|
||||
|
||||
Currently WIP and not yet ready for use
|
4
ui/backend/.gitignore
vendored
@ -1,4 +0,0 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
7643
ui/backend/Cargo.lock
generated
@ -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"
|
@ -1,3 +0,0 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
@ -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"
|
||||
]
|
||||
}
|
Before Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 34 KiB |
Before Width: | Height: | Size: 2.4 KiB |
Before Width: | Height: | Size: 12 KiB |
Before Width: | Height: | Size: 17 KiB |
Before Width: | Height: | Size: 18 KiB |
Before Width: | Height: | Size: 38 KiB |
Before Width: | Height: | Size: 2.2 KiB |
Before Width: | Height: | Size: 42 KiB |
Before Width: | Height: | Size: 3.8 KiB |
Before Width: | Height: | Size: 7.2 KiB |
Before Width: | Height: | Size: 9.7 KiB |
Before Width: | Height: | Size: 4.5 KiB |
Before Width: | Height: | Size: 50 KiB |
Before Width: | Height: | Size: 73 KiB |
@ -1,2 +0,0 @@
|
||||
[toolchain]
|
||||
channel = "1.79"
|
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
@ -1,2 +0,0 @@
|
||||
pub mod aliases;
|
||||
pub mod vars;
|
@ -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(())
|
||||
}
|
@ -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(())
|
||||
}
|
@ -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");
|
||||
}
|
@ -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(())
|
||||
}
|
||||
}
|
@ -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,
|
||||
}
|
||||
]
|
||||
}
|
@ -1,2 +0,0 @@
|
||||
pub mod migrations;
|
||||
pub mod pty;
|
@ -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(())
|
||||
}
|
@ -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>>,
|
||||
}
|
@ -1 +0,0 @@
|
||||
|
@ -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"
|
||||
}
|
@ -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"
|
||||
}
|
||||
}
|
BIN
ui/icon.png
Before Width: | Height: | Size: 131 KiB |
@ -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>
|
@ -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"
|
||||
}
|
||||
}
|
8525
ui/pnpm-lock.yaml
@ -1,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
@ -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 |
@ -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 |
@ -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;
|
||||
}
|
237
ui/src/App.tsx
@ -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;
|
Before Width: | Height: | Size: 12 KiB |
Before Width: | Height: | Size: 14 KiB |
@ -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 |
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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)} />;
|
||||
}
|
@ -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;
|
@ -1,4 +0,0 @@
|
||||
import Sidebar, { SidebarItem } from "./Sidebar";
|
||||
|
||||
export type { SidebarItem };
|
||||
export default Sidebar;
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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> on </span>
|
||||
|
||||
<span className="relative truncate ">{h.host}</span>
|
||||
|
||||
<span> in </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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -1 +0,0 @@
|
||||
export default function QuickActions() {}
|
@ -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;
|
@ -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>
|
||||
);
|
||||
}
|
@ -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} />
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
@ -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);
|
||||
};
|
@ -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;
|
||||
}
|
@ -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>
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
@ -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;
|
@ -1,7 +0,0 @@
|
||||
.editor a {
|
||||
color: #0000ee;
|
||||
}
|
||||
|
||||
.editor a:hover {
|
||||
cursor: pointer;
|
||||
}
|
@ -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]);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
@ -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 }
|
@ -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 }
|
@ -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 }
|
@ -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,
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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,
|
||||
}
|
@ -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,
|
||||
}
|
@ -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,
|
||||
}
|
@ -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,
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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
@ -1 +0,0 @@
|
||||
type Option<T> = T | null;
|
@ -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");
|
||||
}
|
@ -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>,
|
||||
);
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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 });
|
||||
}
|
@ -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,
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
@ -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]);
|
||||
}
|
||||
}
|
@ -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)),
|
||||
),
|
||||
}),
|
||||
);
|
@ -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;
|
||||
}
|
||||
}
|
1
ui/src/vite-env.d.ts
vendored
@ -1 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
@ -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()],
|
||||
};
|