diff --git a/packages/react-components/package.json b/packages/react-components/package.json index 75f2d8a..8284ae9 100644 --- a/packages/react-components/package.json +++ b/packages/react-components/package.json @@ -19,7 +19,8 @@ "dist" ], "devDependencies": { - "@runmedev/react-console": "workspace:*" + "@runmedev/react-console": "workspace:*", + "@runmedev/renderers": "workspace:*" }, "scripts": { "clean": "rimraf dist", diff --git a/packages/react-components/src/App.tsx b/packages/react-components/src/App.tsx index e63bb0b..bb863d4 100644 --- a/packages/react-components/src/App.tsx +++ b/packages/react-components/src/App.tsx @@ -17,6 +17,7 @@ import './index.css' import Layout from './layout' import { getAccessToken } from './token' import { NotFound } from './components' +import DemoConsole from './components/Actions/DemoConsole' export interface AppBranding { name: string @@ -50,6 +51,19 @@ function AppRoutes({ branding }: { branding: AppBranding }) { /> } /> + + } + /> + } + /> (null) + + const eventHandler = (eventName: string) => (e: Event) => { + console.log(eventName, e) + } + + let inputBuffer = '' + let messageListener: ((message: unknown) => void) | undefined + + return ( +
{ + if (!el || el.hasChildNodes()) { + return + } + + const ctxBridge = { + postMessage: (message: any) => { + if (message?.type === ClientMessages.terminalStdin) { + const input = (message.output?.input as string) ?? '' + for (const ch of input) { + if (ch === '\r' || ch === '\n') { + const trimmed = inputBuffer.trim() + messageListener?.({ + type: ClientMessages.terminalStdout, + output: { + 'runme.dev/id': consoleID, + data: `\r\nGot input: ${trimmed}\r\n> `, + }, + }) + inputBuffer = '' + continue + } + + // handle backspace/delete + if (ch === '\u0008' || ch === '\u007f') { + inputBuffer = inputBuffer.slice(0, -1) + messageListener?.({ + type: ClientMessages.terminalStdout, + output: { + 'runme.dev/id': consoleID, + data: '\b \b', + }, + }) + continue + } + + inputBuffer += ch + messageListener?.({ + type: ClientMessages.terminalStdout, + output: { + 'runme.dev/id': consoleID, + data: ch, + }, + }) + } + } + }, + onDidReceiveMessage: (listener: (message: unknown) => void) => { + messageListener = listener + listener({ + type: ClientMessages.terminalStdout, + output: { + 'runme.dev/id': consoleID, + data: 'Welcome to the app console\n> ', + }, + } as any) + return { + dispose: () => {}, + } + }, + } as RendererContext + setContext(ctxBridge, consoleID) + + const elem = document.createElement('console-view') + elemRef.current = elem + + elem.addEventListener('stdout', eventHandler('stdout')) + elem.addEventListener('stderr', eventHandler('stderr')) + elem.addEventListener('exitcode', eventHandler('exitcode')) + elem.addEventListener('pid', eventHandler('pid')) + elem.addEventListener('mimetype', eventHandler('mimetype')) + + elem.setAttribute('id', consoleID) + elem.setAttribute('takeFocus', 'false') + elem.setAttribute('buttons', 'false') + elem.setAttribute('initialContent', 'Hello, world!\n') + elem.setAttribute('theme', 'dark') + elem.setAttribute('fontFamily', 'monospace') + elem.setAttribute('fontSize', '12') + elem.setAttribute('cursorStyle', 'block') + elem.setAttribute('cursorBlink', 'true') + elem.setAttribute('cursorWidth', '1') + elem.setAttribute('smoothScrollDuration', '0') + elem.setAttribute('scrollback', '4000') + + el.appendChild(elem) + + return () => { + removeContext(consoleID) + } + }} + >
+ ) +} + +export default DemoConsole diff --git a/packages/renderers/src/components/closeCellButton.ts b/packages/renderers/src/components/closeCellButton.ts index c0513cb..e07f18e 100644 --- a/packages/renderers/src/components/closeCellButton.ts +++ b/packages/renderers/src/components/closeCellButton.ts @@ -1,7 +1,8 @@ import { LitElement, css, html } from 'lit' -import { customElement } from 'lit/decorators.js' -@customElement('close-cell-button') +import { safeCustomElement } from '../decorators' + +@safeCustomElement('close-cell-button') export class CloseCellButton extends LitElement { /* eslint-disable */ static styles = css` diff --git a/packages/renderers/src/components/console/actionButton.ts b/packages/renderers/src/components/console/actionButton.ts index 5f1d9eb..a50eced 100644 --- a/packages/renderers/src/components/console/actionButton.ts +++ b/packages/renderers/src/components/console/actionButton.ts @@ -1,11 +1,12 @@ import { LitElement, css, html } from 'lit' -import { customElement, property } from 'lit/decorators.js' +import { property } from 'lit/decorators.js' import { when } from 'lit/directives/when.js' +import { safeCustomElement } from '../../decorators' import { SaveIcon } from '../icons/save' import { ShareIcon } from '../icons/share' -@customElement('action-button') +@safeCustomElement('action-button') export class ActionButton extends LitElement { @property({ type: String }) text: string = 'Copy' diff --git a/packages/renderers/src/components/console/gistCell.ts b/packages/renderers/src/components/console/gistCell.ts index 33c9aab..ccce403 100644 --- a/packages/renderers/src/components/console/gistCell.ts +++ b/packages/renderers/src/components/console/gistCell.ts @@ -1,10 +1,11 @@ import { LitElement, css, html } from 'lit' -import { customElement, property } from 'lit/decorators.js' +import { property } from 'lit/decorators.js' import { when } from 'lit/directives/when.js' +import { safeCustomElement } from '../../decorators' import { GistIcon } from '../icons/gistIcon' -@customElement('gist-cell') +@safeCustomElement('gist-cell') export class GistCell extends LitElement { @property({ type: String }) text: string = 'Preview & Gist' diff --git a/packages/renderers/src/components/console/open.ts b/packages/renderers/src/components/console/open.ts index c156140..284487d 100644 --- a/packages/renderers/src/components/console/open.ts +++ b/packages/renderers/src/components/console/open.ts @@ -1,10 +1,11 @@ import { LitElement, css, html } from 'lit' -import { customElement, property } from 'lit/decorators.js' +import { property } from 'lit/decorators.js' import { when } from 'lit/directives/when.js' +import { safeCustomElement } from '../../decorators' import { EyeIcon } from '../icons/eye' -@customElement('open-cell') +@safeCustomElement('open-cell') export class OpenCell extends LitElement { @property({ type: String }) openText: string = 'Open' diff --git a/packages/renderers/src/components/console/runme.ts b/packages/renderers/src/components/console/runme.ts index afc5c3f..88923b5 100644 --- a/packages/renderers/src/components/console/runme.ts +++ b/packages/renderers/src/components/console/runme.ts @@ -10,11 +10,12 @@ import { import { create } from '@bufbuild/protobuf' import { Interceptor } from '@connectrpc/connect' import { LitElement, PropertyValues, html } from 'lit' -import { customElement, property } from 'lit/decorators.js' +import { property } from 'lit/decorators.js' import { Disposable } from 'vscode' import { type RendererContext } from 'vscode-notebook-renderer' import { type VSCodeEvent } from 'vscode-notebook-renderer/events' +import { safeCustomElement } from '../../decorators' import { setContext } from '../../messaging' import Streams from '../../streams' import { ClientMessages } from '../../types' @@ -31,7 +32,7 @@ export interface RunmeConsoleStream { export const RUNME_CONSOLE = 'runme-console' -@customElement(RUNME_CONSOLE) +@safeCustomElement(RUNME_CONSOLE) export class RunmeConsole extends LitElement { protected disposables: Disposable[] = [] diff --git a/packages/renderers/src/components/console/saveButton.ts b/packages/renderers/src/components/console/saveButton.ts index f166e5d..905cdce 100644 --- a/packages/renderers/src/components/console/saveButton.ts +++ b/packages/renderers/src/components/console/saveButton.ts @@ -1,9 +1,10 @@ import { LitElement, html } from 'lit' -import { customElement, property } from 'lit/decorators.js' +import { property } from 'lit/decorators.js' +import { safeCustomElement } from '../../decorators' import './actionButton' -@customElement('save-button') +@safeCustomElement('save-button') export class SaveButton extends LitElement { @property({ type: Boolean, reflect: true }) loading: boolean = false diff --git a/packages/renderers/src/components/console/shareButton.ts b/packages/renderers/src/components/console/shareButton.ts index 54dac10..17f8b36 100644 --- a/packages/renderers/src/components/console/shareButton.ts +++ b/packages/renderers/src/components/console/shareButton.ts @@ -1,9 +1,10 @@ import { LitElement, html } from 'lit' -import { customElement, property } from 'lit/decorators.js' +import { property } from 'lit/decorators.js' +import { safeCustomElement } from '../../decorators' import './actionButton' -@customElement('share-button') +@safeCustomElement('share-button') export class ShareButton extends LitElement { @property({ type: Boolean, reflect: true }) loading: boolean = false diff --git a/packages/renderers/src/components/console/view.ts b/packages/renderers/src/components/console/view.ts index e3a999a..5b8fa05 100644 --- a/packages/renderers/src/components/console/view.ts +++ b/packages/renderers/src/components/console/view.ts @@ -3,7 +3,7 @@ import { Unicode11Addon } from '@xterm/addon-unicode11' import { WebLinksAddon } from '@xterm/addon-web-links' import { ITheme, Terminal as XTermJS } from '@xterm/xterm' import { LitElement, PropertyValues, css, html, unsafeCSS } from 'lit' -import { customElement, property } from 'lit/decorators.js' +import { property } from 'lit/decorators.js' import { when } from 'lit/directives/when.js' import { Observable } from 'rxjs' import { @@ -14,7 +14,9 @@ import { share, } from 'rxjs/operators' import { Disposable, TerminalDimensions } from 'vscode' +import { RendererContext } from 'vscode-notebook-renderer' +import { safeCustomElement } from '../../decorators' import { FitAddon, type ITerminalDimensions } from '../../fitAddon' import { getContext, onClientMessage, postClientMessage } from '../../messaging' import { ClientMessages, OutputType, WebViews } from '../../types' @@ -90,7 +92,7 @@ const ANSI_COLORS = [ export const CONSOLE_VIEW = 'console-view' -@customElement(CONSOLE_VIEW) +@safeCustomElement(CONSOLE_VIEW) export class ConsoleView extends LitElement { protected copyText = 'Copy' @@ -463,6 +465,14 @@ export class ConsoleView extends LitElement { // For 'vscode' theme, no additional styles are applied } + protected getContext(): RendererContext { + try { + return getContext(this.id) + } catch { + return getContext() + } + } + connectedCallback(): void { super.connectedCallback() @@ -525,7 +535,7 @@ export class ConsoleView extends LitElement { this.terminal.unicode.activeVersion = '11' this.terminal.options.drawBoldTextInBrightColors - const ctx = getContext() + const ctx = this.getContext() this.disposables.push( // todo(sebastian): what's the type of e? @@ -726,7 +736,7 @@ export class ConsoleView extends LitElement { this.#subscribeSetTerminalRows(dims) terminalContainer.appendChild(resizeDragHandle) - const ctx = getContext() + const ctx = this.getContext() ctx.postMessage && postClientMessage(ctx, ClientMessages.terminalOpen, { 'runme.dev/id': this.id!, @@ -905,7 +915,7 @@ export class ConsoleView extends LitElement { ) const sub = debounced$.subscribe(async (terminalDimensions) => { - const ctx = getContext() + const ctx = this.getContext() if (!ctx.postMessage) { return } @@ -930,7 +940,7 @@ export class ConsoleView extends LitElement { ) const sub = debounced$.subscribe(async (terminalRows) => { - const ctx = getContext() + const ctx = this.getContext() if (!ctx.postMessage) { return } @@ -963,7 +973,7 @@ export class ConsoleView extends LitElement { this.terminal?.focus() } - const ctx = getContext() + const ctx = this.getContext() if (!ctx.postMessage) { return } @@ -974,7 +984,7 @@ export class ConsoleView extends LitElement { } async #displayShareDialog(): Promise { - const ctx = getContext() + const ctx = this.getContext() if (!ctx.postMessage || !this.shareUrl) { return } @@ -1016,7 +1026,7 @@ export class ConsoleView extends LitElement { } async #triggerOpenEscalation(): Promise { - const ctx = getContext() + const ctx = this.getContext() if (!this.escalationUrl) { return @@ -1026,7 +1036,7 @@ export class ConsoleView extends LitElement { } #openSessionOutput(): Promise | undefined { - const ctx = getContext() + const ctx = this.getContext() if (!ctx.postMessage) { return } @@ -1042,7 +1052,7 @@ export class ConsoleView extends LitElement { async #shareCellOutput( _isUserAction: boolean ): Promise { - const ctx = getContext() + const ctx = this.getContext() if (!ctx.postMessage) { return } @@ -1084,17 +1094,21 @@ export class ConsoleView extends LitElement { } #onWebLinkClick(_event: MouseEvent, uri: string): void { - postClientMessage(getContext(), ClientMessages.openLink, uri) + postClientMessage(this.getContext(), ClientMessages.openLink, uri) } #triggerOpenCellOutput(): void { - postClientMessage(getContext(), ClientMessages.openLink, this.shareUrl!) + postClientMessage( + this.getContext(), + ClientMessages.openLink, + this.shareUrl! + ) } #onEscalateDisabled(): void { const message = 'There is no Slack integration configured yet. \nOpen Dashboard to configure it' - postClientMessage(getContext(), ClientMessages.errorMessage, message) + postClientMessage(this.getContext(), ClientMessages.errorMessage, message) } // Render the UI as a function of component state @@ -1205,7 +1219,7 @@ export class ConsoleView extends LitElement { } #copy() { - const ctx = getContext() + const ctx = this.getContext() if (!ctx.postMessage) { return } diff --git a/packages/renderers/src/components/copyButton.ts b/packages/renderers/src/components/copyButton.ts index 498e6a8..38e0c3b 100644 --- a/packages/renderers/src/components/copyButton.ts +++ b/packages/renderers/src/components/copyButton.ts @@ -1,9 +1,10 @@ import { LitElement, css, html } from 'lit' -import { customElement, property } from 'lit/decorators.js' +import { property } from 'lit/decorators.js' +import { safeCustomElement } from '../decorators' import { CopyIcon } from './icons/copy' -@customElement('copy-button') +@safeCustomElement('copy-button') export class CopyButton extends LitElement { @property({ type: String }) copyText: string = 'Copy' diff --git a/packages/renderers/src/components/dropdownlist.ts b/packages/renderers/src/components/dropdownlist.ts index 76a7928..3f193ae 100644 --- a/packages/renderers/src/components/dropdownlist.ts +++ b/packages/renderers/src/components/dropdownlist.ts @@ -1,5 +1,7 @@ import { LitElement, css, html } from 'lit' -import { customElement, property } from 'lit/decorators.js' +import { property } from 'lit/decorators.js' + +import { safeCustomElement } from '../decorators' export interface DropdownListOption { text: string @@ -12,7 +14,7 @@ export type DropdownListEvent = { key: string } -@customElement('dropdown-list') +@safeCustomElement('dropdown-list') export class DropdownList extends LitElement { @property({ type: String }) label: string | undefined diff --git a/packages/renderers/src/components/env/store.ts b/packages/renderers/src/components/env/store.ts index 001dec9..b2d0353 100644 --- a/packages/renderers/src/components/env/store.ts +++ b/packages/renderers/src/components/env/store.ts @@ -1,9 +1,10 @@ import { MonitorEnvStoreResponseSnapshot_SnapshotEnv } from '@buf/runmedev_runme.bufbuild_es/runme/runner/v2/runner_pb' import { MonitorEnvStoreResponseSnapshot_Status } from '@buf/runmedev_runme.bufbuild_es/runme/runner/v2/runner_pb' import { LitElement, TemplateResult, html } from 'lit' -import { customElement, property } from 'lit/decorators.js' +import { property } from 'lit/decorators.js' import { Disposable } from 'vscode' +import { safeCustomElement } from '../../decorators' import { formatDate, formatDateWithTimeAgo } from '../../utils' import '../envViewer' import { CustomErrorIcon } from '../icons/error' @@ -45,7 +46,7 @@ const COLUMNS = [ const HIDDEN_COLUMNS = ['resolvedValue', 'errors', 'status', 'specClass'] -@customElement('env-store') +@safeCustomElement('env-store') export default class Table extends LitElement { protected disposables: Disposable[] = [] diff --git a/packages/renderers/src/components/envViewer.ts b/packages/renderers/src/components/envViewer.ts index 13b91ba..d781d52 100644 --- a/packages/renderers/src/components/envViewer.ts +++ b/packages/renderers/src/components/envViewer.ts @@ -1,16 +1,17 @@ import { MonitorEnvStoreResponseSnapshot_Status } from '@buf/runmedev_runme.bufbuild_es/runme/runner/v2/runner_pb' import { LitElement, TemplateResult, css, html } from 'lit' -import { customElement, property, state } from 'lit/decorators.js' +import { property, state } from 'lit/decorators.js' import { when } from 'lit/directives/when.js' import { Disposable } from 'vscode' +import { safeCustomElement } from '../decorators' import { CheckIcon } from './icons/check' import { CopyIcon } from './icons/copy' import { EyeIcon } from './icons/eye' import { EyeClosedIcon } from './icons/eyeClosed' import './tooltip' -@customElement('env-viewer') +@safeCustomElement('env-viewer') export class EnvViewer extends LitElement implements Disposable { protected disposables: Disposable[] = [] diff --git a/packages/renderers/src/components/table/index.ts b/packages/renderers/src/components/table/index.ts index 8c2e210..0d051da 100644 --- a/packages/renderers/src/components/table/index.ts +++ b/packages/renderers/src/components/table/index.ts @@ -1,13 +1,15 @@ import { LitElement, TemplateResult, css, html, nothing } from 'lit' -import { customElement, property } from 'lit/decorators.js' +import { property } from 'lit/decorators.js' import { when } from 'lit/directives/when.js' +import { safeCustomElement } from '../../decorators' + export interface Column { text: string colspan?: number | undefined } -@customElement('table-view') +@safeCustomElement('table-view') export class Table extends LitElement { @property({ type: Array }) columns?: Column[] = [] diff --git a/packages/renderers/src/components/tooltip.ts b/packages/renderers/src/components/tooltip.ts index 59321ba..424b40d 100644 --- a/packages/renderers/src/components/tooltip.ts +++ b/packages/renderers/src/components/tooltip.ts @@ -1,8 +1,10 @@ import { LitElement, TemplateResult, css, html } from 'lit' -import { customElement, property } from 'lit/decorators.js' +import { property } from 'lit/decorators.js' import { when } from 'lit/directives/when.js' -@customElement('tooltip-text') +import { safeCustomElement } from '../decorators' + +@safeCustomElement('tooltip-text') export class Tooltip extends LitElement { @property({ type: String }) tooltipText: string | TemplateResult<1> | undefined diff --git a/packages/renderers/src/decorators.ts b/packages/renderers/src/decorators.ts new file mode 100644 index 0000000..c427956 --- /dev/null +++ b/packages/renderers/src/decorators.ts @@ -0,0 +1,12 @@ +/** + * A safe version of @customElement that checks if the element is already defined + * before registering it. This prevents errors when the same module is imported multiple times. + */ +export function safeCustomElement(tagName: string) { + return function (constructor: T): T { + if (!customElements.get(tagName)) { + customElements.define(tagName, constructor) + } + return constructor + } +} diff --git a/packages/renderers/src/index.ts b/packages/renderers/src/index.ts index 54e2f56..840d830 100644 --- a/packages/renderers/src/index.ts +++ b/packages/renderers/src/index.ts @@ -1,10 +1,12 @@ import './components' -import { getContext, setContext } from './messaging' +import { getContext, removeContext, setContext } from './messaging' import { ClientMessages } from './types' +export { type RendererContext } from 'vscode-notebook-renderer' + export { default as Streams, type Authorization } from './streams' export { genRunID, Heartbeat, type StreamError } from './streams' export { ConsoleView, type ConsoleViewConfig } from './components/console' export { type RunmeConsoleStream } from './components/console/runme' -export { setContext, getContext, ClientMessages } +export { setContext, getContext, removeContext, ClientMessages } diff --git a/packages/renderers/src/messaging.ts b/packages/renderers/src/messaging.ts index a35b179..c59949f 100644 --- a/packages/renderers/src/messaging.ts +++ b/packages/renderers/src/messaging.ts @@ -3,7 +3,20 @@ import { RendererContext } from 'vscode-notebook-renderer' import { ClientMessage, ClientMessagePayload } from './types' -let context: RendererContext | undefined +const DEFAULT_NAMESPACE = 'runme' +// Use a global symbol to ensure the contexts Map is shared across all module instances +// This is necessary because Vite/bundlers may create separate module instances +const GLOBAL_CONTEXTS_KEY = Symbol.for('@runmedev/renderers:contexts') + +function getContextsMap(): Map> { + const globalObj = globalThis as any + if (!globalObj[GLOBAL_CONTEXTS_KEY]) { + globalObj[GLOBAL_CONTEXTS_KEY] = new Map>() + } + return globalObj[GLOBAL_CONTEXTS_KEY] +} + +const contexts = getContextsMap() interface Messaging { postMessage(msg: unknown): Thenable | Thenable | void @@ -30,13 +43,21 @@ export function onClientMessage( return messaging.onDidReceiveMessage?.(cb) ?? { dispose: () => {} } } -export function getContext() { +export function getContext(namespace?: string) { + const ns = namespace ?? DEFAULT_NAMESPACE + const context = contexts.get(ns) if (!context) { - throw new Error('Renderer context not defined') + throw new Error(`Renderer context not defined for namespace: ${ns}`) } return context } -export function setContext(c: RendererContext) { - context = c +export function setContext(c: RendererContext, namespace?: string) { + const ns = namespace ?? DEFAULT_NAMESPACE + contexts.set(ns, c) +} + +export function removeContext(namespace?: string) { + const ns = namespace ?? DEFAULT_NAMESPACE + contexts.delete(ns) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5e2754a..d2ff7b4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -232,6 +232,9 @@ importers: '@runmedev/react-console': specifier: workspace:* version: link:../react-console + '@runmedev/renderers': + specifier: workspace:* + version: link:../renderers packages/react-console: dependencies: @@ -299,7 +302,7 @@ importers: version: 14.6.1(@testing-library/dom@10.4.1) '@vitejs/plugin-react': specifier: ^4.0.0 - version: 4.7.0(vite@5.4.20(@types/node@20.12.7)(lightningcss@1.30.1)) + version: 4.7.0(vite@6.3.6(@types/node@20.12.7)(jiti@2.6.1)(lightningcss@1.30.1)) '@vitest/ui': specifier: ^2.0.0 version: 2.1.9(vitest@2.1.9) @@ -5738,18 +5741,6 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-react@4.7.0(vite@5.4.20(@types/node@20.12.7)(lightningcss@1.30.1))': - dependencies: - '@babel/core': 7.28.4 - '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.4) - '@rolldown/pluginutils': 1.0.0-beta.27 - '@types/babel__core': 7.20.5 - react-refresh: 0.17.0 - vite: 5.4.20(@types/node@20.12.7)(lightningcss@1.30.1) - transitivePeerDependencies: - - supports-color - '@vitejs/plugin-react@4.7.0(vite@6.3.6(@types/node@20.12.7)(jiti@2.6.1)(lightningcss@1.30.1))': dependencies: '@babel/core': 7.28.4