diff --git a/cmd/cloudstic/cmd_tui_activity.go b/cmd/cloudstic/cmd_tui_activity.go index 695b887..2e9d345 100644 --- a/cmd/cloudstic/cmd_tui_activity.go +++ b/cmd/cloudstic/cmd_tui_activity.go @@ -26,8 +26,10 @@ func runTUIActionIntoDashboard(ctx context.Context, r *runner, profilesFile stri screen := r.out if profile, ok := selectedTUIProfile(dashboard); ok { if profileNeedsInit(profile) { + log.Start("Initialize store", fmt.Sprintf("profile %s", profile.Name)) log.Printf("Initializing store for profile %s", profile.Name) } else { + log.Start("Run backup", fmt.Sprintf("profile %s", profile.Name)) log.Printf("Running backup for profile %s", profile.Name) } } @@ -44,21 +46,23 @@ func runTUIActionIntoDashboard(ctx context.Context, r *runner, profilesFile stri return case <-ticker.C: live := dashboard - live.ActivityLines = log.Lines() + live.Activity = log.Snapshot() _ = renderTUIScreenWidth(screen, live, tuiWidth(r)) } } }() if err := runSelectedTUIAction(ctx, r, profilesFile, dashboard, log); err != nil { + log.Fail(err.Error()) log.Printf("Action failed: %v", err) } else { + log.Succeed("completed successfully") log.Printf("Action completed successfully") } close(stop) <-done - dashboard.ActivityLines = mergeTUIActivityLines(dashboard.ActivityLines, log.Lines()) + dashboard.Activity = log.Snapshot() return dashboard } @@ -66,6 +70,7 @@ func runTUICheckIntoDashboard(ctx context.Context, r *runner, profilesFile strin log := newTUIActionState(10) screen := r.out if profile, ok := selectedTUIProfile(dashboard); ok { + log.Start("Run repository check", fmt.Sprintf("profile %s", profile.Name)) log.Printf("Running repository check for profile %s", profile.Name) } @@ -81,33 +86,26 @@ func runTUICheckIntoDashboard(ctx context.Context, r *runner, profilesFile strin return case <-ticker.C: live := dashboard - live.ActivityLines = log.Lines() + live.Activity = log.Snapshot() _ = renderTUIScreenWidth(screen, live, tuiWidth(r)) } } }() if err := runSelectedTUICheck(ctx, r, profilesFile, dashboard, log); err != nil { + log.Fail(err.Error()) log.Printf("Check failed: %v", err) } else { + log.Succeed("completed successfully") log.Printf("Check completed successfully") } close(stop) <-done - dashboard.ActivityLines = mergeTUIActivityLines(dashboard.ActivityLines, log.Lines()) + dashboard.Activity = log.Snapshot() return dashboard } -func mergeTUIActivityLines(existing, recent []string) []string { - merged := append([]string{}, recent...) - merged = append(merged, existing...) - if len(merged) > 10 { - merged = merged[:10] - } - return merged -} - type crlfWriter struct { w io.Writer } @@ -144,6 +142,7 @@ type tuiActionState struct { limit int buf bytes.Buffer phase *tuiPhaseState + panel tui.ActivityPanel } type tuiPhaseState struct { @@ -166,6 +165,35 @@ func (l *tuiActionState) Reporter() cloudstic.Reporter { return tuiReporter{state: l} } +func (l *tuiActionState) Start(action, target string) { + l.mu.Lock() + defer l.mu.Unlock() + l.panel.Status = tui.ActivityStatusRunning + if target != "" { + l.panel.Action = fmt.Sprintf("%s (%s)", action, target) + } else { + l.panel.Action = action + } + l.panel.Summary = "" + l.panel.UpdatedAt = "" +} + +func (l *tuiActionState) Succeed(summary string) { + l.mu.Lock() + defer l.mu.Unlock() + l.panel.Status = tui.ActivityStatusSuccess + l.panel.Summary = summary + l.panel.UpdatedAt = time.Now().Local().Format("2006-01-02 15:04:05") +} + +func (l *tuiActionState) Fail(summary string) { + l.mu.Lock() + defer l.mu.Unlock() + l.panel.Status = tui.ActivityStatusError + l.panel.Summary = summary + l.panel.UpdatedAt = time.Now().Local().Format("2006-01-02 15:04:05") +} + func (l *tuiActionState) Write(p []byte) (int, error) { l.mu.Lock() defer l.mu.Unlock() @@ -194,11 +222,25 @@ func (l *tuiActionState) Lines() []string { l.append(tail) l.buf.Reset() } - lines := append([]string{}, l.lines...) - if summary := l.phaseSummary(); summary != "" { - lines = append([]string{summary}, lines...) + return append([]string{}, l.lines...) +} + +func (l *tuiActionState) Snapshot() tui.ActivityPanel { + l.mu.Lock() + defer l.mu.Unlock() + if tail := strings.TrimSpace(l.buf.String()); tail != "" { + l.append(tail) + l.buf.Reset() + } + panel := l.panel + panel.Lines = append([]string{}, l.lines...) + panel.Phase = l.phaseName() + if l.phase != nil { + panel.Current = l.phase.current + panel.Total = l.phase.total + panel.IsBytes = l.phase.isBytes } - return lines + return panel } func (l *tuiActionState) append(line string) { @@ -212,18 +254,11 @@ func (l *tuiActionState) append(line string) { } } -func (l *tuiActionState) phaseSummary() string { +func (l *tuiActionState) phaseName() string { if l.phase == nil || l.phase.name == "" { return "" } - switch { - case l.phase.total > 0 && l.phase.isBytes: - return fmt.Sprintf("%s %s / %s", l.phase.name, formatBytes(l.phase.current), formatBytes(l.phase.total)) - case l.phase.total > 0: - return fmt.Sprintf("%s %d / %d", l.phase.name, l.phase.current, l.phase.total) - default: - return l.phase.name - } + return l.phase.name } type tuiReporter struct { diff --git a/cmd/cloudstic/cmd_tui_test.go b/cmd/cloudstic/cmd_tui_test.go index 1ca9099..736091c 100644 --- a/cmd/cloudstic/cmd_tui_test.go +++ b/cmd/cloudstic/cmd_tui_test.go @@ -530,11 +530,14 @@ func TestTUISession_HandleActionRunRefreshesDashboard(t *testing.T) { if s.dashboard.SelectedProfile != "docs" { t.Fatalf("selected profile lost after refresh: %+v", s.dashboard) } - if len(s.dashboard.ActivityLines) == 0 { + if len(s.dashboard.Activity.Lines) == 0 { t.Fatalf("expected activity lines after action") } - if !strings.Contains(strings.Join(s.dashboard.ActivityLines, "\n"), "Action completed successfully") { - t.Fatalf("missing completion activity: %+v", s.dashboard.ActivityLines) + if s.dashboard.Activity.Status != tui.ActivityStatusSuccess { + t.Fatalf("unexpected activity status: %+v", s.dashboard.Activity) + } + if !strings.Contains(strings.Join(s.dashboard.Activity.Lines, "\n"), "Action completed successfully") { + t.Fatalf("missing completion activity: %+v", s.dashboard.Activity) } } @@ -551,7 +554,7 @@ func TestTUISession_RefreshPreservesSelectionAndActivity(t *testing.T) { s := newTUISession(&runner{}, "profiles.yaml", tui.Dashboard{ SelectedProfile: "docs", - ActivityLines: []string{"running"}, + Activity: tui.ActivityPanel{Status: tui.ActivityStatusRunning, Lines: []string{"running"}}, Profiles: []tui.ProfileCard{{Name: "docs"}}, }) if err := s.refresh(context.Background()); err != nil { @@ -560,8 +563,8 @@ func TestTUISession_RefreshPreservesSelectionAndActivity(t *testing.T) { if s.dashboard.SelectedProfile != "docs" { t.Fatalf("selection not preserved: %+v", s.dashboard) } - if len(s.dashboard.ActivityLines) != 1 || s.dashboard.ActivityLines[0] != "running" { - t.Fatalf("activity not preserved: %+v", s.dashboard.ActivityLines) + if len(s.dashboard.Activity.Lines) != 1 || s.dashboard.Activity.Lines[0] != "running" { + t.Fatalf("activity not preserved: %+v", s.dashboard.Activity) } } diff --git a/cmd/cloudstic/tui_runtime.go b/cmd/cloudstic/tui_runtime.go index b5846ac..b92f627 100644 --- a/cmd/cloudstic/tui_runtime.go +++ b/cmd/cloudstic/tui_runtime.go @@ -305,13 +305,13 @@ func (s *tuiSession) handleAction(ctx context.Context, action tuiAction) (int, e func (s *tuiSession) refresh(ctx context.Context) error { selected := s.dashboard.SelectedProfile - activity := append([]string{}, s.dashboard.ActivityLines...) + activity := s.dashboard.Activity dashboard, err := tuiBuildDashboard(ctx, s.profilesFile) if err != nil { return err } dashboard.SelectedProfile = selected - dashboard.ActivityLines = activity + dashboard.Activity = activity s.dashboard = ensureSelectedProfile(dashboard) return nil } diff --git a/internal/tui/dashboard.go b/internal/tui/dashboard.go index 13dfddb..5f3c3ee 100644 --- a/internal/tui/dashboard.go +++ b/internal/tui/dashboard.go @@ -16,10 +16,31 @@ type Dashboard struct { StoreCount int AuthCount int SelectedProfile string - ActivityLines []string + Activity ActivityPanel Profiles []ProfileCard } +type ActivityStatus string + +const ( + ActivityStatusIdle ActivityStatus = "" + ActivityStatusRunning ActivityStatus = "running" + ActivityStatusSuccess ActivityStatus = "success" + ActivityStatusError ActivityStatus = "error" +) + +type ActivityPanel struct { + Status ActivityStatus + Action string + Phase string + Current int64 + Total int64 + IsBytes bool + Summary string + UpdatedAt string + Lines []string +} + type ActionKind string const ( diff --git a/internal/tui/shell.go b/internal/tui/shell.go index 2ffb82a..bcaf58f 100644 --- a/internal/tui/shell.go +++ b/internal/tui/shell.go @@ -57,11 +57,7 @@ func RenderDashboardWidth(w io.Writer, d Dashboard, width int) error { return err } - activity := d.ActivityLines - if len(activity) == 0 { - activity = []string{fmt.Sprintf("%sNo recent activity.%s", ui.Dim, ui.Reset)} - } - if err := renderBoxExact(w, "Activity", activity, panelWidth(width)); err != nil { + if err := renderBoxExact(w, "Activity", renderActivityPanel(d.Activity), panelWidth(width)); err != nil { return err } @@ -166,6 +162,36 @@ func renderProfileList(d Dashboard) []string { return lines } +func renderActivityPanel(activity ActivityPanel) []string { + lines := []string{} + if activity.Status != ActivityStatusIdle { + lines = append(lines, profileDetailLine("Status", activityStatusLabel(activity.Status))) + } + if activity.Action != "" { + lines = append(lines, profileDetailLine("Action", activity.Action)) + } + if activity.Phase != "" { + lines = append(lines, profileDetailLine("Phase", activity.Phase)) + } + if bar := progressBarLine(activity, 28); bar != "" { + lines = append(lines, profileDetailLine("Progress", bar)) + } + if activity.Summary != "" { + lines = append(lines, profileDetailLine("Result", activity.Summary)) + } + if activity.UpdatedAt != "" { + lines = append(lines, profileDetailLine("Updated", activity.UpdatedAt)) + } + if len(lines) > 0 && len(activity.Lines) > 0 { + lines = append(lines, "") + } + lines = append(lines, activity.Lines...) + if len(lines) == 0 { + return []string{fmt.Sprintf("%sNo recent activity.%s", ui.Dim, ui.Reset)} + } + return lines +} + func renderSelectedProfile(d Dashboard) []string { profile, ok := selectedProfileCard(d) if !ok { @@ -325,6 +351,63 @@ func backupFreshnessLabel(state BackupFreshness) string { } } +func activityStatusLabel(status ActivityStatus) string { + switch status { + case ActivityStatusRunning: + return "running" + case ActivityStatusSuccess: + return "success" + case ActivityStatusError: + return "failed" + default: + return "" + } +} + +func progressBarLine(activity ActivityPanel, width int) string { + if activity.Total <= 0 || activity.Current < 0 || width <= 0 { + return "" + } + current := activity.Current + if current > activity.Total { + current = activity.Total + } + ratio := float64(current) / float64(activity.Total) + filled := int(ratio * float64(width)) + if filled > width { + filled = width + } + if filled < 0 { + filled = 0 + } + bar := strings.Repeat("=", filled) + strings.Repeat("-", width-filled) + if activity.IsBytes { + return fmt.Sprintf("[%s] %s / %s", bar, formatBytesLabel(current), formatBytesLabel(activity.Total)) + } + return fmt.Sprintf("[%s] %d / %d", bar, current, activity.Total) +} + +func formatBytesLabel(b int64) string { + const ( + kb = 1024 + mb = 1024 * kb + gb = 1024 * mb + tb = 1024 * gb + ) + switch { + case b >= tb: + return fmt.Sprintf("%.1f TiB", float64(b)/float64(tb)) + case b >= gb: + return fmt.Sprintf("%.1f GiB", float64(b)/float64(gb)) + case b >= mb: + return fmt.Sprintf("%.1f MiB", float64(b)/float64(mb)) + case b >= kb: + return fmt.Sprintf("%.1f KiB", float64(b)/float64(kb)) + default: + return fmt.Sprintf("%d B", b) + } +} + func visibleLen(s string) int { n := 0 inEscape := false diff --git a/internal/tui/shell_test.go b/internal/tui/shell_test.go index e563a5b..6b53726 100644 --- a/internal/tui/shell_test.go +++ b/internal/tui/shell_test.go @@ -11,6 +11,17 @@ func TestRenderDashboard(t *testing.T) { StoreCount: 1, AuthCount: 0, SelectedProfile: "documents", + Activity: ActivityPanel{ + Status: ActivityStatusSuccess, + Action: "Run backup (profile documents)", + Phase: "Scanning", + Current: 512, + Total: 1024, + IsBytes: true, + Summary: "completed successfully", + UpdatedAt: "2026-04-03 15:05:00", + Lines: []string{"Snapshot abc123 saved"}, + }, Profiles: []ProfileCard{ { Name: "documents", @@ -56,7 +67,13 @@ func TestRenderDashboard(t *testing.T) { "2026-04-03 11:05 (recent)", "Ref", "abc123", - "No recent activity.", + "success", + "Run backup (profile documents)", + "Scanning", + "[==============--------------] 512 B / 1.0 KiB", + "completed successfully", + "2026-04-03 15:05:00", + "Snapshot abc123 saved", "Press c to run repository check", "Use ↑/↓ to select a profile. Press b to backup/init, c to check, q to quit.", } {