summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorEllie Huxtable <ellie@elliehuxtable.com>2024-06-17 15:36:38 +0100
committerGitHub <noreply@github.com>2024-06-17 15:36:38 +0100
commit88633b8994437180afdd66068cc2c8f02aea1db1 (patch)
tree6df7ead44d8cb3c219dd43d0ee86256f4a6025ef
parent39b424f3b1c28cdf50bfd75c344fc53b99460e27 (diff)
feat(gui): automatically install and setup the cli/shell (#2139)
* feat(gui): automatically install and setup the cli/shell * add shell config and toasts
-rw-r--r--Cargo.lock1
-rw-r--r--crates/atuin-common/Cargo.toml1
-rw-r--r--crates/atuin-common/src/shell.rs19
-rw-r--r--ui/backend/Cargo.lock2
-rw-r--r--ui/backend/Cargo.toml3
-rw-r--r--ui/backend/capabilities/migrated.json2
-rw-r--r--ui/backend/src/install.rs67
-rw-r--r--ui/backend/src/main.rs4
-rw-r--r--ui/backend/src/update.rs51
-rw-r--r--ui/package.json1
-rw-r--r--ui/pnpm-lock.yaml56
-rw-r--r--ui/src/App.tsx2
-rw-r--r--ui/src/components/ui/alert.tsx59
-rw-r--r--ui/src/components/ui/toast.tsx127
-rw-r--r--ui/src/components/ui/toaster.tsx33
-rw-r--r--ui/src/components/ui/use-toast.ts192
-rw-r--r--ui/src/pages/Home.tsx28
17 files changed, 595 insertions, 53 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 6df7135e1..01ac5eb78 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -300,6 +300,7 @@ dependencies = [
name = "atuin-common"
version = "18.3.0"
dependencies = [
+ "directories",
"eyre",
"lazy_static",
"pretty_assertions",
diff --git a/crates/atuin-common/Cargo.toml b/crates/atuin-common/Cargo.toml
index 85e41ef6c..5fdcbfa75 100644
--- a/crates/atuin-common/Cargo.toml
+++ b/crates/atuin-common/Cargo.toml
@@ -22,6 +22,7 @@ eyre = { workspace = true }
sqlx = { workspace = true }
semver = { workspace = true }
thiserror = { workspace = true }
+directories = { workspace = true }
sysinfo = "0.30.7"
lazy_static = "1.4.0"
diff --git a/crates/atuin-common/src/shell.rs b/crates/atuin-common/src/shell.rs
index afdccea75..80cdc7423 100644
--- a/crates/atuin-common/src/shell.rs
+++ b/crates/atuin-common/src/shell.rs
@@ -59,6 +59,25 @@ impl Shell {
Shell::from_string(shell.to_string())
}
+ pub fn config_file(&self) -> Option<std::path::PathBuf> {
+ let mut path = if let Some(base) = directories::BaseDirs::new() {
+ base.home_dir().to_owned()
+ } else {
+ return None;
+ };
+
+ // TODO: handle all shells
+ match self {
+ Shell::Bash => path.push(".bashrc"),
+ Shell::Zsh => path.push(".zshrc"),
+ Shell::Fish => path.push(".config/fish/config.fish"),
+
+ _ => return None,
+ };
+
+ Some(path)
+ }
+
/// Best-effort attempt to determine the default shell
/// This implementation will be different across different platforms
/// Caller should ensure to handle Shell::Unknown correctly
diff --git a/ui/backend/Cargo.lock b/ui/backend/Cargo.lock
index e2f454b2f..90b5c8c4f 100644
--- a/ui/backend/Cargo.lock
+++ b/ui/backend/Cargo.lock
@@ -392,6 +392,7 @@ dependencies = [
name = "atuin-common"
version = "18.3.0"
dependencies = [
+ "directories",
"eyre",
"lazy_static",
"rand 0.8.5",
@@ -6122,6 +6123,7 @@ dependencies = [
"tauri-plugin-single-instance",
"tauri-plugin-sql",
"time",
+ "tokio",
"uuid",
]
diff --git a/ui/backend/Cargo.toml b/ui/backend/Cargo.toml
index 5892ed840..ed13c2f22 100644
--- a/ui/backend/Cargo.toml
+++ b/ui/backend/Cargo.toml
@@ -25,7 +25,8 @@ time = "0.3.36"
uuid = "1.7.0"
syntect = "5.2.0"
tauri-plugin-http = "2.0.0-beta"
-tauri-plugin-single-instance = "2.0.0-beta.9"
+tauri-plugin-single-instance = "2.0.0-beta"
+tokio = "1.38.0"
[dependencies.sqlx]
version = "0.7"
diff --git a/ui/backend/capabilities/migrated.json b/ui/backend/capabilities/migrated.json
index ae4f101b5..d6d8889c4 100644
--- a/ui/backend/capabilities/migrated.json
+++ b/ui/backend/capabilities/migrated.json
@@ -19,5 +19,5 @@
"allow": ["https://api.atuin.sh/*"]
}
],
- "platforms": ["linux", "macOS", "windows", "android", "iOS"]
+ "platforms": ["linux", "macOS", "windows"]
}
diff --git a/ui/backend/src/install.rs b/ui/backend/src/install.rs
new file mode 100644
index 000000000..55877c4b2
--- /dev/null
+++ b/ui/backend/src/install.rs
@@ -0,0 +1,67 @@
+// 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 = 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(())
+}
diff --git a/ui/backend/src/main.rs b/ui/backend/src/main.rs
index f07e0c955..f03bccda5 100644
--- a/ui/backend/src/main.rs
+++ b/ui/backend/src/main.rs
@@ -8,6 +8,7 @@ use time::format_description::well_known::Rfc3339;
mod db;
mod dotfiles;
+mod install;
mod store;
use atuin_client::settings::Settings;
@@ -189,6 +190,9 @@ fn main() {
session,
login,
register,
+ install::install_cli,
+ install::is_cli_installed,
+ install::setup_cli,
dotfiles::aliases::import_aliases,
dotfiles::aliases::delete_alias,
dotfiles::aliases::set_alias,
diff --git a/ui/backend/src/update.rs b/ui/backend/src/update.rs
deleted file mode 100644
index d8cd2255a..000000000
--- a/ui/backend/src/update.rs
+++ /dev/null
@@ -1,51 +0,0 @@
-// While technically using a "self update" crate, we can actually use the same method
-// for managing a CLI install. Neat!
-// This should still be locked to the same version as the UI. Drift there could lead to issues.
-// In the future we can follow semver and allow for minor version drift.
-
-// If you'd like to follow the conventions of your OS, distro, etc, then I would suggest
-// following the CLI install instructions. This is intended to streamline install UX
-use eyre::{eyre, Result};
-use std::{
- ffi::{OsStr, OsString},
- path::Path,
-};
-
-pub fn install(version: &str, path: &str) -> Result<()> {
- let dir = std::path::PathBuf::from(path);
- std::fs::create_dir_all(path)?;
- let bin = dir.join("atuin");
-
- let releases = self_update::backends::github::ReleaseList::configure()
- .repo_owner("atuinsh")
- .repo_name("atuin")
- .build()?
- .fetch()?;
-
- let release = releases
- .iter()
- .find(|r| r.version == version)
- .ok_or_else(|| eyre!("No release found for version: {}", version))?;
-
- let asset = release
- .asset_for(&self_update::get_target(), None)
- .ok_or_else(|| eyre!("No asset found for target"))?;
-
- let tmp_dir = tempfile::Builder::new().prefix("atuin").tempdir()?;
- let tmp_tarball_path = tmp_dir.path().join(&asset.name);
- let tmp_tarball = std::fs::File::create(&tmp_tarball_path)?;
- println!("{:?}", tmp_tarball_path);
-
- self_update::Download::from_url(&asset.download_url).download_to(&tmp_tarball)?;
-
- let root = asset.name.replace(".tar.gz", "");
- let bin_name = std::path::PathBuf::from(format!("{}/atuin", root,));
-
- self_update::Extract::from_source(&tmp_tarball_path)
- .archive(self_update::ArchiveKind::Tar(Some(
- self_update::Compression::Gz,
- )))
- .extract_file(&bin, &bin_name)?;
-
- Ok(())
-}
diff --git a/ui/package.json b/ui/package.json
index f1ebf5e6f..11726aa49 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -15,6 +15,7 @@
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-slot": "^1.0.2",
+ "@radix-ui/react-toast": "^1.1.5",
"@tailwindcss/forms": "^0.5.7",
"@tanstack/react-table": "^8.17.3",
"@tanstack/react-virtual": "^3.5.1",
diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml
index 4fc29c270..db5e044f3 100644
--- a/ui/pnpm-lock.yaml
+++ b/ui/pnpm-lock.yaml
@@ -20,6 +20,9 @@ dependencies:
'@radix-ui/react-slot':
specifier: ^1.0.2
version: 1.0.2(@types/react@18.3.3)(react@18.3.1)
+ '@radix-ui/react-toast':
+ specifier: ^1.1.5
+ version: 1.1.5(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1)
'@tailwindcss/forms':
specifier: ^0.5.7
version: 0.5.7(tailwindcss@3.4.4)
@@ -1111,6 +1114,38 @@ packages:
react: 18.3.1
dev: false
+ /@radix-ui/react-toast@1.1.5(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1):
+ resolution: {integrity: sha512-fRLn227WHIBRSzuRzGJ8W+5YALxofH23y0MlPLddaIpLpCDqdE0NZlS2NRQDRiptfxDeeCjgFIpexB1/zkxDlw==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0
+ react-dom: ^16.8 || ^17.0 || ^18.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+ dependencies:
+ '@babel/runtime': 7.24.7
+ '@radix-ui/primitive': 1.0.1
+ '@radix-ui/react-collection': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1)
+ '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.3)(react@18.3.1)
+ '@radix-ui/react-context': 1.0.1(@types/react@18.3.3)(react@18.3.1)
+ '@radix-ui/react-dismissable-layer': 1.0.5(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1)
+ '@radix-ui/react-portal': 1.0.4(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1)
+ '@radix-ui/react-presence': 1.0.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1)
+ '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1)
+ '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.3.3)(react@18.3.1)
+ '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.3.3)(react@18.3.1)
+ '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.3.3)(react@18.3.1)
+ '@radix-ui/react-visually-hidden': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1)
+ '@types/react': 18.3.3
+ '@types/react-dom': 18.3.0
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ dev: false
+
/@radix-ui/react-use-callback-ref@1.0.1(@types/react@18.3.3)(react@18.3.1):
resolution: {integrity: sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==}
peerDependencies:
@@ -1199,6 +1234,27 @@ packages:
react: 18.3.1
dev: false
+ /@radix-ui/react-visually-hidden@1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1):
+ resolution: {integrity: sha512-D4w41yN5YRKtu464TLnByKzMDG/JlMPHtfZgQAu9v6mNakUqGUI9vUrfQKz8NK41VMm/xbZbh76NUTVtIYqOMA==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0
+ react-dom: ^16.8 || ^17.0 || ^18.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+ dependencies:
+ '@babel/runtime': 7.24.7
+ '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1)
+ '@types/react': 18.3.3
+ '@types/react-dom': 18.3.0
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ dev: false
+
/@radix-ui/rect@1.0.1:
resolution: {integrity: sha512-fyrgCaedtvMg9NK3en0pnOYJdtfwxUcNolezkNPUsoX57X8oQk+NkqcvzHXD2uKNij6GXmWU9NDru2IWjrO4BQ==}
dependencies:
diff --git a/ui/src/App.tsx b/ui/src/App.tsx
index 26a4d4da4..9b5242a7d 100644
--- a/ui/src/App.tsx
+++ b/ui/src/App.tsx
@@ -4,6 +4,7 @@ import { useState, ReactElement } from "react";
import { useStore } from "@/state/store";
import Button, { ButtonStyle } from "@/components/Button";
+import { Toaster } from "@/components/ui/toaster";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
@@ -124,6 +125,7 @@ function App() {
</div>
{renderMain(section)}
+ <Toaster />
</div>
);
}
diff --git a/ui/src/components/ui/alert.tsx b/ui/src/components/ui/alert.tsx
new file mode 100644
index 000000000..41fa7e056
--- /dev/null
+++ b/ui/src/components/ui/alert.tsx
@@ -0,0 +1,59 @@
+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 }
diff --git a/ui/src/components/ui/toast.tsx b/ui/src/components/ui/toast.tsx
new file mode 100644
index 000000000..a82247753
--- /dev/null
+++ b/ui/src/components/ui/toast.tsx
@@ -0,0 +1,127 @@
+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,
+}
diff --git a/ui/src/components/ui/toaster.tsx b/ui/src/components/ui/toaster.tsx
new file mode 100644
index 000000000..a2209ba58
--- /dev/null
+++ b/ui/src/components/ui/toaster.tsx
@@ -0,0 +1,33 @@
+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>
+ )
+}
diff --git a/ui/src/components/ui/use-toast.ts b/ui/src/components/ui/use-toast.ts
new file mode 100644
index 000000000..16713070d
--- /dev/null
+++ b/ui/src/components/ui/use-toast.ts
@@ -0,0 +1,192 @@
+// 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,