waveterm/frontend/app/view/term/termwrap.ts
Evan Simkowitz 64084d3e27
Remove global.ts dependency from emain (#1003)
Removes global atoms dependency from emain by moving WOS to grab the
globalAtoms from window, if present. Also removes interdependency
between wshrpcutil and wps

Also adds showmenubar setting for Windows and Linux
2024-10-10 10:12:42 -07:00

273 lines
9.3 KiB
TypeScript

// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { getFileSubject } from "@/app/store/wps";
import { sendWSCommand } from "@/app/store/ws";
import { RpcApi } from "@/app/store/wshclientapi";
import { WindowRpcClient } from "@/app/store/wshrpcutil";
import { PLATFORM, WOS, atoms, fetchWaveFile, globalStore, openLink } from "@/store/global";
import * as services from "@/store/services";
import * as util from "@/util/util";
import { base64ToArray, fireAndForget } from "@/util/util";
import { SerializeAddon } from "@xterm/addon-serialize";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { WebglAddon } from "@xterm/addon-webgl";
import * as TermTypes from "@xterm/xterm";
import { Terminal } from "@xterm/xterm";
import debug from "debug";
import { debounce } from "throttle-debounce";
import { FitAddon } from "./fitaddon";
const dlog = debug("wave:termwrap");
const TermFileName = "term";
const TermCacheFileName = "cache:term:full";
const MinDataProcessedForCache = 100 * 1024;
// detect webgl support
function detectWebGLSupport(): boolean {
try {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("webgl");
return !!ctx;
} catch (e) {
return false;
}
}
const WebGLSupported = detectWebGLSupport();
let loggedWebGL = false;
type TermWrapOptions = {
keydownHandler?: (e: KeyboardEvent) => boolean;
useWebGl?: boolean;
};
export class TermWrap {
blockId: string;
ptyOffset: number;
dataBytesProcessed: number;
terminal: Terminal;
connectElem: HTMLDivElement;
fitAddon: FitAddon;
serializeAddon: SerializeAddon;
mainFileSubject: SubjectWithRef<WSFileEventData>;
loaded: boolean;
heldData: Uint8Array[];
handleResize_debounced: () => void;
isRunning: boolean;
hasResized: boolean;
constructor(
blockId: string,
connectElem: HTMLDivElement,
options: TermTypes.ITerminalOptions & TermTypes.ITerminalInitOnlyOptions,
waveOptions: TermWrapOptions
) {
this.loaded = false;
this.blockId = blockId;
this.ptyOffset = 0;
this.dataBytesProcessed = 0;
this.hasResized = false;
this.terminal = new Terminal(options);
this.fitAddon = new FitAddon();
this.fitAddon.noScrollbar = PLATFORM == "darwin";
this.serializeAddon = new SerializeAddon();
this.terminal.loadAddon(this.fitAddon);
this.terminal.loadAddon(this.serializeAddon);
this.terminal.loadAddon(
new WebLinksAddon((e, uri) => {
e.preventDefault();
switch (PLATFORM) {
case "darwin":
if (e.metaKey) {
fireAndForget(() => openLink(uri));
}
break;
default:
if (e.ctrlKey) {
fireAndForget(() => openLink(uri));
}
break;
}
})
);
if (WebGLSupported && waveOptions.useWebGl) {
const webglAddon = new WebglAddon();
webglAddon.onContextLoss(() => {
webglAddon.dispose();
});
this.terminal.loadAddon(webglAddon);
if (!loggedWebGL) {
console.log("loaded webgl!");
loggedWebGL = true;
}
}
this.terminal.parser.registerOscHandler(7, (data: string) => {
if (!this.loaded) {
return false;
}
if (data == null || data.length == 0) {
return false;
}
if (data.startsWith("file://")) {
data = data.substring(7);
const nextSlashIdx = data.indexOf("/");
if (nextSlashIdx == -1) {
return false;
}
data = data.substring(nextSlashIdx);
}
setTimeout(() => {
services.ObjectService.UpdateObjectMeta(WOS.makeORef("block", this.blockId), { "cmd:cwd": data });
}, 0);
return true;
});
this.terminal.attachCustomKeyEventHandler(waveOptions.keydownHandler);
this.connectElem = connectElem;
this.mainFileSubject = null;
this.heldData = [];
this.handleResize_debounced = debounce(50, this.handleResize.bind(this));
this.terminal.open(this.connectElem);
this.handleResize();
this.isRunning = true;
}
async initTerminal() {
this.terminal.onData(this.handleTermData.bind(this));
this.mainFileSubject = getFileSubject(this.blockId, TermFileName);
this.mainFileSubject.subscribe(this.handleNewFileSubjectData.bind(this));
try {
await this.loadInitialTerminalData();
} finally {
this.loaded = true;
}
this.runProcessIdleTimeout();
}
setIsRunning(isRunning: boolean) {
this.isRunning = isRunning;
}
dispose() {
this.terminal.dispose();
this.mainFileSubject.release();
}
handleTermData(data: string) {
const b64data = util.stringToBase64(data);
RpcApi.ControllerInputCommand(WindowRpcClient, { blockid: this.blockId, inputdata64: b64data });
}
addFocusListener(focusFn: () => void) {
this.terminal.textarea.addEventListener("focus", focusFn);
}
handleNewFileSubjectData(msg: WSFileEventData) {
if (msg.fileop == "truncate") {
this.terminal.clear();
this.heldData = [];
} else if (msg.fileop == "append") {
const decodedData = base64ToArray(msg.data64);
if (this.loaded) {
this.doTerminalWrite(decodedData, null);
} else {
this.heldData.push(decodedData);
}
} else {
console.log("bad fileop for terminal", msg);
return;
}
}
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, TermCacheFileName);
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, TermFileName, 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);
}
}
async resyncController(reason: string) {
dlog("resync controller", this.blockId, reason);
const tabId = globalStore.get(atoms.activeTabId);
const rtOpts: RuntimeOpts = { termsize: { rows: this.terminal.rows, cols: this.terminal.cols } };
try {
await RpcApi.ControllerResyncCommand(WindowRpcClient, {
tabid: tabId,
blockid: this.blockId,
rtopts: rtOpts,
});
} catch (e) {
console.log(`error controller resync (${reason})`, this.blockId, e);
}
}
handleResize() {
const oldRows = this.terminal.rows;
const oldCols = this.terminal.cols;
this.fitAddon.fit();
if (oldRows !== this.terminal.rows || oldCols !== this.terminal.cols) {
const termSize: TermSize = { rows: this.terminal.rows, cols: this.terminal.cols };
const wsCommand: SetBlockTermSizeWSCommand = {
wscommand: "setblocktermsize",
blockid: this.blockId,
termsize: termSize,
};
sendWSCommand(wsCommand);
}
dlog("resize", `${this.terminal.rows}x${this.terminal.cols}`, `${oldRows}x${oldCols}`, this.hasResized);
if (!this.hasResized) {
this.hasResized = true;
this.resyncController("initial resize");
}
}
processAndCacheData() {
if (this.dataBytesProcessed < MinDataProcessedForCache) {
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);
}
}