mirror of
https://github.com/wavetermdev/waveterm.git
synced 2025-01-07 19:28:44 +01:00
da2291f889
This adds support for searching the terminal buffer using the `@xterm/addon-search` library. It also adds three options for searching: regex, case-sensitive, and whole-word. These can be included or excluded from the search options for `useSearch` depending on whether the search backend supports it. ![image](https://github.com/user-attachments/assets/e0b7e2ed-641b-463f-94a2-f24969fb3b06) I didn't like any of the Font Awesome icons for these toggles so until we have time to make some of our own icons that better match the Font Awesome style, I've appropriated VSCode's icons from their [codicons font](https://github.com/microsoft/vscode-codicons). To implement the toggle-able buttons for these options, I've introduced a new HeaderElem component, `ToggleIconButton`. This is styled similarly to `IconButton`, but when you hover over it, it also shows a highlighted background and when active, it shows as fully-opaque and with an accented border. Also removes the `useDismiss` behavior for the search box to better match behavior in other apps. Also fixes the scrollbar observer from my previous PR so it's wider.
321 lines
12 KiB
TypeScript
321 lines
12 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 { SearchAddon } from "@xterm/addon-search";
|
|
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;
|
|
searchAddon: SearchAddon;
|
|
serializeAddon: SerializeAddon;
|
|
mainFileSubject: SubjectWithRef<WSFileEventData>;
|
|
loaded: boolean;
|
|
heldData: Uint8Array[];
|
|
handleResize_debounced: () => void;
|
|
hasResized: boolean;
|
|
onSearchResultsDidChange?: (result: { resultIndex: number; resultCount: number }) => void;
|
|
private toDispose: TermTypes.IDisposable[] = [];
|
|
|
|
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.searchAddon = new SearchAddon();
|
|
this.terminal.loadAddon(this.searchAddon);
|
|
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();
|
|
this.toDispose.push(
|
|
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.toDispose.push(this.terminal.onData(this.handleTermData.bind(this)));
|
|
this.toDispose.push(
|
|
this.terminal.onSelectionChange(
|
|
debounce(50, () => {
|
|
if (!globalStore.get(copyOnSelectAtom)) {
|
|
return;
|
|
}
|
|
const selectedText = this.terminal.getSelection();
|
|
if (selectedText.length > 0) {
|
|
navigator.clipboard.writeText(selectedText);
|
|
}
|
|
})
|
|
)
|
|
);
|
|
if (this.onSearchResultsDidChange != null) {
|
|
this.toDispose.push(this.searchAddon.onDidChangeResults(this.onSearchResultsDidChange.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();
|
|
}
|
|
|
|
dispose() {
|
|
this.terminal.dispose();
|
|
this.toDispose.forEach((d) => {
|
|
try {
|
|
d.dispose();
|
|
} catch (_) {}
|
|
});
|
|
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);
|
|
}
|
|
}
|