feat(gui): add runbook list, ability to create and delete, sql storage (#2282)

* wip

* saving works :))

* functioning delete button

* persist selection properly
This commit is contained in:
Ellie Huxtable 2024-07-15 19:12:01 +01:00 committed by GitHub
parent 947305fc7f
commit 7eb985b616
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 4781 additions and 5216 deletions

90
ui/backend/Cargo.lock generated
View File

@ -140,6 +140,15 @@ version = "1.0.86"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da"
[[package]]
name = "approx"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
dependencies = [
"num-traits",
]
[[package]]
name = "argon2"
version = "0.5.3"
@ -349,6 +358,7 @@ dependencies = [
"base64 0.22.1",
"clap",
"config",
"crossterm",
"crypto_secretbox",
"directories",
"eyre",
@ -360,9 +370,11 @@ dependencies = [
"indicatif",
"interim",
"itertools",
"lazy_static",
"log",
"memchr",
"minspan",
"palette",
"rand 0.8.5",
"regex",
"reqwest 0.11.27",
@ -378,6 +390,8 @@ dependencies = [
"shellexpand",
"sql-builder",
"sqlx",
"strum",
"strum_macros",
"thiserror",
"time",
"tiny-bip39",
@ -609,6 +623,12 @@ version = "3.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c"
[[package]]
name = "by_address"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06"
[[package]]
name = "bytemuck"
version = "1.16.0"
@ -1686,6 +1706,12 @@ dependencies = [
"regex",
]
[[package]]
name = "fast-srgb8"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1"
[[package]]
name = "fastrand"
version = "2.1.0"
@ -1746,7 +1772,7 @@ checksum = "55ac459de2512911e4b674ce33cf20befaba382d05b62b008afc1c8b57cbf181"
dependencies = [
"futures-core",
"futures-sink",
"spin 0.9.8",
"spin",
]
[[package]]
@ -2950,11 +2976,11 @@ dependencies = [
[[package]]
name = "lazy_static"
version = "1.4.0"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
dependencies = [
"spin 0.5.2",
"spin",
]
[[package]]
@ -3686,6 +3712,31 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
[[package]]
name = "palette"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6"
dependencies = [
"approx",
"fast-srgb8",
"palette_derive",
"phf 0.11.2",
"serde",
]
[[package]]
name = "palette_derive"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30"
dependencies = [
"by_address",
"proc-macro2",
"quote",
"syn 2.0.66",
]
[[package]]
name = "pango"
version = "0.18.3"
@ -4473,7 +4524,7 @@ dependencies = [
"cfg-if",
"getrandom 0.2.15",
"libc",
"spin 0.9.8",
"spin",
"untrusted",
"windows-sys 0.52.0",
]
@ -5200,12 +5251,6 @@ dependencies = [
"system-deps",
]
[[package]]
name = "spin"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d"
[[package]]
name = "spin"
version = "0.9.8"
@ -5521,6 +5566,28 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"rustversion",
"syn 2.0.66",
]
[[package]]
name = "subtle"
version = "2.5.0"
@ -6484,6 +6551,7 @@ dependencies = [
"cocoa",
"comrak",
"eyre",
"lazy_static",
"nix 0.29.0",
"portable-pty",
"serde",

View File

@ -18,7 +18,7 @@ atuin-dotfiles = { path = "../../crates/atuin-dotfiles", version = "0.4.0" }
atuin-history = { path = "../../crates/atuin-history", version = "0.3.0" }
eyre = "0.6"
tauri = { version = "2.0.0-beta", features = [ "tray-icon"] }
tauri = { version = "2.0.0-beta", features = ["tray-icon"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
time = "0.3.36"
@ -33,6 +33,7 @@ portable-pty = "0.8.1"
vt100 = "0.15.2"
bytes = "1.6.0"
nix = "0.29.0"
lazy_static = "1.5.0"
[target."cfg(target_os = \"macos\")".dependencies]
cocoa = "0.25"

View File

@ -284,12 +284,16 @@ fn main() {
dotfiles::vars::delete_var,
dotfiles::vars::set_var,
])
.plugin(tauri_plugin_sql::Builder::default().build())
.plugin(tauri_plugin_sql::Builder::default().add_migrations("sqlite:runbooks.db", run::migrations::migrations()).build())
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_single_instance::init(|app, args, cwd| {
let _ = show_window(app);
}))
.manage(state::AtuinState::default())
.setup(|app|{
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View File

@ -0,0 +1,13 @@
use lazy_static::lazy_static;
use tauri_plugin_sql::{Builder, Migration, MigrationKind};
pub fn migrations() -> Vec<Migration> {
vec![
Migration {
version: 1,
description: "create_initial_tables",
sql: "CREATE TABLE runbooks(id string PRIMARY KEY, name TEXT, content TEXT, created bigint, updated bigint);",
kind: MigrationKind::Up,
}
]
}

View File

@ -1 +1,2 @@
pub mod migrations;
pub mod pty;

View File

@ -14,8 +14,6 @@
"title": "Atuin",
"width": 1200,
"height": 800,
"minWidth": 1000,
"minHeight": 500,
"titleBarStyle": "Overlay",
"hiddenTitle": true
}

View File

@ -10,9 +10,9 @@
"tauri": "tauri"
},
"dependencies": {
"@blocknote/core": "^0.14.5",
"@blocknote/mantine": "^0.14.2",
"@blocknote/react": "^0.14.6",
"@blocknote/core": "^0.15.0",
"@blocknote/mantine": "^0.15.0",
"@blocknote/react": "^0.15.0",
"@codemirror/autocomplete": "^6.16.3",
"@codemirror/commands": "^6.6.0",
"@codemirror/language": "^6.10.2",
@ -52,8 +52,8 @@
"react": "^18.3.1",
"react-activity-calendar": "^2.2.10",
"react-dom": "^18.3.1",
"react-spinners": "^0.14.1",
"react-router-dom": "^6.24.1",
"react-spinners": "^0.14.1",
"react-tooltip": "^5.27.0",
"react-window": "^1.8.10",
"react-window-infinite-loader": "^1.0.9",
@ -61,6 +61,7 @@
"tailwind-merge": "^2.4.0",
"tailwindcss-animate": "^1.0.7",
"usehooks-ts": "^3.1.0",
"uuidv7": "^1.0.1",
"vaul": "^0.9.1",
"zustand": "^4.5.2"
},

File diff suppressed because it is too large Load Diff

View File

@ -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" />

View File

@ -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;

View File

@ -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) =>

View File

@ -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}
/>
);

View File

@ -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>,
);

View File

@ -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>
);
}

View File

@ -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]);
}
}

View File

@ -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>()(