mirror of
https://github.com/atuinsh/atuin.git
synced 2025-02-23 05:41:39 +01:00
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:
parent
fcc0dc1bd5
commit
cb19925011
@ -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,
|
||||
|
@ -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);
|
||||
|
@ -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")
|
||||
|
@ -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
6
ui/backend/Cargo.lock
generated
@ -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",
|
||||
|
@ -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,
|
||||
|
@ -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",
|
||||
|
38
ui/pnpm-lock.yaml
generated
38
ui/pnpm-lock.yaml
generated
@ -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
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -1,5 +1,3 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { Drawer as VDrawer } from "vaul";
|
||||
|
||||
export default function Drawer({
|
||||
|
@ -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> on </span>
|
||||
|
||||
<span> on </span>
|
||||
<span className="relative truncate ">{h.host}</span>
|
||||
|
||||
<span className="relative truncate ">
|
||||
{h.host}
|
||||
</span>
|
||||
<span> in </span>
|
||||
|
||||
<span> in </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>
|
||||
);
|
||||
}
|
||||
|
@ -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);
|
||||
}}
|
||||
/>
|
||||
|
@ -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}>
|
||||
|
@ -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">
|
||||
|
@ -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
84
ui/src/pages/Home.tsx
Normal 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
34
ui/src/state/models.ts
Normal 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
72
ui/src/state/store.ts
Normal 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);
|
||||
});
|
||||
},
|
||||
}));
|
Loading…
Reference in New Issue
Block a user