From 5b384487331eaf08031dfe438bb2affa31aafcbb Mon Sep 17 00:00:00 2001 From: Ellie Huxtable Date: Mon, 8 Jul 2024 11:17:47 +0100 Subject: 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 --- .../runbooks/editor/blocks/RunBlock/extensions.ts | 158 +++++++++++++++++++++ .../runbooks/editor/blocks/RunBlock/index.css | 9 ++ .../runbooks/editor/blocks/RunBlock/index.tsx | 146 +++++++++++++++++++ .../runbooks/editor/blocks/RunBlock/terminal.tsx | 82 +++++++++++ 4 files changed, 395 insertions(+) create mode 100644 ui/src/components/runbooks/editor/blocks/RunBlock/extensions.ts create mode 100644 ui/src/components/runbooks/editor/blocks/RunBlock/index.css create mode 100644 ui/src/components/runbooks/editor/blocks/RunBlock/index.tsx create mode 100644 ui/src/components/runbooks/editor/blocks/RunBlock/terminal.tsx (limited to 'ui/src/components/runbooks/editor/blocks') 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(code); + + const [pty, setPty] = useState(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("pty_open"); + setPty(pty); + console.log(pty); + + let val = !value.endsWith("\n") ? value + "\n" : value; + await invoke("pty_write", { pid: pty, data: val }); + } + }; + + return ( +
+
+ +
+ +
+ {pty && } +
+
+
+
+ ); +}; + +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 ( + + ); + }, + toExternalHTML: ({ block }) => { + return ( +
+          {block?.props?.code}
+        
+ ); + }, + }, +); 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
; +}; + +export default TerminalComponent; -- cgit v1.3.1