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.
974 lines
37 KiB
TypeScript
974 lines
37 KiB
TypeScript
// Copyright 2024, Command Line Inc.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
import { Block, SubBlock } from "@/app/block/block";
|
|
import { BlockNodeModel } from "@/app/block/blocktypes";
|
|
import { Search, useSearch } from "@/app/element/search";
|
|
import { getAllGlobalKeyBindings } from "@/app/store/keymodel";
|
|
import { waveEventSubscribe } from "@/app/store/wps";
|
|
import { RpcApi } from "@/app/store/wshclientapi";
|
|
import { makeFeBlockRouteId } from "@/app/store/wshrouter";
|
|
import { DefaultRouter, TabRpcClient } from "@/app/store/wshrpcutil";
|
|
import { TermWshClient } from "@/app/view/term/term-wsh";
|
|
import { VDomModel } from "@/app/view/vdom/vdom-model";
|
|
import {
|
|
atoms,
|
|
getBlockComponentModel,
|
|
getBlockMetaKeyAtom,
|
|
getConnStatusAtom,
|
|
getOverrideConfigAtom,
|
|
getSettingsKeyAtom,
|
|
getSettingsPrefixAtom,
|
|
globalStore,
|
|
useBlockAtom,
|
|
WOS,
|
|
} from "@/store/global";
|
|
import * as services from "@/store/services";
|
|
import * as keyutil from "@/util/keyutil";
|
|
import { boundNumber, fireAndForget, useAtomValueSafe } from "@/util/util";
|
|
import { ISearchOptions } from "@xterm/addon-search";
|
|
import clsx from "clsx";
|
|
import debug from "debug";
|
|
import * as jotai from "jotai";
|
|
import * as React from "react";
|
|
import { TermStickers } from "./termsticker";
|
|
import { TermThemeUpdater } from "./termtheme";
|
|
import { computeTheme, DefaultTermTheme } from "./termutil";
|
|
import { TermWrap } from "./termwrap";
|
|
import "./xterm.css";
|
|
|
|
const dlog = debug("wave:term");
|
|
|
|
type InitialLoadDataType = {
|
|
loaded: boolean;
|
|
heldData: Uint8Array[];
|
|
};
|
|
|
|
class TermViewModel implements ViewModel {
|
|
viewType: string;
|
|
nodeModel: BlockNodeModel;
|
|
connected: boolean;
|
|
termRef: React.MutableRefObject<TermWrap> = { current: null };
|
|
blockAtom: jotai.Atom<Block>;
|
|
termMode: jotai.Atom<string>;
|
|
blockId: string;
|
|
viewIcon: jotai.Atom<string>;
|
|
viewName: jotai.Atom<string>;
|
|
viewText: jotai.Atom<HeaderElem[]>;
|
|
blockBg: jotai.Atom<MetaType>;
|
|
manageConnection: jotai.Atom<boolean>;
|
|
filterOutNowsh?: jotai.Atom<boolean>;
|
|
connStatus: jotai.Atom<ConnStatus>;
|
|
termWshClient: TermWshClient;
|
|
vdomBlockId: jotai.Atom<string>;
|
|
vdomToolbarBlockId: jotai.Atom<string>;
|
|
vdomToolbarTarget: jotai.PrimitiveAtom<VDomTargetToolbar>;
|
|
fontSizeAtom: jotai.Atom<number>;
|
|
termThemeNameAtom: jotai.Atom<string>;
|
|
termTransparencyAtom: jotai.Atom<number>;
|
|
noPadding: jotai.PrimitiveAtom<boolean>;
|
|
endIconButtons: jotai.Atom<IconButtonDecl[]>;
|
|
shellProcFullStatus: jotai.PrimitiveAtom<BlockControllerRuntimeStatus>;
|
|
shellProcStatus: jotai.Atom<string>;
|
|
shellProcStatusUnsubFn: () => void;
|
|
isCmdController: jotai.Atom<boolean>;
|
|
isRestarting: jotai.PrimitiveAtom<boolean>;
|
|
searchAtoms?: SearchAtoms;
|
|
|
|
constructor(blockId: string, nodeModel: BlockNodeModel) {
|
|
this.viewType = "term";
|
|
this.blockId = blockId;
|
|
this.termWshClient = new TermWshClient(blockId, this);
|
|
DefaultRouter.registerRoute(makeFeBlockRouteId(blockId), this.termWshClient);
|
|
this.nodeModel = nodeModel;
|
|
this.blockAtom = WOS.getWaveObjectAtom<Block>(`block:${blockId}`);
|
|
this.vdomBlockId = jotai.atom((get) => {
|
|
const blockData = get(this.blockAtom);
|
|
return blockData?.meta?.["term:vdomblockid"];
|
|
});
|
|
this.vdomToolbarBlockId = jotai.atom((get) => {
|
|
const blockData = get(this.blockAtom);
|
|
return blockData?.meta?.["term:vdomtoolbarblockid"];
|
|
});
|
|
this.vdomToolbarTarget = jotai.atom<VDomTargetToolbar>(null) as jotai.PrimitiveAtom<VDomTargetToolbar>;
|
|
this.termMode = jotai.atom((get) => {
|
|
const blockData = get(this.blockAtom);
|
|
return blockData?.meta?.["term:mode"] ?? "term";
|
|
});
|
|
this.isRestarting = jotai.atom(false);
|
|
this.viewIcon = jotai.atom((get) => {
|
|
const termMode = get(this.termMode);
|
|
if (termMode == "vdom") {
|
|
return "bolt";
|
|
}
|
|
const isCmd = get(this.isCmdController);
|
|
if (isCmd) {
|
|
}
|
|
return "terminal";
|
|
});
|
|
this.viewName = jotai.atom((get) => {
|
|
const blockData = get(this.blockAtom);
|
|
const termMode = get(this.termMode);
|
|
if (termMode == "vdom") {
|
|
return "Wave App";
|
|
}
|
|
if (blockData?.meta?.controller == "cmd") {
|
|
return "";
|
|
}
|
|
return "Terminal";
|
|
});
|
|
this.viewText = jotai.atom((get) => {
|
|
const termMode = get(this.termMode);
|
|
if (termMode == "vdom") {
|
|
return [
|
|
{
|
|
elemtype: "iconbutton",
|
|
icon: "square-terminal",
|
|
title: "Switch back to Terminal",
|
|
click: () => {
|
|
this.setTermMode("term");
|
|
},
|
|
},
|
|
];
|
|
}
|
|
const vdomBlockId = get(this.vdomBlockId);
|
|
const rtn = [];
|
|
if (vdomBlockId) {
|
|
rtn.push({
|
|
elemtype: "iconbutton",
|
|
icon: "bolt",
|
|
title: "Switch to Wave App",
|
|
click: () => {
|
|
this.setTermMode("vdom");
|
|
},
|
|
});
|
|
}
|
|
const isCmd = get(this.isCmdController);
|
|
if (isCmd) {
|
|
const blockMeta = get(this.blockAtom)?.meta;
|
|
let cmdText = blockMeta?.["cmd"];
|
|
let cmdArgs = blockMeta?.["cmd:args"];
|
|
if (cmdArgs != null && Array.isArray(cmdArgs) && cmdArgs.length > 0) {
|
|
cmdText += " " + cmdArgs.join(" ");
|
|
}
|
|
rtn.push({
|
|
elemtype: "text",
|
|
text: cmdText,
|
|
noGrow: true,
|
|
});
|
|
const isRestarting = get(this.isRestarting);
|
|
if (isRestarting) {
|
|
rtn.push({
|
|
elemtype: "iconbutton",
|
|
icon: "refresh",
|
|
iconColor: "var(--success-color)",
|
|
iconSpin: true,
|
|
title: "Restarting Command",
|
|
noAction: true,
|
|
});
|
|
} else {
|
|
const fullShellProcStatus = get(this.shellProcFullStatus);
|
|
if (fullShellProcStatus?.shellprocstatus == "done") {
|
|
if (fullShellProcStatus?.shellprocexitcode == 0) {
|
|
rtn.push({
|
|
elemtype: "iconbutton",
|
|
icon: "check",
|
|
iconColor: "var(--success-color)",
|
|
title: "Command Exited Successfully",
|
|
noAction: true,
|
|
});
|
|
} else {
|
|
rtn.push({
|
|
elemtype: "iconbutton",
|
|
icon: "xmark-large",
|
|
iconColor: "var(--error-color)",
|
|
title: "Exit Code: " + fullShellProcStatus?.shellprocexitcode,
|
|
noAction: true,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return rtn;
|
|
});
|
|
this.manageConnection = jotai.atom((get) => {
|
|
const termMode = get(this.termMode);
|
|
if (termMode == "vdom") {
|
|
return false;
|
|
}
|
|
const isCmd = get(this.isCmdController);
|
|
if (isCmd) {
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
this.filterOutNowsh = jotai.atom(false);
|
|
this.termThemeNameAtom = useBlockAtom(blockId, "termthemeatom", () => {
|
|
return jotai.atom<string>((get) => {
|
|
return get(getOverrideConfigAtom(this.blockId, "term:theme")) ?? DefaultTermTheme;
|
|
});
|
|
});
|
|
this.termTransparencyAtom = useBlockAtom(blockId, "termtransparencyatom", () => {
|
|
return jotai.atom<number>((get) => {
|
|
let value = get(getOverrideConfigAtom(this.blockId, "term:transparency")) ?? 0.5;
|
|
return boundNumber(value, 0, 1);
|
|
});
|
|
});
|
|
this.blockBg = jotai.atom((get) => {
|
|
const fullConfig = get(atoms.fullConfigAtom);
|
|
const themeName = get(this.termThemeNameAtom);
|
|
const termTransparency = get(this.termTransparencyAtom);
|
|
const [_, bgcolor] = computeTheme(fullConfig, themeName, termTransparency);
|
|
if (bgcolor != null) {
|
|
return { bg: bgcolor };
|
|
}
|
|
return null;
|
|
});
|
|
this.connStatus = jotai.atom((get) => {
|
|
const blockData = get(this.blockAtom);
|
|
const connName = blockData?.meta?.connection;
|
|
const connAtom = getConnStatusAtom(connName);
|
|
return get(connAtom);
|
|
});
|
|
this.fontSizeAtom = useBlockAtom(blockId, "fontsizeatom", () => {
|
|
return jotai.atom<number>((get) => {
|
|
const blockData = get(this.blockAtom);
|
|
const fsSettingsAtom = getSettingsKeyAtom("term:fontsize");
|
|
const settingsFontSize = get(fsSettingsAtom);
|
|
const connName = blockData?.meta?.connection;
|
|
const fullConfig = get(atoms.fullConfigAtom);
|
|
const connFontSize = fullConfig?.connections?.[connName]?.["term:fontsize"];
|
|
const rtnFontSize = blockData?.meta?.["term:fontsize"] ?? connFontSize ?? settingsFontSize ?? 12;
|
|
if (typeof rtnFontSize != "number" || isNaN(rtnFontSize) || rtnFontSize < 4 || rtnFontSize > 64) {
|
|
return 12;
|
|
}
|
|
return rtnFontSize;
|
|
});
|
|
});
|
|
this.noPadding = jotai.atom(true);
|
|
this.endIconButtons = jotai.atom((get) => {
|
|
const blockData = get(this.blockAtom);
|
|
const shellProcStatus = get(this.shellProcStatus);
|
|
const connStatus = get(this.connStatus);
|
|
const isCmd = get(this.isCmdController);
|
|
if (blockData?.meta?.["controller"] != "cmd" && shellProcStatus != "done") {
|
|
return [];
|
|
}
|
|
if (connStatus?.status != "connected") {
|
|
return [];
|
|
}
|
|
let iconName: string = null;
|
|
let title: string = null;
|
|
const noun = isCmd ? "Command" : "Shell";
|
|
if (shellProcStatus == "init") {
|
|
iconName = "play";
|
|
title = "Click to Start " + noun;
|
|
} else if (shellProcStatus == "running") {
|
|
iconName = "refresh";
|
|
title = noun + " Running. Click to Restart";
|
|
} else if (shellProcStatus == "done") {
|
|
iconName = "refresh";
|
|
title = noun + " Exited. Click to Restart";
|
|
}
|
|
if (iconName == null) {
|
|
return [];
|
|
}
|
|
const buttonDecl: IconButtonDecl = {
|
|
elemtype: "iconbutton",
|
|
icon: iconName,
|
|
click: this.forceRestartController.bind(this),
|
|
title: title,
|
|
};
|
|
const rtn = [buttonDecl];
|
|
return rtn;
|
|
});
|
|
this.isCmdController = jotai.atom((get) => {
|
|
const controllerMetaAtom = getBlockMetaKeyAtom(this.blockId, "controller");
|
|
return get(controllerMetaAtom) == "cmd";
|
|
});
|
|
this.shellProcFullStatus = jotai.atom(null) as jotai.PrimitiveAtom<BlockControllerRuntimeStatus>;
|
|
const initialShellProcStatus = services.BlockService.GetControllerStatus(blockId);
|
|
initialShellProcStatus.then((rts) => {
|
|
this.updateShellProcStatus(rts);
|
|
});
|
|
this.shellProcStatusUnsubFn = waveEventSubscribe({
|
|
eventType: "controllerstatus",
|
|
scope: WOS.makeORef("block", blockId),
|
|
handler: (event) => {
|
|
let bcRTS: BlockControllerRuntimeStatus = event.data;
|
|
this.updateShellProcStatus(bcRTS);
|
|
},
|
|
});
|
|
this.shellProcStatus = jotai.atom((get) => {
|
|
const fullStatus = get(this.shellProcFullStatus);
|
|
return fullStatus?.shellprocstatus ?? "init";
|
|
});
|
|
}
|
|
|
|
setTermMode(mode: "term" | "vdom") {
|
|
if (mode == "term") {
|
|
mode = null;
|
|
}
|
|
RpcApi.SetMetaCommand(TabRpcClient, {
|
|
oref: WOS.makeORef("block", this.blockId),
|
|
meta: { "term:mode": mode },
|
|
});
|
|
}
|
|
|
|
triggerRestartAtom() {
|
|
globalStore.set(this.isRestarting, true);
|
|
setTimeout(() => {
|
|
globalStore.set(this.isRestarting, false);
|
|
}, 300);
|
|
}
|
|
|
|
updateShellProcStatus(fullStatus: BlockControllerRuntimeStatus) {
|
|
if (fullStatus == null) {
|
|
return;
|
|
}
|
|
const curStatus = globalStore.get(this.shellProcFullStatus);
|
|
if (curStatus == null || curStatus.version < fullStatus.version) {
|
|
globalStore.set(this.shellProcFullStatus, fullStatus);
|
|
}
|
|
}
|
|
|
|
getVDomModel(): VDomModel {
|
|
const vdomBlockId = globalStore.get(this.vdomBlockId);
|
|
if (!vdomBlockId) {
|
|
return null;
|
|
}
|
|
const bcm = getBlockComponentModel(vdomBlockId);
|
|
if (!bcm) {
|
|
return null;
|
|
}
|
|
return bcm.viewModel as VDomModel;
|
|
}
|
|
|
|
getVDomToolbarModel(): VDomModel {
|
|
const vdomToolbarBlockId = globalStore.get(this.vdomToolbarBlockId);
|
|
if (!vdomToolbarBlockId) {
|
|
return null;
|
|
}
|
|
const bcm = getBlockComponentModel(vdomToolbarBlockId);
|
|
if (!bcm) {
|
|
return null;
|
|
}
|
|
return bcm.viewModel as VDomModel;
|
|
}
|
|
|
|
dispose() {
|
|
DefaultRouter.unregisterRoute(makeFeBlockRouteId(this.blockId));
|
|
if (this.shellProcStatusUnsubFn) {
|
|
this.shellProcStatusUnsubFn();
|
|
}
|
|
}
|
|
|
|
giveFocus(): boolean {
|
|
if (this.searchAtoms && globalStore.get(this.searchAtoms.isOpen)) {
|
|
console.log("search is open, not giving focus");
|
|
return true;
|
|
}
|
|
let termMode = globalStore.get(this.termMode);
|
|
if (termMode == "term") {
|
|
if (this.termRef?.current?.terminal) {
|
|
this.termRef.current.terminal.focus();
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
keyDownHandler(waveEvent: WaveKeyboardEvent): boolean {
|
|
if (keyutil.checkKeyPressed(waveEvent, "Cmd:Escape")) {
|
|
const blockAtom = WOS.getWaveObjectAtom<Block>(`block:${this.blockId}`);
|
|
const blockData = globalStore.get(blockAtom);
|
|
const newTermMode = blockData?.meta?.["term:mode"] == "vdom" ? null : "vdom";
|
|
const vdomBlockId = globalStore.get(this.vdomBlockId);
|
|
if (newTermMode == "vdom" && !vdomBlockId) {
|
|
return;
|
|
}
|
|
this.setTermMode(newTermMode);
|
|
return true;
|
|
}
|
|
const blockData = globalStore.get(this.blockAtom);
|
|
if (blockData.meta?.["term:mode"] == "vdom") {
|
|
const vdomModel = this.getVDomModel();
|
|
return vdomModel?.keyDownHandler(waveEvent);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
handleTerminalKeydown(event: KeyboardEvent): boolean {
|
|
const waveEvent = keyutil.adaptFromReactOrNativeKeyEvent(event);
|
|
if (waveEvent.type != "keydown") {
|
|
return true;
|
|
}
|
|
if (this.keyDownHandler(waveEvent)) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
return false;
|
|
}
|
|
// deal with terminal specific keybindings
|
|
if (keyutil.checkKeyPressed(waveEvent, "Ctrl:Shift:v")) {
|
|
const p = navigator.clipboard.readText();
|
|
p.then((text) => {
|
|
this.termRef.current?.terminal.paste(text);
|
|
});
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
return false;
|
|
} else if (keyutil.checkKeyPressed(waveEvent, "Ctrl:Shift:c")) {
|
|
const sel = this.termRef.current?.terminal.getSelection();
|
|
navigator.clipboard.writeText(sel);
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
return false;
|
|
} else if (keyutil.checkKeyPressed(waveEvent, "Cmd:k")) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
this.termRef.current?.terminal?.clear();
|
|
return false;
|
|
}
|
|
const shellProcStatus = globalStore.get(this.shellProcStatus);
|
|
if ((shellProcStatus == "done" || shellProcStatus == "init") && keyutil.checkKeyPressed(waveEvent, "Enter")) {
|
|
this.forceRestartController();
|
|
return false;
|
|
}
|
|
const globalKeys = getAllGlobalKeyBindings();
|
|
for (const key of globalKeys) {
|
|
if (keyutil.checkKeyPressed(waveEvent, key)) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
setTerminalTheme(themeName: string) {
|
|
RpcApi.SetMetaCommand(TabRpcClient, {
|
|
oref: WOS.makeORef("block", this.blockId),
|
|
meta: { "term:theme": themeName },
|
|
});
|
|
}
|
|
|
|
forceRestartController() {
|
|
if (globalStore.get(this.isRestarting)) {
|
|
return;
|
|
}
|
|
this.triggerRestartAtom();
|
|
const termsize = {
|
|
rows: this.termRef.current?.terminal?.rows,
|
|
cols: this.termRef.current?.terminal?.cols,
|
|
};
|
|
const prtn = RpcApi.ControllerResyncCommand(TabRpcClient, {
|
|
tabid: globalStore.get(atoms.staticTabId),
|
|
blockid: this.blockId,
|
|
forcerestart: true,
|
|
rtopts: { termsize: termsize },
|
|
});
|
|
prtn.catch((e) => console.log("error controller resync (force restart)", e));
|
|
}
|
|
|
|
getSettingsMenuItems(): ContextMenuItem[] {
|
|
const fullConfig = globalStore.get(atoms.fullConfigAtom);
|
|
const termThemes = fullConfig?.termthemes ?? {};
|
|
const termThemeKeys = Object.keys(termThemes);
|
|
const curThemeName = globalStore.get(getBlockMetaKeyAtom(this.blockId, "term:theme"));
|
|
const defaultFontSize = globalStore.get(getSettingsKeyAtom("term:fontsize")) ?? 12;
|
|
const transparencyMeta = globalStore.get(getBlockMetaKeyAtom(this.blockId, "term:transparency"));
|
|
const blockData = globalStore.get(this.blockAtom);
|
|
const overrideFontSize = blockData?.meta?.["term:fontsize"];
|
|
|
|
termThemeKeys.sort((a, b) => {
|
|
return (termThemes[a]["display:order"] ?? 0) - (termThemes[b]["display:order"] ?? 0);
|
|
});
|
|
const fullMenu: ContextMenuItem[] = [];
|
|
const submenu: ContextMenuItem[] = termThemeKeys.map((themeName) => {
|
|
return {
|
|
label: termThemes[themeName]["display:name"] ?? themeName,
|
|
type: "checkbox",
|
|
checked: curThemeName == themeName,
|
|
click: () => this.setTerminalTheme(themeName),
|
|
};
|
|
});
|
|
submenu.unshift({
|
|
label: "Default",
|
|
type: "checkbox",
|
|
checked: curThemeName == null,
|
|
click: () => this.setTerminalTheme(null),
|
|
});
|
|
const transparencySubMenu: ContextMenuItem[] = [];
|
|
transparencySubMenu.push({
|
|
label: "Default",
|
|
type: "checkbox",
|
|
checked: transparencyMeta == null,
|
|
click: () => {
|
|
RpcApi.SetMetaCommand(TabRpcClient, {
|
|
oref: WOS.makeORef("block", this.blockId),
|
|
meta: { "term:transparency": null },
|
|
});
|
|
},
|
|
});
|
|
transparencySubMenu.push({
|
|
label: "Transparent Background",
|
|
type: "checkbox",
|
|
checked: transparencyMeta == 0.5,
|
|
click: () => {
|
|
RpcApi.SetMetaCommand(TabRpcClient, {
|
|
oref: WOS.makeORef("block", this.blockId),
|
|
meta: { "term:transparency": 0.5 },
|
|
});
|
|
},
|
|
});
|
|
transparencySubMenu.push({
|
|
label: "No Transparency",
|
|
type: "checkbox",
|
|
checked: transparencyMeta == 0,
|
|
click: () => {
|
|
RpcApi.SetMetaCommand(TabRpcClient, {
|
|
oref: WOS.makeORef("block", this.blockId),
|
|
meta: { "term:transparency": 0 },
|
|
});
|
|
},
|
|
});
|
|
|
|
const fontSizeSubMenu: ContextMenuItem[] = [6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18].map(
|
|
(fontSize: number) => {
|
|
return {
|
|
label: fontSize.toString() + "px",
|
|
type: "checkbox",
|
|
checked: overrideFontSize == fontSize,
|
|
click: () => {
|
|
RpcApi.SetMetaCommand(TabRpcClient, {
|
|
oref: WOS.makeORef("block", this.blockId),
|
|
meta: { "term:fontsize": fontSize },
|
|
});
|
|
},
|
|
};
|
|
}
|
|
);
|
|
fontSizeSubMenu.unshift({
|
|
label: "Default (" + defaultFontSize + "px)",
|
|
type: "checkbox",
|
|
checked: overrideFontSize == null,
|
|
click: () => {
|
|
RpcApi.SetMetaCommand(TabRpcClient, {
|
|
oref: WOS.makeORef("block", this.blockId),
|
|
meta: { "term:fontsize": null },
|
|
});
|
|
},
|
|
});
|
|
fullMenu.push({
|
|
label: "Themes",
|
|
submenu: submenu,
|
|
});
|
|
fullMenu.push({
|
|
label: "Font Size",
|
|
submenu: fontSizeSubMenu,
|
|
});
|
|
fullMenu.push({
|
|
label: "Transparency",
|
|
submenu: transparencySubMenu,
|
|
});
|
|
fullMenu.push({ type: "separator" });
|
|
fullMenu.push({
|
|
label: "Force Restart Controller",
|
|
click: this.forceRestartController.bind(this),
|
|
});
|
|
const isClearOnStart = blockData?.meta?.["cmd:clearonstart"];
|
|
fullMenu.push({
|
|
label: "Clear Output On Restart",
|
|
submenu: [
|
|
{
|
|
label: "On",
|
|
type: "checkbox",
|
|
checked: isClearOnStart,
|
|
click: () => {
|
|
RpcApi.SetMetaCommand(TabRpcClient, {
|
|
oref: WOS.makeORef("block", this.blockId),
|
|
meta: { "cmd:clearonstart": true },
|
|
});
|
|
},
|
|
},
|
|
{
|
|
label: "Off",
|
|
type: "checkbox",
|
|
checked: !isClearOnStart,
|
|
click: () => {
|
|
RpcApi.SetMetaCommand(TabRpcClient, {
|
|
oref: WOS.makeORef("block", this.blockId),
|
|
meta: { "cmd:clearonstart": false },
|
|
});
|
|
},
|
|
},
|
|
],
|
|
});
|
|
const runOnStart = blockData?.meta?.["cmd:runonstart"];
|
|
fullMenu.push({
|
|
label: "Run On Startup",
|
|
submenu: [
|
|
{
|
|
label: "On",
|
|
type: "checkbox",
|
|
checked: runOnStart,
|
|
click: () => {
|
|
RpcApi.SetMetaCommand(TabRpcClient, {
|
|
oref: WOS.makeORef("block", this.blockId),
|
|
meta: { "cmd:runonstart": true },
|
|
});
|
|
},
|
|
},
|
|
{
|
|
label: "Off",
|
|
type: "checkbox",
|
|
checked: !runOnStart,
|
|
click: () => {
|
|
RpcApi.SetMetaCommand(TabRpcClient, {
|
|
oref: WOS.makeORef("block", this.blockId),
|
|
meta: { "cmd:runonstart": false },
|
|
});
|
|
},
|
|
},
|
|
],
|
|
});
|
|
if (blockData?.meta?.["term:vdomtoolbarblockid"]) {
|
|
fullMenu.push({ type: "separator" });
|
|
fullMenu.push({
|
|
label: "Close Toolbar",
|
|
click: () => {
|
|
RpcApi.DeleteSubBlockCommand(TabRpcClient, { blockid: blockData.meta["term:vdomtoolbarblockid"] });
|
|
},
|
|
});
|
|
}
|
|
return fullMenu;
|
|
}
|
|
}
|
|
|
|
function makeTerminalModel(blockId: string, nodeModel: BlockNodeModel): TermViewModel {
|
|
return new TermViewModel(blockId, nodeModel);
|
|
}
|
|
|
|
interface TerminalViewProps {
|
|
blockId: string;
|
|
model: TermViewModel;
|
|
}
|
|
|
|
const TermResyncHandler = React.memo(({ blockId, model }: TerminalViewProps) => {
|
|
const connStatus = jotai.useAtomValue(model.connStatus);
|
|
const [lastConnStatus, setLastConnStatus] = React.useState<ConnStatus>(connStatus);
|
|
|
|
React.useEffect(() => {
|
|
if (!model.termRef.current?.hasResized) {
|
|
return;
|
|
}
|
|
const isConnected = connStatus?.status == "connected";
|
|
const wasConnected = lastConnStatus?.status == "connected";
|
|
const curConnName = connStatus?.connection;
|
|
const lastConnName = lastConnStatus?.connection;
|
|
if (isConnected == wasConnected && curConnName == lastConnName) {
|
|
return;
|
|
}
|
|
model.termRef.current?.resyncController("resync handler");
|
|
setLastConnStatus(connStatus);
|
|
}, [connStatus]);
|
|
|
|
return null;
|
|
});
|
|
|
|
const TermVDomToolbarNode = ({ vdomBlockId, blockId, model }: TerminalViewProps & { vdomBlockId: string }) => {
|
|
React.useEffect(() => {
|
|
const unsub = waveEventSubscribe({
|
|
eventType: "blockclose",
|
|
scope: WOS.makeORef("block", vdomBlockId),
|
|
handler: (event) => {
|
|
RpcApi.SetMetaCommand(TabRpcClient, {
|
|
oref: WOS.makeORef("block", blockId),
|
|
meta: {
|
|
"term:mode": null,
|
|
"term:vdomtoolbarblockid": null,
|
|
},
|
|
});
|
|
},
|
|
});
|
|
return () => {
|
|
unsub();
|
|
};
|
|
}, []);
|
|
let vdomNodeModel = {
|
|
blockId: vdomBlockId,
|
|
isFocused: jotai.atom(false),
|
|
focusNode: () => {},
|
|
onClose: () => {
|
|
if (vdomBlockId != null) {
|
|
RpcApi.DeleteSubBlockCommand(TabRpcClient, { blockid: vdomBlockId });
|
|
}
|
|
},
|
|
};
|
|
const toolbarTarget = jotai.useAtomValue(model.vdomToolbarTarget);
|
|
const heightStr = toolbarTarget?.height ?? "1.5em";
|
|
return (
|
|
<div key="vdomToolbar" className="term-toolbar" style={{ height: heightStr }}>
|
|
<SubBlock key="vdom" nodeModel={vdomNodeModel} />
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const TermVDomNodeSingleId = ({ vdomBlockId, blockId, model }: TerminalViewProps & { vdomBlockId: string }) => {
|
|
React.useEffect(() => {
|
|
const unsub = waveEventSubscribe({
|
|
eventType: "blockclose",
|
|
scope: WOS.makeORef("block", vdomBlockId),
|
|
handler: (event) => {
|
|
RpcApi.SetMetaCommand(TabRpcClient, {
|
|
oref: WOS.makeORef("block", blockId),
|
|
meta: {
|
|
"term:mode": null,
|
|
"term:vdomblockid": null,
|
|
},
|
|
});
|
|
},
|
|
});
|
|
return () => {
|
|
unsub();
|
|
};
|
|
}, []);
|
|
const isFocusedAtom = jotai.atom((get) => {
|
|
return get(model.nodeModel.isFocused) && get(model.termMode) == "vdom";
|
|
});
|
|
let vdomNodeModel = {
|
|
blockId: vdomBlockId,
|
|
isFocused: isFocusedAtom,
|
|
focusNode: () => {
|
|
model.nodeModel.focusNode();
|
|
},
|
|
onClose: () => {
|
|
if (vdomBlockId != null) {
|
|
RpcApi.DeleteSubBlockCommand(TabRpcClient, { blockid: vdomBlockId });
|
|
}
|
|
},
|
|
};
|
|
return (
|
|
<div key="htmlElem" className="term-htmlelem">
|
|
<SubBlock key="vdom" nodeModel={vdomNodeModel} />
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const TermVDomNode = ({ blockId, model }: TerminalViewProps) => {
|
|
const vdomBlockId = jotai.useAtomValue(model.vdomBlockId);
|
|
if (vdomBlockId == null) {
|
|
return null;
|
|
}
|
|
return <TermVDomNodeSingleId key={vdomBlockId} vdomBlockId={vdomBlockId} blockId={blockId} model={model} />;
|
|
};
|
|
|
|
const TermToolbarVDomNode = ({ blockId, model }: TerminalViewProps) => {
|
|
const vdomToolbarBlockId = jotai.useAtomValue(model.vdomToolbarBlockId);
|
|
if (vdomToolbarBlockId == null) {
|
|
return null;
|
|
}
|
|
return (
|
|
<TermVDomToolbarNode
|
|
key={vdomToolbarBlockId}
|
|
vdomBlockId={vdomToolbarBlockId}
|
|
blockId={blockId}
|
|
model={model}
|
|
/>
|
|
);
|
|
};
|
|
|
|
const TerminalView = ({ blockId, model }: TerminalViewProps) => {
|
|
const viewRef = React.useRef<HTMLDivElement>(null);
|
|
const connectElemRef = React.useRef<HTMLDivElement>(null);
|
|
const [blockData] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", blockId));
|
|
const termSettingsAtom = getSettingsPrefixAtom("term");
|
|
const termSettings = jotai.useAtomValue(termSettingsAtom);
|
|
let termMode = blockData?.meta?.["term:mode"] ?? "term";
|
|
if (termMode != "term" && termMode != "vdom") {
|
|
termMode = "term";
|
|
}
|
|
const termModeRef = React.useRef(termMode);
|
|
|
|
const termFontSize = jotai.useAtomValue(model.fontSizeAtom);
|
|
const fullConfig = globalStore.get(atoms.fullConfigAtom);
|
|
const connFontFamily = fullConfig.connections?.[blockData?.meta?.connection]?.["term:fontfamily"];
|
|
|
|
// search
|
|
const searchProps = useSearch({
|
|
anchorRef: viewRef,
|
|
viewModel: model,
|
|
caseSensitive: false,
|
|
wholeWord: false,
|
|
regex: false,
|
|
});
|
|
const searchIsOpen = jotai.useAtomValue<boolean>(searchProps.isOpen);
|
|
const caseSensitive = useAtomValueSafe<boolean>(searchProps.caseSensitive);
|
|
const wholeWord = useAtomValueSafe<boolean>(searchProps.wholeWord);
|
|
const regex = useAtomValueSafe<boolean>(searchProps.regex);
|
|
const searchVal = jotai.useAtomValue<string>(searchProps.searchValue);
|
|
const searchDecorations = React.useMemo(
|
|
() => ({
|
|
matchOverviewRuler: "#000000",
|
|
activeMatchColorOverviewRuler: "#000000",
|
|
activeMatchBorder: "#FF9632",
|
|
matchBorder: "#FFFF00",
|
|
}),
|
|
[]
|
|
);
|
|
const searchOpts = React.useMemo<ISearchOptions>(
|
|
() => ({
|
|
regex,
|
|
wholeWord,
|
|
caseSensitive,
|
|
decorations: searchDecorations,
|
|
}),
|
|
[regex, wholeWord, caseSensitive]
|
|
);
|
|
const handleSearchError = React.useCallback((e: Error) => {
|
|
console.warn("search error:", e);
|
|
}, []);
|
|
const executeSearch = React.useCallback(
|
|
(searchText: string, direction: "next" | "previous") => {
|
|
if (searchText === "") {
|
|
model.termRef.current?.searchAddon.clearDecorations();
|
|
return;
|
|
}
|
|
try {
|
|
model.termRef.current?.searchAddon[direction === "next" ? "findNext" : "findPrevious"](
|
|
searchText,
|
|
searchOpts
|
|
);
|
|
} catch (e) {
|
|
handleSearchError(e);
|
|
}
|
|
},
|
|
[searchOpts, handleSearchError]
|
|
);
|
|
searchProps.onSearch = React.useCallback(
|
|
(searchText: string) => executeSearch(searchText, "previous"),
|
|
[executeSearch]
|
|
);
|
|
searchProps.onPrev = React.useCallback(() => executeSearch(searchVal, "previous"), [executeSearch, searchVal]);
|
|
searchProps.onNext = React.useCallback(() => executeSearch(searchVal, "next"), [executeSearch, searchVal]);
|
|
// Return input focus to the terminal when the search is closed
|
|
React.useEffect(() => {
|
|
if (!searchIsOpen) {
|
|
model.giveFocus();
|
|
}
|
|
}, [searchIsOpen]);
|
|
// rerun search when the searchOpts change
|
|
React.useEffect(() => {
|
|
model.termRef.current?.searchAddon.clearDecorations();
|
|
searchProps.onSearch(searchVal);
|
|
}, [searchOpts]);
|
|
// end search
|
|
|
|
React.useEffect(() => {
|
|
const fullConfig = globalStore.get(atoms.fullConfigAtom);
|
|
const termThemeName = globalStore.get(model.termThemeNameAtom);
|
|
const termTransparency = globalStore.get(model.termTransparencyAtom);
|
|
const [termTheme, _] = computeTheme(fullConfig, termThemeName, termTransparency);
|
|
let termScrollback = 1000;
|
|
if (termSettings?.["term:scrollback"]) {
|
|
termScrollback = Math.floor(termSettings["term:scrollback"]);
|
|
}
|
|
if (blockData?.meta?.["term:scrollback"]) {
|
|
termScrollback = Math.floor(blockData.meta["term:scrollback"]);
|
|
}
|
|
if (termScrollback < 0) {
|
|
termScrollback = 0;
|
|
}
|
|
if (termScrollback > 10000) {
|
|
termScrollback = 10000;
|
|
}
|
|
const wasFocused = model.termRef.current != null && globalStore.get(model.nodeModel.isFocused);
|
|
const termWrap = new TermWrap(
|
|
blockId,
|
|
connectElemRef.current,
|
|
{
|
|
theme: termTheme,
|
|
fontSize: termFontSize,
|
|
fontFamily: termSettings?.["term:fontfamily"] ?? connFontFamily ?? "Hack",
|
|
drawBoldTextInBrightColors: false,
|
|
fontWeight: "normal",
|
|
fontWeightBold: "bold",
|
|
allowTransparency: true,
|
|
scrollback: termScrollback,
|
|
allowProposedApi: true, // Required by @xterm/addon-search to enable search functionality and decorations
|
|
},
|
|
{
|
|
keydownHandler: model.handleTerminalKeydown.bind(model),
|
|
useWebGl: !termSettings?.["term:disablewebgl"],
|
|
}
|
|
);
|
|
(window as any).term = termWrap;
|
|
model.termRef.current = termWrap;
|
|
const rszObs = new ResizeObserver(() => {
|
|
termWrap.handleResize_debounced();
|
|
});
|
|
rszObs.observe(connectElemRef.current);
|
|
termWrap.onSearchResultsDidChange = (results) => {
|
|
globalStore.set(searchProps.resultsIndex, results.resultIndex);
|
|
globalStore.set(searchProps.resultsCount, results.resultCount);
|
|
};
|
|
fireAndForget(termWrap.initTerminal.bind(termWrap));
|
|
if (wasFocused) {
|
|
setTimeout(() => {
|
|
model.giveFocus();
|
|
}, 10);
|
|
}
|
|
return () => {
|
|
termWrap.dispose();
|
|
rszObs.disconnect();
|
|
};
|
|
}, [blockId, termSettings, termFontSize, connFontFamily]);
|
|
|
|
React.useEffect(() => {
|
|
if (termModeRef.current == "vdom" && termMode == "term") {
|
|
// focus the terminal
|
|
model.giveFocus();
|
|
}
|
|
termModeRef.current = termMode;
|
|
}, [termMode]);
|
|
|
|
const scrollbarHideObserverRef = React.useRef<HTMLDivElement>(null);
|
|
const onScrollbarShowObserver = React.useCallback(() => {
|
|
const termViewport = viewRef.current.getElementsByClassName("xterm-viewport")[0] as HTMLDivElement;
|
|
termViewport.style.zIndex = "var(--zindex-xterm-viewport-overlay)";
|
|
scrollbarHideObserverRef.current.style.display = "block";
|
|
}, []);
|
|
const onScrollbarHideObserver = React.useCallback(() => {
|
|
const termViewport = viewRef.current.getElementsByClassName("xterm-viewport")[0] as HTMLDivElement;
|
|
termViewport.style.zIndex = "auto";
|
|
scrollbarHideObserverRef.current.style.display = "none";
|
|
}, []);
|
|
|
|
const stickerConfig = {
|
|
charWidth: 8,
|
|
charHeight: 16,
|
|
rows: model.termRef.current?.terminal.rows ?? 24,
|
|
cols: model.termRef.current?.terminal.cols ?? 80,
|
|
blockId: blockId,
|
|
};
|
|
|
|
return (
|
|
<div className={clsx("view-term", "term-mode-" + termMode)} ref={viewRef}>
|
|
<TermResyncHandler blockId={blockId} model={model} />
|
|
<TermThemeUpdater blockId={blockId} model={model} termRef={model.termRef} />
|
|
<TermStickers config={stickerConfig} />
|
|
<TermToolbarVDomNode key="vdom-toolbar" blockId={blockId} model={model} />
|
|
<TermVDomNode key="vdom" blockId={blockId} model={model} />
|
|
<div key="conntectElem" className="term-connectelem" ref={connectElemRef}>
|
|
<div className="term-scrollbar-show-observer" onPointerOver={onScrollbarShowObserver} />
|
|
<div
|
|
ref={scrollbarHideObserverRef}
|
|
className="term-scrollbar-hide-observer"
|
|
onPointerOver={onScrollbarHideObserver}
|
|
/>
|
|
</div>
|
|
<Search {...searchProps} />
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export { makeTerminalModel, TerminalView, TermViewModel };
|