From a8f24e83debc3c289b98d76ed4486d6b64ab5fb5 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Tue, 30 Dec 2025 15:37:18 +0000 Subject: [PATCH] feat(editor/runtime): Replace bare editor with Monaco-based editor and add runtime + workspace/filetree integration --- changelog.md | 10 + ts_web/00_commitinfo_data.ts | 2 +- .../00group-editor/dees-editor-bare/index.ts | 1 - .../dees-editor-filetree.ts | 530 ++++++++++++++++ .../dees-editor-filetree/index.ts | 1 + .../dees-editor-markdown.ts | 8 +- .../dees-editor-monaco.ts} | 16 +- .../dees-editor-monaco/index.ts | 1 + .../version.ts | 0 .../dees-editor-workspace.ts | 594 ++++++++++++++++++ .../dees-editor-workspace/index.ts | 1 + ts_web/elements/00group-editor/index.ts | 4 +- .../dees-input-code/dees-input-code.ts | 24 +- .../environments/WebContainerEnvironment.ts | 138 ++++ .../00group-runtime/environments/index.ts | 1 + ts_web/elements/00group-runtime/index.ts | 5 + .../interfaces/IExecutionEnvironment.ts | 101 +++ .../00group-runtime/interfaces/index.ts | 1 + .../elements/dees-terminal/dees-terminal.ts | 136 ++-- ts_web/elements/index.ts | 1 + 20 files changed, 1513 insertions(+), 62 deletions(-) delete mode 100644 ts_web/elements/00group-editor/dees-editor-bare/index.ts create mode 100644 ts_web/elements/00group-editor/dees-editor-filetree/dees-editor-filetree.ts create mode 100644 ts_web/elements/00group-editor/dees-editor-filetree/index.ts rename ts_web/elements/00group-editor/{dees-editor-bare/dees-editor-bare.ts => dees-editor-monaco/dees-editor-monaco.ts} (90%) create mode 100644 ts_web/elements/00group-editor/dees-editor-monaco/index.ts rename ts_web/elements/00group-editor/{dees-editor-bare => dees-editor-monaco}/version.ts (100%) create mode 100644 ts_web/elements/00group-editor/dees-editor-workspace/dees-editor-workspace.ts create mode 100644 ts_web/elements/00group-editor/dees-editor-workspace/index.ts create mode 100644 ts_web/elements/00group-runtime/environments/WebContainerEnvironment.ts create mode 100644 ts_web/elements/00group-runtime/environments/index.ts create mode 100644 ts_web/elements/00group-runtime/index.ts create mode 100644 ts_web/elements/00group-runtime/interfaces/IExecutionEnvironment.ts create mode 100644 ts_web/elements/00group-runtime/interfaces/index.ts diff --git a/changelog.md b/changelog.md index 0d10423..df768f3 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,15 @@ # Changelog +## 2025-12-30 - 3.13.0 - feat(editor/runtime) +Replace bare editor with Monaco-based editor and add runtime + workspace/filetree integration + +- Removed dees-editor-bare and replaced usages with dees-editor-monaco (includes MONACO_VERSION file). +- Added IExecutionEnvironment interface and WebContainerEnvironment implementation (uses @webcontainer/api) to provide a browser Node/runtime API. +- Added new components: dees-editor-filetree and dees-editor-workspace to support file tree, multiple open files, and workspace actions wired to the execution environment. +- dees-terminal updated to accept an executionEnvironment (IExecutionEnvironment), renamed environment -> environmentVariables, provides environmentPromise (deprecated note), and now initializes/uses the provided environment to spawn shell processes and write /source.env. +- Updated imports/usages across components (dees-input-code, dees-editor-markdown, group index exports) to use the new Monaco editor and runtime modules. +- Behavioral breaking changes: consumers must supply an IExecutionEnvironment to components that now depend on it (e.g. dees-terminal, workspace, filetree); dees-editor-bare removal is a breaking API change. + ## 2025-12-30 - 3.12.2 - fix(dees-editor-bare) make Monaco editor follow domtools theme and clean up theme subscription on disconnect diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 6b23de7..1678fd2 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@design.estate/dees-catalog', - version: '3.12.2', + version: '3.13.0', description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.' } diff --git a/ts_web/elements/00group-editor/dees-editor-bare/index.ts b/ts_web/elements/00group-editor/dees-editor-bare/index.ts deleted file mode 100644 index 91e0709..0000000 --- a/ts_web/elements/00group-editor/dees-editor-bare/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './dees-editor-bare.js'; diff --git a/ts_web/elements/00group-editor/dees-editor-filetree/dees-editor-filetree.ts b/ts_web/elements/00group-editor/dees-editor-filetree/dees-editor-filetree.ts new file mode 100644 index 0000000..9980685 --- /dev/null +++ b/ts_web/elements/00group-editor/dees-editor-filetree/dees-editor-filetree.ts @@ -0,0 +1,530 @@ +import { + DeesElement, + property, + html, + customElement, + type TemplateResult, + css, + cssManager, + state, +} from '@design.estate/dees-element'; +import * as domtools from '@design.estate/dees-domtools'; +import { themeDefaultStyles } from '../../00theme.js'; +import type { IExecutionEnvironment, IFileEntry } from '../../00group-runtime/index.js'; +import '../../dees-icon/dees-icon.js'; +import '../../dees-contextmenu/dees-contextmenu.js'; +import { DeesContextmenu } from '../../dees-contextmenu/dees-contextmenu.js'; + +declare global { + interface HTMLElementTagNameMap { + 'dees-editor-filetree': DeesEditorFiletree; + } +} + +interface ITreeNode extends IFileEntry { + children?: ITreeNode[]; + expanded?: boolean; + level: number; +} + +@customElement('dees-editor-filetree') +export class DeesEditorFiletree extends DeesElement { + public static demo = () => html` +
+ +
+ `; + + // INSTANCE + @property({ type: Object }) + accessor executionEnvironment: IExecutionEnvironment | null = null; + + @property({ type: String }) + accessor rootPath: string = '/'; + + @property({ type: String }) + accessor selectedPath: string = ''; + + @state() + accessor treeData: ITreeNode[] = []; + + @state() + accessor isLoading: boolean = false; + + @state() + accessor errorMessage: string = ''; + + private expandedPaths: Set = new Set(); + + public static styles = [ + themeDefaultStyles, + cssManager.defaultStyles, + css` + :host { + display: block; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + overflow: auto; + background: ${cssManager.bdTheme('hsl(0 0% 98%)', 'hsl(0 0% 9%)')}; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 13px; + } + + .tree-container { + padding: 8px 0; + } + + .tree-item { + display: flex; + align-items: center; + padding: 4px 8px; + cursor: pointer; + user-select: none; + border-radius: 4px; + margin: 1px 4px; + transition: background 0.1s ease; + } + + .tree-item:hover { + background: ${cssManager.bdTheme('hsl(0 0% 93%)', 'hsl(0 0% 14%)')}; + } + + .tree-item.selected { + background: ${cssManager.bdTheme('hsl(210 100% 95%)', 'hsl(210 50% 20%)')}; + color: ${cssManager.bdTheme('hsl(210 100% 40%)', 'hsl(210 100% 70%)')}; + } + + .tree-item.selected:hover { + background: ${cssManager.bdTheme('hsl(210 100% 92%)', 'hsl(210 50% 25%)')}; + } + + .indent { + display: inline-block; + width: 16px; + flex-shrink: 0; + } + + .expand-icon { + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: ${cssManager.bdTheme('hsl(0 0% 50%)', 'hsl(0 0% 60%)')}; + transition: transform 0.15s ease; + } + + .expand-icon.expanded { + transform: rotate(90deg); + } + + .expand-icon.hidden { + visibility: hidden; + } + + .file-icon { + width: 16px; + height: 16px; + margin-right: 6px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + } + + .file-icon dees-icon { + width: 16px; + height: 16px; + } + + .file-icon.folder { + color: ${cssManager.bdTheme('hsl(45 80% 45%)', 'hsl(45 70% 55%)')}; + } + + .file-icon.file { + color: ${cssManager.bdTheme('hsl(0 0% 50%)', 'hsl(0 0% 60%)')}; + } + + .file-icon.typescript { + color: hsl(211 60% 48%); + } + + .file-icon.javascript { + color: hsl(53 93% 54%); + } + + .file-icon.json { + color: hsl(45 80% 50%); + } + + .file-icon.html { + color: hsl(14 77% 52%); + } + + .file-icon.css { + color: hsl(228 77% 59%); + } + + .file-icon.markdown { + color: hsl(0 0% 50%); + } + + .file-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: ${cssManager.bdTheme('hsl(0 0% 20%)', 'hsl(0 0% 85%)')}; + } + + .loading { + padding: 16px; + text-align: center; + color: ${cssManager.bdTheme('hsl(0 0% 50%)', 'hsl(0 0% 60%)')}; + } + + .error { + padding: 16px; + text-align: center; + color: hsl(0 70% 50%); + } + + .empty { + padding: 16px; + text-align: center; + color: ${cssManager.bdTheme('hsl(0 0% 50%)', 'hsl(0 0% 60%)')}; + font-style: italic; + } + `, + ]; + + public render(): TemplateResult { + if (!this.executionEnvironment) { + return html` +
+ No execution environment provided. +
+ `; + } + + if (this.isLoading) { + return html` +
+ Loading files... +
+ `; + } + + if (this.errorMessage) { + return html` +
+ ${this.errorMessage} +
+ `; + } + + if (this.treeData.length === 0) { + return html` +
+ No files found. +
+ `; + } + + return html` +
+ ${this.renderTree(this.treeData)} +
+ `; + } + + private renderTree(nodes: ITreeNode[]): TemplateResult[] { + return nodes.map(node => this.renderNode(node)); + } + + private renderNode(node: ITreeNode): TemplateResult { + const isDirectory = node.type === 'directory'; + const isExpanded = this.expandedPaths.has(node.path); + const isSelected = node.path === this.selectedPath; + const iconClass = this.getFileIconClass(node); + + return html` +
this.handleItemClick(e, node)} + @contextmenu=${(e: MouseEvent) => this.handleContextMenu(e, node)} + > + + + + + + + ${node.name} +
+ ${isDirectory && isExpanded && node.children + ? this.renderTree(node.children) + : ''} + `; + } + + private getFileIcon(node: ITreeNode): string { + if (node.type === 'directory') { + return this.expandedPaths.has(node.path) ? 'lucide:folderOpen' : 'lucide:folder'; + } + + const ext = node.name.split('.').pop()?.toLowerCase(); + switch (ext) { + case 'ts': + case 'tsx': + return 'lucide:fileCode'; + case 'js': + case 'jsx': + return 'lucide:fileCode'; + case 'json': + return 'lucide:fileJson'; + case 'html': + return 'lucide:fileCode'; + case 'css': + case 'scss': + case 'less': + return 'lucide:fileCode'; + case 'md': + return 'lucide:fileText'; + case 'png': + case 'jpg': + case 'jpeg': + case 'gif': + case 'svg': + return 'lucide:image'; + default: + return 'lucide:file'; + } + } + + private getFileIconClass(node: ITreeNode): string { + if (node.type === 'directory') return 'folder'; + + const ext = node.name.split('.').pop()?.toLowerCase(); + switch (ext) { + case 'ts': + case 'tsx': + return 'typescript'; + case 'js': + case 'jsx': + return 'javascript'; + case 'json': + return 'json'; + case 'html': + return 'html'; + case 'css': + case 'scss': + case 'less': + return 'css'; + case 'md': + return 'markdown'; + default: + return 'file'; + } + } + + private async handleItemClick(e: MouseEvent, node: ITreeNode) { + e.stopPropagation(); + + if (node.type === 'directory') { + await this.toggleDirectory(node); + } else { + this.selectedPath = node.path; + this.dispatchEvent( + new CustomEvent('file-select', { + detail: { path: node.path, name: node.name }, + bubbles: true, + composed: true, + }) + ); + } + } + + private async toggleDirectory(node: ITreeNode) { + if (this.expandedPaths.has(node.path)) { + this.expandedPaths.delete(node.path); + } else { + this.expandedPaths.add(node.path); + // Load children if not already loaded + if (!node.children || node.children.length === 0) { + await this.loadDirectoryContents(node); + } + } + this.requestUpdate(); + } + + private async loadDirectoryContents(node: ITreeNode) { + if (!this.executionEnvironment) return; + + try { + const entries = await this.executionEnvironment.readDir(node.path); + node.children = this.sortEntries(entries).map(entry => ({ + ...entry, + level: node.level + 1, + expanded: false, + children: entry.type === 'directory' ? [] : undefined, + })); + } catch (error) { + console.error(`Failed to load directory ${node.path}:`, error); + } + } + + private async handleContextMenu(e: MouseEvent, node: ITreeNode) { + e.preventDefault(); + e.stopPropagation(); + + const menuItems = []; + + if (node.type === 'directory') { + menuItems.push( + { + name: 'New File', + iconName: 'lucide:filePlus', + action: async () => this.createNewFile(node.path), + }, + { + name: 'New Folder', + iconName: 'lucide:folderPlus', + action: async () => this.createNewFolder(node.path), + }, + { name: 'divider' } + ); + } + + menuItems.push({ + name: 'Delete', + iconName: 'lucide:trash2', + action: async () => this.deleteItem(node), + }); + + await DeesContextmenu.openContextMenuWithOptions(e, menuItems); + } + + private async createNewFile(parentPath: string) { + const fileName = prompt('Enter file name:'); + if (!fileName || !this.executionEnvironment) return; + + const newPath = parentPath === '/' ? `/${fileName}` : `${parentPath}/${fileName}`; + try { + await this.executionEnvironment.writeFile(newPath, ''); + await this.refresh(); + this.dispatchEvent( + new CustomEvent('file-created', { + detail: { path: newPath }, + bubbles: true, + composed: true, + }) + ); + } catch (error) { + console.error('Failed to create file:', error); + } + } + + private async createNewFolder(parentPath: string) { + const folderName = prompt('Enter folder name:'); + if (!folderName || !this.executionEnvironment) return; + + const newPath = parentPath === '/' ? `/${folderName}` : `${parentPath}/${folderName}`; + try { + await this.executionEnvironment.mkdir(newPath); + await this.refresh(); + this.dispatchEvent( + new CustomEvent('folder-created', { + detail: { path: newPath }, + bubbles: true, + composed: true, + }) + ); + } catch (error) { + console.error('Failed to create folder:', error); + } + } + + private async deleteItem(node: ITreeNode) { + if (!this.executionEnvironment) return; + + const confirmed = confirm(`Delete ${node.name}?`); + if (!confirmed) return; + + try { + await this.executionEnvironment.rm(node.path, { recursive: node.type === 'directory' }); + await this.refresh(); + this.dispatchEvent( + new CustomEvent('item-deleted', { + detail: { path: node.path, type: node.type }, + bubbles: true, + composed: true, + }) + ); + } catch (error) { + console.error('Failed to delete item:', error); + } + } + + public async firstUpdated() { + await this.loadTree(); + } + + public async updated(changedProperties: Map) { + if (changedProperties.has('executionEnvironment') && this.executionEnvironment) { + await this.loadTree(); + } + } + + private async loadTree() { + if (!this.executionEnvironment) return; + + this.isLoading = true; + this.errorMessage = ''; + + try { + // Wait for environment to be ready + if (!this.executionEnvironment.ready) { + await this.executionEnvironment.init(); + } + + const entries = await this.executionEnvironment.readDir(this.rootPath); + this.treeData = this.sortEntries(entries).map(entry => ({ + ...entry, + level: 0, + expanded: false, + children: entry.type === 'directory' ? [] : undefined, + })); + } catch (error) { + this.errorMessage = `Failed to load files: ${error}`; + console.error('Failed to load file tree:', error); + } finally { + this.isLoading = false; + } + } + + private sortEntries(entries: IFileEntry[]): IFileEntry[] { + return entries.sort((a, b) => { + // Directories first + if (a.type !== b.type) { + return a.type === 'directory' ? -1 : 1; + } + // Then alphabetically + return a.name.localeCompare(b.name); + }); + } + + public async refresh() { + this.expandedPaths.clear(); + await this.loadTree(); + } + + public selectFile(path: string) { + this.selectedPath = path; + } +} diff --git a/ts_web/elements/00group-editor/dees-editor-filetree/index.ts b/ts_web/elements/00group-editor/dees-editor-filetree/index.ts new file mode 100644 index 0000000..653e6ba --- /dev/null +++ b/ts_web/elements/00group-editor/dees-editor-filetree/index.ts @@ -0,0 +1 @@ +export * from './dees-editor-filetree.js'; diff --git a/ts_web/elements/00group-editor/dees-editor-markdown/dees-editor-markdown.ts b/ts_web/elements/00group-editor/dees-editor-markdown/dees-editor-markdown.ts index 88add08..b629fcf 100644 --- a/ts_web/elements/00group-editor/dees-editor-markdown/dees-editor-markdown.ts +++ b/ts_web/elements/00group-editor/dees-editor-markdown/dees-editor-markdown.ts @@ -9,7 +9,7 @@ import { domtools } from '@design.estate/dees-element'; import { themeDefaultStyles } from '../../00theme.js'; -import { DeesEditorBare } from '../dees-editor-bare/dees-editor-bare.js'; +import { DeesEditorMonaco } from '../dees-editor-monaco/dees-editor-monaco.js'; const deferred = domtools.plugins.smartpromise.defer(); @@ -52,7 +52,7 @@ export class DeesEditorMarkdown extends DeesElement { return html`
- + >
@@ -87,7 +87,7 @@ const hello = 'yes' public async firstUpdated(_changedPropertiesArg) { await super.firstUpdated(_changedPropertiesArg); - const editor = this.shadowRoot.querySelector('dees-editor-bare') as DeesEditorBare; + const editor = this.shadowRoot.querySelector('dees-editor-monaco') as DeesEditorMonaco; // lets care about wiring the markdown stuff. const markdownOutlet = this.shadowRoot.querySelector('dees-editormarkdownoutlet'); diff --git a/ts_web/elements/00group-editor/dees-editor-bare/dees-editor-bare.ts b/ts_web/elements/00group-editor/dees-editor-monaco/dees-editor-monaco.ts similarity index 90% rename from ts_web/elements/00group-editor/dees-editor-bare/dees-editor-bare.ts rename to ts_web/elements/00group-editor/dees-editor-monaco/dees-editor-monaco.ts index 1809d4e..5e0f944 100644 --- a/ts_web/elements/00group-editor/dees-editor-bare/dees-editor-bare.ts +++ b/ts_web/elements/00group-editor/dees-editor-monaco/dees-editor-monaco.ts @@ -15,14 +15,14 @@ import type * as monaco from 'monaco-editor'; declare global { interface HTMLElementTagNameMap { - 'dees-editor-bare': DeesEditorBare; + 'dees-editor-monaco': DeesEditorMonaco; } } -@customElement('dees-editor-bare') -export class DeesEditorBare extends DeesElement { +@customElement('dees-editor-monaco') +export class DeesEditorMonaco extends DeesElement { // DEMO - public static demo = () => html` `; + public static demo = () => html``; // STATIC public static monacoDeferred: ReturnType; @@ -88,17 +88,17 @@ export class DeesEditorBare extends DeesElement { const container = this.shadowRoot.getElementById('container'); const monacoCdnBase = `https://cdn.jsdelivr.net/npm/monaco-editor@${MONACO_VERSION}`; - if (!DeesEditorBare.monacoDeferred) { - DeesEditorBare.monacoDeferred = domtools.plugins.smartpromise.defer(); + if (!DeesEditorMonaco.monacoDeferred) { + DeesEditorMonaco.monacoDeferred = domtools.plugins.smartpromise.defer(); const scriptUrl = `${monacoCdnBase}/min/vs/loader.js`; const script = document.createElement('script'); script.src = scriptUrl; script.onload = () => { - DeesEditorBare.monacoDeferred.resolve(); + DeesEditorMonaco.monacoDeferred.resolve(); }; document.head.appendChild(script); } - await DeesEditorBare.monacoDeferred.promise; + await DeesEditorMonaco.monacoDeferred.promise; (window as any).require.config({ paths: { vs: `${monacoCdnBase}/min/vs` }, diff --git a/ts_web/elements/00group-editor/dees-editor-monaco/index.ts b/ts_web/elements/00group-editor/dees-editor-monaco/index.ts new file mode 100644 index 0000000..58f495d --- /dev/null +++ b/ts_web/elements/00group-editor/dees-editor-monaco/index.ts @@ -0,0 +1 @@ +export * from './dees-editor-monaco.js'; diff --git a/ts_web/elements/00group-editor/dees-editor-bare/version.ts b/ts_web/elements/00group-editor/dees-editor-monaco/version.ts similarity index 100% rename from ts_web/elements/00group-editor/dees-editor-bare/version.ts rename to ts_web/elements/00group-editor/dees-editor-monaco/version.ts diff --git a/ts_web/elements/00group-editor/dees-editor-workspace/dees-editor-workspace.ts b/ts_web/elements/00group-editor/dees-editor-workspace/dees-editor-workspace.ts new file mode 100644 index 0000000..818a171 --- /dev/null +++ b/ts_web/elements/00group-editor/dees-editor-workspace/dees-editor-workspace.ts @@ -0,0 +1,594 @@ +import { + DeesElement, + property, + html, + customElement, + type TemplateResult, + css, + cssManager, + state, +} from '@design.estate/dees-element'; +import * as domtools from '@design.estate/dees-domtools'; +import { themeDefaultStyles } from '../../00theme.js'; +import type { IExecutionEnvironment } from '../../00group-runtime/index.js'; +import { WebContainerEnvironment } from '../../00group-runtime/index.js'; +import '../dees-editor-monaco/dees-editor-monaco.js'; +import '../dees-editor-filetree/dees-editor-filetree.js'; +import '../../dees-terminal/dees-terminal.js'; +import '../../dees-icon/dees-icon.js'; +import { DeesEditorMonaco } from '../dees-editor-monaco/dees-editor-monaco.js'; + +declare global { + interface HTMLElementTagNameMap { + 'dees-editor-workspace': DeesEditorWorkspace; + } +} + +interface IOpenFile { + path: string; + name: string; + content: string; + modified: boolean; +} + +@customElement('dees-editor-workspace') +export class DeesEditorWorkspace extends DeesElement { + public static demo = () => { + const env = new WebContainerEnvironment(); + return html` +
+ +
+ `; + }; + + // INSTANCE + @property({ type: Object }) + accessor executionEnvironment: IExecutionEnvironment | null = null; + + @property({ type: Boolean }) + accessor showFileTree: boolean = true; + + @property({ type: Boolean }) + accessor showTerminal: boolean = true; + + @property({ type: Number }) + accessor fileTreeWidth: number = 250; + + @property({ type: Number }) + accessor terminalHeight: number = 200; + + @state() + accessor openFiles: IOpenFile[] = []; + + @state() + accessor activeFilePath: string = ''; + + @state() + accessor isTerminalCollapsed: boolean = false; + + @state() + accessor isFileTreeCollapsed: boolean = false; + + @state() + accessor isInitializing: boolean = true; + + private editorElement: DeesEditorMonaco | null = null; + + public static styles = [ + themeDefaultStyles, + cssManager.defaultStyles, + css` + :host { + display: block; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: ${cssManager.bdTheme('hsl(0 0% 96%)', 'hsl(0 0% 7%)')}; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + } + + .workspace-container { + display: grid; + height: 100%; + width: 100%; + } + + .workspace-container.with-filetree.with-terminal { + grid-template-columns: auto 1fr; + grid-template-rows: 1fr auto; + grid-template-areas: + "filetree editor" + "filetree terminal"; + } + + .workspace-container.with-filetree:not(.with-terminal) { + grid-template-columns: auto 1fr; + grid-template-rows: 1fr; + grid-template-areas: "filetree editor"; + } + + .workspace-container:not(.with-filetree).with-terminal { + grid-template-columns: 1fr; + grid-template-rows: 1fr auto; + grid-template-areas: + "editor" + "terminal"; + } + + .workspace-container:not(.with-filetree):not(.with-terminal) { + grid-template-columns: 1fr; + grid-template-rows: 1fr; + grid-template-areas: "editor"; + } + + .filetree-panel { + grid-area: filetree; + position: relative; + border-right: 1px solid ${cssManager.bdTheme('hsl(0 0% 85%)', 'hsl(0 0% 15%)')}; + overflow: hidden; + transition: width 0.2s ease; + } + + .filetree-panel.collapsed { + width: 0 !important; + border-right: none; + } + + .editor-panel { + grid-area: editor; + position: relative; + display: flex; + flex-direction: column; + overflow: hidden; + background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 9%)')}; + } + + .terminal-panel { + grid-area: terminal; + position: relative; + border-top: 1px solid ${cssManager.bdTheme('hsl(0 0% 85%)', 'hsl(0 0% 15%)')}; + overflow: hidden; + transition: height 0.2s ease; + } + + .terminal-panel.collapsed { + height: 32px !important; + } + + .panel-header { + height: 32px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 8px; + background: ${cssManager.bdTheme('hsl(0 0% 95%)', 'hsl(0 0% 8%)')}; + border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 85%)', 'hsl(0 0% 15%)')}; + font-size: 12px; + font-weight: 500; + color: ${cssManager.bdTheme('hsl(0 0% 40%)', 'hsl(0 0% 60%)')}; + } + + .panel-header-title { + display: flex; + align-items: center; + gap: 6px; + } + + .panel-header-actions { + display: flex; + align-items: center; + gap: 4px; + } + + .panel-action { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + cursor: pointer; + color: ${cssManager.bdTheme('hsl(0 0% 50%)', 'hsl(0 0% 60%)')}; + transition: all 0.15s ease; + } + + .panel-action:hover { + background: ${cssManager.bdTheme('hsl(0 0% 88%)', 'hsl(0 0% 18%)')}; + color: ${cssManager.bdTheme('hsl(0 0% 30%)', 'hsl(0 0% 80%)')}; + } + + .tabs-bar { + display: flex; + align-items: stretch; + height: 36px; + background: ${cssManager.bdTheme('hsl(0 0% 96%)', 'hsl(0 0% 8%)')}; + border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 85%)', 'hsl(0 0% 15%)')}; + overflow-x: auto; + } + + .tab { + display: flex; + align-items: center; + gap: 6px; + padding: 0 12px; + min-width: 120px; + max-width: 200px; + border-right: 1px solid ${cssManager.bdTheme('hsl(0 0% 88%)', 'hsl(0 0% 12%)')}; + cursor: pointer; + font-size: 12px; + color: ${cssManager.bdTheme('hsl(0 0% 50%)', 'hsl(0 0% 60%)')}; + background: ${cssManager.bdTheme('hsl(0 0% 94%)', 'hsl(0 0% 10%)')}; + transition: all 0.15s ease; + } + + .tab:hover { + background: ${cssManager.bdTheme('hsl(0 0% 92%)', 'hsl(0 0% 12%)')}; + } + + .tab.active { + background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 9%)')}; + color: ${cssManager.bdTheme('hsl(0 0% 20%)', 'hsl(0 0% 90%)')}; + border-bottom: 2px solid ${cssManager.bdTheme('hsl(210 100% 50%)', 'hsl(210 100% 60%)')}; + } + + .tab-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .tab-close { + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + opacity: 0; + transition: all 0.15s ease; + } + + .tab:hover .tab-close { + opacity: 1; + } + + .tab-close:hover { + background: ${cssManager.bdTheme('hsl(0 0% 85%)', 'hsl(0 0% 25%)')}; + } + + .tab-modified { + width: 8px; + height: 8px; + border-radius: 50%; + background: ${cssManager.bdTheme('hsl(0 0% 50%)', 'hsl(0 0% 60%)')}; + } + + .editor-content { + flex: 1; + position: relative; + } + + .terminal-content { + position: absolute; + top: 32px; + left: 0; + right: 0; + bottom: 0; + } + + .empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + color: ${cssManager.bdTheme('hsl(0 0% 50%)', 'hsl(0 0% 50%)')}; + font-size: 14px; + gap: 8px; + } + + .empty-state dees-icon { + width: 48px; + height: 48px; + opacity: 0.5; + } + + .initializing { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + color: ${cssManager.bdTheme('hsl(0 0% 50%)', 'hsl(0 0% 60%)')}; + font-size: 14px; + gap: 12px; + } + + dees-editor-filetree { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + } + + dees-editor-monaco { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + } + + dees-terminal { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + } + `, + ]; + + public render(): TemplateResult { + const containerClasses = [ + 'workspace-container', + this.showFileTree && !this.isFileTreeCollapsed ? 'with-filetree' : '', + this.showTerminal ? 'with-terminal' : '', + ].filter(Boolean).join(' '); + + if (this.isInitializing) { + return html` +
+ + Initializing workspace... +
+ `; + } + + return html` +
+ ${this.showFileTree ? html` +
+ +
+ ` : ''} + +
+
+ ${this.openFiles.map(file => html` +
this.activateFile(file.path)} + > + ${file.modified ? html`` : ''} + ${file.name} + this.closeFile(e, file.path)}> + + +
+ `)} +
+
+ ${this.openFiles.length === 0 ? html` +
+ + Select a file to edit +
+ ` : html` + + `} +
+
+ + ${this.showTerminal ? html` +
+
+
+ + Terminal +
+
+
+ +
+
+
+
+ +
+
+ ` : ''} +
+ `; + } + + public async firstUpdated() { + if (this.executionEnvironment) { + await this.initializeWorkspace(); + } + } + + public async updated(changedProperties: Map) { + if (changedProperties.has('executionEnvironment') && this.executionEnvironment) { + await this.initializeWorkspace(); + } + } + + private async initializeWorkspace() { + if (!this.executionEnvironment) return; + + this.isInitializing = true; + + try { + if (!this.executionEnvironment.ready) { + await this.executionEnvironment.init(); + } + } catch (error) { + console.error('Failed to initialize workspace:', error); + } finally { + this.isInitializing = false; + } + } + + private async handleFileSelect(e: CustomEvent<{ path: string; name: string }>) { + const { path, name } = e.detail; + await this.openFile(path, name); + } + + private async openFile(path: string, name: string) { + // Check if already open + const existingFile = this.openFiles.find(f => f.path === path); + if (existingFile) { + this.activeFilePath = path; + return; + } + + // Load file content + if (!this.executionEnvironment) return; + + try { + const content = await this.executionEnvironment.readFile(path); + this.openFiles = [ + ...this.openFiles, + { path, name, content, modified: false }, + ]; + this.activeFilePath = path; + } catch (error) { + console.error(`Failed to open file ${path}:`, error); + } + } + + private activateFile(path: string) { + this.activeFilePath = path; + } + + private closeFile(e: Event, path: string) { + e.stopPropagation(); + + const fileIndex = this.openFiles.findIndex(f => f.path === path); + if (fileIndex === -1) return; + + // Check for unsaved changes + const file = this.openFiles[fileIndex]; + if (file.modified) { + const confirmed = confirm(`${file.name} has unsaved changes. Close anyway?`); + if (!confirmed) return; + } + + this.openFiles = this.openFiles.filter(f => f.path !== path); + + // If closing the active file, activate another one + if (this.activeFilePath === path) { + if (this.openFiles.length > 0) { + const newIndex = Math.min(fileIndex, this.openFiles.length - 1); + this.activeFilePath = this.openFiles[newIndex].path; + } else { + this.activeFilePath = ''; + } + } + } + + private getActiveFileContent(): string { + const file = this.openFiles.find(f => f.path === this.activeFilePath); + return file?.content || ''; + } + + private handleContentChange(e: CustomEvent) { + const newContent = e.detail; + const fileIndex = this.openFiles.findIndex(f => f.path === this.activeFilePath); + if (fileIndex === -1) return; + + const file = this.openFiles[fileIndex]; + if (file.content !== newContent) { + this.openFiles = [ + ...this.openFiles.slice(0, fileIndex), + { ...file, content: newContent, modified: true }, + ...this.openFiles.slice(fileIndex + 1), + ]; + } + } + + private getLanguageFromPath(path: string): string { + const ext = path.split('.').pop()?.toLowerCase(); + const languageMap: Record = { + ts: 'typescript', + tsx: 'typescript', + js: 'javascript', + jsx: 'javascript', + json: 'json', + html: 'html', + css: 'css', + scss: 'scss', + less: 'less', + md: 'markdown', + yaml: 'yaml', + yml: 'yaml', + xml: 'xml', + sql: 'sql', + py: 'python', + sh: 'shell', + bash: 'shell', + }; + return languageMap[ext || ''] || 'plaintext'; + } + + private toggleTerminal() { + this.isTerminalCollapsed = !this.isTerminalCollapsed; + } + + public async saveActiveFile(): Promise { + const file = this.openFiles.find(f => f.path === this.activeFilePath); + if (!file || !this.executionEnvironment) return; + + try { + await this.executionEnvironment.writeFile(file.path, file.content); + const fileIndex = this.openFiles.findIndex(f => f.path === this.activeFilePath); + this.openFiles = [ + ...this.openFiles.slice(0, fileIndex), + { ...file, modified: false }, + ...this.openFiles.slice(fileIndex + 1), + ]; + } catch (error) { + console.error('Failed to save file:', error); + } + } + + public async saveAllFiles(): Promise { + if (!this.executionEnvironment) return; + + for (const file of this.openFiles.filter(f => f.modified)) { + try { + await this.executionEnvironment.writeFile(file.path, file.content); + } catch (error) { + console.error(`Failed to save ${file.path}:`, error); + } + } + + this.openFiles = this.openFiles.map(f => ({ ...f, modified: false })); + } +} diff --git a/ts_web/elements/00group-editor/dees-editor-workspace/index.ts b/ts_web/elements/00group-editor/dees-editor-workspace/index.ts new file mode 100644 index 0000000..f2fca34 --- /dev/null +++ b/ts_web/elements/00group-editor/dees-editor-workspace/index.ts @@ -0,0 +1 @@ +export * from './dees-editor-workspace.js'; diff --git a/ts_web/elements/00group-editor/index.ts b/ts_web/elements/00group-editor/index.ts index 4fd543f..fafab95 100644 --- a/ts_web/elements/00group-editor/index.ts +++ b/ts_web/elements/00group-editor/index.ts @@ -1,4 +1,6 @@ // Editor Components -export * from './dees-editor-bare/index.js'; +export * from './dees-editor-monaco/index.js'; +export * from './dees-editor-filetree/index.js'; +export * from './dees-editor-workspace/index.js'; export * from './dees-editor-markdown/index.js'; export * from './dees-editor-markdownoutlet/index.js'; diff --git a/ts_web/elements/00group-input/dees-input-code/dees-input-code.ts b/ts_web/elements/00group-input/dees-input-code/dees-input-code.ts index 99e2864..e891381 100644 --- a/ts_web/elements/00group-input/dees-input-code/dees-input-code.ts +++ b/ts_web/elements/00group-input/dees-input-code/dees-input-code.ts @@ -12,8 +12,8 @@ import { themeDefaultStyles } from '../../00theme.js'; import { DeesModal } from '../../dees-modal/dees-modal.js'; import '../../dees-icon/dees-icon.js'; import '../../dees-label/dees-label.js'; -import '../../00group-editor/dees-editor-bare/dees-editor-bare.js'; -import { DeesEditorBare } from '../../00group-editor/dees-editor-bare/dees-editor-bare.js'; +import '../../00group-editor/dees-editor-monaco/dees-editor-monaco.js'; +import { DeesEditorMonaco } from '../../00group-editor/dees-editor-monaco/dees-editor-monaco.js'; declare global { interface HTMLElementTagNameMap { @@ -77,7 +77,7 @@ export class DeesInputCode extends DeesInputBase { @state() accessor copySuccess: boolean = false; - private editorElement: DeesEditorBare | null = null; + private editorElement: DeesEditorMonaco | null = null; public static styles = [ themeDefaultStyles, @@ -207,7 +207,7 @@ export class DeesInputCode extends DeesInputBase { position: relative; } - dees-editor-bare { + dees-editor-monaco { display: block; } @@ -295,12 +295,12 @@ export class DeesInputCode extends DeesInputBase {
- + >
@@ -308,7 +308,7 @@ export class DeesInputCode extends DeesInputBase { } async firstUpdated() { - this.editorElement = this.shadowRoot?.querySelector('dees-editor-bare') as DeesEditorBare; + this.editorElement = this.shadowRoot?.querySelector('dees-editor-monaco') as DeesEditorMonaco; if (this.editorElement) { // Subscribe to content changes from the editor this.editorElement.contentSubject.subscribe((newContent: string) => { @@ -386,7 +386,7 @@ export class DeesInputCode extends DeesInputBase { public async openFullscreen() { const currentValue = this.value; - let modalEditorElement: DeesEditorBare | null = null; + let modalEditorElement: DeesEditorMonaco | null = null; // Modal-specific state let modalLanguage = this.language; @@ -579,11 +579,11 @@ export class DeesInputCode extends DeesInputBase { `, menuOptions: [ @@ -597,7 +597,7 @@ export class DeesInputCode extends DeesInputBase { name: 'Save & Close', action: async (modalRef) => { // Get the editor content from the modal - modalEditorElement = modalRef.shadowRoot?.querySelector('dees-editor-bare') as DeesEditorBare; + modalEditorElement = modalRef.shadowRoot?.querySelector('dees-editor-monaco') as DeesEditorMonaco; if (modalEditorElement) { const editor = await modalEditorElement.editorDeferred.promise; const newValue = editor.getValue(); @@ -611,7 +611,7 @@ export class DeesInputCode extends DeesInputBase { // Wait for modal to render await new Promise(resolve => setTimeout(resolve, 100)); - modalEditorElement = modal.shadowRoot?.querySelector('dees-editor-bare') as DeesEditorBare; + modalEditorElement = modal.shadowRoot?.querySelector('dees-editor-monaco') as DeesEditorMonaco; // Wire up toolbar event handlers const toolbar = modal.shadowRoot?.querySelector('.modal-toolbar'); diff --git a/ts_web/elements/00group-runtime/environments/WebContainerEnvironment.ts b/ts_web/elements/00group-runtime/environments/WebContainerEnvironment.ts new file mode 100644 index 0000000..f348321 --- /dev/null +++ b/ts_web/elements/00group-runtime/environments/WebContainerEnvironment.ts @@ -0,0 +1,138 @@ +import * as webcontainer from '@webcontainer/api'; +import type { IExecutionEnvironment, IFileEntry, IProcessHandle } from '../interfaces/IExecutionEnvironment.js'; + +/** + * WebContainer-based execution environment. + * Runs Node.js and shell commands in the browser using WebContainer API. + */ +export class WebContainerEnvironment implements IExecutionEnvironment { + private container: webcontainer.WebContainer | null = null; + private _ready: boolean = false; + + public readonly type = 'webcontainer' as const; + + public get ready(): boolean { + return this._ready; + } + + // ============ Lifecycle ============ + + public async init(): Promise { + if (this._ready && this.container) { + return; // Already initialized + } + + // Check if SharedArrayBuffer is available (required for WebContainer) + if (typeof SharedArrayBuffer === 'undefined') { + throw new Error( + 'WebContainer requires SharedArrayBuffer which is not available. ' + + 'Ensure your server sends these headers:\n' + + ' Cross-Origin-Opener-Policy: same-origin\n' + + ' Cross-Origin-Embedder-Policy: require-corp' + ); + } + + this.container = await webcontainer.WebContainer.boot(); + this._ready = true; + } + + public async destroy(): Promise { + if (this.container) { + this.container.teardown(); + this.container = null; + this._ready = false; + } + } + + // ============ Filesystem Operations ============ + + public async readFile(path: string): Promise { + this.ensureReady(); + return await this.container!.fs.readFile(path, 'utf-8'); + } + + public async writeFile(path: string, contents: string): Promise { + this.ensureReady(); + await this.container!.fs.writeFile(path, contents, 'utf-8'); + } + + public async readDir(path: string): Promise { + this.ensureReady(); + const entries = await this.container!.fs.readdir(path, { withFileTypes: true }); + + return entries.map((entry) => ({ + type: entry.isDirectory() ? 'directory' as const : 'file' as const, + name: entry.name, + path: path === '/' ? `/${entry.name}` : `${path}/${entry.name}`, + })); + } + + public async mkdir(path: string): Promise { + this.ensureReady(); + await this.container!.fs.mkdir(path, { recursive: true }); + } + + public async rm(path: string, options?: { recursive?: boolean }): Promise { + this.ensureReady(); + await this.container!.fs.rm(path, { recursive: options?.recursive ?? false }); + } + + public async exists(path: string): Promise { + this.ensureReady(); + try { + await this.container!.fs.readFile(path); + return true; + } catch { + try { + await this.container!.fs.readdir(path); + return true; + } catch { + return false; + } + } + } + + // ============ Process Execution ============ + + public async spawn(command: string, args: string[] = []): Promise { + this.ensureReady(); + + const process = await this.container!.spawn(command, args); + + return { + output: process.output as unknown as ReadableStream, + input: process.input as unknown as { getWriter(): WritableStreamDefaultWriter }, + exit: process.exit, + kill: () => process.kill(), + }; + } + + // ============ WebContainer-specific methods ============ + + /** + * Mount files into the virtual filesystem. + * This is a WebContainer-specific operation. + * @param files - File tree structure to mount + */ + public async mount(files: webcontainer.FileSystemTree): Promise { + this.ensureReady(); + await this.container!.mount(files); + } + + /** + * Get the underlying WebContainer instance. + * Use sparingly - prefer the interface methods. + */ + public getContainer(): webcontainer.WebContainer { + this.ensureReady(); + return this.container!; + } + + // ============ Private Helpers ============ + + private ensureReady(): void { + if (!this._ready || !this.container) { + throw new Error('WebContainerEnvironment not initialized. Call init() first.'); + } + } +} diff --git a/ts_web/elements/00group-runtime/environments/index.ts b/ts_web/elements/00group-runtime/environments/index.ts new file mode 100644 index 0000000..584d93f --- /dev/null +++ b/ts_web/elements/00group-runtime/environments/index.ts @@ -0,0 +1 @@ +export * from './WebContainerEnvironment.js'; diff --git a/ts_web/elements/00group-runtime/index.ts b/ts_web/elements/00group-runtime/index.ts new file mode 100644 index 0000000..8de22eb --- /dev/null +++ b/ts_web/elements/00group-runtime/index.ts @@ -0,0 +1,5 @@ +// Runtime Interfaces +export * from './interfaces/index.js'; + +// Environment Implementations +export * from './environments/index.js'; diff --git a/ts_web/elements/00group-runtime/interfaces/IExecutionEnvironment.ts b/ts_web/elements/00group-runtime/interfaces/IExecutionEnvironment.ts new file mode 100644 index 0000000..7088b0e --- /dev/null +++ b/ts_web/elements/00group-runtime/interfaces/IExecutionEnvironment.ts @@ -0,0 +1,101 @@ +/** + * Represents a file or directory entry in the virtual filesystem + */ +export interface IFileEntry { + type: 'file' | 'directory'; + name: string; + path: string; +} + +/** + * Handle to a spawned process with I/O streams + */ +export interface IProcessHandle { + /** Stream of output data from the process */ + output: ReadableStream; + /** Input stream to write data to the process */ + input: { getWriter(): WritableStreamDefaultWriter }; + /** Promise that resolves with exit code when process terminates */ + exit: Promise; + /** Kill the process */ + kill(): void; +} + +/** + * Abstract execution environment interface. + * Implementations can target WebContainer (browser), Backend API (server), or Mock (testing). + */ +export interface IExecutionEnvironment { + // ============ Filesystem Operations ============ + + /** + * Read the contents of a file + * @param path - Absolute path to the file + * @returns File contents as string + */ + readFile(path: string): Promise; + + /** + * Write contents to a file (creates or overwrites) + * @param path - Absolute path to the file + * @param contents - String contents to write + */ + writeFile(path: string, contents: string): Promise; + + /** + * List contents of a directory + * @param path - Absolute path to the directory + * @returns Array of file entries + */ + readDir(path: string): Promise; + + /** + * Create a directory (and parent directories if needed) + * @param path - Absolute path to create + */ + mkdir(path: string): Promise; + + /** + * Remove a file or directory + * @param path - Absolute path to remove + * @param options - Optional: { recursive: true } for directories + */ + rm(path: string, options?: { recursive?: boolean }): Promise; + + /** + * Check if a path exists + * @param path - Absolute path to check + */ + exists(path: string): Promise; + + // ============ Process Execution ============ + + /** + * Spawn a new process + * @param command - Command to run (e.g., 'jsh', 'node', 'npm') + * @param args - Optional arguments + * @returns Process handle with I/O streams + */ + spawn(command: string, args?: string[]): Promise; + + // ============ Lifecycle ============ + + /** + * Initialize the environment (e.g., boot WebContainer) + * Must be called before any other operations + */ + init(): Promise; + + /** + * Destroy the environment and clean up resources + */ + destroy(): Promise; + + // ============ State ============ + + /** Whether the environment has been initialized and is ready */ + readonly ready: boolean; + + /** Type identifier for the environment implementation */ + readonly type: 'webcontainer' | 'backend' | 'mock'; +} diff --git a/ts_web/elements/00group-runtime/interfaces/index.ts b/ts_web/elements/00group-runtime/interfaces/index.ts new file mode 100644 index 0000000..36e828c --- /dev/null +++ b/ts_web/elements/00group-runtime/interfaces/index.ts @@ -0,0 +1 @@ +export * from './IExecutionEnvironment.js'; diff --git a/ts_web/elements/dees-terminal/dees-terminal.ts b/ts_web/elements/dees-terminal/dees-terminal.ts index 27b4e13..c9a4c87 100644 --- a/ts_web/elements/dees-terminal/dees-terminal.ts +++ b/ts_web/elements/dees-terminal/dees-terminal.ts @@ -9,11 +9,11 @@ import { } from '@design.estate/dees-element'; import * as domtools from '@design.estate/dees-domtools'; -import * as webcontainer from '@webcontainer/api'; - 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 { @@ -23,28 +23,39 @@ declare global { @customElement('dees-terminal') export class DeesTerminal extends DeesElement { - public static demo = () => html` `; + 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 environment: {[key: string]: string} = {}; + accessor environmentVariables: { [key: string]: string } = {}; @property() accessor background: string = '#000000'; - // exposing webcontainer - private webcontainerDeferred = new domtools.plugins.smartpromise.Deferred(); - public webcontainerPromise = this.webcontainerDeferred.promise; + /** + * 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(); @@ -262,6 +273,8 @@ export class DeesTerminal extends DeesElement { } private fitAddon: FitAddon; + private terminal: Terminal | null = null; + public async firstUpdated( _changedProperties: Map ): Promise { @@ -280,6 +293,7 @@ export class DeesTerminal extends DeesElement { background: this.background, }, }); + this.terminal = term; this.fitAddon = new FitAddon(); term.loadAddon(this.fitAddon); @@ -289,12 +303,48 @@ export class DeesTerminal extends DeesElement { // Make the terminal's size and geometry fit the size of #terminal-container this.fitAddon.fit(); - term.write(`dees-terminal custom terminal. \r\n$ `); + // 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; + } - // lets start the webcontainer - // Call only once - const webcontainerInstance = await webcontainer.WebContainer.boot(); - const shellProcess = await webcontainerInstance.spawn('jsh'); + 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) { @@ -306,16 +356,24 @@ export class DeesTerminal extends DeesElement { term.onData((data) => { input.write(data); }); + await this.waitForPrompt(term, '~/'); - // lets set the environment variables - await this.setEnvironmentVariables(this.environment, webcontainerInstance); - input.write(`source source.env\n`); - await this.waitForPrompt(term, '~/'); - // lets run the setup command - input.write(this.setupCommand); - await this.waitForPrompt(term, '~/'); - input.write(`clear && echo 'welcome'\n`); - this.webcontainerDeferred.resolve(webcontainerInstance); + + // 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 { @@ -352,17 +410,25 @@ export class DeesTerminal extends DeesElement { }); } - public async setEnvironmentVariables(envArg: {[key: string]: string}, webcontainerInstanceArg?: webcontainer.WebContainer) { - const webcontainerInstance = webcontainerInstanceArg ||await this.webcontainerPromise; - let envFile = `` - for (const key in envArg) { + 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`; } - await webcontainerInstance.mount({'source.env': { - file: { - contents: envFile, - } - }}); + // 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; } } diff --git a/ts_web/elements/index.ts b/ts_web/elements/index.ts index 3bbcc2f..2b44b36 100644 --- a/ts_web/elements/index.ts +++ b/ts_web/elements/index.ts @@ -10,6 +10,7 @@ export * from './00group-editor/index.js'; export * from './00group-form/index.js'; export * from './00group-input/index.js'; export * from './00group-pdf/index.js'; +export * from './00group-runtime/index.js'; export * from './00group-simple/index.js'; // Standalone Components