package tui import ( "context" "fmt" tea "charm.land/bubbletea/v2" ) // operationCancelledError means the TUI requested cancellation and did not // return control until the operation worker had produced its terminal result // and all output had been drained. type operationCancelledError struct { cause error } func (e *operationCancelledError) Error() string { if e.cause == nil { return "operation cancelled" } return fmt.Sprintf("operation cancelled after worker shutdown: %v", e.cause) } func (e *operationCancelledError) Unwrap() error { if e.cause == nil { return context.Canceled } return e.cause } // A pending read belongs to the model even if Bubble Tea stops before delivering // its notification. Closing ready publishes msg to both the UI and exit drainer. type operationRead struct { ready chan struct{} msg tea.Msg } type operationReadyMsg struct{ read *operationRead } func (m *Model) ownOperation(streams saveChannelsMsg) tea.Cmd { m.operationActive = true m.operationTerminal = streams.terminal m.installComplete = false m.outputChan = streams.outputChan m.installDone = streams.installDone m.progressChan = streams.progressChan m.managerPreparedChan = streams.preparedChan m.operationCancel = streams.cancel m.operationCommitGuard = streams.commitGuard m.operationResult = nil m.operationResultReceived = false if m.operationCancelRequested || m.operationQuitAfter { m.requestOperationCancellation(m.operationReturnToMenu, m.operationQuitAfter) } return m.nextOperationStreamCmd() } // nextOperationStreamCmd maintains exactly one outstanding channel reader. A // terminal result is not delivered to Update until output/progress/prepared // streams are closed and drained, eliminating the old select race that could // either lose trailing output or leave the UI permanently installing. func (m *Model) nextOperationStreamCmd() tea.Cmd { if m.operationRead != nil { return m.operationRead.command() } read := &operationRead{ready: make(chan struct{})} m.operationRead = read auxiliaryDrained := m.outputChan == nil && m.progressChan == nil && m.managerPreparedChan == nil if m.operationResultReceived && auxiliaryDrained { if m.operationTerminal != nil { read.msg = m.operationTerminal(m.operationResult) } else { read.msg = cmdDoneMsg{err: m.operationResult} } close(read.ready) } else if auxiliaryDrained && m.installDone == nil { read.msg = cmdDoneMsg{err: fmt.Errorf("operation stream closed without a terminal result")} close(read.ready) } else { stream := startStreaming(m.outputChan, m.installDone, m.progressChan, m.managerPreparedChan) go func() { read.msg = stream() close(read.ready) }() } return read.command() } func (r *operationRead) command() tea.Cmd { return func() tea.Msg { <-r.ready return operationReadyMsg{read: r} } } func (m *Model) requestOperationCancellation(returnToMenu, quitAfter bool) { m.operationCancelRequested = m.operationCommitGuard.cancel(m.operationCancel) m.operationReturnToMenu = m.operationReturnToMenu || returnToMenu m.operationQuitAfter = m.operationQuitAfter || quitAfter m.currentCommand = "Cancelling operation; waiting for worker shutdown..." if !m.operationCancelRequested { m.currentCommand = "Finishing installation commit; waiting for the worker..." } } func (m *Model) clearOperationRuntime() { m.operationActive = false m.operationTerminal = nil m.operationRead = nil if m.operationCancel != nil { m.operationCancel() } m.outputChan = nil m.installDone = nil m.progressChan = nil m.managerPreparedChan = nil m.operationCancel = nil m.operationCommitGuard = nil m.operationResult = nil m.operationResultReceived = false m.operationCancelRequested = false m.operationReturnToMenu = false m.operationQuitAfter = false }