diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index 61989cfe..3b46d296 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -1,8 +1,13 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import { LayoutTreeActionType, LayoutTreeInsertNodeAction, newLayoutNode } from "@/faraday/index"; +import { getLayoutStateAtomForTab } from "@/faraday/lib/layoutAtom"; +import { layoutTreeStateReducer } from "@/faraday/lib/layoutState"; + import * as jotai from "jotai"; import * as rxjs from "rxjs"; +import * as services from "./services"; import * as WOS from "./wos"; import { WSControl } from "./ws"; @@ -62,8 +67,6 @@ const atoms = { workspace: workspaceAtom, }; -type SubjectWithRef = rxjs.Subject & { refCount: number; release: () => void }; - // key is "eventType" or "eventType|oref" const eventSubjects = new Map>(); const fileSubjects = new Map>(); @@ -215,9 +218,58 @@ function getApi(): ElectronApi { return (window as any).api; } +async function createBlock(blockDef: BlockDef) { + const rtOpts: RuntimeOpts = { termsize: { rows: 25, cols: 80 } }; + const blockId = await services.ObjectService.CreateBlock(blockDef, rtOpts); + const insertNodeAction: LayoutTreeInsertNodeAction = { + type: LayoutTreeActionType.InsertNode, + node: newLayoutNode(undefined, undefined, undefined, { blockId }), + }; + const activeTabId = globalStore.get(atoms.uiContext).activetabid; + const layoutStateAtom = getLayoutStateAtomForTab( + activeTabId, + WOS.getWaveObjectAtom(WOS.makeORef("tab", activeTabId)) + ); + const curState = globalStore.get(layoutStateAtom); + globalStore.set(layoutStateAtom, layoutTreeStateReducer(curState, insertNodeAction)); +} + +// when file is not found, returns {data: null, fileInfo: null} +async function fetchWaveFile( + zoneId: string, + fileName: string, + offset?: number +): Promise<{ data: Uint8Array; fileInfo: WaveFile }> { + let usp = new URLSearchParams(); + usp.set("zoneid", zoneId); + usp.set("name", fileName); + if (offset != null) { + usp.set("offset", offset.toString()); + } + const resp = await fetch(getBackendHostPort() + "/wave/file?" + usp.toString()); + if (!resp.ok) { + if (resp.status === 404) { + return { data: null, fileInfo: null }; + } + throw new Error("error getting wave file: " + resp.statusText); + } + if (resp.status == 204) { + return { data: null, fileInfo: null }; + } + let fileInfo64 = resp.headers.get("X-ZoneFileInfo"); + if (fileInfo64 == null) { + throw new Error(`missing zone file info for ${zoneId}:${fileName}`); + } + let fileInfo = JSON.parse(atob(fileInfo64)); + const data = await resp.arrayBuffer(); + return { data: new Uint8Array(data), fileInfo }; +} + export { WOS, atoms, + createBlock, + fetchWaveFile, getApi, getBackendHostPort, getEventORefSubject, diff --git a/frontend/app/store/services.ts b/frontend/app/store/services.ts index a109bf4b..df5de645 100644 --- a/frontend/app/store/services.ts +++ b/frontend/app/store/services.ts @@ -7,6 +7,10 @@ import * as WOS from "./wos"; // blockservice.BlockService (block) class BlockServiceType { + SaveTerminalState(arg2: string, arg3: string, arg4: string, arg5: number): Promise { + return WOS.callBackendService("block", "SaveTerminalState", Array.from(arguments)) + } + // send command to block SendCommand(blockid: string, cmd: BlockCommand): Promise { return WOS.callBackendService("block", "SendCommand", Array.from(arguments)) diff --git a/frontend/app/theme.less b/frontend/app/theme.less index 9d54bb8c..25312904 100644 --- a/frontend/app/theme.less +++ b/frontend/app/theme.less @@ -27,4 +27,5 @@ /* z-index values */ --zindex-header-hover: 100; + --zindex-termstickers: 20; } diff --git a/frontend/app/view/term/term.less b/frontend/app/view/term/term.less index f0b8d533..7ef1d0b4 100644 --- a/frontend/app/view/term/term.less +++ b/frontend/app/view/term/term.less @@ -9,6 +9,7 @@ overflow: hidden; border-left: 4px solid transparent; padding-left: 4px; + position: relative; &:focus-within { border-left: 4px solid var(--accent-color); @@ -84,4 +85,30 @@ border: none; } } + + .term-stickers { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: var(--zindex-termstickers); + pointer-events: none; + + .term-sticker-image { + img { + object-fit: contain; + width: 100%; + height: 100%; + } + } + + .term-sticker-svg { + svg { + object-fit: contain; + width: 100%; + height: 100%; + } + } + } } diff --git a/frontend/app/view/term/term.tsx b/frontend/app/view/term/term.tsx index 9d6f4d73..eb776b5c 100644 --- a/frontend/app/view/term/term.tsx +++ b/frontend/app/view/term/term.tsx @@ -1,17 +1,8 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { - WOS, - atoms, - getBackendHostPort, - getFileSubject, - globalStore, - sendWSCommand, - useBlockAtom, -} from "@/store/global"; +import { WOS, atoms, globalStore, sendWSCommand, useBlockAtom } from "@/store/global"; import * as services from "@/store/services"; -import { base64ToArray } from "@/util/util"; import { FitAddon } from "@xterm/addon-fit"; import type { ITheme } from "@xterm/xterm"; import { Terminal } from "@xterm/xterm"; @@ -20,9 +11,10 @@ import { produce } from "immer"; import * as jotai from "jotai"; import * as React from "react"; import { IJsonView } from "./ijson"; +import { TermStickers } from "./termsticker"; +import { TermWrap } from "./termwrap"; import "public/xterm.css"; -import { debounce } from "throttle-debounce"; import "./term.less"; function getThemeFromCSSVars(el: Element): ITheme { @@ -149,7 +141,7 @@ function setBlockFocus(blockId: string) { const TerminalView = ({ blockId }: { blockId: string }) => { const connectElemRef = React.useRef(null); - const termRef = React.useRef(null); + const termRef = React.useRef(null); const initialLoadRef = React.useRef({ loaded: false, heldData: [] }); const htmlElemFocusRef = React.useRef(null); const [blockData] = WOS.useWaveObjectValue(WOS.makeORef("block", blockId)); @@ -161,8 +153,7 @@ const TerminalView = ({ blockId }: { blockId: string }) => { }); const isFocused = jotai.useAtomValue(isFocusedAtom); React.useEffect(() => { - console.log("terminal created"); - const newTerm = new Terminal({ + const termWrap = new TermWrap(blockId, connectElemRef.current, { theme: getThemeFromCSSVars(connectElemRef.current), fontSize: 12, fontFamily: "Hack", @@ -170,93 +161,22 @@ const TerminalView = ({ blockId }: { blockId: string }) => { fontWeight: "normal", fontWeightBold: "bold", }); - termRef.current = newTerm; - const newFitAddon = new FitAddon(); - newTerm.loadAddon(newFitAddon); - newTerm.open(connectElemRef.current); - newFitAddon.fit(); - sendWSCommand({ - wscommand: "setblocktermsize", - blockid: blockId, - termsize: { rows: newTerm.rows, cols: newTerm.cols }, - }); - connectElemRef.current.addEventListener( - "keydown", - (ev) => { - if (ev.code == "Escape" && ev.metaKey) { - ev.preventDefault(); - ev.stopPropagation(); - const metaCmd: BlockSetMetaCommand = { command: "setmeta", meta: { "term:mode": "html" } }; - services.BlockService.SendCommand(blockId, metaCmd); - return false; - } - }, - true - ); - newTerm.onData((data) => { - const b64data = btoa(data); - const inputCmd: BlockInputCommand = { command: "controller:input", inputdata64: b64data }; - services.BlockService.SendCommand(blockId, inputCmd); - }); - newTerm.textarea.addEventListener("focus", () => { + (window as any).term = termWrap; + termRef.current = termWrap; + termWrap.addFocusListener(() => { setBlockFocus(blockId); }); - const mainFileSubject = getFileSubject(blockId, "main"); - mainFileSubject.subscribe((msg: WSFileEventData) => { - if (msg.fileop != "append") { - console.log("bad fileop for terminal", msg); - return; - } - const decodedData = base64ToArray(msg.data64); - if (initialLoadRef.current.loaded) { - newTerm.write(decodedData); - } else { - initialLoadRef.current.heldData.push(decodedData); - } - }); - // load data from filestore - const startTs = Date.now(); - let loadedBytes = 0; - const localTerm = termRef.current; // avoids devmode double effect running issue (terminal gets created twice) - const usp = new URLSearchParams(); - usp.set("zoneid", blockId); - usp.set("name", "main"); - fetch(getBackendHostPort() + "/wave/file?" + usp.toString()) - .then((resp) => { - if (resp.ok) { - return resp.arrayBuffer(); - } - console.log("error loading file", resp.status, resp.statusText); - }) - .then((data: ArrayBuffer) => { - const uint8View = new Uint8Array(data); - localTerm.write(uint8View); - loadedBytes = uint8View.byteLength; - }) - .finally(() => { - initialLoadRef.current.heldData.forEach((data) => { - localTerm.write(data); - }); - initialLoadRef.current.loaded = true; - initialLoadRef.current.heldData = []; - console.log(`terminal loaded file ${loadedBytes} bytes, ${Date.now() - startTs}ms`); - }); - - const resize_debounced = debounce(50, () => { - handleResize(newFitAddon, blockId, newTerm); - }); const rszObs = new ResizeObserver(() => { - resize_debounced(); + termWrap.handleResize_debounced(); }); rszObs.observe(connectElemRef.current); - + termWrap.initTerminal(); return () => { - newTerm.dispose(); - mainFileSubject.release(); + termWrap.dispose(); }; }, []); - const handleKeyDown = (event: React.KeyboardEvent) => { + const handleHtmlKeyDown = (event: React.KeyboardEvent) => { if (event.code === "Escape" && event.metaKey) { // reset term:mode const metaCmd: BlockSetMetaCommand = { command: "setmeta", meta: { "term:mode": null } }; @@ -280,15 +200,24 @@ const TerminalView = ({ blockId }: { blockId: string }) => { React.useEffect(() => { if (isFocused && termMode == "term") { - termRef.current?.focus(); + termRef.current?.terminal.focus(); } if (isFocused && termMode == "html") { htmlElemFocusRef.current?.focus(); } }); + let stickerConfig = { + charWidth: 8, + charHeight: 16, + rows: termRef.current?.terminal.rows ?? 24, + cols: termRef.current?.terminal.cols ?? 80, + blockId: blockId, + }; + return (
+
{ type="text" value={""} ref={htmlElemFocusRef} - onKeyDown={handleKeyDown} + onKeyDown={handleHtmlKeyDown} onChange={() => {}} />
diff --git a/frontend/app/view/term/termsticker.tsx b/frontend/app/view/term/termsticker.tsx new file mode 100644 index 00000000..7bc6332f --- /dev/null +++ b/frontend/app/view/term/termsticker.tsx @@ -0,0 +1,204 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { createBlock, getBackendHostPort } from "@/store/global"; +import * as services from "@/store/services"; +import clsx from "clsx"; +import * as jotai from "jotai"; +import * as React from "react"; +import GaugeChart from "react-gauge-chart"; +import "./term.less"; + +type StickerType = { + position: "absolute"; + top?: number; + left?: number; + right?: number; + bottom?: number; + width?: number; + height?: number; + color?: string; + opacity?: number; + pointerevents?: boolean; + fontsize?: number; + transform?: string; + + stickertype: "icon" | "image" | "gauge"; + icon?: string; + imgsrc?: string; + clickcmd?: string; + clickblockdef?: BlockDef; +}; + +type StickerTermConfig = { + charWidth: number; + charHeight: number; + rows: number; + cols: number; + blockId: string; +}; + +function convertWidthDimToPx(dim: number, config: StickerTermConfig) { + if (dim == null) { + return null; + } + return dim * config.charWidth; +} + +function convertHeightDimToPx(dim: number, config: StickerTermConfig) { + if (dim == null) { + return null; + } + return dim * config.charHeight; +} + +var valueAtom = jotai.atom(Math.random() * 100); + +function GaugeSticker() { + let [value, setValue] = jotai.useAtom(valueAtom); + React.useEffect(() => { + let interval = setInterval(() => { + var amt = Math.random() * 10 - 5; + setValue((value) => Math.max(0, Math.min(100, value + amt))); + }, 1000); + return () => clearInterval(interval); + }); + return ; +} + +function TermSticker({ sticker, config }: { sticker: StickerType; config: StickerTermConfig }) { + let style: React.CSSProperties = { + position: sticker.position, + top: convertHeightDimToPx(sticker.top, config), + left: convertWidthDimToPx(sticker.left, config), + right: convertWidthDimToPx(sticker.right, config), + bottom: convertHeightDimToPx(sticker.bottom, config), + width: convertWidthDimToPx(sticker.width, config), + height: convertHeightDimToPx(sticker.height, config), + color: sticker.color, + fontSize: sticker.fontsize, + transform: sticker.transform, + opacity: sticker.opacity, + fill: sticker.color, + stroke: sticker.color, + }; + if (sticker.pointerevents) { + style.pointerEvents = "auto"; + } + if (style.width != null) { + style.overflowX = "hidden"; + } + if (style.height != null) { + style.overflowY = "hidden"; + } + let clickHandler = null; + if (sticker.pointerevents && (sticker.clickcmd || sticker.clickblockdef)) { + style.cursor = "pointer"; + clickHandler = () => { + console.log("clickHandler", sticker.clickcmd, sticker.clickblockdef); + if (sticker.clickcmd) { + const b64data = btoa(sticker.clickcmd); + const inputCmd: BlockInputCommand = { command: "controller:input", inputdata64: b64data }; + services.BlockService.SendCommand(config.blockId, inputCmd); + } + if (sticker.clickblockdef) { + createBlock(sticker.clickblockdef); + } + }; + } + if (sticker.stickertype == "icon") { + return ( +
+ +
+ ); + } + if (sticker.stickertype == "image") { + if (sticker.imgsrc == null) { + return null; + } + const streamingUrl = getBackendHostPort() + "/wave/stream-file?path=" + encodeURIComponent(sticker.imgsrc); + return ( +
+ +
+ ); + } + if (sticker.stickertype == "gauge") { + return ( +
+ +
+ ); + } + return null; +} + +export function TermStickers({ config }: { config: StickerTermConfig }) { + let stickers: StickerType[] = []; + if (config.blockId.startsWith("d1eaddcb")) { + stickers.push({ + position: "absolute", + top: 5, + right: 7, + stickertype: "icon", + icon: "paw", + color: "#40cc40aa", + fontsize: 30, + transform: "rotate(-18deg)", + pointerevents: true, + clickcmd: "ls\n", + }); + stickers.push({ + position: "absolute", + top: 8, + right: 8, + stickertype: "icon", + icon: "paw", + color: "#4040ccaa", + fontsize: 30, + transform: "rotate(-20deg)", + pointerevents: true, + clickcmd: "git status\n", + }); + stickers.push({ + position: "absolute", + top: 10, + right: 5, + stickertype: "icon", + icon: "paw", + color: "#cc4040aa", + fontsize: 30, + transform: "rotate(-15deg)", + pointerevents: true, + clickcmd: "cd ~/work/wails/thenextwave\n", + }); + stickers.push({ + position: "absolute", + top: 18, + right: 8, + stickertype: "image", + width: 12, + height: 6, + imgsrc: "~/Downloads/natureicon.png", + opacity: 0.8, + pointerevents: true, + clickblockdef: { view: "preview", meta: { file: "~/" } }, + }); + stickers.push({ + position: "absolute", + top: 2, + right: 25, + width: 20, + stickertype: "gauge", + opacity: 0.7, + }); + } + return ( +
+ {stickers.map((sticker, i) => ( + + ))} +
+ ); +} diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts new file mode 100644 index 00000000..31ae5897 --- /dev/null +++ b/frontend/app/view/term/termwrap.ts @@ -0,0 +1,172 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { fetchWaveFile, getFileSubject, sendWSCommand } from "@/store/global"; +import * as services from "@/store/services"; +import { base64ToArray } from "@/util/util"; +import { FitAddon } from "@xterm/addon-fit"; +import { SerializeAddon } from "@xterm/addon-serialize"; +import * as TermTypes from "@xterm/xterm"; +import { Terminal } from "@xterm/xterm"; +import { debounce } from "throttle-debounce"; + +export class TermWrap { + blockId: string; + ptyOffset: number; + dataBytesProcessed: number; + terminal: Terminal; + connectElem: HTMLDivElement; + fitAddon: FitAddon; + serializeAddon: SerializeAddon; + mainFileSubject: SubjectWithRef; + loaded: boolean; + heldData: Uint8Array[]; + handleResize_debounced: () => void; + + constructor( + blockId: string, + connectElem: HTMLDivElement, + options?: TermTypes.ITerminalOptions & TermTypes.ITerminalInitOnlyOptions + ) { + this.blockId = blockId; + this.ptyOffset = 0; + this.dataBytesProcessed = 0; + this.terminal = new Terminal(options); + this.fitAddon = new FitAddon(); + this.serializeAddon = new SerializeAddon(); + this.terminal.loadAddon(this.fitAddon); + this.terminal.loadAddon(this.serializeAddon); + this.connectElem = connectElem; + this.mainFileSubject = null; + this.loaded = false; + this.heldData = []; + this.handleResize_debounced = debounce(50, this.handleResize.bind(this)); + this.terminal.open(this.connectElem); + this.handleResize(); + } + + async initTerminal() { + this.connectElem.addEventListener("keydown", this.keydownListener.bind(this), true); + this.terminal.onData(this.handleTermData.bind(this)); + this.mainFileSubject = getFileSubject(this.blockId, "main"); + this.mainFileSubject.subscribe(this.handleNewFileSubjectData.bind(this)); + try { + await this.loadInitialTerminalData(); + } finally { + this.loaded = true; + } + this.runProcessIdleTimeout(); + } + + dispose() { + this.terminal.dispose(); + this.mainFileSubject.release(); + } + + handleTermData(data: string) { + const b64data = btoa(data); + if (b64data.length < 512) { + const wsCmd: BlockInputWSCommand = { wscommand: "blockinput", blockid: this.blockId, inputdata64: b64data }; + sendWSCommand(wsCmd); + } else { + const inputCmd: BlockInputCommand = { command: "controller:input", inputdata64: b64data }; + services.BlockService.SendCommand(this.blockId, inputCmd); + } + } + + addFocusListener(focusFn: () => void) { + this.terminal.textarea.addEventListener("focus", focusFn); + } + + keydownListener(ev: KeyboardEvent) { + if (ev.code == "Escape" && ev.metaKey) { + ev.preventDefault(); + ev.stopPropagation(); + const metaCmd: BlockSetMetaCommand = { command: "setmeta", meta: { "term:mode": "html" } }; + services.BlockService.SendCommand(this.blockId, metaCmd); + return false; + } + } + + handleNewFileSubjectData(msg: WSFileEventData) { + if (msg.fileop != "append") { + console.log("bad fileop for terminal", msg); + return; + } + const decodedData = base64ToArray(msg.data64); + if (this.loaded) { + this.doTerminalWrite(decodedData, null); + } else { + this.heldData.push(decodedData); + } + } + + doTerminalWrite(data: string | Uint8Array, setPtyOffset?: number): Promise { + let resolve: () => void = null; + let prtn = new Promise((presolve, _) => { + resolve = presolve; + }); + this.terminal.write(data, () => { + if (setPtyOffset != null) { + this.ptyOffset = setPtyOffset; + } else { + this.ptyOffset += data.length; + this.dataBytesProcessed += data.length; + } + resolve(); + }); + return prtn; + } + + async loadInitialTerminalData(): Promise { + let startTs = Date.now(); + const { data: cacheData, fileInfo: cacheFile } = await fetchWaveFile(this.blockId, "cache:term:full"); + let ptyOffset = 0; + if (cacheFile != null) { + ptyOffset = cacheFile.meta["ptyoffset"] ?? 0; + if (cacheData.byteLength > 0) { + this.doTerminalWrite(cacheData, ptyOffset); + } + } + const { data: mainData, fileInfo: mainFile } = await fetchWaveFile(this.blockId, "main", ptyOffset); + console.log( + `terminal loaded cachefile:${cacheData?.byteLength ?? 0} main:${mainData?.byteLength ?? 0} bytes, ${Date.now() - startTs}ms` + ); + if (mainFile != null) { + await this.doTerminalWrite(mainData, null); + } + } + + handleResize() { + const oldRows = this.terminal.rows; + const oldCols = this.terminal.cols; + this.fitAddon.fit(); + if (oldRows !== this.terminal.rows || oldCols !== this.terminal.cols) { + const wsCommand: SetBlockTermSizeWSCommand = { + wscommand: "setblocktermsize", + blockid: this.blockId, + termsize: { rows: this.terminal.rows, cols: this.terminal.cols }, + }; + sendWSCommand(wsCommand); + } + } + + processAndCacheData() { + if (this.dataBytesProcessed < 10 * 1024) { + return; + } + const serializedOutput = this.serializeAddon.serialize(); + console.log("idle timeout term", this.dataBytesProcessed, serializedOutput.length); + services.BlockService.SaveTerminalState(this.blockId, serializedOutput, "full", this.ptyOffset); + this.dataBytesProcessed = 0; + } + + runProcessIdleTimeout() { + setTimeout(() => { + window.requestIdleCallback(() => { + this.processAndCacheData(); + this.runProcessIdleTimeout(); + }); + }, 5000); + } +} diff --git a/frontend/app/workspace/workspace.tsx b/frontend/app/workspace/workspace.tsx index de84c492..ddf6bf25 100644 --- a/frontend/app/workspace/workspace.tsx +++ b/frontend/app/workspace/workspace.tsx @@ -3,41 +3,13 @@ import { TabBar } from "@/app/tab/tabbar"; import { TabContent } from "@/app/tab/tabcontent"; -import { atoms } from "@/store/global"; -import * as services from "@/store/services"; -import * as WOS from "@/store/wos"; +import { atoms, createBlock } from "@/store/global"; import * as jotai from "jotai"; import { CenteredDiv } from "../element/quickelems"; -import { LayoutTreeActionType, LayoutTreeInsertNodeAction, newLayoutNode } from "@/faraday/index"; -import { getLayoutStateAtomForTab, useLayoutTreeStateReducerAtom } from "@/faraday/lib/layoutAtom"; -import { useMemo } from "react"; import "./workspace.less"; function Widgets() { - const windowData = jotai.useAtomValue(atoms.waveWindow); - const activeTabAtom = useMemo(() => { - return getLayoutStateAtomForTab( - windowData.activetabid, - WOS.getWaveObjectAtom(WOS.makeORef("tab", windowData.activetabid)) - ); - }, [windowData.activetabid]); - const [, dispatchLayoutStateAction] = useLayoutTreeStateReducerAtom(activeTabAtom); - - function addBlockToTab(blockId: string) { - const insertNodeAction: LayoutTreeInsertNodeAction = { - type: LayoutTreeActionType.InsertNode, - node: newLayoutNode(undefined, undefined, undefined, { blockId }), - }; - dispatchLayoutStateAction(insertNodeAction); - } - - async function createBlock(blockDef: BlockDef) { - const rtOpts: RuntimeOpts = { termsize: { rows: 25, cols: 80 } }; - const blockId = await services.ObjectService.CreateBlock(blockDef, rtOpts); - addBlockToTab(blockId); - } - async function clickTerminal() { const termBlockDef = { controller: "shell", diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index b39d82b5..1326e259 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -10,6 +10,8 @@ declare global { isDev: () => boolean; isDevServer: () => boolean; }; + + type SubjectWithRef = rxjs.Subject & { refCount: number; release: () => void }; } export {}; diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 926f130f..de676f35 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -4,12 +4,14 @@ // generated by cmd/generate/main-generate.go declare global { + // wstore.Block type Block = WaveObj & { blockdef: BlockDef; controller: string; view: string; runtimeopts?: RuntimeOpts; + stickers?: StickerType[]; meta: MetaType; }; @@ -29,28 +31,20 @@ declare global { type BlockCommand = { command: string; - } & ( - | BlockSetMetaCommand - | BlockGetMetaCommand - | BlockMessageCommand - | BlockAppendFileCommand - | BlockAppendIJsonCommand - | BlockInputCommand - | BlockSetViewCommand - ); + } & ( BlockAppendIJsonCommand | ResolveIdsCommand | BlockInputCommand | BlockSetViewCommand | BlockSetMetaCommand | BlockGetMetaCommand | BlockMessageCommand | BlockAppendFileCommand ); // wstore.BlockDef type BlockDef = { controller?: string; view?: string; - files?: { [key: string]: FileDef }; + files?: {[key: string]: FileDef}; meta?: MetaType; }; // wshutil.BlockGetMetaCommand type BlockGetMetaCommand = { command: "getmeta"; - oid: string; + oref: string; }; // wshutil.BlockInputCommand @@ -61,6 +55,13 @@ declare global { termsize?: TermSize; }; + // webcmd.BlockInputWSCommand + type BlockInputWSCommand = { + wscommand: "blockinput"; + blockid: string; + inputdata64: string; + }; + // wshutil.BlockMessageCommand type BlockMessageCommand = { command: "message"; @@ -70,7 +71,7 @@ declare global { // wshutil.BlockSetMetaCommand type BlockSetMetaCommand = { command: "setmeta"; - oid?: string; + oref?: string; meta: MetaType; }; @@ -106,6 +107,14 @@ declare global { mimetype?: string; }; + // filestore.FileOptsType + type FileOptsType = { + maxsize?: number; + circular?: boolean; + ijson?: boolean; + ijsonbudget?: number; + }; + // fileservice.FullFile type FullFile = { info: FileInfo; @@ -118,7 +127,7 @@ declare global { meta?: MetaType; }; - type MetaType = { [key: string]: any }; + type MetaType = {[key: string]: any} // tsgenmeta.MethodMeta type MethodMeta = { @@ -139,6 +148,12 @@ declare global { y: number; }; + // wshutil.ResolveIdsCommand + type ResolveIdsCommand = { + command: "resolveids"; + ids: string[]; + }; + // wstore.RuntimeOpts type RuntimeOpts = { termsize?: TermSize; @@ -152,6 +167,27 @@ declare global { termsize: TermSize; }; + // wstore.StickerClickOptsType + type StickerClickOptsType = { + sendinput?: string; + createblock?: BlockDef; + }; + + // wstore.StickerDisplayOptsType + type StickerDisplayOptsType = { + icon: string; + imgsrc: string; + svgblob?: string; + }; + + // wstore.StickerType + type StickerType = { + stickertype: string; + style: MetaType; + clickopts?: StickerClickOptsType; + display: StickerDisplayOptsType; + }; + // wstore.Tab type Tab = WaveObj & { name: string; @@ -174,7 +210,7 @@ declare global { type WSCommandType = { wscommand: string; - } & SetBlockTermSizeWSCommand; + } & ( SetBlockTermSizeWSCommand | BlockInputWSCommand ); // eventbus.WSEventType type WSEventType = { @@ -191,6 +227,17 @@ declare global { data64: string; }; + // filestore.WaveFile + type WaveFile = { + zoneid: string; + name: string; + opts: FileOptsType; + createdts: number; + size: number; + modts: number; + meta: MetaType; + }; + // waveobj.WaveObj type WaveObj = { otype: string; @@ -233,7 +280,7 @@ declare global { workspaceid: string; activetabid: string; activeblockid?: string; - activeblockmap: { [key: string]: string }; + activeblockmap: {[key: string]: string}; pos: Point; winsize: WinSize; lastfocusts: number; @@ -246,6 +293,7 @@ declare global { tabids: string[]; meta: MetaType; }; + } -export {}; +export {} diff --git a/package.json b/package.json index 8a365967..5a57a66c 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "@react-hook/resize-observer": "^2.0.1", "@tanstack/react-table": "^8.17.3", "@xterm/addon-fit": "^0.10.0", + "@xterm/addon-serialize": "^0.13.0", "@xterm/xterm": "^5.5.0", "base64-js": "^1.5.1", "clsx": "^2.1.1", @@ -69,6 +70,7 @@ "react-dnd-html5-backend": "^16.0.1", "react-dom": "^18.3.1", "react-frame-component": "^5.2.7", + "react-gauge-chart": "^0.5.1", "react-markdown": "^9.0.1", "remark-gfm": "^4.0.0", "rxjs": "^7.8.1", diff --git a/pkg/blockcontroller/blockcontroller.go b/pkg/blockcontroller/blockcontroller.go index 9df62e60..450452d4 100644 --- a/pkg/blockcontroller/blockcontroller.go +++ b/pkg/blockcontroller/blockcontroller.go @@ -402,7 +402,6 @@ func (bc *BlockController) Run(bdata *wstore.Block) { } inputUnion.InputData = inputBuf[:nw] } - log.Printf("INPUT: %s | %q\n", bc.BlockId, string(inputUnion.InputData)) bc.ShellInputCh <- inputUnion default: log.Printf("unknown command type %T\n", cmd) diff --git a/pkg/filestore/blockstore.go b/pkg/filestore/blockstore.go index d7760c9b..fc95cec8 100644 --- a/pkg/filestore/blockstore.go +++ b/pkg/filestore/blockstore.go @@ -185,6 +185,9 @@ func (s *FileStore) Stat(ctx context.Context, zoneId string, name string) (*Wave return withLockRtn(s, zoneId, name, func(entry *CacheEntry) (*WaveFile, error) { file, err := entry.loadFileForRead(ctx) if err != nil { + if err == fs.ErrNotExist { + return nil, err + } return nil, fmt.Errorf("error getting file: %v", err) } return file.DeepCopy(), nil diff --git a/pkg/service/blockservice/blockservice.go b/pkg/service/blockservice/blockservice.go index 58b25e0e..499d763a 100644 --- a/pkg/service/blockservice/blockservice.go +++ b/pkg/service/blockservice/blockservice.go @@ -4,13 +4,16 @@ package blockservice import ( + "context" "fmt" "strings" "time" "github.com/wavetermdev/thenextwave/pkg/blockcontroller" + "github.com/wavetermdev/thenextwave/pkg/filestore" "github.com/wavetermdev/thenextwave/pkg/tsgen/tsgenmeta" "github.com/wavetermdev/thenextwave/pkg/wshutil" + "github.com/wavetermdev/thenextwave/pkg/wstore" ) type BlockService struct{} @@ -38,3 +41,24 @@ func (bs *BlockService) SendCommand(blockId string, cmd wshutil.BlockCommand) er } return nil } + +func (bs *BlockService) SaveTerminalState(ctx context.Context, blockId string, state string, stateType string, ptyOffset int64) error { + _, err := wstore.DBMustGet[*wstore.Block](ctx, blockId) + if err != nil { + return err + } + if stateType != "full" && stateType != "preview" { + return fmt.Errorf("invalid state type: %q", stateType) + } + // ignore MakeFile error (already exists is ok) + filestore.WFS.MakeFile(ctx, blockId, "cache:term:"+stateType, nil, filestore.FileOptsType{}) + err = filestore.WFS.WriteFile(ctx, blockId, "cache:term:"+stateType, []byte(state)) + if err != nil { + return fmt.Errorf("cannot save terminal state: %w", err) + } + err = filestore.WFS.WriteMeta(ctx, blockId, "cache:term:"+stateType, filestore.FileMeta{"ptyoffset": ptyOffset}, true) + if err != nil { + return fmt.Errorf("cannot save terminal state meta: %w", err) + } + return nil +} diff --git a/pkg/tsgen/tsgen.go b/pkg/tsgen/tsgen.go index cbfdc883..b1cd903e 100644 --- a/pkg/tsgen/tsgen.go +++ b/pkg/tsgen/tsgen.go @@ -11,6 +11,7 @@ import ( "strings" "github.com/wavetermdev/thenextwave/pkg/eventbus" + "github.com/wavetermdev/thenextwave/pkg/filestore" "github.com/wavetermdev/thenextwave/pkg/service" "github.com/wavetermdev/thenextwave/pkg/tsgen/tsgenmeta" "github.com/wavetermdev/thenextwave/pkg/waveobj" @@ -364,6 +365,7 @@ func GenerateWaveObjTypes(tsTypesMap map[reflect.Type]string) { GenerateTSType(reflect.TypeOf(wstore.UIContext{}), tsTypesMap) GenerateTSType(reflect.TypeOf(eventbus.WSEventType{}), tsTypesMap) GenerateTSType(reflect.TypeOf(eventbus.WSFileEventData{}), tsTypesMap) + GenerateTSType(reflect.TypeOf(filestore.WaveFile{}), tsTypesMap) for _, rtype := range wstore.AllWaveObjTypes() { GenerateTSType(rtype, tsTypesMap) } diff --git a/pkg/web/web.go b/pkg/web/web.go index 9177d29e..2099f2da 100644 --- a/pkg/web/web.go +++ b/pkg/web/web.go @@ -12,6 +12,7 @@ import ( "log" "net/http" "runtime/debug" + "strconv" "time" "github.com/google/uuid" @@ -102,6 +103,15 @@ func marshalReturnValue(data any, err error) []byte { func handleWaveFile(w http.ResponseWriter, r *http.Request) { zoneId := r.URL.Query().Get("zoneid") name := r.URL.Query().Get("name") + offsetStr := r.URL.Query().Get("offset") + var offset int64 = 0 + if offsetStr != "" { + var err error + offset, err = strconv.ParseInt(offsetStr, 10, 64) + if err != nil { + http.Error(w, fmt.Sprintf("invalid offset: %v", err), http.StatusBadRequest) + } + } if _, err := uuid.Parse(zoneId); err != nil { http.Error(w, fmt.Sprintf("invalid zoneid: %v", err), http.StatusBadRequest) return @@ -113,7 +123,7 @@ func handleWaveFile(w http.ResponseWriter, r *http.Request) { } file, err := filestore.WFS.Stat(r.Context(), zoneId, name) if err == fs.ErrNotExist { - http.NotFound(w, r) + w.WriteHeader(http.StatusNoContent) return } if err != nil { @@ -125,15 +135,19 @@ func handleWaveFile(w http.ResponseWriter, r *http.Request) { http.Error(w, fmt.Sprintf("error serializing file info: %v", err), http.StatusInternalServerError) } // can make more efficient by checking modtime + If-Modified-Since headers to allow caching + dataStartIdx := file.DataStartIdx() + if offset >= dataStartIdx { + dataStartIdx = offset + } w.Header().Set(ContentTypeHeaderKey, ContentTypeBinary) - w.Header().Set(ContentLengthHeaderKey, fmt.Sprintf("%d", file.Size)) + w.Header().Set(ContentLengthHeaderKey, fmt.Sprintf("%d", file.Size-dataStartIdx)) w.Header().Set(WaveZoneFileInfoHeaderKey, base64.StdEncoding.EncodeToString(jsonFileBArr)) w.Header().Set(LastModifiedHeaderKey, time.UnixMilli(file.ModTs).UTC().Format(http.TimeFormat)) - if file.Size == 0 { + if dataStartIdx >= file.Size { w.WriteHeader(http.StatusOK) return } - for offset := file.DataStartIdx(); offset < file.Size; offset += filestore.DefaultPartDataSize { + for offset := dataStartIdx; offset < file.Size; offset += filestore.DefaultPartDataSize { _, data, err := filestore.WFS.ReadAt(r.Context(), zoneId, name, offset, filestore.DefaultPartDataSize) if err != nil { if offset == 0 { @@ -177,6 +191,7 @@ func WebFnWrap(opts WebFnOpts, fn WebFnType) WebFnType { if !opts.AllowCaching { w.Header().Set(CacheControlHeaderKey, CacheControlHeaderNoCache) } + w.Header().Set("Access-Control-Expose-Headers", "X-ZoneFileInfo") // reqAuthKey := r.Header.Get("X-AuthKey") // if reqAuthKey == "" { // w.WriteHeader(http.StatusInternalServerError) diff --git a/pkg/web/webcmd/webcmd.go b/pkg/web/webcmd/webcmd.go index 155072a5..f01c9962 100644 --- a/pkg/web/webcmd/webcmd.go +++ b/pkg/web/webcmd/webcmd.go @@ -14,6 +14,7 @@ import ( const ( WSCommand_SetBlockTermSize = "setblocktermsize" + WSCommand_BlockInput = "blockinput" ) type WSCommandType interface { @@ -26,6 +27,7 @@ func WSCommandTypeUnionMeta() tsgenmeta.TypeUnionMeta { TypeFieldName: "wscommand", Types: []reflect.Type{ reflect.TypeOf(SetBlockTermSizeWSCommand{}), + reflect.TypeOf(BlockInputWSCommand{}), }, } } @@ -40,6 +42,16 @@ func (cmd *SetBlockTermSizeWSCommand) GetWSCommand() string { return cmd.WSCommand } +type BlockInputWSCommand struct { + WSCommand string `json:"wscommand" tstype:"\"blockinput\""` + BlockId string `json:"blockid"` + InputData64 string `json:"inputdata64"` +} + +func (cmd *BlockInputWSCommand) GetWSCommand() string { + return cmd.WSCommand +} + func ParseWSCommandMap(cmdMap map[string]any) (WSCommandType, error) { cmdType, ok := cmdMap["wscommand"].(string) if !ok { @@ -53,6 +65,13 @@ func ParseWSCommandMap(cmdMap map[string]any) (WSCommandType, error) { return nil, fmt.Errorf("error decoding SetBlockTermSizeWSCommand: %w", err) } return &cmd, nil + case WSCommand_BlockInput: + var cmd BlockInputWSCommand + err := utilfn.DoMapStucture(&cmd, cmdMap) + if err != nil { + return nil, fmt.Errorf("error decoding BlockInputWSCommand: %w", err) + } + return &cmd, nil default: return nil, fmt.Errorf("unknown wscommand type %q", cmdType) } diff --git a/pkg/web/ws.go b/pkg/web/ws.go index 012b3356..4ea23af1 100644 --- a/pkg/web/ws.go +++ b/pkg/web/ws.go @@ -100,6 +100,12 @@ func processWSCommand(jmsg map[string]any, outputCh chan any) { TermSize: &cmd.TermSize, } blockservice.BlockServiceInstance.SendCommand(cmd.BlockId, blockCmd) + case *webcmd.BlockInputWSCommand: + blockCmd := &wshutil.BlockInputCommand{ + Command: wshutil.BlockCommand_Input, + InputData64: cmd.InputData64, + } + blockservice.BlockServiceInstance.SendCommand(cmd.BlockId, blockCmd) } } diff --git a/pkg/wstore/wstore_types.go b/pkg/wstore/wstore_types.go index d1bed55b..ec46df74 100644 --- a/pkg/wstore/wstore_types.go +++ b/pkg/wstore/wstore_types.go @@ -201,6 +201,24 @@ type BlockDef struct { Meta map[string]any `json:"meta,omitempty"` } +type StickerClickOptsType struct { + SendInput string `json:"sendinput,omitempty"` + CreateBlock *BlockDef `json:"createblock,omitempty"` +} + +type StickerDisplayOptsType struct { + Icon string `json:"icon"` + ImgSrc string `json:"imgsrc"` + SvgBlob string `json:"svgblob,omitempty"` +} + +type StickerType struct { + StickerType string `json:"stickertype"` + Style map[string]any `json:"style"` + ClickOpts *StickerClickOptsType `json:"clickopts,omitempty"` + Display *StickerDisplayOptsType `json:"display"` +} + type RuntimeOpts struct { TermSize shellexec.TermSize `json:"termsize,omitempty"` WinSize WinSize `json:"winsize,omitempty"` @@ -223,6 +241,7 @@ type Block struct { Controller string `json:"controller"` View string `json:"view"` RuntimeOpts *RuntimeOpts `json:"runtimeopts,omitempty"` + Stickers []*StickerType `json:"stickers,omitempty"` Meta map[string]any `json:"meta"` } diff --git a/yarn.lock b/yarn.lock index 21835f50..953dd905 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4706,6 +4706,15 @@ __metadata: languageName: node linkType: hard +"@xterm/addon-serialize@npm:^0.13.0": + version: 0.13.0 + resolution: "@xterm/addon-serialize@npm:0.13.0" + peerDependencies: + "@xterm/xterm": ^5.0.0 + checksum: 10c0/090f502867250cdceca9abdbe37aebe0c01eb5d708a09c51d3e0e9bb5d4d3b0d4d67af1c4c8bde9b78f108a5e4150420180de69e6228aac76596fdbf6c4c59dc + languageName: node + linkType: hard + "@xterm/xterm@npm:^5.5.0": version: 5.5.0 resolution: "@xterm/xterm@npm:5.5.0" @@ -6051,7 +6060,7 @@ __metadata: languageName: node linkType: hard -"d3@npm:^7.9.0": +"d3@npm:^7.6.1, d3@npm:^7.9.0": version: 7.9.0 resolution: "d3@npm:7.9.0" dependencies: @@ -11026,6 +11035,18 @@ __metadata: languageName: node linkType: hard +"react-gauge-chart@npm:^0.5.1": + version: 0.5.1 + resolution: "react-gauge-chart@npm:0.5.1" + dependencies: + d3: "npm:^7.6.1" + peerDependencies: + react: ^16.8.2 || ^17.0 || ^18.x + react-dom: ^16.8.2 || ^17.0 || ^18.x + checksum: 10c0/16d0142130ed56c8c5cd710d596499b44ccf654ea8973a4ed973ff8dca6b01e0d03b9885c34276cf678cd0f6bae1eee836d921a3bcbdbbf4a21eadd9f1686b90 + languageName: node + linkType: hard + "react-is@npm:18.1.0": version: 18.1.0 resolution: "react-is@npm:18.1.0" @@ -12268,6 +12289,7 @@ __metadata: "@vitejs/plugin-react": "npm:^4.3.0" "@vitest/coverage-istanbul": "npm:^1.6.0" "@xterm/addon-fit": "npm:^0.10.0" + "@xterm/addon-serialize": "npm:^0.13.0" "@xterm/xterm": "npm:^5.5.0" base64-js: "npm:^1.5.1" clsx: "npm:^2.1.1" @@ -12289,6 +12311,7 @@ __metadata: react-dnd-html5-backend: "npm:^16.0.1" react-dom: "npm:^18.3.1" react-frame-component: "npm:^5.2.7" + react-gauge-chart: "npm:^0.5.1" react-markdown: "npm:^9.0.1" remark-gfm: "npm:^4.0.0" rxjs: "npm:^7.8.1"