diff --git a/changelog.md b/changelog.md index ca1c554..1a40d53 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,15 @@ # Changelog +## 2026-01-01 - 3.25.0 - feat(dees-actionbar) +add action bar component and improve workspace package update handling + +- Introduce dees-actionbar component (dees-actionbar.ts) with interfaces, queueing, timed auto-trigger and demo usage +- Add actionbar.interfaces.ts and index export; export dees-actionbar from elements index +- Enhance workspace bottombar: add pendingPackageUpdate flag, process-complete handler, and connected/disconnected listeners to auto-refresh package status after updates +- Make pnpm outdated checking robust by streaming output via a reader and adding a 10s timeout to avoid hanging; handle timeout and stream cancellation +- Update package update commands to include '--latest' for updatePackage and updateAllPackages, and show 'Checking...' label during checks +- Add '@types/node' (^22.0.0) to devDependencies in the workspace package config + ## 2026-01-01 - 3.24.0 - feat(workspace) add workspace bottom bar, terminal tab manager, and run-process integration diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index e587f92..b37b75c 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.24.0', + version: '3.25.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 index 05c4025..bbeee3c 100644 --- 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 @@ -12,7 +12,7 @@ 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'; +import type { IRunProcessEventDetail, ITerminalProcessCompleteEventDetail } from '../dees-workspace-terminal/interfaces.js'; declare global { interface HTMLElementTagNameMap { @@ -48,6 +48,19 @@ export class DeesWorkspaceBottombar extends DeesElement { @state() accessor isCheckingPackages: boolean = false; + // Track if we have a pending package update that should trigger refresh + private pendingPackageUpdate: boolean = false; + + // Bound handler for process-complete events + private handleProcessComplete = (e: CustomEvent) => { + // If we have a pending package update and a process completed, refresh + if (this.pendingPackageUpdate) { + this.pendingPackageUpdate = false; + // Small delay to let pnpm-lock.yaml update + setTimeout(() => this.checkPackages(), 500); + } + }; + public static styles = [ themeDefaultStyles, cssManager.defaultStyles, @@ -167,6 +180,17 @@ export class DeesWorkspaceBottombar extends DeesElement { `; } + public async connectedCallback() { + await super.connectedCallback(); + // Listen for process-complete events to refresh after package updates + window.addEventListener('process-complete', this.handleProcessComplete as EventListener); + } + + public async disconnectedCallback() { + await super.disconnectedCallback(); + window.removeEventListener('process-complete', this.handleProcessComplete as EventListener); + } + async firstUpdated() { await this.loadScripts(); await this.checkPackages(); @@ -256,19 +280,46 @@ export class DeesWorkspaceBottombar extends DeesElement { this.packageStatus = 'checking'; this.isCheckingPackages = true; - // Run pnpm outdated --json + // Run pnpm outdated --json with timeout 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; + // Collect output asynchronously - don't await, stream may not close if no output + const outputReader = process.output.getReader(); + const readOutput = async () => { + try { + while (true) { + const { done, value } = await outputReader.read(); + if (done) break; + output += value; + } + } catch { + // Ignore stream errors + } + }; + // Start reading but don't await - we'll use whatever we have when process exits + readOutput(); + + // Wait for process exit with timeout (10 seconds) + const exitCode = await Promise.race([ + process.exit, + new Promise((resolve) => setTimeout(() => resolve(-1), 10000)), + ]); + + // Cancel reader when done + try { + await outputReader.cancel(); + } catch { + // Ignore cancel errors + } + + // Handle timeout + if (exitCode === -1) { + console.warn('Package check timed out'); + this.packageStatus = 'error'; + return; + } // pnpm outdated returns exit code 1 if there are outdated packages if (exitCode === 0) { @@ -318,15 +369,15 @@ export class DeesWorkspaceBottombar extends DeesElement { 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', + name: this.isCheckingPackages ? 'Checking...' : 'Check for updates', iconName: 'lucide:refreshCw', action: async () => { + if (this.isCheckingPackages) return; + // Create terminal tab to show pnpm outdated output const detail: IRunProcessEventDetail = { type: 'package-update', @@ -387,12 +438,15 @@ export class DeesWorkspaceBottombar extends DeesElement { private async updatePackage(packageName: string): Promise { if (!this.executionEnvironment) return; + // Mark that we have a pending update - will trigger refresh when complete + this.pendingPackageUpdate = true; + // 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], + args: ['update', '--latest', packageName], metadata: { packageName }, }; @@ -406,12 +460,15 @@ export class DeesWorkspaceBottombar extends DeesElement { private async updateAllPackages(): Promise { if (!this.executionEnvironment) return; + // Mark that we have a pending update - will trigger refresh when complete + this.pendingPackageUpdate = true; + // 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'], + args: ['update', '--latest'], }; this.dispatchEvent(new CustomEvent('run-process', { 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 6fcc7a5..68913aa 100644 --- a/ts_web/elements/00group-workspace/dees-workspace/dees-workspace.ts +++ b/ts_web/elements/00group-workspace/dees-workspace/dees-workspace.ts @@ -65,6 +65,7 @@ export class DeesWorkspace extends DeesElement { '@push.rocks/smartpromise': '^4.2.3', }, devDependencies: { + '@types/node': '^22.0.0', typescript: '^5.0.0', }, }, diff --git a/ts_web/elements/dees-actionbar/actionbar.interfaces.ts b/ts_web/elements/dees-actionbar/actionbar.interfaces.ts new file mode 100644 index 0000000..94666a6 --- /dev/null +++ b/ts_web/elements/dees-actionbar/actionbar.interfaces.ts @@ -0,0 +1,54 @@ +/** + * Action button configuration for the action bar + */ +export interface IActionBarAction { + /** Unique identifier for the action */ + id: string; + /** Button label text */ + label: string; + /** Primary action gets highlighted styling and receives timeout trigger */ + primary?: boolean; + /** Lucide icon name (optional) */ + icon?: string; +} + +/** + * Configuration options for showing an action bar + */ +export interface IActionBarOptions { + /** Message text to display */ + message: string; + /** Lucide icon name for the message (optional) */ + icon?: string; + /** Visual type affects coloring */ + type?: 'info' | 'warning' | 'error' | 'question'; + /** Action buttons to display */ + actions: IActionBarAction[]; + /** Timeout configuration (optional) */ + timeout?: { + /** Duration in milliseconds before auto-triggering default action */ + duration: number; + /** ID of the action to auto-trigger when timeout expires */ + defaultActionId: string; + }; + /** Whether to show a dismiss (X) button */ + dismissible?: boolean; +} + +/** + * Result returned when an action bar is resolved + */ +export interface IActionBarResult { + /** ID of the action that was triggered */ + actionId: string; + /** Whether the action was triggered by timeout (true) or user click (false) */ + timedOut: boolean; +} + +/** + * Internal queue item for pending action bars + */ +export interface IActionBarQueueItem { + options: IActionBarOptions; + resolve: (result: IActionBarResult) => void; +} diff --git a/ts_web/elements/dees-actionbar/dees-actionbar.ts b/ts_web/elements/dees-actionbar/dees-actionbar.ts new file mode 100644 index 0000000..29e29d3 --- /dev/null +++ b/ts_web/elements/dees-actionbar/dees-actionbar.ts @@ -0,0 +1,485 @@ +import { + customElement, + DeesElement, + type TemplateResult, + html, + css, + state, + cssManager, +} from '@design.estate/dees-element'; +import { themeDefaultStyles } from '../00theme.js'; +import '../dees-icon/dees-icon.js'; +import type { + IActionBarOptions, + IActionBarResult, + IActionBarQueueItem, + IActionBarAction, +} from './actionbar.interfaces.js'; + +declare global { + interface HTMLElementTagNameMap { + 'dees-actionbar': DeesActionbar; + } +} + +@customElement('dees-actionbar') +export class DeesActionbar extends DeesElement { + // STATIC + public static demo = () => { + const getActionbar = (e: Event) => { + const button = e.currentTarget as HTMLElement; + const container = button.closest('.demo-container'); + return container?.querySelector('dees-actionbar') as DeesActionbar | null; + }; + + const showActionBar = async (e: Event) => { + const actionbar = getActionbar(e); + if (!actionbar) return; + const result = await actionbar.show({ + message: 'File changed externally. Reload?', + type: 'warning', + icon: 'lucide:alertTriangle', + actions: [ + { id: 'reload', label: 'Reload', primary: true }, + { id: 'ignore', label: 'Ignore' }, + ], + timeout: { duration: 5000, defaultActionId: 'reload' }, + dismissible: true, + }); + console.log('Action bar result:', result); + }; + + const showErrorBar = async (e: Event) => { + const actionbar = getActionbar(e); + if (!actionbar) return; + const result = await actionbar.show({ + message: 'Process failed with exit code 1', + type: 'error', + icon: 'lucide:xCircle', + actions: [ + { id: 'retry', label: 'Retry', primary: true }, + { id: 'dismiss', label: 'Dismiss' }, + ], + timeout: { duration: 10000, defaultActionId: 'dismiss' }, + }); + console.log('Error bar result:', result); + }; + + const showQuestionBar = async (e: Event) => { + const actionbar = getActionbar(e); + if (!actionbar) return; + const result = await actionbar.show({ + message: 'Save changes before closing?', + type: 'question', + icon: 'lucide:helpCircle', + actions: [ + { id: 'save', label: 'Save', primary: true }, + { id: 'discard', label: 'Discard' }, + { id: 'cancel', label: 'Cancel' }, + ], + }); + console.log('Question bar result:', result); + }; + + return html` + +
+
+ Warning + Error + Question +
+ +
+ `; + }; + + // Queue of pending action bars + private queue: IActionBarQueueItem[] = []; + + // Current active bar state + @state() accessor currentBar: IActionBarOptions | null = null; + @state() accessor timeRemaining: number = 0; + @state() accessor progressPercent: number = 100; + @state() accessor isVisible: boolean = false; + + // Timeout handling + private timeoutInterval: ReturnType | null = null; + private currentResolve: ((result: IActionBarResult) => void) | null = null; + + public static styles = [ + themeDefaultStyles, + cssManager.defaultStyles, + css` + :host { + display: block; + overflow: hidden; + height: 0; + transition: height 0.2s ease-out; + } + + :host(.visible) { + height: auto; + } + + .actionbar-item { + display: flex; + flex-direction: column; + background: ${cssManager.bdTheme('hsl(0 0% 96%)', 'hsl(0 0% 12%)')}; + border-top: 1px solid ${cssManager.bdTheme('hsl(0 0% 88%)', 'hsl(0 0% 20%)')}; + overflow: hidden; + } + + .progress-bar { + height: 3px; + background: ${cssManager.bdTheme('hsl(0 0% 90%)', 'hsl(0 0% 18%)')}; + overflow: hidden; + } + + .progress-bar-fill { + height: 100%; + background: ${cssManager.bdTheme('hsl(210 100% 50%)', 'hsl(210 100% 60%)')}; + transition: width 0.1s linear; + } + + .progress-bar-fill.warning { + background: ${cssManager.bdTheme('hsl(38 92% 50%)', 'hsl(38 92% 55%)')}; + } + + .progress-bar-fill.error { + background: ${cssManager.bdTheme('hsl(0 70% 50%)', 'hsl(0 70% 55%)')}; + } + + .progress-bar-fill.question { + background: ${cssManager.bdTheme('hsl(270 70% 50%)', 'hsl(270 70% 60%)')}; + } + + .content { + display: flex; + align-items: center; + padding: 8px 12px; + gap: 12px; + min-height: 32px; + } + + .message-section { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + min-width: 0; + } + + .message-icon { + flex-shrink: 0; + color: ${cssManager.bdTheme('hsl(0 0% 40%)', 'hsl(0 0% 60%)')}; + } + + .message-icon.info { + color: ${cssManager.bdTheme('hsl(210 100% 45%)', 'hsl(210 100% 60%)')}; + } + + .message-icon.warning { + color: ${cssManager.bdTheme('hsl(38 92% 45%)', 'hsl(38 92% 55%)')}; + } + + .message-icon.error { + color: ${cssManager.bdTheme('hsl(0 70% 50%)', 'hsl(0 70% 55%)')}; + } + + .message-icon.question { + color: ${cssManager.bdTheme('hsl(270 70% 50%)', 'hsl(270 70% 60%)')}; + } + + .message-text { + font-size: 13px; + color: ${cssManager.bdTheme('hsl(0 0% 20%)', 'hsl(0 0% 85%)')}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .actions-section { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + } + + .action-button { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; + cursor: pointer; + border: 1px solid transparent; + transition: all 0.15s ease; + white-space: nowrap; + } + + .action-button.secondary { + background: transparent; + color: ${cssManager.bdTheme('hsl(0 0% 30%)', 'hsl(0 0% 70%)')}; + border-color: ${cssManager.bdTheme('hsl(0 0% 80%)', 'hsl(0 0% 30%)')}; + } + + .action-button.secondary:hover { + background: ${cssManager.bdTheme('hsl(0 0% 92%)', 'hsl(0 0% 18%)')}; + } + + .action-button.primary { + background: ${cssManager.bdTheme('hsl(210 100% 50%)', 'hsl(210 100% 55%)')}; + color: white; + } + + .action-button.primary:hover { + background: ${cssManager.bdTheme('hsl(210 100% 45%)', 'hsl(210 100% 50%)')}; + } + + .action-button.primary.warning { + background: ${cssManager.bdTheme('hsl(38 92% 45%)', 'hsl(38 92% 50%)')}; + } + + .action-button.primary.warning:hover { + background: ${cssManager.bdTheme('hsl(38 92% 40%)', 'hsl(38 92% 45%)')}; + } + + .action-button.primary.error { + background: ${cssManager.bdTheme('hsl(0 70% 50%)', 'hsl(0 70% 55%)')}; + } + + .action-button.primary.error:hover { + background: ${cssManager.bdTheme('hsl(0 70% 45%)', 'hsl(0 70% 50%)')}; + } + + .action-button.primary.question { + background: ${cssManager.bdTheme('hsl(270 70% 50%)', 'hsl(270 70% 55%)')}; + } + + .action-button.primary.question:hover { + background: ${cssManager.bdTheme('hsl(270 70% 45%)', 'hsl(270 70% 50%)')}; + } + + .countdown { + font-size: 11px; + opacity: 0.8; + margin-left: 2px; + } + + .dismiss-button { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 4px; + cursor: pointer; + color: ${cssManager.bdTheme('hsl(0 0% 50%)', 'hsl(0 0% 50%)')}; + transition: all 0.15s ease; + } + + .dismiss-button:hover { + background: ${cssManager.bdTheme('hsl(0 0% 88%)', 'hsl(0 0% 22%)')}; + color: ${cssManager.bdTheme('hsl(0 0% 30%)', 'hsl(0 0% 70%)')}; + } + `, + ]; + + public render(): TemplateResult { + if (!this.currentBar) { + return html``; + } + + const bar = this.currentBar; + const type = bar.type || 'info'; + const hasTimeout = bar.timeout && this.timeRemaining > 0; + + return html` +
+ ${hasTimeout ? html` +
+
+
+ ` : ''} +
+
+ ${bar.icon ? html` + + ` : ''} + ${bar.message} +
+
+ ${bar.actions.map(action => this.renderActionButton(action, bar, hasTimeout))} + ${bar.dismissible ? html` +
this.handleDismiss()} + title="Dismiss" + > + +
+ ` : ''} +
+
+
+ `; + } + + private renderActionButton( + action: IActionBarAction, + bar: IActionBarOptions, + hasTimeout: boolean | undefined + ): TemplateResult { + const isPrimary = action.primary; + const type = bar.type || 'info'; + const isDefaultAction = bar.timeout?.defaultActionId === action.id; + const showCountdown = hasTimeout && isDefaultAction; + const seconds = Math.ceil(this.timeRemaining / 1000); + + return html` + + `; + } + + // ========== Public API ========== + + /** + * Show an action bar with the given options. + * Returns a promise that resolves when an action is taken. + */ + public async show(options: IActionBarOptions): Promise { + return new Promise((resolve) => { + // Add to queue + this.queue.push({ options, resolve }); + + // If no current bar, process queue + if (!this.currentBar) { + this.processQueue(); + } + }); + } + + /** + * Dismiss the current action bar without triggering any action. + */ + public dismiss(): void { + this.handleDismiss(); + } + + /** + * Clear all pending action bars in the queue. + */ + public clearQueue(): void { + // Resolve all queued items with dismiss + for (const item of this.queue) { + item.resolve({ actionId: 'dismissed', timedOut: false }); + } + this.queue = []; + } + + // ========== Private Methods ========== + + private processQueue(): void { + if (this.queue.length === 0) { + this.currentBar = null; + this.currentResolve = null; + this.isVisible = false; + this.classList.remove('visible'); + return; + } + + const item = this.queue.shift()!; + this.currentBar = item.options; + this.currentResolve = item.resolve; + this.isVisible = true; + this.classList.add('visible'); + + // Setup timeout if configured + if (item.options.timeout) { + this.startTimeout(item.options.timeout.duration, item.options.timeout.defaultActionId); + } + } + + private startTimeout(duration: number, defaultActionId: string): void { + this.timeRemaining = duration; + this.progressPercent = 100; + + const startTime = Date.now(); + const updateInterval = 50; // Update every 50ms for smooth animation + + this.timeoutInterval = setInterval(() => { + const elapsed = Date.now() - startTime; + this.timeRemaining = Math.max(0, duration - elapsed); + this.progressPercent = (this.timeRemaining / duration) * 100; + + if (this.timeRemaining <= 0) { + this.clearTimeoutInterval(); + this.handleAction(defaultActionId, true); + } + }, updateInterval); + } + + private clearTimeoutInterval(): void { + if (this.timeoutInterval) { + clearInterval(this.timeoutInterval); + this.timeoutInterval = null; + } + } + + private handleAction(actionId: string, timedOut: boolean): void { + this.clearTimeoutInterval(); + + if (this.currentResolve) { + this.currentResolve({ actionId, timedOut }); + } + + // Process next item in queue + this.processQueue(); + } + + private handleDismiss(): void { + this.handleAction('dismissed', false); + } + + public async disconnectedCallback(): Promise { + await super.disconnectedCallback(); + this.clearTimeoutInterval(); + } +} diff --git a/ts_web/elements/dees-actionbar/index.ts b/ts_web/elements/dees-actionbar/index.ts new file mode 100644 index 0000000..b32bd7f --- /dev/null +++ b/ts_web/elements/dees-actionbar/index.ts @@ -0,0 +1,2 @@ +export * from './dees-actionbar.js'; +export * from './actionbar.interfaces.js'; diff --git a/ts_web/elements/index.ts b/ts_web/elements/index.ts index 0ce20e6..da77ef4 100644 --- a/ts_web/elements/index.ts +++ b/ts_web/elements/index.ts @@ -14,6 +14,7 @@ export * from './00group-runtime/index.js'; export * from './00group-simple/index.js'; // Standalone Components +export * from './dees-actionbar/index.js'; export * from './dees-badge/index.js'; export * from './dees-chips/index.js'; export * from './dees-contextmenu/index.js';