From cb19925011d889c513e1bbedc446e399597e38a0 Mon Sep 17 00:00:00 2001 From: Ellie Huxtable Date: Wed, 17 Apr 2024 14:06:05 +0100 Subject: 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. --- ui/src/App.css | 8 +- ui/src/App.tsx | 18 +++-- ui/src/components/Drawer.tsx | 2 - ui/src/components/HistoryList.tsx | 131 ++++++++++++++++++--------------- ui/src/components/dotfiles/Aliases.tsx | 37 ++++------ ui/src/components/history/Stats.tsx | 56 ++------------ ui/src/pages/Dotfiles.tsx | 7 -- ui/src/pages/History.tsx | 45 ++--------- ui/src/pages/Home.tsx | 84 +++++++++++++++++++++ ui/src/state/models.ts | 34 +++++++++ ui/src/state/store.ts | 72 ++++++++++++++++++ 11 files changed, 306 insertions(+), 188 deletions(-) create mode 100644 ui/src/pages/Home.tsx create mode 100644 ui/src/state/models.ts create mode 100644 ui/src/state/store.ts (limited to 'ui/src') diff --git a/ui/src/App.css b/ui/src/App.css index a89ebd15..5a32a1a5 100644 --- a/ui/src/App.css +++ b/ui/src/App.css @@ -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); } diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 5d1cd863..ae6ebdb1 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -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 ; case Section.History: return ; 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, diff --git a/ui/src/components/Drawer.tsx b/ui/src/components/Drawer.tsx index 65bb5ab4..91753624 100644 --- a/ui/src/components/Drawer.tsx +++ b/ui/src/components/Drawer.tsx @@ -1,5 +1,3 @@ -import * as React from "react"; - import { Drawer as VDrawer } from "vaul"; export default function Drawer({ diff --git a/ui/src/components/HistoryList.tsx b/ui/src/components/HistoryList.tsx index b31a4be4..9616ecf0 100644 --- a/ui/src/components/HistoryList.tsx +++ b/ui/src/components/HistoryList.tsx @@ -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 ( +
    + {props.history.map((h: any) => ( +
  • +
    +
    +

    + {DateTime.fromMillis(h.timestamp / 1000000).toLocaleString( + DateTime.TIME_WITH_SECONDS, + )} +

    +

    + {DateTime.fromMillis(h.timestamp / 1000000).toLocaleString( + DateTime.DATE_SHORT, + )} +

    +
    +
    +
    +                {h.command}
    +              
    +

    + {h.user} -

      - {props.history.map((h) => ( -
    • -
      -
      -

      { DateTime.fromMillis(h.timestamp / 1000000).toLocaleString(DateTime.TIME_WITH_SECONDS)}

      -

      { DateTime.fromMillis(h.timestamp / 1000000).toLocaleString(DateTime.DATE_SHORT)}

      -
      -
      -
      {h.command}
      -

      - - {h.user} - - -  on  +  on  - - {h.host} - + {h.host} -  in  +  in  - - {h.cwd} - -

      -
      -
      -
      -
      -

      {h.exit}

      - {h.duration ? ( -

      - -

      - ) : ( -
      -
      -
      -
      -

      Online

      -
      - )} -
      -
      +
      +
      +
      +

      {h.exit}

      + {h.duration ? ( +

      + +

      + ) : ( +
      +
      +
      -
    • - ))} -
    +

    Online

    +
    + )} +
    +
  • + ))} +
); } diff --git a/ui/src/components/dotfiles/Aliases.tsx b/ui/src/components/dotfiles/Aliases.tsx index 4854e6b5..61fd001c 100644 --- a/ui/src/components/dotfiles/Aliases.tsx +++ b/ui/src/components/dotfiles/Aliases.tsx @@ -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>, -) { - invoke("aliases").then((aliases: any) => { - setAliases(aliases); - }); -} - -type Alias = { - name: string; - value: string; -}; +import { Alias } from "@/state/models"; +import { useStore } from "@/state/store"; -function deleteAlias( - name: string, - setAliases: React.Dispatch>, -) { +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[] = [ @@ -129,7 +118,7 @@ export default function Aliases() { Actions deleteAlias(alias.name, setAliases)} + onClick={() => deleteAlias(alias.name, refreshAliases)} > Delete @@ -141,7 +130,7 @@ export default function Aliases() { ]; useEffect(() => { - loadAliases(setAliases); + refreshAliases(); }, []); return ( @@ -172,7 +161,7 @@ export default function Aliases() { > { - loadAliases(setAliases); + refreshAliases(); setAliasDrawerOpen(false); }} /> diff --git a/ui/src/components/history/Stats.tsx b/ui/src/components/history/Stats.tsx index afd9ed89..ce92ac04 100644 --- a/ui/src/components/history/Stats.tsx +++ b/ui/src/components/history/Stats.tsx @@ -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() { -
- -
; + return ( +
+ +
+ ); } export default function Stats() { @@ -77,7 +66,7 @@ export default function Stats() {
- {stats.map((item) => ( + {stats.map((item: any) => (
-
- {/* Use an "onChange" listener to redirect the user to the selected tab URL. */} - -
-
- -
-
diff --git a/ui/src/pages/Dotfiles.tsx b/ui/src/pages/Dotfiles.tsx index bd209062..6b0870b3 100644 --- a/ui/src/pages/Dotfiles.tsx +++ b/ui/src/pages/Dotfiles.tsx @@ -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 (
diff --git a/ui/src/pages/History.tsx b/ui/src/pages/History.tsx index f74c16ac..91ed9824 100644 --- a/ui/src/pages/History.tsx +++ b/ui/src/pages/History.tsx @@ -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>, - 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
-
+
state.shellHistory); + const refreshHistory = useStore((state) => state.refreshShellHistory); useEffect(() => { - refreshHistory(setHistory, null); + refreshHistory(); }, []); return ( @@ -93,8 +64,8 @@ export default function Search() {
{ - refreshHistory(setHistory, query); + refresh={(query?: string) => { + refreshHistory(query); }} />
diff --git a/ui/src/pages/Home.tsx b/ui/src/pages/Home.tsx new file mode 100644 index 00000000..c0f8fbc5 --- /dev/null +++ b/ui/src/pages/Home.tsx @@ -0,0 +1,84 @@ +import { useEffect } from "react"; +import { formatRelative } from "date-fns"; + +import { useStore } from "@/state/store"; + +function Stats({ stats }: any) { + return ( +
+
+ {stats.map((item: any) => ( +
+
+ {item.name} +
+
+ {item.stat} +
+
+ ))} +
+
+ ); +} + +function Header({ name }: any) { + let greeting = name && name.length > 0 ? "Hey, " + name + "!" : "Hey!"; + + return ( +
+
+

+ {greeting} +

+

+ Welcome to Atuin. +

+
+
+ ); +} + +export default function Home() { + const homeInfo = useStore((state) => state.homeInfo); + const refreshHomeInfo = useStore((state) => state.refreshHomeInfo); + + useEffect(() => { + refreshHomeInfo(); + }, []); + + if (!homeInfo) { + return
Loading...
; + } + + return ( +
+
+
+ +
+

Sync

+ +
+
+
+ ); +} diff --git a/ui/src/state/models.ts b/ui/src/state/models.ts new file mode 100644 index 00000000..f11ce651 --- /dev/null +++ b/ui/src/state/models.ts @@ -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; +} diff --git a/ui/src/state/store.ts b/ui/src/state/store.ts new file mode 100644 index 00000000..08410ba8 --- /dev/null +++ b/ui/src/state/store.ts @@ -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()((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); + }); + }, +})); -- cgit v1.3.1