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>;
+};