aboutsummaryrefslogtreecommitdiffstats
path: root/ui/src
diff options
context:
space:
mode:
Diffstat (limited to 'ui/src')
-rw-r--r--ui/src/App.tsx12
-rw-r--r--ui/src/components/runbooks/editor/Editor.tsx91
-rw-r--r--ui/src/components/runbooks/editor/blocks/RunBlock/extensions.ts158
-rw-r--r--ui/src/components/runbooks/editor/blocks/RunBlock/index.css9
-rw-r--r--ui/src/components/runbooks/editor/blocks/RunBlock/index.tsx146
-rw-r--r--ui/src/components/runbooks/editor/blocks/RunBlock/terminal.tsx82
-rw-r--r--ui/src/components/runbooks/editor/index.css7
-rw-r--r--ui/src/components/runbooks/editor/ui/DeleteBlockButton.tsx28
-rw-r--r--ui/src/pages/Home.tsx18
-rw-r--r--ui/src/pages/Runbooks.tsx9
-rw-r--r--ui/src/state/store.ts6
11 files changed, 559 insertions, 7 deletions
diff --git a/ui/src/App.tsx b/ui/src/App.tsx
index c643720a..6dba3d71 100644
--- a/ui/src/App.tsx
+++ b/ui/src/App.tsx
@@ -13,6 +13,9 @@ import {
ClockIcon,
WrenchScrewdriverIcon,
} from "@heroicons/react/24/outline";
+
+import { ChevronRightSquare } from "lucide-react";
+
import Logo from "./assets/logo-light.svg";
function classNames(...classes: any) {
@@ -23,11 +26,13 @@ import Home from "./pages/Home.tsx";
import History from "./pages/History.tsx";
import Dotfiles from "./pages/Dotfiles.tsx";
import LoginOrRegister from "./components/LoginOrRegister.tsx";
+import Runbooks from "./pages/Runbooks.tsx";
enum Section {
Home,
History,
Dotfiles,
+ Runbooks,
}
function renderMain(section: Section): ReactElement {
@@ -38,6 +43,8 @@ function renderMain(section: Section): ReactElement {
return <History />;
case Section.Dotfiles:
return <Dotfiles />;
+ case Section.Runbooks:
+ return <Runbooks />;
}
}
@@ -65,6 +72,11 @@ function App() {
icon: WrenchScrewdriverIcon,
section: Section.Dotfiles,
},
+ {
+ name: "Runbooks",
+ icon: ChevronRightSquare,
+ section: Section.Runbooks,
+ },
];
return (
diff --git a/ui/src/components/runbooks/editor/Editor.tsx b/ui/src/components/runbooks/editor/Editor.tsx
new file mode 100644
index 00000000..81ea84cf
--- /dev/null
+++ b/ui/src/components/runbooks/editor/Editor.tsx
@@ -0,0 +1,91 @@
+import "@blocknote/core/fonts/inter.css";
+import "@blocknote/mantine/style.css";
+import "./index.css";
+
+import {
+ BlockNoteSchema,
+ defaultBlockSpecs,
+ filterSuggestionItems,
+ insertOrUpdateBlock,
+} from "@blocknote/core";
+import "@blocknote/core/fonts/inter.css";
+
+import {
+ SuggestionMenuController,
+ AddBlockButton,
+ getDefaultReactSlashMenuItems,
+ useCreateBlockNote,
+ SideMenu,
+ SideMenuController,
+} from "@blocknote/react";
+import { BlockNoteView } from "@blocknote/mantine";
+
+import { Code } from "lucide-react";
+
+import RunBlock from "@/components/runbooks/editor/blocks/RunBlock";
+import { DeleteBlock } from "@/components/runbooks/editor/ui/DeleteBlockButton";
+
+// Our schema with block specs, which contain the configs and implementations for blocks
+// that we want our editor to use.
+const schema = BlockNoteSchema.create({
+ blockSpecs: {
+ // Adds all default blocks.
+ ...defaultBlockSpecs,
+
+ // Adds the code block.
+ run: RunBlock,
+ },
+});
+
+// Slash menu item to insert an Alert block
+const insertRun = (editor: typeof schema.BlockNoteEditor) => ({
+ title: "Code block",
+ onItemClick: () => {
+ insertOrUpdateBlock(editor, {
+ type: "run",
+ });
+ },
+ icon: <Code size={18} />,
+ aliases: ["code", "run"],
+ group: "Code",
+});
+
+export default function Editor() {
+ // Creates a new editor instance.
+ const editor = useCreateBlockNote({
+ schema,
+ initialContent: [
+ {
+ type: "heading",
+ content: "Atuin runbooks",
+ id: "foo",
+ },
+ ],
+ });
+
+ // Renders the editor instance.
+ return (
+ <div>
+ <BlockNoteView editor={editor} slashMenu={false} sideMenu={false}>
+ <SuggestionMenuController
+ triggerCharacter={"/"}
+ getItems={async (query) =>
+ filterSuggestionItems(
+ [...getDefaultReactSlashMenuItems(editor), insertRun(editor)],
+ query,
+ )
+ }
+ />
+
+ <SideMenuController
+ sideMenu={(props) => (
+ <SideMenu {...props}>
+ <AddBlockButton {...props} />
+ <DeleteBlock {...props} />
+ </SideMenu>
+ )}
+ />
+ </BlockNoteView>
+ </div>
+ );
+}
diff --git a/ui/src/components/runbooks/editor/blocks/RunBlock/extensions.ts b/ui/src/components/runbooks/editor/blocks/RunBlock/extensions.ts
new file mode 100644
index 00000000..76fc4343
--- /dev/null
+++ b/ui/src/components/runbooks/editor/blocks/RunBlock/extensions.ts
@@ -0,0 +1,158 @@
+// Based on the basicSetup extension, as suggested by the source. Customized for Atuin.
+
+import {
+ KeyBinding,
+ lineNumbers,
+ highlightActiveLineGutter,
+ highlightSpecialChars,
+ drawSelection,
+ dropCursor,
+ rectangularSelection,
+ crosshairCursor,
+ highlightActiveLine,
+ keymap,
+} from "@codemirror/view";
+import { EditorState, Extension } from "@codemirror/state";
+import { history, defaultKeymap, historyKeymap } from "@codemirror/commands";
+import { highlightSelectionMatches, searchKeymap } from "@codemirror/search";
+
+import {
+ closeBrackets,
+ autocompletion,
+ closeBracketsKeymap,
+ completionKeymap,
+ CompletionContext,
+} from "@codemirror/autocomplete";
+
+import {
+ foldGutter,
+ indentOnInput,
+ syntaxHighlighting,
+ defaultHighlightStyle,
+ bracketMatching,
+ indentUnit,
+ foldKeymap,
+} from "@codemirror/language";
+
+import { lintKeymap } from "@codemirror/lint";
+import { invoke } from "@tauri-apps/api/core";
+
+export interface MinimalSetupOptions {
+ highlightSpecialChars?: boolean;
+ history?: boolean;
+ drawSelection?: boolean;
+ syntaxHighlighting?: boolean;
+
+ defaultKeymap?: boolean;
+ historyKeymap?: boolean;
+}
+
+export interface BasicSetupOptions extends MinimalSetupOptions {
+ lineNumbers?: boolean;
+ highlightActiveLineGutter?: boolean;
+ foldGutter?: boolean;
+ dropCursor?: boolean;
+ allowMultipleSelections?: boolean;
+ indentOnInput?: boolean;
+ bracketMatching?: boolean;
+ closeBrackets?: boolean;
+ autocompletion?: boolean;
+ rectangularSelection?: boolean;
+ crosshairCursor?: boolean;
+ highlightActiveLine?: boolean;
+ highlightSelectionMatches?: boolean;
+
+ closeBracketsKeymap?: boolean;
+ searchKeymap?: boolean;
+ foldKeymap?: boolean;
+ completionKeymap?: boolean;
+ lintKeymap?: boolean;
+ tabSize?: number;
+}
+
+function myCompletions(context: CompletionContext) {
+ let word = context.matchBefore(/^.*/);
+
+ if (!word) return null;
+ if (word.from == word.to && !context.explicit) return null;
+
+ return invoke("prefix_search", { query: word.text }).then(
+ // @ts-ignore
+ (results: string[]) => {
+ let options = results.map((i) => {
+ return { label: i, type: "text" };
+ });
+
+ return {
+ from: word.from,
+ options,
+ };
+ },
+ );
+}
+
+const buildAutocomplete = (): Extension => {
+ let ac = autocompletion({ override: [myCompletions] });
+
+ return ac;
+};
+
+export const extensions = (options: BasicSetupOptions = {}): Extension[] => {
+ const { crosshairCursor: initCrosshairCursor = false } = options;
+
+ let keymaps: KeyBinding[] = [];
+ if (options.closeBracketsKeymap !== false) {
+ keymaps = keymaps.concat(closeBracketsKeymap);
+ }
+ if (options.defaultKeymap !== false) {
+ keymaps = keymaps.concat(defaultKeymap);
+ }
+ if (options.searchKeymap !== false) {
+ keymaps = keymaps.concat(searchKeymap);
+ }
+ if (options.historyKeymap !== false) {
+ keymaps = keymaps.concat(historyKeymap);
+ }
+ if (options.foldKeymap !== false) {
+ keymaps = keymaps.concat(foldKeymap);
+ }
+ if (options.completionKeymap !== false) {
+ keymaps = keymaps.concat(completionKeymap);
+ }
+ if (options.lintKeymap !== false) {
+ keymaps = keymaps.concat(lintKeymap);
+ }
+ const extensions: Extension[] = [];
+ if (options.lineNumbers !== false) extensions.push(lineNumbers());
+ if (options.highlightActiveLineGutter !== false)
+ extensions.push(highlightActiveLineGutter());
+ if (options.highlightSpecialChars !== false)
+ extensions.push(highlightSpecialChars());
+ if (options.history !== false) extensions.push(history());
+ if (options.foldGutter !== false) extensions.push(foldGutter());
+ if (options.drawSelection !== false) extensions.push(drawSelection());
+ if (options.dropCursor !== false) extensions.push(dropCursor());
+ if (options.allowMultipleSelections !== false)
+ extensions.push(EditorState.allowMultipleSelections.of(true));
+ if (options.indentOnInput !== false) extensions.push(indentOnInput());
+ if (options.syntaxHighlighting !== false)
+ extensions.push(
+ syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
+ );
+
+ if (options.bracketMatching !== false) extensions.push(bracketMatching());
+ if (options.closeBrackets !== false) extensions.push(closeBrackets());
+ if (options.autocompletion !== false) extensions.push(buildAutocomplete());
+
+ if (options.rectangularSelection !== false)
+ extensions.push(rectangularSelection());
+ if (initCrosshairCursor !== false) extensions.push(crosshairCursor());
+ if (options.highlightActiveLine !== false)
+ extensions.push(highlightActiveLine());
+ if (options.highlightSelectionMatches !== false)
+ extensions.push(highlightSelectionMatches());
+ if (options.tabSize && typeof options.tabSize === "number")
+ extensions.push(indentUnit.of(" ".repeat(options.tabSize)));
+
+ return extensions.concat([keymap.of(keymaps.flat())]).filter(Boolean);
+};
diff --git a/ui/src/components/runbooks/editor/blocks/RunBlock/index.css b/ui/src/components/runbooks/editor/blocks/RunBlock/index.css
new file mode 100644
index 00000000..e854c03b
--- /dev/null
+++ b/ui/src/components/runbooks/editor/blocks/RunBlock/index.css
@@ -0,0 +1,9 @@
+ProseMirror-focused {
+ outline: none !important;
+ box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1) !important;
+}
+
+.cm-editor.cm-focused {
+ outline: none !important;
+ box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1) !important;
+}
diff --git a/ui/src/components/runbooks/editor/blocks/RunBlock/index.tsx b/ui/src/components/runbooks/editor/blocks/RunBlock/index.tsx
new file mode 100644
index 00000000..a5697234
--- /dev/null
+++ b/ui/src/components/runbooks/editor/blocks/RunBlock/index.tsx
@@ -0,0 +1,146 @@
+import { createReactBlockSpec } from "@blocknote/react";
+import "./index.css";
+
+import CodeMirror from "@uiw/react-codemirror";
+import { langs } from "@uiw/codemirror-extensions-langs";
+
+import { Play, Square } from "lucide-react";
+import { useState } from "react";
+
+import { extensions } from "./extensions";
+import { invoke } from "@tauri-apps/api/core";
+import Terminal from "./terminal.tsx";
+
+import "@xterm/xterm/css/xterm.css";
+
+interface RunBlockProps {
+ onChange: (val: string) => void;
+ onPlay?: () => void;
+ onStop?: () => void;
+ id: string;
+ code: string;
+ type: string;
+ isEditable: boolean;
+}
+
+const RunBlock = ({ onPlay, id, code, isEditable }: RunBlockProps) => {
+ 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();
+
+ // If there's no code, don't do anything
+ if (!value) return;
+
+ setIsRunning(!isRunning);
+ setShowTerminal(!isRunning);
+
+ if (isRunning) {
+ // send sigkill
+ console.log("sending sigkill");
+ await invoke("pty_kill", { pid: pty });
+ }
+
+ if (!isRunning) {
+ if (onPlay) onPlay();
+
+ let pty = await invoke<string>("pty_open");
+ setPty(pty);
+ console.log(pty);
+
+ let val = !value.endsWith("\n") ? value + "\n" : value;
+ await invoke("pty_write", { pid: pty, data: val });
+ }
+ };
+
+ return (
+ <div className="w-full !outline-none">
+ <div className="flex items-start">
+ <button
+ onClick={handleToggle}
+ className={`flex items-center justify-center flex-shrink-0 w-8 h-8 mr-2 rounded border focus:outline-none focus:ring-2 transition-all duration-300 ease-in-out ${
+ isRunning
+ ? "border-red-200 bg-red-50 text-red-600 hover:bg-red-100 hover:border-red-300 focus:ring-red-300"
+ : "border-green-200 bg-green-50 text-green-600 hover:bg-green-100 hover:border-green-300 focus:ring-green-300"
+ }`}
+ aria-label={isRunning ? "Stop code" : "Run code"}
+ >
+ <span
+ className={`inline-block transition-transform duration-300 ease-in-out ${isRunning ? "rotate-180" : ""}`}
+ >
+ {isRunning ? <Square size={16} /> : <Play size={16} />}
+ </span>
+ </button>
+ <div className="flex-grow">
+ <CodeMirror
+ id={id}
+ placeholder={"Write your code here..."}
+ className="!pt-0 border border-gray-300 rounded"
+ value={code}
+ editable={isEditable}
+ width="100%"
+ autoFocus
+ onChange={onChange}
+ extensions={[...extensions(), langs.shell()]}
+ basicSetup={false}
+ />
+ <div
+ className={`overflow-hidden transition-all duration-300 ease-in-out ${
+ showTerminal ? "block" : "hidden"
+ }`}
+ >
+ {pty && <Terminal pty={pty} />}
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+};
+
+export default createReactBlockSpec(
+ {
+ type: "run",
+ propSchema: {
+ type: {
+ default: "bash",
+ },
+ code: { default: "" },
+ },
+ content: "none",
+ },
+ {
+ // @ts-ignore
+ render: ({ block, editor, code, type }) => {
+ const onInputChange = (val: string) => {
+ editor.updateBlock(block, {
+ props: { ...block.props, code: val },
+ });
+ };
+
+ return (
+ <RunBlock
+ onChange={onInputChange}
+ id={block?.id}
+ code={code}
+ type={type}
+ isEditable={editor.isEditable}
+ />
+ );
+ },
+ toExternalHTML: ({ block }) => {
+ return (
+ <pre lang="beep boop">
+ <code lang="bash">{block?.props?.code}</code>
+ </pre>
+ );
+ },
+ },
+);
diff --git a/ui/src/components/runbooks/editor/blocks/RunBlock/terminal.tsx b/ui/src/components/runbooks/editor/blocks/RunBlock/terminal.tsx
new file mode 100644
index 00000000..e5ca0fca
--- /dev/null
+++ b/ui/src/components/runbooks/editor/blocks/RunBlock/terminal.tsx
@@ -0,0 +1,82 @@
+/*
+export const openTerm = (pty: string, id: string) => {
+ const term = new Terminal({
+ fontSize: 12,
+ fontFamily: "Courier New",
+ });
+
+ let element = document.getElementById(id);
+ term.open(element);
+
+ //term.onResize(onResize(pty));
+
+ //const fitAddon = new FitAddon();
+ //term.loadAddon(fitAddon);
+ //term.loadAddon(new WebglAddon());
+
+ /*
+ const onSize = (e) => {
+ e.stopPropagation();
+ fitAddon.fit();
+ };
+ fitAddon.fit();
+
+ window.addEventListener("resize", onSize, false);
+ */
+
+import { useEffect, useRef } from "react";
+import { Terminal } from "@xterm/xterm";
+import { listen } from "@tauri-apps/api/event";
+import { FitAddon } from "@xterm/addon-fit";
+import { WebglAddon } from "@xterm/addon-webgl";
+import "@xterm/xterm/css/xterm.css";
+import { invoke } from "@tauri-apps/api/core";
+
+const onResize = (pty: string) => async (size: any) => {
+ await invoke("pty_resize", {
+ pid: pty,
+ cols: size.cols,
+ rows: size.rows,
+ });
+};
+
+const TerminalComponent = ({ pty }: any) => {
+ const terminalRef = useRef(null);
+
+ useEffect(() => {
+ if (pty == null) return;
+ if (!terminalRef.current) return;
+
+ const terminal = new Terminal();
+ const fitAddon = new FitAddon();
+
+ terminal.open(terminalRef.current);
+ terminal.loadAddon(new WebglAddon());
+ terminal.loadAddon(fitAddon);
+ terminal.onResize(onResize(pty));
+
+ const windowResize = () => {
+ fitAddon.fit();
+ };
+
+ listen(`pty-${pty}`, (event: any) => {
+ terminal.write(event.payload);
+ }).then(() => {
+ console.log("Listening for pty events");
+ });
+
+ window.addEventListener("resize", windowResize);
+
+ fitAddon.fit();
+
+ // Customize further as needed
+ return () => {
+ terminal.dispose();
+ window.removeEventListener("resize", windowResize);
+ };
+ }, [pty]);
+
+ return <div ref={terminalRef} />;
+};
+
+export default TerminalComponent;
diff --git a/ui/src/components/runbooks/editor/index.css b/ui/src/components/runbooks/editor/index.css
new file mode 100644
index 00000000..067cc500
--- /dev/null
+++ b/ui/src/components/runbooks/editor/index.css
@@ -0,0 +1,7 @@
+.editor a {
+ color: #0000ee;
+}
+
+.editor a:hover {
+ cursor: pointer;
+}
diff --git a/ui/src/components/runbooks/editor/ui/DeleteBlockButton.tsx b/ui/src/components/runbooks/editor/ui/DeleteBlockButton.tsx
new file mode 100644
index 00000000..84a9f5c8
--- /dev/null
+++ b/ui/src/components/runbooks/editor/ui/DeleteBlockButton.tsx
@@ -0,0 +1,28 @@
+import {
+ SideMenuProps,
+ useBlockNoteEditor,
+ useComponentsContext,
+} from "@blocknote/react";
+import { TrashIcon } from "lucide-react";
+
+// Custom Side Menu button to remove the hovered block.
+export function DeleteBlock(props: SideMenuProps) {
+ const editor = useBlockNoteEditor();
+
+ const Components = useComponentsContext()!;
+
+ return (
+ <Components.SideMenu.Button
+ label="Remove block"
+ className="mx-1"
+ icon={
+ <TrashIcon
+ size={24}
+ onClick={() => {
+ editor.removeBlocks([props.block]);
+ }}
+ />
+ }
+ />
+ );
+}
diff --git a/ui/src/pages/Home.tsx b/ui/src/pages/Home.tsx
index 04bd768e..9b1c0976 100644
--- a/ui/src/pages/Home.tsx
+++ b/ui/src/pages/Home.tsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useState } from "react";
+import React, { useEffect } from "react";
import { formatRelative } from "date-fns";
import { Tooltip as ReactTooltip } from "react-tooltip";
@@ -41,16 +41,24 @@ function Header({ name }: any) {
{greeting}
</h2>
<h3 className="text-xl leading-7 text-gray-900 pt-4">
- Welcome to Atuin.
+ Welcome to{" "}
+ <a
+ href="https://atuin.sh"
+ target="_blank"
+ rel="noopener noreferrer nofollow"
+ >
+ Atuin
+ </a>
+ .
</h3>
</div>
</div>
);
}
-const explicitTheme: ThemeInput = {
+const explicitTheme = {
light: ["#f0f0f0", "#c4edde", "#7ac7c4", "#f73859", "#384259"],
- dark: ["#383838", "#4D455D", "#7DB9B6", "#F5E9CF", "#E96479"],
+ dark: ["#f0f0f0", "#c4edde", "#7ac7c4", "#f73859", "#384259"],
};
export default function Home() {
@@ -147,7 +155,7 @@ export default function Home() {
<ActivityCalendar
theme={explicitTheme}
data={calendar}
- weekStart={weekStart}
+ weekStart={weekStart as any}
renderBlock={(block, activity) =>
React.cloneElement(block, {
"data-tooltip-id": "react-tooltip",
diff --git a/ui/src/pages/Runbooks.tsx b/ui/src/pages/Runbooks.tsx
new file mode 100644
index 00000000..71887310
--- /dev/null
+++ b/ui/src/pages/Runbooks.tsx
@@ -0,0 +1,9 @@
+import Editor from "@/components/runbooks/editor/Editor";
+
+export default function Runbooks() {
+ return (
+ <div className="pl-60 p-4 ">
+ <Editor />
+ </div>
+ );
+}
diff --git a/ui/src/state/store.ts b/ui/src/state/store.ts
index 21fbdf14..3a843d35 100644
--- a/ui/src/state/store.ts
+++ b/ui/src/state/store.ts
@@ -1,5 +1,5 @@
import { create } from "zustand";
-import { persist, createJSONStorage } from "zustand/middleware";
+import { persist } from "zustand/middleware";
import { parseISO } from "date-fns";
@@ -39,13 +39,15 @@ interface AtuinState {
historyNextPage: (query?: string) => void;
}
-let state = (set, get): AtuinState => ({
+let state = (set: any, get: any): AtuinState => ({
user: DefaultUser,
homeInfo: DefaultHomeInfo,
aliases: [],
vars: [],
shellHistory: [],
calendar: [],
+
+ // @ts-ignore
weekStart: new Intl.Locale(navigator.language).getWeekInfo().firstDay,
refreshAliases: () => {