mirror of
https://github.com/atuinsh/atuin.git
synced 2024-11-22 00:03:49 +01:00
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:
parent
17ed668aac
commit
f8c963c7d6
4
ui/backend/Cargo.lock
generated
4
ui/backend/Cargo.lock
generated
@ -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",
|
||||||
]
|
]
|
||||||
|
@ -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.
|
||||||
|
@ -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,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
12149
ui/pnpm-lock.yaml
12149
ui/pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
@ -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[] = [
|
||||||
|
@ -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,25 +27,31 @@ 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">
|
||||||
<div className="flex flex-col justify-center">
|
{!compact && (
|
||||||
<p className="flex text-xs text-gray-500 justify-center">
|
<div className="flex flex-col justify-center">
|
||||||
{DateTime.fromMillis(h.timestamp / 1000000).toLocaleString(
|
<p className="flex text-xs text-gray-500 justify-center">
|
||||||
DateTime.TIME_WITH_SECONDS,
|
{DateTime.fromMillis(h.timestamp / 1000000).toLocaleString(
|
||||||
)}
|
DateTime.TIME_WITH_SECONDS,
|
||||||
</p>
|
)}
|
||||||
<p className="flex text-xs mt-1 text-gray-400 justify-center">
|
</p>
|
||||||
{DateTime.fromMillis(h.timestamp / 1000000).toLocaleString(
|
<p className="flex text-xs mt-1 text-gray-400 justify-center">
|
||||||
DateTime.DATE_SHORT,
|
{DateTime.fromMillis(h.timestamp / 1000000).toLocaleString(
|
||||||
)}
|
DateTime.DATE_SHORT,
|
||||||
</p>
|
)}
|
||||||
</div>
|
</p>
|
||||||
|
</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}
|
||||||
|
1
ui/src/components/home/QuickActions.tsx
Normal file
1
ui/src/components/home/QuickActions.tsx
Normal file
@ -0,0 +1 @@
|
|||||||
|
export default function QuickActions() {}
|
@ -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>
|
||||||
|
@ -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} />
|
||||||
|
@ -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: "" },
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
79
ui/src/components/ui/card.tsx
Normal file
79
ui/src/components/ui/card.tsx
Normal 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 }
|
363
ui/src/components/ui/chart.tsx
Normal file
363
ui/src/components/ui/chart.tsx
Normal 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,
|
||||||
|
}
|
@ -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";
|
||||||
|
|
||||||
|
@ -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>
|
||||||
<dt className="truncate text-sm font-medium text-gray-500">
|
</Card>
|
||||||
{item.name}
|
);
|
||||||
</dt>
|
}
|
||||||
<dd className="mt-1 text-xl font-semibold tracking-tight text-gray-900">
|
|
||||||
{item.stat}
|
function TopChart({ chartData }: any) {
|
||||||
</dd>
|
const chartConfig = {
|
||||||
</div>
|
command: {
|
||||||
))}
|
label: "Command",
|
||||||
</dl>
|
color: "#c4edde",
|
||||||
</div>
|
},
|
||||||
|
} satisfies ChartConfig;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartContainer config={chartConfig} className="max-h-72">
|
||||||
|
<BarChart
|
||||||
|
accessibilityLayer
|
||||||
|
data={chartData}
|
||||||
|
layout="vertical"
|
||||||
|
margin={{
|
||||||
|
right: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CartesianGrid horizontal={false} />
|
||||||
|
<YAxis
|
||||||
|
dataKey="command"
|
||||||
|
type="category"
|
||||||
|
tickLine={false}
|
||||||
|
tickMargin={10}
|
||||||
|
axisLine={false}
|
||||||
|
tickFormatter={(value) => value.slice(0, 3)}
|
||||||
|
hide
|
||||||
|
/>
|
||||||
|
<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,49 +198,97 @@ 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="p-10 grid grid-cols-4 gap-4">
|
||||||
|
<StatCard
|
||||||
|
name="Last Sync"
|
||||||
|
stat={
|
||||||
|
(homeInfo.lastSyncTime &&
|
||||||
|
formatRelative(homeInfo.lastSyncTime, new Date())) ||
|
||||||
|
"Never"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
name="Total Commands"
|
||||||
|
stat={homeInfo.historyCount.toLocaleString()}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
name="Total Runbooks"
|
||||||
|
stat={runbooks.length.toLocaleString()}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
name="Other Records"
|
||||||
|
stat={homeInfo.recordCount - homeInfo.historyCount}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="pt-10">
|
<Card shadow="sm" className="col-span-3">
|
||||||
<Stats
|
<CardHeader>
|
||||||
stats={[
|
<h2 className="uppercase text-gray-500">Activity graph</h2>
|
||||||
{
|
</CardHeader>
|
||||||
name: "Last Sync",
|
<CardBody>
|
||||||
stat:
|
<ActivityCalendar
|
||||||
(homeInfo.lastSyncTime &&
|
hideTotalCount
|
||||||
formatRelative(homeInfo.lastSyncTime, new Date())) ||
|
theme={explicitTheme}
|
||||||
"Never",
|
data={calendar}
|
||||||
},
|
weekStart={weekStart as any}
|
||||||
{
|
renderBlock={(block, activity) =>
|
||||||
name: "Total history records",
|
React.cloneElement(block, {
|
||||||
stat: homeInfo.historyCount.toLocaleString(),
|
"data-tooltip-id": "react-tooltip",
|
||||||
},
|
"data-tooltip-html": `${activity.count} commands on ${activity.date}`,
|
||||||
{
|
})
|
||||||
name: "Other records",
|
}
|
||||||
stat: homeInfo.recordCount - homeInfo.historyCount,
|
/>
|
||||||
},
|
<ReactTooltip id="react-tooltip" />
|
||||||
]}
|
</CardBody>
|
||||||
/>
|
</Card>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pt-10 flex justify-around">
|
<Card shadow="sm">
|
||||||
<ActivityCalendar
|
<CardHeader>
|
||||||
theme={explicitTheme}
|
<h2 className="uppercase text-gray-500">Quick actions </h2>
|
||||||
data={calendar}
|
</CardHeader>
|
||||||
weekStart={weekStart as any}
|
|
||||||
renderBlock={(block, activity) =>
|
<CardBody>
|
||||||
React.cloneElement(block, {
|
<Listbox variant="flat" aria-label="Quick actions">
|
||||||
"data-tooltip-id": "react-tooltip",
|
<ListboxItem
|
||||||
"data-tooltip-html": `${activity.count} commands on ${activity.date}`,
|
key="new-runbook"
|
||||||
})
|
description="Create an executable runbook"
|
||||||
}
|
startContent={<Terminal />}
|
||||||
labels={{
|
>
|
||||||
totalCount: "{{count}} history records in the last year",
|
New runbook
|
||||||
}}
|
</ListboxItem>
|
||||||
/>
|
<ListboxItem
|
||||||
<ReactTooltip id="react-tooltip" />
|
key="shell-history"
|
||||||
</div>
|
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>
|
||||||
);
|
);
|
||||||
|
@ -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 {
|
||||||
|
@ -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],
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
Loading…
Reference in New Issue
Block a user