import debounce from "lodash/debounce"; import { reaction, toJS } from "mobx"; import { Terminal as XTerm } from "xterm"; import { FitAddon } from "xterm-addon-fit"; import { dockStore, TabId } from "./dock.store"; import { TerminalApi } from "../../api/terminal-api"; import { themeStore } from "../../theme.store"; import { autobind } from "../../utils"; export class Terminal { static spawningPool: HTMLElement; static init() { // terminal element must be in DOM before attaching via xterm.open(elem) // https://xtermjs.org/docs/api/terminal/classes/terminal/#open const pool = document.createElement("div"); pool.className = "terminal-init"; pool.style.cssText = "position: absolute; top: 0; left: 0; height: 0; visibility: hidden; overflow: hidden"; document.body.appendChild(pool); Terminal.spawningPool = pool; } static async preloadFonts() { const fontPath = require("../fonts/roboto-mono-nerd.ttf").default; // eslint-disable-line @typescript-eslint/no-var-requires const fontFace = new FontFace("RobotoMono", `url(${fontPath})`); await fontFace.load(); document.fonts.add(fontFace); } public xterm: XTerm; public fitAddon: FitAddon; public scrollPos = 0; public disposers: Function[] = []; @autobind() protected setTheme(colors: Record) { // Replacing keys stored in styles to format accepted by terminal // E.g. terminalBrightBlack -> brightBlack const colorPrefix = "terminal"; const terminalColors = Object.entries(colors) .filter(([name]) => name.startsWith(colorPrefix)) .reduce((colors, [name, color]) => { const colorName = name.split("").slice(colorPrefix.length); colorName[0] = colorName[0].toLowerCase(); colors[colorName.join("")] = color; return colors; }, {}); this.xterm.setOption("theme", terminalColors); } get elem() { return this.xterm.element; } get viewport() { return this.xterm.element.querySelector(".xterm-viewport"); } constructor(public tabId: TabId, protected api: TerminalApi) { this.init(); } get isActive() { const { isOpen, selectedTabId } = dockStore; return isOpen && selectedTabId === this.tabId; } attachTo(parentElem: HTMLElement) { parentElem.appendChild(this.elem); this.onActivate(); } detach() { Terminal.spawningPool.appendChild(this.elem); } async init() { if (this.xterm) { return; } this.xterm = new XTerm({ cursorBlink: true, cursorStyle: "bar", fontSize: 13, fontFamily: "RobotoMono" }); // enable terminal addons this.fitAddon = new FitAddon(); this.xterm.loadAddon(this.fitAddon); this.xterm.open(Terminal.spawningPool); this.xterm.registerLinkMatcher(/https?:\/\/[^\s]+/i, this.onClickLink); this.xterm.attachCustomKeyEventHandler(this.keyHandler); // bind events const onDataHandler = this.xterm.onData(this.onData); this.viewport.addEventListener("scroll", this.onScroll); this.api.onReady.addListener(this.onClear, { once: true }); // clear status logs (connecting..) this.api.onData.addListener(this.onApiData); window.addEventListener("resize", this.onResize); this.disposers.push( reaction(() => toJS(themeStore.activeTheme.colors), this.setTheme, { fireImmediately: true }), dockStore.onResize(this.onResize), () => onDataHandler.dispose(), () => this.fitAddon.dispose(), () => this.api.removeAllListeners(), () => window.removeEventListener("resize", this.onResize), ); } destroy() { if (!this.xterm) return; this.disposers.forEach(dispose => dispose()); this.disposers = []; this.xterm.dispose(); this.xterm = null; } fit = () => { // Since this function is debounced we need to read this value as late as possible if (!this.isActive) return; this.fitAddon.fit(); const { cols, rows } = this.xterm; this.api.sendTerminalSize(cols, rows); }; fitLazy = debounce(this.fit, 250); focus = () => { this.xterm.focus(); } onApiData = (data: string) => { this.xterm.write(data); } onData = (data: string) => { if (!this.api.isReady) return; this.api.sendCommand(data); } onScroll = () => { this.scrollPos = this.viewport.scrollTop; } onClear = () => { this.xterm.clear(); } onResize = () => { this.fitLazy(); this.focus(); } onActivate = () => { this.fit(); setTimeout(() => this.focus(), 250); // delay used to prevent focus on active tab this.viewport.scrollTop = this.scrollPos; // restore last scroll position } onClickLink = (evt: MouseEvent, link: string) => { window.open(link, "_blank"); } keyHandler = (evt: KeyboardEvent): boolean => { const { code, ctrlKey, type } = evt; // Handle custom hotkey bindings if (ctrlKey) { switch (code) { // Ctrl+C: prevent terminal exit on windows / linux (?) case "KeyC": if (this.xterm.hasSelection()) return false; break; // Ctrl+W: prevent unexpected terminal tab closing, e.g. editing file in vim case "KeyW": evt.preventDefault(); break; } } // Pass the event above in DOM for to handle common actions if (!evt.defaultPrevented) { this.elem.dispatchEvent(new KeyboardEvent(type, evt)); } return true; } } Terminal.init();