feat(gui): work on home page, sort state (#1956)

1. Start on a home page, can sort onboarding/etc from there
2. Introduce zustand for state management. It's nice!

Did a production build and clicked around for a while. Memory usage
seems nice and chill.
This commit is contained in:
Ellie Huxtable 2024-04-17 14:06:05 +01:00 committed by GitHub
parent fcc0dc1bd5
commit cb19925011
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 436 additions and 194 deletions

View File

@ -11,7 +11,7 @@ use reqwest::{
use atuin_common::{
api::{
AddHistoryRequest, ChangePasswordRequest, CountResponse, DeleteHistoryRequest,
ErrorResponse, LoginRequest, LoginResponse, RegisterResponse, StatusResponse,
ErrorResponse, LoginRequest, LoginResponse, MeResponse, RegisterResponse, StatusResponse,
SyncHistoryResponse,
},
record::RecordStatus,
@ -234,6 +234,18 @@ impl<'a> Client<'a> {
Ok(status)
}
pub async fn me(&self) -> Result<MeResponse> {
let url = format!("{}/api/v0/me", self.sync_addr);
let url = Url::parse(url.as_str())?;
let resp = self.client.get(url).send().await?;
let resp = handle_resp_error(resp).await?;
let status = resp.json::<MeResponse>().await?;
Ok(status)
}
pub async fn get_history(
&self,
sync_ts: OffsetDateTime,

View File

@ -18,7 +18,7 @@ mod builder;
pub mod store;
const HISTORY_VERSION: &str = "v0";
const HISTORY_TAG: &str = "history";
pub const HISTORY_TAG: &str = "history";
#[derive(Clone, Debug, Eq, PartialEq, Hash)]
pub struct HistoryId(pub String);

View File

@ -180,6 +180,16 @@ impl Store for SqliteStore {
self.idx(host, tag, 0).await
}
async fn len_all(&self) -> Result<u64> {
let res: Result<(i64,), sqlx::Error> = sqlx::query_as("select count(*) from store")
.fetch_one(&self.pool)
.await;
match res {
Err(e) => Err(eyre!("failed to fetch local store len: {}", e)),
Ok(v) => Ok(v.0 as u64),
}
}
async fn len_tag(&self, tag: &str) -> Result<u64> {
let res: Result<(i64,), sqlx::Error> =
sqlx::query_as("select count(*) from store where tag=?1")

View File

@ -25,6 +25,7 @@ pub trait Store {
async fn delete(&self, id: RecordId) -> Result<()>;
async fn delete_all(&self) -> Result<()>;
async fn len_all(&self) -> Result<u64>;
async fn len(&self, host: HostId, tag: &str) -> Result<u64>;
async fn len_tag(&self, tag: &str) -> Result<u64>;

6
ui/backend/Cargo.lock generated
View File

@ -212,7 +212,7 @@ dependencies = [
[[package]]
name = "atuin-client"
version = "18.1.0"
version = "18.2.0"
dependencies = [
"async-trait",
"atuin-common",
@ -259,7 +259,7 @@ dependencies = [
[[package]]
name = "atuin-common"
version = "18.1.0"
version = "18.2.0"
dependencies = [
"eyre",
"lazy_static",
@ -276,7 +276,7 @@ dependencies = [
[[package]]
name = "atuin-dotfiles"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"atuin-client",
"atuin-common",

View File

@ -2,6 +2,7 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::path::PathBuf;
use time::format_description::well_known::Rfc3339;
use atuin_client::settings::Settings;
@ -9,9 +10,20 @@ mod db;
mod dotfiles;
mod store;
use atuin_client::{
encryption, history::HISTORY_TAG, record::sqlite_store::SqliteStore, record::store::Store,
};
use db::{GlobalStats, HistoryDB, UIHistory};
use dotfiles::aliases::aliases;
#[derive(Debug, serde::Serialize)]
struct HomeInfo {
pub username: String,
pub record_count: u64,
pub history_count: u64,
pub last_sync: String,
}
#[tauri::command]
async fn list() -> Result<Vec<UIHistory>, String> {
let settings = Settings::new().map_err(|e| e.to_string())?;
@ -47,6 +59,54 @@ async fn global_stats() -> Result<GlobalStats, String> {
Ok(stats)
}
#[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 client = atuin_client::api_client::Client::new(
&settings.sync_address,
&settings.session_token,
settings.network_connect_timeout,
settings.network_timeout,
)
.map_err(|e| e.to_string())?;
let session_path = settings.session_path.as_str();
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 info = if !PathBuf::from(session_path).exists() {
HomeInfo {
username: String::from(""),
last_sync: last_sync.to_string(),
record_count,
history_count,
}
} else {
let me = client.me().await.map_err(|e| e.to_string())?;
HomeInfo {
username: me.username,
last_sync: last_sync.to_string(),
record_count,
history_count,
}
};
Ok(info)
}
fn main() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![
@ -54,6 +114,7 @@ fn main() {
search,
global_stats,
aliases,
home_info,
dotfiles::aliases::import_aliases,
dotfiles::aliases::delete_alias,
dotfiles::aliases::set_alias,

View File

@ -20,6 +20,7 @@
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"core": "link:@tauri-apps/api/core",
"date-fns": "^3.6.0",
"highlight.js": "^11.9.0",
"lucide-react": "^0.367.0",
"luxon": "^3.4.4",
@ -29,7 +30,8 @@
"recharts": "^2.12.4",
"tailwind-merge": "^2.2.2",
"tailwindcss-animate": "^1.0.7",
"vaul": "^0.9.0"
"vaul": "^0.9.0",
"zustand": "^4.5.2"
},
"devDependencies": {
"@tauri-apps/cli": "2.0.0-beta.2",

View File

@ -35,6 +35,9 @@ dependencies:
core:
specifier: link:@tauri-apps/api/core
version: link:@tauri-apps/api/core
date-fns:
specifier: ^3.6.0
version: 3.6.0
highlight.js:
specifier: ^11.9.0
version: 11.9.0
@ -65,6 +68,9 @@ dependencies:
vaul:
specifier: ^0.9.0
version: 0.9.0(@types/react-dom@18.2.24)(@types/react@18.2.74)(react-dom@18.2.0)(react@18.2.0)
zustand:
specifier: ^4.5.2
version: 4.5.2(@types/react@18.2.74)(react@18.2.0)
devDependencies:
'@tauri-apps/cli':
@ -1786,6 +1792,10 @@ packages:
engines: {node: '>=12'}
dev: false
/date-fns@3.6.0:
resolution: {integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==}
dev: false
/debug@4.3.4:
resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==}
engines: {node: '>=6.0'}
@ -2679,6 +2689,14 @@ packages:
tslib: 2.6.2
dev: false
/use-sync-external-store@1.2.0(react@18.2.0):
resolution: {integrity: sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
dependencies:
react: 18.2.0
dev: false
/util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
@ -2798,3 +2816,23 @@ packages:
resolution: {integrity: sha512-pIXzoImaqmfOrL7teGUBt/T7ZDnyeGBWyXQBvOVhLkWLN37GXv8NMLK406UY6dS51JfcQHsmcW5cJ441bHg6Lg==}
engines: {node: '>= 14'}
hasBin: true
/zustand@4.5.2(@types/react@18.2.74)(react@18.2.0):
resolution: {integrity: sha512-2cN1tPkDVkwCy5ickKrI7vijSjPksFRfqS6237NzT0vqSsztTNnQdHw9mmN7uBdk3gceVXU0a+21jFzFzAc9+g==}
engines: {node: '>=12.7.0'}
peerDependencies:
'@types/react': '>=16.8'
immer: '>=9.0.6'
react: '>=16.8'
peerDependenciesMeta:
'@types/react':
optional: true
immer:
optional: true
react:
optional: true
dependencies:
'@types/react': 18.2.74
react: 18.2.0
use-sync-external-store: 1.2.0(react@18.2.0)
dev: false

View File

@ -1,7 +1,11 @@
html {
overscroll-behavior: none;
}
.logo.vite:hover {
filter: drop-shadow(0 0 2em #747bff);
filter: drop-shadow(0 0 2em #747bff);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafb);
filter: drop-shadow(0 0 2em #61dafb);
}

View File

@ -1,14 +1,9 @@
import "./App.css";
import { Fragment, useState, useEffect, ReactElement } from "react";
import { Dialog, Transition } from "@headlessui/react";
import { useState, ReactElement } from "react";
import {
Bars3Icon,
ChartPieIcon,
Cog6ToothIcon,
HomeIcon,
XMarkIcon,
MagnifyingGlassIcon,
ClockIcon,
WrenchScrewdriverIcon,
} from "@heroicons/react/24/outline";
@ -18,16 +13,20 @@ function classNames(...classes: any) {
return classes.filter(Boolean).join(" ");
}
import Home from "./pages/Home.tsx";
import History from "./pages/History.tsx";
import Dotfiles from "./pages/Dotfiles.tsx";
enum Section {
Home,
History,
Dotfiles,
}
function renderMain(section: Section): ReactElement {
switch (section) {
case Section.Home:
return <Home />;
case Section.History:
return <History />;
case Section.Dotfiles:
@ -39,9 +38,14 @@ 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.History);
const [section, setSection] = useState(Section.Home);
const navigation = [
{
name: "Home",
icon: HomeIcon,
section: Section.Home,
},
{
name: "History",
icon: ClockIcon,

View File

@ -1,5 +1,3 @@
import * as React from "react";
import { Drawer as VDrawer } from "vaul";
export default function Drawer({

View File

@ -1,75 +1,88 @@
import { DateTime } from 'luxon';
import { ChevronRightIcon } from '@heroicons/react/20/solid'
import { ChevronRightIcon } from "@heroicons/react/20/solid";
function msToTime(ms) {
let milliseconds = (ms).toFixed(1);
let seconds = (ms / 1000).toFixed(1);
let minutes = (ms / (1000 * 60)).toFixed(1);
let hours = (ms / (1000 * 60 * 60)).toFixed(1);
let days = (ms / (1000 * 60 * 60 * 24)).toFixed(1);
// @ts-ignore
import { DateTime } from "luxon";
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"
else return days + " Days";
}
export default function HistoryList(props){
export default function HistoryList(props: any) {
return (
<ul
role="list"
className="divide-y divide-gray-100 overflow-hidden bg-white shadow-sm ring-1 ring-gray-900/5"
>
{props.history.map((h: any) => (
<li
key={h.id}
className="relative flex justify-between gap-x-6 px-4 py-5 hover:bg-gray-50 sm:px-6"
>
<div className="flex min-w-0 gap-x-4">
<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">
<pre className="whitespace-pre-wrap">
<code className="text-sm">{h.command}</code>
</pre>
<p className="mt-1 flex text-xs leading-5 text-gray-500">
<span className="relative truncate ">{h.user}</span>
<ul
role="list"
className="divide-y divide-gray-100 overflow-hidden bg-white shadow-sm ring-1 ring-gray-900/5"
>
{props.history.map((h) => (
<li key={h.id} className="relative flex justify-between gap-x-6 px-4 py-5 hover:bg-gray-50 sm:px-6">
<div className="flex min-w-0 gap-x-4">
<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">
<pre className="whitespace-pre-wrap"><code className="text-sm">{h.command}</code></pre>
<p className="mt-1 flex text-xs leading-5 text-gray-500">
<span className="relative truncate ">
{h.user}
</span>
<span>&nbsp;on&nbsp;</span>
<span>&nbsp;on&nbsp;</span>
<span className="relative truncate ">{h.host}</span>
<span className="relative truncate ">
{h.host}
</span>
<span>&nbsp;in&nbsp;</span>
<span>&nbsp;in&nbsp;</span>
<span className="relative truncate ">
{h.cwd}
</span>
</p>
</div>
<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 className="mt-1 flex items-center gap-x-1.5">
<div className="flex-none rounded-full bg-emerald-500/20 p-1">
<div className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
</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 className="mt-1 flex items-center gap-x-1.5">
<div className="flex-none rounded-full bg-emerald-500/20 p-1">
<div className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
</div>
<p className="text-xs leading-5 text-gray-500">Online</p>
</div>
)}
</div>
<ChevronRightIcon className="h-5 w-5 flex-none text-gray-400" aria-hidden="true" />
</div>
</li>
))}
</ul>
<p className="text-xs leading-5 text-gray-500">Online</p>
</div>
)}
</div>
<ChevronRightIcon
className="h-5 w-5 flex-none text-gray-400"
aria-hidden="true"
/>
</div>
</li>
))}
</ul>
);
}

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react";
import { useEffect, useState } from "react";
import DataTable from "@/components/ui/data-table";
import { Button } from "@/components/ui/button";
@ -8,34 +8,21 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ColumnDef } from "@tanstack/react-table";
import { invoke } from "@tauri-apps/api/core";
import Drawer from "@/components/Drawer";
function loadAliases(
setAliases: React.Dispatch<React.SetStateAction<never[]>>,
) {
invoke("aliases").then((aliases: any) => {
setAliases(aliases);
});
}
import { Alias } from "@/state/models";
import { useStore } from "@/state/store";
type Alias = {
name: string;
value: string;
};
function deleteAlias(
name: string,
setAliases: React.Dispatch<React.SetStateAction<never[]>>,
) {
function deleteAlias(name: string, refreshAliases: () => void) {
invoke("delete_alias", { name: name })
.then(() => {
console.log("Deleted alias");
loadAliases(setAliases);
refreshAliases();
})
.catch(() => {
console.error("Failed to delete alias");
@ -101,7 +88,9 @@ function AddAlias({ onAdd: onAdd }: { onAdd?: () => void }) {
}
export default function Aliases() {
let [aliases, setAliases] = useState([]);
const aliases = useStore((state) => state.aliases);
const refreshAliases = useStore((state) => state.refreshAliases);
let [aliasDrawerOpen, setAliasDrawerOpen] = useState(false);
const columns: ColumnDef<Alias>[] = [
@ -129,7 +118,7 @@ export default function Aliases() {
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => deleteAlias(alias.name, setAliases)}
onClick={() => deleteAlias(alias.name, refreshAliases)}
>
Delete
</DropdownMenuItem>
@ -141,7 +130,7 @@ export default function Aliases() {
];
useEffect(() => {
loadAliases(setAliases);
refreshAliases();
}, []);
return (
@ -172,7 +161,7 @@ export default function Aliases() {
>
<AddAlias
onAdd={() => {
loadAliases(setAliases);
refreshAliases();
setAliasDrawerOpen(false);
}}
/>

View File

@ -5,29 +5,18 @@ import PacmanLoader from "react-spinners/PacmanLoader";
import {
BarChart,
Bar,
Rectangle,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from "recharts";
const tabs = [
{ name: "Daily", href: "#", current: true },
{ name: "Weekly", href: "#", current: false },
{ name: "Monthly", href: "#", current: false },
];
function classNames(...classes) {
return classes.filter(Boolean).join(" ");
}
function renderLoading() {
<div className="flex items-center justify-center h-full">
<PacmanLoader color="#26bd65" />
</div>;
return (
<div className="flex items-center justify-center h-full">
<PacmanLoader color="#26bd65" />
</div>
);
}
export default function Stats() {
@ -77,7 +66,7 @@ export default function Stats() {
<div className="flex flex-col">
<div className="flexfull">
<dl className="grid grid-cols-1 sm:grid-cols-4 w-full">
{stats.map((item) => (
{stats.map((item: any) => (
<div
key={item.name}
className="overflow-hidden bg-white px-4 py-5 shadow sm:p-6"
@ -94,39 +83,6 @@ export default function Stats() {
</div>
<div className="flex flex-col h-54 py-4 pl-5">
<div className="sm:hidden">
{/* Use an "onChange" listener to redirect the user to the selected tab URL. */}
<select
id="tabs"
name="tabs"
className="block w-full rounded-md border-gray-300 focus:border-green-500 focus:ring-green-500"
defaultValue={tabs.find((tab) => tab.current).name}
>
{tabs.map((tab) => (
<option key={tab.name}>{tab.name}</option>
))}
</select>
</div>
<div className="hidden sm:block">
<nav className="flex space-x-4" aria-label="Tabs">
{tabs.map((tab) => (
<a
key={tab.name}
href={tab.href}
className={classNames(
tab.current
? "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.current ? "page" : undefined}
>
{tab.name}
</a>
))}
</nav>
</div>
<div className="flex flex-col h-48 pt-5 pr-5">
<ResponsiveContainer width="100%" height="100%">
<BarChart width={500} height={300} data={chart}>

View File

@ -1,12 +1,5 @@
import { useState } from "react";
import { Cog6ToothIcon } from "@heroicons/react/24/outline";
import Aliases from "@/components/dotfiles/Aliases";
import { Drawer } from "@/components/drawer";
import { invoke } from "@tauri-apps/api/core";
function Header() {
return (
<div className="md:flex md:items-center md:justify-between">

View File

@ -1,40 +1,10 @@
import { Fragment, useState, useEffect } from "react";
import { Dialog, Transition } from "@headlessui/react";
import {
Bars3Icon,
ChartPieIcon,
Cog6ToothIcon,
HomeIcon,
XMarkIcon,
} from "@heroicons/react/24/outline";
import Logo from "../assets/logo-light.svg";
import { invoke } from "@tauri-apps/api/core";
import { useEffect } from "react";
import HistoryList from "@/components/HistoryList.tsx";
import HistorySearch from "@/components/HistorySearch.tsx";
import Stats from "@/components/history/Stats.tsx";
import Drawer from "@/components/Drawer.tsx";
function refreshHistory(
setHistory: React.Dispatch<React.SetStateAction<never[]>>,
query: String | null,
) {
if (query) {
invoke("search", { query: query })
.then((res: any[]) => {
setHistory(res);
})
.catch((e) => {
console.log(e);
});
} else {
invoke("list").then((h: any[]) => {
setHistory(h);
});
}
}
import { useStore } from "@/state/store";
function Header() {
return (
@ -44,7 +14,7 @@ function Header() {
Shell History
</h2>
</div>
<div className="mt-4 flex md:ml-4 md:mt-0">
<div className="flex">
<Drawer
width="70%"
trigger={
@ -77,10 +47,11 @@ function Header() {
}
export default function Search() {
let [history, setHistory] = useState([]);
const history = useStore((state) => state.shellHistory);
const refreshHistory = useStore((state) => state.refreshShellHistory);
useEffect(() => {
refreshHistory(setHistory, null);
refreshHistory();
}, []);
return (
@ -93,8 +64,8 @@ export default function Search() {
<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">
<HistorySearch
refresh={(query: String | null) => {
refreshHistory(setHistory, query);
refresh={(query?: string) => {
refreshHistory(query);
}}
/>
</div>

84
ui/src/pages/Home.tsx Normal file
View File

@ -0,0 +1,84 @@
import { useEffect } from "react";
import { formatRelative } from "date-fns";
import { useStore } from "@/state/store";
function Stats({ stats }: any) {
return (
<div>
<dl className="mt-5 grid grid-cols-1 gap-5 sm:grid-cols-3">
{stats.map((item: any) => (
<div
key={item.name}
className="overflow-hidden rounded-lg 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>
);
}
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="min-w-0 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 Atuin.
</h3>
</div>
</div>
);
}
export default function Home() {
const homeInfo = useStore((state) => state.homeInfo);
const refreshHomeInfo = useStore((state) => state.refreshHomeInfo);
useEffect(() => {
refreshHomeInfo();
}, []);
if (!homeInfo) {
return <div>Loading...</div>;
}
return (
<div className="pl-60">
<div className="p-10">
<Header name={"Ellie"} />
<div className="pt-10">
<h2 className="text-xl font-bold">Sync</h2>
<Stats
stats={[
{
name: "Last Sync",
stat: formatRelative(homeInfo.lastSyncTime, new Date()),
},
{
name: "Total history records",
stat: homeInfo.historyCount.toLocaleString(),
},
{
name: "Other records",
stat: homeInfo.recordCount - homeInfo.historyCount,
},
]}
/>
</div>
</div>
</div>
);
}

34
ui/src/state/models.ts Normal file
View File

@ -0,0 +1,34 @@
export interface User {
username: string;
}
export const DefaultUser: User = {
username: "",
};
export interface HomeInfo {
historyCount: number;
recordCount: number;
lastSyncTime: Date;
}
export const DefaultHomeInfo: HomeInfo = {
historyCount: 0,
recordCount: 0,
lastSyncTime: new Date(),
};
export interface ShellHistory {
id: string;
timestamp: number;
command: string;
user: string;
host: string;
cwd: string;
duration: number;
}
export interface Alias {
name: string;
value: string;
}

72
ui/src/state/store.ts Normal file
View File

@ -0,0 +1,72 @@
import { create } from "zustand";
import { parseISO } from "date-fns";
import {
User,
DefaultUser,
HomeInfo,
DefaultHomeInfo,
Alias,
ShellHistory,
} from "./models";
import { invoke } from "@tauri-apps/api/core";
// 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.
interface AtuinState {
user: User;
homeInfo: HomeInfo;
aliases: Alias[];
shellHistory: ShellHistory[];
refreshHomeInfo: () => void;
refreshAliases: () => void;
refreshShellHistory: (query?: string) => void;
}
export const useStore = create<AtuinState>()((set) => ({
user: DefaultUser,
homeInfo: DefaultHomeInfo,
aliases: [],
shellHistory: [],
refreshAliases: () => {
invoke("aliases").then((aliases: any) => {
set({ aliases: aliases });
});
},
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) => {
set({
homeInfo: {
historyCount: res.history_count,
recordCount: res.record_count,
lastSyncTime: parseISO(res.last_sync),
},
});
})
.catch((e) => {
console.log(e);
});
},
}));