aboutsummaryrefslogtreecommitdiffstats
path: root/ui/src/components/runbooks/editor/blocks
diff options
context:
space:
mode:
authorEllie Huxtable <ellie@atuin.sh>2024-07-08 11:17:47 +0100
committerGitHub <noreply@github.com>2024-07-08 11:17:47 +0100
commit5b384487331eaf08031dfe438bb2affa31aafcbb (patch)
tree51904c3df8c54cbc5b7aa5832a5bae49d57f7141 /ui/src/components/runbooks/editor/blocks
parentfeat(bash/blesh): hook into BLE_ONLOAD to resolve loading order issue (#2234) (diff)
downloadatuin-5b384487331eaf08031dfe438bb2affa31aafcbb.zip
feat(gui): runbooks that run (#2233)
* add initial runbooks frontend * fix buttons, scroll, add shell support to editor * work * some tweaks * wip - run crate * functioning executable blocks * handle resizing, killing ptys * clear properly on stop * move terminal to its own component, handle lifecycle better * fix all build issues * ffs codespelll * update lockfile * clippy is needy once more * only build pty stuff on mac/linux * vendor pty handling into desktop * update lockfile
Diffstat (limited to 'ui/src/components/runbooks/editor/blocks')
-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
4 files changed, 395 insertions, 0 deletions
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;