diff options
author | Ellie Huxtable <ellie@elliehuxtable.com> | 2024-06-17 15:36:38 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-06-17 15:36:38 +0100 |
commit | 88633b8994437180afdd66068cc2c8f02aea1db1 (patch) | |
tree | 6df7ead44d8cb3c219dd43d0ee86256f4a6025ef | |
parent | 39b424f3b1c28cdf50bfd75c344fc53b99460e27 (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.lock | 1 | ||||
-rw-r--r-- | crates/atuin-common/Cargo.toml | 1 | ||||
-rw-r--r-- | crates/atuin-common/src/shell.rs | 19 | ||||
-rw-r--r-- | ui/backend/Cargo.lock | 2 | ||||
-rw-r--r-- | ui/backend/Cargo.toml | 3 | ||||
-rw-r--r-- | ui/backend/capabilities/migrated.json | 2 | ||||
-rw-r--r-- | ui/backend/src/install.rs | 67 | ||||
-rw-r--r-- | ui/backend/src/main.rs | 4 | ||||
-rw-r--r-- | ui/backend/src/update.rs | 51 | ||||
-rw-r--r-- | ui/package.json | 1 | ||||
-rw-r--r-- | ui/pnpm-lock.yaml | 56 | ||||
-rw-r--r-- | ui/src/App.tsx | 2 | ||||
-rw-r--r-- | ui/src/components/ui/alert.tsx | 59 | ||||
-rw-r--r-- | ui/src/components/ui/toast.tsx | 127 | ||||
-rw-r--r-- | ui/src/components/ui/toaster.tsx | 33 | ||||
-rw-r--r-- | ui/src/components/ui/use-toast.ts | 192 | ||||
-rw-r--r-- | ui/src/pages/Home.tsx | 28 |
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, |