// Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { getBackendHostPort, getORefSubject, WOS } from "@/store/global"; import * as services from "@/store/services"; import { base64ToArray } from "@/util/util"; import { FitAddon } from "@xterm/addon-fit"; import type { ITheme } from "@xterm/xterm"; import { Terminal } from "@xterm/xterm"; import * as React from "react"; import { debounce } from "throttle-debounce"; import "./view.less"; import "/public/xterm.css"; function getThemeFromCSSVars(el: Element): ITheme { const theme: ITheme = {}; const elemStyle = getComputedStyle(el); theme.foreground = elemStyle.getPropertyValue("--term-foreground"); theme.background = elemStyle.getPropertyValue("--term-background"); theme.black = elemStyle.getPropertyValue("--term-black"); theme.red = elemStyle.getPropertyValue("--term-red"); theme.green = elemStyle.getPropertyValue("--term-green"); theme.yellow = elemStyle.getPropertyValue("--term-yellow"); theme.blue = elemStyle.getPropertyValue("--term-blue"); theme.magenta = elemStyle.getPropertyValue("--term-magenta"); theme.cyan = elemStyle.getPropertyValue("--term-cyan"); theme.white = elemStyle.getPropertyValue("--term-white"); theme.brightBlack = elemStyle.getPropertyValue("--term-bright-black"); theme.brightRed = elemStyle.getPropertyValue("--term-bright-red"); theme.brightGreen = elemStyle.getPropertyValue("--term-bright-green"); theme.brightYellow = elemStyle.getPropertyValue("--term-bright-yellow"); theme.brightBlue = elemStyle.getPropertyValue("--term-bright-blue"); theme.brightMagenta = elemStyle.getPropertyValue("--term-bright-magenta"); theme.brightCyan = elemStyle.getPropertyValue("--term-bright-cyan"); theme.brightWhite = elemStyle.getPropertyValue("--term-bright-white"); theme.selectionBackground = elemStyle.getPropertyValue("--term-selection-background"); theme.selectionInactiveBackground = elemStyle.getPropertyValue("--term-selection-background"); theme.cursor = elemStyle.getPropertyValue("--term-selection-background"); theme.cursorAccent = elemStyle.getPropertyValue("--term-cursor-accent"); return theme; } function handleResize(fitAddon: FitAddon, blockId: string, term: Terminal) { if (term == null) { return; } const oldRows = term.rows; const oldCols = term.cols; fitAddon.fit(); if (oldRows !== term.rows || oldCols !== term.cols) { const resizeCommand: BlockInputCommand = { command: "controller:input", termsize: { rows: term.rows, cols: term.cols }, }; services.BlockService.SendCommand(blockId, resizeCommand); } } type InitialLoadDataType = { loaded: boolean; heldData: Uint8Array[]; }; const TerminalView = ({ blockId }: { blockId: string }) => { const connectElemRef = React.useRef(null); const termRef = React.useRef(null); const initialLoadRef = React.useRef({ loaded: false, heldData: [] }); React.useEffect(() => { console.log("terminal created"); const newTerm = new Terminal({ theme: getThemeFromCSSVars(connectElemRef.current), fontSize: 12, fontFamily: "Hack", drawBoldTextInBrightColors: false, fontWeight: "normal", fontWeightBold: "bold", }); termRef.current = newTerm; const newFitAddon = new FitAddon(); newTerm.loadAddon(newFitAddon); newTerm.open(connectElemRef.current); newFitAddon.fit(); services.BlockService.SendCommand(blockId, { command: "controller:input", termsize: { rows: newTerm.rows, cols: newTerm.cols }, }); newTerm.onData((data) => { const b64data = btoa(data); const inputCmd: BlockInputCommand = { command: "controller:input", inputdata64: b64data }; services.BlockService.SendCommand(blockId, inputCmd); }); // block subject const blockSubject = getORefSubject(WOS.makeORef("block", blockId)); blockSubject.subscribe((data) => { // base64 decode const decodedData = base64ToArray(data.ptydata); if (initialLoadRef.current.loaded) { newTerm.write(decodedData); } else { initialLoadRef.current.heldData.push(decodedData); } }); // load data from filestore const startTs = Date.now(); let loadedBytes = 0; const localTerm = termRef.current; // avoids devmode double effect running issue (terminal gets created twice) const usp = new URLSearchParams(); usp.set("zoneid", blockId); usp.set("name", "main"); fetch(getBackendHostPort() + "/wave/file?" + usp.toString()) .then((resp) => { if (resp.ok) { return resp.arrayBuffer(); } console.log("error loading file", resp.status, resp.statusText); }) .then((data: ArrayBuffer) => { const uint8View = new Uint8Array(data); localTerm.write(uint8View); loadedBytes = uint8View.byteLength; }) .finally(() => { initialLoadRef.current.heldData.forEach((data) => { localTerm.write(data); }); initialLoadRef.current.loaded = true; initialLoadRef.current.heldData = []; console.log(`terminal loaded file ${loadedBytes} bytes, ${Date.now() - startTs}ms`); }); const resize_debounced = debounce(50, () => { handleResize(newFitAddon, blockId, newTerm); }); const rszObs = new ResizeObserver(() => { resize_debounced(); }); rszObs.observe(connectElemRef.current); return () => { newTerm.dispose(); blockSubject.release(); }; }, []); return (
); }; export { TerminalView };