diff options
| author | Ellie Huxtable <ellie@atuin.sh> | 2024-07-08 11:17:47 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2024-07-08 11:17:47 +0100 |
| commit | 5b384487331eaf08031dfe438bb2affa31aafcbb (patch) | |
| tree | 51904c3df8c54cbc5b7aa5832a5bae49d57f7141 /ui/src/components/runbooks | |
| parent | feat(bash/blesh): hook into BLE_ONLOAD to resolve loading order issue (#2234) (diff) | |
| download | atuin-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')
7 files changed, 521 insertions, 0 deletions
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]); + }} + /> + } + /> + ); +} |
