import { DeesElement, property, html, customElement, type TemplateResult, css, cssManager, } from '@design.estate/dees-element'; import * as domtools from '@design.estate/dees-domtools'; import { Terminal } from 'xterm'; import { FitAddon } from 'xterm-addon-fit'; import { themeDefaultStyles } from '../00theme.js'; import type { IExecutionEnvironment } from '../00group-runtime/index.js'; import { WebContainerEnvironment } from '../00group-runtime/index.js'; declare global { interface HTMLElementTagNameMap { 'dees-terminal': DeesTerminal; } } @customElement('dees-terminal') export class DeesTerminal extends DeesElement { public static demo = () => { const env = new WebContainerEnvironment(); return html``; }; // INSTANCE private resizeObserver: ResizeObserver; /** * The execution environment (required). * Use WebContainerEnvironment for browser-based execution. */ @property({ type: Object }) accessor executionEnvironment: IExecutionEnvironment | null = null; @property() accessor setupCommand = `pnpm install @serve.zone/cli && servezone cli\n`; /** * Environment variables to set in the shell */ @property() accessor environmentVariables: { [key: string]: string } = {}; @property() accessor background: string = '#000000'; /** * Promise that resolves when the environment is ready. * @deprecated Use executionEnvironment directly */ private environmentDeferred = new domtools.plugins.smartpromise.Deferred(); public environmentPromise = this.environmentDeferred.promise; constructor() { super(); this.resizeObserver = new ResizeObserver((entries) => { for (const entry of entries) { // Handle the resize event console.log(`Terminal Resized`); this.handleResize(); } }); } public static styles = [ themeDefaultStyles, cssManager.defaultStyles, css` /* TODO: Migrate hardcoded values to --dees-* CSS variables */ :host { padding: 20px; background: var(--dees-terminal-background, #000000); position: absolute; height: 100%; width: 100%; } * { box-sizing: border-box; } #container { position: absolute; height: calc(100% - 40px); width: calc(100% - 40px); } /** * Copyright (c) 2014 The xterm.js authors. All rights reserved. * Copyright (c) 2012-2013, Christopher Jeffrey (MIT License) * https://github.com/chjj/term.js * @license MIT * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. * * Originally forked from (with the author's permission): * Fabrice Bellard's javascript vt100 for jslinux: * http://bellard.org/jslinux/ * Copyright (c) 2011 Fabrice Bellard * The original design remains. The terminal itself * has been extended to include xterm CSI codes, among * other features. */ /** * Default styles for xterm.js */ .xterm { font-feature-settings: 'liga' 0; position: relative; user-select: none; -ms-user-select: none; -webkit-user-select: none; } .xterm.focus, .xterm:focus { outline: none; } .xterm .xterm-helpers { position: absolute; top: 0; /** * The z-index of the helpers must be higher than the canvases in order for * IMEs to appear on top. */ z-index: 5; } .xterm .xterm-helper-textarea { padding: 0; border: 0; margin: 0; /* Move textarea out of the screen to the far left, so that the cursor is not visible */ position: absolute; opacity: 0; left: -9999em; top: 0; width: 0; height: 0; z-index: -5; /** Prevent wrapping so the IME appears against the textarea at the correct position */ white-space: nowrap; overflow: hidden; resize: none; } .xterm .composition-view { /* TODO: Composition position got messed up somewhere */ background: var(--dees-terminal-background, #000000); color: #fff; display: none; position: absolute; white-space: nowrap; z-index: 1; } .xterm .composition-view.active { display: block; } .xterm .xterm-viewport { /* On OS X this is required in order for the scroll bar to appear fully opaque */ background-color: var(--dees-terminal-background, #000000); overflow-y: scroll; cursor: default; position: absolute; right: 0; left: 0; top: 0; bottom: 0; } .xterm .xterm-screen { position: relative; } .xterm .xterm-screen canvas { position: absolute; left: 0; top: 0; } .xterm .xterm-scroll-area { visibility: hidden; } .xterm-char-measure-element { display: inline-block; visibility: hidden; position: absolute; top: 0; left: -9999em; line-height: normal; } .xterm { cursor: text; } .xterm.enable-mouse-events { /* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */ cursor: default; } .xterm.xterm-cursor-pointer { cursor: pointer; } .xterm.column-select.focus { /* Column selection mode */ cursor: crosshair; } .xterm .xterm-accessibility, .xterm .xterm-message { position: absolute; left: 0; top: 0; bottom: 0; right: 0; z-index: 10; color: transparent; } .xterm .live-region { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; } .xterm-dim { opacity: 0.5; } .xterm-underline { text-decoration: underline; } `, ]; public render(): TemplateResult { return html`
`; } private fitAddon: FitAddon; private terminal: Terminal | null = null; public async firstUpdated( _changedProperties: Map ): Promise { const domtools = await this.domtoolsPromise; super.firstUpdated(_changedProperties); // Sync CSS variable with background property this.style.setProperty('--dees-terminal-background', this.background); const container = this.shadowRoot.getElementById('container'); const term = new Terminal({ convertEol: true, cursorBlink: true, theme: { background: this.background, }, }); this.terminal = term; this.fitAddon = new FitAddon(); term.loadAddon(this.fitAddon); // Open the terminal in #terminal-container term.open(container); // Make the terminal's size and geometry fit the size of #terminal-container this.fitAddon.fit(); // Check if execution environment is provided if (!this.executionEnvironment) { term.write('\x1b[31m'); // Red color term.write('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\r\n'); term.write(' ❌ No execution environment provided.\r\n'); term.write('\r\n'); term.write(' Pass an IExecutionEnvironment via the\r\n'); term.write(' \'executionEnvironment\' property.\r\n'); term.write('\r\n'); term.write(' Example:\r\n'); term.write(' const env = new WebContainerEnvironment();\r\n'); term.write(' \r\n'); term.write('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\r\n'); term.write('\x1b[0m'); // Reset color return; } term.write('Initializing execution environment...\r\n'); // Initialize the execution environment try { await this.executionEnvironment.init(); term.write('Environment ready. Starting shell...\r\n'); } catch (error) { term.write('\x1b[31m'); // Red color term.write(`\r\n❌ Failed to initialize environment: ${error}\r\n`); term.write('\x1b[0m'); // Reset color console.error('Failed to initialize execution environment:', error); return; } // Spawn shell process let shellProcess; try { shellProcess = await this.executionEnvironment.spawn('jsh'); } catch (error) { term.write('\x1b[31m'); // Red color term.write(`\r\n❌ Failed to spawn shell: ${error}\r\n`); term.write('\x1b[0m'); // Reset color console.error('Failed to spawn shell:', error); return; } shellProcess.output.pipeTo( new WritableStream({ write(data) { term.write(data); }, }) ); const input = shellProcess.input.getWriter(); term.onData((data) => { input.write(data); }); await this.waitForPrompt(term, '~/'); // Set environment variables if provided if (Object.keys(this.environmentVariables).length > 0) { await this.setEnvironmentVariables(this.environmentVariables); input.write(`source source.env\n`); await this.waitForPrompt(term, '~/'); } // Run setup command if provided if (this.setupCommand) { input.write(this.setupCommand); await this.waitForPrompt(term, '~/'); } input.write(`clear && echo 'Terminal ready.'\n`); this.environmentDeferred.resolve(this.executionEnvironment); } async connectedCallback(): Promise { await super.connectedCallback(); this.resizeObserver.observe(this); } async disconnectedCallback(): Promise { this.resizeObserver.unobserve(this); await super.disconnectedCallback(); } handleResize() { this.fitAddon.fit(); } public async waitForPrompt(term: Terminal, prompt: string): Promise { return new Promise((resolve) => { const checkPrompt = () => { const lines = term.buffer.active; for (let i = 0; i < lines.length; i++) { const line = lines.getLine(i); if (line && line.translateToString().includes(prompt)) { setTimeout(() => { resolve(); }, 100); return; } } setTimeout(checkPrompt, 100); // check every 100 ms }; checkPrompt(); }); } public async setEnvironmentVariables(envArg: { [key: string]: string }): Promise { if (!this.executionEnvironment) { throw new Error('No execution environment available'); } let envFile = ''; for (const key in envArg) { envFile += `export ${key}="${envArg[key]}"\n`; } // Write the environment file using the filesystem API await this.executionEnvironment.writeFile('/source.env', envFile); } /** * Get the underlying execution environment. * Useful for advanced operations like filesystem access. */ public getExecutionEnvironment(): IExecutionEnvironment | null { return this.executionEnvironment; } }