feat(gui): clean up home page, fix a few bugs (#2304)

* wip home screen changes

* more

* adjust

* fixes and things

* patch runbook pty check
This commit is contained in:
Ellie Huxtable 2024-07-23 13:18:54 +01:00 committed by GitHub
parent 17ed668aac
commit f8c963c7d6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 6536 additions and 6557 deletions

4
ui/backend/Cargo.lock generated
View File

@ -2866,9 +2866,9 @@ dependencies = [
[[package]] [[package]]
name = "itertools" name = "itertools"
version = "0.12.1" version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [ dependencies = [
"either", "either",
] ]

View File

@ -36,7 +36,7 @@ pub struct GlobalStats {
pub last_30d: u64, pub last_30d: u64,
} }
#[derive(Serialize)] #[derive(Serialize, Debug)]
pub struct UIHistory { pub struct UIHistory {
pub id: String, pub id: String,
/// When the command was run. /// When the command was run.

View File

@ -30,6 +30,8 @@ struct HomeInfo {
pub history_count: u64, pub history_count: u64,
pub username: Option<String>, pub username: Option<String>,
pub last_sync: Option<String>, pub last_sync: Option<String>,
pub top_commands: Vec<(String, u64)>,
pub recent_commands: Vec<UIHistory>,
} }
#[tauri::command] #[tauri::command]
@ -138,6 +140,8 @@ async fn home_info() -> Result<HomeInfo, String> {
let sqlite_store = SqliteStore::new(record_store_path, settings.local_timeout) let sqlite_store = SqliteStore::new(record_store_path, settings.local_timeout)
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let db_path = PathBuf::from(settings.db_path.as_str());
let db = HistoryDB::new(db_path, settings.local_timeout).await?;
let last_sync = Settings::last_sync() let last_sync = Settings::last_sync()
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
@ -150,12 +154,20 @@ async fn home_info() -> Result<HomeInfo, String> {
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let history = db.list(None, None).await?;
let stats = stats::compute(&settings, &history, 10, 1).map_or(vec![], |stats|stats.top[0..5].to_vec()).iter().map(|(commands, count)| (commands.join(" "), *count as u64)).collect();
let recent = if history.len() > 5 {history[0..5].to_vec()} else {vec![]};
let recent = recent.into_iter().map(|h|h.into()).collect();
let info = if !settings.logged_in() { let info = if !settings.logged_in() {
HomeInfo { HomeInfo {
username: None, username: None,
last_sync: None, last_sync: None,
record_count, record_count,
history_count, history_count,
top_commands: stats,
recent_commands: recent,
} }
} else { } else {
let client = atuin_client::api_client::Client::new( let client = atuin_client::api_client::Client::new(
@ -176,6 +188,8 @@ async fn home_info() -> Result<HomeInfo, String> {
last_sync: Some(last_sync.to_string()), last_sync: Some(last_sync.to_string()),
record_count, record_count,
history_count, history_count,
top_commands: stats,
recent_commands: recent,
} }
}; };

View File

@ -10,30 +10,31 @@
"tauri": "tauri" "tauri": "tauri"
}, },
"dependencies": { "dependencies": {
"@blocknote/core": "^0.15.0", "@blocknote/core": "^0.15.3",
"@blocknote/mantine": "^0.15.3", "@blocknote/mantine": "^0.15.3",
"@blocknote/react": "^0.15.0", "@blocknote/react": "^0.15.3",
"@codemirror/autocomplete": "^6.16.3", "@codemirror/autocomplete": "^6.17.0",
"@codemirror/commands": "^6.6.0", "@codemirror/commands": "^6.6.0",
"@codemirror/language": "^6.10.2", "@codemirror/language": "^6.10.2",
"@codemirror/lint": "^6.8.1", "@codemirror/lint": "^6.8.1",
"@codemirror/search": "^6.5.6", "@codemirror/search": "^6.5.6",
"@codemirror/state": "^6.4.1", "@codemirror/state": "^6.4.1",
"@codemirror/view": "^6.28.4", "@codemirror/view": "^6.28.6",
"@headlessui/react": "^2.1.1", "@headlessui/react": "^2.1.2",
"@heroicons/react": "^2.1.4", "@heroicons/react": "^2.1.5",
"@nextui-org/react": "^2.4.2", "@nextui-org/react": "^2.4.6",
"@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-toast": "^1.2.1", "@radix-ui/react-toast": "^1.2.1",
"@tailwindcss/forms": "^0.5.7", "@tailwindcss/forms": "^0.5.7",
"@tanstack/react-table": "^8.19.3", "@tanstack/react-table": "^8.19.3",
"@tanstack/react-virtual": "^3.8.1", "@tanstack/react-virtual": "^3.8.3",
"@tauri-apps/api": "2.0.0-beta.15", "@tauri-apps/api": "2.0.0-beta.15",
"@tauri-apps/plugin-http": "2.0.0-beta.8", "@tauri-apps/plugin-http": "2.0.0-beta.8",
"@tauri-apps/plugin-shell": "2.0.0-beta.8", "@tauri-apps/plugin-shell": "2.0.0-beta.8",
"@tauri-apps/plugin-sql": "2.0.0-beta.5", "@tauri-apps/plugin-sql": "2.0.0-beta.5",
"@types/luxon": "^3.4.2",
"@uiw/codemirror-extensions-langs": "^4.23.0", "@uiw/codemirror-extensions-langs": "^4.23.0",
"@uiw/react-codemirror": "^4.23.0", "@uiw/react-codemirror": "^4.23.0",
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-fit": "^0.10.0",
@ -43,18 +44,18 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"core": "link:@tauri-apps/api/core", "core": "link:@tauri-apps/api/core",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"framer-motion": "^11.2.13", "framer-motion": "^11.3.8",
"highlight.js": "^11.10.0", "highlight.js": "^11.10.0",
"lucide-react": "^0.402.0", "lucide-react": "^0.402.0",
"luxon": "^3.4.4", "luxon": "^3.4.4",
"prism-react-renderer": "^2.3.1", "prism-react-renderer": "^2.3.1",
"prismjs": "^1.29.0", "prismjs": "^1.29.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-activity-calendar": "^2.2.10", "react-activity-calendar": "^2.2.11",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.24.1", "react-router-dom": "^6.25.1",
"react-spinners": "^0.14.1", "react-spinners": "^0.14.1",
"react-tooltip": "^5.27.0", "react-tooltip": "^5.27.1",
"react-window": "^1.8.10", "react-window": "^1.8.10",
"react-window-infinite-loader": "^1.0.9", "react-window-infinite-loader": "^1.0.9",
"recharts": "^2.12.7", "recharts": "^2.12.7",
@ -72,10 +73,10 @@
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1", "@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"postcss": "^8.4.38", "postcss": "^8.4.39",
"tailwindcss": "^3.4.6", "tailwindcss": "^3.4.6",
"typescript": "^5.4.5", "typescript": "^5.5.3",
"vite": "^5.3.1", "vite": "^5.3.4",
"vite-tsconfig-paths": "^4.3.2" "vite-tsconfig-paths": "^4.3.2"
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,34 +1,13 @@
import "./App.css"; import "./App.css";
import { open } from "@tauri-apps/plugin-shell"; import { open } from "@tauri-apps/plugin-shell";
import { useState, ReactElement, useEffect } from "react"; import { useState, ReactElement } from "react";
import { useStore } from "@/state/store"; import { useStore } from "@/state/store";
import { Toaster } from "@/components/ui/toaster"; import { Toaster } from "@/components/ui/toaster";
import { import { KeyRoundIcon } from "lucide-react";
SettingsIcon,
CircleHelpIcon,
KeyRoundIcon,
LogOutIcon,
} from "lucide-react";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import {
HomeIcon,
ClockIcon,
WrenchScrewdriverIcon,
} from "@heroicons/react/24/outline";
import { ChevronRightSquare } from "lucide-react";
import Logo from "./assets/logo-light.svg";
function classNames(...classes: any) {
return classes.filter(Boolean).join(" ");
}
import Home from "./pages/Home.tsx"; import Home from "./pages/Home.tsx";
import History from "./pages/History.tsx"; import History from "./pages/History.tsx";
import Dotfiles from "./pages/Dotfiles.tsx"; import Dotfiles from "./pages/Dotfiles.tsx";
@ -41,7 +20,6 @@ import {
Button, Button,
ScrollShadow, ScrollShadow,
Spacer, Spacer,
Tooltip,
Dropdown, Dropdown,
DropdownItem, DropdownItem,
DropdownMenu, DropdownMenu,
@ -49,19 +27,10 @@ import {
DropdownTrigger, DropdownTrigger,
Modal, Modal,
ModalContent, ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
useDisclosure, useDisclosure,
Checkbox,
Input,
Link,
} from "@nextui-org/react"; } from "@nextui-org/react";
import { cn } from "@/lib/utils";
import { sectionItems } from "@/components/Sidebar/sidebar-items";
import Sidebar, { SidebarItem } from "@/components/Sidebar"; import Sidebar, { SidebarItem } from "@/components/Sidebar";
import icon from "@/assets/icon.svg"; import icon from "@/assets/icon.svg";
import iconText from "@/assets/logo-light.svg";
import { logout } from "./state/client.ts"; import { logout } from "./state/client.ts";
enum Section { enum Section {
@ -89,8 +58,8 @@ function App() {
// I think hashrouter may work, but I'd rather avoiding thinking of them as // I think hashrouter may work, but I'd rather avoiding thinking of them as
// pages // pages
const [section, setSection] = useState(Section.Home); const [section, setSection] = useState(Section.Home);
const user = useStore((state) => state.user); const user = useStore((state: any) => state.user);
const refreshUser = useStore((state) => state.refreshUser); const refreshUser = useStore((state: any) => state.refreshUser);
const { isOpen, onOpen, onOpenChange } = useDisclosure(); const { isOpen, onOpen, onOpenChange } = useDisclosure();
const navigation: SidebarItem[] = [ const navigation: SidebarItem[] = [

View File

@ -11,6 +11,7 @@ import "prismjs/components/prism-bash";
import Drawer from "../Drawer"; import Drawer from "../Drawer";
import HistoryInspect from "./HistoryInspect"; import HistoryInspect from "./HistoryInspect";
import { cn } from "@/lib/utils";
function msToTime(ms: number) { function msToTime(ms: number) {
let milliseconds = parseInt(ms.toFixed(1)); let milliseconds = parseInt(ms.toFixed(1));
@ -26,13 +27,18 @@ function msToTime(ms: number) {
else return days + " Days"; else return days + " Days";
} }
export default function HistoryRow({ h }: any) { export default function HistoryRow({ h, compact }: any) {
return ( return (
<li <li
key={h.id} key={h.id}
className="relative flex justify-between gap-x-6 px-4 py-5 hover:bg-gray-50 sm:px-6" className={cn(
"relative flex justify-between gap-x-6 px-4 py-5 hover:bg-gray-50 sm:px-6",
{ "py-5": !compact },
{ "py-1": compact },
)}
> >
<div className="flex min-w-0 gap-x-4"> <div className="flex min-w-0 gap-x-4">
{!compact && (
<div className="flex flex-col justify-center"> <div className="flex flex-col justify-center">
<p className="flex text-xs text-gray-500 justify-center"> <p className="flex text-xs text-gray-500 justify-center">
{DateTime.fromMillis(h.timestamp / 1000000).toLocaleString( {DateTime.fromMillis(h.timestamp / 1000000).toLocaleString(
@ -45,6 +51,7 @@ export default function HistoryRow({ h }: any) {
)} )}
</p> </p>
</div> </div>
)}
<div className="min-w-0 flex-col justify-center truncate"> <div className="min-w-0 flex-col justify-center truncate">
<Highlight <Highlight
theme={themes.github} theme={themes.github}

View File

@ -0,0 +1 @@
export default function QuickActions() {}

View File

@ -1,12 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect } from "react";
import { import {
Input,
Button, Button,
ButtonGroup, ButtonGroup,
Card,
CardBody,
CardHeader,
Divider,
Tooltip, Tooltip,
Listbox, Listbox,
ListboxItem, ListboxItem,
@ -46,13 +41,13 @@ const NoteSidebar = () => {
<div className="overflow-y-auto flex-grow"> <div className="overflow-y-auto flex-grow">
<Listbox <Listbox
hideSelectedIcon hideSelectedIcon
items={runbooks.map((runbook) => { items={runbooks.map((runbook: any): any => {
return [runbook, runbookInfo[runbook.id]]; return [runbook, runbookInfo[runbook.id]];
})} })}
variant="flat" variant="flat"
aria-label="Runbook list" aria-label="Runbook list"
selectionMode="single" selectionMode="single"
selectedKeys={[currentRunbook]} selectedKeys={currentRunbook ? [currentRunbook] : []}
itemClasses={{ base: "data-[selected=true]:bg-gray-200" }} itemClasses={{ base: "data-[selected=true]:bg-gray-200" }}
topContent={ topContent={
<ButtonGroup className="z-20"> <ButtonGroup className="z-20">
@ -74,7 +69,7 @@ const NoteSidebar = () => {
</ButtonGroup> </ButtonGroup>
} }
> >
{([runbook, info]) => ( {([runbook, info]: [Runbook, { ptys: number }]) => (
<ListboxItem <ListboxItem
key={runbook.id} key={runbook.id}
onPress={() => { onPress={() => {
@ -124,7 +119,7 @@ const NoteSidebar = () => {
<div className="text-xs text-gray-500"> <div className="text-xs text-gray-500">
<em> <em>
{DateTime.fromJSDate(runbook.updated).toLocaleString( {DateTime.fromJSDate(runbook.updated).toLocaleString(
DateTime.DATETIME_SIMPLE, DateTime.DATETIME_SHORT,
)} )}
</em> </em>
</div> </div>

View File

@ -1,37 +1,47 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import "@blocknote/core/fonts/inter.css";
import "@blocknote/mantine/style.css";
import "./index.css"; import "./index.css";
import { Spinner } from "@nextui-org/react"; import { Spinner } from "@nextui-org/react";
// Errors, but it all works fine and is there. Maybe missing ts defs?
// I'll figure it out later
import { import {
// @ts-ignore
BlockNoteSchema, BlockNoteSchema,
// @ts-ignore
BlockNoteEditor, BlockNoteEditor,
// @ts-ignore
defaultBlockSpecs, defaultBlockSpecs,
// @ts-ignore
filterSuggestionItems, filterSuggestionItems,
// @ts-ignore
insertOrUpdateBlock, insertOrUpdateBlock,
} from "@blocknote/core"; } from "@blocknote/core";
import "@blocknote/core/fonts/inter.css";
import { import {
//@ts-ignore
SuggestionMenuController, SuggestionMenuController,
// @ts-ignore
AddBlockButton, AddBlockButton,
// @ts-ignore
getDefaultReactSlashMenuItems, getDefaultReactSlashMenuItems,
useCreateBlockNote, // @ts-ignore
SideMenu, SideMenu,
// @ts-ignore
SideMenuController, SideMenuController,
} from "@blocknote/react"; } from "@blocknote/react";
import { BlockNoteView } from "@blocknote/mantine"; import { BlockNoteView } from "@blocknote/mantine";
import "@blocknote/core/fonts/inter.css";
import "@blocknote/mantine/style.css";
import { Code } from "lucide-react"; import { Code } from "lucide-react";
import { useDebounceCallback } from "usehooks-ts"; import { useDebounceCallback } from "usehooks-ts";
import RunBlock from "@/components/runbooks/editor/blocks/RunBlock"; import RunBlock from "@/components/runbooks/editor/blocks/RunBlock";
import { DeleteBlock } from "@/components/runbooks/editor/ui/DeleteBlockButton"; import { DeleteBlock } from "@/components/runbooks/editor/ui/DeleteBlockButton";
import { useStore } from "@/state/store"; import { AtuinState, useStore } from "@/state/store";
import Runbook from "@/state/runbooks/runbook"; import Runbook from "@/state/runbooks/runbook";
// Our schema with block specs, which contain the configs and implementations for blocks // Our schema with block specs, which contain the configs and implementations for blocks
@ -60,8 +70,10 @@ const insertRun = (editor: typeof schema.BlockNoteEditor) => ({
}); });
export default function Editor() { export default function Editor() {
const runbookId = useStore((store) => store.currentRunbook); const runbookId = useStore((store: AtuinState) => store.currentRunbook);
const refreshRunbooks = useStore((store) => store.refreshRunbooks); const refreshRunbooks = useStore(
(store: AtuinState) => store.refreshRunbooks,
);
let [runbook, setRunbook] = useState<Runbook | null>(null); let [runbook, setRunbook] = useState<Runbook | null>(null);
useEffect(() => { useEffect(() => {
@ -76,11 +88,21 @@ export default function Editor() {
fetchRunbook(); fetchRunbook();
}, [runbookId]); }, [runbookId]);
const editor = useMemo(() => { const onChange = async () => {
if (!runbook) { if (!runbook) return;
return undefined;
}
console.log("saved!");
runbook.name = fetchName();
if (editor) runbook.content = JSON.stringify(editor.document);
await runbook.save();
refreshRunbooks();
};
const debouncedOnChange = useDebounceCallback(onChange, 1000);
const editor = useMemo(() => {
if (!runbook) return undefined;
if (runbook.content) { if (runbook.content) {
return BlockNoteEditor.create({ return BlockNoteEditor.create({
initialContent: JSON.parse(runbook.content), initialContent: JSON.parse(runbook.content),
@ -91,28 +113,18 @@ export default function Editor() {
return BlockNoteEditor.create({ schema }); return BlockNoteEditor.create({ schema });
}, [runbook]); }, [runbook]);
const onChange = async () => {
if (!runbook) return;
console.log("saved!");
runbook.name = fetchName();
runbook.content = JSON.stringify(editor.document);
await runbook.save();
await refreshRunbooks();
};
const debouncedOnChange = useDebounceCallback(onChange, 1000);
const fetchName = (): string => { const fetchName = (): string => {
// Infer the title from the first text block // Infer the title from the first text block
if (!editor) return "Untitled";
let blocks = editor.document; let blocks = editor.document;
for (const block of blocks) { for (const block of blocks) {
if (block.type == "heading" || block.type == "paragraph") { if (block.type == "heading" || block.type == "paragraph") {
if (block.content.length == 0) continue; if (block.content.length == 0) continue;
// @ts-ignore
if (block.content[0].text.length == 0) continue; if (block.content[0].text.length == 0) continue;
// @ts-ignore
return block.content[0].text; return block.content[0].text;
} }
} }
@ -120,6 +132,14 @@ export default function Editor() {
return "Untitled"; return "Untitled";
}; };
if (!runbook) {
return (
<div className="flex w-full h-full flex-col justify-center items-center">
<Spinner />
</div>
);
}
if (editor === undefined) { if (editor === undefined) {
return ( return (
<div className="flex w-full h-full flex-col justify-center items-center"> <div className="flex w-full h-full flex-col justify-center items-center">
@ -139,7 +159,7 @@ export default function Editor() {
> >
<SuggestionMenuController <SuggestionMenuController
triggerCharacter={"/"} triggerCharacter={"/"}
getItems={async (query) => getItems={async (query: any) =>
filterSuggestionItems( filterSuggestionItems(
[...getDefaultReactSlashMenuItems(editor), insertRun(editor)], [...getDefaultReactSlashMenuItems(editor), insertRun(editor)],
query, query,
@ -148,7 +168,7 @@ export default function Editor() {
/> />
<SideMenuController <SideMenuController
sideMenu={(props) => ( sideMenu={(props: any) => (
<SideMenu {...props}> <SideMenu {...props}>
<AddBlockButton {...props} /> <AddBlockButton {...props} />
<DeleteBlock {...props} /> <DeleteBlock {...props} />

View File

@ -1,4 +1,4 @@
import React from "react"; // @ts-ignore
import { createReactBlockSpec } from "@blocknote/react"; import { createReactBlockSpec } from "@blocknote/react";
import "./index.css"; import "./index.css";
@ -48,7 +48,7 @@ const RunBlock = ({
], ],
); );
const isRunning = pty !== null; const isRunning = pty !== null && pty !== "";
const handleToggle = async (event: any | null) => { const handleToggle = async (event: any | null) => {
if (event) event.stopPropagation(); if (event) event.stopPropagation();
@ -63,21 +63,21 @@ const RunBlock = ({
cleanupPtyTerm(pty); cleanupPtyTerm(pty);
if (onStop) onStop(pty); if (onStop) onStop(pty);
decRunbookPty(currentRunbook); if (currentRunbook) decRunbookPty(currentRunbook);
} }
if (!isRunning) { if (!isRunning) {
let pty = await invoke<string>("pty_open"); let pty = await invoke<string>("pty_open");
if (onRun) onRun(pty); if (onRun) onRun(pty);
incRunbookPty(currentRunbook); if (currentRunbook) incRunbookPty(currentRunbook);
let val = !value.endsWith("\n") ? value + "\r\n" : value; let val = !value.endsWith("\n") ? value + "\r\n" : value;
await invoke("pty_write", { pid: pty, data: val }); await invoke("pty_write", { pid: pty, data: val });
} }
}; };
const handleCmdEnter = (view) => { const handleCmdEnter = () => {
handleToggle(null); handleToggle(null);
return true; return true;
}; };
@ -145,7 +145,7 @@ export default createReactBlockSpec(
default: "bash", default: "bash",
}, },
code: { default: "" }, code: { default: "" },
pty: { default: null }, pty: { default: "" },
}, },
content: "none", content: "none",
}, },
@ -154,19 +154,21 @@ export default createReactBlockSpec(
render: ({ block, editor, code, type }) => { render: ({ block, editor, code, type }) => {
const onInputChange = (val: string) => { const onInputChange = (val: string) => {
editor.updateBlock(block, { editor.updateBlock(block, {
// @ts-ignore
props: { ...block.props, code: val }, props: { ...block.props, code: val },
}); });
}; };
const onRun = (pty: string) => { const onRun = (pty: string) => {
editor.updateBlock(block, { editor.updateBlock(block, {
// @ts-ignore
props: { ...block.props, pty: pty }, props: { ...block.props, pty: pty },
}); });
}; };
const onStop = (pty: string) => { const onStop = (_pty: string) => {
editor.updateBlock(block, { editor?.updateBlock(block, {
props: { ...block.props, pty: null }, props: { ...block.props, pty: "" },
}); });
}; };

View File

@ -0,0 +1,79 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@ -0,0 +1,363 @@
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
})
ChartContainer.displayName = "Chart"
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([_, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref
) => {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item.dataKey || item.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
ref={ref}
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
)
ChartTooltipContent.displayName = "ChartTooltip"
const ChartLegend = RechartsPrimitive.Legend
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}
>(
(
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
ref
) => {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
ref={ref}
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
)
ChartLegendContent.displayName = "ChartLegend"
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}

View File

@ -1,6 +1,6 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { NextUIProvider, Spacer } from "@nextui-org/react"; import { NextUIProvider } from "@nextui-org/react";
import App from "./App"; import App from "./App";
import "./styles.css"; import "./styles.css";

View File

@ -2,32 +2,94 @@ import React, { useEffect } from "react";
import { formatRelative } from "date-fns"; import { formatRelative } from "date-fns";
import { Tooltip as ReactTooltip } from "react-tooltip"; import { Tooltip as ReactTooltip } from "react-tooltip";
import { useStore } from "@/state/store"; import { AtuinState, useStore } from "@/state/store";
import { useToast } from "@/components/ui/use-toast"; import { useToast } from "@/components/ui/use-toast";
import { ToastAction } from "@/components/ui/toast"; import { ToastAction } from "@/components/ui/toast";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import {
Card,
CardHeader,
CardBody,
Listbox,
ListboxItem,
} from "@nextui-org/react";
import {
Bar,
BarChart,
CartesianGrid,
LabelList,
XAxis,
YAxis,
} from "recharts";
import { ChartConfig, ChartContainer } from "@/components/ui/chart";
import { Clock, Terminal } from "lucide-react";
import ActivityCalendar from "react-activity-calendar"; import ActivityCalendar from "react-activity-calendar";
import HistoryRow from "@/components/history/HistoryRow";
import { ShellHistory } from "@/state/models";
function Stats({ stats }: any) { function StatCard({ name, stat }: any) {
return ( return (
<div> <Card shadow="sm">
<dl className="mt-5 grid grid-cols-1 gap-5 sm:grid-cols-3"> <CardHeader>
{stats.map((item: any) => ( <h3 className="uppercase text-gray-500">{name}</h3>
<div </CardHeader>
key={item.name} <CardBody>
className="overflow-hidden rounded-lg bg-white px-4 py-5 shadow sm:p-6" <h2 className="font-bold text-xl">{stat}</h2>
</CardBody>
</Card>
);
}
function TopChart({ chartData }: any) {
const chartConfig = {
command: {
label: "Command",
color: "#c4edde",
},
} satisfies ChartConfig;
return (
<ChartContainer config={chartConfig} className="max-h-72">
<BarChart
accessibilityLayer
data={chartData}
layout="vertical"
margin={{
right: 16,
}}
> >
<dt className="truncate text-sm font-medium text-gray-500"> <CartesianGrid horizontal={false} />
{item.name} <YAxis
</dt> dataKey="command"
<dd className="mt-1 text-xl font-semibold tracking-tight text-gray-900"> type="category"
{item.stat} tickLine={false}
</dd> tickMargin={10}
</div> axisLine={false}
))} tickFormatter={(value) => value.slice(0, 3)}
</dl> hide
</div> />
<XAxis dataKey="count" type="number" hide />
<Bar dataKey="count" layout="vertical" fill="#c4edde" radius={4}>
<LabelList
dataKey="command"
position="insideLeft"
offset={8}
className="fill-[--color-label]"
fontSize={12}
/>
<LabelList
dataKey="count"
position="right"
offset={8}
className="fill-foreground"
fontSize={12}
/>
</Bar>
</BarChart>
</ChartContainer>
); );
} }
@ -62,14 +124,22 @@ const explicitTheme = {
}; };
export default function Home() { export default function Home() {
const homeInfo = useStore((state) => state.homeInfo); const homeInfo = useStore((state: AtuinState) => state.homeInfo);
const user = useStore((state) => state.user); const user = useStore((state: AtuinState) => state.user);
const calendar = useStore((state) => state.calendar); const calendar = useStore((state: AtuinState) => state.calendar);
const weekStart = useStore((state) => state.weekStart); const runbooks = useStore((state: AtuinState) => state.runbooks);
const weekStart = useStore((state: AtuinState) => state.weekStart);
const refreshHomeInfo = useStore((state) => state.refreshHomeInfo); const refreshHomeInfo = useStore(
const refreshUser = useStore((state) => state.refreshUser); (state: AtuinState) => state.refreshHomeInfo,
const refreshCalendar = useStore((state) => state.refreshCalendar); );
const refreshUser = useStore((state: AtuinState) => state.refreshUser);
const refreshCalendar = useStore(
(state: AtuinState) => state.refreshCalendar,
);
const refreshRunbooks = useStore(
(state: AtuinState) => state.refreshRunbooks,
);
const { toast } = useToast(); const { toast } = useToast();
@ -77,6 +147,9 @@ export default function Home() {
refreshHomeInfo(); refreshHomeInfo();
refreshUser(); refreshUser();
refreshCalendar(); refreshCalendar();
refreshRunbooks();
console.log(homeInfo);
let setup = async () => { let setup = async () => {
let installed = await invoke("is_cli_installed"); let installed = await invoke("is_cli_installed");
@ -125,34 +198,39 @@ export default function Home() {
} }
return ( return (
<div className="w-full flex-1 flex-col p-4"> <div className="w-full flex-1 flex-col p-4 overflow-y-auto">
<div className="p-10"> <div className="pl-10">
<Header name={user.username} /> <Header name={user.username} />
</div>
<div className="pt-10"> <div className="p-10 grid grid-cols-4 gap-4">
<Stats <StatCard
stats={[ name="Last Sync"
{ stat={
name: "Last Sync",
stat:
(homeInfo.lastSyncTime && (homeInfo.lastSyncTime &&
formatRelative(homeInfo.lastSyncTime, new Date())) || formatRelative(homeInfo.lastSyncTime, new Date())) ||
"Never", "Never"
}, }
{ />
name: "Total history records", <StatCard
stat: homeInfo.historyCount.toLocaleString(), name="Total Commands"
}, stat={homeInfo.historyCount.toLocaleString()}
{ />
name: "Other records", <StatCard
stat: homeInfo.recordCount - homeInfo.historyCount, name="Total Runbooks"
}, stat={runbooks.length.toLocaleString()}
]} />
<StatCard
name="Other Records"
stat={homeInfo.recordCount - homeInfo.historyCount}
/> />
</div>
<div className="pt-10 flex justify-around"> <Card shadow="sm" className="col-span-3">
<CardHeader>
<h2 className="uppercase text-gray-500">Activity graph</h2>
</CardHeader>
<CardBody>
<ActivityCalendar <ActivityCalendar
hideTotalCount
theme={explicitTheme} theme={explicitTheme}
data={calendar} data={calendar}
weekStart={weekStart as any} weekStart={weekStart as any}
@ -162,12 +240,55 @@ export default function Home() {
"data-tooltip-html": `${activity.count} commands on ${activity.date}`, "data-tooltip-html": `${activity.count} commands on ${activity.date}`,
}) })
} }
labels={{
totalCount: "{{count}} history records in the last year",
}}
/> />
<ReactTooltip id="react-tooltip" /> <ReactTooltip id="react-tooltip" />
</div> </CardBody>
</Card>
<Card shadow="sm">
<CardHeader>
<h2 className="uppercase text-gray-500">Quick actions </h2>
</CardHeader>
<CardBody>
<Listbox variant="flat" aria-label="Quick actions">
<ListboxItem
key="new-runbook"
description="Create an executable runbook"
startContent={<Terminal />}
>
New runbook
</ListboxItem>
<ListboxItem
key="shell-history"
description="Search and explore shell history"
startContent={<Clock />}
>
Shell History
</ListboxItem>
</Listbox>
</CardBody>
</Card>
<Card shadow="sm" className="col-span-2">
<CardHeader>
<h2 className="uppercase text-gray-500">Recent commands</h2>
</CardHeader>
<CardBody>
{homeInfo.recentCommands?.map((i: ShellHistory) => {
return <HistoryRow compact h={i} />;
})}
</CardBody>
</Card>
<Card shadow="sm" className="col-span-2">
<CardHeader>
<h2 className="uppercase text-gray-500">Top commands</h2>
</CardHeader>
<CardBody>
<TopChart chartData={homeInfo.topCommands} />
</CardBody>
</Card>
</div> </div>
</div> </div>
); );

View File

@ -18,12 +18,16 @@ export interface HomeInfo {
historyCount: number; historyCount: number;
recordCount: number; recordCount: number;
lastSyncTime: Date | null; lastSyncTime: Date | null;
recentCommands: ShellHistory[];
topCommands: ShellHistory[];
} }
export const DefaultHomeInfo: HomeInfo = { export const DefaultHomeInfo: HomeInfo = {
historyCount: 0, historyCount: 0,
recordCount: 0, recordCount: 0,
lastSyncTime: new Date(), lastSyncTime: new Date(),
recentCommands: [],
topCommands: [],
}; };
export class ShellHistory { export class ShellHistory {

View File

@ -58,7 +58,7 @@ export interface AtuinState {
setCurrentRunbook: (id: String) => void; setCurrentRunbook: (id: String) => void;
setPtyTerm: (pty: string, terminal: any) => void; setPtyTerm: (pty: string, terminal: any) => void;
newPtyTerm: (pty: string, runbook: string) => TerminalData; newPtyTerm: (pty: string) => TerminalData;
cleanupPtyTerm: (pty: string) => void; cleanupPtyTerm: (pty: string) => void;
terminals: { [pty: string]: TerminalData }; terminals: { [pty: string]: TerminalData };
@ -125,11 +125,17 @@ let state = (set: any, get: any): AtuinState => ({
refreshHomeInfo: () => { refreshHomeInfo: () => {
invoke("home_info") invoke("home_info")
.then((res: any) => { .then((res: any) => {
console.log(res);
set({ set({
homeInfo: { homeInfo: {
historyCount: res.history_count, historyCount: res.history_count,
recordCount: res.record_count, recordCount: res.record_count,
lastSyncTime: (res.last_sync && parseISO(res.last_sync)) || null, lastSyncTime: (res.last_sync && parseISO(res.last_sync)) || null,
recentCommands: res.recent_commands,
topCommands: res.top_commands.map((top: any) => ({
command: top[0],
count: top[1],
})),
}, },
}); });
}) })