mirror of
https://github.com/ellie/atuin.git
synced 2024-09-11 21:18:22 +03:00
feat(gui): automatically install and setup the cli/shell (#2139)
* feat(gui): automatically install and setup the cli/shell * add shell config and toasts
This commit is contained in:
parent
39b424f3b1
commit
88633b8994
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -300,6 +300,7 @@ dependencies = [
|
||||
name = "atuin-common"
|
||||
version = "18.3.0"
|
||||
dependencies = [
|
||||
"directories",
|
||||
"eyre",
|
||||
"lazy_static",
|
||||
"pretty_assertions",
|
||||
|
@ -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"
|
||||
|
@ -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
|
||||
|
2
ui/backend/Cargo.lock
generated
2
ui/backend/Cargo.lock
generated
@ -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",
|
||||
]
|
||||
|
||||
|
@ -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"
|
||||
|
@ -19,5 +19,5 @@
|
||||
"allow": ["https://api.atuin.sh/*"]
|
||||
}
|
||||
],
|
||||
"platforms": ["linux", "macOS", "windows", "android", "iOS"]
|
||||
"platforms": ["linux", "macOS", "windows"]
|
||||
}
|
||||
|
67
ui/backend/src/install.rs
Normal file
67
ui/backend/src/install.rs
Normal file
@ -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(())
|
||||
}
|
@ -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,
|
||||
|
@ -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(())
|
||||
}
|
@ -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",
|
||||
|
@ -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:
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
59
ui/src/components/ui/alert.tsx
Normal file
59
ui/src/components/ui/alert.tsx
Normal file
@ -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 }
|
127
ui/src/components/ui/toast.tsx
Normal file
127
ui/src/components/ui/toast.tsx
Normal file
@ -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,
|
||||
}
|
33
ui/src/components/ui/toaster.tsx
Normal file
33
ui/src/components/ui/toaster.tsx
Normal file
@ -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>
|
||||
)
|
||||
}
|
192
ui/src/components/ui/use-toast.ts
Normal file
192
ui/src/components/ui/use-toast.ts
Normal file
@ -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,
|
||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||
}
|
||||
}
|
||||
|
||||
export { useToast, toast }
|
@ -2,6 +2,8 @@ import { useEffect } from "react";
|
||||
import { formatRelative } from "date-fns";
|
||||
|
||||
import { useStore } from "@/state/store";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
function Stats({ stats }: any) {
|
||||
return (
|
||||
@ -47,10 +49,36 @@ export default function Home() {
|
||||
const user = useStore((state) => state.user);
|
||||
const refreshHomeInfo = useStore((state) => state.refreshHomeInfo);
|
||||
const refreshUser = useStore((state) => state.refreshUser);
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
refreshHomeInfo();
|
||||
refreshUser();
|
||||
|
||||
let setup = async () => {
|
||||
let installed = await invoke("is_cli_installed");
|
||||
console.log("CLI installation status:", installed);
|
||||
|
||||
if (!installed) {
|
||||
toast({
|
||||
title: "Atuin CLI",
|
||||
description: "Started CLI setup and installation...",
|
||||
});
|
||||
|
||||
console.log("Installing CLI...");
|
||||
await invoke("install_cli");
|
||||
|
||||
console.log("Setting up plugin...");
|
||||
await invoke("setup_cli");
|
||||
|
||||
toast({
|
||||
title: "Atuin CLI",
|
||||
description: "Installation complete",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
setup();
|
||||
}, []);
|
||||
|
||||
if (!homeInfo) {
|
||||
|
Loading…
Reference in New Issue
Block a user