waveterm/frontend/app/view/term/term.tsx
Evan Simkowitz a2974a3e6d
Fix escape getting eaten by global event handler (#1668)
The terminal keydown handler was set to filter out all key bindings that
have a registered global handler, regardless of whether they actually
propagated or not. This allowed the global handlers to still work
despite the terminal input having precedence, but it also meant that
global key bindings that were invalid for the current context would
still get eaten and not sent to stdin.

Now, the terminal keydown handler will directly call the global handlers
so we can actually see whether or not the global key binding is valid.
If the global handler is valid, it'll be processed immediately and stdin
won't receive the input. If it's not handled, we'll let xterm pass it to
stdin. Because anything xterm doesn't handle gets sent to the
globally-registered version of the handler, we need to make sure we
don't do extra work to process an input we've already checked. We'll
store the last-handled keydown event as a static variable so we can
dedupe later calls for the same event to prevent doing double work.
2025-01-02 08:38:07 -08:00

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 { appHandleKeyDown } 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 appHandled = appHandleKeyDown(waveEvent);
if (appHandled) {
event.preventDefault();
event.stopPropagation();
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 };