mirror of
https://github.com/wavetermdev/waveterm.git
synced 2024-12-22 16:48:23 +01:00
95b1767c45
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **Bug Fixes** - Improved handling of terminal data processing by ensuring it only occurs when the terminal is fully initialized. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
303 lines
11 KiB
TypeScript
303 lines
11 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 { TabRpcClient } from "@/app/store/wshrpcutil";
|
|
import { PLATFORM, WOS, atoms, fetchWaveFile, getSettingsKeyAtom, 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;
|
|
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(() => {
|
|
fireAndForget(() =>
|
|
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();
|
|
}
|
|
|
|
async initTerminal() {
|
|
const copyOnSelectAtom = getSettingsKeyAtom("term:copyonselect");
|
|
this.terminal.onData(this.handleTermData.bind(this));
|
|
this.terminal.onSelectionChange(
|
|
debounce(50, () => {
|
|
if (!globalStore.get(copyOnSelectAtom)) {
|
|
return;
|
|
}
|
|
const selectedText = this.terminal.getSelection();
|
|
if (selectedText.length > 0) {
|
|
navigator.clipboard.writeText(selectedText);
|
|
}
|
|
})
|
|
);
|
|
this.mainFileSubject = getFileSubject(this.blockId, TermFileName);
|
|
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) {
|
|
if (!this.loaded) {
|
|
return;
|
|
}
|
|
const b64data = util.stringToBase64(data);
|
|
RpcApi.ControllerInputCommand(TabRpcClient, { 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) {
|
|
const curTermSize: TermSize = { rows: this.terminal.rows, cols: this.terminal.cols };
|
|
const fileTermSize: TermSize = cacheFile.meta["termsize"];
|
|
let didResize = false;
|
|
if (
|
|
fileTermSize != null &&
|
|
(fileTermSize.rows != curTermSize.rows || fileTermSize.cols != curTermSize.cols)
|
|
) {
|
|
console.log("terminal restore size mismatch, temp resize", fileTermSize, curTermSize);
|
|
this.terminal.resize(fileTermSize.cols, fileTermSize.rows);
|
|
didResize = true;
|
|
}
|
|
this.doTerminalWrite(cacheData, ptyOffset);
|
|
if (didResize) {
|
|
this.terminal.resize(curTermSize.cols, curTermSize.rows);
|
|
}
|
|
}
|
|
}
|
|
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.staticTabId);
|
|
const rtOpts: RuntimeOpts = { termsize: { rows: this.terminal.rows, cols: this.terminal.cols } };
|
|
try {
|
|
await RpcApi.ControllerResyncCommand(TabRpcClient, {
|
|
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();
|
|
const termSize: TermSize = { rows: this.terminal.rows, cols: this.terminal.cols };
|
|
console.log("idle timeout term", this.dataBytesProcessed, serializedOutput.length, termSize);
|
|
fireAndForget(() =>
|
|
services.BlockService.SaveTerminalState(this.blockId, serializedOutput, "full", this.ptyOffset, termSize)
|
|
);
|
|
this.dataBytesProcessed = 0;
|
|
}
|
|
|
|
runProcessIdleTimeout() {
|
|
setTimeout(() => {
|
|
window.requestIdleCallback(() => {
|
|
this.processAndCacheData();
|
|
this.runProcessIdleTimeout();
|
|
});
|
|
}, 5000);
|
|
}
|
|
}
|