2024-05-14 08:45:41 +02:00
|
|
|
// Copyright 2024, Command Line Inc.
|
|
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
|
2024-06-14 08:54:04 +02:00
|
|
|
import {
|
|
|
|
WOS,
|
|
|
|
atoms,
|
|
|
|
getBackendHostPort,
|
|
|
|
getFileSubject,
|
|
|
|
globalStore,
|
|
|
|
sendWSCommand,
|
|
|
|
useBlockAtom,
|
|
|
|
} from "@/store/global";
|
2024-06-12 02:42:10 +02:00
|
|
|
import * as services from "@/store/services";
|
2024-05-15 01:53:03 +02:00
|
|
|
import { base64ToArray } from "@/util/util";
|
2024-05-28 21:12:28 +02:00
|
|
|
import { FitAddon } from "@xterm/addon-fit";
|
|
|
|
import type { ITheme } from "@xterm/xterm";
|
|
|
|
import { Terminal } from "@xterm/xterm";
|
2024-06-13 23:41:28 +02:00
|
|
|
import clsx from "clsx";
|
2024-06-14 08:54:04 +02:00
|
|
|
import { produce } from "immer";
|
|
|
|
import * as jotai from "jotai";
|
2024-05-28 21:12:28 +02:00
|
|
|
import * as React from "react";
|
2024-06-14 08:54:04 +02:00
|
|
|
import { IJsonView } from "./ijson";
|
2024-05-14 08:45:41 +02:00
|
|
|
|
2024-06-14 01:49:25 +02:00
|
|
|
import "public/xterm.css";
|
2024-06-06 23:57:37 +02:00
|
|
|
import { debounce } from "throttle-debounce";
|
2024-05-14 08:45:41 +02:00
|
|
|
import "./view.less";
|
|
|
|
|
|
|
|
function getThemeFromCSSVars(el: Element): ITheme {
|
|
|
|
const theme: ITheme = {};
|
|
|
|
const elemStyle = getComputedStyle(el);
|
|
|
|
theme.foreground = elemStyle.getPropertyValue("--term-foreground");
|
|
|
|
theme.background = elemStyle.getPropertyValue("--term-background");
|
|
|
|
theme.black = elemStyle.getPropertyValue("--term-black");
|
|
|
|
theme.red = elemStyle.getPropertyValue("--term-red");
|
|
|
|
theme.green = elemStyle.getPropertyValue("--term-green");
|
|
|
|
theme.yellow = elemStyle.getPropertyValue("--term-yellow");
|
|
|
|
theme.blue = elemStyle.getPropertyValue("--term-blue");
|
|
|
|
theme.magenta = elemStyle.getPropertyValue("--term-magenta");
|
|
|
|
theme.cyan = elemStyle.getPropertyValue("--term-cyan");
|
|
|
|
theme.white = elemStyle.getPropertyValue("--term-white");
|
|
|
|
theme.brightBlack = elemStyle.getPropertyValue("--term-bright-black");
|
|
|
|
theme.brightRed = elemStyle.getPropertyValue("--term-bright-red");
|
|
|
|
theme.brightGreen = elemStyle.getPropertyValue("--term-bright-green");
|
|
|
|
theme.brightYellow = elemStyle.getPropertyValue("--term-bright-yellow");
|
|
|
|
theme.brightBlue = elemStyle.getPropertyValue("--term-bright-blue");
|
|
|
|
theme.brightMagenta = elemStyle.getPropertyValue("--term-bright-magenta");
|
|
|
|
theme.brightCyan = elemStyle.getPropertyValue("--term-bright-cyan");
|
|
|
|
theme.brightWhite = elemStyle.getPropertyValue("--term-bright-white");
|
|
|
|
theme.selectionBackground = elemStyle.getPropertyValue("--term-selection-background");
|
|
|
|
theme.selectionInactiveBackground = elemStyle.getPropertyValue("--term-selection-background");
|
|
|
|
theme.cursor = elemStyle.getPropertyValue("--term-selection-background");
|
|
|
|
theme.cursorAccent = elemStyle.getPropertyValue("--term-cursor-accent");
|
|
|
|
return theme;
|
|
|
|
}
|
|
|
|
|
2024-06-05 08:47:18 +02:00
|
|
|
function handleResize(fitAddon: FitAddon, blockId: string, term: Terminal) {
|
2024-06-12 02:42:10 +02:00
|
|
|
if (term == null) {
|
|
|
|
return;
|
|
|
|
}
|
2024-06-05 08:47:18 +02:00
|
|
|
const oldRows = term.rows;
|
|
|
|
const oldCols = term.cols;
|
|
|
|
fitAddon.fit();
|
|
|
|
if (oldRows !== term.rows || oldCols !== term.cols) {
|
2024-06-12 23:18:03 +02:00
|
|
|
const wsCommand: SetBlockTermSizeWSCommand = {
|
|
|
|
wscommand: "setblocktermsize",
|
|
|
|
blockid: blockId,
|
2024-06-12 22:47:13 +02:00
|
|
|
termsize: { rows: term.rows, cols: term.cols },
|
|
|
|
};
|
2024-06-12 23:18:03 +02:00
|
|
|
sendWSCommand(wsCommand);
|
2024-06-05 08:47:18 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-06-14 08:54:04 +02:00
|
|
|
const keyMap = {
|
|
|
|
Enter: "\r",
|
|
|
|
Backspace: "\x7f",
|
|
|
|
Tab: "\t",
|
|
|
|
Escape: "\x1b",
|
|
|
|
ArrowUp: "\x1b[A",
|
|
|
|
ArrowDown: "\x1b[B",
|
|
|
|
ArrowRight: "\x1b[C",
|
|
|
|
ArrowLeft: "\x1b[D",
|
|
|
|
Insert: "\x1b[2~",
|
|
|
|
Delete: "\x1b[3~",
|
|
|
|
Home: "\x1b[1~",
|
|
|
|
End: "\x1b[4~",
|
|
|
|
PageUp: "\x1b[5~",
|
|
|
|
PageDown: "\x1b[6~",
|
|
|
|
};
|
|
|
|
|
|
|
|
function keyboardEventToASCII(event: React.KeyboardEvent<HTMLInputElement>): string {
|
|
|
|
// check modifiers
|
|
|
|
// if no modifiers are set, just send the key
|
|
|
|
if (!event.altKey && !event.ctrlKey && !event.metaKey) {
|
|
|
|
if (event.key == null || event.key == "") {
|
|
|
|
return "";
|
|
|
|
}
|
|
|
|
if (keyMap[event.key] != null) {
|
|
|
|
return keyMap[event.key];
|
|
|
|
}
|
|
|
|
if (event.key.length == 1) {
|
|
|
|
return event.key;
|
|
|
|
} else {
|
|
|
|
console.log("not sending keyboard event", event.key, event);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
// if meta or alt is set, there is no ASCII representation
|
|
|
|
if (event.metaKey || event.altKey) {
|
|
|
|
return "";
|
|
|
|
}
|
|
|
|
// if ctrl is set, if it is a letter, subtract 64 from the uppercase value to get the ASCII value
|
|
|
|
if (event.ctrlKey) {
|
|
|
|
if (
|
|
|
|
(event.key.length === 1 && event.key >= "A" && event.key <= "Z") ||
|
|
|
|
(event.key >= "a" && event.key <= "z")
|
|
|
|
) {
|
|
|
|
const key = event.key.toUpperCase();
|
|
|
|
return String.fromCharCode(key.charCodeAt(0) - 64);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return "";
|
|
|
|
}
|
|
|
|
|
2024-05-29 09:28:25 +02:00
|
|
|
type InitialLoadDataType = {
|
|
|
|
loaded: boolean;
|
|
|
|
heldData: Uint8Array[];
|
|
|
|
};
|
|
|
|
|
2024-06-14 08:54:04 +02:00
|
|
|
const IJSONConst = {
|
|
|
|
tag: "div",
|
|
|
|
children: [
|
|
|
|
{
|
|
|
|
tag: "h1",
|
|
|
|
children: ["Hello World"],
|
|
|
|
},
|
|
|
|
{
|
|
|
|
tag: "p",
|
|
|
|
children: ["This is a paragraph"],
|
|
|
|
},
|
|
|
|
],
|
|
|
|
};
|
|
|
|
|
|
|
|
function setBlockFocus(blockId: string) {
|
|
|
|
let winData = globalStore.get(atoms.waveWindow);
|
|
|
|
winData = produce(winData, (draft) => {
|
|
|
|
draft.activeblockid = blockId;
|
|
|
|
});
|
|
|
|
WOS.setObjectValue(winData, globalStore.set, true);
|
|
|
|
}
|
|
|
|
|
2024-05-14 08:45:41 +02:00
|
|
|
const TerminalView = ({ blockId }: { blockId: string }) => {
|
|
|
|
const connectElemRef = React.useRef<HTMLDivElement>(null);
|
2024-05-16 09:29:58 +02:00
|
|
|
const termRef = React.useRef<Terminal>(null);
|
2024-05-29 09:28:25 +02:00
|
|
|
const initialLoadRef = React.useRef<InitialLoadDataType>({ loaded: false, heldData: [] });
|
2024-06-13 23:41:28 +02:00
|
|
|
const htmlElemFocusRef = React.useRef<HTMLInputElement>(null);
|
|
|
|
const [blockData] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", blockId));
|
2024-06-14 08:54:04 +02:00
|
|
|
const isFocusedAtom = useBlockAtom<boolean>(blockId, "isFocused", () => {
|
|
|
|
return jotai.atom((get) => {
|
|
|
|
const winData = get(atoms.waveWindow);
|
|
|
|
return winData.activeblockid === blockId;
|
|
|
|
});
|
|
|
|
});
|
|
|
|
const isFocused = jotai.useAtomValue(isFocusedAtom);
|
2024-05-14 08:45:41 +02:00
|
|
|
React.useEffect(() => {
|
2024-06-07 00:08:39 +02:00
|
|
|
console.log("terminal created");
|
|
|
|
const newTerm = new Terminal({
|
|
|
|
theme: getThemeFromCSSVars(connectElemRef.current),
|
|
|
|
fontSize: 12,
|
|
|
|
fontFamily: "Hack",
|
|
|
|
drawBoldTextInBrightColors: false,
|
|
|
|
fontWeight: "normal",
|
|
|
|
fontWeightBold: "bold",
|
|
|
|
});
|
|
|
|
termRef.current = newTerm;
|
|
|
|
const newFitAddon = new FitAddon();
|
|
|
|
newTerm.loadAddon(newFitAddon);
|
|
|
|
newTerm.open(connectElemRef.current);
|
|
|
|
newFitAddon.fit();
|
2024-06-12 23:18:03 +02:00
|
|
|
sendWSCommand({
|
|
|
|
wscommand: "setblocktermsize",
|
|
|
|
blockid: blockId,
|
2024-06-07 00:08:39 +02:00
|
|
|
termsize: { rows: newTerm.rows, cols: newTerm.cols },
|
|
|
|
});
|
2024-06-14 09:15:09 +02:00
|
|
|
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
|
|
|
|
);
|
2024-06-07 00:08:39 +02:00
|
|
|
newTerm.onData((data) => {
|
|
|
|
const b64data = btoa(data);
|
2024-06-12 22:47:13 +02:00
|
|
|
const inputCmd: BlockInputCommand = { command: "controller:input", inputdata64: b64data };
|
2024-06-12 02:42:10 +02:00
|
|
|
services.BlockService.SendCommand(blockId, inputCmd);
|
2024-06-07 00:08:39 +02:00
|
|
|
});
|
2024-06-14 08:54:04 +02:00
|
|
|
newTerm.textarea.addEventListener("focus", () => {
|
|
|
|
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);
|
2024-06-07 00:08:39 +02:00
|
|
|
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");
|
2024-06-12 02:42:10 +02:00
|
|
|
fetch(getBackendHostPort() + "/wave/file?" + usp.toString())
|
2024-06-07 00:08:39 +02:00
|
|
|
.then((resp) => {
|
|
|
|
if (resp.ok) {
|
|
|
|
return resp.arrayBuffer();
|
2024-06-06 23:57:37 +02:00
|
|
|
}
|
2024-06-07 00:08:39 +02:00
|
|
|
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`);
|
2024-06-06 23:57:37 +02:00
|
|
|
});
|
|
|
|
|
2024-06-12 02:42:10 +02:00
|
|
|
const resize_debounced = debounce(50, () => {
|
|
|
|
handleResize(newFitAddon, blockId, newTerm);
|
|
|
|
});
|
|
|
|
const rszObs = new ResizeObserver(() => {
|
|
|
|
resize_debounced();
|
|
|
|
});
|
|
|
|
rszObs.observe(connectElemRef.current);
|
|
|
|
|
2024-06-07 00:08:39 +02:00
|
|
|
return () => {
|
|
|
|
newTerm.dispose();
|
2024-06-14 08:54:04 +02:00
|
|
|
mainFileSubject.release();
|
2024-06-07 00:08:39 +02:00
|
|
|
};
|
|
|
|
}, []);
|
2024-06-06 23:57:37 +02:00
|
|
|
|
2024-06-13 23:41:28 +02:00
|
|
|
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
|
|
if (event.code === "Escape" && event.metaKey) {
|
|
|
|
// reset term:mode
|
|
|
|
const metaCmd: BlockSetMetaCommand = { command: "setmeta", meta: { "term:mode": null } };
|
|
|
|
services.BlockService.SendCommand(blockId, metaCmd);
|
|
|
|
return false;
|
|
|
|
}
|
2024-06-14 08:54:04 +02:00
|
|
|
const asciiVal = keyboardEventToASCII(event);
|
|
|
|
if (asciiVal.length == 0) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
const b64data = btoa(asciiVal);
|
|
|
|
const inputCmd: BlockInputCommand = { command: "controller:input", inputdata64: b64data };
|
|
|
|
services.BlockService.SendCommand(blockId, inputCmd);
|
2024-06-13 23:41:28 +02:00
|
|
|
return true;
|
|
|
|
};
|
|
|
|
|
|
|
|
let termMode = blockData?.meta?.["term:mode"] ?? "term";
|
|
|
|
if (termMode != "term" && termMode != "html") {
|
|
|
|
termMode = "term";
|
|
|
|
}
|
2024-06-14 08:54:04 +02:00
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
if (isFocused && termMode == "term") {
|
|
|
|
termRef.current?.focus();
|
|
|
|
}
|
|
|
|
if (isFocused && termMode == "html") {
|
|
|
|
htmlElemFocusRef.current?.focus();
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
2024-05-15 01:53:03 +02:00
|
|
|
return (
|
2024-06-14 08:54:04 +02:00
|
|
|
<div className={clsx("view-term", "term-mode-" + termMode, isFocused ? "is-focused" : null)}>
|
2024-05-15 01:53:03 +02:00
|
|
|
<div key="conntectElem" className="term-connectelem" ref={connectElemRef}></div>
|
2024-06-13 23:41:28 +02:00
|
|
|
<div
|
|
|
|
key="htmlElem"
|
|
|
|
className="term-htmlelem"
|
|
|
|
onClick={() => {
|
|
|
|
if (htmlElemFocusRef.current != null) {
|
|
|
|
htmlElemFocusRef.current.focus();
|
|
|
|
}
|
2024-06-14 08:54:04 +02:00
|
|
|
setBlockFocus(blockId);
|
2024-06-13 23:41:28 +02:00
|
|
|
}}
|
|
|
|
>
|
|
|
|
<div key="htmlElemFocus" className="term-htmlelem-focus">
|
2024-06-14 08:54:04 +02:00
|
|
|
<input
|
|
|
|
type="text"
|
|
|
|
value={""}
|
|
|
|
ref={htmlElemFocusRef}
|
|
|
|
onKeyDown={handleKeyDown}
|
|
|
|
onChange={() => {}}
|
|
|
|
/>
|
2024-06-13 23:41:28 +02:00
|
|
|
</div>
|
|
|
|
<div key="htmlElemContent" className="term-htmlelem-content">
|
2024-06-14 08:54:04 +02:00
|
|
|
<IJsonView rootNode={IJSONConst} />
|
2024-06-13 23:41:28 +02:00
|
|
|
</div>
|
|
|
|
</div>
|
2024-05-15 01:53:03 +02:00
|
|
|
</div>
|
|
|
|
);
|
2024-05-14 08:45:41 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
export { TerminalView };
|