chore(widgets): 🔧 Add ServiceList, ServicePickerModal, ShutdownModal with service queue management capabilities
This commit is contained in:
parent
718b3472af
commit
db0544e2f7
6 changed files with 538 additions and 36 deletions
|
|
@ -1,4 +1,5 @@
|
|||
export * from './Dashboard';
|
||||
export * from './widgets';
|
||||
export * from './interactions';
|
||||
export * from './types/blessed-extensions';
|
||||
export * from './types/blessed-extensions';
|
||||
export * from './types/service-queue';
|
||||
148
src/types/service-queue.ts
Normal file
148
src/types/service-queue.ts
Normal file
|
|
@ -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<string>()
|
||||
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'
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
279
src/widgets/ShutdownModal.ts
Normal file
279
src/widgets/ShutdownModal.ts
Normal file
|
|
@ -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<void>
|
||||
/** 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<void>
|
||||
private onComplete: () => void
|
||||
private onCancel?: () => void
|
||||
|
||||
private isRunning = false
|
||||
private isCancelled = false
|
||||
private spinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
||||
private spinnerIndex = 0
|
||||
private spinnerInterval: ReturnType<typeof setInterval> | 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<void> {
|
||||
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<void> {
|
||||
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'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,3 +5,4 @@ export * from './StatusBar'
|
|||
export * from './ProgressPanel'
|
||||
export * from './RoadmapPanel'
|
||||
export * from './ServicePickerModal'
|
||||
export * from './ShutdownModal'
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue