// 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 { 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, globalStore, useBlockAtom, useSettingsPrefixAtom, WOS, } from "@/store/global"; import * as services from "@/store/services"; import * as keyutil from "@/util/keyutil"; 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 { viewType: string; nodeModel: BlockNodeModel; connected: boolean; termRef: React.RefObject; blockAtom: jotai.Atom; termMode: jotai.Atom; blockId: string; viewIcon: jotai.Atom; viewName: jotai.Atom; viewText: jotai.Atom; blockBg: jotai.Atom; manageConnection: jotai.Atom; connStatus: jotai.Atom; termWshClient: TermWshClient; vdomBlockId: jotai.Atom; vdomToolbarBlockId: jotai.Atom; vdomToolbarTarget: jotai.PrimitiveAtom; fontSizeAtom: jotai.Atom; termThemeNameAtom: jotai.Atom; noPadding: jotai.PrimitiveAtom; endIconButtons: jotai.Atom; shellProcFullStatus: jotai.PrimitiveAtom; shellProcStatus: jotai.Atom; shellProcStatusUnsubFn: () => void; isCmdController: jotai.Atom; isRestarting: jotai.PrimitiveAtom; 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:${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(null) as jotai.PrimitiveAtom; 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.termThemeNameAtom = useBlockAtom(blockId, "termthemeatom", () => { return jotai.atom((get) => { return get(getOverrideConfigAtom(this.blockId, "term:theme")) ?? DefaultTermTheme; }); }); this.blockBg = jotai.atom((get) => { const fullConfig = get(atoms.fullConfigAtom); const themeName = get(this.termThemeNameAtom); const [_, bgcolor] = computeTheme(fullConfig, themeName); 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((get) => { const blockData = get(this.blockAtom); const fsSettingsAtom = getSettingsKeyAtom("term:fontsize"); const settingsFontSize = get(fsSettingsAtom); const rtnFontSize = blockData?.meta?.["term:fontsize"] ?? 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; 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) { globalStore.set(this.shellProcFullStatus, fullStatus); const status = fullStatus?.shellprocstatus ?? "init"; if (status == "running") { this.termRef.current?.setIsRunning?.(true); } else { this.termRef.current?.setIsRunning?.(false); } } 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 { 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:${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; } 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 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 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({ 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); 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 (
); }; 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 (
); }; const TermVDomNode = ({ blockId, model }: TerminalViewProps) => { const vdomBlockId = jotai.useAtomValue(model.vdomBlockId); if (vdomBlockId == null) { return null; } return ; }; const TermToolbarVDomNode = ({ blockId, model }: TerminalViewProps) => { const vdomToolbarBlockId = jotai.useAtomValue(model.vdomToolbarBlockId); if (vdomToolbarBlockId == null) { return null; } return ( ); }; const TerminalView = ({ blockId, model }: TerminalViewProps) => { const viewRef = React.useRef(null); const connectElemRef = React.useRef(null); const termRef = React.useRef(null); model.termRef = termRef; const [blockData] = WOS.useWaveObjectValue(WOS.makeORef("block", blockId)); const termSettingsAtom = useSettingsPrefixAtom("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); React.useEffect(() => { const fullConfig = globalStore.get(atoms.fullConfigAtom); const termThemeName = globalStore.get(model.termThemeNameAtom); const [termTheme, _] = computeTheme(fullConfig, termThemeName); 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 = termRef.current != null && globalStore.get(model.nodeModel.isFocused); const termWrap = new TermWrap( blockId, connectElemRef.current, { theme: termTheme, fontSize: termFontSize, fontFamily: termSettings?.["term:fontfamily"] ?? "Hack", drawBoldTextInBrightColors: false, fontWeight: "normal", fontWeightBold: "bold", allowTransparency: true, scrollback: termScrollback, }, { keydownHandler: model.handleTerminalKeydown.bind(model), useWebGl: !termSettings?.["term:disablewebgl"], } ); const shellProcStatus = globalStore.get(model.shellProcStatus); if (shellProcStatus == "running") { termWrap.setIsRunning(true); } else if (shellProcStatus == "done") { termWrap.setIsRunning(false); } (window as any).term = termWrap; termRef.current = termWrap; const rszObs = new ResizeObserver(() => { termWrap.handleResize_debounced(); }); rszObs.observe(connectElemRef.current); termWrap.initTerminal(); if (wasFocused) { setTimeout(() => { model.giveFocus(); }, 10); } return () => { termWrap.dispose(); rszObs.disconnect(); }; }, [blockId, termSettings, termFontSize]); React.useEffect(() => { if (termModeRef.current == "vdom" && termMode == "term") { // focus the terminal model.giveFocus(); } termModeRef.current = termMode; }, [termMode]); let stickerConfig = { charWidth: 8, charHeight: 16, rows: termRef.current?.terminal.rows ?? 24, cols: termRef.current?.terminal.cols ?? 80, blockId: blockId, }; return (
); }; export { makeTerminalModel, TerminalView, TermViewModel };