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,
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(())
}

View File

@ -131,127 +131,136 @@ function App() {
return (
<div
className="flex h-dvh w-screen select-none"
style={{ maxWidth: "100vw" }}
className="flex w-screen select-none"
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 items-center gap-0 px-3 justify-center">
<div className="flex h-8 w-8">
<img src={icon} alt="icon" className="h-8 w-8" />
<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 h-8 w-8">
<img src={icon} alt="icon" className="h-8 w-8" />
</div>
</div>
<ScrollShadow className="-mr-6 h-full max-h-full py-6 pr-6">
<Sidebar
defaultSelectedKey="home"
isCompact={true}
items={navigation}
className="z-50"
/>
</ScrollShadow>
<Spacer y={2} />
<div className="flex items-center gap-3 px-3">
<Dropdown showArrow placement="right-start">
<DropdownTrigger>
<Button disableRipple isIconOnly radius="full" variant="light">
<Avatar
isBordered
className="flex-none"
size="sm"
name={user.username || ""}
/>
</Button>
</DropdownTrigger>
<DropdownMenu aria-label="Custom item styles">
<DropdownItem
key="profile"
isReadOnly
className="h-14 opacity-100"
textValue="Signed in as"
>
<User
avatarProps={{
size: "sm",
name: user.username || "Anonymous User",
showFallback: true,
imgProps: {
className: "transition-none",
},
}}
classNames={{
name: "text-default-600",
description: "text-default-500",
}}
description={
user.bio || (user.username && "No bio") || "Sign up now"
}
name={user.username || "Anonymous User"}
/>
</DropdownItem>
<DropdownItem
key="settings"
description="Configure Atuin"
startContent={
<Icon icon="solar:settings-linear" width={24} />
}
>
Settings
</DropdownItem>
<DropdownSection aria-label="Help & Feedback">
<DropdownItem
key="help_and_feedback"
description="Get in touch"
onPress={() => open("https://forum.atuin.sh")}
startContent={
<Icon width={24} icon="solar:question-circle-linear" />
}
>
Help & Feedback
</DropdownItem>
{(user.username && (
<DropdownItem
key="logout"
startContent={
<Icon width={24} icon="solar:logout-broken" />
}
onClick={() => {
logout();
refreshUser();
}}
>
Log Out
</DropdownItem>
)) || (
<DropdownItem
key="signup"
description="Sync, backup and share your data"
className="bg-emerald-100"
startContent={<KeyRoundIcon size="18px" />}
onPress={onOpen}
>
Log in or Register
</DropdownItem>
)}
</DropdownSection>
</DropdownMenu>
</Dropdown>
</div>
</div>
<ScrollShadow className="-mr-6 h-full max-h-full py-6 pr-6">
<Sidebar
defaultSelectedKey="home"
isCompact={true}
items={navigation}
/>
</ScrollShadow>
{renderMain(section)}
<Spacer y={2} />
<div className="flex items-center gap-3 px-3">
<Dropdown showArrow placement="right-start">
<DropdownTrigger>
<Button disableRipple isIconOnly radius="full" variant="light">
<Avatar
isBordered
className="flex-none"
size="sm"
name={user.username || ""}
/>
</Button>
</DropdownTrigger>
<DropdownMenu aria-label="Custom item styles">
<DropdownItem
key="profile"
isReadOnly
className="h-14 opacity-100"
textValue="Signed in as"
>
<User
avatarProps={{
size: "sm",
name: user.username || "Anonymous User",
showFallback: true,
imgProps: {
className: "transition-none",
},
}}
classNames={{
name: "text-default-600",
description: "text-default-500",
}}
description={
user.bio || (user.username && "No bio") || "Sign up now"
}
name={user.username || "Anonymous User"}
/>
</DropdownItem>
<DropdownItem
key="settings"
description="Configure Atuin"
startContent={<Icon icon="solar:settings-linear" width={24} />}
>
Settings
</DropdownItem>
<DropdownSection aria-label="Help & Feedback">
<DropdownItem
key="help_and_feedback"
description="Get in touch"
onPress={() => open("https://forum.atuin.sh")}
startContent={
<Icon width={24} icon="solar:question-circle-linear" />
}
>
Help & Feedback
</DropdownItem>
{(user.username && (
<DropdownItem
key="logout"
startContent={
<Icon width={24} icon="solar:logout-broken" />
}
onClick={() => {
logout();
refreshUser();
}}
>
Log Out
</DropdownItem>
)) || (
<DropdownItem
key="signup"
description="Sync, backup and share your data"
className="bg-emerald-100"
startContent={<KeyRoundIcon size="18px" />}
onPress={onOpen}
>
Log in or Register
</DropdownItem>
)}
</DropdownSection>
</DropdownMenu>
</Dropdown>
</div>
<Toaster />
<Modal
isOpen={isOpen}
onOpenChange={onOpenChange}
placement="top-center"
>
<ModalContent className="p-8">
{(onClose) => (
<>
<LoginOrRegister onClose={onClose} />
</>
)}
</ModalContent>
</Modal>
</div>
{renderMain(section)}
<Toaster />
<Modal isOpen={isOpen} onOpenChange={onOpenChange} placement="top-center">
<ModalContent className="p-8">
{(onClose) => (
<>
<LoginOrRegister onClose={onClose} />
</>
)}
</ModalContent>
</Modal>
</div>
);
}

