diff --git a/changelog.md b/changelog.md index 78a78a4..ca1c554 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,14 @@ # Changelog +## 2026-01-01 - 3.24.0 - feat(workspace) +add workspace bottom bar, terminal tab manager, and run-process integration + +- Add dees-workspace-bottombar component and export; bottom bar emits run-process events to launch processes. +- Introduce terminal interfaces (IRunProcessEventDetail, ITerminalTab, ICreateTerminalTabOptions, etc.) and a TerminalTabManager to manage multiple terminal tabs and lifecycle. +- Integrate bottombar into dees-workspace: layout refactor (workspace-outer), move/adjust resize handles and panels, and handle @run-process to create terminal tabs and focus terminal panel. +- Enhance dees-workspace-terminal: tabbed terminal UI, new public APIs (createProcessTab, writeToTab, sendInputToTab), theme updates, and improved disposal/cleanup behavior. +- Update module exports to include bottombar and additional terminal sub-exports (interfaces, terminal-tab-manager). + ## 2025-12-31 - 3.23.0 - feat(workspace) add resizable file tree and terminal panes with draggable handles and public layout APIs diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 3ca497a..e587f92 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.23.0', + version: '3.24.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-workspace/dees-workspace-bottombar/dees-workspace-bottombar.ts b/ts_web/elements/00group-workspace/dees-workspace-bottombar/dees-workspace-bottombar.ts new file mode 100644 index 0000000..05c4025 --- /dev/null +++ b/ts_web/elements/00group-workspace/dees-workspace-bottombar/dees-workspace-bottombar.ts @@ -0,0 +1,491 @@ +import { + DeesElement, + property, + html, + customElement, + type TemplateResult, + css, + cssManager, + state, +} from '@design.estate/dees-element'; +import { themeDefaultStyles } from '../../00theme.js'; +import type { IExecutionEnvironment } from '../../00group-runtime/index.js'; +import '../../dees-icon/dees-icon.js'; +import { DeesContextmenu } from '../../dees-contextmenu/dees-contextmenu.js'; +import type { IRunProcessEventDetail } from '../dees-workspace-terminal/interfaces.js'; + +declare global { + interface HTMLElementTagNameMap { + 'dees-workspace-bottombar': DeesWorkspaceBottombar; + } +} + +interface IOutdatedPackage { + name: string; + current: string; + wanted: string; + latest: string; + type: 'dependencies' | 'devDependencies'; +} + +@customElement('dees-workspace-bottombar') +export class DeesWorkspaceBottombar extends DeesElement { + // INSTANCE + @property({ type: Object }) + accessor executionEnvironment: IExecutionEnvironment | null = null; + + // Script runner state + @state() + accessor scripts: Record = {}; + + // Package checker state + @state() + accessor packageStatus: 'checking' | 'up-to-date' | 'updates-available' | 'error' | 'idle' = 'idle'; + + @state() + accessor outdatedPackages: IOutdatedPackage[] = []; + + @state() + accessor isCheckingPackages: boolean = false; + + public static styles = [ + themeDefaultStyles, + cssManager.defaultStyles, + css` + :host { + display: block; + height: 24px; + flex-shrink: 0; + } + + .bottom-bar { + height: 24px; + display: flex; + align-items: center; + padding: 0 8px; + gap: 4px; + background: ${cssManager.bdTheme('hsl(0 0% 94%)', 'hsl(0 0% 6%)')}; + border-top: 1px solid ${cssManager.bdTheme('hsl(0 0% 85%)', 'hsl(0 0% 15%)')}; + font-size: 11px; + color: ${cssManager.bdTheme('hsl(0 0% 40%)', 'hsl(0 0% 60%)')}; + } + + .widget { + display: flex; + align-items: center; + gap: 4px; + padding: 2px 6px; + border-radius: 3px; + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; + white-space: nowrap; + } + + .widget:hover { + background: ${cssManager.bdTheme('hsl(0 0% 88%)', 'hsl(0 0% 12%)')}; + color: ${cssManager.bdTheme('hsl(0 0% 20%)', 'hsl(0 0% 80%)')}; + } + + .widget dees-icon { + flex-shrink: 0; + } + + .widget-separator { + width: 1px; + height: 14px; + background: ${cssManager.bdTheme('hsl(0 0% 80%)', 'hsl(0 0% 20%)')}; + margin: 0 4px; + } + + .widget.running { + color: ${cssManager.bdTheme('hsl(210 100% 45%)', 'hsl(210 100% 60%)')}; + } + + .widget.up-to-date { + color: ${cssManager.bdTheme('hsl(142 70% 35%)', 'hsl(142 70% 50%)')}; + } + + .widget.updates-available { + color: ${cssManager.bdTheme('hsl(38 92% 45%)', 'hsl(38 92% 55%)')}; + } + + .widget.error { + color: ${cssManager.bdTheme('hsl(0 70% 50%)', 'hsl(0 70% 60%)')}; + } + + @keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } + } + + .spinning { + animation: spin 1s linear infinite; + } + + .spacer { + flex: 1; + } + `, + ]; + + public render(): TemplateResult { + return html` +
+ +
+ + Scripts +
+ +
+ + +
+ + ${this.getPackageStatusText()} +
+ +
+ + +
+ `; + } + + async firstUpdated() { + await this.loadScripts(); + await this.checkPackages(); + } + + async updated(changedProperties: Map) { + if (changedProperties.has('executionEnvironment') && this.executionEnvironment) { + await this.loadScripts(); + await this.checkPackages(); + } + } + + // ========== Script Runner ========== + + private async loadScripts(): Promise { + if (!this.executionEnvironment) return; + + try { + const packageJsonExists = await this.executionEnvironment.exists('/package.json'); + if (!packageJsonExists) { + this.scripts = {}; + return; + } + + const content = await this.executionEnvironment.readFile('/package.json'); + const packageJson = JSON.parse(content); + this.scripts = packageJson.scripts || {}; + } catch (error) { + console.warn('Failed to load scripts from package.json:', error); + this.scripts = {}; + } + } + + private async handleScriptClick(e: MouseEvent): Promise { + e.stopPropagation(); + + const scriptNames = Object.keys(this.scripts); + if (scriptNames.length === 0) { + return; + } + + const menuItems = scriptNames.map(name => ({ + name: name, + iconName: 'lucide:terminal' as const, + action: async () => { + await this.runScript(name); + }, + })); + + await DeesContextmenu.openContextMenuWithOptions(e, menuItems); + } + + private async runScript(scriptName: string): Promise { + if (!this.executionEnvironment) return; + + // Emit run-process event for the workspace to create a terminal tab + const detail: IRunProcessEventDetail = { + type: 'script', + label: scriptName, + command: 'pnpm', + args: ['run', scriptName], + metadata: { scriptName }, + }; + + this.dispatchEvent(new CustomEvent('run-process', { + bubbles: true, + composed: true, + detail, + })); + } + + // ========== Package Checker ========== + + private async checkPackages(): Promise { + if (!this.executionEnvironment) { + this.packageStatus = 'idle'; + return; + } + + try { + const packageJsonExists = await this.executionEnvironment.exists('/package.json'); + if (!packageJsonExists) { + this.packageStatus = 'idle'; + return; + } + + this.packageStatus = 'checking'; + this.isCheckingPackages = true; + + // Run pnpm outdated --json + const process = await this.executionEnvironment.spawn('pnpm', ['outdated', '--json']); + + let output = ''; + await process.output.pipeTo( + new WritableStream({ + write: (chunk) => { + output += chunk; + }, + }) + ); + + const exitCode = await process.exit; + + // pnpm outdated returns exit code 1 if there are outdated packages + if (exitCode === 0) { + // No outdated packages + this.packageStatus = 'up-to-date'; + this.outdatedPackages = []; + } else { + // Parse outdated packages + try { + const outdatedData = JSON.parse(output); + this.outdatedPackages = this.parseOutdatedPackages(outdatedData); + this.packageStatus = this.outdatedPackages.length > 0 ? 'updates-available' : 'up-to-date'; + } catch { + // If parsing fails but exit code is 1, assume there are updates + this.packageStatus = 'updates-available'; + this.outdatedPackages = []; + } + } + } catch (error) { + console.warn('Failed to check for package updates:', error); + this.packageStatus = 'error'; + } finally { + this.isCheckingPackages = false; + } + } + + private parseOutdatedPackages(data: any): IOutdatedPackage[] { + const packages: IOutdatedPackage[] = []; + + // pnpm outdated --json returns an object with package names as keys + if (typeof data === 'object' && data !== null) { + for (const [name, info] of Object.entries(data)) { + const pkgInfo = info as any; + packages.push({ + name, + current: pkgInfo.current || 'unknown', + wanted: pkgInfo.wanted || pkgInfo.current || 'unknown', + latest: pkgInfo.latest || pkgInfo.wanted || 'unknown', + type: pkgInfo.dependencyType === 'devDependencies' ? 'devDependencies' : 'dependencies', + }); + } + } + + return packages; + } + + private async handlePackageClick(e: MouseEvent): Promise { + e.stopPropagation(); + + if (this.isCheckingPackages) return; + + const menuItems: Parameters[1] = []; + + // Refresh option - show output in terminal + menuItems.push({ + name: 'Check for updates', + iconName: 'lucide:refreshCw', + action: async () => { + // Create terminal tab to show pnpm outdated output + const detail: IRunProcessEventDetail = { + type: 'package-update', + label: 'check packages', + command: 'pnpm', + args: ['outdated'], + }; + + this.dispatchEvent(new CustomEvent('run-process', { + bubbles: true, + composed: true, + detail, + })); + + // Also refresh the widget status silently after a delay + setTimeout(() => this.checkPackages(), 3000); + }, + }); + + if (this.outdatedPackages.length > 0) { + menuItems.push({ divider: true }); + + // Show outdated packages (max 10) + const displayPackages = this.outdatedPackages.slice(0, 10); + for (const pkg of displayPackages) { + menuItems.push({ + name: `${pkg.name}: ${pkg.current} → ${pkg.latest}`, + iconName: 'lucide:package', + action: async () => { + // Update single package + await this.updatePackage(pkg.name); + }, + }); + } + + if (this.outdatedPackages.length > 10) { + menuItems.push({ + name: `... and ${this.outdatedPackages.length - 10} more`, + iconName: 'lucide:moreHorizontal', + action: async () => {}, + }); + } + + menuItems.push({ divider: true }); + + menuItems.push({ + name: 'Update all packages', + iconName: 'lucide:arrowUpCircle', + action: async () => { + await this.updateAllPackages(); + }, + }); + } + + await DeesContextmenu.openContextMenuWithOptions(e, menuItems); + } + + private async updatePackage(packageName: string): Promise { + if (!this.executionEnvironment) return; + + // Emit run-process event for the workspace to create a terminal tab + const detail: IRunProcessEventDetail = { + type: 'package-update', + label: `update ${packageName}`, + command: 'pnpm', + args: ['update', packageName], + metadata: { packageName }, + }; + + this.dispatchEvent(new CustomEvent('run-process', { + bubbles: true, + composed: true, + detail, + })); + } + + private async updateAllPackages(): Promise { + if (!this.executionEnvironment) return; + + // Emit run-process event for the workspace to create a terminal tab + const detail: IRunProcessEventDetail = { + type: 'package-update', + label: 'update all', + command: 'pnpm', + args: ['update'], + }; + + this.dispatchEvent(new CustomEvent('run-process', { + bubbles: true, + composed: true, + detail, + })); + } + + // ========== Helper Methods ========== + + private getPackageStatusClass(): string { + if (this.isCheckingPackages) return ''; + return this.packageStatus; + } + + private getPackageIcon(): string { + if (this.isCheckingPackages) return 'lucide:loader2'; + + switch (this.packageStatus) { + case 'up-to-date': + return 'lucide:checkCircle'; + case 'updates-available': + return 'lucide:alertCircle'; + case 'error': + return 'lucide:xCircle'; + default: + return 'lucide:package'; + } + } + + private getPackageStatusText(): string { + if (this.isCheckingPackages) return 'Checking...'; + + switch (this.packageStatus) { + case 'up-to-date': + return 'Up to date'; + case 'updates-available': + return `${this.outdatedPackages.length} update${this.outdatedPackages.length !== 1 ? 's' : ''}`; + case 'error': + return 'Check failed'; + default: + return 'Packages'; + } + } + + private getPackageTooltip(): string { + if (this.isCheckingPackages) return 'Checking for package updates...'; + + switch (this.packageStatus) { + case 'up-to-date': + return 'All packages are up to date'; + case 'updates-available': + return `${this.outdatedPackages.length} package update${this.outdatedPackages.length !== 1 ? 's' : ''} available`; + case 'error': + return 'Failed to check for updates. Click to retry.'; + default: + return 'Click to check for package updates'; + } + } + + // ========== Public Methods ========== + + /** + * Manually trigger a package check + */ + public async refreshPackageStatus(): Promise { + await this.checkPackages(); + } + + /** + * Manually reload scripts from package.json + */ + public async refreshScripts(): Promise { + await this.loadScripts(); + } +} diff --git a/ts_web/elements/00group-workspace/dees-workspace-bottombar/index.ts b/ts_web/elements/00group-workspace/dees-workspace-bottombar/index.ts new file mode 100644 index 0000000..0f4fa66 --- /dev/null +++ b/ts_web/elements/00group-workspace/dees-workspace-bottombar/index.ts @@ -0,0 +1 @@ +export * from './dees-workspace-bottombar.js'; diff --git a/ts_web/elements/00group-workspace/dees-workspace-terminal/dees-workspace-terminal.ts b/ts_web/elements/00group-workspace/dees-workspace-terminal/dees-workspace-terminal.ts index 89a07d1..caf1d46 100644 --- a/ts_web/elements/00group-workspace/dees-workspace-terminal/dees-workspace-terminal.ts +++ b/ts_web/elements/00group-workspace/dees-workspace-terminal/dees-workspace-terminal.ts @@ -6,6 +6,7 @@ import { type TemplateResult, css, cssManager, + state, } from '@design.estate/dees-element'; import * as domtools from '@design.estate/dees-domtools'; @@ -14,6 +15,13 @@ 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'; +import '../../dees-icon/dees-icon.js'; +import { TerminalTabManager } from './terminal-tab-manager.js'; +import type { + ITerminalTab, + ICreateTerminalTabOptions, + TTerminalTabType, +} from './interfaces.js'; declare global { interface HTMLElementTagNameMap { @@ -30,6 +38,7 @@ export class DeesWorkspaceTerminal extends DeesElement { // INSTANCE private resizeObserver: ResizeObserver; + private tabManager: TerminalTabManager; /** * The execution environment (required). @@ -39,7 +48,7 @@ export class DeesWorkspaceTerminal extends DeesElement { accessor executionEnvironment: IExecutionEnvironment | null = null; @property() - accessor setupCommand = `pnpm install @serve.zone/cli && servezone cli\n`; + accessor setupCommand = ''; /** * Environment variables to set in the shell @@ -47,6 +56,29 @@ export class DeesWorkspaceTerminal extends DeesElement { @property() accessor environmentVariables: { [key: string]: string } = {}; + /** + * Width of the tab bar in pixels + */ + @property({ type: Number }) + accessor tabBarWidth: number = 160; + + /** + * Whether to show the tab bar + */ + @property({ type: Boolean }) + accessor showTabBar: boolean = true; + + // Tab state + @state() + accessor tabs: ITerminalTab[] = []; + + @state() + accessor activeTabId: string | null = null; + + // Theme subscription for dynamic theme updates + private terminalThemeSubscription: any = null; + private isBright: boolean = false; + /** * Promise that resolves when the environment is ready. * @deprecated Use executionEnvironment directly @@ -54,17 +86,11 @@ export class DeesWorkspaceTerminal extends DeesElement { private environmentDeferred = new domtools.plugins.smartpromise.Deferred(); public environmentPromise = this.environmentDeferred.promise; - // Theme subscription for dynamic theme updates - private terminalThemeSubscription: any = null; - constructor() { super(); - this.resizeObserver = new ResizeObserver((entries) => { - for (const entry of entries) { - // Handle the resize event - console.log(`Terminal Resized`); - this.handleResize(); - } + this.tabManager = new TerminalTabManager(); + this.resizeObserver = new ResizeObserver(() => { + this.handleResize(); }); } @@ -73,59 +99,191 @@ export class DeesWorkspaceTerminal extends DeesElement { cssManager.defaultStyles, css` :host { - padding: 20px; background: ${cssManager.bdTheme('#ffffff', '#000000')}; position: absolute; height: 100%; width: 100%; + display: flex; + flex-direction: row; } * { box-sizing: border-box; } - #container { - position: absolute; - height: calc(100% - 40px); - width: calc(100% - 40px); + .terminal-container { + display: flex; + flex-direction: row; + width: 100%; + height: 100%; } - /** - * 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 - */ + .terminal-content { + flex: 1; + position: relative; + overflow: hidden; + background: ${cssManager.bdTheme('#ffffff', '#000000')}; + } + #active-terminal-container { + position: absolute; + top: 20px; + left: 20px; + right: 20px; + bottom: 20px; + } + + /* Tab bar on the right side */ + .tab-bar { + display: flex; + flex-direction: column; + background: ${cssManager.bdTheme('hsl(0 0% 96%)', 'hsl(0 0% 7%)')}; + border-left: 1px solid ${cssManager.bdTheme('hsl(0 0% 85%)', 'hsl(0 0% 15%)')}; + flex-shrink: 0; + overflow: hidden; + } + + .tab-bar-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 10px; + border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 88%)', 'hsl(0 0% 12%)')}; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: ${cssManager.bdTheme('hsl(0 0% 45%)', 'hsl(0 0% 55%)')}; + } + + .tab-bar-title { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .tab-bar-actions { + display: flex; + gap: 2px; + } + + .tab-action { + width: 22px; + height: 22px; + 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; + } + + .tab-action:hover { + background: ${cssManager.bdTheme('hsl(0 0% 88%)', 'hsl(0 0% 15%)')}; + color: ${cssManager.bdTheme('hsl(0 0% 30%)', 'hsl(0 0% 80%)')}; + } + + .tab-list { + flex: 1; + overflow-y: auto; + padding: 4px; + } + + .terminal-tab { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + margin-bottom: 2px; + border-radius: 6px; + cursor: pointer; + font-size: 12px; + color: ${cssManager.bdTheme('hsl(0 0% 45%)', 'hsl(0 0% 60%)')}; + transition: all 0.15s ease; + } + + .terminal-tab:hover { + background: ${cssManager.bdTheme('hsl(0 0% 90%)', 'hsl(0 0% 12%)')}; + color: ${cssManager.bdTheme('hsl(0 0% 25%)', 'hsl(0 0% 80%)')}; + } + + .terminal-tab.active { + background: ${cssManager.bdTheme('hsl(210 100% 95%)', 'hsl(210 30% 15%)')}; + color: ${cssManager.bdTheme('hsl(210 100% 40%)', 'hsl(210 100% 70%)')}; + } + + .terminal-tab.exited { + opacity: 0.7; + } + + .tab-icon { + flex-shrink: 0; + } + + .tab-icon.running { + color: ${cssManager.bdTheme('hsl(142 70% 40%)', 'hsl(142 70% 55%)')}; + } + + .tab-label { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .exit-badge { + font-size: 10px; + padding: 1px 5px; + border-radius: 8px; + font-weight: 600; + flex-shrink: 0; + } + + .exit-badge.success { + background: ${cssManager.bdTheme('hsl(142 70% 90%)', 'hsl(142 30% 20%)')}; + color: ${cssManager.bdTheme('hsl(142 70% 35%)', 'hsl(142 70% 60%)')}; + } + + .exit-badge.error { + background: ${cssManager.bdTheme('hsl(0 70% 93%)', 'hsl(0 30% 20%)')}; + color: ${cssManager.bdTheme('hsl(0 70% 45%)', 'hsl(0 70% 60%)')}; + } + + .tab-close { + width: 18px; + height: 18px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + opacity: 0; + transition: all 0.15s ease; + flex-shrink: 0; + } + + .terminal-tab:hover .tab-close { + opacity: 0.6; + } + + .tab-close:hover { + opacity: 1 !important; + background: ${cssManager.bdTheme('hsl(0 0% 85%)', 'hsl(0 0% 25%)')}; + color: ${cssManager.bdTheme('hsl(0 70% 50%)', 'hsl(0 70% 60%)')}; + } + + .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: 13px; + gap: 8px; + } + + /* xterm.js styles */ .xterm { font-feature-settings: 'liga' 0; position: relative; @@ -142,10 +300,6 @@ export class DeesWorkspaceTerminal extends DeesElement { .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; } @@ -153,7 +307,6 @@ export class DeesWorkspaceTerminal extends DeesElement { 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; @@ -161,14 +314,12 @@ export class DeesWorkspaceTerminal extends DeesElement { 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: ${cssManager.bdTheme('#ffffff', '#000000')}; color: ${cssManager.bdTheme('#333333', '#ffffff')}; display: none; @@ -182,7 +333,6 @@ export class DeesWorkspaceTerminal extends DeesElement { } .xterm .xterm-viewport { - /* On OS X this is required in order for the scroll bar to appear fully opaque */ background-color: ${cssManager.bdTheme('#ffffff', '#000000')}; overflow-y: scroll; cursor: default; @@ -221,7 +371,6 @@ export class DeesWorkspaceTerminal extends DeesElement { } .xterm.enable-mouse-events { - /* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */ cursor: default; } @@ -230,7 +379,6 @@ export class DeesWorkspaceTerminal extends DeesElement { } .xterm.column-select.focus { - /* Column selection mode */ cursor: crosshair; } @@ -264,141 +412,81 @@ export class DeesWorkspaceTerminal extends DeesElement { ]; public render(): TemplateResult { + const activeTab = this.activeTabId ? this.tabManager.getTab(this.activeTabId) : null; + return html` -
-
+
+ +
+ ${activeTab ? html` +
+ ` : html` +
+ + No terminal open +
+ `} +
+ + + ${this.showTabBar ? html` +
+
+ Terminals +
+
+ +
+
+
+ +
+ ${this.tabs.map(tab => html` +
this.switchToTab(tab.id)} + > + + ${tab.label} + ${tab.exited ? html` + + ${tab.exitCode} + + ` : ''} + ${tab.closeable ? html` + this.handleTabClose(e, tab.id)}> + + + ` : ''} +
+ `)} +
+
+ ` : ''}
`; } - private fitAddon: FitAddon; - private terminal: Terminal | null = null; - - /** - * Get terminal theme colors based on bright/dark mode - */ - private getTerminalTheme(isBright: boolean) { - return isBright - ? { - background: '#ffffff', - foreground: '#333333', - cursor: '#333333', - cursorAccent: '#ffffff', - selectionBackground: 'rgba(0, 0, 0, 0.2)', - } - : { - background: '#000000', - foreground: '#cccccc', - cursor: '#cccccc', - cursorAccent: '#000000', - selectionBackground: 'rgba(255, 255, 255, 0.2)', - }; - } - - public async firstUpdated( - _changedProperties: Map - ): Promise { + public async firstUpdated(): Promise { const domtoolsInstance = await this.domtoolsPromise; - super.firstUpdated(_changedProperties); // Get current theme - const isBright = domtoolsInstance.themeManager.goBrightBoolean; - - const container = this.shadowRoot.getElementById('container'); - - const term = new Terminal({ - convertEol: true, - cursorBlink: true, - theme: this.getTerminalTheme(isBright), - }); - this.terminal = term; + this.isBright = domtoolsInstance.themeManager.goBrightBoolean; // Subscribe to theme changes - this.terminalThemeSubscription = domtoolsInstance.themeManager.themeObservable.subscribe((goBright: boolean) => { - if (this.terminal) { - this.terminal.options.theme = this.getTerminalTheme(goBright); + this.terminalThemeSubscription = domtoolsInstance.themeManager.themeObservable.subscribe( + (goBright: boolean) => { + this.isBright = goBright; + this.tabManager.updateAllThemes(goBright); } - }); - 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); + // Create default shell tab + await this.createShellTab(); } async connectedCallback(): Promise { @@ -412,17 +500,324 @@ export class DeesWorkspaceTerminal extends DeesElement { this.terminalThemeSubscription.unsubscribe(); this.terminalThemeSubscription = null; } - if (this.terminal) { - this.terminal.dispose(); - this.terminal = null; - } + this.tabManager.disposeAll(); await super.disconnectedCallback(); } - handleResize() { - this.fitAddon.fit(); + // ========== Tab Management ========== + + private async handleAddTab(): Promise { + await this.createShellTab(); } + private handleTabClose(e: Event, tabId: string): void { + e.stopPropagation(); + this.closeTab(tabId); + } + + private switchToTab(tabId: string): void { + if (tabId === this.activeTabId) return; + + const tab = this.tabManager.getTab(tabId); + if (!tab) return; + + const previousTabId = this.activeTabId; + + // Detach current terminal from DOM + if (this.activeTabId) { + this.detachActiveTerminal(); + } + + // Update active tab + this.activeTabId = tabId; + this.tabs = this.tabManager.getAllTabs(); + + // Wait for render then attach new terminal + this.updateComplete.then(() => { + this.attachTerminalToContainer(tab); + + // Dispatch event + this.dispatchEvent( + new CustomEvent('tab-switched', { + bubbles: true, + composed: true, + detail: { tabId, previousTabId }, + }) + ); + }); + } + + private detachActiveTerminal(): void { + const container = this.shadowRoot?.getElementById('active-terminal-container'); + if (container) { + container.innerHTML = ''; + } + } + + private attachTerminalToContainer(tab: ITerminalTab): void { + const container = this.shadowRoot?.getElementById('active-terminal-container'); + if (!container) return; + + // Clear container + container.innerHTML = ''; + + // Open terminal in container + tab.terminal.open(container); + tab.fitAddon.fit(); + tab.terminal.focus(); + } + + private handleResize(): void { + if (this.activeTabId) { + const tab = this.tabManager.getTab(this.activeTabId); + if (tab) { + tab.fitAddon.fit(); + } + } + } + + // ========== Process Management ========== + + private async spawnProcessForTab( + tab: ITerminalTab, + command: string, + args: string[] = [] + ): Promise { + if (!this.executionEnvironment) { + tab.terminal.write('\x1b[31m'); + tab.terminal.write('❌ No execution environment available.\r\n'); + tab.terminal.write('\x1b[0m'); + return; + } + + try { + // Initialize environment if needed + if (!this.executionEnvironment.ready) { + tab.terminal.write('Initializing environment...\r\n'); + await this.executionEnvironment.init(); + } + + // Spawn process + const process = await this.executionEnvironment.spawn(command, args); + + // Set up output piping + process.output.pipeTo( + new WritableStream({ + write: (data) => { + tab.terminal.write(data); + }, + }) + ); + + // Set up input for interactive processes + const inputWriter = process.input.getWriter(); + tab.terminal.onData((data) => { + inputWriter.write(data); + }); + + // Store process reference + this.tabManager.setTabProcess(tab.id, process, inputWriter); + this.tabs = this.tabManager.getAllTabs(); + + // Handle process exit + process.exit.then((exitCode) => { + this.handleProcessExit(tab.id, exitCode); + }); + } catch (error) { + tab.terminal.write('\x1b[31m'); + tab.terminal.write(`❌ Failed to spawn process: ${error}\r\n`); + tab.terminal.write('\x1b[0m'); + console.error('Failed to spawn process:', error); + } + } + + private handleProcessExit(tabId: string, exitCode: number): void { + const tab = this.tabManager.getTab(tabId); + if (!tab) return; + + // Update tab state + this.tabManager.markTabExited(tabId, exitCode); + + // Write exit message to terminal + const message = + exitCode === 0 + ? '\r\n\x1b[32m[Process completed successfully]\x1b[0m\r\n' + : `\r\n\x1b[31m[Process exited with code ${exitCode}]\x1b[0m\r\n`; + tab.terminal.write(message); + + // Update state to trigger re-render + this.tabs = this.tabManager.getAllTabs(); + + // Dispatch event + this.dispatchEvent( + new CustomEvent('process-complete', { + bubbles: true, + composed: true, + detail: { tabId, exitCode }, + }) + ); + } + + // ========== Public API ========== + + /** + * Create a new shell tab + */ + public async createShellTab(label?: string): Promise { + const tab = this.tabManager.createTab( + { + type: 'shell', + label: label || `bash ${this.tabManager.getTabCount() + 1}`, + closeable: this.tabManager.getTabCount() > 0, // First tab not closeable + }, + this.isBright + ); + + this.tabs = this.tabManager.getAllTabs(); + + // Switch to new tab + this.switchToTab(tab.id); + + // Wait for DOM update then spawn shell + await this.updateComplete; + await this.spawnProcessForTab(tab, 'jsh'); + + // Run setup command if this is the first tab + if (this.tabManager.getTabCount() === 1 && this.setupCommand) { + await this.waitForPrompt(tab.terminal, '~/'); + if (tab.inputWriter) { + tab.inputWriter.write(this.setupCommand); + } + } + + // Dispatch event + this.dispatchEvent( + new CustomEvent('tab-created', { + bubbles: true, + composed: true, + detail: { tabId: tab.id }, + }) + ); + + return tab.id; + } + + /** + * Create a terminal tab for running a process + */ + public async createProcessTab(options: ICreateTerminalTabOptions): Promise { + const tab = this.tabManager.createTab(options, this.isBright); + + this.tabs = this.tabManager.getAllTabs(); + + // Switch to new tab if requested (default: true) + if (options.switchToTab !== false) { + this.switchToTab(tab.id); + } + + // Wait for DOM update + await this.updateComplete; + + // Spawn process if command provided + if (options.command) { + await this.spawnProcessForTab(tab, options.command, options.args); + } + + // Dispatch event + this.dispatchEvent( + new CustomEvent('tab-created', { + bubbles: true, + composed: true, + detail: { tabId: tab.id }, + }) + ); + + return tab.id; + } + + /** + * Get the currently active tab + */ + public getActiveTab(): ITerminalTab | null { + if (!this.activeTabId) return null; + return this.tabManager.getTab(this.activeTabId) || null; + } + + /** + * Get all tabs + */ + public getTabs(): ITerminalTab[] { + return this.tabManager.getAllTabs(); + } + + /** + * Switch to a specific tab by ID + */ + public selectTab(tabId: string): boolean { + if (!this.tabManager.hasTab(tabId)) return false; + this.switchToTab(tabId); + return true; + } + + /** + * Close a tab by ID + */ + public closeTab(tabId: string): boolean { + const tab = this.tabManager.getTab(tabId); + if (!tab || !tab.closeable) return false; + + // If closing active tab, switch to another + if (tabId === this.activeTabId) { + const allTabs = this.tabManager.getAllTabs(); + const currentIndex = allTabs.findIndex((t) => t.id === tabId); + const nextTab = allTabs[currentIndex + 1] || allTabs[currentIndex - 1]; + + if (nextTab) { + this.switchToTab(nextTab.id); + } else { + this.activeTabId = null; + this.detachActiveTerminal(); + } + } + + // Close the tab + this.tabManager.closeTab(tabId); + this.tabs = this.tabManager.getAllTabs(); + + // Dispatch event + this.dispatchEvent( + new CustomEvent('tab-closed', { + bubbles: true, + composed: true, + detail: { tabId }, + }) + ); + + return true; + } + + /** + * Write data to a tab's terminal + */ + public writeToTab(tabId: string, data: string): boolean { + const tab = this.tabManager.getTab(tabId); + if (!tab) return false; + tab.terminal.write(data); + return true; + } + + /** + * Send input to a tab's process (if interactive) + */ + public sendInputToTab(tabId: string, data: string): boolean { + const tab = this.tabManager.getTab(tabId); + if (!tab || !tab.inputWriter) return false; + tab.inputWriter.write(data); + return true; + } + + // ========== Utility Methods ========== + public async waitForPrompt(term: Terminal, prompt: string): Promise { return new Promise((resolve) => { const checkPrompt = () => { @@ -436,9 +831,9 @@ export class DeesWorkspaceTerminal extends DeesElement { return; } } - setTimeout(checkPrompt, 100); // check every 100 ms + setTimeout(checkPrompt, 100); }; - + checkPrompt(); }); } @@ -453,13 +848,11 @@ export class DeesWorkspaceTerminal extends DeesElement { 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; diff --git a/ts_web/elements/00group-workspace/dees-workspace-terminal/index.ts b/ts_web/elements/00group-workspace/dees-workspace-terminal/index.ts index 061a727..6ec03d8 100644 --- a/ts_web/elements/00group-workspace/dees-workspace-terminal/index.ts +++ b/ts_web/elements/00group-workspace/dees-workspace-terminal/index.ts @@ -1 +1,3 @@ export * from './dees-workspace-terminal.js'; +export * from './interfaces.js'; +export * from './terminal-tab-manager.js'; diff --git a/ts_web/elements/00group-workspace/dees-workspace-terminal/interfaces.ts b/ts_web/elements/00group-workspace/dees-workspace-terminal/interfaces.ts new file mode 100644 index 0000000..7e8e0d4 --- /dev/null +++ b/ts_web/elements/00group-workspace/dees-workspace-terminal/interfaces.ts @@ -0,0 +1,126 @@ +import type { Terminal } from 'xterm'; +import type { FitAddon } from 'xterm-addon-fit'; +import type { IProcessHandle } from '../../00group-runtime/index.js'; + +/** + * Type of terminal tab based on its source/purpose + */ +export type TTerminalTabType = + | 'shell' // Default interactive shell (jsh) + | 'script' // Script from package.json + | 'package-update' // Package update process + | 'custom'; // External process from API + +/** + * Represents a single terminal tab instance + */ +export interface ITerminalTab { + /** Unique identifier for the tab */ + id: string; + + /** Display label for the tab */ + label: string; + + /** Icon name (lucide icon) */ + iconName: string; + + /** Type of terminal process */ + type: TTerminalTabType; + + /** Whether the tab can be closed */ + closeable: boolean; + + /** xterm.js Terminal instance */ + terminal: Terminal; + + /** FitAddon for this terminal */ + fitAddon: FitAddon; + + /** Process handle (if process is running) */ + process: IProcessHandle | null; + + /** Process input writer (for interactive terminals) */ + inputWriter: WritableStreamDefaultWriter | null; + + /** Whether the process has exited */ + exited: boolean; + + /** Exit code (if process has exited) */ + exitCode: number | null; + + /** Timestamp when tab was created */ + createdAt: number; + + /** Optional metadata for script tabs */ + metadata?: { + scriptName?: string; + packageName?: string; + command?: string; + }; +} + +/** + * Options for creating a new terminal tab + */ +export interface ICreateTerminalTabOptions { + /** Display label (auto-generated if not provided) */ + label?: string; + + /** Tab type */ + type: TTerminalTabType; + + /** Icon name override */ + iconName?: string; + + /** Whether the tab can be closed (default: true for non-shell) */ + closeable?: boolean; + + /** Command to spawn (default: 'jsh' for shell type) */ + command?: string; + + /** Command arguments */ + args?: string[]; + + /** Additional metadata */ + metadata?: ITerminalTab['metadata']; + + /** Whether to switch to this tab after creation (default: true) */ + switchToTab?: boolean; +} + +/** + * Event detail for terminal tab events + */ +export interface ITerminalTabEventDetail { + tabId: string; +} + +/** + * Event detail for tab switch events + */ +export interface ITerminalTabSwitchEventDetail { + tabId: string; + previousTabId: string | null; +} + +/** + * Event detail for process completion + */ +export interface ITerminalProcessCompleteEventDetail { + tabId: string; + exitCode: number; +} + +/** + * Event detail for run-process events from bottom bar + */ +export interface IRunProcessEventDetail { + type: TTerminalTabType; + label: string; + command: string; + args?: string[]; + metadata?: { + scriptName?: string; + packageName?: string; + }; +} diff --git a/ts_web/elements/00group-workspace/dees-workspace-terminal/terminal-tab-manager.ts b/ts_web/elements/00group-workspace/dees-workspace-terminal/terminal-tab-manager.ts new file mode 100644 index 0000000..3bc77be --- /dev/null +++ b/ts_web/elements/00group-workspace/dees-workspace-terminal/terminal-tab-manager.ts @@ -0,0 +1,243 @@ +import { Terminal } from 'xterm'; +import { FitAddon } from 'xterm-addon-fit'; +import type { ITerminalTab, ICreateTerminalTabOptions, TTerminalTabType } from './interfaces.js'; + +/** + * Manages terminal tabs lifecycle and state + */ +export class TerminalTabManager { + private tabs: Map = new Map(); + private tabCounter: number = 0; + + /** + * Generate unique tab ID + */ + private generateTabId(): string { + this.tabCounter++; + return `terminal-${this.tabCounter}-${Date.now()}`; + } + + /** + * Get default label for tab type + */ + private getDefaultLabel(type: TTerminalTabType, metadata?: ITerminalTab['metadata']): string { + switch (type) { + case 'shell': + return 'bash'; + case 'script': + return metadata?.scriptName || 'script'; + case 'package-update': + return metadata?.packageName ? `update ${metadata.packageName}` : 'update'; + case 'custom': + return metadata?.command || 'process'; + default: + return 'terminal'; + } + } + + /** + * Get default icon for tab type + */ + private getDefaultIcon(type: TTerminalTabType): string { + switch (type) { + case 'shell': + return 'lucide:terminal'; + case 'script': + return 'lucide:play'; + case 'package-update': + return 'lucide:packageCheck'; + case 'custom': + return 'lucide:code'; + default: + return 'lucide:terminal'; + } + } + + /** + * Get terminal theme configuration + */ + private getTerminalTheme(isBright: boolean): any { + if (isBright) { + return { + background: '#ffffff', + foreground: '#333333', + cursor: '#333333', + cursorAccent: '#ffffff', + selection: 'rgba(0, 0, 0, 0.2)', + black: '#000000', + red: '#cd3131', + green: '#00bc00', + yellow: '#949800', + blue: '#0451a5', + magenta: '#bc05bc', + cyan: '#0598bc', + white: '#555555', + brightBlack: '#666666', + brightRed: '#cd3131', + brightGreen: '#14ce14', + brightYellow: '#b5ba00', + brightBlue: '#0451a5', + brightMagenta: '#bc05bc', + brightCyan: '#0598bc', + brightWhite: '#a5a5a5', + }; + } else { + return { + background: '#000000', + foreground: '#ffffff', + cursor: '#ffffff', + cursorAccent: '#000000', + selection: 'rgba(255, 255, 255, 0.2)', + }; + } + } + + /** + * Create a new tab instance + */ + createTab(options: ICreateTerminalTabOptions, isBright: boolean): ITerminalTab { + const id = this.generateTabId(); + const type = options.type; + + // Create xterm.js Terminal instance + const terminal = new Terminal({ + convertEol: true, + cursorBlink: true, + theme: this.getTerminalTheme(isBright), + fontFamily: 'Menlo, Monaco, "Courier New", monospace', + fontSize: 13, + lineHeight: 1.2, + }); + + // Create FitAddon + const fitAddon = new FitAddon(); + terminal.loadAddon(fitAddon); + + const tab: ITerminalTab = { + id, + label: options.label || this.getDefaultLabel(type, options.metadata), + iconName: options.iconName || this.getDefaultIcon(type), + type, + closeable: options.closeable ?? (type !== 'shell'), + terminal, + fitAddon, + process: null, + inputWriter: null, + exited: false, + exitCode: null, + createdAt: Date.now(), + metadata: options.metadata, + }; + + this.tabs.set(id, tab); + return tab; + } + + /** + * Get tab by ID + */ + getTab(id: string): ITerminalTab | undefined { + return this.tabs.get(id); + } + + /** + * Get all tabs as array (ordered by creation time) + */ + getAllTabs(): ITerminalTab[] { + return Array.from(this.tabs.values()).sort((a, b) => a.createdAt - b.createdAt); + } + + /** + * Get the number of tabs + */ + getTabCount(): number { + return this.tabs.size; + } + + /** + * Check if tab exists + */ + hasTab(id: string): boolean { + return this.tabs.has(id); + } + + /** + * Close and cleanup a tab + */ + closeTab(id: string): boolean { + const tab = this.tabs.get(id); + if (!tab) return false; + + // Kill process if still running + if (tab.process && !tab.exited) { + try { + tab.process.kill(); + } catch (e) { + console.warn('Failed to kill process:', e); + } + } + + // Dispose terminal + try { + tab.terminal.dispose(); + } catch (e) { + console.warn('Failed to dispose terminal:', e); + } + + this.tabs.delete(id); + return true; + } + + /** + * Rename a tab + */ + renameTab(id: string, newLabel: string): boolean { + const tab = this.tabs.get(id); + if (!tab) return false; + + tab.label = newLabel; + return true; + } + + /** + * Update tab process state to exited + */ + markTabExited(id: string, exitCode: number): void { + const tab = this.tabs.get(id); + if (!tab) return; + + tab.exited = true; + tab.exitCode = exitCode; + } + + /** + * Set process for a tab + */ + setTabProcess(id: string, process: ITerminalTab['process'], inputWriter: ITerminalTab['inputWriter']): void { + const tab = this.tabs.get(id); + if (!tab) return; + + tab.process = process; + tab.inputWriter = inputWriter; + } + + /** + * Update theme for all terminals + */ + updateAllThemes(isBright: boolean): void { + const theme = this.getTerminalTheme(isBright); + for (const tab of this.tabs.values()) { + tab.terminal.options.theme = theme; + } + } + + /** + * Dispose all tabs and cleanup + */ + disposeAll(): void { + for (const [id] of this.tabs) { + this.closeTab(id); + } + this.tabs.clear(); + } +} diff --git a/ts_web/elements/00group-workspace/dees-workspace/dees-workspace.ts b/ts_web/elements/00group-workspace/dees-workspace/dees-workspace.ts index 6001df8..6fcc7a5 100644 --- a/ts_web/elements/00group-workspace/dees-workspace/dees-workspace.ts +++ b/ts_web/elements/00group-workspace/dees-workspace/dees-workspace.ts @@ -17,7 +17,10 @@ import '../dees-workspace-monaco/dees-workspace-monaco.js'; import '../dees-workspace-filetree/dees-workspace-filetree.js'; import { DeesWorkspaceFiletree } from '../dees-workspace-filetree/dees-workspace-filetree.js'; import '../dees-workspace-terminal/dees-workspace-terminal.js'; +import { DeesWorkspaceTerminal } from '../dees-workspace-terminal/dees-workspace-terminal.js'; +import type { IRunProcessEventDetail } from '../dees-workspace-terminal/interfaces.js'; import '../dees-workspace-terminal-preview/dees-workspace-terminal-preview.js'; +import '../dees-workspace-bottombar/dees-workspace-bottombar.js'; import '../../dees-icon/dees-icon.js'; import { DeesWorkspaceMonaco } from '../dees-workspace-monaco/dees-workspace-monaco.js'; import { TypeScriptIntelliSenseManager } from './typescript-intellisense.js'; @@ -370,10 +373,18 @@ testSmartPromise(); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; } + .workspace-outer { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + } + .workspace-container { display: flex; flex-direction: row; - height: 100%; + flex: 1; + min-height: 0; width: 100%; } @@ -857,121 +868,129 @@ testSmartPromise(); } return html` -
- - ${this.showFileTree ? html` -
- -
- ${!this.isFileTreeCollapsed ? 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 && !this.isTerminalCollapsed ? html` -
- ` : ''} - - - ${this.showTerminal ? html` -
-
-
-
this.activeBottomPanel = 'terminal'} - > - - Terminal -
-
this.activeBottomPanel = 'problems'} - > - - Problems - ${this.diagnosticMarkers.length > 0 ? html` - ${this.diagnosticMarkers.length} - ` : ''} -
+ +
+ ${!this.isFileTreeCollapsed ? html` +
+ ` : ''} + ` : ''} + + +
+
+
+
+ ${this.openFiles.map(file => html` +
this.activateFile(file.path)} + > + ${file.modified ? html`` : ''} + ${file.name} + this.closeFile(e, file.path)}> + + +
+ `)}
-
-
- -
+
+
-
- -
-
- ${this.renderProblemsPanel()} +
+ ${this.openFiles.length === 0 ? html` +
+ + Select a file to edit +
+ ` : html` + + `}
- ` : ''} + + + ${this.showTerminal && !this.isTerminalCollapsed ? html` +
+ ` : ''} + + + ${this.showTerminal ? html` +
+
+
+
this.activeBottomPanel = 'terminal'} + > + + Terminal +
+
this.activeBottomPanel = 'problems'} + > + + Problems + ${this.diagnosticMarkers.length > 0 ? html` + ${this.diagnosticMarkers.length} + ` : ''} +
+
+
+
+ +
+
+
+
+ +
+
+ ${this.renderProblemsPanel()} +
+
+ ` : ''} +
+ + +
`; } @@ -1484,6 +1503,44 @@ testSmartPromise(); })); } + // ========== Bottom Bar Event Handlers ========== + + /** + * Handle run-process events from bottom bar widgets + * Creates a new terminal tab for the process + */ + private async handleRunProcess(e: CustomEvent): Promise { + const detail = e.detail; + + // Find the terminal component + const terminal = this.shadowRoot?.querySelector('dees-workspace-terminal') as DeesWorkspaceTerminal; + if (!terminal) { + console.warn('Terminal component not found'); + return; + } + + // Expand terminal if collapsed + if (this.isTerminalCollapsed) { + this.isTerminalCollapsed = false; + } + + // Switch to terminal panel + this.activeBottomPanel = 'terminal'; + + // Wait for UI update + await this.updateComplete; + + // Create a new terminal tab for the process + await terminal.createProcessTab({ + type: detail.type, + label: detail.label, + command: detail.command, + args: detail.args, + metadata: detail.metadata, + switchToTab: true, + }); + } + // ========== Public Layout Methods ========== /** diff --git a/ts_web/elements/00group-workspace/index.ts b/ts_web/elements/00group-workspace/index.ts index 869d201..5b217f5 100644 --- a/ts_web/elements/00group-workspace/index.ts +++ b/ts_web/elements/00group-workspace/index.ts @@ -6,3 +6,4 @@ export * from './dees-workspace-terminal/index.js'; export * from './dees-workspace-terminal-preview/index.js'; export * from './dees-workspace-markdown/index.js'; export * from './dees-workspace-markdownoutlet/index.js'; +export * from './dees-workspace-bottombar/index.js';