feat(gui): background terminals and more (#2303)

* fixes & allow for background terminals to stay running

* status indicators etc
This commit is contained in:
Ellie Huxtable 2024-07-22 16:31:12 +01:00 committed by GitHub
parent fde86d3899
commit 95cef71490
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 444 additions and 218 deletions

View File

@ -86,9 +86,17 @@ pub(crate) async fn pty_kill(
pid: uuid::Uuid, pid: uuid::Uuid,
state: tauri::State<'_, AtuinState>, state: tauri::State<'_, AtuinState>,
) -> Result<(), String> { ) -> Result<(), String> {
let pty = state.pty_sessions.write().await.remove(&pid).unwrap(); let pty = state.pty_sessions.write().await.remove(&pid);
match pty {
Some(pty)=>{
pty.kill_child().await.map_err(|e|e.to_string())?; pty.kill_child().await.map_err(|e|e.to_string())?;
println!("RIP {pid:?}"); println!("RIP {pid:?}");
}
None=>{}
}
Ok(()) Ok(())
} }

View File

@ -131,10 +131,11 @@ function App() {
return ( return (
<div <div
className="flex h-dvh w-screen select-none" className="flex w-screen select-none"
style={{ maxWidth: "100vw" }} style={{ maxWidth: "100vw", height: "calc(100dvh - 2rem)" }}
> >
<div className="relative flex h-full flex-col !border-r-small border-divider transition-width pb-6 pt-9 items-center"> <div className="flex w-full">
<div className="relative flex flex-col !border-r-small border-divider transition-width pb-6 pt-4 items-center">
<div className="flex items-center gap-0 px-3 justify-center"> <div className="flex items-center gap-0 px-3 justify-center">
<div className="flex h-8 w-8"> <div className="flex h-8 w-8">
<img src={icon} alt="icon" className="h-8 w-8" /> <img src={icon} alt="icon" className="h-8 w-8" />
@ -146,6 +147,7 @@ function App() {
defaultSelectedKey="home" defaultSelectedKey="home"
isCompact={true} isCompact={true}
items={navigation} items={navigation}
className="z-50"
/> />
</ScrollShadow> </ScrollShadow>
@ -193,7 +195,9 @@ function App() {
<DropdownItem <DropdownItem
key="settings" key="settings"
description="Configure Atuin" description="Configure Atuin"
startContent={<Icon icon="solar:settings-linear" width={24} />} startContent={
<Icon icon="solar:settings-linear" width={24} />
}
> >
Settings Settings
</DropdownItem> </DropdownItem>
@ -243,7 +247,11 @@ function App() {
{renderMain(section)} {renderMain(section)}
<Toaster /> <Toaster />
<Modal isOpen={isOpen} onOpenChange={onOpenChange} placement="top-center"> <Modal
isOpen={isOpen}
onOpenChange={onOpenChange}
placement="top-center"
>
<ModalContent className="p-8"> <ModalContent className="p-8">
{(onClose) => ( {(onClose) => (
<> <>
@ -253,6 +261,7 @@ function App() {
</ModalContent> </ModalContent>
</Modal> </Modal>
</div> </div>
</div>
); );
} }

View File

@ -14,6 +14,7 @@ import {
DropdownTrigger, DropdownTrigger,
DropdownMenu, DropdownMenu,
DropdownItem, DropdownItem,
Badge,
} from "@nextui-org/react"; } from "@nextui-org/react";
import { EllipsisVerticalIcon } from "lucide-react"; import { EllipsisVerticalIcon } from "lucide-react";
@ -22,32 +23,39 @@ import { DateTime } from "luxon";
import { NotebookPenIcon } from "lucide-react"; import { NotebookPenIcon } from "lucide-react";
import Runbook from "@/state/runbooks/runbook"; import Runbook from "@/state/runbooks/runbook";
import { useStore } from "@/state/store"; import { AtuinState, useStore } from "@/state/store";
const NoteSidebar = () => { const NoteSidebar = () => {
const runbooks = useStore((state) => state.runbooks); const runbooks = useStore((state: AtuinState) => state.runbooks);
const refreshRunbooks = useStore((state) => state.refreshRunbooks); const refreshRunbooks = useStore(
(state: AtuinState) => state.refreshRunbooks,
);
const currentRunbook = useStore((state) => state.currentRunbook); const currentRunbook = useStore((state: AtuinState) => state.currentRunbook);
const setCurrentRunbook = useStore((state) => state.setCurrentRunbook); const setCurrentRunbook = useStore(
(state: AtuinState) => state.setCurrentRunbook,
);
const runbookInfo = useStore((state: AtuinState) => state.runbookInfo);
useEffect(() => { useEffect(() => {
refreshRunbooks(); refreshRunbooks();
}, []); }, []);
return ( return (
<div className="min-w-48 h-screen flex flex-col border-r-1"> <div className="w-48 flex flex-col border-r-1">
<div className="overflow-y-auto flex-grow"> <div className="overflow-y-auto flex-grow">
<Listbox <Listbox
hideSelectedIcon hideSelectedIcon
items={runbooks} items={runbooks.map((runbook) => {
return [runbook, runbookInfo[runbook.id]];
})}
variant="flat" variant="flat"
aria-label="Runbook list" aria-label="Runbook list"
selectionMode="single" selectionMode="single"
selectedKeys={[currentRunbook]} selectedKeys={[currentRunbook]}
itemClasses={{ base: "data-[selected=true]:bg-gray-200" }} itemClasses={{ base: "data-[selected=true]:bg-gray-200" }}
topContent={ topContent={
<ButtonGroup> <ButtonGroup className="z-20">
<Tooltip showArrow content="New Runbook" closeDelay={50}> <Tooltip showArrow content="New Runbook" closeDelay={50}>
<Button <Button
isIconOnly isIconOnly
@ -66,7 +74,7 @@ const NoteSidebar = () => {
</ButtonGroup> </ButtonGroup>
} }
> >
{(runbook) => ( {([runbook, info]) => (
<ListboxItem <ListboxItem
key={runbook.id} key={runbook.id}
onPress={() => { onPress={() => {
@ -75,6 +83,17 @@ const NoteSidebar = () => {
textValue={runbook.name || "Untitled"} textValue={runbook.name || "Untitled"}
endContent={ endContent={
<Dropdown> <Dropdown>
<Badge
content={info?.ptys}
color="primary"
style={
info && info?.ptys > 0
? {}
: {
display: "none",
}
}
>
<DropdownTrigger className="bg-transparent"> <DropdownTrigger className="bg-transparent">
<Button isIconOnly> <Button isIconOnly>
<EllipsisVerticalIcon <EllipsisVerticalIcon
@ -83,6 +102,7 @@ const NoteSidebar = () => {
/> />
</Button> </Button>
</DropdownTrigger> </DropdownTrigger>
</Badge>
<DropdownMenu aria-label="Dynamic Actions"> <DropdownMenu aria-label="Dynamic Actions">
<DropdownItem <DropdownItem
key={"delete"} key={"delete"}

View File

@ -4,6 +4,8 @@ import "@blocknote/core/fonts/inter.css";
import "@blocknote/mantine/style.css"; import "@blocknote/mantine/style.css";
import "./index.css"; import "./index.css";
import { Spinner } from "@nextui-org/react";
import { import {
BlockNoteSchema, BlockNoteSchema,
BlockNoteEditor, BlockNoteEditor,
@ -102,7 +104,7 @@ export default function Editor() {
const debouncedOnChange = useDebounceCallback(onChange, 1000); const debouncedOnChange = useDebounceCallback(onChange, 1000);
const fetchName = (): String => { const fetchName = (): string => {
// Infer the title from the first text block // Infer the title from the first text block
let blocks = editor.document; let blocks = editor.document;
@ -119,7 +121,11 @@ export default function Editor() {
}; };
if (editor === undefined) { if (editor === undefined) {
return "Loading content..."; return (
<div className="flex w-full h-full flex-col justify-center items-center">
<Spinner />
</div>
);
} }
// Renders the editor instance. // Renders the editor instance.

View File

@ -1,7 +1,9 @@
import React from "react";
import { createReactBlockSpec } from "@blocknote/react"; import { createReactBlockSpec } from "@blocknote/react";
import "./index.css"; import "./index.css";
import CodeMirror from "@uiw/react-codemirror"; import CodeMirror from "@uiw/react-codemirror";
import { keymap } from "@codemirror/view";
import { langs } from "@uiw/codemirror-extensions-langs"; import { langs } from "@uiw/codemirror-extensions-langs";
import { Play, Square } from "lucide-react"; import { Play, Square } from "lucide-react";
@ -12,58 +14,81 @@ import { invoke } from "@tauri-apps/api/core";
import Terminal from "./terminal.tsx"; import Terminal from "./terminal.tsx";
import "@xterm/xterm/css/xterm.css"; import "@xterm/xterm/css/xterm.css";
import { AtuinState, useStore } from "@/state/store.ts";
interface RunBlockProps { interface RunBlockProps {
onChange: (val: string) => void; onChange: (val: string) => void;
onPlay?: () => void; onRun?: (pty: string) => void;
onStop?: () => void; onStop?: (pty: string) => void;
id: string; id: string;
code: string; code: string;
type: string; type: string;
pty: string;
isEditable: boolean; isEditable: boolean;
} }
const RunBlock = ({ const RunBlock = ({
onChange, onChange,
onPlay,
id, id,
code, code,
isEditable, isEditable,
onRun,
onStop,
pty,
}: RunBlockProps) => { }: RunBlockProps) => {
console.log(code);
const [isRunning, setIsRunning] = useState(false);
const [showTerminal, setShowTerminal] = useState(false);
const [value, setValue] = useState<String>(code); const [value, setValue] = useState<String>(code);
const cleanupPtyTerm = useStore((store: AtuinState) => store.cleanupPtyTerm);
const terminals = useStore((store: AtuinState) => store.terminals);
const [pty, setPty] = useState<string | null>(null); const [currentRunbook, incRunbookPty, decRunbookPty] = useStore(
(store: AtuinState) => [
store.currentRunbook,
store.incRunbookPty,
store.decRunbookPty,
],
);
const handleToggle = async (event: any) => { const isRunning = pty !== null;
event.stopPropagation();
const handleToggle = async (event: any | null) => {
if (event) event.stopPropagation();
// If there's no code, don't do anything // If there's no code, don't do anything
if (!value) return; if (!value) return;
setIsRunning(!isRunning);
setShowTerminal(!isRunning);
if (isRunning) { if (isRunning) {
// send sigkill
console.log("sending sigkill");
await invoke("pty_kill", { pid: pty }); await invoke("pty_kill", { pid: pty });
terminals[pty].terminal.dispose();
cleanupPtyTerm(pty);
if (onStop) onStop(pty);
decRunbookPty(currentRunbook);
} }
if (!isRunning) { if (!isRunning) {
if (onPlay) onPlay();
let pty = await invoke<string>("pty_open"); let pty = await invoke<string>("pty_open");
setPty(pty); if (onRun) onRun(pty);
console.log(pty);
incRunbookPty(currentRunbook);
let val = !value.endsWith("\n") ? value + "\r\n" : value; let val = !value.endsWith("\n") ? value + "\r\n" : value;
await invoke("pty_write", { pid: pty, data: val }); 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 ( return (
<div className="w-full !max-w-full !outline-none overflow-none"> <div className="w-full !max-w-full !outline-none overflow-none">
<div className="flex flex-row items-start"> <div className="flex flex-row items-start">
@ -96,12 +121,12 @@ const RunBlock = ({
setValue(val); setValue(val);
onChange(val); onChange(val);
}} }}
extensions={[...extensions(), langs.shell()]} extensions={[customKeymap, ...extensions(), langs.shell()]}
basicSetup={false} basicSetup={false}
/> />
<div <div
className={`overflow-hidden transition-all duration-300 ease-in-out min-w-0 ${ className={`overflow-hidden transition-all duration-300 ease-in-out min-w-0 ${
showTerminal ? "block" : "hidden" isRunning ? "block" : "hidden"
}`} }`}
> >
{pty && <Terminal pty={pty} />} {pty && <Terminal pty={pty} />}
@ -120,6 +145,7 @@ export default createReactBlockSpec(
default: "bash", default: "bash",
}, },
code: { default: "" }, code: { default: "" },
pty: { default: null },
}, },
content: "none", content: "none",
}, },
@ -130,7 +156,18 @@ export default createReactBlockSpec(
editor.updateBlock(block, { editor.updateBlock(block, {
props: { ...block.props, code: val }, 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 ( return (
@ -139,7 +176,10 @@ export default createReactBlockSpec(
id={block?.id} id={block?.id}
code={block.props.code} code={block.props.code}
type={block.props.type} type={block.props.type}
pty={block.props.pty}
isEditable={editor.isEditable} isEditable={editor.isEditable}
onRun={onRun}
onStop={onStop}
/> />
); );
}, },

View File

@ -1,79 +1,98 @@
/* import { useState, useEffect, useRef } from "react";
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 { 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 "@xterm/xterm/css/xterm.css";
import { invoke } from "@tauri-apps/api/core"; import { useStore } from "@/state/store";
const onResize = (pty: string) => async (size: any) => { const usePersistentTerminal = (pty: string) => {
await invoke("pty_resize", { const newPtyTerm = useStore((store) => store.newPtyTerm);
pid: pty, const terminals = useStore((store) => store.terminals);
cols: size.cols, const [isReady, setIsReady] = useState(false);
rows: size.rows,
}); 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 TerminalComponent = ({ pty }: any) => {
const terminalRef = useRef(null); const terminalRef = useRef(null);
const { terminalData, isReady } = usePersistentTerminal(pty);
const [isAttached, setIsAttached] = useState(false);
const cleanupListenerRef = useRef<(() => void) | null>(null);
useEffect(() => { useEffect(() => {
// no pty? no terminal
if (pty == null) return; if (pty == null) return;
if (!terminalRef.current) return;
const terminal = new Terminal(); // the terminal may still be being created so hold off
const fitAddon = new FitAddon(); if (!isReady) return;
terminal.open(terminalRef.current);
terminal.loadAddon(new WebglAddon());
terminal.loadAddon(fitAddon);
terminal.onResize(onResize(pty));
fitAddon.fit();
const windowResize = () => { const windowResize = () => {
fitAddon.fit(); if (!terminalData || !terminalData.fitAddon) return;
terminalData.fitAddon.fit();
}; };
listen(`pty-${pty}`, (event: any) => { // terminal object needs attaching to a ref to a div
terminal.write(event.payload); if (!isAttached && terminalData && terminalData.terminal) {
}).then(() => { // If it's never been attached, attach it
console.log("Listening for pty events"); if (!terminalData.terminal.element && terminalRef.current) {
}); terminalData.terminal.open(terminalRef.current);
// 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); window.addEventListener("resize", windowResize);
}
listen(`pty-${pty}`, (event: any) => {
terminalData.terminal.write(event.payload);
}).then((ul) => {
cleanupListenerRef.current = ul;
});
// Customize further as needed // Customize further as needed
return () => { 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); window.removeEventListener("resize", windowResize);
}; };
}, [pty]); }, [terminalData, isReady]);
if (!isReady) return null;
return ( return (
<div className="!max-w-full min-w-0 overflow-hidden" ref={terminalRef} /> <div className="!max-w-full min-w-0 overflow-hidden" ref={terminalRef} />

View File

@ -8,8 +8,14 @@ ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode> <React.StrictMode>
<NextUIProvider> <NextUIProvider>
<main className="text-foreground bg-background"> <main className="text-foreground bg-background">
<div data-tauri-drag-region className="w-full min-h-8 absolute z-10" /> <div
data-tauri-drag-region
className="w-full min-h-8 z-10 border-b-1"
/>
<div className="z-20 ">
<App /> <App />
</div>
</main> </main>
</NextUIProvider> </NextUIProvider>
</React.StrictMode>, </React.StrictMode>,

View File

@ -19,11 +19,24 @@ import { invoke } from "@tauri-apps/api/core";
import { sessionToken, settings } from "./client"; import { sessionToken, settings } from "./client";
import { getWeekInfo } from "@/lib/utils"; import { getWeekInfo } from "@/lib/utils";
import Runbook from "./runbooks/runbook"; 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 // I'll probs want to slice this up at some point, but for now a
// big blobby lump of state is fine. // big blobby lump of state is fine.
// Totally just hoping that structure will be emergent in the future. // Totally just hoping that structure will be emergent in the future.
interface AtuinState { export interface AtuinState {
user: User; user: User;
homeInfo: HomeInfo; homeInfo: HomeInfo;
aliases: Alias[]; aliases: Alias[];
@ -32,7 +45,7 @@ interface AtuinState {
calendar: any[]; calendar: any[];
weekStart: number; weekStart: number;
runbooks: Runbook[]; runbooks: Runbook[];
currentRunbook: String | null; currentRunbook: string | null;
refreshHomeInfo: () => void; refreshHomeInfo: () => void;
refreshCalendar: () => void; refreshCalendar: () => void;
@ -44,6 +57,16 @@ interface AtuinState {
historyNextPage: (query?: string) => void; historyNextPage: (query?: string) => void;
setCurrentRunbook: (id: 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 => ({ let state = (set: any, get: any): AtuinState => ({
@ -55,6 +78,8 @@ let state = (set: any, get: any): AtuinState => ({
calendar: [], calendar: [],
runbooks: [], runbooks: [],
currentRunbook: "", currentRunbook: "",
terminals: {},
runbookInfo: {},
weekStart: getWeekInfo().firstDay, weekStart: getWeekInfo().firstDay,
@ -158,8 +183,101 @@ let state = (set: any, get: any): AtuinState => ({
setCurrentRunbook: (id: String) => { setCurrentRunbook: (id: String) => {
set({ currentRunbook: id }); 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<AtuinState>()( export const useStore = create<AtuinState>()(
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)),
),
}),
); );