From 95cef714902bbcbdc3ef016457e7a77d38293ea8 Mon Sep 17 00:00:00 2001 From: Ellie Huxtable Date: Mon, 22 Jul 2024 16:31:12 +0100 Subject: [PATCH] feat(gui): background terminals and more (#2303) * fixes & allow for background terminals to stay running * status indicators etc --- ui/backend/src/run/pty.rs | 14 +- ui/src/App.tsx | 239 +++++++++--------- ui/src/components/runbooks/List.tsx | 54 ++-- ui/src/components/runbooks/editor/Editor.tsx | 10 +- .../runbooks/editor/blocks/RunBlock/index.tsx | 82 ++++-- .../editor/blocks/RunBlock/terminal.tsx | 129 ++++++---- ui/src/main.tsx | 10 +- ui/src/state/store.ts | 124 ++++++++- 8 files changed, 444 insertions(+), 218 deletions(-) diff --git a/ui/backend/src/run/pty.rs b/ui/backend/src/run/pty.rs index 819dc7d0..2af617dd 100644 --- a/ui/backend/src/run/pty.rs +++ b/ui/backend/src/run/pty.rs @@ -86,9 +86,17 @@ pub(crate) async fn pty_kill( pid: uuid::Uuid, state: tauri::State<'_, AtuinState>, ) -> Result<(), String> { - let pty = state.pty_sessions.write().await.remove(&pid).unwrap(); - pty.kill_child().await.map_err(|e|e.to_string())?; - println!("RIP {pid:?}"); + let pty = state.pty_sessions.write().await.remove(&pid); + + match pty { + Some(pty)=>{ + + pty.kill_child().await.map_err(|e|e.to_string())?; + println!("RIP {pid:?}"); + } + None=>{} + } + Ok(()) } diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 27c57207..5963d31e 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -131,127 +131,136 @@ function App() { return (
-
-
-
- icon +
+
+
+
+ icon +
+
+ + + + + + + +
+ + + + + + + + + + + } + > + Settings + + + + open("https://forum.atuin.sh")} + startContent={ + + } + > + Help & Feedback + + + {(user.username && ( + + } + onClick={() => { + logout(); + refreshUser(); + }} + > + Log Out + + )) || ( + } + onPress={onOpen} + > + Log in or Register + + )} + + +
- - - + {renderMain(section)} - - -
- - - - - - - - - - } - > - Settings - - - - open("https://forum.atuin.sh")} - startContent={ - - } - > - Help & Feedback - - - {(user.username && ( - - } - onClick={() => { - logout(); - refreshUser(); - }} - > - Log Out - - )) || ( - } - onPress={onOpen} - > - Log in or Register - - )} - - - -
+ + + + {(onClose) => ( + <> + + + )} + +
- - {renderMain(section)} - - - - - {(onClose) => ( - <> - - - )} - -
); } diff --git a/ui/src/components/runbooks/List.tsx b/ui/src/components/runbooks/List.tsx index be6e84f5..72c1b3b3 100644 --- a/ui/src/components/runbooks/List.tsx +++ b/ui/src/components/runbooks/List.tsx @@ -14,6 +14,7 @@ import { DropdownTrigger, DropdownMenu, DropdownItem, + Badge, } from "@nextui-org/react"; import { EllipsisVerticalIcon } from "lucide-react"; @@ -22,32 +23,39 @@ import { DateTime } from "luxon"; import { NotebookPenIcon } from "lucide-react"; import Runbook from "@/state/runbooks/runbook"; -import { useStore } from "@/state/store"; +import { AtuinState, useStore } from "@/state/store"; const NoteSidebar = () => { - const runbooks = useStore((state) => state.runbooks); - const refreshRunbooks = useStore((state) => state.refreshRunbooks); + const runbooks = useStore((state: AtuinState) => state.runbooks); + const refreshRunbooks = useStore( + (state: AtuinState) => state.refreshRunbooks, + ); - const currentRunbook = useStore((state) => state.currentRunbook); - const setCurrentRunbook = useStore((state) => state.setCurrentRunbook); + const currentRunbook = useStore((state: AtuinState) => state.currentRunbook); + const setCurrentRunbook = useStore( + (state: AtuinState) => state.setCurrentRunbook, + ); + const runbookInfo = useStore((state: AtuinState) => state.runbookInfo); useEffect(() => { refreshRunbooks(); }, []); return ( -
+
{ + return [runbook, runbookInfo[runbook.id]]; + })} variant="flat" aria-label="Runbook list" selectionMode="single" selectedKeys={[currentRunbook]} itemClasses={{ base: "data-[selected=true]:bg-gray-200" }} topContent={ - + - + 0 + ? {} + : { + display: "none", + } + } + > + + + + { + const fetchName = (): string => { // Infer the title from the first text block let blocks = editor.document; @@ -119,7 +121,11 @@ export default function Editor() { }; if (editor === undefined) { - return "Loading content..."; + return ( +
+ +
+ ); } // Renders the editor instance. diff --git a/ui/src/components/runbooks/editor/blocks/RunBlock/index.tsx b/ui/src/components/runbooks/editor/blocks/RunBlock/index.tsx index c7386806..15653611 100644 --- a/ui/src/components/runbooks/editor/blocks/RunBlock/index.tsx +++ b/ui/src/components/runbooks/editor/blocks/RunBlock/index.tsx @@ -1,7 +1,9 @@ +import React from "react"; import { createReactBlockSpec } from "@blocknote/react"; import "./index.css"; import CodeMirror from "@uiw/react-codemirror"; +import { keymap } from "@codemirror/view"; import { langs } from "@uiw/codemirror-extensions-langs"; import { Play, Square } from "lucide-react"; @@ -12,58 +14,81 @@ import { invoke } from "@tauri-apps/api/core"; import Terminal from "./terminal.tsx"; import "@xterm/xterm/css/xterm.css"; +import { AtuinState, useStore } from "@/state/store.ts"; interface RunBlockProps { onChange: (val: string) => void; - onPlay?: () => void; - onStop?: () => void; + onRun?: (pty: string) => void; + onStop?: (pty: string) => void; id: string; code: string; type: string; + pty: string; isEditable: boolean; } const RunBlock = ({ onChange, - onPlay, id, code, isEditable, + onRun, + onStop, + pty, }: RunBlockProps) => { - console.log(code); - const [isRunning, setIsRunning] = useState(false); - const [showTerminal, setShowTerminal] = useState(false); const [value, setValue] = useState(code); + const cleanupPtyTerm = useStore((store: AtuinState) => store.cleanupPtyTerm); + const terminals = useStore((store: AtuinState) => store.terminals); - const [pty, setPty] = useState(null); + const [currentRunbook, incRunbookPty, decRunbookPty] = useStore( + (store: AtuinState) => [ + store.currentRunbook, + store.incRunbookPty, + store.decRunbookPty, + ], + ); - const handleToggle = async (event: any) => { - event.stopPropagation(); + const isRunning = pty !== null; + + const handleToggle = async (event: any | null) => { + if (event) 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 }); + + terminals[pty].terminal.dispose(); + cleanupPtyTerm(pty); + + if (onStop) onStop(pty); + decRunbookPty(currentRunbook); } if (!isRunning) { - if (onPlay) onPlay(); - let pty = await invoke("pty_open"); - setPty(pty); - console.log(pty); + if (onRun) onRun(pty); + + incRunbookPty(currentRunbook); let val = !value.endsWith("\n") ? value + "\r\n" : value; await invoke("pty_write", { pid: pty, data: val }); } }; + const handleCmdEnter = (view) => { + handleToggle(null); + return true; + }; + + const customKeymap = keymap.of([ + { + key: "Mod-Enter", + run: handleCmdEnter, + }, + ]); + return (
@@ -96,12 +121,12 @@ const RunBlock = ({ setValue(val); onChange(val); }} - extensions={[...extensions(), langs.shell()]} + extensions={[customKeymap, ...extensions(), langs.shell()]} basicSetup={false} />
{pty && } @@ -120,6 +145,7 @@ export default createReactBlockSpec( default: "bash", }, code: { default: "" }, + pty: { default: null }, }, content: "none", }, @@ -130,7 +156,18 @@ export default createReactBlockSpec( editor.updateBlock(block, { props: { ...block.props, code: val }, }); - console.log(block.props); + }; + + const onRun = (pty: string) => { + editor.updateBlock(block, { + props: { ...block.props, pty: pty }, + }); + }; + + const onStop = (pty: string) => { + editor.updateBlock(block, { + props: { ...block.props, pty: null }, + }); }; return ( @@ -139,7 +176,10 @@ export default createReactBlockSpec( id={block?.id} code={block.props.code} type={block.props.type} + pty={block.props.pty} isEditable={editor.isEditable} + onRun={onRun} + onStop={onStop} /> ); }, diff --git a/ui/src/components/runbooks/editor/blocks/RunBlock/terminal.tsx b/ui/src/components/runbooks/editor/blocks/RunBlock/terminal.tsx index fa203fc9..cb490887 100644 --- a/ui/src/components/runbooks/editor/blocks/RunBlock/terminal.tsx +++ b/ui/src/components/runbooks/editor/blocks/RunBlock/terminal.tsx @@ -1,79 +1,98 @@ -/* -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 { useState, useEffect, useRef } from "react"; 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"; +import { useStore } from "@/state/store"; -const onResize = (pty: string) => async (size: any) => { - await invoke("pty_resize", { - pid: pty, - cols: size.cols, - rows: size.rows, - }); +const usePersistentTerminal = (pty: string) => { + const newPtyTerm = useStore((store) => store.newPtyTerm); + const terminals = useStore((store) => store.terminals); + const [isReady, setIsReady] = useState(false); + + useEffect(() => { + if (!terminals.hasOwnProperty(pty)) { + // create a new terminal and store it in the store. + // this means we can resume the same instance even across mount/dismount + newPtyTerm(pty); + } + + setIsReady(true); + + return () => { + // We don't dispose of the terminal when the component unmounts + }; + }, [pty, terminals, newPtyTerm]); + + return { terminalData: terminals[pty], isReady }; }; const TerminalComponent = ({ pty }: any) => { const terminalRef = useRef(null); + const { terminalData, isReady } = usePersistentTerminal(pty); + const [isAttached, setIsAttached] = useState(false); + const cleanupListenerRef = useRef<(() => void) | null>(null); useEffect(() => { + // no pty? no terminal if (pty == null) return; - if (!terminalRef.current) return; - const terminal = new Terminal(); - const fitAddon = new FitAddon(); + // the terminal may still be being created so hold off + if (!isReady) return; - terminal.open(terminalRef.current); - terminal.loadAddon(new WebglAddon()); - terminal.loadAddon(fitAddon); - terminal.onResize(onResize(pty)); - - fitAddon.fit(); const windowResize = () => { - fitAddon.fit(); + if (!terminalData || !terminalData.fitAddon) return; + + terminalData.fitAddon.fit(); }; - listen(`pty-${pty}`, (event: any) => { - terminal.write(event.payload); - }).then(() => { - console.log("Listening for pty events"); - }); + // terminal object needs attaching to a ref to a div + if (!isAttached && terminalData && terminalData.terminal) { + // If it's never been attached, attach it + if (!terminalData.terminal.element && terminalRef.current) { + terminalData.terminal.open(terminalRef.current); - window.addEventListener("resize", windowResize); + // it might have been previously attached, but need moving elsewhere + } else if (terminalData && terminalRef.current) { + // @ts-ignore + terminalRef.current.appendChild(terminalData.terminal.element); + } + + terminalData.fitAddon.fit(); + setIsAttached(true); + + window.addEventListener("resize", windowResize); + } + + listen(`pty-${pty}`, (event: any) => { + terminalData.terminal.write(event.payload); + }).then((ul) => { + cleanupListenerRef.current = ul; + }); // Customize further as needed return () => { - terminal.dispose(); + if ( + terminalData && + terminalData.terminal && + terminalData.terminal.element + ) { + // Instead of removing, we just detach + if (terminalData.terminal.element.parentElement) { + terminalData.terminal.element.parentElement.removeChild( + terminalData.terminal.element, + ); + } + setIsAttached(false); + } + + if (cleanupListenerRef.current) { + cleanupListenerRef.current(); + } + window.removeEventListener("resize", windowResize); }; - }, [pty]); + }, [terminalData, isReady]); + + if (!isReady) return null; return (
diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 58cfd77b..96d570a3 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -8,8 +8,14 @@ ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
-
- +
+ +
+ +
, diff --git a/ui/src/state/store.ts b/ui/src/state/store.ts index cde2da17..c6d3c152 100644 --- a/ui/src/state/store.ts +++ b/ui/src/state/store.ts @@ -19,11 +19,24 @@ import { invoke } from "@tauri-apps/api/core"; import { sessionToken, settings } from "./client"; import { getWeekInfo } from "@/lib/utils"; import Runbook from "./runbooks/runbook"; +import { Terminal } from "@xterm/xterm"; +import { FitAddon } from "@xterm/addon-fit"; +import { WebglAddon } from "@xterm/addon-webgl"; + +export class TerminalData { + terminal: Terminal; + fitAddon: FitAddon; + + constructor(terminal: Terminal, fit: FitAddon) { + this.terminal = terminal; + this.fitAddon = fit; + } +} // I'll probs want to slice this up at some point, but for now a // big blobby lump of state is fine. // Totally just hoping that structure will be emergent in the future. -interface AtuinState { +export interface AtuinState { user: User; homeInfo: HomeInfo; aliases: Alias[]; @@ -32,7 +45,7 @@ interface AtuinState { calendar: any[]; weekStart: number; runbooks: Runbook[]; - currentRunbook: String | null; + currentRunbook: string | null; refreshHomeInfo: () => void; refreshCalendar: () => void; @@ -44,6 +57,16 @@ interface AtuinState { historyNextPage: (query?: string) => void; setCurrentRunbook: (id: String) => void; + setPtyTerm: (pty: string, terminal: any) => void; + newPtyTerm: (pty: string, runbook: string) => TerminalData; + cleanupPtyTerm: (pty: string) => void; + + terminals: { [pty: string]: TerminalData }; + + // Store ephemeral state for runbooks, that is not persisted to the database + runbookInfo: { [runbook: string]: { ptys: number } }; + incRunbookPty: (runbook: string) => void; + decRunbookPty: (runbook: string) => void; } let state = (set: any, get: any): AtuinState => ({ @@ -55,6 +78,8 @@ let state = (set: any, get: any): AtuinState => ({ calendar: [], runbooks: [], currentRunbook: "", + terminals: {}, + runbookInfo: {}, weekStart: getWeekInfo().firstDay, @@ -158,8 +183,101 @@ let state = (set: any, get: any): AtuinState => ({ setCurrentRunbook: (id: String) => { set({ currentRunbook: id }); }, + + setPtyTerm: (pty: string, terminal: TerminalData) => { + set({ + terminals: { ...get().terminals, [pty]: terminal }, + }); + }, + + cleanupPtyTerm: (pty: string) => { + set((state: AtuinState) => { + const terminals = Object.keys(state.terminals).reduce( + (terms: { [pty: string]: TerminalData }, key) => { + if (key !== pty) { + terms[key] = state.terminals[key]; + } + return terms; + }, + {}, + ); + + return { terminals }; + }); + }, + + newPtyTerm: (pty: string) => { + let terminal = new Terminal(); + + // TODO: fallback to canvas, also some sort of setting to allow disabling webgl usage + // probs fine for now though, it's widely supported. maybe issues on linux. + terminal.loadAddon(new WebglAddon()); + + let fitAddon = new FitAddon(); + terminal.loadAddon(fitAddon); + + const onResize = (size: { cols: number; rows: number }) => { + invoke("pty_resize", { + pid: pty, + cols: size.cols, + rows: size.rows, + }); + }; + + terminal.onResize(onResize); + + let td = new TerminalData(terminal, fitAddon); + + set({ + terminals: { ...get().terminals, [pty]: td }, + }); + + return td; + }, + + incRunbookPty: (runbook: string) => { + set((state: AtuinState) => { + let oldVal = state.runbookInfo[runbook] || { ptys: 0 }; + let newVal = { ptys: oldVal.ptys + 1 }; + console.log(newVal); + + return { + runbookInfo: { + ...state.runbookInfo, + [runbook]: newVal, + }, + }; + }); + }, + + decRunbookPty: (runbook: string) => { + set((state: AtuinState) => { + let newVal = state.runbookInfo[runbook]; + if (!newVal) { + return; + } + + newVal.ptys--; + + return { + runbookInfo: { + ...state.runbookInfo, + [runbook]: newVal, + }, + }; + }); + }, }); export const useStore = create()( - persist(state, { name: "atuin-storage" }), + persist(state, { + name: "atuin-storage", + + // don't serialize the terminals map + // it won't work as JSON. too cyclical + partialize: (state) => + Object.fromEntries( + Object.entries(state).filter(([key]) => !["terminals"].includes(key)), + ), + }), );