aboutsummaryrefslogtreecommitdiffstats
path: root/ui/src
diff options
context:
space:
mode:
authorEllie Huxtable <ellie@atuin.sh>2024-07-15 19:12:01 +0100
committerGitHub <noreply@github.com>2024-07-15 19:12:01 +0100
commit7eb985b616c12aed261fbef74a47c5a928c03e61 (patch)
treefe3088dc687c640176bf376b4ca8baf2088bd69f /ui/src
parentchore(deps): bump cachix/install-nix-action from 20 to 27 (#2268) (diff)
downloadatuin-7eb985b616c12aed261fbef74a47c5a928c03e61.zip
feat(gui): add runbook list, ability to create and delete, sql storage (#2282)
* wip * saving works :)) * functioning delete button * persist selection properly
Diffstat (limited to 'ui/src')
-rw-r--r--ui/src/App.tsx2
-rw-r--r--ui/src/components/runbooks/List.tsx120
-rw-r--r--ui/src/components/runbooks/editor/Editor.tsx94
-rw-r--r--ui/src/components/runbooks/editor/blocks/RunBlock/index.tsx23
-rw-r--r--ui/src/main.tsx6
-rw-r--r--ui/src/pages/Runbooks.tsx15
-rw-r--r--ui/src/state/runbooks/runbook.ts124
-rw-r--r--ui/src/state/store.ts17
8 files changed, 371 insertions, 30 deletions
diff --git a/ui/src/App.tsx b/ui/src/App.tsx
index 7a9ac395..2f804b1b 100644
--- a/ui/src/App.tsx
+++ b/ui/src/App.tsx
@@ -131,7 +131,7 @@ function App() {
return (
<div className="flex h-dvh w-full select-none">
- <div className="relative flex h-full flex-col !border-r-small border-divider p-6 transition-width px-2 pb-6 pt-9 w-16 items-center">
+ <div className="relative flex h-full flex-col !border-r-small border-divider transition-width pb-6 pt-9 min-w-[4.5rem] items-center">
<div className="flex items-center gap-0 px-3 justify-center">
<div className="flex h-8 w-8">
<img src={icon} alt="icon" className="h-8 w-8" />
diff --git a/ui/src/components/runbooks/List.tsx b/ui/src/components/runbooks/List.tsx
new file mode 100644
index 00000000..be6e84f5
--- /dev/null
+++ b/ui/src/components/runbooks/List.tsx
@@ -0,0 +1,120 @@
+import { useEffect, useState } from "react";
+import {
+ Input,
+ Button,
+ ButtonGroup,
+ Card,
+ CardBody,
+ CardHeader,
+ Divider,
+ Tooltip,
+ Listbox,
+ ListboxItem,
+ Dropdown,
+ DropdownTrigger,
+ DropdownMenu,
+ DropdownItem,
+} from "@nextui-org/react";
+
+import { EllipsisVerticalIcon } from "lucide-react";
+
+import { DateTime } from "luxon";
+
+import { NotebookPenIcon } from "lucide-react";
+import Runbook from "@/state/runbooks/runbook";
+import { useStore } from "@/state/store";
+
+const NoteSidebar = () => {
+ const runbooks = useStore((state) => state.runbooks);
+ const refreshRunbooks = useStore((state) => state.refreshRunbooks);
+
+ const currentRunbook = useStore((state) => state.currentRunbook);
+ const setCurrentRunbook = useStore((state) => state.setCurrentRunbook);
+
+ useEffect(() => {
+ refreshRunbooks();
+ }, []);
+
+ return (
+ <div className="min-w-48 h-screen flex flex-col border-r-1">
+ <div className="overflow-y-auto flex-grow">
+ <Listbox
+ hideSelectedIcon
+ items={runbooks}
+ variant="flat"
+ aria-label="Runbook list"
+ selectionMode="single"
+ selectedKeys={[currentRunbook]}
+ itemClasses={{ base: "data-[selected=true]:bg-gray-200" }}
+ topContent={
+ <ButtonGroup>
+ <Tooltip showArrow content="New Runbook" closeDelay={50}>
+ <Button
+ isIconOnly
+ aria-label="New note"
+ variant="light"
+ size="sm"
+ onPress={async () => {
+ let runbook = await Runbook.create();
+ setCurrentRunbook(runbook.id);
+ refreshRunbooks();
+ }}
+ >
+ <NotebookPenIcon className="p-[0.15rem]" />
+ </Button>
+ </Tooltip>
+ </ButtonGroup>
+ }
+ >
+ {(runbook) => (
+ <ListboxItem
+ key={runbook.id}
+ onPress={() => {
+ setCurrentRunbook(runbook.id);
+ }}
+ textValue={runbook.name || "Untitled"}
+ endContent={
+ <Dropdown>
+ <DropdownTrigger className="bg-transparent">
+ <Button isIconOnly>
+ <EllipsisVerticalIcon
+ size="16px"
+ className="bg-transparent"
+ />
+ </Button>
+ </DropdownTrigger>
+ <DropdownMenu aria-label="Dynamic Actions">
+ <DropdownItem
+ key={"delete"}
+ color="danger"
+ className="text-danger"
+ onPress={async () => {
+ await Runbook.delete(runbook.id);
+ refreshRunbooks();
+ }}
+ >
+ Delete
+ </DropdownItem>
+ </DropdownMenu>
+ </Dropdown>
+ }
+ >
+ <div className="flex flex-col">
+ <div className="text-md">{runbook.name || "Untitled"}</div>
+ <div className="text-xs text-gray-500">
+ <em>
+ {DateTime.fromJSDate(runbook.updated).toLocaleString(
+ DateTime.DATETIME_SIMPLE,
+ )}
+ </em>
+ </div>
+ </div>
+ </ListboxItem>
+ )}
+ </Listbox>
+ </div>
+ </div>
+ );
+};
+
+export default NoteSidebar;
diff --git a/ui/src/components/runbooks/editor/Editor.tsx b/ui/src/components/runbooks/editor/Editor.tsx
index 3f05d9f3..8ed52660 100644
--- a/ui/src/components/runbooks/editor/Editor.tsx
+++ b/ui/src/components/runbooks/editor/Editor.tsx
@@ -1,13 +1,17 @@
+import { useEffect, useMemo, useState } from "react";
+
import "@blocknote/core/fonts/inter.css";
import "@blocknote/mantine/style.css";
import "./index.css";
import {
BlockNoteSchema,
+ BlockNoteEditor,
defaultBlockSpecs,
filterSuggestionItems,
insertOrUpdateBlock,
} from "@blocknote/core";
+
import "@blocknote/core/fonts/inter.css";
import {
@@ -21,9 +25,12 @@ import {
import { BlockNoteView } from "@blocknote/mantine";
import { Code } from "lucide-react";
+import { useDebounceCallback } from "usehooks-ts";
import RunBlock from "@/components/runbooks/editor/blocks/RunBlock";
import { DeleteBlock } from "@/components/runbooks/editor/ui/DeleteBlockButton";
+import { useStore } from "@/state/store";
+import Runbook from "@/state/runbooks/runbook";
// Our schema with block specs, which contain the configs and implementations for blocks
// that we want our editor to use.
@@ -51,26 +58,79 @@ const insertRun = (editor: typeof schema.BlockNoteEditor) => ({
});
export default function Editor() {
- // Creates a new editor instance.
- const editor = useCreateBlockNote({
- schema,
- initialContent: [
- {
- type: "heading",
- content: "Atuin runbooks",
- id: "foo",
- },
- {
- type: "run",
- id: "bar",
- },
- ],
- });
+ const runbookId = useStore((store) => store.currentRunbook);
+ const refreshRunbooks = useStore((store) => store.refreshRunbooks);
+ let [runbook, setRunbook] = useState<Runbook | null>(null);
+
+ useEffect(() => {
+ if (!runbookId) return;
+
+ const fetchRunbook = async () => {
+ let rb = await Runbook.load(runbookId);
+
+ setRunbook(rb);
+ };
+
+ fetchRunbook();
+ }, [runbookId]);
+
+ const editor = useMemo(() => {
+ if (!runbook) {
+ return undefined;
+ }
+
+ if (runbook.content) {
+ return BlockNoteEditor.create({
+ initialContent: JSON.parse(runbook.content),
+ schema,
+ });
+ }
+
+ return BlockNoteEditor.create({ schema });
+ }, [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 => {
+ // Infer the title from the first text block
+
+ let blocks = editor.document;
+ for (const block of blocks) {
+ if (block.type == "heading" || block.type == "paragraph") {
+ if (block.content.length == 0) continue;
+ if (block.content[0].text.length == 0) continue;
+
+ return block.content[0].text;
+ }
+ }
+
+ return "Untitled";
+ };
+
+ if (editor === undefined) {
+ return "Loading content...";
+ }
// Renders the editor instance.
return (
- <div>
- <BlockNoteView editor={editor} slashMenu={false} sideMenu={false}>
+ <div className="p-4 w-full">
+ <BlockNoteView
+ editor={editor}
+ slashMenu={false}
+ sideMenu={false}
+ onChange={debouncedOnChange}
+ >
<SuggestionMenuController
triggerCharacter={"/"}
getItems={async (query) =>
diff --git a/ui/src/components/runbooks/editor/blocks/RunBlock/index.tsx b/ui/src/components/runbooks/editor/blocks/RunBlock/index.tsx
index 78928876..9b2fe515 100644
--- a/ui/src/components/runbooks/editor/blocks/RunBlock/index.tsx
+++ b/ui/src/components/runbooks/editor/blocks/RunBlock/index.tsx
@@ -23,17 +23,20 @@ interface RunBlockProps {
isEditable: boolean;
}
-const RunBlock = ({ onPlay, id, code, isEditable }: RunBlockProps) => {
+const RunBlock = ({
+ onChange,
+ onPlay,
+ id,
+ code,
+ isEditable,
+}: RunBlockProps) => {
+ console.log(code);
const [isRunning, setIsRunning] = useState(false);
const [showTerminal, setShowTerminal] = useState(false);
const [value, setValue] = useState<String>(code);
const [pty, setPty] = useState<string | null>(null);
- const onChange = (val: any) => {
- setValue(val);
- };
-
const handleToggle = async (event: any) => {
event.stopPropagation();
@@ -88,7 +91,10 @@ const RunBlock = ({ onPlay, id, code, isEditable }: RunBlockProps) => {
editable={isEditable}
width="100%"
autoFocus
- onChange={onChange}
+ onChange={(val) => {
+ setValue(val);
+ onChange(val);
+ }}
extensions={[...extensions(), langs.shell()]}
basicSetup={false}
/>
@@ -123,14 +129,15 @@ export default createReactBlockSpec(
editor.updateBlock(block, {
props: { ...block.props, code: val },
});
+ console.log(block.props);
};
return (
<RunBlock
onChange={onInputChange}
id={block?.id}
- code={code}
- type={type}
+ code={block.props.code}
+ type={block.props.type}
isEditable={editor.isEditable}
/>
);
diff --git a/ui/src/main.tsx b/ui/src/main.tsx
index dc4c24a3..074552f7 100644
--- a/ui/src/main.tsx
+++ b/ui/src/main.tsx
@@ -7,8 +7,10 @@ import "./styles.css";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<NextUIProvider>
- <div data-tauri-drag-region className="w-full h-8 absolute" />
- <App />
+ <main className="text-foreground bg-background">
+ <div data-tauri-drag-region className="w-full h-8 absolute" />
+ <App />
+ </main>
</NextUIProvider>
</React.StrictMode>,
);
diff --git a/ui/src/pages/Runbooks.tsx b/ui/src/pages/Runbooks.tsx
index 4237e065..0452a578 100644
--- a/ui/src/pages/Runbooks.tsx
+++ b/ui/src/pages/Runbooks.tsx
@@ -1,9 +1,20 @@
import Editor from "@/components/runbooks/editor/Editor";
+import List from "@/components/runbooks/List";
+import { useStore } from "@/state/store";
export default function Runbooks() {
+ const currentRunbook = useStore((store) => store.currentRunbook);
+
return (
- <div className="w-full flex-1 flex-col p-4">
- <Editor />
+ <div className="w-full flex flex-row ">
+ <List />
+ {currentRunbook && <Editor />}
+
+ {!currentRunbook && (
+ <div className="flex align-middle justify-center flex-col h-screen w-full">
+ <h1 className="text-center">Select or create a runbook</h1>
+ </div>
+ )}
</div>
);
}
diff --git a/ui/src/state/runbooks/runbook.ts b/ui/src/state/runbooks/runbook.ts
new file mode 100644
index 00000000..8555f4ea
--- /dev/null
+++ b/ui/src/state/runbooks/runbook.ts
@@ -0,0 +1,124 @@
+import Database from "@tauri-apps/plugin-sql";
+import { uuidv7 } from "uuidv7";
+
+export default class Runbook {
+ id: string;
+ created: Date;
+ updated: Date;
+
+ private _name: string;
+ private _content: string;
+
+ set name(value: string) {
+ this.updated = new Date();
+ this._name = value;
+ }
+
+ set content(value: string) {
+ this.updated = new Date();
+ this._content = value;
+ }
+
+ get content() {
+ return this._content;
+ }
+
+ get name() {
+ return this._name;
+ }
+
+ constructor(
+ id: string,
+ name: string,
+ content: string,
+ created: Date,
+ updated: Date,
+ ) {
+ this.id = id;
+ this._name = name;
+ this._content = content;
+ this.created = created;
+ this.updated = updated;
+ }
+
+ /// Create a new Runbook, and automatically generate an ID.
+ public static async create(): Promise<Runbook> {
+ let now = new Date();
+
+ // Initialize with the same value for created/updated, to avoid needing null.
+ let runbook = new Runbook(uuidv7(), "", "", now, now);
+ await runbook.save();
+
+ return runbook;
+ }
+
+ public static async load(id: String): Promise<Runbook | null> {
+ const db = await Database.load("sqlite:runbooks.db");
+
+ let res = await db.select<any[]>("select * from runbooks where id = $1", [
+ id,
+ ]);
+
+ if (res.length == 0) return null;
+
+ let rb = res[0];
+
+ return new Runbook(
+ rb.id,
+ rb.name,
+ rb.content,
+ new Date(rb.created / 1000000),
+ new Date(rb.updated / 1000000),
+ );
+ }
+
+ static async all(): Promise<Runbook[]> {
+ const db = await Database.load("sqlite:runbooks.db");
+
+ let res = await db.select<any[]>(
+ "select * from runbooks order by updated desc",
+ );
+
+ return res.map((i) => {
+ return new Runbook(
+ i.id,
+ i.name,
+ i.content,
+ new Date(i.created / 1000000),
+ new Date(i.updated / 1000000),
+ );
+ });
+ }
+
+ public async save() {
+ const db = await Database.load("sqlite:runbooks.db");
+
+ await db.execute(
+ `insert into runbooks(id, name, content, created, updated)
+ values ($1, $2, $3, $4, $5)
+
+ on conflict(id) do update
+ set
+ name=$2,
+ content=$3,
+ updated=$5`,
+
+ // getTime returns a timestamp as unix milliseconds
+ // we won't need or use the resolution here, but elsewhere Atuin stores timestamps in sqlite as nanoseconds since epoch
+ // let's do that across the board to avoid mistakes
+ [
+ this.id,
+ this._name,
+ this._content,
+ this.created.getTime() * 1000000,
+ this.updated.getTime() * 1000000,
+ ],
+ );
+ }
+
+ public static async delete(id: string) {
+ const db = await Database.load("sqlite:runbooks.db");
+
+ await db.execute("delete from runbooks where id=$1", [id]);
+ }
+}
diff --git a/ui/src/state/store.ts b/ui/src/state/store.ts
index 1e835cbd..cde2da17 100644
--- a/ui/src/state/store.ts
+++ b/ui/src/state/store.ts
@@ -18,6 +18,7 @@ import {
import { invoke } from "@tauri-apps/api/core";
import { sessionToken, settings } from "./client";
import { getWeekInfo } from "@/lib/utils";
+import Runbook from "./runbooks/runbook";
// I'll probs want to slice this up at some point, but for now a
// big blobby lump of state is fine.
@@ -30,14 +31,19 @@ interface AtuinState {
shellHistory: ShellHistory[];
calendar: any[];
weekStart: number;
+ runbooks: Runbook[];
+ currentRunbook: String | null;
refreshHomeInfo: () => void;
refreshCalendar: () => void;
refreshAliases: () => void;
refreshVars: () => void;
refreshUser: () => void;
+ refreshRunbooks: () => void;
refreshShellHistory: (query?: string) => void;
historyNextPage: (query?: string) => void;
+
+ setCurrentRunbook: (id: String) => void;
}
let state = (set: any, get: any): AtuinState => ({
@@ -47,6 +53,8 @@ let state = (set: any, get: any): AtuinState => ({
vars: [],
shellHistory: [],
calendar: [],
+ runbooks: [],
+ currentRunbook: "",
weekStart: getWeekInfo().firstDay,
@@ -68,6 +76,11 @@ let state = (set: any, get: any): AtuinState => ({
});
},
+ refreshRunbooks: async () => {
+ let runbooks = await Runbook.all();
+ set({ runbooks });
+ },
+
refreshShellHistory: (query?: string) => {
if (query) {
invoke("search", { query: query })
@@ -141,6 +154,10 @@ let state = (set: any, get: any): AtuinState => ({
});
}
},
+
+ setCurrentRunbook: (id: String) => {
+ set({ currentRunbook: id });
+ },
});
export const useStore = create<AtuinState>()(