From d6bd277384f3828ea3cda79ebe9d9bb5d06d6b12 Mon Sep 17 00:00:00 2001 From: sawka <mike@commandline.dev> Date: Tue, 3 Dec 2024 10:51:35 -0800 Subject: [PATCH] working on a standalone terminal element that can be embedded --- frontend/app/element/termelem/fitaddon.tsx | 100 ++++++++++ frontend/app/element/termelem/termelem.tsx | 220 +++++++++++++++++++++ 2 files changed, 320 insertions(+) create mode 100644 frontend/app/element/termelem/fitaddon.tsx create mode 100644 frontend/app/element/termelem/termelem.tsx diff --git a/frontend/app/element/termelem/fitaddon.tsx b/frontend/app/element/termelem/fitaddon.tsx new file mode 100644 index 000000000..db8c7add9 --- /dev/null +++ b/frontend/app/element/termelem/fitaddon.tsx @@ -0,0 +1,100 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +// This file is a copy of the original xterm.js file, with the following changes: +// - removed the allowance for the scrollbar + +import type { FitAddon as IFitApi } from "@xterm/addon-fit"; +import type { ITerminalAddon, Terminal } from "@xterm/xterm"; +import { IRenderDimensions } from "@xterm/xterm/src/browser/renderer/shared/Types"; + +interface ITerminalDimensions { + /** + * The number of rows in the terminal. + */ + rows: number; + + /** + * The number of columns in the terminal. + */ + cols: number; +} + +const MINIMUM_COLS = 2; +const MINIMUM_ROWS = 1; + +export class FitAddon implements ITerminalAddon, IFitApi { + private _terminal: Terminal | undefined; + public noScrollbar: boolean = false; + + public activate(terminal: Terminal): void { + this._terminal = terminal; + } + + public dispose(): void {} + + public fit(): void { + const dims = this.proposeDimensions(); + if (!dims || !this._terminal || isNaN(dims.cols) || isNaN(dims.rows)) { + return; + } + + // TODO: Remove reliance on private API + const core = (this._terminal as any)._core; + + // Force a full render + if (this._terminal.rows !== dims.rows || this._terminal.cols !== dims.cols) { + core._renderService.clear(); + this._terminal.resize(dims.cols, dims.rows); + } + } + + public proposeDimensions(): ITerminalDimensions | undefined { + if (!this._terminal) { + return undefined; + } + + if (!this._terminal.element || !this._terminal.element.parentElement) { + return undefined; + } + + // TODO: Remove reliance on private API + const core = (this._terminal as any)._core; + const dims: IRenderDimensions = core._renderService.dimensions; + + if (dims.css.cell.width === 0 || dims.css.cell.height === 0) { + return undefined; + } + + // UPDATED CODE (removed reliance on FALLBACK_SCROLL_BAR_WIDTH in viewport) + const measuredScrollBarWidth = + core.viewport._viewportElement.offsetWidth - core.viewport._scrollArea.offsetWidth; + let scrollbarWidth = this._terminal.options.scrollback === 0 ? 0 : measuredScrollBarWidth; + if (this.noScrollbar) { + scrollbarWidth = 0; + } + // END UPDATED CODE + + const parentElementStyle = window.getComputedStyle(this._terminal.element.parentElement); + const parentElementHeight = parseInt(parentElementStyle.getPropertyValue("height")); + const parentElementWidth = Math.max(0, parseInt(parentElementStyle.getPropertyValue("width"))); + const elementStyle = window.getComputedStyle(this._terminal.element); + const elementPadding = { + top: parseInt(elementStyle.getPropertyValue("padding-top")), + bottom: parseInt(elementStyle.getPropertyValue("padding-bottom")), + right: parseInt(elementStyle.getPropertyValue("padding-right")), + left: parseInt(elementStyle.getPropertyValue("padding-left")), + }; + const elementPaddingVer = elementPadding.top + elementPadding.bottom; + const elementPaddingHor = elementPadding.right + elementPadding.left; + const availableHeight = parentElementHeight - elementPaddingVer; + const availableWidth = parentElementWidth - elementPaddingHor - scrollbarWidth; + const geometry = { + cols: Math.max(MINIMUM_COLS, Math.floor(availableWidth / dims.css.cell.width)), + rows: Math.max(MINIMUM_ROWS, Math.floor(availableHeight / dims.css.cell.height)), + }; + return geometry; + } +} diff --git a/frontend/app/element/termelem/termelem.tsx b/frontend/app/element/termelem/termelem.tsx new file mode 100644 index 000000000..736931188 --- /dev/null +++ b/frontend/app/element/termelem/termelem.tsx @@ -0,0 +1,220 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { PLATFORM } from "@/store/global"; +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 { useEffect, useRef } from "react"; +import { debounce } from "throttle-debounce"; +import { FitAddon } from "./fitaddon"; + +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; + +export type TermWrapOptions = { + xtermOpts: TermTypes.ITerminalOptions & TermTypes.ITerminalInitOnlyOptions; + keydownHandler?: (e: KeyboardEvent) => boolean; + useWebGl?: boolean; + useWebLinksAddon?: boolean; + useSerializeAddon?: boolean; + onOpenLink?: (uri: string) => void; + onCwdChange?: (newCwd: string) => void; + handleInputData?: (data: string) => void; + resyncController?: (reason: string) => void; + onResize?: (termSize: TermSize) => void; + onSelectionChange?: (selectedText: string) => void; + onFocus?: () => void; + onMount?: () => void; + onDispose?: () => void; +}; + +export class TermWrap { + ptyOffset: number; + dataBytesProcessed: number; + terminal: Terminal; + connectElem?: HTMLDivElement; + fitAddon: FitAddon; + serializeAddon?: SerializeAddon; + handleResize_debounced: () => void; + hasResized: boolean; + termOpts: TermWrapOptions; + + constructor(waveOptions: TermWrapOptions) { + this.termOpts = waveOptions; + this.ptyOffset = 0; + this.dataBytesProcessed = 0; + this.hasResized = false; + this.terminal = new Terminal(this.termOpts.xtermOpts); + this.fitAddon = new FitAddon(); + this.fitAddon.noScrollbar = PLATFORM == "darwin"; + this.terminal.loadAddon(this.fitAddon); + if (this.termOpts.useSerializeAddon) { + this.serializeAddon = new SerializeAddon(); + this.terminal.loadAddon(this.serializeAddon); + } + if (this.termOpts.useWebLinksAddon && this.termOpts.onOpenLink) { + this.terminal.loadAddon( + new WebLinksAddon((e, uri) => { + e.preventDefault(); + switch (PLATFORM) { + case "darwin": + if (e.metaKey) { + this.termOpts.onOpenLink?.(uri); + } + break; + default: + if (e.ctrlKey) { + this.termOpts.onOpenLink?.(uri); + } + break; + } + }) + ); + } + if (WebGLSupported && this.termOpts.useWebGl) { + const webglAddon = new WebglAddon(); + webglAddon.onContextLoss(() => { + webglAddon.dispose(); + }); + this.terminal.loadAddon(webglAddon); + if (!loggedWebGL) { + console.log("loaded webgl!"); + loggedWebGL = true; + } + } + this.terminal.parser.registerOscHandler(7, (data: string) => { + 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(() => { + this.termOpts?.onCwdChange?.(data); + }, 0); + return true; + }); + this.handleResize_debounced = debounce(50, this.handleResize.bind(this)); + if (this.termOpts.keydownHandler != null) { + this.terminal.attachCustomKeyEventHandler((e: KeyboardEvent) => { + return this.termOpts.keydownHandler?.(e); + }); + } + if (this.termOpts.onFocus != null) { + this.terminal.textarea.addEventListener("focus", () => { + this.termOpts.onFocus?.(); + }); + } + } + + async initTerminal(connectElem: HTMLDivElement) { + this.connectElem = connectElem; + this.terminal.open(this.connectElem); + this.handleResize(); + this.terminal.onData(this.handleTermData.bind(this)); + this.terminal.onSelectionChange(() => { + const selectedText = this.terminal.getSelection(); + this.termOpts.onSelectionChange?.(selectedText); + }); + this.termOpts.onMount?.(); + } + + dispose() { + this.termOpts.onDispose?.(); + this.terminal.dispose(); + } + + handleTermData(data: string) { + this.termOpts.handleInputData?.(data); + } + + addFocusListener(focusFn: () => void) { + this.terminal.textarea.addEventListener("focus", focusFn); + } + + 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; + } + + resizeTerminal(termSize: TermSize) { + this.terminal.resize(termSize.cols, termSize.rows); + } + + resyncController(reason: string) { + this.termOpts.resyncController?.(reason); + } + + 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 }; + this.termOpts.onResize?.(termSize); + } + if (!this.hasResized) { + this.hasResized = true; + this.resyncController("initial resize"); + } + } + + getDataBytesProcessed(): number { + return this.dataBytesProcessed; + } + + getTerminalCacheData(): { data: string; ptyOffset: number; termSize: TermSize } { + if (this.serializeAddon == null) { + return null; + } + const serializedOutput = this.serializeAddon.serialize(); + const termSize: TermSize = { rows: this.terminal.rows, cols: this.terminal.cols }; + return { data: serializedOutput, ptyOffset: this.ptyOffset, termSize }; + } +} + +export const TermElem = (props: { termOpts: TermWrapOptions }) => { + const connectElemRef = useRef<HTMLDivElement>(null); + const termWrapRef = useRef<TermWrap>(null); + useEffect(() => { + termWrapRef.current = new TermWrap(props.termOpts); + termWrapRef.current.initTerminal(connectElemRef.current); + return () => { + termWrapRef.current.dispose(); + }; + }, []); + return <div key="conntectElem" className="term-connectelem" ref={connectElemRef}></div>; +};