stickers and terminal serialization (#57)

This commit is contained in:
Mike Sawka 2024-06-17 22:38:48 -07:00 committed by GitHub
parent b6c85e38f6
commit 4ded6d94b6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 670 additions and 147 deletions

View File

@ -1,8 +1,13 @@
// Copyright 2024, Command Line Inc. // Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0 // 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 jotai from "jotai";
import * as rxjs from "rxjs"; import * as rxjs from "rxjs";
import * as services from "./services";
import * as WOS from "./wos"; import * as WOS from "./wos";
import { WSControl } from "./ws"; import { WSControl } from "./ws";
@ -62,8 +67,6 @@ const atoms = {
workspace: workspaceAtom, workspace: workspaceAtom,
}; };
type SubjectWithRef<T> = rxjs.Subject<T> & { refCount: number; release: () => void };
// key is "eventType" or "eventType|oref" // key is "eventType" or "eventType|oref"
const eventSubjects = new Map<string, SubjectWithRef<WSEventType>>(); const eventSubjects = new Map<string, SubjectWithRef<WSEventType>>();
const fileSubjects = new Map<string, SubjectWithRef<WSFileEventData>>(); const fileSubjects = new Map<string, SubjectWithRef<WSFileEventData>>();
@ -215,9 +218,58 @@ function getApi(): ElectronApi {
return (window as any).api; 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<TabLayoutData> = {
type: LayoutTreeActionType.InsertNode,
node: newLayoutNode<TabLayoutData>(undefined, undefined, undefined, { blockId }),
};
const activeTabId = globalStore.get(atoms.uiContext).activetabid;
const layoutStateAtom = getLayoutStateAtomForTab(
activeTabId,
WOS.getWaveObjectAtom<Tab>(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 { export {
WOS, WOS,
atoms, atoms,
createBlock,
fetchWaveFile,
getApi, getApi,
getBackendHostPort, getBackendHostPort,
getEventORefSubject, getEventORefSubject,

View File

@ -7,6 +7,10 @@ import * as WOS from "./wos";
// blockservice.BlockService (block) // blockservice.BlockService (block)
class BlockServiceType { class BlockServiceType {
SaveTerminalState(arg2: string, arg3: string, arg4: string, arg5: number): Promise<void> {
return WOS.callBackendService("block", "SaveTerminalState", Array.from(arguments))
}
// send command to block // send command to block
SendCommand(blockid: string, cmd: BlockCommand): Promise<void> { SendCommand(blockid: string, cmd: BlockCommand): Promise<void> {
return WOS.callBackendService("block", "SendCommand", Array.from(arguments)) return WOS.callBackendService("block", "SendCommand", Array.from(arguments))

View File

@ -27,4 +27,5 @@
/* z-index values */ /* z-index values */
--zindex-header-hover: 100; --zindex-header-hover: 100;
--zindex-termstickers: 20;
} }

View File

@ -9,6 +9,7 @@
overflow: hidden; overflow: hidden;
border-left: 4px solid transparent; border-left: 4px solid transparent;
padding-left: 4px; padding-left: 4px;
position: relative;
&:focus-within { &:focus-within {
border-left: 4px solid var(--accent-color); border-left: 4px solid var(--accent-color);
@ -84,4 +85,30 @@
border: none; 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%;
}
}
}
} }

View File

@ -1,17 +1,8 @@
// Copyright 2024, Command Line Inc. // Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
import { import { WOS, atoms, globalStore, sendWSCommand, useBlockAtom } from "@/store/global";
WOS,
atoms,
getBackendHostPort,
getFileSubject,
globalStore,
sendWSCommand,
useBlockAtom,
} from "@/store/global";
import * as services from "@/store/services"; import * as services from "@/store/services";
import { base64ToArray } from "@/util/util";
import { FitAddon } from "@xterm/addon-fit"; import { FitAddon } from "@xterm/addon-fit";
import type { ITheme } from "@xterm/xterm"; import type { ITheme } from "@xterm/xterm";
import { Terminal } from "@xterm/xterm"; import { Terminal } from "@xterm/xterm";
@ -20,9 +11,10 @@ import { produce } from "immer";
import * as jotai from "jotai"; import * as jotai from "jotai";
import * as React from "react"; import * as React from "react";
import { IJsonView } from "./ijson"; import { IJsonView } from "./ijson";
import { TermStickers } from "./termsticker";
import { TermWrap } from "./termwrap";
import "public/xterm.css"; import "public/xterm.css";
import { debounce } from "throttle-debounce";
import "./term.less"; import "./term.less";
function getThemeFromCSSVars(el: Element): ITheme { function getThemeFromCSSVars(el: Element): ITheme {
@ -149,7 +141,7 @@ function setBlockFocus(blockId: string) {
const TerminalView = ({ blockId }: { blockId: string }) => { const TerminalView = ({ blockId }: { blockId: string }) => {
const connectElemRef = React.useRef<HTMLDivElement>(null); const connectElemRef = React.useRef<HTMLDivElement>(null);
const termRef = React.useRef<Terminal>(null); const termRef = React.useRef<TermWrap>(null);
const initialLoadRef = React.useRef<InitialLoadDataType>({ loaded: false, heldData: [] }); const initialLoadRef = React.useRef<InitialLoadDataType>({ loaded: false, heldData: [] });
const htmlElemFocusRef = React.useRef<HTMLInputElement>(null); const htmlElemFocusRef = React.useRef<HTMLInputElement>(null);
const [blockData] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", blockId)); const [blockData] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", blockId));
@ -161,8 +153,7 @@ const TerminalView = ({ blockId }: { blockId: string }) => {
}); });
const isFocused = jotai.useAtomValue(isFocusedAtom); const isFocused = jotai.useAtomValue(isFocusedAtom);
React.useEffect(() => { React.useEffect(() => {
console.log("terminal created"); const termWrap = new TermWrap(blockId, connectElemRef.current, {
const newTerm = new Terminal({
theme: getThemeFromCSSVars(connectElemRef.current), theme: getThemeFromCSSVars(connectElemRef.current),
fontSize: 12, fontSize: 12,
fontFamily: "Hack", fontFamily: "Hack",
@ -170,93 +161,22 @@ const TerminalView = ({ blockId }: { blockId: string }) => {
fontWeight: "normal", fontWeight: "normal",
fontWeightBold: "bold", fontWeightBold: "bold",
}); });
termRef.current = newTerm; (window as any).term = termWrap;
const newFitAddon = new FitAddon(); termRef.current = termWrap;
newTerm.loadAddon(newFitAddon); termWrap.addFocusListener(() => {
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", () => {
setBlockFocus(blockId); 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(() => { const rszObs = new ResizeObserver(() => {
resize_debounced(); termWrap.handleResize_debounced();
}); });
rszObs.observe(connectElemRef.current); rszObs.observe(connectElemRef.current);
termWrap.initTerminal();
return () => { return () => {
newTerm.dispose(); termWrap.dispose();
mainFileSubject.release();
}; };
}, []); }, []);
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => { const handleHtmlKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.code === "Escape" && event.metaKey) { if (event.code === "Escape" && event.metaKey) {
// reset term:mode // reset term:mode
const metaCmd: BlockSetMetaCommand = { command: "setmeta", meta: { "term:mode": null } }; const metaCmd: BlockSetMetaCommand = { command: "setmeta", meta: { "term:mode": null } };
@ -280,15 +200,24 @@ const TerminalView = ({ blockId }: { blockId: string }) => {
React.useEffect(() => { React.useEffect(() => {
if (isFocused && termMode == "term") { if (isFocused && termMode == "term") {
termRef.current?.focus(); termRef.current?.terminal.focus();
} }
if (isFocused && termMode == "html") { if (isFocused && termMode == "html") {
htmlElemFocusRef.current?.focus(); htmlElemFocusRef.current?.focus();
} }
}); });
let stickerConfig = {
charWidth: 8,
charHeight: 16,
rows: termRef.current?.terminal.rows ?? 24,
cols: termRef.current?.terminal.cols ?? 80,
blockId: blockId,
};
return ( return (
<div className={clsx("view-term", "term-mode-" + termMode, isFocused ? "is-focused" : null)}> <div className={clsx("view-term", "term-mode-" + termMode, isFocused ? "is-focused" : null)}>
<TermStickers config={stickerConfig} />
<div key="conntectElem" className="term-connectelem" ref={connectElemRef}></div> <div key="conntectElem" className="term-connectelem" ref={connectElemRef}></div>
<div <div
key="htmlElem" key="htmlElem"
@ -305,7 +234,7 @@ const TerminalView = ({ blockId }: { blockId: string }) => {
type="text" type="text"
value={""} value={""}
ref={htmlElemFocusRef} ref={htmlElemFocusRef}
onKeyDown={handleKeyDown} onKeyDown={handleHtmlKeyDown}
onChange={() => {}} onChange={() => {}}
/> />
</div> </div>

View File

@ -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 <GaugeChart id="gauge-chart1" nrOfLevels={20} percent={value / 100} />;
}
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 (
<div className="term-sticker" style={style} onClick={clickHandler}>
<i className={clsx("fa", "fa-" + sticker.icon)} />
</div>
);
}
if (sticker.stickertype == "image") {
if (sticker.imgsrc == null) {
return null;
}
const streamingUrl = getBackendHostPort() + "/wave/stream-file?path=" + encodeURIComponent(sticker.imgsrc);
return (
<div className="term-sticker term-sticker-image" style={style} onClick={clickHandler}>
<img src={streamingUrl} />
</div>
);
}
if (sticker.stickertype == "gauge") {
return (
<div className="term-sticker term-sticker-gauge" style={style}>
<GaugeSticker />
</div>
);
}
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 (
<div className="term-stickers">
{stickers.map((sticker, i) => (
<TermSticker key={i} sticker={sticker} config={config} />
))}
</div>
);
}

View File

@ -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<Uint8Array>;
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<void> {
let resolve: () => void = null;
let prtn = new Promise<void>((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<void> {
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);
}
}

View File

@ -3,41 +3,13 @@
import { TabBar } from "@/app/tab/tabbar"; import { TabBar } from "@/app/tab/tabbar";
import { TabContent } from "@/app/tab/tabcontent"; import { TabContent } from "@/app/tab/tabcontent";
import { atoms } from "@/store/global"; import { atoms, createBlock } from "@/store/global";
import * as services from "@/store/services";
import * as WOS from "@/store/wos";
import * as jotai from "jotai"; import * as jotai from "jotai";
import { CenteredDiv } from "../element/quickelems"; 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"; import "./workspace.less";
function Widgets() { function Widgets() {
const windowData = jotai.useAtomValue(atoms.waveWindow);
const activeTabAtom = useMemo(() => {
return getLayoutStateAtomForTab(
windowData.activetabid,
WOS.getWaveObjectAtom<Tab>(WOS.makeORef("tab", windowData.activetabid))
);
}, [windowData.activetabid]);
const [, dispatchLayoutStateAction] = useLayoutTreeStateReducerAtom(activeTabAtom);
function addBlockToTab(blockId: string) {
const insertNodeAction: LayoutTreeInsertNodeAction<TabLayoutData> = {
type: LayoutTreeActionType.InsertNode,
node: newLayoutNode<TabLayoutData>(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() { async function clickTerminal() {
const termBlockDef = { const termBlockDef = {
controller: "shell", controller: "shell",

View File

@ -10,6 +10,8 @@ declare global {
isDev: () => boolean; isDev: () => boolean;
isDevServer: () => boolean; isDevServer: () => boolean;
}; };
type SubjectWithRef<T> = rxjs.Subject<T> & { refCount: number; release: () => void };
} }
export {}; export {};

View File

@ -4,12 +4,14 @@
// generated by cmd/generate/main-generate.go // generated by cmd/generate/main-generate.go
declare global { declare global {
// wstore.Block // wstore.Block
type Block = WaveObj & { type Block = WaveObj & {
blockdef: BlockDef; blockdef: BlockDef;
controller: string; controller: string;
view: string; view: string;
runtimeopts?: RuntimeOpts; runtimeopts?: RuntimeOpts;
stickers?: StickerType[];
meta: MetaType; meta: MetaType;
}; };
@ -29,28 +31,20 @@ declare global {
type BlockCommand = { type BlockCommand = {
command: string; command: string;
} & ( } & ( BlockAppendIJsonCommand | ResolveIdsCommand | BlockInputCommand | BlockSetViewCommand | BlockSetMetaCommand | BlockGetMetaCommand | BlockMessageCommand | BlockAppendFileCommand );
| BlockSetMetaCommand
| BlockGetMetaCommand
| BlockMessageCommand
| BlockAppendFileCommand
| BlockAppendIJsonCommand
| BlockInputCommand
| BlockSetViewCommand
);
// wstore.BlockDef // wstore.BlockDef
type BlockDef = { type BlockDef = {
controller?: string; controller?: string;
view?: string; view?: string;
files?: { [key: string]: FileDef }; files?: {[key: string]: FileDef};
meta?: MetaType; meta?: MetaType;
}; };
// wshutil.BlockGetMetaCommand // wshutil.BlockGetMetaCommand
type BlockGetMetaCommand = { type BlockGetMetaCommand = {
command: "getmeta"; command: "getmeta";
oid: string; oref: string;
}; };
// wshutil.BlockInputCommand // wshutil.BlockInputCommand
@ -61,6 +55,13 @@ declare global {
termsize?: TermSize; termsize?: TermSize;
}; };
// webcmd.BlockInputWSCommand
type BlockInputWSCommand = {
wscommand: "blockinput";
blockid: string;
inputdata64: string;
};
// wshutil.BlockMessageCommand // wshutil.BlockMessageCommand
type BlockMessageCommand = { type BlockMessageCommand = {
command: "message"; command: "message";
@ -70,7 +71,7 @@ declare global {
// wshutil.BlockSetMetaCommand // wshutil.BlockSetMetaCommand
type BlockSetMetaCommand = { type BlockSetMetaCommand = {
command: "setmeta"; command: "setmeta";
oid?: string; oref?: string;
meta: MetaType; meta: MetaType;
}; };
@ -106,6 +107,14 @@ declare global {
mimetype?: string; mimetype?: string;
}; };
// filestore.FileOptsType
type FileOptsType = {
maxsize?: number;
circular?: boolean;
ijson?: boolean;
ijsonbudget?: number;
};
// fileservice.FullFile // fileservice.FullFile
type FullFile = { type FullFile = {
info: FileInfo; info: FileInfo;
@ -118,7 +127,7 @@ declare global {
meta?: MetaType; meta?: MetaType;
}; };
type MetaType = { [key: string]: any }; type MetaType = {[key: string]: any}
// tsgenmeta.MethodMeta // tsgenmeta.MethodMeta
type MethodMeta = { type MethodMeta = {
@ -139,6 +148,12 @@ declare global {
y: number; y: number;
}; };
// wshutil.ResolveIdsCommand
type ResolveIdsCommand = {
command: "resolveids";
ids: string[];
};
// wstore.RuntimeOpts // wstore.RuntimeOpts
type RuntimeOpts = { type RuntimeOpts = {
termsize?: TermSize; termsize?: TermSize;
@ -152,6 +167,27 @@ declare global {
termsize: TermSize; 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 // wstore.Tab
type Tab = WaveObj & { type Tab = WaveObj & {
name: string; name: string;
@ -174,7 +210,7 @@ declare global {
type WSCommandType = { type WSCommandType = {
wscommand: string; wscommand: string;
} & SetBlockTermSizeWSCommand; } & ( SetBlockTermSizeWSCommand | BlockInputWSCommand );
// eventbus.WSEventType // eventbus.WSEventType
type WSEventType = { type WSEventType = {
@ -191,6 +227,17 @@ declare global {
data64: string; data64: string;
}; };
// filestore.WaveFile
type WaveFile = {
zoneid: string;
name: string;
opts: FileOptsType;
createdts: number;
size: number;
modts: number;
meta: MetaType;
};
// waveobj.WaveObj // waveobj.WaveObj
type WaveObj = { type WaveObj = {
otype: string; otype: string;
@ -233,7 +280,7 @@ declare global {
workspaceid: string; workspaceid: string;
activetabid: string; activetabid: string;
activeblockid?: string; activeblockid?: string;
activeblockmap: { [key: string]: string }; activeblockmap: {[key: string]: string};
pos: Point; pos: Point;
winsize: WinSize; winsize: WinSize;
lastfocusts: number; lastfocusts: number;
@ -246,6 +293,7 @@ declare global {
tabids: string[]; tabids: string[];
meta: MetaType; meta: MetaType;
}; };
} }
export {}; export {}

View File

@ -55,6 +55,7 @@
"@react-hook/resize-observer": "^2.0.1", "@react-hook/resize-observer": "^2.0.1",
"@tanstack/react-table": "^8.17.3", "@tanstack/react-table": "^8.17.3",
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-fit": "^0.10.0",
"@xterm/addon-serialize": "^0.13.0",
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
"base64-js": "^1.5.1", "base64-js": "^1.5.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@ -69,6 +70,7 @@
"react-dnd-html5-backend": "^16.0.1", "react-dnd-html5-backend": "^16.0.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-frame-component": "^5.2.7", "react-frame-component": "^5.2.7",
"react-gauge-chart": "^0.5.1",
"react-markdown": "^9.0.1", "react-markdown": "^9.0.1",
"remark-gfm": "^4.0.0", "remark-gfm": "^4.0.0",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",

View File

@ -402,7 +402,6 @@ func (bc *BlockController) Run(bdata *wstore.Block) {
} }
inputUnion.InputData = inputBuf[:nw] inputUnion.InputData = inputBuf[:nw]
} }
log.Printf("INPUT: %s | %q\n", bc.BlockId, string(inputUnion.InputData))
bc.ShellInputCh <- inputUnion bc.ShellInputCh <- inputUnion
default: default:
log.Printf("unknown command type %T\n", cmd) log.Printf("unknown command type %T\n", cmd)

View File

@ -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) { return withLockRtn(s, zoneId, name, func(entry *CacheEntry) (*WaveFile, error) {
file, err := entry.loadFileForRead(ctx) file, err := entry.loadFileForRead(ctx)
if err != nil { if err != nil {
if err == fs.ErrNotExist {
return nil, err
}
return nil, fmt.Errorf("error getting file: %v", err) return nil, fmt.Errorf("error getting file: %v", err)
} }
return file.DeepCopy(), nil return file.DeepCopy(), nil

View File

@ -4,13 +4,16 @@
package blockservice package blockservice
import ( import (
"context"
"fmt" "fmt"
"strings" "strings"
"time" "time"
"github.com/wavetermdev/thenextwave/pkg/blockcontroller" "github.com/wavetermdev/thenextwave/pkg/blockcontroller"
"github.com/wavetermdev/thenextwave/pkg/filestore"
"github.com/wavetermdev/thenextwave/pkg/tsgen/tsgenmeta" "github.com/wavetermdev/thenextwave/pkg/tsgen/tsgenmeta"
"github.com/wavetermdev/thenextwave/pkg/wshutil" "github.com/wavetermdev/thenextwave/pkg/wshutil"
"github.com/wavetermdev/thenextwave/pkg/wstore"
) )
type BlockService struct{} type BlockService struct{}
@ -38,3 +41,24 @@ func (bs *BlockService) SendCommand(blockId string, cmd wshutil.BlockCommand) er
} }
return nil 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
}

View File

@ -11,6 +11,7 @@ import (
"strings" "strings"
"github.com/wavetermdev/thenextwave/pkg/eventbus" "github.com/wavetermdev/thenextwave/pkg/eventbus"
"github.com/wavetermdev/thenextwave/pkg/filestore"
"github.com/wavetermdev/thenextwave/pkg/service" "github.com/wavetermdev/thenextwave/pkg/service"
"github.com/wavetermdev/thenextwave/pkg/tsgen/tsgenmeta" "github.com/wavetermdev/thenextwave/pkg/tsgen/tsgenmeta"
"github.com/wavetermdev/thenextwave/pkg/waveobj" "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(wstore.UIContext{}), tsTypesMap)
GenerateTSType(reflect.TypeOf(eventbus.WSEventType{}), tsTypesMap) GenerateTSType(reflect.TypeOf(eventbus.WSEventType{}), tsTypesMap)
GenerateTSType(reflect.TypeOf(eventbus.WSFileEventData{}), tsTypesMap) GenerateTSType(reflect.TypeOf(eventbus.WSFileEventData{}), tsTypesMap)
GenerateTSType(reflect.TypeOf(filestore.WaveFile{}), tsTypesMap)
for _, rtype := range wstore.AllWaveObjTypes() { for _, rtype := range wstore.AllWaveObjTypes() {
GenerateTSType(rtype, tsTypesMap) GenerateTSType(rtype, tsTypesMap)
} }

View File

@ -12,6 +12,7 @@ import (
"log" "log"
"net/http" "net/http"
"runtime/debug" "runtime/debug"
"strconv"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
@ -102,6 +103,15 @@ func marshalReturnValue(data any, err error) []byte {
func handleWaveFile(w http.ResponseWriter, r *http.Request) { func handleWaveFile(w http.ResponseWriter, r *http.Request) {
zoneId := r.URL.Query().Get("zoneid") zoneId := r.URL.Query().Get("zoneid")
name := r.URL.Query().Get("name") 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 { if _, err := uuid.Parse(zoneId); err != nil {
http.Error(w, fmt.Sprintf("invalid zoneid: %v", err), http.StatusBadRequest) http.Error(w, fmt.Sprintf("invalid zoneid: %v", err), http.StatusBadRequest)
return return
@ -113,7 +123,7 @@ func handleWaveFile(w http.ResponseWriter, r *http.Request) {
} }
file, err := filestore.WFS.Stat(r.Context(), zoneId, name) file, err := filestore.WFS.Stat(r.Context(), zoneId, name)
if err == fs.ErrNotExist { if err == fs.ErrNotExist {
http.NotFound(w, r) w.WriteHeader(http.StatusNoContent)
return return
} }
if err != nil { 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) 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 // 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(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(WaveZoneFileInfoHeaderKey, base64.StdEncoding.EncodeToString(jsonFileBArr))
w.Header().Set(LastModifiedHeaderKey, time.UnixMilli(file.ModTs).UTC().Format(http.TimeFormat)) w.Header().Set(LastModifiedHeaderKey, time.UnixMilli(file.ModTs).UTC().Format(http.TimeFormat))
if file.Size == 0 { if dataStartIdx >= file.Size {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
return 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) _, data, err := filestore.WFS.ReadAt(r.Context(), zoneId, name, offset, filestore.DefaultPartDataSize)
if err != nil { if err != nil {
if offset == 0 { if offset == 0 {
@ -177,6 +191,7 @@ func WebFnWrap(opts WebFnOpts, fn WebFnType) WebFnType {
if !opts.AllowCaching { if !opts.AllowCaching {
w.Header().Set(CacheControlHeaderKey, CacheControlHeaderNoCache) w.Header().Set(CacheControlHeaderKey, CacheControlHeaderNoCache)
} }
w.Header().Set("Access-Control-Expose-Headers", "X-ZoneFileInfo")
// reqAuthKey := r.Header.Get("X-AuthKey") // reqAuthKey := r.Header.Get("X-AuthKey")
// if reqAuthKey == "" { // if reqAuthKey == "" {
// w.WriteHeader(http.StatusInternalServerError) // w.WriteHeader(http.StatusInternalServerError)

View File

@ -14,6 +14,7 @@ import (
const ( const (
WSCommand_SetBlockTermSize = "setblocktermsize" WSCommand_SetBlockTermSize = "setblocktermsize"
WSCommand_BlockInput = "blockinput"
) )
type WSCommandType interface { type WSCommandType interface {
@ -26,6 +27,7 @@ func WSCommandTypeUnionMeta() tsgenmeta.TypeUnionMeta {
TypeFieldName: "wscommand", TypeFieldName: "wscommand",
Types: []reflect.Type{ Types: []reflect.Type{
reflect.TypeOf(SetBlockTermSizeWSCommand{}), reflect.TypeOf(SetBlockTermSizeWSCommand{}),
reflect.TypeOf(BlockInputWSCommand{}),
}, },
} }
} }
@ -40,6 +42,16 @@ func (cmd *SetBlockTermSizeWSCommand) GetWSCommand() string {
return cmd.WSCommand 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) { func ParseWSCommandMap(cmdMap map[string]any) (WSCommandType, error) {
cmdType, ok := cmdMap["wscommand"].(string) cmdType, ok := cmdMap["wscommand"].(string)
if !ok { if !ok {
@ -53,6 +65,13 @@ func ParseWSCommandMap(cmdMap map[string]any) (WSCommandType, error) {
return nil, fmt.Errorf("error decoding SetBlockTermSizeWSCommand: %w", err) return nil, fmt.Errorf("error decoding SetBlockTermSizeWSCommand: %w", err)
} }
return &cmd, nil 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: default:
return nil, fmt.Errorf("unknown wscommand type %q", cmdType) return nil, fmt.Errorf("unknown wscommand type %q", cmdType)
} }

View File

@ -100,6 +100,12 @@ func processWSCommand(jmsg map[string]any, outputCh chan any) {
TermSize: &cmd.TermSize, TermSize: &cmd.TermSize,
} }
blockservice.BlockServiceInstance.SendCommand(cmd.BlockId, blockCmd) 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)
} }
} }

View File

@ -201,6 +201,24 @@ type BlockDef struct {
Meta map[string]any `json:"meta,omitempty"` 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 { type RuntimeOpts struct {
TermSize shellexec.TermSize `json:"termsize,omitempty"` TermSize shellexec.TermSize `json:"termsize,omitempty"`
WinSize WinSize `json:"winsize,omitempty"` WinSize WinSize `json:"winsize,omitempty"`
@ -223,6 +241,7 @@ type Block struct {
Controller string `json:"controller"` Controller string `json:"controller"`
View string `json:"view"` View string `json:"view"`
RuntimeOpts *RuntimeOpts `json:"runtimeopts,omitempty"` RuntimeOpts *RuntimeOpts `json:"runtimeopts,omitempty"`
Stickers []*StickerType `json:"stickers,omitempty"`
Meta map[string]any `json:"meta"` Meta map[string]any `json:"meta"`
} }

View File

@ -4706,6 +4706,15 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "@xterm/xterm@npm:^5.5.0":
version: 5.5.0 version: 5.5.0
resolution: "@xterm/xterm@npm:5.5.0" resolution: "@xterm/xterm@npm:5.5.0"
@ -6051,7 +6060,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"d3@npm:^7.9.0": "d3@npm:^7.6.1, d3@npm:^7.9.0":
version: 7.9.0 version: 7.9.0
resolution: "d3@npm:7.9.0" resolution: "d3@npm:7.9.0"
dependencies: dependencies:
@ -11026,6 +11035,18 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "react-is@npm:18.1.0":
version: 18.1.0 version: 18.1.0
resolution: "react-is@npm:18.1.0" resolution: "react-is@npm:18.1.0"
@ -12268,6 +12289,7 @@ __metadata:
"@vitejs/plugin-react": "npm:^4.3.0" "@vitejs/plugin-react": "npm:^4.3.0"
"@vitest/coverage-istanbul": "npm:^1.6.0" "@vitest/coverage-istanbul": "npm:^1.6.0"
"@xterm/addon-fit": "npm:^0.10.0" "@xterm/addon-fit": "npm:^0.10.0"
"@xterm/addon-serialize": "npm:^0.13.0"
"@xterm/xterm": "npm:^5.5.0" "@xterm/xterm": "npm:^5.5.0"
base64-js: "npm:^1.5.1" base64-js: "npm:^1.5.1"
clsx: "npm:^2.1.1" clsx: "npm:^2.1.1"
@ -12289,6 +12311,7 @@ __metadata:
react-dnd-html5-backend: "npm:^16.0.1" react-dnd-html5-backend: "npm:^16.0.1"
react-dom: "npm:^18.3.1" react-dom: "npm:^18.3.1"
react-frame-component: "npm:^5.2.7" react-frame-component: "npm:^5.2.7"
react-gauge-chart: "npm:^0.5.1"
react-markdown: "npm:^9.0.1" react-markdown: "npm:^9.0.1"
remark-gfm: "npm:^4.0.0" remark-gfm: "npm:^4.0.0"
rxjs: "npm:^7.8.1" rxjs: "npm:^7.8.1"