diff --git a/cmd/cloudstic/cmd_tui_input.go b/cmd/cloudstic/cmd_tui_input.go index 199d3f7..d52fe74 100644 --- a/cmd/cloudstic/cmd_tui_input.go +++ b/cmd/cloudstic/cmd_tui_input.go @@ -17,6 +17,8 @@ const ( tuiActionNone tuiActionKind = iota tuiActionUp tuiActionDown + tuiActionSummaryView + tuiActionHistoryView tuiActionRun tuiActionCheck tuiActionCreate @@ -33,6 +35,9 @@ type tuiAction struct { } func ensureSelectedProfile(d tui.Dashboard) tui.Dashboard { + if d.SelectedView == "" { + d.SelectedView = tui.ProfileViewSummary + } if d.SelectedProfile != "" || len(d.Profiles) == 0 { return d } @@ -93,6 +98,10 @@ func readTUIAction(r io.ByteReader, layout tui.DashboardLayout) (tuiAction, erro return tuiAction{Kind: tuiActionRun}, nil case 'c', 'C': return tuiAction{Kind: tuiActionCheck}, nil + case 's', 'S': + return tuiAction{Kind: tuiActionSummaryView}, nil + case 'h', 'H': + return tuiAction{Kind: tuiActionHistoryView}, nil case 'n', 'N': return tuiAction{Kind: tuiActionCreate}, nil case 'e', 'E': diff --git a/cmd/cloudstic/cmd_tui_test.go b/cmd/cloudstic/cmd_tui_test.go index 710a1fd..0e99b39 100644 --- a/cmd/cloudstic/cmd_tui_test.go +++ b/cmd/cloudstic/cmd_tui_test.go @@ -585,6 +585,24 @@ func TestReadTUIAction_ParsesCheckShortcut(t *testing.T) { } } +func TestReadTUIAction_ParsesViewShortcuts(t *testing.T) { + ev, err := readTUIAction(bufio.NewReader(bytes.NewBufferString("s")), tui.DashboardLayout{}) + if err != nil { + t.Fatalf("readTUIAction summary: %v", err) + } + if ev.Kind != tuiActionSummaryView { + t.Fatalf("summary action=%v want %v", ev.Kind, tuiActionSummaryView) + } + + ev, err = readTUIAction(bufio.NewReader(bytes.NewBufferString("h")), tui.DashboardLayout{}) + if err != nil { + t.Fatalf("readTUIAction history: %v", err) + } + if ev.Kind != tuiActionHistoryView { + t.Fatalf("history action=%v want %v", ev.Kind, tuiActionHistoryView) + } +} + func TestReadTUIAction_ParsesManagementShortcuts(t *testing.T) { ev, err := readTUIAction(bufio.NewReader(bytes.NewBufferString("n")), tui.DashboardLayout{}) if err != nil { @@ -711,6 +729,7 @@ func TestTUISession_HandleActionRunRefreshesDashboard(t *testing.T) { ProfileCount: 1, StoreCount: 1, SelectedProfile: "docs", + SelectedView: tui.ProfileViewSummary, Profiles: []tui.ProfileCard{ { Name: "docs", @@ -735,6 +754,7 @@ func TestTUISession_HandleActionRunRefreshesDashboard(t *testing.T) { var out strings.Builder s := newTUISession(&runner{out: &out, stdoutFile: os.Stdout, stdin: os.Stdin}, "profiles.yaml", tui.Dashboard{ SelectedProfile: "docs", + SelectedView: tui.ProfileViewHistory, Profiles: []tui.ProfileCard{ { Name: "docs", @@ -756,6 +776,9 @@ func TestTUISession_HandleActionRunRefreshesDashboard(t *testing.T) { if s.dashboard.SelectedProfile != "docs" { t.Fatalf("selected profile lost after refresh: %+v", s.dashboard) } + if s.dashboard.SelectedView != tui.ProfileViewHistory { + t.Fatalf("selected view lost after refresh: %+v", s.dashboard) + } if len(s.dashboard.Activity.Lines) == 0 { t.Fatalf("expected activity lines after action") } @@ -767,6 +790,32 @@ func TestTUISession_HandleActionRunRefreshesDashboard(t *testing.T) { } } +func TestTUISession_HandleActionSwitchesSelectedView(t *testing.T) { + stubTUITestHooks(t) + + s := newTUISession(&runner{out: io.Discard, stdoutFile: os.Stdout, stdin: os.Stdin}, "profiles.yaml", tui.Dashboard{ + SelectedProfile: "docs", + SelectedView: tui.ProfileViewSummary, + Profiles: []tui.ProfileCard{ + {Name: "docs", Enabled: true, Status: tui.ProfileStatusReady}, + }, + }) + + if _, err := s.handleAction(context.Background(), tuiAction{Kind: tuiActionHistoryView}); err != nil { + t.Fatalf("handleAction(history): %v", err) + } + if s.dashboard.SelectedView != tui.ProfileViewHistory { + t.Fatalf("selected view = %q want history", s.dashboard.SelectedView) + } + + if _, err := s.handleAction(context.Background(), tuiAction{Kind: tuiActionSummaryView}); err != nil { + t.Fatalf("handleAction(summary): %v", err) + } + if s.dashboard.SelectedView != tui.ProfileViewSummary { + t.Fatalf("selected view = %q want summary", s.dashboard.SelectedView) + } +} + func TestTUISession_HandleActionRunRefreshFailureRestoresRawMode(t *testing.T) { readEnd, writeEnd, err := os.Pipe() if err != nil { diff --git a/cmd/cloudstic/tui_runtime.go b/cmd/cloudstic/tui_runtime.go index 9b1502e..b82ee6e 100644 --- a/cmd/cloudstic/tui_runtime.go +++ b/cmd/cloudstic/tui_runtime.go @@ -278,6 +278,10 @@ func (s *tuiSession) handleAction(ctx context.Context, action tuiAction) (int, e s.dashboard = moveTUISelection(s.dashboard, -1) case tuiActionDown: s.dashboard = moveTUISelection(s.dashboard, 1) + case tuiActionSummaryView: + s.dashboard.SelectedView = tui.ProfileViewSummary + case tuiActionHistoryView: + s.dashboard.SelectedView = tui.ProfileViewHistory case tuiActionSelectProfile: if action.Profile != "" { s.dashboard.SelectedProfile = action.Profile @@ -344,12 +348,14 @@ func (s *tuiSession) runSuspended(ctx context.Context, fn func(context.Context) func (s *tuiSession) refresh(ctx context.Context) error { selected := s.dashboard.SelectedProfile + selectedView := s.dashboard.SelectedView activity := s.dashboard.Activity dashboard, err := tuiBuildDashboard(ctx, s.profilesFile) if err != nil { return err } dashboard.SelectedProfile = selected + dashboard.SelectedView = selectedView dashboard.Activity = activity s.dashboard = ensureSelectedProfile(dashboard) return nil diff --git a/internal/tui/dashboard.go b/internal/tui/dashboard.go index 8a3913c..34938c2 100644 --- a/internal/tui/dashboard.go +++ b/internal/tui/dashboard.go @@ -16,11 +16,19 @@ type Dashboard struct { StoreCount int AuthCount int SelectedProfile string + SelectedView ProfileView Activity ActivityPanel Modal *Modal Profiles []ProfileCard } +type ProfileView string + +const ( + ProfileViewSummary ProfileView = "summary" + ProfileViewHistory ProfileView = "history" +) + type ModalKind string const ( @@ -161,9 +169,15 @@ type ProfileCard struct { BackupState BackupFreshness LastBackup string LastRef string + History []ProfileSnapshot Actions []ProfileAction } +type ProfileSnapshot struct { + Created string + Ref string +} + type StoreProbe struct { Status string Error string @@ -216,11 +230,13 @@ func BuildDashboard(cfg *engine.ProfilesConfig, probes map[string]StoreProbe) Da ProfileCount: len(cfg.Profiles), StoreCount: len(cfg.Stores), AuthCount: len(cfg.Auth), + SelectedView: ProfileViewSummary, Profiles: make([]ProfileCard, 0, len(cfg.Profiles)), } for _, name := range names { profile := cfg.Profiles[name] status, note := profileStatus(cfg, profile, probes[profile.Store]) + history := snapshotHistory(profile.Source, probes[profile.Store].Snapshots) lastBackup, lastRef, lastCreated := latestBackup(profile.Source, probes[profile.Store].Snapshots) storeHealth := deriveStoreHealth(cfg, profile, probes[profile.Store]) reachability := deriveStoreReachability(storeHealth) @@ -243,6 +259,7 @@ func BuildDashboard(cfg *engine.ProfilesConfig, probes map[string]StoreProbe) Da BackupState: backupState, LastBackup: lastBackup, LastRef: lastRef, + History: history, Actions: deriveProfileActions(status, storeHealth), }) } @@ -357,20 +374,59 @@ func profileStatus(cfg *engine.ProfilesConfig, p engine.BackupProfile, probe Sto return ProfileStatusReady, "" } -func latestBackup(sourceURI string, entries []engine.SnapshotEntry) (string, string, time.Time) { +func snapshotHistory(sourceURI string, entries []engine.SnapshotEntry) []ProfileSnapshot { want := sourceKeyFromURI(sourceURI) if want.Type == "" { - return "", "", time.Time{} + return nil + } + type item struct { + created time.Time + entry ProfileSnapshot } + items := make([]item, 0, len(entries)) for _, entry := range entries { if snapshotMatchesSource(entry.Snap.Source, want) { + snap := ProfileSnapshot{Ref: entry.Ref} if entry.Created.IsZero() { - return "unknown time", entry.Ref, time.Time{} + snap.Created = "unknown time" + } else { + snap.Created = entry.Created.Local().Format("2006-01-02 15:04") } - return entry.Created.Local().Format("2006-01-02 15:04"), entry.Ref, entry.Created + items = append(items, item{created: entry.Created, entry: snap}) } } - return "", "", time.Time{} + sort.SliceStable(items, func(i, j int) bool { + return items[i].created.After(items[j].created) + }) + history := make([]ProfileSnapshot, 0, len(items)) + for _, item := range items { + history = append(history, item.entry) + } + return history +} + +func latestBackup(sourceURI string, entries []engine.SnapshotEntry) (string, string, time.Time) { + want := sourceKeyFromURI(sourceURI) + if want.Type == "" { + return "", "", time.Time{} + } + var latest *engine.SnapshotEntry + for i := range entries { + entry := entries[i] + if !snapshotMatchesSource(entry.Snap.Source, want) { + continue + } + if latest == nil || entry.Created.After(latest.Created) { + latest = &entry + } + } + if latest == nil { + return "", "", time.Time{} + } + if latest.Created.IsZero() { + return "unknown time", latest.Ref, time.Time{} + } + return latest.Created.Local().Format("2006-01-02 15:04"), latest.Ref, latest.Created } func deriveStoreHealth(cfg *engine.ProfilesConfig, p engine.BackupProfile, probe StoreProbe) StoreHealth { diff --git a/internal/tui/dashboard_test.go b/internal/tui/dashboard_test.go index 46697cd..4f2fb96 100644 --- a/internal/tui/dashboard_test.go +++ b/internal/tui/dashboard_test.go @@ -67,6 +67,9 @@ func TestBuildDashboard_SortsProfilesAndCountsSections(t *testing.T) { if got.Profiles[0].LastRef != "snapshot/abc" { t.Fatalf("last ref = %q want snapshot/abc", got.Profiles[0].LastRef) } + if got.SelectedView != ProfileViewSummary { + t.Fatalf("selected view = %q want summary", got.SelectedView) + } if got.Profiles[0].Status != ProfileStatusReady { t.Fatalf("status = %q want ready", got.Profiles[0].Status) } @@ -82,6 +85,12 @@ func TestBuildDashboard_SortsProfilesAndCountsSections(t *testing.T) { if got.Profiles[0].BackupState != BackupFreshnessRecent { t.Fatalf("backup state = %q want recent", got.Profiles[0].BackupState) } + if len(got.Profiles[0].History) != 1 { + t.Fatalf("history entries = %d want 1", len(got.Profiles[0].History)) + } + if got.Profiles[0].History[0].Ref != "snapshot/abc" { + t.Fatalf("history ref = %q want snapshot/abc", got.Profiles[0].History[0].Ref) + } if len(got.Profiles[0].Actions) != 2 || got.Profiles[0].Actions[0].Kind != ActionKindBackup || !got.Profiles[0].Actions[0].Enabled { t.Fatalf("unexpected actions: %+v", got.Profiles[0].Actions) } @@ -165,6 +174,57 @@ func TestBuildDashboardFromConfig_LoadsStoreSnapshots(t *testing.T) { } } +func TestBuildDashboard_BuildsProfileHistoryNewestFirst(t *testing.T) { + cfg := &engine.ProfilesConfig{ + Stores: map[string]engine.ProfileStore{ + "remote": {URI: "s3:bucket/prod"}, + }, + Profiles: map[string]engine.BackupProfile{ + "docs": {Source: "local:/docs", Store: "remote"}, + }, + } + + got := BuildDashboard(cfg, map[string]StoreProbe{ + "remote": { + Status: "ok", + Snapshots: []engine.SnapshotEntry{ + { + Ref: "snapshot/old", + Created: mustTime(t, "2026-04-02T10:00:00Z"), + Snap: core.Snapshot{ + Source: &core.SourceInfo{Type: "local", Path: "/docs"}, + }, + }, + { + Ref: "snapshot/new", + Created: mustTime(t, "2026-04-03T12:30:00Z"), + Snap: core.Snapshot{ + Source: &core.SourceInfo{Type: "local", Path: "/docs"}, + }, + }, + { + Ref: "snapshot/other", + Created: mustTime(t, "2026-04-04T08:00:00Z"), + Snap: core.Snapshot{ + Source: &core.SourceInfo{Type: "local", Path: "/photos"}, + }, + }, + }, + }, + }) + + if len(got.Profiles) != 1 { + t.Fatalf("profiles=%d want 1", len(got.Profiles)) + } + history := got.Profiles[0].History + if len(history) != 2 { + t.Fatalf("history entries = %d want 2", len(history)) + } + if history[0].Ref != "snapshot/new" || history[1].Ref != "snapshot/old" { + t.Fatalf("unexpected history order: %+v", history) + } +} + func TestBuildDashboardFromConfig_StoreErrorBecomesWarning(t *testing.T) { cfg := &engine.ProfilesConfig{ Stores: map[string]engine.ProfileStore{ diff --git a/internal/tui/shell.go b/internal/tui/shell.go index 6d1aa59..a94b60b 100644 --- a/internal/tui/shell.go +++ b/internal/tui/shell.go @@ -117,7 +117,7 @@ func dashboardLinesWidth(d Dashboard, width int) []string { )...) lines = append(lines, boxLinesExact("Activity", renderActivityPanel(d.Activity), panelWidth(width))...) - footer := fmt.Sprintf("%sUse ↑/↓ to select a profile. Press b to backup/init, c to check, n to create, e to edit, d to delete, q to quit.%s", ui.Dim, ui.Reset) + footer := fmt.Sprintf("%sUse ↑/↓ to select a profile. Press s/h to switch views, b to backup/init, c to check, n to create, e to edit, d to delete, q to quit.%s", ui.Dim, ui.Reset) if width > 0 { footer = truncateVisible(footer, width) } @@ -219,11 +219,19 @@ func renderSelectedProfile(d Dashboard) ([]string, map[int]string) { } lines := []string{ fmt.Sprintf("%s%s%s", ui.Bold, profile.Name, ui.Reset), + renderProfileViewTabs(d.SelectedView), + "", + } + if d.SelectedView == ProfileViewHistory { + lines = append(lines, renderProfileHistory(profile)...) + return appendProfileActionButtons(lines, profile) + } + lines = append(lines, profileDetailLine("State", plainProfileStateLabel(profile)), profileDetailLine("Source", profile.Source), profileDetailLine("Store", profile.StoreRef), profileDetailLine("Health", profileHealthSummary(profile)), - } + ) if profile.AuthRef != "" { lines = append(lines, profileDetailLine("Auth", profile.AuthRef)) } @@ -249,21 +257,7 @@ func renderSelectedProfile(d Dashboard) ([]string, map[int]string) { if profile.StatusNote != "" && noteAddsContext(profile) { lines = append(lines, profileDetailLine("Status", profile.StatusNote)) } - buttons := selectedProfileActionButtons(profile) - actionRows := map[int]string{} - if len(buttons) > 0 { - lines = append(lines, "") - for _, button := range buttons { - if button.Enabled { - actionRows[len(lines)] = button.Key - } - lines = append(lines, renderActionButton(button)) - if !button.Enabled && button.Reason != "" { - lines = append(lines, fmt.Sprintf(" %s%s%s", ui.Dim, button.Reason, ui.Reset)) - } - } - } - return lines, actionRows + return appendProfileActionButtons(lines, profile) } func renderModalOverlay(w io.Writer, modal Modal, screenWidth, screenHeight int) error { @@ -749,6 +743,56 @@ type actionButton struct { Reason string } +func renderProfileViewTabs(selected ProfileView) string { + summary := "[s] Summary" + history := "[h] History" + switch selected { + case ProfileViewSummary: + summary = fmt.Sprintf("%s[s] Summary%s", ui.Cyan, ui.Reset) + case ProfileViewHistory: + history = fmt.Sprintf("%s[h] History%s", ui.Cyan, ui.Reset) + } + return fmt.Sprintf(" %s %s", summary, history) +} + +func renderProfileHistory(profile ProfileCard) []string { + if len(profile.History) == 0 { + return []string{ + fmt.Sprintf("%sNo snapshots found for this profile.%s", ui.Dim, ui.Reset), + } + } + lines := []string{ + fmt.Sprintf("%sRecent snapshots for this profile:%s", ui.Dim, ui.Reset), + } + limit := len(profile.History) + if limit > 8 { + limit = 8 + } + for i := 0; i < limit; i++ { + snapshot := profile.History[i] + lines = append(lines, fmt.Sprintf(" %s %s", snapshot.Created, trimSnapshotRef(snapshot.Ref))) + } + return lines +} + +func appendProfileActionButtons(lines []string, profile ProfileCard) ([]string, map[int]string) { + buttons := selectedProfileActionButtons(profile) + actionRows := map[int]string{} + if len(buttons) > 0 { + lines = append(lines, "") + for _, button := range buttons { + if button.Enabled { + actionRows[len(lines)] = button.Key + } + lines = append(lines, renderActionButton(button)) + if !button.Enabled && button.Reason != "" { + lines = append(lines, fmt.Sprintf(" %s%s%s", ui.Dim, button.Reason, ui.Reset)) + } + } + } + return lines, actionRows +} + func selectedProfileActionButtons(profile ProfileCard) []actionButton { buttons := make([]actionButton, 0, len(profile.Actions)+2) for _, action := range profile.Actions { diff --git a/internal/tui/shell_test.go b/internal/tui/shell_test.go index 71782f4..9ca4b20 100644 --- a/internal/tui/shell_test.go +++ b/internal/tui/shell_test.go @@ -12,6 +12,7 @@ func TestRenderDashboard(t *testing.T) { StoreCount: 1, AuthCount: 0, SelectedProfile: "documents", + SelectedView: ProfileViewSummary, Activity: ActivityPanel{ Status: ActivityStatusSuccess, ActionKind: ActionKindCheck, @@ -38,6 +39,9 @@ func TestRenderDashboard(t *testing.T) { BackupState: BackupFreshnessRecent, LastBackup: "2026-04-03 11:05", LastRef: "snapshot/abc123", + History: []ProfileSnapshot{ + {Created: "2026-04-03 11:05", Ref: "snapshot/abc123"}, + }, Actions: []ProfileAction{ {Kind: ActionKindBackup, Key: "b", Label: "Press b to run backup", Enabled: true}, {Kind: ActionKindCheck, Key: "c", Label: "Press c to run repository check", Enabled: true}, @@ -61,6 +65,8 @@ func TestRenderDashboard(t *testing.T) { "Auth", "documents", "›", + "[s] Summary", + "[h] History", "enabled", "Source", "local:/Users/test/Documents", @@ -85,7 +91,50 @@ func TestRenderDashboard(t *testing.T) { "[c] Run check", "[e] Edit profile", "[d] Delete profile", - "Use ↑/↓ to select a profile. Press b to backup/init, c to check, n to create, e to edit, d to delete, q to quit.", + "Use ↑/↓ to select a profile. Press s/h to switch views, b to backup/init, c to check, n to create, e to edit, d to delete, q to quit.", + } { + if !strings.Contains(got, want) { + t.Fatalf("missing %q in output:\n%s", want, got) + } + } +} + +func TestRenderDashboard_HistoryView(t *testing.T) { + d := Dashboard{ + ProfileCount: 1, + StoreCount: 1, + SelectedProfile: "documents", + SelectedView: ProfileViewHistory, + Profiles: []ProfileCard{ + { + Name: "documents", + Source: "local:/Users/test/Documents", + StoreRef: "remote", + Enabled: true, + Status: ProfileStatusReady, + StoreHealth: StoreHealthReady, + History: []ProfileSnapshot{ + {Created: "2026-04-03 11:05", Ref: "snapshot/abc123"}, + {Created: "2026-04-02 09:00", Ref: "snapshot/def456"}, + }, + Actions: []ProfileAction{ + {Kind: ActionKindBackup, Key: "b", Label: "Press b to run backup", Enabled: true}, + }, + }, + }, + } + + var out strings.Builder + if err := RenderDashboard(&out, d); err != nil { + t.Fatalf("RenderDashboard: %v", err) + } + got := stripANSI(out.String()) + for _, want := range []string{ + "[s] Summary", + "[h] History", + "Recent snapshots for this profile:", + "2026-04-03 11:05 abc123", + "2026-04-02 09:00 def456", } { if !strings.Contains(got, want) { t.Fatalf("missing %q in output:\n%s", want, got)