From 7eb985b616c12aed261fbef74a47c5a928c03e61 Mon Sep 17 00:00:00 2001 From: Ellie Huxtable Date: Mon, 15 Jul 2024 19:12:01 +0100 Subject: feat(gui): add runbook list, ability to create and delete, sql storage (#2282) * wip * saving works :)) * functioning delete button * persist selection properly --- ui/src/App.tsx | 2 +- ui/src/components/runbooks/List.tsx | 120 ++++++++++++++++++++ ui/src/components/runbooks/editor/Editor.tsx | 94 +++++++++++++--- .../runbooks/editor/blocks/RunBlock/index.tsx | 23 ++-- ui/src/main.tsx | 6 +- ui/src/pages/Runbooks.tsx | 15 ++- ui/src/state/runbooks/runbook.ts | 124 +++++++++++++++++++++ ui/src/state/store.ts | 17 +++ 8 files changed, 371 insertions(+), 30 deletions(-) create mode 100644 ui/src/components/runbooks/List.tsx create mode 100644 ui/src/state/runbooks/runbook.ts (limited to 'ui/src') 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 (
-
+
icon 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 ( +
+
+ + + + + + } + > + {(runbook) => ( + { + setCurrentRunbook(runbook.id); + }} + textValue={runbook.name || "Untitled"} + endContent={ + + + + + + { + await Runbook.delete(runbook.id); + refreshRunbooks(); + }} + > + Delete + + + + } + > +
+
{runbook.name || "Untitled"}
+
+ + {DateTime.fromJSDate(runbook.updated).toLocaleString( + DateTime.DATETIME_SIMPLE, + )} + +
+
+
+ )} +
+
+
+ ); +}; + +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(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 ( -
- +
+ 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(code); const [pty, setPty] = useState(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 ( ); 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( -
- +
+
+ +
, ); 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 ( -
- +
+ + {currentRunbook && } + + {!currentRunbook && ( +
+

Select or create a runbook

+
+ )}
); } 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 { + 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 { + const db = await Database.load("sqlite:runbooks.db"); + + let res = await db.select("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 { + const db = await Database.load("sqlite:runbooks.db"); + + let res = await db.select( + "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()( -- cgit v1.3.1