// Package tui provides the backup picker component. package tui import ( "fmt" "strings" "time" "github.com/charmbracelet/lipgloss" "github.com/lukaszraczylo/lolcathost/internal/protocol" ) // BackupMode represents the backup view mode. type BackupMode int const ( BackupModeSelect BackupMode = iota BackupModeConfirmRestore ) // BackupPicker handles the backup selection and restore UI. type BackupPicker struct { backups []protocol.BackupInfo cursor int width int height int mode BackupMode previewContent string previewScroll int } // NewBackupPicker creates a new backup picker. func NewBackupPicker() *BackupPicker { return &BackupPicker{ mode: BackupModeSelect, } } // SetBackups updates the available backups. func (b *BackupPicker) SetBackups(backups []protocol.BackupInfo) { b.backups = backups if b.cursor >= len(backups) { b.cursor = max(0, len(backups)-1) } } // SetSize sets the picker dimensions. func (b *BackupPicker) SetSize(width, height int) { b.width = width b.height = height } // MoveUp moves the cursor up. func (b *BackupPicker) MoveUp() { if b.cursor > 0 { b.cursor-- b.previewContent = "" // Clear preview to trigger reload b.previewScroll = 0 } } // MoveDown moves the cursor down. func (b *BackupPicker) MoveDown() { if b.cursor < len(b.backups)-1 { b.cursor++ b.previewContent = "" // Clear preview to trigger reload b.previewScroll = 0 } } // SetPreviewContent sets the preview content for the current backup. func (b *BackupPicker) SetPreviewContent(content string) { b.previewContent = content b.previewScroll = 0 } // PreviewContent returns the current preview content. func (b *BackupPicker) PreviewContent() string { return b.previewContent } // ScrollPreviewUp scrolls the preview up. func (b *BackupPicker) ScrollPreviewUp() { if b.previewScroll > 0 { b.previewScroll-- } } // ScrollPreviewDown scrolls the preview down. func (b *BackupPicker) ScrollPreviewDown() { b.previewScroll++ } // Selected returns the currently selected backup name. func (b *BackupPicker) Selected() string { if b.cursor >= 0 && b.cursor < len(b.backups) { return b.backups[b.cursor].Name } return "" } // SelectedInfo returns the currently selected backup info. func (b *BackupPicker) SelectedInfo() *protocol.BackupInfo { if b.cursor >= 0 && b.cursor < len(b.backups) { return &b.backups[b.cursor] } return nil } // Len returns the number of backups. func (b *BackupPicker) Len() int { return len(b.backups) } // Mode returns the current mode. func (b *BackupPicker) Mode() BackupMode { return b.mode } // InitRestore starts restore confirmation. func (b *BackupPicker) InitRestore() { if b.SelectedInfo() == nil { return } b.mode = BackupModeConfirmRestore } // Cancel cancels the current operation. func (b *BackupPicker) Cancel() { b.mode = BackupModeSelect } // View renders the backup picker. func (b *BackupPicker) View() string { switch b.mode { case BackupModeConfirmRestore: return b.restoreView() default: return b.selectView() } } func (b *BackupPicker) selectView() string { if len(b.backups) == 0 { var sb strings.Builder sb.WriteString(titleStyle.Render("Backups")) sb.WriteString("\n\n") sb.WriteString(helpDescStyle.Render("No backups available.")) sb.WriteString("\n\n") sb.WriteString(helpDescStyle.Render("Backups are created automatically when hosts are modified.")) sb.WriteString("\n\n") sb.WriteString(helpDescStyle.Render("Esc cancel")) return dialogStyle.Render(sb.String()) } // Build left panel (backup list) var leftSb strings.Builder leftSb.WriteString(titleStyle.Render("Backups")) leftSb.WriteString("\n\n") leftSb.WriteString(helpDescStyle.Render(fmt.Sprintf("%d backup(s)", len(b.backups)))) leftSb.WriteString("\n\n") for i, backup := range b.backups { timestamp := time.Unix(backup.Timestamp, 0).Format("2006-01-02 15:04:05") sizeStr := formatSize(backup.Size) line := fmt.Sprintf("%s (%s)", timestamp, sizeStr) if i == b.cursor { leftSb.WriteString(presetSelectedStyle.Render("▸ " + line)) } else { leftSb.WriteString(presetItemStyle.Render(" " + line)) } leftSb.WriteString("\n") } leftSb.WriteString("\n") leftSb.WriteString(WrapHelpText("↑↓ navigate • Enter restore • Esc cancel", 40)) // Build right panel (preview) var rightSb strings.Builder rightSb.WriteString(titleStyle.Render("Preview")) rightSb.WriteString("\n\n") if b.previewContent == "" { rightSb.WriteString(helpDescStyle.Render("Loading...")) } else { // Show content with scroll support lines := strings.Split(b.previewContent, "\n") previewHeight := b.height - 12 // Reserve space for title, borders, help if previewHeight < 5 { previewHeight = 5 } // Clamp scroll position maxScroll := len(lines) - previewHeight if maxScroll < 0 { maxScroll = 0 } if b.previewScroll > maxScroll { b.previewScroll = maxScroll } // Get visible lines endLine := b.previewScroll + previewHeight if endLine > len(lines) { endLine = len(lines) } visibleLines := lines[b.previewScroll:endLine] for _, line := range visibleLines { // Truncate long lines if len(line) > 50 { line = line[:47] + "..." } rightSb.WriteString(helpDescStyle.Render(line)) rightSb.WriteString("\n") } // Show scroll indicator if len(lines) > previewHeight { rightSb.WriteString("\n") scrollText := fmt.Sprintf("%d-%d of %d (↑↓ scroll)", b.previewScroll+1, endLine, len(lines)) rightSb.WriteString(helpDescStyle.Render(scrollText)) } } // Style the panels leftWidth := 45 rightWidth := b.width - leftWidth - 10 if rightWidth < 30 { rightWidth = 30 } leftPanel := lipgloss.NewStyle(). Width(leftWidth). Border(lipgloss.RoundedBorder()). BorderForeground(colorAccent). Padding(1, 2). Render(leftSb.String()) rightPanel := lipgloss.NewStyle(). Width(rightWidth). Border(lipgloss.RoundedBorder()). BorderForeground(colorMuted). Padding(1, 2). Render(rightSb.String()) return lipgloss.JoinHorizontal(lipgloss.Top, leftPanel, " ", rightPanel) } func (b *BackupPicker) restoreView() string { var sb strings.Builder backup := b.SelectedInfo() timestamp := "" if backup != nil { timestamp = time.Unix(backup.Timestamp, 0).Format("2006-01-02 15:04:05") } sb.WriteString(titleStyle.Render("Restore Backup")) sb.WriteString("\n\n") sb.WriteString(errorMsgStyle.Render(fmt.Sprintf("Restore /etc/hosts from backup '%s'?", timestamp))) sb.WriteString("\n\n") sb.WriteString(helpDescStyle.Render("This will replace your current hosts file.")) sb.WriteString("\n\n") sb.WriteString(helpDescStyle.Render("y confirm • n/Esc cancel")) return dialogStyle.Render(sb.String()) } // formatSize formats bytes to human readable format. func formatSize(bytes int64) string { const ( KB = 1024 MB = KB * 1024 ) switch { case bytes >= MB: return fmt.Sprintf("%.1f MB", float64(bytes)/float64(MB)) case bytes >= KB: return fmt.Sprintf("%.1f KB", float64(bytes)/float64(KB)) default: return fmt.Sprintf("%d B", bytes) } }