diff --git a/src/index.ts b/src/index.ts index dc82778..97f7e40 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ export * from './Dashboard'; export * from './widgets'; export * from './interactions'; -export * from './types/blessed-extensions'; \ No newline at end of file +export * from './types/blessed-extensions'; +export * from './types/service-queue'; \ No newline at end of file diff --git a/src/types/service-queue.ts b/src/types/service-queue.ts new file mode 100644 index 0000000..3ea8f53 --- /dev/null +++ b/src/types/service-queue.ts @@ -0,0 +1,148 @@ +/** + * Shared types for service queue management + * + * Provides consistent service representation across widgets + */ + +import type { ServiceStatus } from '../widgets/ServiceList' + +/** + * Service item for rendering in lists + */ +export interface QueuedServiceInfo { + id: string + feature: string + name: string + status: ServiceStatus + port?: number + queued: boolean +} + +/** + * Queue state manager for services + */ +export class ServiceQueue { + private queuedServices = new Set() + private callbacks: Array<(serviceId: string, queued: boolean) => void> = [] + + /** + * Add a service to the queue + */ + add(serviceId: string): void { + if (!this.queuedServices.has(serviceId)) { + this.queuedServices.add(serviceId) + this.notifyCallbacks(serviceId, true) + } + } + + /** + * Remove a service from the queue + */ + remove(serviceId: string): void { + if (this.queuedServices.has(serviceId)) { + this.queuedServices.delete(serviceId) + this.notifyCallbacks(serviceId, false) + } + } + + /** + * Toggle service queue state + */ + toggle(serviceId: string): boolean { + if (this.queuedServices.has(serviceId)) { + this.remove(serviceId) + return false + } else { + this.add(serviceId) + return true + } + } + + /** + * Check if service is queued + */ + has(serviceId: string): boolean { + return this.queuedServices.has(serviceId) + } + + /** + * Get all queued service IDs + */ + getAll(): string[] { + return Array.from(this.queuedServices) + } + + /** + * Clear all queued services + */ + clear(): void { + const previous = this.getAll() + this.queuedServices.clear() + previous.forEach(id => this.notifyCallbacks(id, false)) + } + + /** + * Subscribe to queue changes + */ + onChange(callback: (serviceId: string, queued: boolean) => void): () => void { + this.callbacks.push(callback) + return () => { + this.callbacks = this.callbacks.filter(cb => cb !== callback) + } + } + + private notifyCallbacks(serviceId: string, queued: boolean): void { + this.callbacks.forEach(cb => cb(serviceId, queued)) + } +} + +/** + * Format service as list item with status symbol and color + */ +export function formatServiceListItem( + service: { id: string; name: string; status: ServiceStatus; port?: number; queued?: boolean }, + options: { showPort?: boolean; showQueuedIndicator?: boolean } = {} +): string { + const symbol = getServiceSymbol(service.status, service.queued) + const color = getServiceColor(service.status, service.queued) + const port = options.showPort && service.port ? ` {gray-fg}:${service.port}{/}` : '' + const queuedMark = options.showQueuedIndicator && service.queued ? ' {blue-fg}[queued]{/}' : '' + + return `{${color}-fg}${symbol}{/} ${service.name}${port}${queuedMark}` +} + +/** + * Get status symbol for service + */ +export function getServiceSymbol(status: ServiceStatus, queued?: boolean): string { + if (queued) return '◎' // Queued for startup + + switch (status) { + case 'available': return '◯' // Available to start + case 'pending': return '○' // Not started + case 'starting': return '◐' // Starting + case 'running': + case 'healthy': return '●' // Running/healthy + case 'failed': return '✗' // Failed + case 'skipped': return '⊘' // Skipped + default: return '?' + } +} + +/** + * Get status color for service + */ +export function getServiceColor(status: ServiceStatus, queued?: boolean): string { + if (queued) return 'cyan' // Cyan for queued + + switch (status) { + case 'available': return 'blue' + case 'pending': return 'gray' + case 'starting': return 'yellow' + case 'running': + case 'healthy': return 'green' + case 'failed': return 'red' + case 'skipped': return 'gray' + default: return 'white' + } +} diff --git a/src/widgets/ServiceList.ts b/src/widgets/ServiceList.ts index 155cbca..c6bf2bd 100644 --- a/src/widgets/ServiceList.ts +++ b/src/widgets/ServiceList.ts @@ -11,6 +11,8 @@ import * as blessed from 'blessed' import type { ExtendedBoxElement } from '../types/blessed-extensions' +import type { ServiceQueue } from '../types/service-queue' +import { getServiceSymbol, getServiceColor } from '../types/service-queue' // ============================================================================= // Types @@ -44,6 +46,10 @@ export interface ServiceListOptions { collapsible?: boolean /** Start features collapsed (default: true) */ startCollapsed?: boolean + /** Service queue manager for tracking queued services */ + serviceQueue?: ServiceQueue + /** Callback when service is queued/dequeued */ + onServiceQueueChange?: (serviceId: string, queued: boolean) => void /** Box options for the widget */ boxOptions?: blessed.Widgets.BoxOptions } @@ -59,6 +65,7 @@ export class ServiceList { private featureOrder: string[] = [] private sortedFeatureOrder: string[] = [] private selectedFeature: string | null = null + private selectedServiceIndex: number = -1 // Index within expanded feature (-1 = feature header) private lastSortTime: number = 0 private readonly SORT_THROTTLE_MS = 30000 // 30 seconds @@ -66,12 +73,16 @@ export class ServiceList { private sortByStatus: boolean private collapsible: boolean private startCollapsed: boolean + private serviceQueue?: ServiceQueue + private onServiceQueueChange?: (serviceId: string, queued: boolean) => void constructor(parent: blessed.Widgets.Screen | blessed.Widgets.Node, options: ServiceListOptions = {}) { this.groupByFeature = options.groupByFeature ?? true this.sortByStatus = options.sortByStatus ?? true this.collapsible = options.collapsible ?? true this.startCollapsed = options.startCollapsed ?? true + this.serviceQueue = options.serviceQueue + this.onServiceQueueChange = options.onServiceQueueChange this.box = blessed.box({ parent, @@ -92,6 +103,54 @@ export class ServiceList { clickable: true, ...options.boxOptions, }) as ExtendedBoxElement + + this.setupKeyBindings() + } + + private setupKeyBindings(): void { + // 'a' or 'Enter' to add/toggle service in queue + this.box.key(['a', 'enter'], () => { + this.toggleServiceQueue() + }) + + // Space to toggle feature expand/collapse + this.box.key(['space'], () => { + this.toggleFeatureExpand() + }) + } + + private toggleServiceQueue(): void { + if (!this.serviceQueue) return + if (!this.selectedFeature) return + + const group = this.featureGroups.get(this.selectedFeature) + if (!group || !group.expanded) { + // Feature header selected or collapsed - queue all services in feature + if (group) { + const allQueued = group.services.every(s => this.serviceQueue!.has(s.id)) + group.services.forEach(s => { + if (allQueued) { + this.serviceQueue!.remove(s.id) + } else { + this.serviceQueue!.add(s.id) + } + }) + if (this.onServiceQueueChange) { + group.services.forEach(s => { + this.onServiceQueueChange!(s.id, !allQueued) + }) + } + } + } else if (this.selectedServiceIndex >= 0 && this.selectedServiceIndex < group.services.length) { + // Service selected - toggle its queue state + const service = group.services[this.selectedServiceIndex] + const queued = this.serviceQueue.toggle(service.id) + if (this.onServiceQueueChange) { + this.onServiceQueueChange(service.id, queued) + } + } + + this.render() } // --------------------------------------------------------------------------- @@ -167,19 +226,53 @@ export class ServiceList { } /** - * Navigate to next/previous feature (relative to current visual order) + * Navigate to next/previous item (feature or service, relative to current visual order) */ navigate(direction: number): void { // Use the current sorted visual order for navigation const currentOrder = this.sortedFeatureOrder.length > 0 ? this.sortedFeatureOrder : this.featureOrder if (currentOrder.length === 0) return + // Initialize selection if needed if (this.selectedFeature === null) { this.selectedFeature = currentOrder[0] + this.selectedServiceIndex = -1 + this.render() + this.scrollToSelectedFeature() + return + } + + const currentFeatureIndex = currentOrder.indexOf(this.selectedFeature) + const currentGroup = this.featureGroups.get(this.selectedFeature) + + if (direction > 0) { + // Navigate down + if (currentGroup?.expanded && this.selectedServiceIndex < currentGroup.services.length - 1) { + // Move to next service in expanded feature + this.selectedServiceIndex++ + } else { + // Move to next feature + if (currentFeatureIndex < currentOrder.length - 1) { + this.selectedFeature = currentOrder[currentFeatureIndex + 1] + this.selectedServiceIndex = -1 + } + } } else { - const currentIndex = currentOrder.indexOf(this.selectedFeature) - const newIndex = Math.max(0, Math.min(currentOrder.length - 1, currentIndex + direction)) - this.selectedFeature = currentOrder[newIndex] + // Navigate up + if (this.selectedServiceIndex > -1) { + // Move to previous service or feature header + this.selectedServiceIndex-- + } else if (currentFeatureIndex > 0) { + // Move to previous feature + this.selectedFeature = currentOrder[currentFeatureIndex - 1] + const prevGroup = this.featureGroups.get(this.selectedFeature) + if (prevGroup?.expanded) { + // Select last service in previous expanded feature + this.selectedServiceIndex = prevGroup.services.length - 1 + } else { + this.selectedServiceIndex = -1 + } + } } this.render() @@ -300,14 +393,18 @@ export class ServiceList { }) : group.services - for (const service of sortedServices) { - const symbol = this.getStatusSymbol(service.status) - const color = this.getStatusColor(service.status) + for (let i = 0; i < sortedServices.length; i++) { + const service = sortedServices[i] + const queued = this.serviceQueue?.has(service.id) ?? false + const symbol = getServiceSymbol(service.status, queued) + const color = getServiceColor(service.status, queued) const port = service.port ? `{gray-fg}:${service.port}{/}` : '' const duration = service.duration ? ` {gray-fg}${service.duration}ms{/}` : '' const errorMsg = service.error ? ` {red-fg}${service.error.slice(0, 30)}{/}` : '' + const selected = this.selectedFeature === featureId && this.selectedServiceIndex === i ? '{inverse}' : '' + const selectedEnd = this.selectedFeature === featureId && this.selectedServiceIndex === i ? '{/inverse}' : '' - lines.push(` {${color}-fg}${symbol}{/} ${service.name}${port}${duration}${errorMsg}`) + lines.push(`${selected} {${color}-fg}${symbol}{/} ${service.name}${port}${duration}${errorMsg}${selectedEnd}`) } } } @@ -358,29 +455,4 @@ export class ServiceList { return ['unknown', id] } - private getStatusSymbol(status: ServiceStatus): string { - switch (status) { - case 'available': return '◯' // Hollow circle for available - case 'pending': return '○' - case 'starting': return '◐' - case 'running': - case 'healthy': return '●' - case 'failed': return '✗' - case 'skipped': return '⊘' - default: return '?' - } - } - - private getStatusColor(status: ServiceStatus): string { - switch (status) { - case 'available': return 'blue' // Blue for available services - case 'pending': return 'gray' - case 'starting': return 'yellow' - case 'running': - case 'healthy': return 'green' - case 'failed': return 'red' - case 'skipped': return 'gray' - default: return 'white' - } - } } diff --git a/src/widgets/ServicePickerModal.ts b/src/widgets/ServicePickerModal.ts index a48b44b..a64751d 100644 --- a/src/widgets/ServicePickerModal.ts +++ b/src/widgets/ServicePickerModal.ts @@ -108,14 +108,15 @@ export class ServicePickerModal { }) // Service list below search + // Note: keys/vi disabled - we handle navigation manually for consistency this.listBox = blessed.list({ parent: this.container, top: 3, left: 2, right: 2, bottom: 3, - keys: true, - vi: true, + keys: false, + vi: false, mouse: true, tags: true, style: { diff --git a/src/widgets/ShutdownModal.ts b/src/widgets/ShutdownModal.ts new file mode 100644 index 0000000..ca4adc4 --- /dev/null +++ b/src/widgets/ShutdownModal.ts @@ -0,0 +1,279 @@ +/** + * ShutdownModal Widget - Cleanup progress modal for graceful shutdown + * + * Features: + * - Shows cleanup progress when quitting + * - Displays individual cleanup tasks with status + * - Spinner animation during active cleanup + * - Cancel option (Escape) to abort shutdown + * - Proceeds to exit after all cleanup completes + */ + +import * as blessed from 'blessed' +import type { ExtendedBoxElement } from '../types/blessed-extensions' + +// ============================================================================= +// Types +// ============================================================================= + +export type CleanupTaskStatus = 'pending' | 'running' | 'done' | 'failed' | 'skipped' + +export interface CleanupTask { + id: string + label: string + status: CleanupTaskStatus + error?: string +} + +export interface ShutdownModalOptions { + /** Cleanup tasks to perform */ + tasks: CleanupTask[] + /** Called for each task to execute */ + onExecuteTask: (taskId: string) => Promise + /** Called when all cleanup is complete */ + onComplete: () => void + /** Called if shutdown is cancelled */ + onCancel?: () => void +} + +// ============================================================================= +// ShutdownModal Widget +// ============================================================================= + +export class ShutdownModal { + private screen: blessed.Widgets.Screen + private container: ExtendedBoxElement + private titleBox: blessed.Widgets.BoxElement + private taskList: blessed.Widgets.BoxElement + private statusBar: blessed.Widgets.BoxElement + + private tasks: CleanupTask[] + private onExecuteTask: (taskId: string) => Promise + private onComplete: () => void + private onCancel?: () => void + + private isRunning = false + private isCancelled = false + private spinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] + private spinnerIndex = 0 + private spinnerInterval: ReturnType | null = null + + constructor(screen: blessed.Widgets.Screen, options: ShutdownModalOptions) { + this.screen = screen + this.tasks = options.tasks.map(t => ({ ...t })) + this.onExecuteTask = options.onExecuteTask + this.onComplete = options.onComplete + this.onCancel = options.onCancel + + // Container (centered modal) + this.container = blessed.box({ + parent: screen, + top: 'center', + left: 'center', + width: 50, + height: this.tasks.length + 8, + border: { type: 'line' }, + style: { + border: { fg: 'yellow' }, + bg: 'black', + }, + label: ' Shutting Down ', + tags: true, + shadow: true, + }) as ExtendedBoxElement + + // Title/spinner area + this.titleBox = blessed.box({ + parent: this.container, + top: 1, + left: 2, + right: 2, + height: 2, + style: { + fg: 'yellow', + bg: 'black', + }, + tags: true, + }) + + // Task list + this.taskList = blessed.box({ + parent: this.container, + top: 4, + left: 2, + right: 2, + height: this.tasks.length + 1, + style: { + fg: 'white', + bg: 'black', + }, + tags: true, + }) + + // Status bar + this.statusBar = blessed.box({ + parent: this.container, + bottom: 0, + left: 0, + right: 0, + height: 1, + style: { + fg: 'white', + bg: 'blue', + }, + tags: true, + }) + + this.setupKeyBindings() + this.updateTitle() + this.renderTasks() + this.updateStatusBar() + } + + // --------------------------------------------------------------------------- + // Public API + // --------------------------------------------------------------------------- + + async show(): Promise { + this.container.show() + this.container.focus() + this.screen.render() + + // Start spinner + this.spinnerInterval = setInterval(() => { + this.spinnerIndex = (this.spinnerIndex + 1) % this.spinnerFrames.length + this.updateTitle() + this.screen.render() + }, 80) + + // Execute cleanup + await this.executeCleanup() + } + + hide(): void { + if (this.spinnerInterval) { + clearInterval(this.spinnerInterval) + this.spinnerInterval = null + } + this.container.hide() + this.screen.render() + } + + destroy(): void { + if (this.spinnerInterval) { + clearInterval(this.spinnerInterval) + this.spinnerInterval = null + } + this.container.destroy() + this.screen.render() + } + + // --------------------------------------------------------------------------- + // Private Methods + // --------------------------------------------------------------------------- + + private setupKeyBindings(): void { + // Escape to cancel (only during cleanup) + this.container.key(['escape', 'C-c'], () => { + if (this.isRunning && !this.isCancelled) { + this.isCancelled = true + this.updateStatusBar('Cancelling...') + if (this.onCancel) { + this.hide() + this.onCancel() + } + } + }) + } + + private async executeCleanup(): Promise { + this.isRunning = true + + for (const task of this.tasks) { + if (this.isCancelled) break + + task.status = 'running' + this.renderTasks() + this.screen.render() + + try { + await this.onExecuteTask(task.id) + task.status = 'done' + } catch (err) { + task.status = 'failed' + task.error = err instanceof Error ? err.message : String(err) + } + + this.renderTasks() + this.screen.render() + } + + this.isRunning = false + + // Brief pause to show final state + await new Promise(resolve => setTimeout(resolve, 300)) + + if (!this.isCancelled) { + this.hide() + this.onComplete() + } + } + + private updateTitle(): void { + const spinner = this.isRunning ? this.spinnerFrames[this.spinnerIndex] : '✓' + const status = this.isCancelled + ? 'Cancelled' + : this.isRunning + ? 'Cleaning up...' + : 'Complete' + + this.titleBox.setContent( + `{center}{bold}${spinner} ${status}{/}{/center}\n` + + `{center}{gray-fg}Performing cleanup tasks{/}{/center}` + ) + } + + private renderTasks(): void { + const lines = this.tasks.map(task => { + const symbol = this.getTaskSymbol(task.status) + const color = this.getTaskColor(task.status) + const errorSuffix = task.error ? ` {red-fg}(${task.error}){/}` : '' + return ` {${color}-fg}${symbol}{/} ${task.label}${errorSuffix}` + }) + + this.taskList.setContent(lines.join('\n')) + } + + private updateStatusBar(message?: string): void { + if (message) { + this.statusBar.setContent(` ${message}`) + } else { + this.statusBar.setContent( + ` {bold}[Esc]{/} Cancel {|} Press Esc to abort shutdown` + ) + } + this.screen.render() + } + + private getTaskSymbol(status: CleanupTaskStatus): string { + switch (status) { + case 'pending': return '○' + case 'running': return this.spinnerFrames[this.spinnerIndex] + case 'done': return '✓' + case 'failed': return '✗' + case 'skipped': return '⊘' + default: return '?' + } + } + + private getTaskColor(status: CleanupTaskStatus): string { + switch (status) { + case 'pending': return 'gray' + case 'running': return 'yellow' + case 'done': return 'green' + case 'failed': return 'red' + case 'skipped': return 'gray' + default: return 'white' + } + } +} diff --git a/src/widgets/index.ts b/src/widgets/index.ts index 5ef3903..902b054 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -5,3 +5,4 @@ export * from './StatusBar' export * from './ProgressPanel' export * from './RoadmapPanel' export * from './ServicePickerModal' +export * from './ShutdownModal'