View File

@ -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 (
<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">
<Listbox
hideSelectedIcon
items={runbooks}
items={runbooks.map((runbook) => {
return [runbook, runbookInfo[runbook.id]];
})}
variant="flat"
aria-label="Runbook list"
selectionMode="single"
selectedKeys={[currentRunbook]}
itemClasses={{ base: "data-[selected=true]:bg-gray-200" }}
topContent={
<ButtonGroup>
<ButtonGroup className="z-20">
<Tooltip showArrow content="New Runbook" closeDelay={50}>
<Button
isIconOnly
@ -66,7 +74,7 @@ const NoteSidebar = () => {
</ButtonGroup>
}
>
{(runbook) => (
{([runbook, info]) => (
<ListboxItem
key={runbook.id}
onPress={() => {
@ -75,14 +83,26 @@ const NoteSidebar = () => {
textValue={runbook.name || "Untitled"}
endContent={
<Dropdown>
<DropdownTrigger className="bg-transparent">
<Button isIconOnly>
<EllipsisVerticalIcon
size="16px"
className="bg-transparent"
/>
</Button>
</DropdownTrigger>
<Badge
content={info?.ptys}
color="primary"
style={
info && info?.ptys > 0
? {}
: {
display: "none",
}
}
>
<DropdownTrigger className="bg-transparent">
<Button isIconOnly>
<EllipsisVerticalIcon
size="16px"
className="bg-transparent"
/>
</Button>
</DropdownTrigger>
</Badge>
<DropdownMenu aria-label="Dynamic Actions">
<DropdownItem
key={"delete"}

View File

@ -4,6 +4,8 @@ import "@blocknote/core/fonts/inter.css";
import "@blocknote/mantine/style.css";
import "./index.css";
import { Spinner } from "@nextui-org/react";
import {
BlockNoteSchema,
BlockNoteEditor,
@ -102,7 +104,7 @@ export default function Editor() {
const debouncedOnChange = useDebounceCallback(onChange, 1000);
const fetchName = (): String => {
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 (
<div className="flex w-full h-full flex-col justify-center items-center">
<Spinner />
</div>
);
}
// Renders the editor instance.

View File

@ -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<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) => {
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<string>("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 (
<div className="w-full !max-w-full !outline-none overflow-none">
<div className="flex flex-row items-start">
@ -96,12 +121,12 @@ const RunBlock = ({
setValue(val);
onChange(val);
}}
extensions={[...extensions(), langs.shell()]}
extensions={[customKeymap, ...extensions(), langs.shell()]}
basicSetup={false}
/>
<div
className={`overflow-hidden transition-all duration-300 ease-in-out min-w-0 ${
showTerminal ? "block" : "hidden"
isRunning ? "block" : "hidden"
}`}
>
{pty && <Terminal pty={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}
/>
);
},

View File

@ -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 (
<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>
<NextUIProvider>
<main className="text-foreground bg-background">
<div data-tauri-drag-region className="w-full min-h-8 absolute z-10" />
<App />
<div
data-tauri-drag-region
className="w-full min-h-8 z-10 border-b-1"
/>
<div className="z-20 ">
<App />
</div>
</main>
</NextUIProvider>
</React.StrictMode>,

View File

@ -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<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)),
),
}),
);