package tui import ( "errors" "fmt" "strings" "time" "charm.land/bubbles/v2/list" tea "charm.land/bubbletea/v2" "gitgud.io/mike/mpv-manager/pkg/jobhistory" "gitgud.io/mike/mpv-manager/pkg/log" ) // jobHistoryRecord is the shared on-disk record written to job-history.json // in the mpv config directory by both the Web UI job manager and the TUI. // Only terminal jobs (complete/error/partial/cancelled) are persisted. type jobHistoryRecord = jobhistory.Record // partialOperationError records the important distinction between a failed // operation and a completed physical mutation whose authoritative metadata // could not be persisted. The latter requires reconciliation and must never // appear as either unconditional success or an ordinary retry-safe failure. type partialOperationError struct { operation string cause error guidance string } func (e *partialOperationError) Error() string { return fmt.Sprintf("%s completed, but authoritative state persistence failed: %v", e.operation, e.cause) } func (e *partialOperationError) Unwrap() error { return e.cause } // jobHistoryFilePath resolves the job history file in the mpv config dir. func jobHistoryFilePath() (string, error) { return jobhistory.FilePath() } // loadJobHistoryRecords reads the persisted job history, newest first. A // missing file yields an empty history; a corrupt file yields an error. func loadJobHistoryRecords(path string) ([]jobHistoryRecord, error) { return jobhistory.NewStore(path).LoadRecords() } // recordJobHistory persists a finished TUI install/uninstall/update operation // to the shared job history store, so it shows up in both the TUI // Installation History view and the Web UI job history. opErr is the // operation's result (nil on success). Persistence failures are logged only — // history recording must never break an install flow. func (m *Model) recordJobHistory(opErr error) { jobType := "" methodID := m.selectedMethodID appName := "" switch { case m.selectedUninstall != nil: jobType = "uninstall" methodID = m.selectedUninstall.InstallMethod appName = m.selectedUninstall.AppName case m.installOperationType == OpTypeInstall: if m.selectedMethodID == "" || m.selectedMethodID == "install-to-path" { return } jobType = "install" appName = getClientName(m.selectedMethodID, m.selectedMethod) case m.installOperationType == OpTypeUpdate: if m.selectedUpdateItem == nil { return } jobType = "update" methodID = m.selectedUpdateItem.MethodID appName = m.selectedUpdateItem.AppName case m.installOperationType == OpTypeUIChange: if m.selectedUIApp == nil { return } jobType = "ui-change" methodID = m.selectedUIApp.InstallMethod appName = m.selectedUIApp.AppName default: // Config, PATH, and manager self-update operations are not app jobs. return } startedAt := m.installStartedAt if startedAt.IsZero() { startedAt = time.Now() } now := time.Now() record := jobhistory.Record{ ID: jobhistory.NewID(), Type: jobType, MethodID: methodID, AppName: appName, StartedAt: startedAt, CompletedAt: &now, Output: sanitizedOutputLines(m.installOutput), } var partialErr *partialOperationError var cancelledErr *operationCancelledError if errors.As(opErr, &partialErr) { record.Status = "partial" record.Progress = 100 record.Message = jobTypeLabel(jobType) + " completed; reconciliation required" record.Error = partialErr.Error() record.ErrorDetails = partialErr.guidance } else if errors.As(opErr, &cancelledErr) { record.Status = "cancelled" record.Progress = m.installProgress record.Message = jobTypeLabel(jobType) + " cancelled after worker shutdown" record.ErrorDetails = "All output was drained before the TUI released operation ownership. Verify external package-manager state before retrying if mutation had begun." } else if opErr != nil { record.Status = "error" record.Progress = m.installProgress record.Message = jobTypeLabel(jobType) + " failed" record.Error = opErr.Error() } else { record.Status = "complete" record.Progress = 100 record.Message = jobTypeLabel(jobType) + " completed successfully" } path, err := jobHistoryFilePath() if err != nil { log.Warn("Job history recording disabled, cannot resolve mpv config dir: " + err.Error()) return } if err := jobhistory.NewStore(path).Append(record); err != nil { log.Warn("Failed to record job history: " + err.Error()) } } // jobHistoryItem implements list.Item for a persisted job record. type jobHistoryItem struct { record *jobHistoryRecord } func jobStatusIcon(status string) string { switch status { case "complete": return "✓" case "error": return "✗" case "partial": return "⚠" case "cancelled": return "⊘" default: return "•" } } func jobTypeLabel(jobType string) string { switch jobType { case "install": return "Install" case "uninstall": return "Uninstall" case "update": return "Update" case "ui-change": return "UI change" default: return "Job" } } func (j jobHistoryItem) Title() string { name := sanitizeTerminalText(j.record.AppName) if name == "" { name = sanitizeTerminalText(j.record.MethodID) } return fmt.Sprintf("%s %s %s", jobStatusIcon(j.record.Status), jobTypeLabel(j.record.Type), name) } func (j jobHistoryItem) Description() string { desc := j.record.StartedAt.Local().Format("2006-01-02 15:04:05") if (j.record.Status == "error" || j.record.Status == "partial") && j.record.Error != "" { return desc + " - " + sanitizeTerminalText(j.record.Error) } if j.record.Message != "" { return desc + " - " + sanitizeTerminalText(j.record.Message) } return desc } func (j jobHistoryItem) FilterValue() string { return sanitizeTerminalText(j.record.AppName + " " + j.record.MethodID + " " + j.record.Status) } // setupJobHistoryList loads the persisted job history and builds the list. func (m *Model) setupJobHistoryList() { m.jobHistoryLoadErr = "" path, err := jobHistoryFilePath() if err != nil { m.jobHistoryLoadErr = "Cannot resolve mpv config directory: " + err.Error() m.jobHistoryRecords = nil } else { records, err := loadJobHistoryRecords(path) if err != nil { m.jobHistoryLoadErr = "Failed to load job history: " + err.Error() records = nil } m.jobHistoryRecords = records } items := make([]list.Item, len(m.jobHistoryRecords)) for i := range m.jobHistoryRecords { items[i] = jobHistoryItem{record: &m.jobHistoryRecords[i]} } m.jobHistoryList = m.createStyledList("Installation History", items) } // handleJobHistoryUpdate handles the job history list state. func (m *Model) handleJobHistoryUpdate(msg tea.Msg) (*Model, tea.Cmd) { var cmd tea.Cmd switch msg := msg.(type) { case tea.KeyPressMsg: switch keyCode(msg) { case tea.KeyEsc: m.resetToMainMenu() return m, nil case tea.KeyEnter: selected := m.jobHistoryList.SelectedItem() if selected != nil { item := selected.(jobHistoryItem) m.selectedJobRecord = item.record m.jobDetailViewport.SetContent(m.jobDetailContent(item.record)) m.jobDetailViewport.GotoTop() m.state = StateJobHistoryDetail return m, nil } } } m.jobHistoryList, cmd = m.jobHistoryList.Update(msg) return m, cmd } // handleJobHistoryDetailUpdate handles the job detail scroll state. func (m *Model) handleJobHistoryDetailUpdate(msg tea.Msg) (*Model, tea.Cmd) { if keyMsg, ok := msg.(tea.KeyPressMsg); ok && keyCode(keyMsg) == tea.KeyEsc { m.selectedJobRecord = nil m.state = StateJobHistory return m, nil } var cmd tea.Cmd m.jobDetailViewport, cmd = m.jobDetailViewport.Update(msg) return m, cmd } // jobDetailContent renders a single job record for the detail viewport. func (m Model) jobDetailContent(record *jobHistoryRecord) string { var content strings.Builder content.WriteString(titleStyle.Render(jobTypeLabel(record.Type) + " " + sanitizeTerminalText(record.AppName))) content.WriteString("\n") content.WriteString(subtitleStyle.Render("══════════════════════")) content.WriteString("\n\n") content.WriteString(infoStyle.Render("Status: ")) status := record.Status + " " + jobStatusIcon(record.Status) if record.Status == "partial" { content.WriteString(warningStyle.Render(status)) } else if record.Status == "error" { content.WriteString(errorStyle.Render(status)) } else { content.WriteString(successStyle.Render(status)) } content.WriteString("\n") content.WriteString(infoStyle.Render("Started: " + record.StartedAt.Local().Format("2006-01-02 15:04:05"))) content.WriteString("\n") if record.CompletedAt != nil { content.WriteString(infoStyle.Render("Finished: " + record.CompletedAt.Local().Format("2006-01-02 15:04:05"))) content.WriteString("\n") } if record.MethodID != "" { content.WriteString(infoStyle.Render("Method: " + sanitizeTerminalText(record.MethodID))) content.WriteString("\n") } if record.Message != "" { content.WriteString(infoStyle.Render("Message: " + sanitizeTerminalText(record.Message))) content.WriteString("\n") } if record.Error != "" { content.WriteString("\n") style := errorStyle label := "Error: " if record.Status == "partial" { style = warningStyle label = "Reconciliation: " } content.WriteString(style.Render(label + sanitizeTerminalText(record.Error))) content.WriteString("\n") } if record.ErrorDetails != "" { style := errorStyle if record.Status == "partial" { style = warningStyle } content.WriteString(style.Render("Details: " + sanitizeTerminalText(record.ErrorDetails))) content.WriteString("\n") } content.WriteString("\n") if len(record.Output) == 0 { content.WriteString(infoStyle.Render("No output recorded.")) } else { content.WriteString(infoStyle.Render("Output:")) content.WriteString("\n") content.WriteString(strings.Join(sanitizedOutputLines(record.Output), "\n")) } return content.String() } // jobHistoryListView renders the job history list. func (m Model) jobHistoryListView() string { var content strings.Builder content.WriteString(titleStyle.Render("📜 Installation History")) content.WriteString("\n") content.WriteString(subtitleStyle.Render("═══════════════════════")) content.WriteString("\n\n") if m.jobHistoryLoadErr != "" { content.WriteString(errorStyle.Render(m.jobHistoryLoadErr)) content.WriteString("\n\n") } else if len(m.jobHistoryList.Items()) == 0 { content.WriteString(infoStyle.Render("No completed jobs yet.")) content.WriteString("\n") content.WriteString(infoStyle.Render("Install, update, and uninstall jobs are recorded here.")) content.WriteString("\n\n") } else { content.WriteString(m.jobHistoryList.View()) content.WriteString("\n") } content.WriteString(infoStyle.Render("Press ")) content.WriteString(subtitleStyle.Render("Enter")) content.WriteString(infoStyle.Render(" to view details, ")) content.WriteString(subtitleStyle.Render("Esc")) content.WriteString(infoStyle.Render(" to go back")) return content.String() } // jobHistoryDetailView renders the selected job record with scrolling. func (m Model) jobHistoryDetailView() string { var content strings.Builder content.WriteString(m.jobDetailViewport.View()) content.WriteString("\n") content.WriteString(infoStyle.Render("Press ")) content.WriteString(subtitleStyle.Render("↑/↓")) content.WriteString(infoStyle.Render(" to scroll, ")) content.WriteString(subtitleStyle.Render("Esc")) content.WriteString(infoStyle.Render(" to go back")) return content.String() }